[PM-40526] Access Leasing: persistence & schema - #8002
Conversation
🤖 Bitwarden Claude Code ReviewOverall Assessment: APPROVE Re-reviewed the delta since the last pass ( Code Review DetailsVerified at head (no action needed)
Existing open threads still valid at head (not re-posted inline)
|
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #8002 +/- ##
==========================================
+ Coverage 68.12% 68.41% +0.28%
==========================================
Files 2373 2380 +7
Lines 102846 103679 +833
Branches 9328 9386 +58
==========================================
+ Hits 70068 70930 +862
+ Misses 30454 30421 -33
- Partials 2324 2328 +4 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
49577aa to
6124c0d
Compare
6124c0d to
b9fe7d1
Compare
a615a81 to
b8f1900
Compare
|
This is marked as a draft. Do you still need a review at this stage? |
b8f1900 to
9113dde
Compare
9113dde to
1e78704
Compare
1e78704 to
6e566aa
Compare
6e566aa to
13f364d
Compare
d5c3c17 to
b8113f7
Compare
| WHERE | ||
| AR.[Id] = @AccessRequestId | ||
| AND AR.[RequesterId] = @RequesterId | ||
| AND AR.[Status] = 1 -- Approved | ||
| AND AR.[NotBefore] <= @Now | ||
| AND AR.[NotAfter] > @Now | ||
| AND NOT EXISTS (SELECT 1 FROM [dbo].[AccessLease] AL WHERE AL.[AccessRequestId] = AR.[Id]) |
There was a problem hiding this comment.
🎨 SUGGESTED: The mint guard does not exclude extension requests, so activating one would mint a second lease alongside the extended parent.
Details and fix
AccessRequest_ReadActiveApprovedByRequesterIdCipherId deliberately excludes extension requests (AR.[ExtensionOfLeaseId] IS NULL) because "an approved extension pushes its parent lease's end out in place and never produces a lease of its own". This re-check block — which the comment above describes as re-checking every application-level precondition — omits that one, as does the EF CreateFromApprovedRequestAsync predicate.
An extension request created by AccessRequest_CreateApprovedExtension is Status = 1 (Approved), NotBefore = [old lease end], NotAfter = [new lease end], and has produced no lease of its own — so once the parent lease's original window lapses it satisfies every condition here and mints a second AccessLease for the same cipher, breaking the single-active-lease invariant the extension path exists to preserve. When the governing rule does not set SingleActiveLease, nothing downstream catches it either.
Today this is only reachable if a caller passes an extension request's id to CreateFromApprovedRequestAsync, and the command layer isn't in this stack yet — so this is defense in depth on the write that authorizes access to Vault Data, matching the read path's existing guard.
| WHERE | |
| AR.[Id] = @AccessRequestId | |
| AND AR.[RequesterId] = @RequesterId | |
| AND AR.[Status] = 1 -- Approved | |
| AND AR.[NotBefore] <= @Now | |
| AND AR.[NotAfter] > @Now | |
| AND NOT EXISTS (SELECT 1 FROM [dbo].[AccessLease] AL WHERE AL.[AccessRequestId] = AR.[Id]) | |
| WHERE | |
| AR.[Id] = @AccessRequestId | |
| AND AR.[RequesterId] = @RequesterId | |
| AND AR.[Status] = 1 -- Approved | |
| AND AR.[ExtensionOfLeaseId] IS NULL -- an extension extends its parent lease; it never mints one | |
| AND AR.[NotBefore] <= @Now | |
| AND AR.[NotAfter] > @Now | |
| AND NOT EXISTS (SELECT 1 FROM [dbo].[AccessLease] AL WHERE AL.[AccessRequestId] = AR.[Id]) |
The EF repository would need the matching r.ExtensionOfLeaseId == null clause.
| [CreationDate] DATETIME2 (7) NOT NULL, | ||
| CONSTRAINT [PK_AccessLease] PRIMARY KEY CLUSTERED ([Id] ASC), | ||
| CONSTRAINT [FK_AccessLease_AccessRequest] FOREIGN KEY ([AccessRequestId]) REFERENCES [dbo].[AccessRequest] ([Id]), | ||
| CONSTRAINT [FK_AccessLease_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]) ON DELETE CASCADE |
There was a problem hiding this comment.
♻️ DEBT: FK_AccessLease_Organization is ON DELETE CASCADE but no MSSQL index leads with OrganizationId, so org deletion scans the whole table.
Details and fix
All five indexes on this table lead with RequesterId, NotAfter, CollectionId, CipherId, or AccessRequestId. Organization_DeleteById relies on the database cascade for this table, which forces SQL Server to scan AccessLease to find the rows to delete. AccessLease grows monotonically — ended leases are retained for the governance history reads — so this gets worse over time.
This is also a dual-ORM divergence: EF's convention creates IX_AccessLease_OrganizationId on all three providers (util/PostgresMigrations/.../20260811180700_AddAccessRequestAndLease.cs:128 and the MySQL/SQLite equivalents), so only MSSQL is missing it. AccessRequest is already covered by IX_AccessRequest_OrganizationId_Status, and every other org-scoped table in src/Sql/dbo (Collection, SecurityTask, AccessRule, …) carries an OrganizationId-leading index.
CREATE NONCLUSTERED INDEX [IX_AccessLease_OrganizationId]
ON [dbo].[AccessLease] ([OrganizationId] ASC);
GONeeds the matching guarded CREATE INDEX in util/Migrator/DbScripts/2026-08-11_01_AddAccessRequestAndLease.sql.
…Lease Extract the MSSQL/Dapper persistence for the PAM access-request/lease flow: the AccessRequest, AccessDecision, and AccessLease SSDT tables; the 22 AccessRequest_*/AccessLease_* stored procedures; the Dapper AccessRequestRepository and AccessLeaseRepository (registered in DapperServiceCollectionExtensions); the repository integration tests; and one consolidated migration (2026-07-16_00_AddAccessRequestAndLease.sql) that folds poc's incremental scripts into a single create, matching the final SSDT shape verbatim. EF Core parity for the non-MSSQL providers lands in the next commit.
Author from scratch the non-MSSQL (PostgreSQL/MySQL/SQLite) persistence for the PAM access-request/lease flow, since the POC was Dapper/MSSQL-only. Adds the EF entity models + AutoMapper profiles, inline DatabaseContext configuration and DbSets, the EF AccessRequestRepository and AccessLeaseRepository implementing the domain interfaces with LINQ matching the stored-procedure behaviour, the DI registrations, and generated migrations for all three providers (AddAccessRequestAndLease, matching the MSSQL migration name). The two race-guarded writes (lease mint, approved extension) use a Serializable transaction as the cross-provider stand-in for the MSSQL procs' UPDLOCK/HOLDLOCK; a losing concurrent writer may surface a provider serialization error rather than a clean outcome enum. Cross-provider parity is covered by the [DatabaseData] integration tests.
…AM tests CoreHelpers.GenerateComb() is obsolete; PAM code standardizes on Bit.Core.Utilities.CombGuid.Generate() (already used by the domain entities).
These have to apply after everything already on main. The EF timestamps for all three providers sort after AddAccessRule, and the MSSQL script now takes the 01 sequence for 2026-08-11 because main picked up AddKeyRegenerationStoredProcedures on that date, which already holds 00. DbUp runs DbScripts in name order, so sharing a prefix would have left the two ordering on the rest of their filenames instead of on the sequence number that exists to settle it.
Only record an AccessDecision when the guarded write actually transitioned the row. AccessRequest_ResolveWithDecision and AccessLease_Revoke inserted unconditionally, so a losing approver's verdict or a repeat revoke appended a decision to a row it never changed, leaving the decision log contradicting that row's status. AccessLease_Revoke now derives the request id from the ended lease via OUTPUT rather than trusting a caller-supplied @AccessRequestId, which also drops that parameter. Add SET XACT_ABORT ON to the four transactional procedures that lacked it. Without it a constraint violation aborts only the failing statement, execution falls through to COMMIT, and half the transaction is persisted alone. Bound both of AccessRequest_ReadManyByRequesterId's result sets to the same 250-row page; the decision set was previously unbounded across the requester's entire history. Replace the OUTER APPLY/TOP 1 produced-lease lookups with LEFT JOINs now that IX_AccessLease_AccessRequestId is unique. Mirror the two behavioural changes in the EF repositories, and set the extension decision's AccessRequestId from the request being created; it was left at the caller's value, which violated FK_AccessDecision_AccessRequest on the EF providers while MSSQL passed. Cover the no-op decision paths, the broader cancellable set, and the governance history ordering with integration tests.
Timestamps do not round-trip exactly on any provider: Dapper binds a plain DateTime as DbType.DateTime -- the legacy 3.33 ms type -- so MSSQL writes are rounded before they reach the DATETIME2(7) columns, and the EF providers store microseconds rather than ticks. Four assertions compared with exact equality and were failing on SqlServer. LaxDateTimeComparer.Default allows 2 ms, which covers datetime's worst-case rounding error of 1/600 s, so the comparison is bounded rather than merely empirical.
This branch adds FK_AccessRequest_AccessRule, which does not cascade (NO ACTION on SQL Server, RESTRICT on the EF providers). AccessRule_DeleteById and the EF AccessRuleRepository.DeleteAsync only detached Collection.AccessRuleId, so once any request had pinned a rule that rule became undeletable on all four databases: an admin creating a rule, a member submitting a request against it, and the admin then removing the rule was enough to hit a foreign-key violation. Detach AccessRequest.RuleId alongside the collection links. RuleId is provenance rather than authority -- the request's own window and decision log are the record of what was granted -- and the column is already nullable for requests that were never gated through a stored rule. AccessRule_DeleteById has shipped, so it is re-deployed via CREATE OR ALTER in this branch's migration, which is where the FK that requires the change is added.
Four indexes, none of which the tables carried: IX_AccessLease_CipherId_Status — AccessLease_CreateFromApprovedRequest's per-cipher singleton guard filters on CipherId alone under UPDLOCK/HOLDLOCK. With no CipherId-leading index the optimizer had to take either a clustered scan or a seek on IX_AccessLease_NotAfter_Status for the open-ended NotAfter > @now range, so the range lock covered every currently-active and future lease and serialized unrelated organizations' activations against each other for the life of the transaction. IX_AccessRequest_CollectionId_Status — the approver inbox reads join the caller's manageable collection ids, which neither existing index could serve, so the hottest read on the table scanned it in full. IX_AccessRequest_ExtensionOfLeaseId — the extension cap check runs an EXISTS on this column while holding the parent lease's UPDLOCK, so the scan happened inside the lock window. Also indexes FK_AccessRequest_AccessLease. IX_AccessRequest_RuleId — indexes FK_AccessRequest_AccessRule so the rule detach added in the previous commit seeks rather than scans. The three EF migrations are regenerated rather than hand-edited, which also replaces the hand-re-dated pair from a1bb877 with tool-authored files. They still sort after AddAccessRule, and has-pending-model-changes reports no drift on any provider.
ResolveWithDecisionAsync and CancelWithDecisionAsync were the last two EF writes still taking the decision's AccessRequestId from the caller. The MSSQL procedures reuse a single @AccessRequestId for both the guarded UPDATE and the AccessDecision insert, so on SQL Server the verdict always lands on the request being resolved; on EF a decision naming a different request would file it there instead. Matches CreateAutoApprovedAsync, CreateApprovedExtensionAsync, and the lease RevokeAsync, so the PAM audit trail is now identical across all four providers regardless of what the command layer hands in.
Three fixes, all cases where the EF path behaved differently from the stored procedures: The per-cipher singleton guard resolved the cipher from the caller's copy of the lease rather than from the request. The procedure reads it authoritatively via WHERE [CipherId] = (SELECT [CipherId] FROM [dbo].[AccessRequest] ...), and this method already treats the caller's other copies as non-authoritative, so a lease whose CipherId disagreed with its AccessRequestId was checked for contention against the wrong cipher and could mint a second concurrent active lease. A request that does not exist yields no cipher and the guard is skipped, leaving the precondition check to report the failure, mirroring the procedure's NULL scalar subquery. The mint path caught every DbUpdateException and reported PreconditionFailed, so a genuine persistence failure was indistinguishable from losing the activation race. Narrowed to duplicate-key violations only. Note this needs different codes from EntityFrameworkCache.IsDuplicateKeyException: the backstop is a unique index, which reports 2601 on SQL Server and 2067 on SQLite rather than the primary-key 2627/1555 that helper looks for. Organization deletion could not complete on the EF providers once an organization held an extended lease. Organization cascades to both AccessRequest and AccessLease while the two reference each other under Restrict, so whichever cascade the provider fired first was blocked by the other. The delete now detaches the extension links, then removes leases, then requests, before the existing AccessRule removal — which also releases the requests' RuleId hold. MSSQL was unaffected: its FKs are NO ACTION, validated once the cascade completes. OrganizationDeleteAsync_WithExtendedLease_Succeeds pins the last of these; it fails with a foreign-key violation on SQLite without the fix.
Several callers seed a lease they expect a read to exclude. With the outcome discarded those assertions would pass vacuously if the mint had never happened, which matters most on the EF path where a failed mint returns an outcome rather than throwing. Matches the other call sites, which already assert Minted.
b8113f7 to
ace6a05
Compare
This reverts commit 635bf0e.
There was a problem hiding this comment.
We discussed some future work on error handling and possible race conditions, but no blocking issues
Jira Notes
🎟️ Tracking
https://bitwarden.atlassian.net/browse/PM-40526
📔 Objective
Persistence & schema for the PAM access-request/lease domain (introduced in #8001), across both ORMs:
AccessRequest,AccessDecision, andAccessLeaseSSDT tables; the 22AccessRequest_*/AccessLease_*stored procedures; the DapperAccessRequestRepositoryandAccessLeaseRepository; repository integration tests; and one consolidated migration (2026-07-16_00_AddAccessRequestAndLease.sql) that folds the POC's incremental scripts into a single create matching the final SSDT shape verbatim.DatabaseContextconfiguration and DbSets, the EF repositories implementing the domain interfaces with LINQ matching the stored-procedure behaviour, DI registrations, and generated migrations for all three providers.Collection_SetAccessRuleAssociationsto the AdminConsole SSDT folder so collection sprocs stay under the table's owning team (per PR review).The two race-guarded writes (lease mint, approved extension) use a
Serializabletransaction as the cross-provider stand-in for the MSSQL procs'UPDLOCK/HOLDLOCK; a losing concurrent writer may surface a provider serialization error rather than a clean outcome enum. Cross-provider parity is covered by the[DatabaseData]integration tests.