Skip to content

Experiment: PAM POC#7832

Draft
Hinton wants to merge 56 commits into
mainfrom
pam/poc
Draft

Experiment: PAM POC#7832
Hinton wants to merge 56 commits into
mainfrom
pam/poc

Conversation

@Hinton

@Hinton Hinton commented Jun 18, 2026

Copy link
Copy Markdown
Member

🎟️ Tracking

📔 Objective

📸 Screenshots

Hinton and others added 30 commits May 21, 2026 16:57
Adds LeasingEnabled (bool) and LeasingPolicy (JSON) columns to Collection,
gated by the FeatureFlagKeys.Pam feature flag. Includes:

- Schema: SSDT table + sprocs update, dated MSSQL migration with COL_LENGTH
  guards and CREATE OR ALTER for the affected Collection_* sprocs, and EF
  migrations for Postgres, MySQL, and SQLite.
- Domain: new src/Core/PrivilegedAccessManagement/ namespace with policy
  DTOs (human_approval, ip_allowlist, time_of_day, all_of) and the
  LeasingPolicyValidator service.
- Commands: CreateCollectionCommand and UpdateCollectionCommand validate the
  policy when the flag is on and clear the leasing fields when it is off.
- API: request/response models surface the two new fields; leasing flows
  through the existing collection create/update endpoints (the dedicated
  /leasing endpoint from the design doc is intentionally not introduced).
- Tests: LeasingPolicyValidatorTests cover the four policy kinds and
  failure modes; command tests cover the flag on/off and validation paths.
Promotes leasing policy from inline Collection columns to a first-class,
org-scoped, reusable entity that collections (and eventually Secrets
Manager entities) reference by FK.

- Schema: drop Collection.LeasingEnabled and Collection.LeasingPolicy;
  add Collection.LeasingPolicyId UNIQUEIDENTIFIER NULL with FK to the new
  LeasingPolicy(Id) ON DELETE SET NULL.
- New LeasingPolicy table (org-scoped, unique Name per org) with CRUD
  stored procedures under src/Sql/dbo/PrivilegedAccessManagement/.
- EF: new LeasingPolicy entity + DbSet, explicit OnModelCreating
  configuration for SET NULL behavior and the (OrganizationId, Name)
  unique index. Generated migrations for Postgres, MySQL, and SQLite.
- MSSQL migration 2026-05-21_00_AddLeasingPolicy.sql (renamed from the
  previous AddCollectionLeasing) creates the table + sprocs, drops the
  prior inline columns, adds the FK column, and CREATE OR ALTERs the
  affected Collection_* sprocs to forward @LeasingPolicyId.
- Collection request/response models now expose Guid? LeasingPolicyId.
- Create/UpdateCollectionCommand reverted to pre-leasing shape; policy
  validation moves to upcoming LeasingPolicy CRUD commands.

The existing LeasingPolicy DTOs and LeasingPolicyValidator survive for
the new policy CRUD pipeline (commands, controller, DI wiring to follow).
Wires the HTTP surface for managing reusable PAM leasing policies — the
backing schema, entities, and migrations from the prior commit are now
reachable via:

- GET    /organizations/{orgId}/leasing-policies          (org member)
- GET    /organizations/{orgId}/leasing-policies/{id}     (org member)
- POST   /organizations/{orgId}/leasing-policies          (Owner/Admin)
- PUT    /organizations/{orgId}/leasing-policies/{id}     (Owner/Admin)
- DELETE /organizations/{orgId}/leasing-policies/{id}     (Owner/Admin)

