From 9cc398eeb5d034dff0365b9a1c168a0f0dd04c62 Mon Sep 17 00:00:00 2001 From: Hinton Date: Mon, 6 Jul 2026 11:01:21 +0200 Subject: [PATCH 1/3] Scaffold PAM lease endpoints and DTOs for OpenAPI binding generation Adds the /leases Minimal API group (active, history, mine, revoke, extend) with an intentionally unimplemented handler, following the access-rule and access-request scaffolds. The response models are already on main; the two new request DTOs (revoke, extension) carry the remaining wire contract. Contract tests lock the routes, names, methods, and return types the generated spec is built from. --- .../Handlers/LeaseEndpointsHandler.cs | 33 +++++++ .../Pam/Api/Endpoints/LeaseEndpoints.cs | 41 ++++++++ .../Api/Endpoints/PamEndpointsExtensions.cs | 1 + .../AccessLeaseExtensionRequestModel.cs | 20 ++++ .../Request/AccessLeaseRevokeRequestModel.cs | 12 +++ .../Utilities/ServiceCollectionExtensions.cs | 1 + .../Endpoints/AccessRequestEndpointsTests.cs | 1 + .../Api/Endpoints/AccessRuleEndpointsTests.cs | 1 + .../Api/Endpoints/LeaseEndpointsTests.cs | 99 +++++++++++++++++++ 9 files changed, 209 insertions(+) create mode 100644 bitwarden_license/src/Services/Pam/Api/Endpoints/Handlers/LeaseEndpointsHandler.cs create mode 100644 bitwarden_license/src/Services/Pam/Api/Endpoints/LeaseEndpoints.cs create mode 100644 bitwarden_license/src/Services/Pam/Api/Models/Request/AccessLeaseExtensionRequestModel.cs create mode 100644 bitwarden_license/src/Services/Pam/Api/Models/Request/AccessLeaseRevokeRequestModel.cs create mode 100644 bitwarden_license/test/Services/Pam.Test/Api/Endpoints/LeaseEndpointsTests.cs diff --git a/bitwarden_license/src/Services/Pam/Api/Endpoints/Handlers/LeaseEndpointsHandler.cs b/bitwarden_license/src/Services/Pam/Api/Endpoints/Handlers/LeaseEndpointsHandler.cs new file mode 100644 index 000000000000..5a3ca8519ffc --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Api/Endpoints/Handlers/LeaseEndpointsHandler.cs @@ -0,0 +1,33 @@ +using System.Security.Claims; +using Bit.HttpExtensions; +using Bit.Services.Pam.Api.Models.Request; +using Bit.Services.Pam.Api.Models.Response; + +namespace Bit.Services.Pam.Api.Endpoints.Handlers; + +/// +/// Handler for the leases resource. The Minimal API endpoints (see LeaseEndpoints) resolve this +/// handler from DI. +/// +/// +/// 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. +/// +public class LeaseEndpointsHandler +{ + public Task> GetActive(ClaimsPrincipal user) + => throw new NotImplementedException(); + + public Task> GetHistory(ClaimsPrincipal user) + => throw new NotImplementedException(); + + public Task> GetMine(ClaimsPrincipal user) + => throw new NotImplementedException(); + + public Task Revoke(ClaimsPrincipal user, Guid id, AccessLeaseRevokeRequestModel model) + => throw new NotImplementedException(); + + public Task Extend(ClaimsPrincipal user, Guid id, AccessLeaseExtensionRequestModel model) + => throw new NotImplementedException(); +} diff --git a/bitwarden_license/src/Services/Pam/Api/Endpoints/LeaseEndpoints.cs b/bitwarden_license/src/Services/Pam/Api/Endpoints/LeaseEndpoints.cs new file mode 100644 index 000000000000..346511d42c24 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Api/Endpoints/LeaseEndpoints.cs @@ -0,0 +1,41 @@ +using System.Security.Claims; +using Bit.Services.Pam.Api.Endpoints.Handlers; +using Bit.Services.Pam.Api.Models.Request; + +namespace Bit.Services.Pam.Api.Endpoints; + +/// +/// The leases resource: the caller's own leases, the governance surface over manageable collections, and the +/// per-lease actions (revoke, extend). +/// +internal static class LeaseEndpoints +{ + public static RouteGroupBuilder MapLeaseEndpoints(this RouteGroupBuilder group) + { + group.WithTags("Leases"); + + group.MapGet("active", (LeaseEndpointsHandler handler, ClaimsPrincipal user) => handler.GetActive(user)) + .WithName("Pam_Leases_GetActive"); + + group.MapGet("history", (LeaseEndpointsHandler handler, ClaimsPrincipal user) => handler.GetHistory(user)) + .WithName("Pam_Leases_GetHistory"); + + group.MapGet("mine", (LeaseEndpointsHandler handler, ClaimsPrincipal user) => handler.GetMine(user)) + .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(); + }) + .WithName("Pam_Leases_Revoke"); + + group.MapPost("{id:guid}/extend", + (Guid id, AccessLeaseExtensionRequestModel model, LeaseEndpointsHandler handler, ClaimsPrincipal user) => + handler.Extend(user, id, model)) + .WithName("Pam_Leases_Extend"); + + return group; + } +} diff --git a/bitwarden_license/src/Services/Pam/Api/Endpoints/PamEndpointsExtensions.cs b/bitwarden_license/src/Services/Pam/Api/Endpoints/PamEndpointsExtensions.cs index 7895cba3a514..057306a914b8 100644 --- a/bitwarden_license/src/Services/Pam/Api/Endpoints/PamEndpointsExtensions.cs +++ b/bitwarden_license/src/Services/Pam/Api/Endpoints/PamEndpointsExtensions.cs @@ -14,6 +14,7 @@ public static class PamEndpointsExtensions { public static void MapPamEndpoints(this IEndpointRouteBuilder endpoints) { + endpoints.MapGroup("/leases").WithPamDefaults().MapLeaseEndpoints(); endpoints.MapGroup("/access-requests").WithPamDefaults().MapAccessRequestEndpoints(); endpoints.MapGroup("/organizations/{orgId:guid}/access-rules").WithPamDefaults().MapAccessRuleEndpoints(); } diff --git a/bitwarden_license/src/Services/Pam/Api/Models/Request/AccessLeaseExtensionRequestModel.cs b/bitwarden_license/src/Services/Pam/Api/Models/Request/AccessLeaseExtensionRequestModel.cs new file mode 100644 index 000000000000..5e977d3c6fdf --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Api/Models/Request/AccessLeaseExtensionRequestModel.cs @@ -0,0 +1,20 @@ +namespace Bit.Services.Pam.Api.Models.Request; + +/// +/// A request to extend an active lease, identified by the route's lease id. The lease's end is pushed out by +/// ; a justifying is required. Extensions are always auto-approved, +/// subject to the governing rule allowing extensions and the per-lease maximum not being reached. +/// +public class AccessLeaseExtensionRequestModel +{ + /// + /// How far the lease's end is pushed out, in seconds. Must be positive and no longer than the governing rule's + /// maximum extension duration. + /// + public int DurationSeconds { get; set; } + + /// + /// The justification recorded with the extension. Required to be non-empty. + /// + public string? Reason { get; set; } +} diff --git a/bitwarden_license/src/Services/Pam/Api/Models/Request/AccessLeaseRevokeRequestModel.cs b/bitwarden_license/src/Services/Pam/Api/Models/Request/AccessLeaseRevokeRequestModel.cs new file mode 100644 index 000000000000..df0003248a5c --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Api/Models/Request/AccessLeaseRevokeRequestModel.cs @@ -0,0 +1,12 @@ +namespace Bit.Services.Pam.Api.Models.Request; + +/// +/// A request to revoke an active lease early. is optional and retained for the audit trail. +/// +public class AccessLeaseRevokeRequestModel +{ + /// + /// An optional note explaining the revocation. Recorded on the audit trail; not surfaced on the lease itself. + /// + public string? Reason { get; set; } +} diff --git a/bitwarden_license/src/Services/Pam/Utilities/ServiceCollectionExtensions.cs b/bitwarden_license/src/Services/Pam/Utilities/ServiceCollectionExtensions.cs index 2d8f6bcfaf98..2dfd4a931641 100644 --- a/bitwarden_license/src/Services/Pam/Utilities/ServiceCollectionExtensions.cs +++ b/bitwarden_license/src/Services/Pam/Utilities/ServiceCollectionExtensions.cs @@ -9,6 +9,7 @@ public static class ServiceCollectionExtensions public static IServiceCollection AddPamServices(this IServiceCollection services) { // Minimal API endpoint handlers. The endpoints (see PamEndpointsExtensions) resolve these from DI. + services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/bitwarden_license/test/Services/Pam.Test/Api/Endpoints/AccessRequestEndpointsTests.cs b/bitwarden_license/test/Services/Pam.Test/Api/Endpoints/AccessRequestEndpointsTests.cs index 171a094ed34d..a177e85e36a5 100644 --- a/bitwarden_license/test/Services/Pam.Test/Api/Endpoints/AccessRequestEndpointsTests.cs +++ b/bitwarden_license/test/Services/Pam.Test/Api/Endpoints/AccessRequestEndpointsTests.cs @@ -26,6 +26,7 @@ private static List MaterializeEndpoints() // The handlers must be known services so Minimal API binding treats the handler parameter as injected // (not an inferred request body) — the same registration AddPamServices performs in the app. // MapPamEndpoints maps every PAM group, so each group's handler has to be resolvable here. + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/bitwarden_license/test/Services/Pam.Test/Api/Endpoints/AccessRuleEndpointsTests.cs b/bitwarden_license/test/Services/Pam.Test/Api/Endpoints/AccessRuleEndpointsTests.cs index 7a219c19d07e..009de24f245d 100644 --- a/bitwarden_license/test/Services/Pam.Test/Api/Endpoints/AccessRuleEndpointsTests.cs +++ b/bitwarden_license/test/Services/Pam.Test/Api/Endpoints/AccessRuleEndpointsTests.cs @@ -26,6 +26,7 @@ private static List MaterializeEndpoints() // The handlers must be known services so Minimal API binding treats the handler parameter as injected // (not an inferred request body) — the same registration AddPamServices performs in the app. // MapPamEndpoints maps every PAM group, so each group's handler has to be resolvable here. + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/bitwarden_license/test/Services/Pam.Test/Api/Endpoints/LeaseEndpointsTests.cs b/bitwarden_license/test/Services/Pam.Test/Api/Endpoints/LeaseEndpointsTests.cs new file mode 100644 index 000000000000..d47ea0496e98 --- /dev/null +++ b/bitwarden_license/test/Services/Pam.Test/Api/Endpoints/LeaseEndpointsTests.cs @@ -0,0 +1,99 @@ +using Bit.Core.Models.Api; +using Bit.HttpExtensions; +using Bit.Services.Pam.Api.Endpoints; +using Bit.Services.Pam.Api.Endpoints.Handlers; +using Bit.Services.Pam.Api.Models.Response; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Metadata; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Bit.Services.Pam.Test.Api.Endpoints; + +/// +/// Locks the lease wire contract that the generated OpenAPI spec — and the client bindings built from it — +/// depend on. The endpoint bodies are scaffold stubs; the contract (routes, names, methods, return types) is the +/// thing under test. Endpoints are materialized by mapping them onto a minimal host and reading its +/// — the same metadata the offline OpenAPI generator inspects. +/// +public class LeaseEndpointsTests +{ + private static List MaterializeEndpoints() + { + var builder = WebApplication.CreateSlimBuilder(); + // The handlers must be known services so Minimal API binding treats the handler parameter as injected + // (not an inferred request body) — the same registration AddPamServices performs in the app. + // MapPamEndpoints maps every PAM group, so each group's handler has to be resolvable here. + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + + var app = builder.Build(); + app.MapPamEndpoints(); + + // Enumerating the data sources builds the endpoints — applying the route group's prefix, metadata, and + // conventions — without starting the request pipeline, the same set the OpenAPI generator discovers. + return ((IEndpointRouteBuilder)app).DataSources + .SelectMany(dataSource => dataSource.Endpoints) + .OfType() + .ToList(); + } + + [Fact] + public void MapPamEndpoints_RegistersTheFiveLeaseRoutes_InTheInternalDoc() + { + var endpoints = MaterializeEndpoints() + .Where(e => e.Metadata.GetMetadata()!.Tags.Contains("Leases")) + .ToList(); + + Assert.Equal(5, endpoints.Count); + Assert.All(endpoints, endpoint => + Assert.Equal("internal", endpoint.Metadata.GetMetadata()?.EndpointGroupName)); + } + + [Theory] + [InlineData("Pam_Leases_GetActive", "GET", "leases/active")] + [InlineData("Pam_Leases_GetHistory", "GET", "leases/history")] + [InlineData("Pam_Leases_GetMine", "GET", "leases/mine")] + [InlineData("Pam_Leases_Revoke", "POST", "leases/{id:guid}/revoke")] + [InlineData("Pam_Leases_Extend", "POST", "leases/{id:guid}/extend")] + public void MapPamEndpoints_RegistersExpectedRoute(string name, string method, string route) + { + var endpoints = MaterializeEndpoints(); + + var endpoint = Assert.Single( + endpoints, + e => e.Metadata.GetMetadata()?.EndpointName == name); + // Trim slashes: the raw pattern carries routing's leading/trailing slashes (e.g. "/leases/active") + // that the generated spec path does not. + Assert.Equal(route, endpoint.RoutePattern.RawText?.Trim('/')); + Assert.Contains(method, endpoint.Metadata.GetMetadata()!.HttpMethods); + } + + [Fact] + public void LeaseGroup_DocumentsErrorResponseModel_For400And404() + { + var endpoint = MaterializeEndpoints() + .First(e => e.Metadata.GetMetadata()!.Tags.Contains("Leases")); + var produces = endpoint.Metadata.GetOrderedMetadata(); + + Assert.Contains(produces, p => p.StatusCode == StatusCodes.Status400BadRequest && p.Type == typeof(ErrorResponseModel)); + Assert.Contains(produces, p => p.StatusCode == StatusCodes.Status404NotFound && p.Type == typeof(ErrorResponseModel)); + } + + [Theory] + [InlineData(nameof(LeaseEndpointsHandler.GetActive), typeof(Task>))] + [InlineData(nameof(LeaseEndpointsHandler.GetHistory), typeof(Task>))] + [InlineData(nameof(LeaseEndpointsHandler.GetMine), typeof(Task>))] + [InlineData(nameof(LeaseEndpointsHandler.Revoke), typeof(Task))] + [InlineData(nameof(LeaseEndpointsHandler.Extend), typeof(Task))] + public void Handler_HasExpectedReturnType(string methodName, Type expectedReturnType) + { + var method = typeof(LeaseEndpointsHandler).GetMethod(methodName); + + Assert.NotNull(method); + Assert.Equal(expectedReturnType, method!.ReturnType); + } +} From b9e70f41cc9a1849fc73614487bd6511cf1dae83 Mon Sep 17 00:00:00 2001 From: Hinton Date: Mon, 6 Jul 2026 11:15:21 +0200 Subject: [PATCH 2/3] Encode lease-extension request constraints as DataAnnotations The validation filter only enforces attributed constraints, and only those surface in the generated OpenAPI schema: Range(1, max) on DurationSeconds and Required on Reason, matching how AccessDecisionRequestModel encodes its contract. The rule-specific duration ceiling stays server-side. --- .../Models/Request/AccessLeaseExtensionRequestModel.cs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/bitwarden_license/src/Services/Pam/Api/Models/Request/AccessLeaseExtensionRequestModel.cs b/bitwarden_license/src/Services/Pam/Api/Models/Request/AccessLeaseExtensionRequestModel.cs index 5e977d3c6fdf..4fa10e5ba829 100644 --- a/bitwarden_license/src/Services/Pam/Api/Models/Request/AccessLeaseExtensionRequestModel.cs +++ b/bitwarden_license/src/Services/Pam/Api/Models/Request/AccessLeaseExtensionRequestModel.cs @@ -1,4 +1,6 @@ -namespace Bit.Services.Pam.Api.Models.Request; +using System.ComponentModel.DataAnnotations; + +namespace Bit.Services.Pam.Api.Models.Request; /// /// A request to extend an active lease, identified by the route's lease id. The lease's end is pushed out by @@ -9,12 +11,14 @@ public class AccessLeaseExtensionRequestModel { /// /// How far the lease's end is pushed out, in seconds. Must be positive and no longer than the governing rule's - /// maximum extension duration. + /// maximum extension duration (enforced server-side against the resolved rule). /// + [Range(1, int.MaxValue)] public int DurationSeconds { get; set; } /// /// The justification recorded with the extension. Required to be non-empty. /// + [Required] public string? Reason { get; set; } } From 734e4329d432dca55eca665d410fd40070928842 Mon Sep 17 00:00:00 2001 From: Hinton Date: Mon, 6 Jul 2026 11:16:26 +0200 Subject: [PATCH 3/3] Drop redundant doc parenthetical on DurationSeconds --- .../Pam/Api/Models/Request/AccessLeaseExtensionRequestModel.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bitwarden_license/src/Services/Pam/Api/Models/Request/AccessLeaseExtensionRequestModel.cs b/bitwarden_license/src/Services/Pam/Api/Models/Request/AccessLeaseExtensionRequestModel.cs index 4fa10e5ba829..0e81b4051876 100644 --- a/bitwarden_license/src/Services/Pam/Api/Models/Request/AccessLeaseExtensionRequestModel.cs +++ b/bitwarden_license/src/Services/Pam/Api/Models/Request/AccessLeaseExtensionRequestModel.cs @@ -11,7 +11,7 @@ public class AccessLeaseExtensionRequestModel { /// /// How far the lease's end is pushed out, in seconds. Must be positive and no longer than the governing rule's - /// maximum extension duration (enforced server-side against the resolved rule). + /// maximum extension duration. /// [Range(1, int.MaxValue)] public int DurationSeconds { get; set; }