Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
56 commits
Select commit Hold shift + click to select a range
d718374
[PM-37751] Add Collection leasing config (enable + policy)
Hinton May 21, 2026
2fa4697
Replace inline leasing config with reusable LeasingPolicy entity
Hinton May 25, 2026
b71f8d6
Add LeasingPolicy CRUD endpoints
Hinton May 25, 2026
1c79bbd
Fix migrations
Hinton May 26, 2026
68f7f1f
Rename LeasingPolicy to AccessRule
Hinton May 26, 2026
e08c446
Merge branch 'main' of github.com:bitwarden/server into pam/collectio…
Hinton Jun 1, 2026
6c2a4be
Add collection support
Hinton Jun 2, 2026
04bb28e
Add sync support
Hinton Jun 2, 2026
994b836
Initial naive implementation of rule engine
patriksvensson May 29, 2026
dd340f5
Add lease request flow
Hinton Jun 4, 2026
47e8cd4
Add endpoint for getting a leased cipher
patriksvensson Jun 4, 2026
00a96e3
Add approver inbox endpoints
Hinton Jun 5, 2026
e5cd361
Enforce access rules during lease creation via AccessRuleEngine
patriksvensson Jun 5, 2026
27cfb5d
Update pre-check to validate if we have active lease
Hinton Jun 5, 2026
2c2a297
Add endpointns for pending and active leases
Hinton Jun 8, 2026
decdf8f
Enable PAM feature flag by default for dev/demo
abergs Jun 10, 2026
8011757
Unify PAM nomenclature around the Access* family
patriksvensson Jun 10, 2026
49962b5
Defer lease minting from approval to requester activation
patriksvensson Jun 10, 2026
bb38f86
Surface produced lease status in approver inbox
patriksvensson Jun 10, 2026
7601d7f
Enforce per-cipher single-active-lease in PAM leasing
abergs Jun 11, 2026
9fa13df
Defer automatic lease minting to requester activation
patriksvensson Jun 11, 2026
eea4813
Persist default and max lease durations on PAM access rules
abergs Jun 11, 2026
2482b37
Add endpoint to cancel a pending access request
abergs Jun 11, 2026
e3282e0
Persist AccessRule Enabled flag through create/update (MSSQL/Dapper)
abergs Jun 12, 2026
1bc89de
Serialize PAM API response timestamps as UTC
abergs Jun 15, 2026
94c7b5d
PAM: allow requester or approver to cancel non-activated access requests
abergs Jun 15, 2026
cf7ee9b
Add PAM lease extension with per-rule extension settings
patriksvensson Jun 15, 2026
390b856
Refine PAM lease extension: single extension + admin-set max length
patriksvensson Jun 15, 2026
38b88a2
PAM: allow a conditionless access rule (empty all_of)
patriksvensson Jun 15, 2026
4ea701e
PAM: flatten access rule conditions from a tree to a flat list
patriksvensson Jun 15, 2026
82c75c1
Restructure PAM leasing API to /access-requests and /leases (v2 routes)
abergs Jun 15, 2026
552afc3
Add governance lease list endpoints: GET /leases/active and /leases/h…
abergs Jun 15, 2026
e8a4878
Mark GET /ciphers/{id}/lease/cipher deprecated (scheduled for removal)
abergs Jun 15, 2026
e4c176d
simplify: post-refactor cleanup of PAM access-requests / leases
abergs Jun 15, 2026
a7c3903
Allow a lease holder to end (revoke) their own active lease
abergs Jun 15, 2026
456500d
PAM: push requester-scoped lease lifecycle notifications
abergs Jun 15, 2026
43415f8
simplify: collapse identical user-refresh push handlers in HubHelpers…
abergs Jun 16, 2026
fcc2198
Add email
Hinton Jun 16, 2026
6afa266
Organize endpoinnts by resource
Hinton Jun 16, 2026
40ba1a5
PAM: add UsePam organization capability
Hinton Jun 16, 2026
f5368bb
Convert to enums
Hinton Jun 16, 2026
5db2c9a
PAM: fix inverted GoverningRule precedence (least-restrictive wins)
patriksvensson Jun 16, 2026
914c412
PAM: resolve approver identity (name/email) in access-request reads
abergs Jun 16, 2026
d681676
PAM: expose the access-request decision log as a decisions[] array
abergs Jun 16, 2026
b85f8ce
PAM: flip AccessDecisionVerdict to deny=0, approve=1
abergs Jun 17, 2026
610d504
Add access checks to all endpoints
Hinton Jun 18, 2026
e131b02
Extract ITableObject, IRepository, and comb-guid generation into a Da…
Hinton Jun 18, 2026
2bb3290
PAM: rename Bit.Core.Pam.* namespaces to Bit.Pam.*
Hinton Jun 18, 2026
c6d36e1
PAM: extract the pure PAM domain into a Pam.Domain library below Core
Hinton Jun 18, 2026
3d57506
PAM: move Core-coupled PAM orchestration into the upper Pam library
Hinton Jun 18, 2026
44c38ac
PAM: license the implementation under the Bitwarden License
Hinton Jun 18, 2026
7d6f553
PAM: move the proprietary PAM domain into the Commercial.Pam library
Hinton Jun 18, 2026
dca402b
Change to use WebAPI
Hinton Jun 22, 2026
dc50c32
Move to minimal api
Hinton Jun 22, 2026
d882acc
Merge branch 'main' of github.com:bitwarden/server into pam/poc
Hinton Jun 22, 2026
2d130a1
Fix build
Hinton Jun 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
6 changes: 6 additions & 0 deletions bitwarden-server.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,15 @@
<Project Path="src/Api/Api.csproj" />
<Project Path="src/Billing/Billing.csproj" />
<Project Path="src/Core/Core.csproj" />
<Project Path="src/Data/Data.csproj" />
<Project Path="src/Events/Events.csproj" />
<Project Path="src/EventsProcessor/EventsProcessor.csproj" />
<Project Path="src/Icons/Icons.csproj" />
<Project Path="src/Identity/Identity.csproj" />
<Project Path="src/Infrastructure.Dapper/Infrastructure.Dapper.csproj" />
<Project Path="src/Infrastructure.EntityFramework/Infrastructure.EntityFramework.csproj" />
<Project Path="src/Notifications/Notifications.csproj" />
<Project Path="src/Pam.Domain/Pam.Domain.csproj" />
<Project Path="src/SharedWeb/SharedWeb.csproj" />
<Project Path="src/Sql/Sql.sqlproj">
<Build />
Expand All @@ -37,11 +39,15 @@
<BuildDependency Project="src/Core/Core.csproj" />
</Project>
<Project Path="bitwarden_license/src/Commercial.Infrastructure.EntityFramework/Commercial.Infrastructure.EntityFramework.csproj" />
<Project Path="bitwarden_license/src/Commercial.Pam/Commercial.Pam.csproj">
<BuildDependency Project="src/Core/Core.csproj" />
</Project>
<Project Path="bitwarden_license/src/Scim/Scim.csproj" />
<Project Path="bitwarden_license/src/Sso/Sso.csproj" />
</Folder>
<Folder Name="/test - Bitwarden License/">
<Project Path="bitwarden_license/test/Commercial.Core.Test/Commercial.Core.Test.csproj" />
<Project Path="bitwarden_license/test/Commercial.Pam.Test/Commercial.Pam.Test.csproj" />
<Project Path="bitwarden_license/test/Scim.IntegrationTest/Scim.IntegrationTest.csproj" />
<Project Path="bitwarden_license/test/Scim.Test/Scim.Test.csproj" />
<Project Path="bitwarden_license/test/Sso.IntegrationTest/Sso.IntegrationTest.csproj" />
Expand Down
11 changes: 11 additions & 0 deletions bitwarden_license/src/Commercial.Core/packages.lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -1159,6 +1159,7 @@
"BitPay.Light": "[1.0.1907, 1.0.1907]",
"Braintree": "[5.36.0, 5.36.0]",
"CsvHelper": "[33.1.0, 33.1.0]",
"Data": "[2026.6.1, )",
"DnsClient": "[1.8.0, 1.8.0]",
"Duende.IdentityServer": "[7.4.6, 7.4.6]",
"DuoUniversal": "[1.3.1, 1.3.1]",
Expand All @@ -1183,6 +1184,7 @@
"Newtonsoft.Json": "[13.0.3, 13.0.3]",
"OneOf": "[3.0.271, 3.0.271]",
"Otp.NET": "[1.4.0, 1.4.0]",
"Pam.Domain": "[2026.6.1, )",
"Quartz": "[3.15.1, 3.15.1]",
"Quartz.Extensions.DependencyInjection": "[3.15.1, 3.15.1]",
"Quartz.Extensions.Hosting": "[3.15.1, 3.15.1]",
Expand All @@ -1195,6 +1197,15 @@
"ZiggyCreatures.FusionCache.Backplane.StackExchangeRedis": "[2.0.2, 2.0.2]",
"ZiggyCreatures.FusionCache.Serialization.SystemTextJson": "[2.0.2, 2.0.2]"
}
},
"data": {
"type": "Project"
},
"pam.domain": {
"type": "Project",
"dependencies": {
"Data": "[2026.6.1, )"
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1315,6 +1315,7 @@
"BitPay.Light": "[1.0.1907, 1.0.1907]",
"Braintree": "[5.36.0, 5.36.0]",
"CsvHelper": "[33.1.0, 33.1.0]",
"Data": "[2026.6.1, )",
"DnsClient": "[1.8.0, 1.8.0]",
"Duende.IdentityServer": "[7.4.6, 7.4.6]",
"DuoUniversal": "[1.3.1, 1.3.1]",
Expand All @@ -1339,6 +1340,7 @@
"Newtonsoft.Json": "[13.0.3, 13.0.3]",
"OneOf": "[3.0.271, 3.0.271]",
"Otp.NET": "[1.4.0, 1.4.0]",
"Pam.Domain": "[2026.6.1, )",
"Quartz": "[3.15.1, 3.15.1]",
"Quartz.Extensions.DependencyInjection": "[3.15.1, 3.15.1]",
"Quartz.Extensions.Hosting": "[3.15.1, 3.15.1]",
Expand All @@ -1352,19 +1354,29 @@
"ZiggyCreatures.FusionCache.Serialization.SystemTextJson": "[2.0.2, 2.0.2]"
}
},
"data": {
"type": "Project"
},
"infrastructure.entityframework": {
"type": "Project",
"dependencies": {
"AutoMapper": "[14.0.0, 14.0.0]",
"Core": "[2026.6.0, )",
"Core": "[2026.6.1, )",
"Microsoft.EntityFrameworkCore.Relational": "[8.0.8, 8.0.8]",
"Microsoft.EntityFrameworkCore.SqlServer": "[8.0.8, 8.0.8]",
"Microsoft.EntityFrameworkCore.Sqlite": "[8.0.8, 8.0.8]",
"Npgsql.EntityFrameworkCore.PostgreSQL": "[8.0.4, 8.0.4]",
"Pam.Domain": "[2026.6.1, )",
"Pomelo.EntityFrameworkCore.MySql": "[8.0.2, 8.0.2]",
"linq2db": "[5.4.1, 5.4.1]",
"linq2db.EntityFrameworkCore": "[8.1.0, 8.1.0]"
}
},
"pam.domain": {
"type": "Project",
"dependencies": {
"Data": "[2026.6.1, )"
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
using System.Security.Claims;
using Bit.Commercial.Pam.Api.Endpoints.Handlers;
using Bit.Commercial.Pam.Api.Models.Request;

namespace Bit.Commercial.Pam.Api.Endpoints;

/// <summary>
/// The <c>access-requests</c> resource: lease requests through their lifecycle (the requester's own queue plus the
/// approver's queue and decision). Mirrors the routes the former <c>AccessRequestsController</c> served.
/// </summary>
internal static class AccessRequestEndpoints
{
public static RouteGroupBuilder MapAccessRequestEndpoints(this RouteGroupBuilder group)
{
group.MapGet("inbox", (AccessRequestEndpointsHandler handler, ClaimsPrincipal user) => handler.GetInbox(user))
.WithName("Pam_AccessRequests_GetInbox");

group.MapGet("history", (AccessRequestEndpointsHandler handler, ClaimsPrincipal user) => handler.GetHistory(user))
.WithName("Pam_AccessRequests_GetHistory");

group.MapGet("mine", (AccessRequestEndpointsHandler handler, ClaimsPrincipal user) => handler.GetMine(user))
.WithName("Pam_AccessRequests_GetMine");

group.MapPost("{id:guid}/decision",
(Guid id, AccessDecisionRequestModel model, AccessRequestEndpointsHandler handler, ClaimsPrincipal user) =>
handler.Decide(user, id, model))
.WithName("Pam_AccessRequests_Decide");

group.MapPost("{id:guid}/activate",
(Guid id, AccessRequestEndpointsHandler handler, ClaimsPrincipal user) => handler.Activate(user, id))
.WithName("Pam_AccessRequests_Activate");

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

return group;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
using Bit.Commercial.Pam.Api.Endpoints.Handlers;
using Bit.Commercial.Pam.Api.Models.Request;

namespace Bit.Commercial.Pam.Api.Endpoints;

/// <summary>
/// The <c>organizations/{orgId}/access-rules</c> resource: rule CRUD scoped to an organization. Mirrors the routes
/// the former <c>AccessRulesController</c> served. <c>orgId</c> is bound from the group's route prefix.
/// </summary>
internal static class AccessRuleEndpoints
{
public static RouteGroupBuilder MapAccessRuleEndpoints(this RouteGroupBuilder group)
{
group.MapGet("", (Guid orgId, AccessRuleEndpointsHandler handler) => handler.GetAll(orgId))
.WithName("Pam_AccessRules_GetAll");

group.MapGet("{id:guid}", (Guid orgId, Guid id, AccessRuleEndpointsHandler handler) => handler.Get(orgId, id))
.WithName("Pam_AccessRules_Get");

group.MapPost("", (Guid orgId, AccessRuleRequestModel model, AccessRuleEndpointsHandler handler) => handler.Post(orgId, model))
.WithName("Pam_AccessRules_Post");

group.MapPut("{id:guid}", (Guid orgId, Guid id, AccessRuleRequestModel model, AccessRuleEndpointsHandler handler) => handler.Put(orgId, id, model))
.WithName("Pam_AccessRules_Put");

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

return group;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
using System.Security.Claims;
using Bit.Commercial.Pam.Api.Endpoints.Handlers;
using Bit.Commercial.Pam.Api.Models.Request;

namespace Bit.Commercial.Pam.Api.Endpoints;

/// <summary>
/// The <c>ciphers/{id}/lease</c> resource: the per-cipher leasing entry points (pre-check, state, submit).
/// Mirrors the routes the former <c>CipherLeaseController</c> served. <c>id</c> is bound from the group's route
/// prefix. The deprecated <c>GET …/lease/cipher</c> read-back is hosted by a small MVC controller in the Api
/// project (it depends on the Api Vault response models).
/// </summary>
internal static class CipherLeaseEndpoints
{
public static RouteGroupBuilder MapCipherLeaseEndpoints(this RouteGroupBuilder group)
{
group.MapGet("pre-check", (Guid id, CipherLeaseEndpointsHandler handler, ClaimsPrincipal user) => handler.PreCheck(user, id))
.WithName("Pam_CipherLease_PreCheck");

group.MapGet("state", (Guid id, CipherLeaseEndpointsHandler handler, ClaimsPrincipal user) => handler.State(user, id))
.WithName("Pam_CipherLease_State");

group.MapPost("",
(Guid id, AccessRequestCreateRequestModel model, CipherLeaseEndpointsHandler handler, ClaimsPrincipal user) =>
handler.Post(user, id, model))
.WithName("Pam_CipherLease_Post");

return group;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
using Bit.Core.Exceptions;
using Bit.Core.Models.Api;
using Microsoft.IdentityModel.Tokens;

namespace Bit.Commercial.Pam.Api.Endpoints.Filters;

/// <summary>
/// Minimal API equivalent of the internal-API branch of <c>Bit.Api.Utilities.ExceptionHandlerFilterAttribute</c>.
/// Minimal API endpoints do not run MVC exception filters and <c>src/Api</c> has no exception-handling middleware,
/// so this filter translates thrown exceptions into Bitwarden's <see cref="ErrorResponseModel"/> with the same
/// status codes the controllers produced (e.g. <see cref="NotFoundException"/> → 404 "Resource not found.").
/// </summary>
public class PamExceptionHandlerEndpointFilter : IEndpointFilter
{
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
{
try
{
return await next(context);
}
catch (Exception exception)
{
return Handle(exception, context.HttpContext);
}
}

private static IResult Handle(Exception exception, HttpContext httpContext)
{
var message = "An error has occurred.";
int statusCode;
ErrorResponseModel? validationModel = null;

switch (exception)
{
case BadRequestException badRequestException:
statusCode = StatusCodes.Status400BadRequest;
if (badRequestException.ModelState != null)
{
validationModel = new ErrorResponseModel(badRequestException.ModelState);
}
else
{
message = badRequestException.Message;
}
break;
case NotSupportedException when !string.IsNullOrWhiteSpace(exception.Message):
message = exception.Message;
statusCode = StatusCodes.Status400BadRequest;
break;
case ApplicationException:
statusCode = StatusCodes.Status402PaymentRequired;
break;
case NotFoundException:
message = "Resource not found.";
statusCode = StatusCodes.Status404NotFound;
break;
case SecurityTokenValidationException:
message = "Invalid token.";
statusCode = StatusCodes.Status403Forbidden;
break;
case UnauthorizedAccessException:
message = "Unauthorized.";
statusCode = StatusCodes.Status401Unauthorized;
break;
case ConflictException:
message = exception.Message;
statusCode = StatusCodes.Status409Conflict;
break;
case AggregateException aggregateException:
statusCode = StatusCodes.Status400BadRequest;
validationModel = new ErrorResponseModel(message, aggregateException.InnerExceptions.Select(e => e.Message));
break;
default:
httpContext.RequestServices.GetRequiredService<ILogger<PamExceptionHandlerEndpointFilter>>()
.LogError(0, exception, "Unhandled exception");
message = "An unhandled server error has occurred.";
statusCode = StatusCodes.Status500InternalServerError;
break;
}

var errorModel = validationModel ?? new ErrorResponseModel(message);
var environment = httpContext.RequestServices.GetRequiredService<IWebHostEnvironment>();
if (environment.IsDevelopment())
{
errorModel.ExceptionMessage = exception.Message;
errorModel.ExceptionStackTrace = exception.StackTrace;
errorModel.InnerExceptionMessage = exception.InnerException?.Message;
}

return Results.Json(errorModel, statusCode: statusCode);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
using System.ComponentModel.DataAnnotations;
using Bit.Core.Models.Api;

namespace Bit.Commercial.Pam.Api.Endpoints.Filters;

/// <summary>
/// Minimal API equivalent of the MVC <c>ModelStateValidationFilterAttribute</c>: runs DataAnnotations validation
/// (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>
public class PamValidationEndpointFilter : IEndpointFilter
{
private const string RequestModelNamespace = "Bit.Commercial.Pam.Api.Models.Request";

public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
{
foreach (var argument in context.Arguments)
{
if (argument is null || argument.GetType().Namespace != RequestModelNamespace)
{
continue;
}

var results = new List<ValidationResult>();
if (Validator.TryValidateObject(argument, new ValidationContext(argument), results, validateAllProperties: true))
{
continue;
}

var validationErrors = results
.SelectMany(
result => result.MemberNames.Any() ? result.MemberNames : [string.Empty],
(result, member) => (member, message: result.ErrorMessage ?? string.Empty))
.GroupBy(error => error.member)
.ToDictionary(group => group.Key, group => (IEnumerable<string>)group.Select(error => error.message).ToArray());

return Results.Json(
new ErrorResponseModel("The model state is invalid.", validationErrors),
statusCode: StatusCodes.Status400BadRequest);
}

return await next(context);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using Bit.Core.Exceptions;
using Bit.Core.Services;

namespace Bit.Commercial.Pam.Api.Endpoints.Filters;

/// <summary>
/// Minimal API equivalent of <see cref="Bit.Core.Utilities.RequireFeatureAttribute"/>: gates an endpoint group
/// behind a boolean feature flag. When the flag is disabled a <see cref="FeatureUnavailableException"/> (a
/// <see cref="NotFoundException"/>) is thrown, which <see cref="PamExceptionHandlerEndpointFilter"/> renders as a 404.
/// </summary>
public class RequireFeatureEndpointFilter(string featureFlagKey) : IEndpointFilter
{
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
{
var featureService = context.HttpContext.RequestServices.GetRequiredService<IFeatureService>();
if (!featureService.IsEnabled(featureFlagKey))
{
throw new FeatureUnavailableException();
}

return await next(context);
}
}
Loading
Loading