diff --git a/src/Pam.Domain/Entities/AccessDecision.cs b/src/Pam.Domain/Entities/AccessDecision.cs
new file mode 100644
index 000000000000..23c7de4882ad
--- /dev/null
+++ b/src/Pam.Domain/Entities/AccessDecision.cs
@@ -0,0 +1,61 @@
+using Bit.Core.Entities;
+using Bit.Core.Utilities;
+using Bit.Pam.Enums;
+
+namespace Bit.Pam.Entities;
+
+///
+/// A single decision on a . In v0 there is exactly one decision per request: an automated
+/// verdict for auto-approval, or a
+/// verdict once approver endpoints land.
+///
+public class AccessDecision : ITableObject
+{
+ public Guid Id { get; set; }
+
+ ///
+ /// The request this decision was made on.
+ ///
+ public Guid AccessRequestId { get; set; }
+
+ ///
+ /// Discriminates the decision: determines whether or is populated.
+ ///
+ public AccessDeciderKind DeciderKind { get; set; }
+
+ ///
+ /// The human approver. NULL when is .
+ ///
+ public Guid? ApproverId { get; set; }
+
+ ///
+ /// The condition kind that decided (e.g. ). NULL when
+ /// is .
+ ///
+ public AccessConditionKind? ConditionKind { get; set; }
+
+ ///
+ /// The approve-or-deny outcome recorded by this decision.
+ ///
+ public AccessDecisionVerdict Verdict { get; set; }
+
+ ///
+ /// Human comment, or a future automatic-evaluation reason string.
+ ///
+ public string? Comment { get; set; }
+
+ ///
+ /// Forward-compatible snapshot of the inputs the evaluation saw. Null in this slice (no signals are evaluated).
+ ///
+ public string? EvaluationContext { get; set; }
+
+ ///
+ /// When the decision was recorded, stamped in UTC at construction.
+ ///
+ public DateTime CreationDate { get; set; } = DateTime.UtcNow;
+
+ public void SetNewId()
+ {
+ Id = CombGuid.Generate();
+ }
+}
diff --git a/src/Pam.Domain/Entities/AccessLease.cs b/src/Pam.Domain/Entities/AccessLease.cs
new file mode 100644
index 000000000000..6316abcff682
--- /dev/null
+++ b/src/Pam.Domain/Entities/AccessLease.cs
@@ -0,0 +1,61 @@
+using Bit.Core.Entities;
+using Bit.Core.Utilities;
+using Bit.Pam.Enums;
+
+namespace Bit.Pam.Entities;
+
+///
+/// An active grant of access to a cipher, born from an approved . Only
+/// leases inside their / window
+/// authorize access.
+///
+public class AccessLease : ITableObject
+{
+ public Guid Id { get; set; }
+
+ ///
+ /// The request that birthed this lease.
+ ///
+ public Guid AccessRequestId { get; set; }
+
+ public Guid OrganizationId { get; set; }
+ public Guid CollectionId { get; set; }
+ public Guid CipherId { get; set; }
+ public Guid RequesterId { get; set; }
+
+ ///
+ /// The lease's position in its lifecycle. Only an lease within its window
+ /// authorizes access.
+ ///
+ public AccessLeaseStatus Status { get; set; }
+
+ ///
+ /// The start of the granted access window, carried over from the approved .
+ ///
+ public DateTime NotBefore { get; set; }
+
+ ///
+ /// The end of the granted access window.
+ ///
+ public DateTime NotAfter { get; set; }
+
+ ///
+ /// Set when an operator revokes the lease (). NULL otherwise.
+ ///
+ public DateTime? RevokedDate { get; set; }
+
+ ///
+ /// The operator who revoked the lease. NULL unless is .
+ ///
+ public Guid? RevokedBy { get; set; }
+
+ ///
+ /// When the lease was minted, stamped in UTC at construction.
+ ///
+ public DateTime CreationDate { get; set; } = DateTime.UtcNow;
+
+ public void SetNewId()
+ {
+ Id = CombGuid.Generate();
+ }
+}
diff --git a/src/Pam.Domain/Entities/AccessRequest.cs b/src/Pam.Domain/Entities/AccessRequest.cs
new file mode 100644
index 000000000000..aea633e8ab18
--- /dev/null
+++ b/src/Pam.Domain/Entities/AccessRequest.cs
@@ -0,0 +1,72 @@
+using Bit.Core.Entities;
+using Bit.Core.Utilities;
+using Bit.Pam.Enums;
+
+namespace Bit.Pam.Entities;
+
+///
+/// A request to lease access to a cipher in a leasing-governed collection. Auto-approved requests are created
+/// already ; requests that require human approval are created
+/// and resolved later by an approver. Neither approval mints the lease —
+/// the requester activates the approved request within its window, and that activation produces the
+/// .
+///
+public class AccessRequest : ITableObject
+{
+ public Guid Id { get; set; }
+
+ ///
+ /// NULL for original requests. Set only for extension requests, which point at the lease being extended.
+ ///
+ public Guid? ExtensionOfLeaseId { get; set; }
+
+ public Guid OrganizationId { get; set; }
+ public Guid CollectionId { get; set; }
+ public Guid CipherId { get; set; }
+ public Guid RequesterId { get; set; }
+
+ ///
+ /// The access rule that governed this request, resolved once at submit (oldest wins) and pinned here so every
+ /// downstream operation reads the same rule rather than re-resolving. Null for requests created before pinning
+ /// existed, or when the cipher was not leasing-gated through a stored rule.
+ ///
+ public Guid? RuleId { get; set; }
+
+ ///
+ /// The requested access window. For automatic approval this is now; for human approval it is the
+ /// requester-supplied start.
+ ///
+ public DateTime NotBefore { get; set; }
+
+ ///
+ /// The end of the requested access window. For automatic approval this is now + duration; for human
+ /// approval it is the requester-supplied end.
+ ///
+ public DateTime NotAfter { get; set; }
+
+ ///
+ /// Optional for automatic approval, required for human approval (enforced in the command).
+ ///
+ public string? Reason { get; set; }
+
+ ///
+ /// The request's position in its lifecycle. Created for human approval or
+ /// already for automatic approval, then settling in one terminal state.
+ ///
+ public AccessRequestStatus Status { get; set; }
+
+ ///
+ /// When the request was submitted, stamped in UTC at construction.
+ ///
+ public DateTime CreationDate { get; set; } = DateTime.UtcNow;
+
+ ///
+ /// Set when the request leaves .
+ ///
+ public DateTime? ResolvedDate { get; set; }
+
+ public void SetNewId()
+ {
+ Id = CombGuid.Generate();
+ }
+}
diff --git a/src/Pam.Domain/Enums/AccessConditionKind.cs b/src/Pam.Domain/Enums/AccessConditionKind.cs
new file mode 100644
index 000000000000..2f6b08012d90
--- /dev/null
+++ b/src/Pam.Domain/Enums/AccessConditionKind.cs
@@ -0,0 +1,18 @@
+namespace Bit.Pam.Enums;
+
+///
+/// The kind of access condition that produced an automatic . Mirrors the
+/// kind discriminator on , which remains the JSON wire format
+/// for a rule's conditions; this enum is the persisted, type-safe form recorded against the decision.
+///
+public enum AccessConditionKind : byte
+{
+ /// Requires a human approver; matching requests are routed for manual approval rather than auto-decided.
+ HumanApproval = 0,
+
+ /// Matches when the requester's IP is on a configured allowlist.
+ IpAllowlist = 1,
+
+ /// Matches when the request falls within a configured time-of-day window.
+ TimeOfDay = 2,
+}
diff --git a/src/Pam.Domain/Enums/AccessDeciderKind.cs b/src/Pam.Domain/Enums/AccessDeciderKind.cs
new file mode 100644
index 000000000000..969a5dff6b86
--- /dev/null
+++ b/src/Pam.Domain/Enums/AccessDeciderKind.cs
@@ -0,0 +1,25 @@
+namespace Bit.Pam.Enums;
+
+///
+/// Who made a : an automatic condition evaluation or a human approver.
+///
+public enum AccessDeciderKind : byte
+{
+ /// A condition on the governing access rule decided, with no human involved.
+ Automatic = 0,
+
+ /// A human approver decided.
+ Human = 1,
+}
+
+///
+/// The verdict recorded on a .
+///
+public enum AccessDecisionVerdict : byte
+{
+ /// Access was refused.
+ Deny = 0,
+
+ /// Access was granted.
+ Approve = 1,
+}
diff --git a/src/Pam.Domain/Enums/AccessLeaseExtendOutcome.cs b/src/Pam.Domain/Enums/AccessLeaseExtendOutcome.cs
new file mode 100644
index 000000000000..a1baadaeb510
--- /dev/null
+++ b/src/Pam.Domain/Enums/AccessLeaseExtendOutcome.cs
@@ -0,0 +1,23 @@
+namespace Bit.Pam.Enums;
+
+///
+/// The result of a race-safe lease extension. The extension stored procedure returns a distinct integer code so the
+/// caller can tell a lease that is no longer extendable apart from one that has already been extended.
+///
+public enum AccessLeaseExtendOutcome
+{
+ /// The lease's window was extended (stored proc returned 1).
+ Extended = 1,
+
+ ///
+ /// The lease was no longer active, or its window had already ended, when the guarded update ran (stored proc
+ /// returned 0). A concurrent revoke or expiry likely won.
+ ///
+ LeaseNotActive = 0,
+
+ ///
+ /// The lease has already been extended (a lease may be extended once; stored proc returned -1). Nothing was
+ /// persisted.
+ ///
+ AlreadyExtended = -1,
+}
diff --git a/src/Pam.Domain/Enums/AccessLeaseMintOutcome.cs b/src/Pam.Domain/Enums/AccessLeaseMintOutcome.cs
new file mode 100644
index 000000000000..822b8f14baf7
--- /dev/null
+++ b/src/Pam.Domain/Enums/AccessLeaseMintOutcome.cs
@@ -0,0 +1,23 @@
+namespace Bit.Pam.Enums;
+
+///
+/// The result of a race-safe lease mint. The mint stored procedures return a distinct integer code so the caller can
+/// tell a lost-the-race precondition failure apart from a per-cipher single-active-lease conflict.
+///
+public enum AccessLeaseMintOutcome
+{
+ /// The active lease was minted (stored proc returned 1).
+ Minted = 1,
+
+ ///
+ /// A precondition no longer held when the guarded insert ran (stored proc returned 0, or the unique-index
+ /// backstop fired). A concurrent activation likely won; the caller re-reads the winner.
+ ///
+ PreconditionFailed = 0,
+
+ ///
+ /// Another active in-window lease already exists for this cipher and the governing rule enforces a per-cipher
+ /// singleton (stored proc returned -1). Nothing was persisted.
+ ///
+ SingleActiveLeaseConflict = -1,
+}
diff --git a/src/Pam.Domain/Enums/AccessLeaseStatus.cs b/src/Pam.Domain/Enums/AccessLeaseStatus.cs
new file mode 100644
index 000000000000..e8188c28ff48
--- /dev/null
+++ b/src/Pam.Domain/Enums/AccessLeaseStatus.cs
@@ -0,0 +1,19 @@
+namespace Bit.Pam.Enums;
+
+///
+/// Lifecycle of a . Only leases authorize access.
+///
+public enum AccessLeaseStatus : byte
+{
+ /// Live; within its window it authorizes access.
+ Active = 0,
+
+ /// The lease's window ended on its own.
+ Expired = 1,
+
+ /// An operator ended the lease early.
+ Revoked = 2,
+
+ /// The holder ended their own lease early, as opposed to (an operator ended it).
+ Cancelled = 3,
+}
diff --git a/src/Pam.Domain/Enums/AccessRequestStatus.cs b/src/Pam.Domain/Enums/AccessRequestStatus.cs
new file mode 100644
index 000000000000..14d8ec20368e
--- /dev/null
+++ b/src/Pam.Domain/Enums/AccessRequestStatus.cs
@@ -0,0 +1,23 @@
+namespace Bit.Pam.Enums;
+
+///
+/// Lifecycle of a . A request starts and moves to exactly
+/// one terminal state. Auto-approved requests are created already .
+///
+public enum AccessRequestStatus : byte
+{
+ /// Awaiting a human approver's decision.
+ Pending = 0,
+
+ /// Approved automatically or by an approver; the requester can activate it into a lease within its window.
+ Approved = 1,
+
+ /// An approver refused the request.
+ Denied = 2,
+
+ /// Withdrawn by the requester before it was decided.
+ Cancelled = 3,
+
+ /// The approval window lapsed with no decision recorded.
+ ExpiredUnanswered = 4,
+}
diff --git a/src/Pam.Domain/Models/AccessRequestDecision.cs b/src/Pam.Domain/Models/AccessRequestDecision.cs
new file mode 100644
index 000000000000..088699d3b834
--- /dev/null
+++ b/src/Pam.Domain/Models/AccessRequestDecision.cs
@@ -0,0 +1,33 @@
+using Bit.Pam.Enums;
+
+namespace Bit.Pam.Models;
+
+///
+/// One decision on an , projected from an
+/// row. The element of — there is one per recorded decision, human or
+/// automatic. A human decision carries the approver's identity ( plus the denormalized name/email); an
+/// automatic decision has none ( null — it was decided by an access-rule condition).
+///
+public class AccessRequestDecision
+{
+ /// Who decided: a human approver or an automatic condition evaluation.
+ public AccessDeciderKind DeciderKind { get; set; }
+
+ /// The human approver, or null for an automatic decision.
+ public Guid? Id { get; set; }
+
+ /// The human approver's display name, or null (automatic, or the server could not resolve the user).
+ public string? Name { get; set; }
+
+ /// The human approver's email, the fallback display when is unset.
+ public string? Email { get; set; }
+
+ /// The decision's comment (a human approver's note, or a future automatic-evaluation reason), if any.
+ public string? Comment { get; set; }
+
+ /// The verdict reached.
+ public AccessDecisionVerdict Verdict { get; set; }
+
+ /// When the decision was made (the decision's CreationDate).
+ public DateTime DecidedAt { get; set; }
+}
diff --git a/src/Pam.Domain/Models/AccessRequestDetails.cs b/src/Pam.Domain/Models/AccessRequestDetails.cs
new file mode 100644
index 000000000000..98fb1bb5bb92
--- /dev/null
+++ b/src/Pam.Domain/Models/AccessRequestDetails.cs
@@ -0,0 +1,67 @@
+using Bit.Pam.Enums;
+
+namespace Bit.Pam.Models;
+
+///
+/// A lease request projected for the approver inbox: every field plus the
+/// denormalized requester identity the client needs, the lease the request produced (if any), and the human resolver's
+/// identity/comment. Populated by a single join in the read procedures so the client avoids an N+1.
+///
+public class AccessRequestDetails
+{
+ public Guid Id { get; set; }
+
+ /// The parent lease for an extension request; null for original requests.
+ public Guid? ExtensionOfLeaseId { get; set; }
+
+ public Guid OrganizationId { get; set; }
+ public Guid CollectionId { get; set; }
+ public Guid CipherId { get; set; }
+ public Guid RequesterId { get; set; }
+
+ /// The access rule pinned on the request at submit, or null for requests created before pinning.
+ public Guid? RuleId { get; set; }
+
+ ///
+ public DateTime NotBefore { get; set; }
+
+ ///
+ public DateTime NotAfter { get; set; }
+
+ ///
+ public string? Reason { get; set; }
+
+ ///
+ public AccessRequestStatus Status { get; set; }
+
+ ///
+ public DateTime CreationDate { get; set; }
+
+ ///
+ public DateTime? ResolvedDate { get; set; }
+
+ /// The lease this request produced once activated, or null if it has not produced a lease.
+ public Guid? ProducedLeaseId { get; set; }
+
+ ///
+ /// The produced lease's current status (Active/Expired/Revoked), or null when the request has not produced a
+ /// lease. Lets the inbox distinguish a still-live lease from one that has ended, so an ended lease is not offered
+ /// for revocation.
+ ///
+ public AccessLeaseStatus? ProducedLeaseStatus { get; set; }
+
+ ///
+ /// Every decision recorded against this request, oldest first — one element per
+ /// row (human or automatic; identity denormalized from the User join for
+ /// human decisions). Empty only while pending (no decision recorded yet). The resolved reads return the decisions
+ /// as a second result set that the repository groups onto this list; the constructed reads (decision result,
+ /// cipher access-state snapshot) set it directly.
+ ///
+ public List Decisions { get; set; } = new();
+
+ /// The requester's display name, denormalized from the User join; null when unset or the user could not be resolved.
+ public string? RequesterName { get; set; }
+
+ /// The requester's email, the fallback display when is unset.
+ public string? RequesterEmail { get; set; }
+}
diff --git a/src/Pam.Domain/Repositories/IAccessLeaseRepository.cs b/src/Pam.Domain/Repositories/IAccessLeaseRepository.cs
new file mode 100644
index 000000000000..91659843b77b
--- /dev/null
+++ b/src/Pam.Domain/Repositories/IAccessLeaseRepository.cs
@@ -0,0 +1,59 @@
+using Bit.Pam.Entities;
+using Bit.Pam.Enums;
+
+namespace Bit.Pam.Repositories;
+
+public interface IAccessLeaseRepository
+{
+ Task GetByIdAsync(Guid id);
+
+ ///
+ /// Returns the lease the request produced (whatever its status), or null if the request has not been activated.
+ ///
+ Task GetByAccessRequestIdAsync(Guid accessRequestId);
+
+ ///
+ /// Returns the caller's active lease for the cipher whose window contains , or null.
+ ///
+ Task GetActiveByRequesterIdCipherIdAsync(Guid requesterId, Guid cipherId, DateTime now);
+
+ ///
+ /// Returns the caller's currently-active leases (status Active, window containing , not
+ /// revoked) across every organization they belong to. Returns an empty collection when none are active.
+ ///
+ Task> GetManyActiveByRequesterIdAsync(Guid requesterId, DateTime now);
+
+ ///
+ /// Returns every currently-active lease (status Active, window containing ) on the given
+ /// collections, across all members — the governance view over a set of caller-manageable collections. Returns an
+ /// empty collection when none are active.
+ ///
+ Task> GetManyActiveByCollectionIdsAsync(IEnumerable collectionIds, DateTime now);
+
+ ///
+ /// Returns the ended leases (status Expired, Revoked, or Cancelled) on the given collections that ended on or after
+ /// — the governance history view over a set of caller-manageable collections. A
+ /// revoked/cancelled lease's end is its revoked date; an expired lease's end is its not-after. Returns an empty
+ /// collection when none qualify.
+ ///
+ Task> GetManyEndedByCollectionIdsAsync(IEnumerable collectionIds, DateTime since);
+
+ ///
+ /// Race-safely mints the active lease for an approved request, copying the request's window. The insert
+ /// re-checks ownership, Approved status, an open window, and that the request has not already produced a lease;
+ /// returns when any precondition no longer holds (e.g. a
+ /// concurrent activation won). When is true and another active
+ /// in-window lease already exists for the cipher, returns
+ /// without minting. The lease must already have its id assigned.
+ ///
+ Task CreateFromApprovedRequestAsync(AccessLease lease, DateTime now,
+ bool enforceSingleActiveLease);
+
+ ///
+ /// Atomically ends an active lease — setting its status to (Revoked when an operator
+ /// ended it, Cancelled when the holder ended their own) along with its revoked date and revoker — and records the
+ /// reason as a human against the lease's originating request. The decision must
+ /// already have its id assigned.
+ ///
+ Task RevokeAsync(AccessLease lease, AccessLeaseStatus endStatus, AccessDecision auditDecision, DateTime now);
+}
diff --git a/src/Pam.Domain/Repositories/IAccessRequestRepository.cs b/src/Pam.Domain/Repositories/IAccessRequestRepository.cs
new file mode 100644
index 000000000000..04e9343c621f
--- /dev/null
+++ b/src/Pam.Domain/Repositories/IAccessRequestRepository.cs
@@ -0,0 +1,105 @@
+using Bit.Pam.Entities;
+using Bit.Pam.Enums;
+using Bit.Pam.Models;
+
+namespace Bit.Pam.Repositories;
+
+public interface IAccessRequestRepository
+{
+ Task CreateAsync(AccessRequest request);
+
+ ///
+ /// Atomically creates an auto-approved (status ,
+ /// resolved now) and its automatic in a single transaction. No lease is minted: the
+ /// requester activates the approved request later via ,
+ /// just like the human path after approval. Both supplied entities must already have their ids assigned.
+ ///
+ Task CreateAutoApprovedAsync(AccessRequest request, AccessDecision decision);
+
+ Task GetByIdAsync(Guid id);
+
+ ///
+ /// Returns a single request's full projection (denormalized display fields,
+ /// produced lease, and the complete decision list) for the dedicated request page, or null if no request has the
+ /// id. Unlike this populates the display-name fields. Authorization (the caller is the
+ /// requester or can manage the request's collection) is enforced by the calling query, not this read.
+ ///
+ Task GetDetailsByIdAsync(Guid id);
+
+ ///
+ /// Returns the caller's pending (unresolved) lease request for the cipher, or null if there is none.
+ ///
+ Task GetActivePendingByRequesterIdCipherIdAsync(Guid requesterId, Guid cipherId);
+
+ ///
+ /// Returns the caller's approved-but-not-yet-activated request for the cipher whose window has not lapsed
+ /// (NotAfter after ), or null. Future windows are included so the client can show the
+ /// upcoming window; a request that has produced a lease is activated, not approved, and is excluded.
+ ///
+ Task GetActiveApprovedByRequesterIdCipherIdAsync(Guid requesterId, Guid cipherId, DateTime now);
+
+ ///
+ /// Returns the caller's own lease requests across every organization they belong to, regardless of status, most
+ /// recent first and capped server-side. Display-name fields are not populated for this caller-scoped surface.
+ ///
+ Task> GetManyByRequesterIdAsync(Guid requesterId);
+
+ ///
+ /// Returns the pending approver-inbox rows for the given collections, joined with their denormalized display
+ /// fields. An empty yields an empty result.
+ ///
+ Task> GetManyInboxPendingByCollectionIdsAsync(IEnumerable collectionIds);
+
+ ///
+ /// Returns the resolved approver-inbox rows (anything no longer pending) created on or after
+ /// for the given collections. An empty yields an empty
+ /// result.
+ ///
+ Task> GetManyInboxHistoryByCollectionIdsAsync(IEnumerable collectionIds, DateTime since);
+
+ ///
+ /// Atomically transitions a pending request to (setting its resolved date) and records
+ /// the approver's human . No lease is created here: the requester activates an
+ /// approved request later via . Both supplied
+ /// entities must already have their ids assigned.
+ ///
+ Task ResolveWithDecisionAsync(AccessRequest request, AccessDecision decision, AccessRequestStatus status, DateTime now);
+
+ ///
+ /// Withdraws a not-yet-activated request on the requester's behalf: transitions it to
+ /// (from or an
+ /// request the requester has not activated) and stamps
+ /// as its resolved date. No is written — a cancellation is the
+ /// requester acting on their own request, not an approver verdict. The write is guarded so a request that has
+ /// already left the cancellable set or produced a lease is left untouched (race-safe / idempotent).
+ ///
+ Task CancelAsync(Guid id, DateTime now);
+
+ ///
+ /// Retracts a not-yet-activated request on a managing approver's behalf: transitions it to
+ /// (from or an unactivated
+ /// request), stamps the resolved date, and records the approver's human
+ /// Deny so the audit trail names them. The write is guarded so a request that has
+ /// already left the cancellable set or produced a lease is left untouched (race-safe); the decision is recorded
+ /// only when the transition happens. Both supplied entities must already have their ids assigned.
+ ///
+ Task CancelWithDecisionAsync(AccessRequest request, AccessDecision decision, DateTime now);
+
+ ///
+ /// Returns the number of extension requests recorded against the lease (a lease may be extended once, so this is
+ /// 0 or 1). Used to gate whether another extension is allowed.
+ ///
+ Task CountExtensionsByLeaseIdAsync(Guid leaseId);
+
+ ///
+ /// Atomically records an auto-approved extension request (with its automatic decision) and pushes the parent
+ /// lease's end out to the request's NotAfter, all under a per-lease lock. Returns
+ /// when the lease is no longer active or its window has
+ /// ended, or when the lease has already been extended (a
+ /// lease may be extended once); otherwise . Both supplied entities
+ /// must already have their ids assigned, and the request's ExtensionOfLeaseId identifies the lease being
+ /// extended.
+ ///
+ Task CreateApprovedExtensionAsync(AccessRequest request, AccessDecision decision,
+ DateTime now);
+}