All endpoints are gated by [RequireFeature(FeatureFlagKeys.Pam)] and
return 404 on auth failure (matches PoliciesController convention so
resource existence isn't leaked).

- Repository: ILeasingPolicyRepository extends IRepository<T, Guid>;
  base picks up the LeasingPolicy_Create/_Update/_ReadById/_DeleteById
  sprocs from the migration, custom GetManyByOrganizationIdAsync added
  for both Dapper and EF.
- Commands: CreateLeasingPolicyCommand and UpdateLeasingPolicyCommand
  validate the JSON via ILeasingPolicyValidator, set/refresh
  CreationDate/RevisionDate, and check name uniqueness within the org.
  DeleteLeasingPolicyCommand performs the cross-org check then calls
  DeleteAsync (FK ON DELETE SET NULL handles detaching referencing
  collections at the DB level).
- API: LeasingPoliciesController plus request/response models under
  src/Api/PrivilegedAccessManagement/. Request accepts `policy` as
  structured JSON; response surfaces it as a parsed JsonElement.
- DI: AddLeasingPolicyCommands() in OrganizationServiceCollectionExtensions
  registers the validator (singleton) and three commands (scoped). The
  repository is registered alongside other singletons in the Dapper and
  EF service-collection extensions.
- Tests: 11 new command tests (4 Create / 4 Update / 3 Delete) covering
  happy paths, cross-org NotFound, validator failures, and duplicate
  names.
Renames the PAM leasing-policy concept to access rule across the codebase
so the persisted entity and the JSON DSL have distinct names:

- Entity LeasingPolicy → AccessRule (table, Collection.AccessRuleId FK,
  repos, commands, controller, request/response models). Controller route
  is now /organizations/{orgId}/access-rules.
- JSON DSL base LeasingPolicy → Rule, subclasses *Policy → *Rule, namespace
  Models/Policies → Models/Rules.
- Validator LeasingPolicyValidator → AccessRuleValidator and validates the
  AccessRule.Rule column (renamed from Policy).
- DB column [Policy] → [Rule] on AccessRule; sproc params @Policy@rule.
- MSSQL migration renamed to 2026-05-21_00_AddAccessRule.sql; EF migrations
  regenerated as AddAccessRule. The legacy LeasingEnabled/LeasingPolicy
  inline-column cleanup is preserved for dev DBs that ran the earlier
  iteration.
Complete the server side of the PAM Approver Inbox: the four HTTP
endpoints the web client calls plus the RefreshApproverInbox push.

- GET  /leasing/inbox/requests  — pending queue for collections the
  caller can Manage
- GET  /leasing/inbox/history   — resolved queue (90-day window)
- POST /leasing/requests/{id}/decision — approve/deny a pending request
- POST /leasing/leases/{id}/revoke     — revoke an active lease

Inbox queries and the decision/revoke commands authorize via a reusable
Manage-on-collection predicate (IApproverCollectionAccessQuery); list
endpoints filter by the manageable set, decision/revoke check a single
collection. The decision endpoint records a human LeaseDecision and the
revoke endpoint persists its reason as a LeaseDecision (no Lease schema
change). State conflicts return 409; self-approval is blocked as 400
(Bitwarden clients treat 403 as a forced logout).

Each inbox-affecting change (new pending request, decision, revoke)
pushes RefreshApproverInbox (PushType 28) to every user who can Manage
the collection, resolved via a new ICollectionRepository
.GetManagingUserIdsAsync (Dapper + EF parity). Lease repositories
remain Dapper/MSSQL-only.

Adds unit tests (commands, queries, status mapper, predicate service,
notifier, controller) and SqlServer integration tests for the new
repository methods.
AccessRule (Conditions) -> AccessRequest -> AccessDecision (Verdict) ->
AccessLease, with the AccessCondition tree replacing the polymorphic Rule
model and the engine renamed to AccessRuleEngine returning AccessEvaluation.
Requester/approver replace grantee/resolver; ticket/redeem/policy vocabulary
removed. Tables, sprocs, and the branch migrations are renamed in place, so
dev databases must be recreated. Routes and controller names are unchanged.
Approving an access request now records only the verdict; the lease that
authorizes access is minted when the requester activates the approved
request (POST leasing/requests/{id}/activate), within its window. The
automatic path still mints instantly at submit. Activation is idempotent
while the produced lease is live, race-safe via a guarded insert plus a
unique index on AccessLease(AccessRequestId), and the state endpoint now
surfaces the approved-but-not-started request. The caller-scoped list
endpoints also wrap in ListResponseModel so clients can parse them.
The inbox read procedures returned only the produced lease's id, never its status, so a lease that had ended (revoked/expired) still looked active to the client and offered a Revoke the server then rejected with 409.

Select [Status] alongside [Id] in the pending/history inbox procedures and carry it through AccessRequestDetails to the response as ProducedLeaseStatus (active|expired|revoked).
Add SingleActiveLease to AccessRule (entity, API request/response, AccessRule_Create/_Update, migration) and enforce it when a lease is minted. Scope is per-cipher with union/OR gating: the singleton binds only when every collection a member reaches the cipher through is governed by a single-active-lease rule; any ungated or non-singleton path is an escape that leaves them unconstrained. The mint procs take @EnforceSingleActiveLease and serialize same-cipher activations under a UPDLOCK/HOLDLOCK range lock inside an explicit transaction; the activate and auto-approve paths surface contention as a retryable conflict.
The automatic (no-approval) path minted an active lease at submit, so the
server granted access the moment the client posted the request on retrieval
— never an explicit user choice. Approval was already deferred for the human
path; this brings the automatic path in line.

Auto-approval now records only the already-Approved AccessRequest and its
automatic AccessDecision (no lease) via the new AccessRequest_CreateAutoApproved
proc; the requester explicitly activates the approved request to mint the lease
(ActivateAccessRequestCommand), exactly like the human path after approval, and
the state endpoint surfaces it as the startable ApprovedRequest. The per-cipher
single-active-lease guard now runs only at activation, the one remaining mint
site, so it drops from the submit path. AccessLease_CreateAutoApproved is
dropped; the submit result carries the approved request rather than a lease.
The access-rule dialog sends defaultLeaseDurationSeconds and maxLeaseDurationSeconds, and the client reads them back from the rule response, but the server had no fields to receive them: AccessRuleRequestModel ignored them, the AccessRule entity had no columns, and the response never returned them. The values were silently dropped on every save, so reopening a rule reset both to defaults.

Add DefaultLeaseDurationSeconds and MaxLeaseDurationSeconds (nullable int seconds) to AccessRule end to end: entity, API request/response, AccessRule_Create/_Update procs, table, the field-by-field rebuild in UpdateAccessRuleCommand, and AccessRuleDetails.From, plus a migration. Null default duration means the backend default; null max means no per-rule cap. Matches the SingleActiveLease footprint; EF migrations stay deferred for the POC.
AccessRule gains AllowsExtensions + MaxExtensions, threaded through the
entity, SSDT table/procs, API request/response models, and create/update
validation. Extensions are always auto-approved (never routed to an
approver): a new POST /leasing/requests/extension runs
RequestLeaseExtensionCommand, which validates the active lease, the rule
opt-in, the duration, a required justification, and the per-lease cap,
then atomically records an approved extension request and pushes the parent
lease's NotAfter out in place (AccessRequest_CreateApprovedExtension, under
a per-lease lock) without minting a new lease.

