Skip to content

PAM UAT - #8216

Draft
patriksvensson wants to merge 15 commits into
pam/cipher-partial-data-witnessfrom
pam/uat
Draft

PAM UAT#8216
patriksvensson wants to merge 15 commits into
pam/cipher-partial-data-witnessfrom
pam/uat

Conversation

@patriksvensson

Copy link
Copy Markdown
Member

No description provided.

@patriksvensson
patriksvensson changed the base branch from main to pam/cipher-partial-data-witness August 14, 2026 10:56
@codecov

codecov Bot commented Aug 14, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 91.41750% with 155 lines in your changes missing coverage. Please review.
✅ Project coverage is 69.10%. Comparing base (2724685) to head (1dc37e0).

Files with missing lines Patch % Lines
...odels/Response/AccessRequestResultResponseModel.cs 0.00% 34 Missing ⚠️
src/Core/Platform/Push/IPushNotificationService.cs 14.28% 24 Missing ⚠️
...ork/Pam/Repositories/AccessAuditEventRepository.cs 78.57% 21 Missing and 3 partials ⚠️
.../Endpoints/Handlers/CipherLeaseEndpointsHandler.cs 50.00% 10 Missing ⚠️
...e/src/Services/Pam/Api/Models/DomainEnumMapping.cs 71.42% 5 Missing and 5 partials ⚠️
...ionFeatures/Commands/SubmitAccessRequestCommand.cs 93.97% 6 Missing and 4 partials ⚠️
.../Models/Request/AccessRequestCreateRequestModel.cs 0.00% 7 Missing ⚠️
...Api/Models/Response/AccessPreCheckResponseModel.cs 0.00% 7 Missing ⚠️
.../AdminConsole/Repositories/CollectionRepository.cs 91.22% 2 Missing and 3 partials ⚠️
...cense/src/Services/Pam/Services/CipherLeaseGate.cs 96.36% 2 Missing and 2 partials ⚠️
... and 10 more
Additional details and impacted files
@@                         Coverage Diff                         @@
##           pam/cipher-partial-data-witness    #8216      +/-   ##
===================================================================
+ Coverage                            63.32%   69.10%   +5.78%     
===================================================================
  Files                                 2387     2425      +38     
  Lines                               104060   105811    +1751     
  Branches                              9422     9551     +129     
===================================================================
+ Hits                                 65891    73118    +7227     
+ Misses                               35914    30311    -5603     
- Partials                              2255     2382     +127     

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

The witness test was written three ways — `fullAccess.Authorizes(id)`,
`fullAccess?.Authorizes(id) == true`, and `is not null && Authorizes(id)` — at
the branch that decides whether secrets go out. One of those getting inverted
leaks, and only the path it sits on would notice.

A static `From` on each abstract base now owns that test, so it appears once
per family and nowhere else. Call sites state what they want and get the shape
the witness permits; the five casts to the base type and the controller's
`BuildCipherMiniResponse` helper are gone with it.

Paths authorized out of band still construct their `Full*` directly, which
keeps `_cipherLeaseGate.Unrestricted()` visible at the admin and export call
sites rather than hidden behind a factory.
patriksvensson and others added 8 commits August 17, 2026 11:17
Stage 1 of two. Copies the final-state files from pam/poc-rebased to their
pam/uat paths with no adaptation, so the next commit's diff shows every
deviation from the POC. Does not compile on its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… handlers

Stage 2 of two: adapts the POC blobs to pam/uat. Diffing this commit against its
parent shows every deviation from pam/poc-rebased.

The three handlers now orchestrate the ported queries and commands instead of
throwing NotImplementedException, so GET /leases/mine, /leases/active,
/access-requests/mine and /leases/ciphers/{id}/state return data rather than 500.

Adaptations:
- Restore the API enum shim as the wire contract. The domain's AccessRequestStatus
  has no Activated member, so mapping it straight to the wire would decode a Denied
  request as Activated. DomainEnumMapping derives Activated from ProducedLeaseId.
