[PM-40526] Access Leasing: domain - #8001
Conversation
🤖 Bitwarden Claude Code ReviewOverall Assessment: APPROVE This PR adds the PAM access-request/lease domain ( Code Review DetailsNo new findings. Five earlier threads on this PR are still unresolved and were intentionally not re-raised:
|
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #8001 +/- ##
==========================================
+ Coverage 63.89% 68.10% +4.20%
==========================================
Files 2359 2371 +12
Lines 102319 102795 +476
Branches 9240 9320 +80
==========================================
+ Hits 65379 70006 +4627
+ Misses 34713 30469 -4244
- Partials 2227 2320 +93 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
d15334c to
b7eebb4
Compare
b7eebb4 to
3c540d8
Compare
The merge-base changed after approval.
3775616 to
d275670
Compare
d275670 to
85d24b5
Compare
85d24b5 to
b2be46e
Compare
268969f to
1ad881b
Compare
1ad881b to
18a5003
Compare
| /// <summary> | ||
| /// Set when an operator revokes the lease (<see cref="AccessLeaseStatus.Revoked"/>). NULL otherwise. | ||
| /// </summary> | ||
| public DateTime? RevokedDate { get; set; } | ||
|
|
||
| /// <summary> | ||
| /// The operator who revoked the lease. NULL unless <see cref="Status"/> is <see cref="AccessLeaseStatus.Revoked"/>. | ||
| /// </summary> | ||
| public Guid? RevokedBy { get; set; } |
There was a problem hiding this comment.
♻️ DEBT: RevokedDate/RevokedBy docs contradict RevokeAsync — both are also set for Cancelled
Details and fix
IAccessLeaseRepository.RevokeAsync in this same PR writes the revoked date and revoker for either end status:
setting its status to
endStatus(Revoked when an operator ended it, Cancelled when the holder ended their own) along with its revoked date and revoker
and GetManyEndedByCollectionIdsAsync relies on that ("A revoked/cancelled lease's end is its revoked date"). So "NULL otherwise" / "NULL unless Status is Revoked" is wrong for cancellations, and RevokedBy is the holder — not an operator — in that path. This matters because the stacked schema PR reads these docs: a CHECK (RevokedDate IS NULL OR Status = 2) constraint derived from them would reject every self-cancel.
| /// <summary> | |
| /// Set when an operator revokes the lease (<see cref="AccessLeaseStatus.Revoked"/>). NULL otherwise. | |
| /// </summary> | |
| public DateTime? RevokedDate { get; set; } | |
| /// <summary> | |
| /// The operator who revoked the lease. NULL unless <see cref="Status"/> is <see cref="AccessLeaseStatus.Revoked"/>. | |
| /// </summary> | |
| public Guid? RevokedBy { get; set; } | |
| /// <summary> | |
| /// When the lease was ended early — set for both <see cref="AccessLeaseStatus.Revoked"/> (an operator ended it) | |
| /// and <see cref="AccessLeaseStatus.Cancelled"/> (the holder ended their own). NULL otherwise. | |
| /// </summary> | |
| public DateTime? RevokedDate { get; set; } | |
| /// <summary> | |
| /// Who ended the lease early: the operator who revoked it, or the holder who cancelled their own. NULL unless | |
| /// <see cref="Status"/> is <see cref="AccessLeaseStatus.Revoked"/> or <see cref="AccessLeaseStatus.Cancelled"/>. | |
| /// </summary> | |
| public Guid? RevokedBy { get; set; } |
There was a problem hiding this comment.
I think this should be fixed as well.
| /// <summary> | ||
| /// 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. | ||
| /// </summary> | ||
| public AccessLeaseStatus? ProducedLeaseStatus { get; set; } | ||
|
|
||
| /// <summary> | ||
| /// Every decision recorded against this request, oldest first — one element per | ||
| /// <see cref="Entities.AccessDecision"/> 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. | ||
| /// </summary> | ||
| public List<AccessRequestDecision> Decisions { get; set; } = new(); |
There was a problem hiding this comment.
♻️ DEBT: Two projection docs are narrower than the states the repository contracts can produce
Details and fix
ProducedLeaseStatuslists "Active/Expired/Revoked" butAccessLeaseStatusalso hasCancelled, whichRevokeAsyncwrites for holder-ended leases — an inbox row can carry it.Decisionssays "Empty only while pending", yet two non-pending terminal states have no decision row:CancelAsyncexplicitly writes none ("NoAccessDecisionis written"), andExpiredUnansweredis "the approval window lapsed with no decision recorded". A consumer that treats a non-pending request as guaranteed to haveDecisions[0]would throw.
| /// <summary> | |
| /// 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. | |
| /// </summary> | |
| public AccessLeaseStatus? ProducedLeaseStatus { get; set; } | |
| /// <summary> | |
| /// Every decision recorded against this request, oldest first — one element per | |
| /// <see cref="Entities.AccessDecision"/> 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. | |
| /// </summary> | |
| public List<AccessRequestDecision> Decisions { get; set; } = new(); | |
| /// <summary> | |
| /// The produced lease's current status (Active/Expired/Revoked/Cancelled), 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. | |
| /// </summary> | |
| public AccessLeaseStatus? ProducedLeaseStatus { get; set; } | |
| /// <summary> | |
| /// Every decision recorded against this request, oldest first — one element per | |
| /// <see cref="Entities.AccessDecision"/> row (human or automatic; identity denormalized from the User join for | |
| /// human decisions). Empty while pending, and for the terminal states that record no verdict: a requester | |
| /// cancellation (<c>IAccessRequestRepository.CancelAsync</c>) and | |
| /// <see cref="AccessRequestStatus.ExpiredUnanswered"/>. 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. | |
| /// </summary> | |
| public List<AccessRequestDecision> Decisions { get; set; } = new(); |
| public AccessDeciderKind DeciderKind { get; set; } | ||
|
|
||
| /// <summary>The human approver, or null for an automatic decision.</summary> | ||
| public Guid? Id { get; set; } |
There was a problem hiding this comment.
🎨 SUGGESTED: Rename Id to ApproverId — on a class named AccessRequestDecision, Id reads as the decision's id
Details and fix
This projects an AccessDecision row, which has its own Id column, but here Id holds AccessDecision.ApproverId. The doc comment has to correct the name ("The human approver"), and the sibling Name/Email are also the approver's.
The concrete hazard is in the stacked data PR: Dapper maps by column name, so a read procedure that selects ad.Id (natural when projecting a decision row) binds the decision id into this property with no compiler or test signal. Naming it ApproverId makes that mismatch impossible and matches AccessDecision.ApproverId.
| public Guid? Id { get; set; } | |
| /// <summary>The human approver, or null for an automatic decision.</summary> | |
| public Guid? ApproverId { get; set; } |
The class-level <see cref="Id"/> references above would need the same update.
144355e to
06271f5
Compare
06271f5 to
53f5c7b
Compare
15f93c2 to
53d1c0b
Compare
53d1c0b to
f4d8317
Compare
f4d8317 to
71aa8b4
Compare
f4d8317 to
61a316d
Compare
00f0274 to
85e9baa
Compare
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).
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.
Use <inheritdoc cref> on the six read-model fields that project AccessRequest verbatim, so their documentation tracks the entity instead of duplicating it.
Pam.csproj already references Pam.Domain, so the four scaffold copies under Api/Models were not decoupling the DTOs from anything. They were duplicates whose members had drifted from the domain enums they mirror: - AccessRequestStatus disagreed from its third member onward: the domain's Denied, Cancelled and ExpiredUnanswered sat where the wire had Activated, Denied and Canceled, and the spelling differed too (Cancelled against Canceled). A name-based mapping would have failed loudly; a cast between the two would have compiled and quietly mislabelled every terminal state, reporting a denied request as activated. - AccessLeaseStatus had no counterpart for the domain's Cancelled, so a lease its holder ended could not be represented on the wire. - DeciderKind and AccessDecisionVerdict already matched the domain member for member: pure duplication. The scaffolds described several members better than the domain enums did, so that prose carries over. The domain enums also now say that activation is not a status of its own — a request promoted to a lease stays approved, and the produced lease is what records it — which is the derivation the deleted wire Activated member was standing in for.
It was declared alongside AccessDeciderKind, so the type could not be found by its own filename and the folder was the only enum here holding two. One per file, matching its siblings.
Nothing consumed the member yet. The doc comment carries the "unanswered" part, which the name no longer needs to: this is the window lapsing with no decision recorded, not an approved request that was never activated.
3e96d78 to
47dd23a
Compare
| /// <summary> | ||
| /// Set when an operator revokes the lease (<see cref="AccessLeaseStatus.Revoked"/>). NULL otherwise. | ||
| /// </summary> | ||
| public DateTime? RevokedDate { get; set; } | ||
|
|
||
| /// <summary> | ||
| /// The operator who revoked the lease. NULL unless <see cref="Status"/> is <see cref="AccessLeaseStatus.Revoked"/>. | ||
| /// </summary> | ||
| public Guid? RevokedBy { get; set; } |
There was a problem hiding this comment.
I think this should be fixed as well.
| /// <summary> | ||
| /// A single decision on a <see cref="AccessRequest"/>. In v0 there is exactly one decision per request: an automated | ||
| /// <see cref="AccessDeciderKind.Automatic"/> verdict for auto-approval, or a <see cref="AccessDeciderKind.Human"/> | ||
| /// verdict once approver endpoints land. | ||
| /// </summary> |
There was a problem hiding this comment.
I think this should be fixed as well.
🎟️ Tracking
https://bitwarden.atlassian.net/browse/PM-40526
📔 Objective
Extract the pure domain for the PAM access-request/lease flow into
Pam.Domain, alongside the existingAccessRuledomain:AccessRequest,AccessDecision, andAccessLeaseentities and their status/kind/verdict/outcome enums.AccessRequestDetails,AccessRequestDecision).IAccessRequestRepositoryandIAccessLeaseRepositorycontracts.Requests and leases ship as one feature because their persistence is coupled (
AccessRequest.ExtensionOfLeaseIdFKsAccessLease; the request reads project lease status). No audit surface is included (deferred, as on the rule slice).