Also surface ExtensionsAllowed/ExtensionsRemaining on the cipher
access-state snapshot, and exclude extension requests from the
startable-approved read so an applied extension never shows as activatable.
Per updated product direction, a lease may now be extended exactly once,
and the admin caps the length of that extension instead of a count. The
AccessRule setting MaxExtensions (a count) becomes MaxExtensionDurationSeconds
(the longest a single extension may run); the member picks any duration up to
that maximum. RequestLeaseExtensionCommand caps the requested duration at the
rule's MaxExtensionDurationSeconds and rejects a second extension (the outcome
MaxExtensionsReached becomes AlreadyExtended; AccessRequest_CreateApprovedExtension
drops its @MaxExtensions parameter and guards on EXISTS instead of a count).

The cipher access-state snapshot now reports ExtensionsAllowed (the rule opts
in and the lease has not been extended yet) plus MaxExtensionDurationSeconds so
the client can cap its duration picker.

Migration 2026-06-15_00 drops MaxExtensions, adds MaxExtensionDurationSeconds,
and recreates the affected procs, leaving 2026-06-12_01/02 intact so existing
dev databases roll forward without a reset.
A rule with no conditions is a deliberate way to route a collection's access
through the PAM flow purely for audit logging. The engine and governing-rule
resolver already treat an empty all_of as vacuously satisfied (auto-allow, no
human approval), but AccessRuleValidator rejected it outright, blocking the
create/update path.

Permit an empty all_of in the validator (depth and max-children bounds are
unchanged); the resolver's fail-closed-on-malformed behavior is untouched.
Documents the semantics on AllOfCondition and adds validator, engine, and
resolver test coverage for the conditionless case.
AccessRule.Conditions was a polymorphic AccessCondition tree rooted at an all_of node. Replace it with a flat, implicitly-ANDed array of leaf conditions, stored and serialized as a bare JSON array. Remove AllOfCondition; the engine, validator, resolver, and GoverningRule now operate over IReadOnlyList<AccessCondition>. An empty list is vacuously allowed.

Unshipped PoC, so a clean break with no data migration. A future grouping condition kind can be reintroduced within the array, so a bare array does not preclude trees later.
abergs and others added 24 commits June 15, 2026 20:17
Add the UsePam organization ability across the stack, modeled on
UseInviteLinks/UseMyItems:

- Entity + OrganizationAbility + profile/provider detail models
- Licensing: claim emission and claims-based VerifyData using the
  conditional HasClaim check (PM-33980) so pre-existing license files
  still validate
- API response models and an Admin portal toggle
- MSSQL schema + migration; EF migrations for Postgres/MySQL/SQLite

Defaults off for all organizations. Plan/pricing wiring is deliberately
deferred until a PAM plan tier exists, so the Admin toggle is currently
the only way to enable it.
GoverningRuleResolver returned the first human-approval rule it found
("most restrictive wins") — the inverse of pam.allium, which resolves the
governing rule by union/OR: an automatic grant is favoured over one that
needs human approval, which is favoured over a denial. A member with a
valid auto-grant path was needlessly routed to an approver.

Resolve by evaluating each candidate rule through AccessRuleEngine and
returning the best (lowest) AccessEvaluationOutcome (Allow < RequiresApproval
< Deny), reusing the same engine the downstream decision uses. This also
honours the spec's rule that a failing automatic rule must not pre-empt a
path that needs approval but would grant.

ResolveAsync now takes AccessSignals (caller IP + timestamp); the five
callers build and pass them (three gained ICurrentContext, via the new
AccessSignals.From factory). As a side effect AccessPreCheckQuery is now
condition-aware instead of keying only on RequiresHumanApproval.

Adds multi-collection precedence tests to GoverningRuleResolverTests.
AccessDecision is 1-to-many with AccessRequest, so the details contract surfaces a decisions[] log (one element per decision, human or automatic) instead of flat approver fields. Resolved reads return the decisions as a second result set the repository groups onto AccessRequestDetails.Decisions; each element is {deciderKind,id,name,email,comment,verdict,decidedAt}.
…ta base project

Introduce src/Data as the new base of the dependency graph (no project
references). Relocate ITableObject<T> and IRepository<T,TId> there, and move
the sequential comb-guid generation into a new CombGuid helper.

Namespaces are preserved (Bit.Core.Entities, Bit.Core.Repositories,
Bit.Core.Utilities) so the ~1000 existing consumers are untouched;
CoreHelpers.GenerateComb now delegates to CombGuid. Core gains its first
project reference (-> Data).

This is a self-contained, feature-agnostic refactor intended to be
cherry-picked onto main ahead of dependent work.
Mechanical namespace rename ahead of extracting PAM into its own libraries.
Files stay physically in place for now; only namespaces and using directives
change. Two non-token fixes: CipherLeaseGate gains 'using Bit.Core;' (it relied
on implicit Bit.Core parent-namespace resolution for FeatureFlagKeys, which the
new Bit.Pam.* namespace no longer provides), and the EF AccessRule model's
unprefixed Core.Pam.* references are fully qualified to Bit.Pam.*.
Create src/Pam.Domain (references only Data, never Core) and move the pure
PAM domain into it: entities, enums, models/conditions, the rule engine,
repository interfaces, and the pure command/query/service interfaces and
query implementations. Core, Infrastructure.Dapper, Infrastructure.EntityFramework,
and Api now reference Pam.Domain.

To keep the lower lib Core-free:
- entities use CombGuid.Generate() (Data) instead of CoreHelpers.GenerateComb();
- AccessSignals.From now takes the caller IP string instead of ICurrentContext,
  so the record stays pure; its callers pass currentContext.IpAddress.

Core-coupled command/query/service implementations and the Vault<->PAM gate
remain in Core for now (moved to the upper Pam lib in a follow-up).
Create src/Pam (references Core + Pam.Domain) for the implementations that
genuinely depend on Core: the command implementations, the Core-coupled query
implementations (and the Vault-typed IGetLeasedCipherQuery interface), and the
service implementations.

The Vault<->PAM gate (ICipherLeaseGate + CipherLeaseGate) stays in Core, since
Core's CipherService consumes it and FullCipherAccess is minted via internal
factories. It only needs PAM types from the lower Pam.Domain lib.

DI: AddPamServices moves to Pam's PamServiceCollectionExtensions and is invoked
from the SharedWeb composition root, right after AddOrganizationServices, so
every host that registers organization services also registers PAM. Core no
longer references any PAM implementation.

Test projects (Core.Test, Api.Test, Infrastructure.IntegrationTest) reference
the new projects. Full solution builds; PAM unit tests pass.
Move all PAM business logic into a new commercial project
bitwarden_license/src/Commercial.Pam (namespace Bit.Commercial.Pam): the
command/query/service implementations, the rule engine, and the real
CipherLeaseGate. The AGPL Pam.Domain keeps the data models, enums, and all
interfaces; Core keeps the ICipherLeaseGate interface.

Open-source builds:
- A NoopCipherLeaseGate (Core) is the always-unrestricted ICipherLeaseGate
  fallback, registered broadly via TryAddScoped in SharedWeb.AddBaseServices;
  the real gate is registered (last-wins) by AddCommercialPamServices in Api's
  non-OSS Startup branch.
- The PAM API controllers (src/Api/Pam) are excluded from OSS via
  <Compile Remove> + a conditional Commercial.Pam reference.

Core exposes its internal FullCipherAccess mints to Commercial.Pam via
InternalsVisibleTo. PAM repositories, EF model, DbContext config, and mail
stay AGPL (data access for the open-source data models).

src/Pam is removed; PAM implementation + controller tests move to
bitwarden_license/test/Commercial.Pam.Test; pure-domain and CipherService
tests stay in Core.Test.

Verified: full commercial solution build; OSS builds (Api/Admin/Identity);
Commercial.Pam.Test (247) and Core.Test PAM/CipherService (107) pass.
Keep only the data-layer contract the OSS persistence/Core layer binds to in
Pam.Domain (the 4 entities, 3 repository interfaces, the AccessRequestDetails/
AccessRuleDetails/AccessRequestDecision models, and the 6 contract enums), and
move everything the OSS build never compiles into Commercial.Pam, re-namespaced
to Bit.Commercial.Pam.*: the engine, command/query interfaces, service
interfaces, the conditions, the submission/result models, and the
AccessApprovalMode/AccessWeekday enums.

The three enum wire-format helpers (Access{DeciderKind,LeaseStatus,RequestStatus}Names)
are used only by the Api response models, so they move to Api/Pam/Models/Response
under Bit.Api.Pam.Models.Response instead.
@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
E Reliability Rating on New Code (required ≥ D)

See analysis details on SonarQube Cloud

Catch issues before they fail your Quality Gate with our IDE extension SonarQube for IDE

Hinton added 2 commits June 22, 2026 18:28
# Conflicts:
#	src/Core/Vault/Services/Implementations/CipherService.cs
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