Skip to content

[PM-40526] Access Leasing: domain - #8001

Open
Hinton wants to merge 6 commits into
mainfrom
pam/access-leasing-domain
Open

[PM-40526] Access Leasing: domain#8001
Hinton wants to merge 6 commits into
mainfrom
pam/access-leasing-domain

Conversation

@Hinton

@Hinton Hinton commented Jul 17, 2026

Copy link
Copy Markdown
Member

🎟️ 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 existing AccessRule domain:

  • The AccessRequest, AccessDecision, and AccessLease entities and their status/kind/verdict/outcome enums.
  • The request projection models (AccessRequestDetails, AccessRequestDecision).
  • 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).

Stacked PR — targets pam/access-rule-engine. Review/merge the engine PR (#7992) first. Persistence & schema for these entities follows in the data PR stacked on this branch.

@Hinton
Hinton requested a review from a team as a code owner July 17, 2026 10:32
@Hinton Hinton added ai-review Request a Claude code review t:feature Change Type - Feature Development labels Jul 17, 2026
@github-actions

github-actions Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

🤖 Bitwarden Claude Code Review

Overall Assessment: APPROVE

This PR adds the PAM access-request/lease domain (AccessRequest, AccessDecision, AccessLease, their enums, the AccessRequestDetails/AccessRequestDecision projections, and the two repository contracts) under src/Pam.Domain, and drops the four duplicated wire enums in favour of the domain ones. All added code is declarative — entities, enums, and interfaces with no behaviour — and the PAM endpoint handlers remain scaffolds that throw NotImplementedException, so there is no runtime path to exercise. Swapping the wire enums for the domain enums renumbers the serialized AccessRequestStatus (Denied 3→2, Cancelled 4→3, Expired 5→4, Activated removed) and adds AccessLeaseStatus.Cancelled; since nothing serves these endpoints yet this is a spec/SDK-codegen change rather than a live contract break, and it matches the direction already agreed in the earlier review threads. No new findings this pass.

Code Review Details

No new findings.

Five earlier threads on this PR are still unresolved and were intentionally not re-raised:

  • src/Pam.Domain/Entities/AccessLease.csRevokedDate/RevokedBy docs vs. RevokeAsync's Cancelled path
  • src/Pam.Domain/Entities/AccessDecision.cs — "exactly one decision per request"
  • src/Pam.Domain/Models/AccessRequestDetails.csProducedLeaseStatus / Decisions projection docs
  • src/Pam.Domain/Models/AccessRequestDecision.csId naming
  • src/Pam.Domain/Repositories/IAccessRequestRepository.csCancelWithDecisionAsync naming

@codecov

codecov Bot commented Jul 17, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 0% with 69 lines in your changes missing coverage. Please review.
✅ Project coverage is 68.10%. Comparing base (63728c3) to head (47dd23a).

Files with missing lines Patch % Lines
src/Pam.Domain/Models/AccessRequestDetails.cs 0.00% 18 Missing ⚠️
src/Pam.Domain/Entities/AccessRequest.cs 0.00% 16 Missing ⚠️
src/Pam.Domain/Entities/AccessLease.cs 0.00% 15 Missing ⚠️
src/Pam.Domain/Entities/AccessDecision.cs 0.00% 12 Missing ⚠️
src/Pam.Domain/Models/AccessRequestDecision.cs 0.00% 7 Missing ⚠️
...els/Response/AccessRequestDecisionResponseModel.cs 0.00% 1 Missing ⚠️
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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@Hinton
Hinton force-pushed the pam/access-leasing-domain branch from d15334c to b7eebb4 Compare July 17, 2026 13:36
@Hinton
Hinton force-pushed the pam/access-leasing-domain branch from b7eebb4 to 3c540d8 Compare July 17, 2026 13:45
Comment thread src/Pam.Domain/Enums/AccessConditionKind.cs
kspearrin
kspearrin previously approved these changes Jul 17, 2026
@Hinton
Hinton dismissed kspearrin’s stale review July 27, 2026 08:49

The merge-base changed after approval.

@Hinton
Hinton force-pushed the pam/access-leasing-domain branch 2 times, most recently from 3775616 to d275670 Compare July 27, 2026 09:06
@Hinton
Hinton force-pushed the pam/access-leasing-domain branch from d275670 to 85d24b5 Compare July 27, 2026 13:38
@Hinton
Hinton marked this pull request as draft July 27, 2026 13:56
@Hinton
Hinton force-pushed the pam/access-leasing-domain branch from 85d24b5 to b2be46e Compare July 27, 2026 15:22
@Hinton
Hinton force-pushed the pam/access-leasing-domain branch 2 times, most recently from 268969f to 1ad881b Compare July 27, 2026 16:32
@Hinton
Hinton force-pushed the pam/access-leasing-domain branch from 1ad881b to 18a5003 Compare July 30, 2026 12:03
Comment on lines +42 to +50
/// <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; }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ 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.

Suggested change
/// <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; }

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should be fixed as well.

Comment on lines +46 to +60
/// <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();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ DEBT: Two projection docs are narrower than the states the repository contracts can produce

Details and fix
  • ProducedLeaseStatus lists "Active/Expired/Revoked" but AccessLeaseStatus also has Cancelled, which RevokeAsync writes for holder-ended leases — an inbox row can carry it.
  • Decisions says "Empty only while pending", yet two non-pending terminal states have no decision row: CancelAsync explicitly writes none ("No AccessDecision is written"), and ExpiredUnanswered is "the approval window lapsed with no decision recorded". A consumer that treats a non-pending request as guaranteed to have Decisions[0] would throw.
Suggested change
/// <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; }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎨 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.

Suggested change
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.

@Hinton
Hinton force-pushed the pam/access-leasing-domain branch from 144355e to 06271f5 Compare August 10, 2026 15:45
@Hinton
Hinton force-pushed the pam/access-leasing-domain branch from 06271f5 to 53f5c7b Compare August 11, 2026 08:14
@Hinton
Hinton force-pushed the pam/access-leasing-domain branch 2 times, most recently from 15f93c2 to 53d1c0b Compare August 11, 2026 10:01
@Hinton
Hinton force-pushed the pam/access-leasing-domain branch from 53d1c0b to f4d8317 Compare August 11, 2026 10:07
@Hinton
Hinton force-pushed the pam/access-leasing-domain branch from f4d8317 to 71aa8b4 Compare August 11, 2026 12:04
@Hinton
Hinton requested a review from patriksvensson August 11, 2026 13:50
Base automatically changed from pam/access-rule-engine to main August 11, 2026 14:06
@Hinton
Hinton force-pushed the pam/access-leasing-domain branch 3 times, most recently from f4d8317 to 61a316d Compare August 11, 2026 14:15
Comment thread bitwarden_license/src/Services/Pam/Api/Models/AccessDecisionVerdict.cs Outdated
Comment thread bitwarden_license/src/Services/Pam/Api/Models/AccessLeaseStatus.cs Outdated
Comment thread bitwarden_license/src/Services/Pam/Api/Models/AccessRequestStatus.cs Outdated
Comment thread bitwarden_license/src/Services/Pam/Api/Models/DeciderKind.cs Outdated
@Hinton
Hinton force-pushed the pam/access-leasing-domain branch from 00f0274 to 85e9baa Compare August 11, 2026 14:35
Comment thread src/Pam.Domain/Enums/AccessDeciderKind.cs Outdated
@Hinton
Hinton marked this pull request as ready for review August 11, 2026 15:09
Hinton added 6 commits August 11, 2026 20:54
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.
@Hinton
Hinton force-pushed the pam/access-leasing-domain branch from 3e96d78 to 47dd23a Compare August 11, 2026 18:54

@patriksvensson patriksvensson left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just some minor things that Claude already complained about.

Comment on lines +42 to +50
/// <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; }

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should be fixed as well.

Comment on lines +7 to +11
/// <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>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should be fixed as well.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai-review Request a Claude code review t:feature Change Type - Feature Development

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants