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..0e81b4051876 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Api/Models/Request/AccessLeaseExtensionRequestModel.cs @@ -0,0 +1,24 @@ +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 +/// ; 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. + /// + [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; } +} 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); + } +}