From 3484327a77a07c2c80cfb65db259b1117f9893ba Mon Sep 17 00:00:00 2001 From: Hinton Date: Thu, 16 Jul 2026 16:17:52 +0200 Subject: [PATCH 1/3] Add the AccessRequest, AccessDecision, and AccessLease domain Extract the pure domain for the PAM access-request/lease flow into Pam.Domain, alongside the existing AccessRule domain: the AccessRequest and AccessDecision and AccessLease entities, their status/kind/verdict/outcome enums, the request projection models (AccessRequestDetails, AccessRequestDecision), and the IAccessRequestRepository and IAccessLeaseRepository contracts. Requests and leases ship as one feature because their persistence is coupled (AccessRequest.ExtensionOfLeaseId FKs AccessLease; the request reads project lease status). No audit surface is included (deferred, as on the rule slice). --- src/Pam.Domain/Entities/AccessDecision.cs | 49 ++++++++ src/Pam.Domain/Entities/AccessLease.cs | 39 +++++++ src/Pam.Domain/Entities/AccessRequest.cs | 64 +++++++++++ src/Pam.Domain/Enums/AccessConditionKind.cs | 13 +++ src/Pam.Domain/Enums/AccessDeciderKind.cs | 19 ++++ .../Enums/AccessLeaseExtendOutcome.cs | 23 ++++ .../Enums/AccessLeaseMintOutcome.cs | 23 ++++ src/Pam.Domain/Enums/AccessLeaseStatus.cs | 14 +++ src/Pam.Domain/Enums/AccessRequestStatus.cs | 14 +++ .../Models/AccessRequestDecision.cs | 33 ++++++ src/Pam.Domain/Models/AccessRequestDetails.cs | 52 +++++++++ .../Repositories/IAccessLeaseRepository.cs | 59 ++++++++++ .../Repositories/IAccessRequestRepository.cs | 105 ++++++++++++++++++ 13 files changed, 507 insertions(+) create mode 100644 src/Pam.Domain/Entities/AccessDecision.cs create mode 100644 src/Pam.Domain/Entities/AccessLease.cs create mode 100644 src/Pam.Domain/Entities/AccessRequest.cs create mode 100644 src/Pam.Domain/Enums/AccessConditionKind.cs create mode 100644 src/Pam.Domain/Enums/AccessDeciderKind.cs create mode 100644 src/Pam.Domain/Enums/AccessLeaseExtendOutcome.cs create mode 100644 src/Pam.Domain/Enums/AccessLeaseMintOutcome.cs create mode 100644 src/Pam.Domain/Enums/AccessLeaseStatus.cs create mode 100644 src/Pam.Domain/Enums/AccessRequestStatus.cs create mode 100644 src/Pam.Domain/Models/AccessRequestDecision.cs create mode 100644 src/Pam.Domain/Models/AccessRequestDetails.cs create mode 100644 src/Pam.Domain/Repositories/IAccessLeaseRepository.cs create mode 100644 src/Pam.Domain/Repositories/IAccessRequestRepository.cs diff --git a/src/Pam.Domain/Entities/AccessDecision.cs b/src/Pam.Domain/Entities/AccessDecision.cs new file mode 100644 index 000000000000..a46b45e5954b --- /dev/null +++ b/src/Pam.Domain/Entities/AccessDecision.cs @@ -0,0 +1,49 @@ +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; } + + public Guid AccessRequestId { get; set; } + + 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; } + + 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; } + + 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..9901bcc3ff7f --- /dev/null +++ b/src/Pam.Domain/Entities/AccessLease.cs @@ -0,0 +1,39 @@ +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; } + + public AccessLeaseStatus Status { get; set; } + public DateTime NotBefore { get; set; } + public DateTime NotAfter { get; set; } + + public DateTime? RevokedDate { get; set; } + public Guid? RevokedBy { get; set; } + + 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..dd0454ebf344 --- /dev/null +++ b/src/Pam.Domain/Entities/AccessRequest.cs @@ -0,0 +1,64 @@ +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; } + + public AccessRequestStatus Status { get; set; } + 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..5546e62b3f89 --- /dev/null +++ b/src/Pam.Domain/Enums/AccessConditionKind.cs @@ -0,0 +1,13 @@ +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 +{ + HumanApproval = 0, + IpAllowlist = 1, + TimeOfDay = 2, +} diff --git a/src/Pam.Domain/Enums/AccessDeciderKind.cs b/src/Pam.Domain/Enums/AccessDeciderKind.cs new file mode 100644 index 000000000000..5aa0f2bbc2e0 --- /dev/null +++ b/src/Pam.Domain/Enums/AccessDeciderKind.cs @@ -0,0 +1,19 @@ +namespace Bit.Pam.Enums; + +/// +/// Who made a : an automatic condition evaluation or a human approver. +/// +public enum AccessDeciderKind : byte +{ + Automatic = 0, + Human = 1, +} + +/// +/// The verdict recorded on a . +/// +public enum AccessDecisionVerdict : byte +{ + Deny = 0, + 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..53b874818383 --- /dev/null +++ b/src/Pam.Domain/Enums/AccessLeaseStatus.cs @@ -0,0 +1,14 @@ +namespace Bit.Pam.Enums; + +/// +/// Lifecycle of a . Only leases authorize access. +/// +public enum AccessLeaseStatus : byte +{ + Active = 0, + Expired = 1, + 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..d6dcd6ad36b1 --- /dev/null +++ b/src/Pam.Domain/Enums/AccessRequestStatus.cs @@ -0,0 +1,14 @@ +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 +{ + Pending = 0, + Approved = 1, + Denied = 2, + Cancelled = 3, + 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..01bfc3dcd489 --- /dev/null +++ b/src/Pam.Domain/Models/AccessRequestDetails.cs @@ -0,0 +1,52 @@ +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(); + + public string? RequesterName { get; set; } + 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); +} From 6bc444a2224e136164f8a629c962eb4f033060d0 Mon Sep 17 00:00:00 2001 From: Hinton Date: Fri, 17 Jul 2026 12:58:30 +0200 Subject: [PATCH 2/3] Document PAM domain entities, enums, and read-model fields Add XML doc comments to the AccessRequest/AccessLease/AccessDecision entities, their status/verdict/kind enum members, and the denormalized identity fields on the AccessRequestDetails read model. Documents the non-obvious semantics (nullability, lifecycle, granted vs requested window) while leaving plain ids and straight projections bare. --- src/Pam.Domain/Entities/AccessDecision.cs | 12 ++++++++++ src/Pam.Domain/Entities/AccessLease.cs | 22 +++++++++++++++++++ src/Pam.Domain/Entities/AccessRequest.cs | 8 +++++++ src/Pam.Domain/Enums/AccessConditionKind.cs | 5 +++++ src/Pam.Domain/Enums/AccessDeciderKind.cs | 6 +++++ src/Pam.Domain/Enums/AccessLeaseStatus.cs | 5 +++++ src/Pam.Domain/Enums/AccessRequestStatus.cs | 9 ++++++++ src/Pam.Domain/Models/AccessRequestDetails.cs | 3 +++ 8 files changed, 70 insertions(+) diff --git a/src/Pam.Domain/Entities/AccessDecision.cs b/src/Pam.Domain/Entities/AccessDecision.cs index a46b45e5954b..23c7de4882ad 100644 --- a/src/Pam.Domain/Entities/AccessDecision.cs +++ b/src/Pam.Domain/Entities/AccessDecision.cs @@ -13,8 +13,14 @@ 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; } /// @@ -28,6 +34,9 @@ public class AccessDecision : ITableObject /// public AccessConditionKind? ConditionKind { get; set; } + /// + /// The approve-or-deny outcome recorded by this decision. + /// public AccessDecisionVerdict Verdict { get; set; } /// @@ -40,6 +49,9 @@ public class AccessDecision : ITableObject /// 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() diff --git a/src/Pam.Domain/Entities/AccessLease.cs b/src/Pam.Domain/Entities/AccessLease.cs index 9901bcc3ff7f..6316abcff682 100644 --- a/src/Pam.Domain/Entities/AccessLease.cs +++ b/src/Pam.Domain/Entities/AccessLease.cs @@ -23,13 +23,35 @@ public class AccessLease : ITableObject 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() diff --git a/src/Pam.Domain/Entities/AccessRequest.cs b/src/Pam.Domain/Entities/AccessRequest.cs index dd0454ebf344..aea633e8ab18 100644 --- a/src/Pam.Domain/Entities/AccessRequest.cs +++ b/src/Pam.Domain/Entities/AccessRequest.cs @@ -49,7 +49,15 @@ public class AccessRequest : ITableObject /// 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; /// diff --git a/src/Pam.Domain/Enums/AccessConditionKind.cs b/src/Pam.Domain/Enums/AccessConditionKind.cs index 5546e62b3f89..2f6b08012d90 100644 --- a/src/Pam.Domain/Enums/AccessConditionKind.cs +++ b/src/Pam.Domain/Enums/AccessConditionKind.cs @@ -7,7 +7,12 @@ /// 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 index 5aa0f2bbc2e0..969a5dff6b86 100644 --- a/src/Pam.Domain/Enums/AccessDeciderKind.cs +++ b/src/Pam.Domain/Enums/AccessDeciderKind.cs @@ -5,7 +5,10 @@ /// public enum AccessDeciderKind : byte { + /// A condition on the governing access rule decided, with no human involved. Automatic = 0, + + /// A human approver decided. Human = 1, } @@ -14,6 +17,9 @@ public enum AccessDeciderKind : byte /// public enum AccessDecisionVerdict : byte { + /// Access was refused. Deny = 0, + + /// Access was granted. Approve = 1, } diff --git a/src/Pam.Domain/Enums/AccessLeaseStatus.cs b/src/Pam.Domain/Enums/AccessLeaseStatus.cs index 53b874818383..e8188c28ff48 100644 --- a/src/Pam.Domain/Enums/AccessLeaseStatus.cs +++ b/src/Pam.Domain/Enums/AccessLeaseStatus.cs @@ -5,8 +5,13 @@ /// 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). diff --git a/src/Pam.Domain/Enums/AccessRequestStatus.cs b/src/Pam.Domain/Enums/AccessRequestStatus.cs index d6dcd6ad36b1..14d8ec20368e 100644 --- a/src/Pam.Domain/Enums/AccessRequestStatus.cs +++ b/src/Pam.Domain/Enums/AccessRequestStatus.cs @@ -6,9 +6,18 @@ /// 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/AccessRequestDetails.cs b/src/Pam.Domain/Models/AccessRequestDetails.cs index 01bfc3dcd489..2b307f4aa011 100644 --- a/src/Pam.Domain/Models/AccessRequestDetails.cs +++ b/src/Pam.Domain/Models/AccessRequestDetails.cs @@ -47,6 +47,9 @@ public class AccessRequestDetails /// 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; } } From 3c540d817210cf86ae8bf16e743dcfa476c9d548 Mon Sep 17 00:00:00 2001 From: Hinton Date: Fri, 17 Jul 2026 13:01:44 +0200 Subject: [PATCH 3/3] Reference AccessRequest docs from AccessRequestDetails projections Use on the six read-model fields that project AccessRequest verbatim, so their documentation tracks the entity instead of duplicating it. --- src/Pam.Domain/Models/AccessRequestDetails.cs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/Pam.Domain/Models/AccessRequestDetails.cs b/src/Pam.Domain/Models/AccessRequestDetails.cs index 2b307f4aa011..98fb1bb5bb92 100644 --- a/src/Pam.Domain/Models/AccessRequestDetails.cs +++ b/src/Pam.Domain/Models/AccessRequestDetails.cs @@ -21,11 +21,23 @@ public class AccessRequestDetails /// 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.