- Register a no-op IAccessAuditEventEmitter in the Pam service project. It cannot
  live in Core, which has no Pam.Domain reference. Every command injects it, so the
  registration is load-bearing even though it records nothing.
- Ship the two approver/requester notifiers as no-ops: the push types they would
  send do not exist on this branch.
- Drop the approver email. Its ICollectionRepository.GetManagingUserIdsAsync sproc
  and IMailService overload are both absent here.
- Widen AccessRequestResult with the automatic decision so the submit response
  carries the decision log its published contract promises.
- Keep uat's response-model shape (parameterless ctor, mutable properties) and add
  a domain-taking ctor alongside, preserving the generated OpenAPI schemas.

The only wire change is AccessRequestStatus gaining Activated and shifting the
values after Approved, which aligns the spec with the already-published
sdk-internal binding. All 18 PAM paths and the other 24 PAM schemas are unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Write gating was deliberately out of scope for the UAT endpoint port, which
left a leasing-gated cipher fully editable: a member holding no lease could
edit, delete, restore, re-file, or attach to a credential whose secrets the
read gate was already withholding from them. The read gate returns partial
data for such a cipher, so a save would also have written the client's blanks
over the fields the server suppressed.

Add EnsureCanMutateAsync and EnsureCanMutateManyAsync to ICipherLeaseGate,
no-op them in UnrestrictedCipherLeaseGate so OSS and flag-off behaviour is
unchanged, and call them from the twelve CipherService mutation paths. Also
gate CiphersController.PutPartial, which writes straight to the repository and
so never reaches the service-level gate — leaving it open would let a caller
re-file one gated cipher at a time while MoveManyAsync refused the batch.
Brand-new ciphers, skipPermissionCheck, and org-admin paths stay ungated, for
the same reason the read gate mints an unrestricted witness for a context
already authorized out-of-band.

Refusal is NotFound so a write attempt cannot confirm that a credential the
caller cannot reach exists. The bulk decision honours leases where the bulk
read deliberately does not: a read copies secrets into every client's local
store for as long as that store lives, whereas a write copies no secret
anywhere, so refusing the holder's own edit would withhold nothing. It refuses
the whole batch when any cipher is gated, because DeleteManyAsync has no
per-item result channel and a partial success would silently diverge from what
the client believes happened.

The bulk path resolves the governing rule per cipher, so a flag-on bulk
mutation costs a query per cipher. A structural pre-filter over the caller's
collections would remove that but is not sound: UserCollectionDetails filters
on Organization.Enabled and CollectionCipher_ReadByUserId does not, so a
disabled organization would clear a cipher the resolver gates. Batching
belongs behind IGoverningRuleResolver instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The append-only store the audit trail is written to and read back from: the
AccessAuditEvent table and its two stored procedures, a consolidated migration
for MSSQL plus generated ones for the EF providers, and the Dapper and EF
repositories behind IAccessAuditEventRepository.

Rows are self-contained. AccessAuditEvent_Create snapshots the actor,
requester, cipher, collection, and rule display names into the row at write
time, so the trail read touches no other table and a later rename or delete
cannot rewrite history. The subject ids are deliberately not foreign keyed for
the same reason -- an event outlives what it references. Only OrganizationId
is, so the rows go when the organization does. The EF path resolves those names
in C#, because JSON_VALUE -- which the procedure uses to read the cipher name
out of its encrypted Data document -- has no portable EF translation.

This is the persistence layer only; nothing consumes it yet. The emitter that
writes to it and the trail endpoint that reads from it are separate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Wires the commercial PAM library to the audit store: the emitter every
state-changing command already calls now appends to it, and the trail is read
back through GET organizations/{orgId}/audit, authorized by AccessEventLogs so
whoever can read the organization's event logs sees the whole trail regardless
of collection management.

The read collapses each action's before/after pair, which share a correlation
id, into one row -- the Outcome when it landed, otherwise the lone Attempt,
which the response flags as in-doubt rather than dropping. Emission is not
transactional by design, so an Attempt with no Outcome marks an interrupted
action instead of a silently lost event.

NoopAccessAuditEventEmitter goes with this: the interface is commercial-only, so
the placeholder had no remaining caller once the real emitter was registered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The three access-rule commands never emitted, so creating, editing, or deleting
a rule left no audit event -- even though RuleCreated/RuleUpdated/RuleDeleted
were defined and the write payload carried AccessRuleId and RuleName for exactly
this purpose. Unlike the expiry and credential-access kinds, these three were
never marked deferred; they were simply unwired.

Each command now emits the Attempt/Outcome pair the rest of the module does.
Create and update take the actor from the LastEditedBy the handler already
stamps. Delete had no actor at all, so DeleteAsync takes the caller's id -- the
delete is hard, which makes the audit event the only surviving record of who did
it and what the rule was called. That is also why RuleName is captured from the
row before the delete rather than joined at write time.

The create's Attempt cannot name the rule: Repository.CreateAsync assigns the
id, so before the write there is no rule to name. Both create and update hold
their Outcome until the collection links are written too, so an Attempt with no
Outcome flags a half-applied change rather than reading as a clean one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"[dbo].[AccessAuditEvent_Create]",
new
{
Id = CoreHelpers.GenerateComb(),

var row = new EfModel
{
Id = CoreHelpers.GenerateComb(),

var rule = new AccessRule
{
Id = CoreHelpers.GenerateComb(),

var rule = new AccessRule
{
Id = CoreHelpers.GenerateComb(),
patriksvensson and others added 6 commits August 19, 2026 21:24
AccessRule.DefaultLeaseDurationSeconds and MaxLeaseDurationSeconds were
write-only: both round-tripped through create/update and rendered in the
admin console's rule form, but nothing at request time ever read them. The
only cap applied was the hardcoded 24h global in SubmitAccessRequestCommand,
so a rule configured for 15 minutes granted a 1 hour lease in full (PM-39858).

LeaseDurationBounds now owns the arithmetic that folds a rule's optional
bounds together with the global ceiling. It has two callers that have to
agree exactly -- the pre-check publishes the bounds so a client can shape its
duration picker, and submit enforces them -- because a client narrowing to a
cap the server does not enforce is how the rule's maximum came to be ignored
in the first place.

GoverningRule (and the resolver that builds it) now carry both fields; it
previously copied only the extension-related ones, leaving the two
lease-duration fields unreadable downstream. Submit applies the effective cap
on both paths: the automatic duration, and the human-approval window, which
is pinned at submit and so has no later gate of its own.

AccessRuleWriteValidator additionally rejects non-positive durations and a
default above the rule's own maximum. The edit form already couples its two
pickers, but a write straight to the API bypassed that and could persist a
rule whose every pre-filled request exceeded its own cap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The collection browser renders a "Controlled access" column, but a collection row
had nothing to put in it: no collection response model carried the PAM association,
so a client could only learn that a collection is governed by reading the
organization's access rules itself. Providers cannot do that -- the access-rules
endpoint requires organization membership, deliberately, because rules gate who may
lease credentials out of an organization and that is not a provider's to read. Their
column stays empty however the client is written.

Surface the fact on the collection instead, as HasEnabledAccessRule on the responses
that feed sync and the organization listing.

The flag is derived rather than the stored association. Collection.AccessRuleId
records which rule governs a collection, but a rule that is switched off gates
nothing, so reporting the association alone would start badging collections whose
rule is disabled. Each read path therefore joins AccessRule and reports whether it is
enabled: MSSQL in Collection_ReadByUserId, Collection_ReadByIdWithPermissions and
Collection_ReadSharedCollectionsByOrganizationIdWithPermissions, Entity Framework via
an EXISTS subquery in UserCollectionDetailsQuery and CollectionAdminDetailsQuery
(Collection has no AccessRule navigation property to traverse) carried through the
GROUP BY key of all three repository reads.

Collection_ReadByUserId needed its columns qualified: joining AccessRule makes Id,
OrganizationId and Name ambiguous against the UserCollectionDetails table function.

Nothing writes the column and no request model gains a field --
SetAccessRuleAssociationsAsync remains the single writer of the association.

Covered by CollectionRepositoryHasEnabledAccessRuleTests, which runs the same truth
table -- ungoverned, governed by an enabled rule, governed by a disabled rule --
against every read path on both implementations.
GoverningRuleResolver decided RequiresHumanApproval by evaluating the rule's
whole condition list against the caller's current signals and asking whether
the combined outcome was RequiresApproval. Combine gives deny precedence over
requires-approval, so a single denying condition -- a source IP outside the
rule's CIDR allowlist, a request outside its time windows -- short-circuits
the fold to Deny and the flag comes back false. A rule carrying a
HumanApprovalCondition was then handed downstream as though it had none
(PM-42256).

Nothing downstream recovers from that. SubmitAccessRequestCommand branches on
the flag, takes the automatic path, re-evaluates the same conditions and
rejects the request outright; AccessPreCheckQuery reports Automatic, so the
client never offers the approval flow in the first place. The member is turned
away at the door on precisely the rules whose purpose is that a human decides,
and the approver never learns of it, because no request is created.

The flag is now structural -- whether the rule's conditions contain a
HumanApprovalCondition -- and no longer depends on signals. That matches how
the governing rule is already chosen (oldest wins, on structure alone, never
on how the conditions evaluate for the caller in front of us), and it makes
the answer stable for a rule whose callers differ in IP or clock. Resolution
no longer evaluates anything, so the resolver drops its IAccessRuleEngine
dependency; the engine keeps its one real caller on the automatic path.

One consequence is deliberate but worth naming: the approval path pins a
window and routes to an approver without evaluating the rule's other
conditions, so on a human-gated rule the IP and time-window checks now inform
the approver's decision rather than pre-empting it. Routing these requests to
a human is what the ticket asks for; that it also widens what a human-gated
rule admits is a consequence of it, not only a routing fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
GetManagingUserIdsAsync admits four kinds of manager and the tests exercised three:
direct Manage, Manage through a group, and an Owner with and without organization-wide
admin access. The Custom member holding EditAnyCollection went untested.

That is the branch where the two implementations are most likely to drift, because it is
the only one that reads the Permissions column rather than a typed one. MSSQL asks the
column directly with JSON_VALUE(..., '$.editAnyCollection'), while Entity Framework
deserializes the blob and reads the property, so the two agree only as long as the
serializer keeps writing camelCase keys. Nothing in either implementation states that
dependency, and a change to the serializer would break MSSQL alone.

Turn admin access off in the new case so that EditAnyCollection is the only thing that
can grant Manage, and pair the Custom member holding it against one holding
ManageGroups, so the assertion distinguishes the permission from the member type.
CreateTestUserAsync names the user "{identifier}-{guid}", and User.Name is 50
characters, so an identifier longer than 13 overflows it. "editanycollection" is 17.

Only Postgres reports this. The MSSQL path passes the name to a procedure whose
parameter is declared NVARCHAR(50), and SQL Server truncates silently on assignment
rather than raising, while SQLite does not enforce declared lengths at all -- so the
test passed on both while inserting a name it had quietly cut short.
Collection_ReadManagingUserIds and the three HasEnabledAccessRule read paths
go out together, so they are one deployment step rather than two dated
scripts. Re-dated to the day the pair lands.

Also drops the note above Collection_ReadByUserId's SELECT explaining why its
columns carry the UCD prefix.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants