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);
+ }
+}