Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
1ad313b
feat(pam): withhold gated cipher secrets behind a type-checked witness
Hinton Jul 31, 2026
11bf643
style: apply dotnet format
Hinton Aug 3, 2026
dab90e8
refactor(pam): emit partial cipher data as a camelCase envelope
Hinton Aug 7, 2026
aef7622
refactor(pam): namespace the Core-side leasing gate under Bit.Core
Hinton Aug 14, 2026
53aad21
fix(pam): register the OSS leasing gate with AddScoped
Hinton Aug 14, 2026
342ac95
refactor(pam): make the partial and full cipher shapes distinct types
Hinton Aug 14, 2026
86fee45
refactor(sync): fold the two cipher filters into one
Hinton Aug 14, 2026
38e4a56
chore(sync): use the SDK feature service
Hinton Aug 14, 2026
a623ee2
refactor(pam): name the OSS gate for what it does
Hinton Aug 14, 2026
da37d38
docs(pam): state the leasing gate's contract and where decisions live
Hinton Aug 14, 2026
cac0497
chore: drop the unused feature service from the export controller
Hinton Aug 14, 2026
640824d
docs(pam): drop the remark on UnrestrictedCipherLeaseGate
Hinton Aug 14, 2026
46c4de4
refactor(pam): make PartialCipherData the shape it describes
Hinton Aug 14, 2026
588cf3c
refactor(pam): filter the export in the controller, not the response …
Hinton Aug 14, 2026
2724685
refactor(pam): choose the cipher response shape through one factory
Hinton Aug 14, 2026
bbc9aeb
refactor(pam): choose the cipher response shape through one factory
Hinton Aug 14, 2026
24e0e41
wip(pam): materialize POC lease/access-request blobs verbatim
patriksvensson Aug 17, 2026
9e05561
feat(pam): implement lease, access-request, and cipher-lease endpoint…
patriksvensson Aug 17, 2026
037adc4
Migrate the commercial CipherLeaseGate read path so PAM-governed ciph…
abergs Aug 17, 2026
93d89e4
Add PAM approver-inbox and access-request push notifications
abergs Aug 17, 2026
c086bcb
Gate mutations of leased ciphers behind a valid active lease
patriksvensson Aug 18, 2026
d06d9fa
Add the PAM access-audit event store
patriksvensson Aug 18, 2026
c24e862
Record and expose the PAM access-audit trail
patriksvensson Aug 18, 2026
1dc37e0
Record rule administration in the PAM audit trail
patriksvensson Aug 18, 2026
1f927dc
Enforce an access rule's lease duration bounds when requesting access
patriksvensson Aug 19, 2026
d785413
Let a Bitwarden validation problem carry a status other than 400
Hinton Aug 20, 2026
8d44cb9
Give every PAM failure a stable, machine-readable code
Hinton Aug 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,7 @@ public static RouteGroupBuilder MapAccessRequestEndpoints(this RouteGroupBuilder
.WithName("Pam_AccessRequests_Activate");

group.MapPost("{id:guid}/revoke",
async (Guid id, AccessRequestEndpointsHandler handler, ClaimsPrincipal user) =>
{
await handler.Revoke(user, id);
return TypedResults.NoContent();
})
(Guid id, AccessRequestEndpointsHandler handler, ClaimsPrincipal user) => handler.Revoke(user, id))
.WithName("Pam_AccessRequests_Revoke");

return group;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,7 @@ public static RouteGroupBuilder MapAccessRuleEndpoints(this RouteGroupBuilder gr
.WithName("Pam_AccessRules_Put")
.RequireAuthorization(new AuthorizeAttribute<ManageAccessRulesRequirement>());

group.MapDelete("{id:guid}",
async (Guid orgId, Guid id, AccessRuleEndpointsHandler handler) =>
{
await handler.Delete(orgId, id);
return TypedResults.NoContent();
})
group.MapDelete("{id:guid}", (Guid orgId, Guid id, AccessRuleEndpointsHandler handler) => handler.Delete(orgId, id))
.WithName("Pam_AccessRules_Delete")
.RequireAuthorization(new AuthorizeAttribute<ManageAccessRulesRequirement>());

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
ο»Ώusing Bit.Services.Pam.Api.Endpoints.Handlers;

namespace Bit.Services.Pam.Api.Endpoints;

/// <summary>
/// The <c>organizations/{orgId}/audit</c> resource: the org-wide governance access-audit trail, authorized by the
/// AccessEventLogs permission. A read-only projection of existing PAM state β€” no actions.
/// </summary>
internal static class AuditEndpoints
{
public static RouteGroupBuilder MapAuditEndpoints(this RouteGroupBuilder group)
{
group.WithTags("Audit");

group.MapGet("", (AuditEndpointsHandler handler, Guid orgId) => handler.GetTrail(orgId))
.WithName("Pam_Audit_GetTrail");

return group;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,21 @@ namespace Bit.Services.Pam.Api.Endpoints.Filters;
/// (including <see cref="IValidatableObject"/>) over the request-model arguments and, on failure, short-circuits
/// with Bitwarden's internal <see cref="ErrorResponseModel"/> 400 β€” the same body the controllers produced.
/// </summary>
/// <remarks>
/// <para>
/// Deliberately uncoded, unlike the failures a handler returns through <c>PamErrorResult</c>. A stable code earns
/// its place when a client acts on that failure differently β€” reconcile, mark a control, offer another form β€” and a
/// request that never bound cleanly is not one of those: there is nothing to do but report a malformed request.
/// Coding these would also give a client two vocabularies for one user-facing condition, since the attributes here
/// overlap the domain errors in <c>Bit.Services.Pam.Errors</c> that name the same failures more precisely.
/// </para>
/// <para>
/// The built-in .NET 10 <c>AddValidation()</c> could replace the walk below, but not the response: it keys entries
/// by the CLR property name rather than the serialized one, and answers with <c>HttpValidationProblemDetails</c>
/// rather than this body. Note also that its errors are <c>IDictionary&lt;string, string[]&gt;</c>, so it could not
/// carry codes here even if we later decided we wanted them.
/// </para>
/// </remarks>
public class PamValidationEndpointFilter : IEndpointFilter
{
private const string RequestModelNamespace = "Bit.Services.Pam.Api.Models.Request";
Expand Down
Original file line number Diff line number Diff line change
@@ -1,39 +1,77 @@
ο»Ώusing System.Security.Claims;
using Bit.Core.Services;
using Bit.HttpExtensions;
using Bit.Services.Pam.Api.Models.Request;
using Bit.Services.Pam.Api.Models.Response;
using Bit.Services.Pam.OrganizationFeatures.Commands.Interfaces;
using Bit.Services.Pam.OrganizationFeatures.Queries.Interfaces;
using Microsoft.AspNetCore.Http.HttpResults;

namespace Bit.Services.Pam.Api.Endpoints.Handlers;

/// <summary>
/// Handler for the <c>access-requests</c> resource. The Minimal API endpoints (see <c>AccessRequestEndpoints</c>)
/// resolve this handler from DI.
/// </summary>
/// <remarks>
/// Scaffold only: the method signatures define the wire contract (request/response models, status codes) that the
/// generated OpenAPI spec and client bindings are built from. The bodies are intentionally unimplemented β€” the
/// behavior lands with the rest of the PAM feature.
/// </remarks>
public class AccessRequestEndpointsHandler
public class AccessRequestEndpointsHandler(
IUserService userService,
IListInboxRequestsQuery listInboxRequestsQuery,
IListInboxHistoryQuery listInboxHistoryQuery,
IDecideAccessRequestCommand decideAccessRequestCommand,
IListMyAccessRequestsQuery listMyAccessRequestsQuery,
IActivateAccessRequestCommand activateAccessRequestCommand,
ICancelAccessRequestCommand cancelAccessRequestCommand,
IGetAccessRequestDetailsQuery getAccessRequestDetailsQuery)
{
public Task<ListResponseModel<AccessRequestDetailsResponseModel>> GetInbox(ClaimsPrincipal user)
=> throw new NotImplementedException();
public async Task<ListResponseModel<AccessRequestDetailsResponseModel>> GetInbox(ClaimsPrincipal user)
{
var userId = userService.GetProperUserId(user)!.Value;
var requests = await listInboxRequestsQuery.GetPendingAsync(userId);
return new ListResponseModel<AccessRequestDetailsResponseModel>(
requests.Select(r => new AccessRequestDetailsResponseModel(r)));
}

public Task<ListResponseModel<AccessRequestDetailsResponseModel>> GetHistory(ClaimsPrincipal user)
=> throw new NotImplementedException();
public async Task<ListResponseModel<AccessRequestDetailsResponseModel>> GetHistory(ClaimsPrincipal user)
{
var userId = userService.GetProperUserId(user)!.Value;
var history = await listInboxHistoryQuery.GetHistoryAsync(userId);
return new ListResponseModel<AccessRequestDetailsResponseModel>(
history.Select(r => new AccessRequestDetailsResponseModel(r)));
}

public Task<ListResponseModel<AccessRequestDetailsResponseModel>> GetMine(ClaimsPrincipal user)
=> throw new NotImplementedException();
public async Task<ListResponseModel<AccessRequestDetailsResponseModel>> GetMine(ClaimsPrincipal user)
{
var userId = userService.GetProperUserId(user)!.Value;
var requests = await listMyAccessRequestsQuery.GetMineAsync(userId);
return new ListResponseModel<AccessRequestDetailsResponseModel>(
requests.Select(r => new AccessRequestDetailsResponseModel(r)));
}

public Task<AccessRequestDetailsResponseModel> GetDetails(ClaimsPrincipal user, Guid id)
=> throw new NotImplementedException();
public async Task<AccessRequestDetailsResponseModel> GetDetails(ClaimsPrincipal user, Guid id)
{
var userId = userService.GetProperUserId(user)!.Value;
var details = await getAccessRequestDetailsQuery.GetDetailsAsync(userId, id);
return new AccessRequestDetailsResponseModel(details);
}

public Task<AccessRequestDetailsResponseModel> Decide(ClaimsPrincipal user, Guid id, AccessDecisionRequestModel model)
=> throw new NotImplementedException();
public async Task<Results<Ok<AccessRequestDetailsResponseModel>, PamErrorResult>> Decide(
ClaimsPrincipal user, Guid id, AccessDecisionRequestModel model)
{
var userId = userService.GetProperUserId(user)!.Value;
var result = await decideAccessRequestCommand.DecideAsync(userId, id, model.ToSubmission());
return PamResults.Ok(result, decided => new AccessRequestDetailsResponseModel(decided));
}

public Task<AccessLeaseResponseModel> Activate(ClaimsPrincipal user, Guid id)
=> throw new NotImplementedException();
public async Task<Results<Ok<AccessLeaseResponseModel>, PamErrorResult>> Activate(ClaimsPrincipal user, Guid id)
{
var userId = userService.GetProperUserId(user)!.Value;
var result = await activateAccessRequestCommand.ActivateAsync(userId, id);
return PamResults.Ok(result, lease => new AccessLeaseResponseModel(lease));
}

public Task Revoke(ClaimsPrincipal user, Guid id)
=> throw new NotImplementedException();
public async Task<Results<NoContent, PamErrorResult>> Revoke(ClaimsPrincipal user, Guid id)
{
var userId = userService.GetProperUserId(user)!.Value;
return PamResults.NoContent(await cancelAccessRequestCommand.CancelAsync(userId, id));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using Bit.Services.Pam.Api.Models.Request;
using Bit.Services.Pam.Api.Models.Response;
using Bit.Services.Pam.OrganizationFeatures.Commands.Interfaces;
using Microsoft.AspNetCore.Http.HttpResults;

namespace Bit.Services.Pam.Api.Endpoints.Handlers;

Expand Down Expand Up @@ -42,24 +43,24 @@ public async Task<AccessRuleResponseModel> Get(Guid orgId, Guid id)
return new AccessRuleResponseModel(rule);
}

public async Task<AccessRuleResponseModel> Post(Guid orgId, AccessRuleRequestModel model)
public async Task<Results<Ok<AccessRuleResponseModel>, PamErrorResult>> Post(Guid orgId, AccessRuleRequestModel model)
{
var toCreate = model.ToAccessRule(orgId);
toCreate.LastEditedBy = currentContext.UserId;
var rule = await createCommand.CreateAsync(toCreate, model.Collections);
return new AccessRuleResponseModel(rule);
var result = await createCommand.CreateAsync(toCreate, model.Collections);
return PamResults.Ok(result, rule => new AccessRuleResponseModel(rule));
}

public async Task<AccessRuleResponseModel> Put(Guid orgId, Guid id, AccessRuleRequestModel model)
public async Task<Results<Ok<AccessRuleResponseModel>, PamErrorResult>> Put(Guid orgId, Guid id, AccessRuleRequestModel model)
{
var toUpdate = model.ToAccessRule(orgId);
toUpdate.LastEditedBy = currentContext.UserId;
var rule = await updateCommand.UpdateAsync(orgId, id, toUpdate, model.Collections);
return new AccessRuleResponseModel(rule);
var result = await updateCommand.UpdateAsync(orgId, id, toUpdate, model.Collections);
return PamResults.Ok(result, rule => new AccessRuleResponseModel(rule));
}

public async Task Delete(Guid orgId, Guid id)
public async Task<Results<NoContent, PamErrorResult>> Delete(Guid orgId, Guid id)
{
await deleteCommand.DeleteAsync(orgId, id);
return PamResults.NoContent(await deleteCommand.DeleteAsync(orgId, id, currentContext.UserId));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
ο»Ώusing Bit.Core.Context;
using Bit.Core.Exceptions;
using Bit.HttpExtensions;
using Bit.Services.Pam.Api.Models.Response;
using Bit.Services.Pam.OrganizationFeatures.Queries.Interfaces;

namespace Bit.Services.Pam.Api.Endpoints.Handlers;

/// <summary>
/// Handler for the <c>organizations/{orgId}/audit</c> resource: the org-wide access-audit trail, read from the
/// dedicated append-only audit store β€” read-only, no actions. Authorized by the AccessEventLogs permission: anyone who
/// can view the organization's event logs sees the full PAM audit trail, regardless of collection management.
/// </summary>
public class AuditEndpointsHandler(
ICurrentContext currentContext,
IListAccessAuditTrailQuery listAccessAuditTrailQuery)
{
public async Task<ListResponseModel<AccessAuditEventResponseModel>> GetTrail(Guid orgId)
{
if (!await currentContext.AccessEventLogs(orgId))
{
throw new NotFoundException();
}

var events = await listAccessAuditTrailQuery.GetTrailAsync(orgId);
return new ListResponseModel<AccessAuditEventResponseModel>(
events.Select(e => new AccessAuditEventResponseModel(e)));
}
}
Original file line number Diff line number Diff line change
@@ -1,28 +1,43 @@
ο»Ώusing System.Security.Claims;
using Bit.Core.Services;
using Bit.Services.Pam.Api.Models.Request;
using Bit.Services.Pam.Api.Models.Response;
using Bit.Services.Pam.OrganizationFeatures.Commands.Interfaces;
using Bit.Services.Pam.OrganizationFeatures.Queries.Interfaces;
using Microsoft.AspNetCore.Http.HttpResults;

namespace Bit.Services.Pam.Api.Endpoints.Handlers;

/// <summary>
/// Handler for the <c>leases/ciphers/{id}</c> resource: the per-cipher leasing entry points (pre-check, state,
/// submit). The Minimal API endpoints (see <c>CipherLeaseEndpoints</c>) resolve this handler from DI. The deprecated
/// full-cipher read-back (<c>GET …/cipher</c>) is hosted separately, by a small MVC controller in the Api project,
/// since it depends on the Api Vault response models.
/// submit). The deprecated full-cipher read-back (<c>GET …/cipher</c>) is hosted by a small MVC controller
/// in the Api project instead, since it depends on the Api Vault response models.
/// </summary>
/// <remarks>
/// Scaffold only: the method signatures define the wire contract (request/response models, status codes) that the
/// generated OpenAPI spec and client bindings are built from. The bodies are intentionally unimplemented β€” the
/// behavior lands with the rest of the PAM feature.
/// </remarks>
public class CipherLeaseEndpointsHandler
public class CipherLeaseEndpointsHandler(
IUserService userService,
IAccessPreCheckQuery preCheckQuery,
IGetCipherAccessStateQuery cipherAccessStateQuery,
ISubmitAccessRequestCommand submitAccessRequestCommand)
{
public Task<AccessPreCheckResponseModel> PreCheck(ClaimsPrincipal user, Guid id)
=> throw new NotImplementedException();
public async Task<AccessPreCheckResponseModel> PreCheck(ClaimsPrincipal user, Guid id)
{
var userId = userService.GetProperUserId(user)!.Value;
var result = await preCheckQuery.PreCheckAsync(userId, id);
return new AccessPreCheckResponseModel(id, result);
}

public Task<CipherAccessStateResponseModel> State(ClaimsPrincipal user, Guid id)
=> throw new NotImplementedException();
public async Task<CipherAccessStateResponseModel> State(ClaimsPrincipal user, Guid id)
{
var userId = userService.GetProperUserId(user)!.Value;
var result = await cipherAccessStateQuery.GetStateAsync(userId, id);
return new CipherAccessStateResponseModel(result);
}

public Task<AccessRequestResultResponseModel> Post(ClaimsPrincipal user, Guid id, AccessRequestCreateRequestModel model)
=> throw new NotImplementedException();
public async Task<Results<Ok<AccessRequestResultResponseModel>, PamErrorResult>> Post(
ClaimsPrincipal user, Guid id, AccessRequestCreateRequestModel model)
{
var userId = userService.GetProperUserId(user)!.Value;
var result = await submitAccessRequestCommand.SubmitAsync(userId, id, model.ToSubmission());
return PamResults.Ok(result, submitted => new AccessRequestResultResponseModel(submitted));
}
}
Original file line number Diff line number Diff line change
@@ -1,33 +1,62 @@
ο»Ώusing System.Security.Claims;
using Bit.Core.Services;
using Bit.HttpExtensions;
using Bit.Services.Pam.Api.Models.Request;
using Bit.Services.Pam.Api.Models.Response;
using Bit.Services.Pam.OrganizationFeatures.Commands.Interfaces;
using Bit.Services.Pam.OrganizationFeatures.Queries.Interfaces;
using Microsoft.AspNetCore.Http.HttpResults;

namespace Bit.Services.Pam.Api.Endpoints.Handlers;

/// <summary>
/// Handler for the <c>leases</c> resource. The Minimal API endpoints (see <c>LeaseEndpoints</c>) resolve this
/// handler from DI.
/// Handler for the <c>leases</c> resource. Holds the logic the <c>LeasesController</c> previously hosted; the
/// Minimal API endpoints (see <c>LeaseEndpoints</c>) are thin lambdas that resolve this handler from DI.
/// </summary>
/// <remarks>
/// Scaffold only: the method signatures define the wire contract (request/response models, status codes) that the
/// generated OpenAPI spec and client bindings are built from. The bodies are intentionally unimplemented β€” the
/// behavior lands with the rest of the PAM feature.
/// </remarks>
public class LeaseEndpointsHandler
public class LeaseEndpointsHandler(
IUserService userService,
IListActiveLeasesQuery listActiveLeasesQuery,
IListLeaseHistoryQuery listLeaseHistoryQuery,
IListMyActiveAccessLeasesQuery listMyActiveAccessLeasesQuery,
IRevokeAccessLeaseCommand revokeAccessLeaseCommand,
IRequestLeaseExtensionCommand requestLeaseExtensionCommand)
{
public Task<ListResponseModel<AccessLeaseResponseModel>> GetActive(ClaimsPrincipal user)
=> throw new NotImplementedException();
public async Task<ListResponseModel<AccessLeaseResponseModel>> GetActive(ClaimsPrincipal user)
{
var userId = userService.GetProperUserId(user)!.Value;
var leases = await listActiveLeasesQuery.GetActiveAsync(userId);
return new ListResponseModel<AccessLeaseResponseModel>(
leases.Select(l => new AccessLeaseResponseModel(l)));
}

public Task<ListResponseModel<AccessLeaseResponseModel>> GetHistory(ClaimsPrincipal user)
=> throw new NotImplementedException();
public async Task<ListResponseModel<AccessLeaseResponseModel>> GetHistory(ClaimsPrincipal user)
{
var userId = userService.GetProperUserId(user)!.Value;
var leases = await listLeaseHistoryQuery.GetHistoryAsync(userId);
return new ListResponseModel<AccessLeaseResponseModel>(
leases.Select(l => new AccessLeaseResponseModel(l)));
}

public Task<ListResponseModel<AccessLeaseResponseModel>> GetMine(ClaimsPrincipal user)
=> throw new NotImplementedException();
public async Task<ListResponseModel<AccessLeaseResponseModel>> GetMine(ClaimsPrincipal user)
{
var userId = userService.GetProperUserId(user)!.Value;
var leases = await listMyActiveAccessLeasesQuery.GetMineActiveAsync(userId);
return new ListResponseModel<AccessLeaseResponseModel>(
leases.Select(l => new AccessLeaseResponseModel(l)));
}

public Task Revoke(ClaimsPrincipal user, Guid id, AccessLeaseRevokeRequestModel model)
=> throw new NotImplementedException();
public async Task<Results<NoContent, PamErrorResult>> Revoke(
ClaimsPrincipal user, Guid id, AccessLeaseRevokeRequestModel model)
{
var userId = userService.GetProperUserId(user)!.Value;
return PamResults.NoContent(await revokeAccessLeaseCommand.RevokeAsync(userId, id, model.Reason));
}

public Task<AccessRequestDetailsResponseModel> Extend(ClaimsPrincipal user, Guid id, AccessLeaseExtensionRequestModel model)
=> throw new NotImplementedException();
public async Task<Results<Ok<AccessRequestDetailsResponseModel>, PamErrorResult>> Extend(
ClaimsPrincipal user, Guid id, AccessLeaseExtensionRequestModel model)
{
var userId = userService.GetProperUserId(user)!.Value;
var result = await requestLeaseExtensionCommand.ExtendAsync(userId, model.ToSubmission(id));
return PamResults.Ok(result, extension => new AccessRequestDetailsResponseModel(extension));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,8 @@ public static RouteGroupBuilder MapLeaseEndpoints(this RouteGroupBuilder group)
.WithName("Pam_Leases_GetMine");

group.MapPost("{id:guid}/revoke",
async (Guid id, AccessLeaseRevokeRequestModel model, LeaseEndpointsHandler handler, ClaimsPrincipal user) =>
{
await handler.Revoke(user, id, model);
return TypedResults.NoContent();
})
(Guid id, AccessLeaseRevokeRequestModel model, LeaseEndpointsHandler handler, ClaimsPrincipal user) =>
handler.Revoke(user, id, model))
.WithName("Pam_Leases_Revoke");

group.MapPost("{id:guid}/extend",
Expand Down
Loading
Loading