From d30a9b62a2f8beab11b0ca123df02c7beb7b6bb3 Mon Sep 17 00:00:00 2001 From: Patrik Svensson Date: Thu, 15 Oct 2026 22:56:41 +0200 Subject: [PATCH 01/12] [PAM-193] Access Connectors: API --- .../Api/Endpoints/AccessConnectorEndpoints.cs | 5 +- .../AccessConnectorHeartbeatEndpointFilter.cs | 32 +++ .../AccessConnectorEndpointsHandler.cs | 80 ++++--- ...AssignAccessConnectorTargetRequestModel.cs | 7 +- .../RegisterAccessConnectorRequestModel.cs | 16 +- .../PamAccessConnectorDetailResponseModel.cs | 18 +- .../PamAccessConnectorResponseModel.cs | 53 ++--- .../RegisterAccessConnectorResponseModel.cs | 41 ++-- .../AssignAccessConnectorToTargetCommand.cs | 90 ++++++++ .../Commands/DeleteAccessConnectorCommand.cs | 53 +++++ .../IAssignAccessConnectorToTargetCommand.cs | 10 + .../IDeleteAccessConnectorCommand.cs | 10 + .../IRegisterAccessConnectorCommand.cs | 14 ++ .../ISetAccessConnectorStatusCommand.cs | 10 + ...nassignAccessConnectorFromTargetCommand.cs | 7 + .../RegisterAccessConnectorCommand.cs | 100 +++++++++ .../SetAccessConnectorStatusCommand.cs | 63 ++++++ ...nassignAccessConnectorFromTargetCommand.cs | 67 ++++++ .../Models/PamAccessConnectorHistory.cs | 11 + .../Models/PamAccessConnectorListItem.cs | 11 + .../PamAccessConnectorRegistrationResult.cs | 9 + .../Pam/AccessConnector/PamRotationOptions.cs | 40 ++++ .../Queries/GetAccessConnectorDetailsQuery.cs | 54 +++++ .../IGetAccessConnectorDetailsQuery.cs | 12 ++ .../Interfaces/IListAccessConnectorsQuery.cs | 9 + .../Queries/ListAccessConnectorsQuery.cs | 44 ++++ .../PamRotationAttemptResponseModel.cs | 18 ++ .../Response/PamRotationJobResponseModel.cs | 18 ++ .../Api/Endpoints/PamEndpointsExtensions.cs | 12 +- .../Utilities/ServiceCollectionExtensions.cs | 35 +++- .../AccessConnectorAuthorizationTests.cs | 146 +++++++++++++ ...ssConnectorHeartbeatEndpointFilterTests.cs | 95 +++++++++ .../AccessConnectorAdminEndpointsTests.cs | 30 +-- .../AccessConnectorMachineEndpointsTests.cs | 88 +++++--- .../RotationDetailResponseModelTests.cs | 86 ++++++++ ...signAccessConnectorToTargetCommandTests.cs | 196 ++++++++++++++++++ .../DeleteAccessConnectorCommandTests.cs | 89 ++++++++ .../RegisterAccessConnectorCommandTests.cs | 116 +++++++++++ .../SetAccessConnectorStatusCommandTests.cs | 163 +++++++++++++++ ...gnAccessConnectorFromTargetCommandTests.cs | 149 +++++++++++++ .../GetAccessConnectorDetailsQueryTests.cs | 125 +++++++++++ .../ServiceCollectionExtensionsTests.cs | 4 +- src/Api/Startup.cs | 2 +- 43 files changed, 2068 insertions(+), 170 deletions(-) create mode 100644 bitwarden_license/src/Services/Pam/AccessConnector/Api/Endpoints/Filters/AccessConnectorHeartbeatEndpointFilter.cs create mode 100644 bitwarden_license/src/Services/Pam/AccessConnector/Commands/AssignAccessConnectorToTargetCommand.cs create mode 100644 bitwarden_license/src/Services/Pam/AccessConnector/Commands/DeleteAccessConnectorCommand.cs create mode 100644 bitwarden_license/src/Services/Pam/AccessConnector/Commands/Interfaces/IAssignAccessConnectorToTargetCommand.cs create mode 100644 bitwarden_license/src/Services/Pam/AccessConnector/Commands/Interfaces/IDeleteAccessConnectorCommand.cs create mode 100644 bitwarden_license/src/Services/Pam/AccessConnector/Commands/Interfaces/IRegisterAccessConnectorCommand.cs create mode 100644 bitwarden_license/src/Services/Pam/AccessConnector/Commands/Interfaces/ISetAccessConnectorStatusCommand.cs create mode 100644 bitwarden_license/src/Services/Pam/AccessConnector/Commands/Interfaces/IUnassignAccessConnectorFromTargetCommand.cs create mode 100644 bitwarden_license/src/Services/Pam/AccessConnector/Commands/RegisterAccessConnectorCommand.cs create mode 100644 bitwarden_license/src/Services/Pam/AccessConnector/Commands/SetAccessConnectorStatusCommand.cs create mode 100644 bitwarden_license/src/Services/Pam/AccessConnector/Commands/UnassignAccessConnectorFromTargetCommand.cs create mode 100644 bitwarden_license/src/Services/Pam/AccessConnector/Models/PamAccessConnectorHistory.cs create mode 100644 bitwarden_license/src/Services/Pam/AccessConnector/Models/PamAccessConnectorListItem.cs create mode 100644 bitwarden_license/src/Services/Pam/AccessConnector/Models/PamAccessConnectorRegistrationResult.cs create mode 100644 bitwarden_license/src/Services/Pam/AccessConnector/PamRotationOptions.cs create mode 100644 bitwarden_license/src/Services/Pam/AccessConnector/Queries/GetAccessConnectorDetailsQuery.cs create mode 100644 bitwarden_license/src/Services/Pam/AccessConnector/Queries/Interfaces/IGetAccessConnectorDetailsQuery.cs create mode 100644 bitwarden_license/src/Services/Pam/AccessConnector/Queries/Interfaces/IListAccessConnectorsQuery.cs create mode 100644 bitwarden_license/src/Services/Pam/AccessConnector/Queries/ListAccessConnectorsQuery.cs create mode 100644 bitwarden_license/test/Services/Pam.IntegrationTest/AccessConnectorAuthorizationTests.cs create mode 100644 bitwarden_license/test/Services/Pam.Test/AccessConnector/Api/AccessConnectorHeartbeatEndpointFilterTests.cs create mode 100644 bitwarden_license/test/Services/Pam.Test/AccessConnector/Api/Models/RotationDetailResponseModelTests.cs create mode 100644 bitwarden_license/test/Services/Pam.Test/AccessConnector/Commands/AssignAccessConnectorToTargetCommandTests.cs create mode 100644 bitwarden_license/test/Services/Pam.Test/AccessConnector/Commands/DeleteAccessConnectorCommandTests.cs create mode 100644 bitwarden_license/test/Services/Pam.Test/AccessConnector/Commands/RegisterAccessConnectorCommandTests.cs create mode 100644 bitwarden_license/test/Services/Pam.Test/AccessConnector/Commands/SetAccessConnectorStatusCommandTests.cs create mode 100644 bitwarden_license/test/Services/Pam.Test/AccessConnector/Commands/UnassignAccessConnectorFromTargetCommandTests.cs create mode 100644 bitwarden_license/test/Services/Pam.Test/AccessConnector/Queries/GetAccessConnectorDetailsQueryTests.cs diff --git a/bitwarden_license/src/Services/Pam/AccessConnector/Api/Endpoints/AccessConnectorEndpoints.cs b/bitwarden_license/src/Services/Pam/AccessConnector/Api/Endpoints/AccessConnectorEndpoints.cs index 0fc894dde419..a28631f47a72 100644 --- a/bitwarden_license/src/Services/Pam/AccessConnector/Api/Endpoints/AccessConnectorEndpoints.cs +++ b/bitwarden_license/src/Services/Pam/AccessConnector/Api/Endpoints/AccessConnectorEndpoints.cs @@ -4,10 +4,7 @@ namespace Bit.Services.Pam.AccessConnector.Api.Endpoints; -/// -/// The organizations/{orgId}/access-connectors resource: fleet registration, enable/disable, deletion, and -/// target assignment. orgId is bound from the group's route prefix. -/// +/// The organizations/{orgId}/access-connectors resource. internal static class AccessConnectorEndpoints { public static RouteGroupBuilder MapAccessConnectorEndpoints(this RouteGroupBuilder group) diff --git a/bitwarden_license/src/Services/Pam/AccessConnector/Api/Endpoints/Filters/AccessConnectorHeartbeatEndpointFilter.cs b/bitwarden_license/src/Services/Pam/AccessConnector/Api/Endpoints/Filters/AccessConnectorHeartbeatEndpointFilter.cs new file mode 100644 index 000000000000..b2b7d521797a --- /dev/null +++ b/bitwarden_license/src/Services/Pam/AccessConnector/Api/Endpoints/Filters/AccessConnectorHeartbeatEndpointFilter.cs @@ -0,0 +1,32 @@ +using Bit.Core.Context; +using Bit.Core.Exceptions; +using Bit.Pam.Entities; +using Bit.Pam.Repositories; +using Microsoft.Extensions.Options; + +namespace Bit.Services.Pam.AccessConnector.Api.Endpoints.Filters; + +/// +/// Bumps on every connector-facing route, at most once per +/// HeartbeatMinInterval since the repository write is conditional. Authorizes nothing; token issuance and the +/// work queries check eligibility. +/// +public class AccessConnectorHeartbeatEndpointFilter : IEndpointFilter +{ + public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) + { + var services = context.HttpContext.RequestServices; + var currentContext = services.GetRequiredService(); + var accessConnectorRepository = services.GetRequiredService(); + var options = services.GetRequiredService>(); + var timeProvider = services.GetRequiredService(); + + var accessConnectorId = currentContext.PamAccessConnectorId ?? throw new NotFoundException(); + + var now = timeProvider.GetUtcNow().UtcDateTime; + await accessConnectorRepository.UpdateHeartbeatAsync( + accessConnectorId, now, options.Value.HeartbeatMinInterval); + + return await next(context); + } +} diff --git a/bitwarden_license/src/Services/Pam/AccessConnector/Api/Endpoints/Handlers/AccessConnectorEndpointsHandler.cs b/bitwarden_license/src/Services/Pam/AccessConnector/Api/Endpoints/Handlers/AccessConnectorEndpointsHandler.cs index 2bc7c889d4be..9de52ad76e86 100644 --- a/bitwarden_license/src/Services/Pam/AccessConnector/Api/Endpoints/Handlers/AccessConnectorEndpointsHandler.cs +++ b/bitwarden_license/src/Services/Pam/AccessConnector/Api/Endpoints/Handlers/AccessConnectorEndpointsHandler.cs @@ -1,42 +1,70 @@ -using Bit.HttpExtensions; +using Bit.Core.Context; +using Bit.HttpExtensions; using Bit.Services.Pam.AccessConnector.Api.Models.Request; using Bit.Services.Pam.AccessConnector.Api.Models.Response; +using Bit.Services.Pam.AccessConnector.Commands.Interfaces; +using Bit.Services.Pam.AccessConnector.Queries.Interfaces; namespace Bit.Services.Pam.AccessConnector.Api.Endpoints.Handlers; /// -/// Handler for the organizations/{orgId}/access-connectors resource: fleet registration, enable/disable, -/// deletion, and target assignment. The Minimal API endpoints (see AccessConnectorEndpoints) resolve this -/// handler from DI. +/// Handler for the organizations/{orgId}/access-connectors resource, authorized in the middleware by +/// ManageAccessConnectorRequirement. /// -/// -/// 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 rotation feature. -/// -public class AccessConnectorEndpointsHandler +public class AccessConnectorEndpointsHandler( + ICurrentContext currentContext, + IListAccessConnectorsQuery listAccessConnectorsQuery, + IGetAccessConnectorDetailsQuery getAccessConnectorDetailsQuery, + IRegisterAccessConnectorCommand registerAccessConnectorCommand, + ISetAccessConnectorStatusCommand setAccessConnectorStatusCommand, + IDeleteAccessConnectorCommand deleteAccessConnectorCommand, + IAssignAccessConnectorToTargetCommand assignAccessConnectorToTargetCommand, + IUnassignAccessConnectorFromTargetCommand unassignAccessConnectorFromTargetCommand) { - public Task> GetAll(Guid orgId) - => throw new NotImplementedException(); + public async Task> GetAll(Guid orgId) + { + var connectors = await listAccessConnectorsQuery.ListAsync(orgId); + return new ListResponseModel( + connectors.Select(connector => new PamAccessConnectorResponseModel(connector))); + } - public Task Get(Guid orgId, Guid id) - => throw new NotImplementedException(); + public async Task Get(Guid orgId, Guid id) + { + var history = await getAccessConnectorDetailsQuery.GetAsync(orgId, id); + return new PamAccessConnectorDetailResponseModel(history); + } - public Task Post(Guid orgId, RegisterAccessConnectorRequestModel model) - => throw new NotImplementedException(); + public async Task Post(Guid orgId, RegisterAccessConnectorRequestModel model) + { + var result = await registerAccessConnectorCommand.RegisterAsync( + orgId, currentContext.UserId!.Value, model.Name, model.EncryptedPayload, model.Key); + return new RegisterAccessConnectorResponseModel(result); + } - public Task Enable(Guid orgId, Guid id) - => throw new NotImplementedException(); + public async Task Enable(Guid orgId, Guid id) + { + await setAccessConnectorStatusCommand.SetStatusAsync(orgId, currentContext.UserId!.Value, id, enable: true); + } - public Task Disable(Guid orgId, Guid id) - => throw new NotImplementedException(); + public async Task Disable(Guid orgId, Guid id) + { + await setAccessConnectorStatusCommand.SetStatusAsync(orgId, currentContext.UserId!.Value, id, enable: false); + } - public Task Delete(Guid orgId, Guid id) - => throw new NotImplementedException(); + public async Task Delete(Guid orgId, Guid id) + { + await deleteAccessConnectorCommand.DeleteAsync(orgId, currentContext.UserId!.Value, id); + } - public Task AssignTarget(Guid orgId, Guid id, AssignAccessConnectorTargetRequestModel model) - => throw new NotImplementedException(); + public async Task AssignTarget(Guid orgId, Guid id, AssignAccessConnectorTargetRequestModel model) + { + await assignAccessConnectorToTargetCommand.AssignAsync( + orgId, currentContext.UserId!.Value, id, model.TargetSystemId); + } - public Task UnassignTarget(Guid orgId, Guid id, Guid targetSystemId) - => throw new NotImplementedException(); + public async Task UnassignTarget(Guid orgId, Guid id, Guid targetSystemId) + { + await unassignAccessConnectorFromTargetCommand.UnassignAsync( + orgId, currentContext.UserId!.Value, id, targetSystemId); + } } diff --git a/bitwarden_license/src/Services/Pam/AccessConnector/Api/Models/Request/AssignAccessConnectorTargetRequestModel.cs b/bitwarden_license/src/Services/Pam/AccessConnector/Api/Models/Request/AssignAccessConnectorTargetRequestModel.cs index 6e8a99352ac1..8e32be258b28 100644 --- a/bitwarden_license/src/Services/Pam/AccessConnector/Api/Models/Request/AssignAccessConnectorTargetRequestModel.cs +++ b/bitwarden_license/src/Services/Pam/AccessConnector/Api/Models/Request/AssignAccessConnectorTargetRequestModel.cs @@ -2,14 +2,11 @@ namespace Bit.Services.Pam.AccessConnector.Api.Models.Request; -/// The body of POST access-connectors/{id}/assignments: the target system to assign the access -/// connector to. public class AssignAccessConnectorTargetRequestModel { /// - /// The target system the access connector should work. Assignment is what makes the target's rotation jobs visible - /// to the access connector -- an unassigned access connector sees no work, and a manual target has no access - /// connector to assign. + /// Assignment makes the target's rotation jobs visible to the access connector. A manual target cannot be + /// assigned. /// [Required] public Guid TargetSystemId { get; set; } diff --git a/bitwarden_license/src/Services/Pam/AccessConnector/Api/Models/Request/RegisterAccessConnectorRequestModel.cs b/bitwarden_license/src/Services/Pam/AccessConnector/Api/Models/Request/RegisterAccessConnectorRequestModel.cs index bc0dbf6b4b64..b683d3400439 100644 --- a/bitwarden_license/src/Services/Pam/AccessConnector/Api/Models/Request/RegisterAccessConnectorRequestModel.cs +++ b/bitwarden_license/src/Services/Pam/AccessConnector/Api/Models/Request/RegisterAccessConnectorRequestModel.cs @@ -4,32 +4,26 @@ namespace Bit.Services.Pam.AccessConnector.Api.Models.Request; /// -/// Registers a new access connector (spec ConnectorRegistration). and -/// carry the organization key wrapped client-side, so the server holds ciphertext only and never -/// sees the plaintext key. is a plaintext display label. +/// Registers a new access connector (spec ConnectorRegistration). The organization key arrives wrapped +/// client-side in and , so the server never sees it in plaintext. /// public class RegisterAccessConnectorRequestModel { - /// The access connector's plaintext display label, shown wherever access connectors are listed and - /// managed. + /// The access connector's display label, sent in plaintext. [Required] [StringLength(200)] public string Name { get; set; } = null!; /// /// The organization key, wrapped client-side with the encryption-key half of the access connector's credential. - /// Stored verbatim and handed back on every token response so the access connector can unwrap it locally; the - /// server only ever holds the ciphertext. + /// Returned on every token response so the access connector can unwrap it locally. /// [Required] [EncryptedString] [EncryptedStringLength(4000)] public string EncryptedPayload { get; set; } = null!; - /// - /// The key protecting , itself uploaded wrapped -- opaque ciphertext the server - /// stores but can never use, recoverable only client-side. - /// + /// The key protecting , wrapped with the organization key. [Required] [EncryptedString] public string Key { get; set; } = null!; diff --git a/bitwarden_license/src/Services/Pam/AccessConnector/Api/Models/Response/PamAccessConnectorDetailResponseModel.cs b/bitwarden_license/src/Services/Pam/AccessConnector/Api/Models/Response/PamAccessConnectorDetailResponseModel.cs index 6259591987ef..0ba76822c2b6 100644 --- a/bitwarden_license/src/Services/Pam/AccessConnector/Api/Models/Response/PamAccessConnectorDetailResponseModel.cs +++ b/bitwarden_license/src/Services/Pam/AccessConnector/Api/Models/Response/PamAccessConnectorDetailResponseModel.cs @@ -1,22 +1,20 @@ -using Bit.Services.Pam.AccessConnector.Rotation.Api.Models.Response; +using Bit.Services.Pam.AccessConnector.Models; +using Bit.Services.Pam.AccessConnector.Rotation.Api.Models.Response; namespace Bit.Services.Pam.AccessConnector.Api.Models.Response; -/// -/// An access connector's detail view for GET access-connectors/{id}: the list shape flattened onto the same -/// object, plus the access connector's recent rotation activity. The fleet surface renders the header from the access -/// connector fields and the activity section from , so both arrive in one response. -/// +/// An access connector with its recent rotation activity. public class PamAccessConnectorDetailResponseModel : PamAccessConnectorResponseModel { - public PamAccessConnectorDetailResponseModel() - : base("pamAccessConnectorDetails") + public PamAccessConnectorDetailResponseModel(PamAccessConnectorHistory history) + : base( + history?.AccessConnector ?? throw new ArgumentNullException(nameof(history)), "pamAccessConnectorDetails") { + Jobs = history.Jobs.Select(job => new PamRotationJobResponseModel(job)).ToList(); } /// - /// The access connector's recent jobs, newest first, each carrying only the attempts this access connector recorded - /// -- capped by GetAccessConnectorDetailsQuery rather than being the access connector's whole history. + /// The access connector's most recent jobs (capped), newest first, each with only the attempts it recorded. /// public IReadOnlyList Jobs { get; set; } = []; } diff --git a/bitwarden_license/src/Services/Pam/AccessConnector/Api/Models/Response/PamAccessConnectorResponseModel.cs b/bitwarden_license/src/Services/Pam/AccessConnector/Api/Models/Response/PamAccessConnectorResponseModel.cs index a5b7a3f9fe4a..9d2ef751c753 100644 --- a/bitwarden_license/src/Services/Pam/AccessConnector/Api/Models/Response/PamAccessConnectorResponseModel.cs +++ b/bitwarden_license/src/Services/Pam/AccessConnector/Api/Models/Response/PamAccessConnectorResponseModel.cs @@ -1,65 +1,54 @@ using Bit.HttpExtensions; using Bit.Pam.Enums; +using Bit.Services.Pam.AccessConnector.Models; +using Bit.Services.Pam.Api.Models.Response; namespace Bit.Services.Pam.AccessConnector.Api.Models.Response; -/// -/// An access connector as the fleet-admin surface renders it: its derived liveness (spec ConnectorConnection) -/// and the target systems it is assigned to. The list view model for GET access-connectors. -/// +/// An access connector with its derived liveness and target assignments. public class PamAccessConnectorResponseModel : ResponseModel { - public PamAccessConnectorResponseModel(string obj = "pamAccessConnector") + public PamAccessConnectorResponseModel(PamAccessConnectorListItem item, string obj = "pamAccessConnector") : base(obj) { + ArgumentNullException.ThrowIfNull(item); + + Id = item.AccessConnector.Id; + OrganizationId = item.AccessConnector.OrganizationId; + Name = item.AccessConnector.Name; + Status = item.AccessConnector.Status; + IsConnected = item.IsConnected; + LastHeartbeatAt = item.AccessConnector.LastHeartbeatDate.AsUtc(); + AssignedTargetSystemIds = item.AssignedTargetSystemIds; + CreationDate = item.AccessConnector.CreationDate.AsUtc(); + RevisionDate = item.AccessConnector.RevisionDate.AsUtc(); } - /// - /// The access connector's unique identifier. - /// public Guid Id { get; set; } - /// - /// The organization this access connector belongs to. - /// public Guid OrganizationId { get; set; } - /// - /// The access connector's display label, shown wherever access connectors are listed and managed. - /// public string Name { get; set; } = null!; /// - /// Whether the access connector may authenticate, poll, and claim jobs -- see - /// . A disabled access connector keeps its credential and can be re-enabled. + /// Whether the access connector may authenticate and claim jobs. A disabled one keeps its credential and can be + /// re-enabled. /// public PamAccessConnectorStatus Status { get; set; } /// - /// Derived from against PamRotationOptions.ConnectorOfflineAfter -- spec - /// ConnectorConnection. + /// Whether is within AccessConnectorOfflineAfter of now (spec + /// ConnectorConnection). /// public bool IsConnected { get; set; } - /// - /// The last time the access connector polled or reported (UTC). Null until its first request; bumped only by the - /// access connector's own requests, never by a sweep. - /// + /// The last time the access connector polled or reported. Null until its first request. public DateTime? LastHeartbeatAt { get; set; } - /// - /// The target systems this access connector is assigned to work. An access connector is offered rotation jobs only - /// for the targets it is assigned. - /// + /// The only target systems the access connector is offered rotation jobs for. public IReadOnlyList AssignedTargetSystemIds { get; set; } = []; - /// - /// When the access connector was registered (UTC). - /// public DateTime CreationDate { get; set; } - /// - /// When the access connector was last modified (UTC). - /// public DateTime RevisionDate { get; set; } } diff --git a/bitwarden_license/src/Services/Pam/AccessConnector/Api/Models/Response/RegisterAccessConnectorResponseModel.cs b/bitwarden_license/src/Services/Pam/AccessConnector/Api/Models/Response/RegisterAccessConnectorResponseModel.cs index 956360846cf3..1ac49dd781b4 100644 --- a/bitwarden_license/src/Services/Pam/AccessConnector/Api/Models/Response/RegisterAccessConnectorResponseModel.cs +++ b/bitwarden_license/src/Services/Pam/AccessConnector/Api/Models/Response/RegisterAccessConnectorResponseModel.cs @@ -1,54 +1,43 @@ using Bit.HttpExtensions; using Bit.Pam.Enums; +using Bit.Services.Pam.AccessConnector.Models; +using Bit.Services.Pam.Api.Models.Response; namespace Bit.Services.Pam.AccessConnector.Api.Models.Response; /// The response to POST access-connectors (spec ConnectorRegistration). public class RegisterAccessConnectorResponseModel : ResponseModel { - public RegisterAccessConnectorResponseModel() + public RegisterAccessConnectorResponseModel(PamAccessConnectorRegistrationResult result) : base("pamAccessConnector") { + ArgumentNullException.ThrowIfNull(result); + + Id = result.AccessConnector.Id; + OrganizationId = result.AccessConnector.OrganizationId; + Name = result.AccessConnector.Name; + Status = result.AccessConnector.Status; + CreationDate = result.AccessConnector.CreationDate.AsUtc(); + ApiKeyId = result.AccessConnector.ApiKeyId; + ClientSecret = result.ClientSecret; } - /// - /// The access connector's unique identifier. - /// public Guid Id { get; set; } - /// - /// The organization the access connector was registered in. - /// public Guid OrganizationId { get; set; } - /// - /// The access connector's display label, as supplied at registration. - /// public string Name { get; set; } = null!; - /// - /// Whether the access connector may authenticate, poll, and claim jobs -- see - /// . - /// public PamAccessConnectorStatus Status { get; set; } - /// - /// When the access connector was registered (UTC). - /// public DateTime CreationDate { get; set; } - /// - /// The id of the access connector's dbo.ApiKey credential. The operator assembles the access connector's - /// OAuth client id from it (daemon.<ApiKeyId>, resolved server-side by - /// PamAccessConnectorClientProvider in Identity). - /// + /// The access connector's OAuth client id is access-connector.<ApiKeyId>. public Guid ApiKeyId { get; set; } /// - /// WARNING: shown exactly once. The plaintext client secret for the access connector's credential -- store it now; - /// the server hashes it for storage and never persists or returns the plaintext again. Pair with the client-wrapped - /// org key you already hold locally to assemble the access connector's token - /// (0.daemon.<apiKeyId>.<client_secret>:<encryption_key>). + /// WARNING: shown exactly once; the server stores only its hash. Combine it with the client-side encryption key to + /// form the token, 0.access-connector.<apiKeyId>.<client_secret>:<encryption_key>. /// public string ClientSecret { get; set; } = null!; } diff --git a/bitwarden_license/src/Services/Pam/AccessConnector/Commands/AssignAccessConnectorToTargetCommand.cs b/bitwarden_license/src/Services/Pam/AccessConnector/Commands/AssignAccessConnectorToTargetCommand.cs new file mode 100644 index 000000000000..a21d57d00c00 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/AccessConnector/Commands/AssignAccessConnectorToTargetCommand.cs @@ -0,0 +1,90 @@ +using Bit.Core.Exceptions; +using Bit.Pam.Entities; +using Bit.Pam.Enums; +using Bit.Pam.Models; +using Bit.Pam.Repositories; +using Bit.Services.Pam.AccessConnector.Commands.Interfaces; +using Bit.Services.Pam.Services; + +namespace Bit.Services.Pam.AccessConnector.Commands; + +/// +public class AssignAccessConnectorToTargetCommand : IAssignAccessConnectorToTargetCommand +{ + private readonly IPamAccessConnectorRepository _accessConnectorRepository; + private readonly IPamTargetSystemRepository _targetSystemRepository; + private readonly IAccessAuditEventEmitter _accessAuditEventEmitter; + private readonly TimeProvider _timeProvider; + + public AssignAccessConnectorToTargetCommand( + IPamAccessConnectorRepository accessConnectorRepository, + IPamTargetSystemRepository targetSystemRepository, + IAccessAuditEventEmitter accessAuditEventEmitter, + TimeProvider timeProvider) + { + _accessConnectorRepository = accessConnectorRepository; + _targetSystemRepository = targetSystemRepository; + _accessAuditEventEmitter = accessAuditEventEmitter; + _timeProvider = timeProvider; + } + + public async Task AssignAsync(Guid organizationId, Guid actingUserId, Guid accessConnectorId, Guid targetSystemId) + { + var accessConnector = await _accessConnectorRepository.GetByIdAsync(accessConnectorId); + if (accessConnector is null || accessConnector.OrganizationId != organizationId) + { + throw new NotFoundException(); + } + + var target = await _targetSystemRepository.GetByIdAsync(targetSystemId); + if (target is null || target.OrganizationId != organizationId) + { + throw new NotFoundException(); + } + + // Both rows matched the route organization, so the same-org invariant holds. + if (accessConnector.Status != PamAccessConnectorStatus.Enabled) + { + throw new BadRequestException("This access connector is deactivated."); + } + + if (target.Method != PamTargetSystemMethod.Automatic) + { + throw new BadRequestException("Only automatic target systems can be assigned an access connector."); + } + + if (await _accessConnectorRepository.AssignmentExistsAsync(accessConnectorId, targetSystemId)) + { + throw new BadRequestException("This access connector is already assigned to this target system."); + } + + var now = _timeProvider.GetUtcNow().UtcDateTime; + var assignment = new PamAccessConnectorTargetAssignment + { + AccessConnectorId = accessConnectorId, + TargetSystemId = targetSystemId, + OrganizationId = organizationId, + CreationDate = now, + }; + // CreateAssignmentAsync, unlike the generic CreateAsync, does not assign the id. + assignment.SetNewId(); + + // Attempt before the insert, outcome after. + var audit = new AccessAuditEventData + { + Kind = AccessAuditEventKind.AccessConnectorAssignedToTarget, + OccurredDate = now, + OrganizationId = organizationId, + ActorId = actingUserId, + AccessConnectorId = accessConnector.Id, + AccessConnectorName = accessConnector.Name, + TargetSystemId = target.Id, + TargetSystemName = target.Name, + }; + await _accessAuditEventEmitter.EmitAsync(audit with { Phase = AccessAuditEventPhase.Attempt }); + + await _accessConnectorRepository.CreateAssignmentAsync(assignment); + + await _accessAuditEventEmitter.EmitAsync(audit with { Phase = AccessAuditEventPhase.Outcome }); + } +} diff --git a/bitwarden_license/src/Services/Pam/AccessConnector/Commands/DeleteAccessConnectorCommand.cs b/bitwarden_license/src/Services/Pam/AccessConnector/Commands/DeleteAccessConnectorCommand.cs new file mode 100644 index 000000000000..914f0f775cd4 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/AccessConnector/Commands/DeleteAccessConnectorCommand.cs @@ -0,0 +1,53 @@ +using Bit.Core.Exceptions; +using Bit.Pam.Enums; +using Bit.Pam.Models; +using Bit.Pam.Repositories; +using Bit.Services.Pam.AccessConnector.Commands.Interfaces; +using Bit.Services.Pam.Services; + +namespace Bit.Services.Pam.AccessConnector.Commands; + +/// +public class DeleteAccessConnectorCommand : IDeleteAccessConnectorCommand +{ + private readonly IPamAccessConnectorRepository _accessConnectorRepository; + private readonly IAccessAuditEventEmitter _accessAuditEventEmitter; + private readonly TimeProvider _timeProvider; + + public DeleteAccessConnectorCommand( + IPamAccessConnectorRepository accessConnectorRepository, + IAccessAuditEventEmitter accessAuditEventEmitter, + TimeProvider timeProvider) + { + _accessConnectorRepository = accessConnectorRepository; + _accessAuditEventEmitter = accessAuditEventEmitter; + _timeProvider = timeProvider; + } + + public async Task DeleteAsync(Guid organizationId, Guid actingUserId, Guid accessConnectorId) + { + var accessConnector = await _accessConnectorRepository.GetByIdAsync(accessConnectorId); + if (accessConnector is null || accessConnector.OrganizationId != organizationId) + { + throw new NotFoundException(); + } + + var now = _timeProvider.GetUtcNow().UtcDateTime; + + // Attempt before the delete, outcome after. + var audit = new AccessAuditEventData + { + Kind = AccessAuditEventKind.AccessConnectorDeleted, + OccurredDate = now, + OrganizationId = organizationId, + ActorId = actingUserId, + AccessConnectorId = accessConnector.Id, + AccessConnectorName = accessConnector.Name, + }; + await _accessAuditEventEmitter.EmitAsync(audit with { Phase = AccessAuditEventPhase.Attempt }); + + await _accessConnectorRepository.DeleteAsync(accessConnector); + + await _accessAuditEventEmitter.EmitAsync(audit with { Phase = AccessAuditEventPhase.Outcome }); + } +} diff --git a/bitwarden_license/src/Services/Pam/AccessConnector/Commands/Interfaces/IAssignAccessConnectorToTargetCommand.cs b/bitwarden_license/src/Services/Pam/AccessConnector/Commands/Interfaces/IAssignAccessConnectorToTargetCommand.cs new file mode 100644 index 000000000000..49ab3dadcdaa --- /dev/null +++ b/bitwarden_license/src/Services/Pam/AccessConnector/Commands/Interfaces/IAssignAccessConnectorToTargetCommand.cs @@ -0,0 +1,10 @@ +namespace Bit.Services.Pam.AccessConnector.Commands.Interfaces; + +public interface IAssignAccessConnectorToTargetCommand +{ + /// + /// Assigns an access connector to a target system (invariant OneAssignmentPerConnectorTarget). Requires an + /// enabled access connector and an target. + /// + Task AssignAsync(Guid organizationId, Guid actingUserId, Guid accessConnectorId, Guid targetSystemId); +} diff --git a/bitwarden_license/src/Services/Pam/AccessConnector/Commands/Interfaces/IDeleteAccessConnectorCommand.cs b/bitwarden_license/src/Services/Pam/AccessConnector/Commands/Interfaces/IDeleteAccessConnectorCommand.cs new file mode 100644 index 000000000000..5adc3730a2c2 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/AccessConnector/Commands/Interfaces/IDeleteAccessConnectorCommand.cs @@ -0,0 +1,10 @@ +namespace Bit.Services.Pam.AccessConnector.Commands.Interfaces; + +public interface IDeleteAccessConnectorCommand +{ + /// + /// Permanently deletes an access connector, its target assignments and its dbo.ApiKey credential, releasing + /// its claimed jobs in the same transaction. + /// + Task DeleteAsync(Guid organizationId, Guid actingUserId, Guid accessConnectorId); +} diff --git a/bitwarden_license/src/Services/Pam/AccessConnector/Commands/Interfaces/IRegisterAccessConnectorCommand.cs b/bitwarden_license/src/Services/Pam/AccessConnector/Commands/Interfaces/IRegisterAccessConnectorCommand.cs new file mode 100644 index 000000000000..791b3f617e7c --- /dev/null +++ b/bitwarden_license/src/Services/Pam/AccessConnector/Commands/Interfaces/IRegisterAccessConnectorCommand.cs @@ -0,0 +1,14 @@ +using Bit.Services.Pam.AccessConnector.Models; + +namespace Bit.Services.Pam.AccessConnector.Commands.Interfaces; + +public interface IRegisterAccessConnectorCommand +{ + /// + /// Registers a new access connector with a dbo.ApiKey credential scoped to api.pam.rotation. + /// and arrive wrapped client-side; the client secret is + /// stored only as a hash. + /// + Task RegisterAsync( + Guid organizationId, Guid actingUserId, string name, string encryptedPayload, string key); +} diff --git a/bitwarden_license/src/Services/Pam/AccessConnector/Commands/Interfaces/ISetAccessConnectorStatusCommand.cs b/bitwarden_license/src/Services/Pam/AccessConnector/Commands/Interfaces/ISetAccessConnectorStatusCommand.cs new file mode 100644 index 000000000000..c98cfd91c379 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/AccessConnector/Commands/Interfaces/ISetAccessConnectorStatusCommand.cs @@ -0,0 +1,10 @@ +namespace Bit.Services.Pam.AccessConnector.Commands.Interfaces; + +public interface ISetAccessConnectorStatusCommand +{ + /// + /// Enables or disables an access connector. A disabled access connector keeps its credential but can neither + /// authenticate nor see or claim jobs. + /// + Task SetStatusAsync(Guid organizationId, Guid actingUserId, Guid accessConnectorId, bool enable); +} diff --git a/bitwarden_license/src/Services/Pam/AccessConnector/Commands/Interfaces/IUnassignAccessConnectorFromTargetCommand.cs b/bitwarden_license/src/Services/Pam/AccessConnector/Commands/Interfaces/IUnassignAccessConnectorFromTargetCommand.cs new file mode 100644 index 000000000000..196e2e7403be --- /dev/null +++ b/bitwarden_license/src/Services/Pam/AccessConnector/Commands/Interfaces/IUnassignAccessConnectorFromTargetCommand.cs @@ -0,0 +1,7 @@ +namespace Bit.Services.Pam.AccessConnector.Commands.Interfaces; + +public interface IUnassignAccessConnectorFromTargetCommand +{ + /// Removes an access connector's assignment to a target system. + Task UnassignAsync(Guid organizationId, Guid actingUserId, Guid accessConnectorId, Guid targetSystemId); +} diff --git a/bitwarden_license/src/Services/Pam/AccessConnector/Commands/RegisterAccessConnectorCommand.cs b/bitwarden_license/src/Services/Pam/AccessConnector/Commands/RegisterAccessConnectorCommand.cs new file mode 100644 index 000000000000..a722203f3676 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/AccessConnector/Commands/RegisterAccessConnectorCommand.cs @@ -0,0 +1,100 @@ +using System.Security.Cryptography; +using System.Text; +using Bit.Core.Exceptions; +using Bit.Core.SecretsManager.Entities; +using Bit.Core.SecretsManager.Repositories; +using Bit.Core.Utilities; +using Bit.Pam.Entities; +using Bit.Pam.Enums; +using Bit.Pam.Models; +using Bit.Pam.Repositories; +using Bit.Services.Pam.AccessConnector.Commands.Interfaces; +using Bit.Services.Pam.AccessConnector.Models; +using Bit.Services.Pam.Services; + +namespace Bit.Services.Pam.AccessConnector.Commands; + +/// +public class RegisterAccessConnectorCommand : IRegisterAccessConnectorCommand +{ + private const string AccessConnectorScope = "[\"api.pam.rotation\"]"; + private const int ClientSecretLength = 30; + + private readonly IApiKeyRepository _apiKeyRepository; + private readonly IPamAccessConnectorRepository _accessConnectorRepository; + private readonly IAccessAuditEventEmitter _accessAuditEventEmitter; + private readonly TimeProvider _timeProvider; + + public RegisterAccessConnectorCommand( + IApiKeyRepository apiKeyRepository, + IPamAccessConnectorRepository accessConnectorRepository, + IAccessAuditEventEmitter accessAuditEventEmitter, + TimeProvider timeProvider) + { + _apiKeyRepository = apiKeyRepository; + _accessConnectorRepository = accessConnectorRepository; + _accessAuditEventEmitter = accessAuditEventEmitter; + _timeProvider = timeProvider; + } + + public async Task RegisterAsync( + Guid organizationId, Guid actingUserId, string name, string encryptedPayload, string key) + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new BadRequestException("Name is required."); + } + + var now = _timeProvider.GetUtcNow().UtcDateTime; + + // Attempt before either row is written, outcome after both exist. + var audit = new AccessAuditEventData + { + Kind = AccessAuditEventKind.AccessConnectorRegistered, + OccurredDate = now, + OrganizationId = organizationId, + ActorId = actingUserId, + AccessConnectorName = name, + }; + await _accessAuditEventEmitter.EmitAsync(audit with { Phase = AccessAuditEventPhase.Attempt }); + + // A generic dbo.ApiKey row (null ServiceAccountId) in the Secrets Manager credential store, hashed as + // CreateAccessTokenCommand does so Identity verifies it the same way. + var clientSecret = CoreHelpers.SecureRandomString(ClientSecretLength); + var apiKey = new ApiKey + { + ServiceAccountId = null, + Name = name, + ClientSecretHash = Hash(clientSecret), + Scope = AccessConnectorScope, + EncryptedPayload = encryptedPayload, + Key = key, + }; + var createdApiKey = await _apiKeyRepository.CreateAsync(apiKey); + + var accessConnector = new PamAccessConnector + { + OrganizationId = organizationId, + Name = name, + ApiKeyId = createdApiKey.Id, + Status = PamAccessConnectorStatus.Enabled, + CreationDate = now, + RevisionDate = now, + }; + var createdAccessConnector = await _accessConnectorRepository.CreateAsync(accessConnector); + + await _accessAuditEventEmitter.EmitAsync( + audit with { Phase = AccessAuditEventPhase.Outcome, AccessConnectorId = createdAccessConnector.Id }); + + // The plaintext secret leaves the server only here; it is never persisted or logged. + return new PamAccessConnectorRegistrationResult(createdAccessConnector, clientSecret); + } + + private static string Hash(string input) + { + using var sha = SHA256.Create(); + var bytes = Encoding.UTF8.GetBytes(input); + var hash = sha.ComputeHash(bytes); + return Convert.ToBase64String(hash); + } +} diff --git a/bitwarden_license/src/Services/Pam/AccessConnector/Commands/SetAccessConnectorStatusCommand.cs b/bitwarden_license/src/Services/Pam/AccessConnector/Commands/SetAccessConnectorStatusCommand.cs new file mode 100644 index 000000000000..16de6f5ef9a5 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/AccessConnector/Commands/SetAccessConnectorStatusCommand.cs @@ -0,0 +1,63 @@ +using Bit.Core.Exceptions; +using Bit.Pam.Enums; +using Bit.Pam.Models; +using Bit.Pam.Repositories; +using Bit.Services.Pam.AccessConnector.Commands.Interfaces; +using Bit.Services.Pam.Services; + +namespace Bit.Services.Pam.AccessConnector.Commands; + +/// +public class SetAccessConnectorStatusCommand : ISetAccessConnectorStatusCommand +{ + private readonly IPamAccessConnectorRepository _accessConnectorRepository; + private readonly IAccessAuditEventEmitter _accessAuditEventEmitter; + private readonly TimeProvider _timeProvider; + + public SetAccessConnectorStatusCommand( + IPamAccessConnectorRepository accessConnectorRepository, + IAccessAuditEventEmitter accessAuditEventEmitter, + TimeProvider timeProvider) + { + _accessConnectorRepository = accessConnectorRepository; + _accessAuditEventEmitter = accessAuditEventEmitter; + _timeProvider = timeProvider; + } + + public async Task SetStatusAsync(Guid organizationId, Guid actingUserId, Guid accessConnectorId, bool enable) + { + var accessConnector = await _accessConnectorRepository.GetByIdAsync(accessConnectorId); + if (accessConnector is null || accessConnector.OrganizationId != organizationId) + { + throw new NotFoundException(); + } + + var desired = enable ? PamAccessConnectorStatus.Enabled : PamAccessConnectorStatus.Disabled; + if (accessConnector.Status == desired) + { + throw new BadRequestException(enable + ? "This access connector is already active." + : "This access connector is already deactivated."); + } + + var now = _timeProvider.GetUtcNow().UtcDateTime; + + // Attempt before the write, outcome after. + var audit = new AccessAuditEventData + { + Kind = enable ? AccessAuditEventKind.AccessConnectorEnabled : AccessAuditEventKind.AccessConnectorDisabled, + OccurredDate = now, + OrganizationId = organizationId, + ActorId = actingUserId, + AccessConnectorId = accessConnector.Id, + AccessConnectorName = accessConnector.Name, + }; + await _accessAuditEventEmitter.EmitAsync(audit with { Phase = AccessAuditEventPhase.Attempt }); + + accessConnector.Status = desired; + accessConnector.RevisionDate = now; + await _accessConnectorRepository.ReplaceAsync(accessConnector); + + await _accessAuditEventEmitter.EmitAsync(audit with { Phase = AccessAuditEventPhase.Outcome }); + } +} diff --git a/bitwarden_license/src/Services/Pam/AccessConnector/Commands/UnassignAccessConnectorFromTargetCommand.cs b/bitwarden_license/src/Services/Pam/AccessConnector/Commands/UnassignAccessConnectorFromTargetCommand.cs new file mode 100644 index 000000000000..7c96f2956367 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/AccessConnector/Commands/UnassignAccessConnectorFromTargetCommand.cs @@ -0,0 +1,67 @@ +using Bit.Core.Exceptions; +using Bit.Pam.Enums; +using Bit.Pam.Models; +using Bit.Pam.Repositories; +using Bit.Services.Pam.AccessConnector.Commands.Interfaces; +using Bit.Services.Pam.Services; + +namespace Bit.Services.Pam.AccessConnector.Commands; + +/// +public class UnassignAccessConnectorFromTargetCommand : IUnassignAccessConnectorFromTargetCommand +{ + private readonly IPamAccessConnectorRepository _accessConnectorRepository; + private readonly IPamTargetSystemRepository _targetSystemRepository; + private readonly IAccessAuditEventEmitter _accessAuditEventEmitter; + private readonly TimeProvider _timeProvider; + + public UnassignAccessConnectorFromTargetCommand( + IPamAccessConnectorRepository accessConnectorRepository, + IPamTargetSystemRepository targetSystemRepository, + IAccessAuditEventEmitter accessAuditEventEmitter, + TimeProvider timeProvider) + { + _accessConnectorRepository = accessConnectorRepository; + _targetSystemRepository = targetSystemRepository; + _accessAuditEventEmitter = accessAuditEventEmitter; + _timeProvider = timeProvider; + } + + public async Task UnassignAsync(Guid organizationId, Guid actingUserId, Guid accessConnectorId, Guid targetSystemId) + { + var accessConnector = await _accessConnectorRepository.GetByIdAsync(accessConnectorId); + if (accessConnector is null || accessConnector.OrganizationId != organizationId) + { + throw new NotFoundException(); + } + + var target = await _targetSystemRepository.GetByIdAsync(targetSystemId); + if (target is null || target.OrganizationId != organizationId) + { + throw new NotFoundException(); + } + + if (!await _accessConnectorRepository.AssignmentExistsAsync(accessConnectorId, targetSystemId)) + { + throw new NotFoundException(); + } + + var now = _timeProvider.GetUtcNow().UtcDateTime; + var audit = new AccessAuditEventData + { + Kind = AccessAuditEventKind.AccessConnectorUnassignedFromTarget, + OccurredDate = now, + OrganizationId = organizationId, + ActorId = actingUserId, + AccessConnectorId = accessConnector.Id, + AccessConnectorName = accessConnector.Name, + TargetSystemId = target.Id, + TargetSystemName = target.Name, + }; + await _accessAuditEventEmitter.EmitAsync(audit with { Phase = AccessAuditEventPhase.Attempt }); + + await _accessConnectorRepository.DeleteAssignmentAsync(accessConnectorId, targetSystemId); + + await _accessAuditEventEmitter.EmitAsync(audit with { Phase = AccessAuditEventPhase.Outcome }); + } +} diff --git a/bitwarden_license/src/Services/Pam/AccessConnector/Models/PamAccessConnectorHistory.cs b/bitwarden_license/src/Services/Pam/AccessConnector/Models/PamAccessConnectorHistory.cs new file mode 100644 index 000000000000..b8b804f098f0 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/AccessConnector/Models/PamAccessConnectorHistory.cs @@ -0,0 +1,11 @@ +using Bit.Pam.Models; + +namespace Bit.Services.Pam.AccessConnector.Models; + +/// +/// An access connector's detail view: its list projection plus its recent jobs, newest first, each with only the +/// attempts it recorded. +/// +public sealed record PamAccessConnectorHistory( + PamAccessConnectorListItem AccessConnector, + IReadOnlyList Jobs); diff --git a/bitwarden_license/src/Services/Pam/AccessConnector/Models/PamAccessConnectorListItem.cs b/bitwarden_license/src/Services/Pam/AccessConnector/Models/PamAccessConnectorListItem.cs new file mode 100644 index 000000000000..7a6f289c3f25 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/AccessConnector/Models/PamAccessConnectorListItem.cs @@ -0,0 +1,11 @@ +using Bit.Pam.Entities; + +namespace Bit.Services.Pam.AccessConnector.Models; + +/// +/// An access connector with its derived liveness (spec ConnectorConnection) and assigned target systems. +/// +public sealed record PamAccessConnectorListItem( + PamAccessConnector AccessConnector, + bool IsConnected, + IReadOnlyList AssignedTargetSystemIds); diff --git a/bitwarden_license/src/Services/Pam/AccessConnector/Models/PamAccessConnectorRegistrationResult.cs b/bitwarden_license/src/Services/Pam/AccessConnector/Models/PamAccessConnectorRegistrationResult.cs new file mode 100644 index 000000000000..a6ec958df3b2 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/AccessConnector/Models/PamAccessConnectorRegistrationResult.cs @@ -0,0 +1,9 @@ +using Bit.Pam.Entities; + +namespace Bit.Services.Pam.AccessConnector.Models; + +/// +/// The result of registering an access connector. is the plaintext secret, returned only +/// here and never persisted or logged. +/// +public sealed record PamAccessConnectorRegistrationResult(PamAccessConnector AccessConnector, string ClientSecret); diff --git a/bitwarden_license/src/Services/Pam/AccessConnector/PamRotationOptions.cs b/bitwarden_license/src/Services/Pam/AccessConnector/PamRotationOptions.cs new file mode 100644 index 000000000000..16158031f943 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/AccessConnector/PamRotationOptions.cs @@ -0,0 +1,40 @@ +namespace Bit.Services.Pam.AccessConnector; + +/// +/// Timing settings for PAM credential rotation, bound from globalSettings:pam:rotation. The defaults below +/// apply to an unconfigured environment. +/// +public class PamRotationOptions +{ + /// How long a rotation job may live before the sweep times it out (spec JobTimesOut). + public TimeSpan JobTtl { get; set; } = TimeSpan.FromHours(1); + + /// The number of Errored attempts a job may accrue before it fails outright. + public int MaxAttempts { get; set; } = 5; + + /// The base of the exponential retry backoff: RetryBaseDelay * 2^(erroredCount-1). + public TimeSpan RetryBaseDelay { get; set; } = TimeSpan.FromSeconds(1); + + /// + /// The claim lease length. Past it, the release sweep may reclaim a job whose access connector has gone offline. + /// + public TimeSpan ReleaseDelay { get; set; } = TimeSpan.FromMinutes(15); + + /// How far a scheduled config's next rotation is pushed out after its job fails or times out. + public TimeSpan FailureRetryDelay { get; set; } = TimeSpan.FromHours(1); + + /// + /// How long after its last heartbeat an access connector still counts as connected (spec + /// ConnectorConnection). + /// + public TimeSpan AccessConnectorOfflineAfter { get; set; } = TimeSpan.FromMinutes(5); + + /// Minimum gap between heartbeat writes, so a polling access connector does not hammer its row. + public TimeSpan HeartbeatMinInterval { get; set; } = TimeSpan.FromMinutes(1); + + /// Minimum gap between two consecutive occurrences of a config's cron. + public TimeSpan MinScheduleInterval { get; set; } = TimeSpan.FromMinutes(15); + + /// Minimum gap between a config's last rotation and an on-demand trigger (abuse floor). + public TimeSpan OnDemandCooldown { get; set; } = TimeSpan.FromMinutes(1); +} diff --git a/bitwarden_license/src/Services/Pam/AccessConnector/Queries/GetAccessConnectorDetailsQuery.cs b/bitwarden_license/src/Services/Pam/AccessConnector/Queries/GetAccessConnectorDetailsQuery.cs new file mode 100644 index 000000000000..318e1b2db7d7 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/AccessConnector/Queries/GetAccessConnectorDetailsQuery.cs @@ -0,0 +1,54 @@ +using Bit.Core.Exceptions; +using Bit.Pam; +using Bit.Pam.Repositories; +using Bit.Services.Pam.AccessConnector.Models; +using Bit.Services.Pam.AccessConnector.Queries.Interfaces; +using Bit.Services.Pam.OrganizationFeatures.Queries; +using Microsoft.Extensions.Options; + +namespace Bit.Services.Pam.AccessConnector.Queries; + +/// +public class GetAccessConnectorDetailsQuery : IGetAccessConnectorDetailsQuery +{ + /// Caps the read, since an access connector accumulates a job per rotation it ever executes. + private const int RecentJobLimit = 50; + + private readonly IPamAccessConnectorRepository _accessConnectorRepository; + private readonly IPamRotationJobRepository _jobRepository; + private readonly IOptions _options; + private readonly TimeProvider _timeProvider; + + public GetAccessConnectorDetailsQuery( + IPamAccessConnectorRepository accessConnectorRepository, + IPamRotationJobRepository jobRepository, + IOptions options, + TimeProvider timeProvider) + { + _accessConnectorRepository = accessConnectorRepository; + _jobRepository = jobRepository; + _options = options; + _timeProvider = timeProvider; + } + + public async Task GetAsync(Guid organizationId, Guid accessConnectorId) + { + var accessConnector = await _accessConnectorRepository.GetByIdAsync(accessConnectorId); + if (accessConnector is null || accessConnector.OrganizationId != organizationId) + { + throw new NotFoundException(); + } + + var now = _timeProvider.GetUtcNow().UtcDateTime; + var assignments = await _accessConnectorRepository.GetAssignmentsByOrganizationIdAsync(organizationId); + var jobs = await _jobRepository.GetManyRecentByAccessConnectorIdAsync( + accessConnectorId, RecentJobLimit, now.AddDays(-AccessHistoryWindow.RetentionDays), now); + + var listItem = new PamAccessConnectorListItem( + accessConnector, + PamRotationRules.IsConnected(accessConnector, now, _options.Value.AccessConnectorOfflineAfter), + assignments.Where(a => a.AccessConnectorId == accessConnectorId).Select(a => a.TargetSystemId).ToList()); + + return new PamAccessConnectorHistory(listItem, jobs.ToList()); + } +} diff --git a/bitwarden_license/src/Services/Pam/AccessConnector/Queries/Interfaces/IGetAccessConnectorDetailsQuery.cs b/bitwarden_license/src/Services/Pam/AccessConnector/Queries/Interfaces/IGetAccessConnectorDetailsQuery.cs new file mode 100644 index 000000000000..b194b85bbff8 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/AccessConnector/Queries/Interfaces/IGetAccessConnectorDetailsQuery.cs @@ -0,0 +1,12 @@ +using Bit.Services.Pam.AccessConnector.Models; + +namespace Bit.Services.Pam.AccessConnector.Queries.Interfaces; + +public interface IGetAccessConnectorDetailsQuery +{ + /// + /// An access connector's detail view. Throws if it does not + /// exist or belongs to another organization. + /// + Task GetAsync(Guid organizationId, Guid accessConnectorId); +} diff --git a/bitwarden_license/src/Services/Pam/AccessConnector/Queries/Interfaces/IListAccessConnectorsQuery.cs b/bitwarden_license/src/Services/Pam/AccessConnector/Queries/Interfaces/IListAccessConnectorsQuery.cs new file mode 100644 index 000000000000..2d2cadd614b0 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/AccessConnector/Queries/Interfaces/IListAccessConnectorsQuery.cs @@ -0,0 +1,9 @@ +using Bit.Services.Pam.AccessConnector.Models; + +namespace Bit.Services.Pam.AccessConnector.Queries.Interfaces; + +public interface IListAccessConnectorsQuery +{ + /// An organization's access connectors, with derived connection state and target assignments. + Task> ListAsync(Guid organizationId); +} diff --git a/bitwarden_license/src/Services/Pam/AccessConnector/Queries/ListAccessConnectorsQuery.cs b/bitwarden_license/src/Services/Pam/AccessConnector/Queries/ListAccessConnectorsQuery.cs new file mode 100644 index 000000000000..5e8a436ff6d0 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/AccessConnector/Queries/ListAccessConnectorsQuery.cs @@ -0,0 +1,44 @@ +using Bit.Pam; +using Bit.Pam.Repositories; +using Bit.Services.Pam.AccessConnector.Models; +using Bit.Services.Pam.AccessConnector.Queries.Interfaces; +using Microsoft.Extensions.Options; + +namespace Bit.Services.Pam.AccessConnector.Queries; + +/// +public class ListAccessConnectorsQuery : IListAccessConnectorsQuery +{ + private readonly IPamAccessConnectorRepository _accessConnectorRepository; + private readonly IOptions _options; + private readonly TimeProvider _timeProvider; + + public ListAccessConnectorsQuery( + IPamAccessConnectorRepository accessConnectorRepository, + IOptions options, + TimeProvider timeProvider) + { + _accessConnectorRepository = accessConnectorRepository; + _options = options; + _timeProvider = timeProvider; + } + + public async Task> ListAsync(Guid organizationId) + { + var accessConnectors = await _accessConnectorRepository.GetManyByOrganizationIdAsync(organizationId); + var assignments = await _accessConnectorRepository.GetAssignmentsByOrganizationIdAsync(organizationId); + var assignmentsByAccessConnector = assignments + .GroupBy(a => a.AccessConnectorId) + .ToDictionary(g => g.Key, g => (IReadOnlyList)g.Select(a => a.TargetSystemId).ToList()); + + var now = _timeProvider.GetUtcNow().UtcDateTime; + var offlineAfter = _options.Value.AccessConnectorOfflineAfter; + + return accessConnectors + .Select(accessConnector => new PamAccessConnectorListItem( + accessConnector, + PamRotationRules.IsConnected(accessConnector, now, offlineAfter), + assignmentsByAccessConnector.TryGetValue(accessConnector.Id, out var targetIds) ? targetIds : [])) + .ToList(); + } +} diff --git a/bitwarden_license/src/Services/Pam/AccessConnector/Rotation/Api/Models/Response/PamRotationAttemptResponseModel.cs b/bitwarden_license/src/Services/Pam/AccessConnector/Rotation/Api/Models/Response/PamRotationAttemptResponseModel.cs index f4628c589a13..8b3d7c965e89 100644 --- a/bitwarden_license/src/Services/Pam/AccessConnector/Rotation/Api/Models/Response/PamRotationAttemptResponseModel.cs +++ b/bitwarden_license/src/Services/Pam/AccessConnector/Rotation/Api/Models/Response/PamRotationAttemptResponseModel.cs @@ -1,10 +1,28 @@ using Bit.Pam.Enums; +using Bit.Pam.Models; +using Bit.Services.Pam.Api.Models.Response; namespace Bit.Services.Pam.AccessConnector.Rotation.Api.Models.Response; /// One access connector's try at executing a rotation job. public class PamRotationAttemptResponseModel { + public PamRotationAttemptResponseModel(PamRotationAttemptDetails attempt) + { + ArgumentNullException.ThrowIfNull(attempt); + + Id = attempt.Id; + JobId = attempt.JobId; + ClaimedByAccessConnectorId = attempt.ClaimedByAccessConnectorId; + CipherUpdated = attempt.CipherUpdated; + Status = attempt.Status; + FailureReason = attempt.FailureReason; + SyncState = attempt.SyncState; + SessionTermination = attempt.SessionTermination; + CreationDate = attempt.CreationDate.AsUtc(); + ResolvedDate = attempt.ResolvedDate.AsUtc(); + } + public Guid Id { get; set; } public Guid JobId { get; set; } diff --git a/bitwarden_license/src/Services/Pam/AccessConnector/Rotation/Api/Models/Response/PamRotationJobResponseModel.cs b/bitwarden_license/src/Services/Pam/AccessConnector/Rotation/Api/Models/Response/PamRotationJobResponseModel.cs index a01185f89cd2..3256666f95a4 100644 --- a/bitwarden_license/src/Services/Pam/AccessConnector/Rotation/Api/Models/Response/PamRotationJobResponseModel.cs +++ b/bitwarden_license/src/Services/Pam/AccessConnector/Rotation/Api/Models/Response/PamRotationJobResponseModel.cs @@ -1,10 +1,28 @@ using Bit.Pam.Enums; +using Bit.Pam.Models; +using Bit.Services.Pam.Api.Models.Response; namespace Bit.Services.Pam.AccessConnector.Rotation.Api.Models.Response; /// One offer of rotation work for a config, with its attempts. public class PamRotationJobResponseModel { + public PamRotationJobResponseModel(PamRotationJobDetails job) + { + ArgumentNullException.ThrowIfNull(job); + + Id = job.Id; + RotationConfigId = job.RotationConfigId; + Source = job.Source; + Status = job.Status; + ClaimedByAccessConnectorId = job.ClaimedByAccessConnectorId; + ClaimedAt = job.ClaimedDate.AsUtc(); + CreationDate = job.CreationDate.AsUtc(); + NextClaimableAt = job.NextClaimableDate.AsUtc(); + ExpiresAt = job.ExpirationDate.AsUtc(); + Attempts = job.Attempts.Select(attempt => new PamRotationAttemptResponseModel(attempt)).ToList(); + } + public Guid Id { get; set; } public Guid RotationConfigId { get; set; } diff --git a/bitwarden_license/src/Services/Pam/Api/Endpoints/PamEndpointsExtensions.cs b/bitwarden_license/src/Services/Pam/Api/Endpoints/PamEndpointsExtensions.cs index f9c51b6fcc00..12b0cba72efb 100644 --- a/bitwarden_license/src/Services/Pam/Api/Endpoints/PamEndpointsExtensions.cs +++ b/bitwarden_license/src/Services/Pam/Api/Endpoints/PamEndpointsExtensions.cs @@ -4,6 +4,7 @@ using Bit.OrganizationAuthorization; using Bit.Services.Pam.AccessConnector.Api.Authorization; using Bit.Services.Pam.AccessConnector.Api.Endpoints; +using Bit.Services.Pam.AccessConnector.Api.Endpoints.Filters; using Bit.Services.Pam.AccessConnector.Rotation.Api.Endpoints; using Bit.Services.Pam.Api.Endpoints.Filters; @@ -57,15 +58,14 @@ private static RouteGroupBuilder WithPamAccessConnectorAdminDefaults(this RouteG } /// - /// The connector-facing surface. These routes carry no {orgId} and no organization requirement: a connector's - /// organization comes from its token, and the work queries scope every read and write to it. + /// is added last, so a disabled flag or malformed body + /// short-circuits ahead of the heartbeat write. The organization comes from the connector's token. /// - /// TODO(PM-39040): these routes belong behind the machine-credential policy, not the user-token - /// . That policy — and the client type and API scope it asserts on — lands - /// with the connector identity wiring, which must merge before the handlers below stop throwing. + /// TODO(PM-39040): rate-limit this group by client_id. /// private static RouteGroupBuilder WithPamAccessConnectorMachineDefaults(this RouteGroupBuilder group) => - group.WithPamDefaults(Policies.Application, FeatureFlagKeys.PamAccessConnector); + group.WithPamDefaults(Policies.AccessConnector, FeatureFlagKeys.PamAccessConnector) + .AddEndpointFilter(); /// /// Applies the shared PAM endpoint chain to a group for the given authorization policy and feature flag. Order diff --git a/bitwarden_license/src/Services/Pam/Utilities/ServiceCollectionExtensions.cs b/bitwarden_license/src/Services/Pam/Utilities/ServiceCollectionExtensions.cs index 2777d87fe377..ca745e19ac8a 100644 --- a/bitwarden_license/src/Services/Pam/Utilities/ServiceCollectionExtensions.cs +++ b/bitwarden_license/src/Services/Pam/Utilities/ServiceCollectionExtensions.cs @@ -1,6 +1,12 @@ using Bit.Core.Pam.Services; using Bit.HttpExtensions; +using Bit.Services.Pam.AccessConnector; +using Bit.Services.Pam.AccessConnector.Api.Endpoints.Filters; using Bit.Services.Pam.AccessConnector.Api.Endpoints.Handlers; +using Bit.Services.Pam.AccessConnector.Commands; +using Bit.Services.Pam.AccessConnector.Commands.Interfaces; +using Bit.Services.Pam.AccessConnector.Queries; +using Bit.Services.Pam.AccessConnector.Queries.Interfaces; using Bit.Services.Pam.AccessConnector.Rotation.Api.Endpoints.Handlers; using Bit.Services.Pam.Api.Endpoints; using Bit.Services.Pam.Api.Endpoints.Handlers; @@ -10,13 +16,18 @@ using Bit.Services.Pam.OrganizationFeatures.Queries; using Bit.Services.Pam.OrganizationFeatures.Queries.Interfaces; using Bit.Services.Pam.Services; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection.Extensions; namespace Bit.Services.Pam.Utilities; public static class ServiceCollectionExtensions { - public static IServiceCollection AddPamServices(this IServiceCollection services) + /// + /// Registers PAM's commercial services, including credential rotation. binds + /// from globalSettings:pam:rotation. + /// + public static IServiceCollection AddPamServices(this IServiceCollection services, IConfiguration configuration) { services.AddScoped(); services.AddScoped(); @@ -77,11 +88,33 @@ public static IServiceCollection AddPamServices(this IServiceCollection services services.TryAddScoped(); services.TryAddScoped(); + services.AddScoped(); + + services.AddPamRotationServices(configuration); services.AddPamOpenApiEndpointDataSource(); return services; } + /// + /// Registers credential rotation. The repositories are registered elsewhere. + /// + private static IServiceCollection AddPamRotationServices(this IServiceCollection services, IConfiguration configuration) + { + services.Configure(configuration.GetSection("globalSettings:pam:rotation")); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + services.AddScoped(); + services.AddScoped(); + + return services; + } + /// /// Exposes the PAM endpoints to the offline OpenAPI generator (dotnet swagger tofile), which never runs the /// Configure pipeline that maps them. diff --git a/bitwarden_license/test/Services/Pam.IntegrationTest/AccessConnectorAuthorizationTests.cs b/bitwarden_license/test/Services/Pam.IntegrationTest/AccessConnectorAuthorizationTests.cs new file mode 100644 index 000000000000..0b389f5af154 --- /dev/null +++ b/bitwarden_license/test/Services/Pam.IntegrationTest/AccessConnectorAuthorizationTests.cs @@ -0,0 +1,146 @@ +using System.Net; +using System.Net.Http.Json; +using Bit.Api.IntegrationTest.Factories; +using Bit.Api.IntegrationTest.Helpers; +using Bit.Core; +using Bit.Core.Enums; +using Bit.Core.Models.Data; +using NSubstitute; +using Xunit; + +namespace Bit.Services.Pam.IntegrationTest; + +/// +/// Authorization for the organizations/{orgId}/access-connectors admin surface over the real request pipeline. +/// One route per group is enough, since the requirement is applied to the parent group. +/// +public class AccessConnectorAuthorizationTests(ApiApplicationFactory factory) + : AccessRuleIntegrationTestBase(factory, "pam-connector-authz") +{ + public override async Task InitializeAsync() + { + await base.InitializeAsync(); + // Connector groups sit behind their own flag instead of the base PAM flag. + FeatureService.IsEnabled(FeatureFlagKeys.PamAccessConnector).Returns(true); + } + + private string ConnectorUrl(string resource) => + $"organizations/{Organization.Id}/access-connectors{resource}"; + + [Theory] + [InlineData("")] + [InlineData("/rotation/target-systems")] + [InlineData("/rotation/configs")] + public async Task Read_AsNonMember_ReturnsForbidden(string resource) + { + var outsiderEmail = $"outsider-{Guid.NewGuid()}@bitwarden.com"; + await Factory.LoginWithNewAccount(outsiderEmail); + await LoginHelper.LoginAsync(outsiderEmail); + + var response = await Client.GetAsync(ConnectorUrl(resource)); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Theory] + [InlineData("")] + [InlineData("/rotation/target-systems")] + [InlineData("/rotation/configs")] + public async Task Read_AsPlainMember_ReturnsForbidden(string resource) + { + var (memberEmail, _) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(Factory, + Organization.Id, OrganizationUserType.User); + await LoginHelper.LoginAsync(memberEmail); + + var response = await Client.GetAsync(ConnectorUrl(resource)); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Theory] + [InlineData("")] + [InlineData("/rotation/target-systems")] + [InlineData("/rotation/configs")] + public async Task Read_AsCustomUserWithManageAccessRules_ReturnsForbidden(string resource) + { + // ManageAccessRules governs who may lease a credential, not the access connectors that rotate it. + var (customEmail, _) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(Factory, + Organization.Id, OrganizationUserType.Custom, new Permissions { ManageAccessRules = true }); + await LoginHelper.LoginAsync(customEmail); + + var response = await Client.GetAsync(ConnectorUrl(resource)); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Theory] + [InlineData("")] + [InlineData("/rotation/target-systems")] + [InlineData("/rotation/configs")] + public async Task Read_AsProviderUserForTheOrganization_ReturnsForbidden(string resource) + { + // A registered access connector is handed the organization key, which is not a provider's to hold. + await LoginAsProviderForOrganizationAsync(); + + var response = await Client.GetAsync(ConnectorUrl(resource)); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task Write_AsPlainMember_ReturnsForbidden() + { + var (memberEmail, _) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(Factory, + Organization.Id, OrganizationUserType.User); + await LoginHelper.LoginAsync(memberEmail); + + var response = await Client.PostAsJsonAsync(ConnectorUrl(""), new { }); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Theory] + [InlineData("")] + [InlineData("/rotation/target-systems")] + [InlineData("/rotation/configs")] + public async Task Read_AsOwner_IsNotForbidden(string resource) + { + await LoginHelper.LoginAsync(OwnerEmail); + + var response = await Client.GetAsync(ConnectorUrl(resource)); + + AssertReachedTheHandler(response); + } + + [Theory] + [InlineData("")] + [InlineData("/rotation/target-systems")] + [InlineData("/rotation/configs")] + public async Task Read_AsAdmin_IsNotForbidden(string resource) + { + var (adminEmail, _) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(Factory, + Organization.Id, OrganizationUserType.Admin); + await LoginHelper.LoginAsync(adminEmail); + + var response = await Client.GetAsync(ConnectorUrl(resource)); + + AssertReachedTheHandler(response); + } + + [Fact] + public async Task Write_AsOwner_IsNotForbidden() + { + await LoginHelper.LoginAsync(OwnerEmail); + + var response = await Client.PostAsJsonAsync(ConnectorUrl(""), new { }); + + AssertReachedTheHandler(response); + } + + /// Also rejects NotFound, so a feature gate swallowing the route does not pass as authorized. + private static void AssertReachedTheHandler(HttpResponseMessage response) + { + Assert.NotEqual(HttpStatusCode.Forbidden, response.StatusCode); + Assert.NotEqual(HttpStatusCode.NotFound, response.StatusCode); + } +} diff --git a/bitwarden_license/test/Services/Pam.Test/AccessConnector/Api/AccessConnectorHeartbeatEndpointFilterTests.cs b/bitwarden_license/test/Services/Pam.Test/AccessConnector/Api/AccessConnectorHeartbeatEndpointFilterTests.cs new file mode 100644 index 000000000000..0b9d3fcbec3d --- /dev/null +++ b/bitwarden_license/test/Services/Pam.Test/AccessConnector/Api/AccessConnectorHeartbeatEndpointFilterTests.cs @@ -0,0 +1,95 @@ +using System.Runtime.CompilerServices; +using Bit.Core.Context; +using Bit.Core.Exceptions; +using Bit.Pam.Repositories; +using Bit.Services.Pam.AccessConnector; +using Bit.Services.Pam.AccessConnector.Api.Endpoints.Filters; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Time.Testing; +using NSubstitute; +using Xunit; + +namespace Bit.Services.Pam.Test.AccessConnector.Api; + +/// +/// The filter does not check connector eligibility; PamAccessConnectorClientProviderTests and +/// PamRotationJobRepositoryTests cover it. +/// +public class AccessConnectorHeartbeatEndpointFilterTests +{ + private static readonly DateTime _now = new(2026, 6, 5, 12, 0, 0, DateTimeKind.Utc); + private static readonly TimeSpan _heartbeatMinInterval = TimeSpan.FromSeconds(15); + + [Fact] + public async Task InvokeAsync_NoPamAccessConnectorIdInContext_ThrowsNotFound_SkipsNext() + { + var currentContext = Substitute.For(); + currentContext.PamAccessConnectorId.Returns((Guid?)null); + var accessConnectorRepository = Substitute.For(); + var (context, nextCalled) = CreateContext(currentContext, accessConnectorRepository); + + await Assert.ThrowsAsync( + () => new AccessConnectorHeartbeatEndpointFilter().InvokeAsync(context, NextDelegate(nextCalled)).AsTask()); + + Assert.False(nextCalled.Value); + await accessConnectorRepository.DidNotReceiveWithAnyArgs().UpdateHeartbeatAsync(default, default, default); + } + + [Fact] + public async Task InvokeAsync_AccessConnectorIdInContext_BumpsHeartbeatAndCallsNext() + { + var accessConnectorId = Guid.NewGuid(); + var currentContext = Substitute.For(); + currentContext.PamAccessConnectorId.Returns(accessConnectorId); + var accessConnectorRepository = Substitute.For(); + var (context, nextCalled) = CreateContext(currentContext, accessConnectorRepository); + + var result = await new AccessConnectorHeartbeatEndpointFilter().InvokeAsync(context, NextDelegate(nextCalled)); + + Assert.True(nextCalled.Value); + Assert.Equal("ok", result); + await accessConnectorRepository + .Received(1).UpdateHeartbeatAsync(accessConnectorId, _now, _heartbeatMinInterval); + } + + /// + /// The id comes off the token, so the poll route a connector hits continuously pays one conditional write and no + /// reads. + /// + [Fact] + public async Task InvokeAsync_AccessConnectorIdInContext_WritesTheHeartbeatWithoutReadingTheAccessConnectorRow() + { + var accessConnectorId = Guid.NewGuid(); + var currentContext = Substitute.For(); + currentContext.PamAccessConnectorId.Returns(accessConnectorId); + var accessConnectorRepository = Substitute.For(); + var (context, nextCalled) = CreateContext(currentContext, accessConnectorRepository); + + await new AccessConnectorHeartbeatEndpointFilter().InvokeAsync(context, NextDelegate(nextCalled)); + + await accessConnectorRepository.DidNotReceiveWithAnyArgs().GetByIdAsync(default); + } + + private static EndpointFilterDelegate NextDelegate(StrongBox nextCalled) => _ => + { + nextCalled.Value = true; + return ValueTask.FromResult("ok"); + }; + + private static (EndpointFilterInvocationContext Context, StrongBox NextCalled) CreateContext( + ICurrentContext currentContext, IPamAccessConnectorRepository accessConnectorRepository) + { + var timeProvider = new FakeTimeProvider(); + timeProvider.SetUtcNow(_now); + var services = new ServiceCollection(); + services.AddSingleton(currentContext); + services.AddSingleton(accessConnectorRepository); + services.AddSingleton>( + Options.Create(new PamRotationOptions { HeartbeatMinInterval = _heartbeatMinInterval })); + services.AddSingleton(timeProvider); + var httpContext = new DefaultHttpContext { RequestServices = services.BuildServiceProvider() }; + return (EndpointFilterInvocationContext.Create(httpContext), new StrongBox(false)); + } +} diff --git a/bitwarden_license/test/Services/Pam.Test/AccessConnector/Api/Endpoints/AccessConnectorAdminEndpointsTests.cs b/bitwarden_license/test/Services/Pam.Test/AccessConnector/Api/Endpoints/AccessConnectorAdminEndpointsTests.cs index ed75cfacdbc1..63568ae7ea7d 100644 --- a/bitwarden_license/test/Services/Pam.Test/AccessConnector/Api/Endpoints/AccessConnectorAdminEndpointsTests.cs +++ b/bitwarden_license/test/Services/Pam.Test/AccessConnector/Api/Endpoints/AccessConnectorAdminEndpointsTests.cs @@ -20,11 +20,8 @@ namespace Bit.Services.Pam.Test.AccessConnector.Api.Endpoints; /// -/// Locks the rotation admin wire contract — fleet, target-system, and config management — 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) and the authorization the group carries are the things under test. -/// Endpoints are materialized by mapping them onto a minimal host and reading its — -/// the same metadata the offline OpenAPI generator inspects. +/// Locks the rotation admin wire contract (routes, names, methods, return types) the OpenAPI spec depends on, and the +/// authorization the group carries. /// public class AccessConnectorAdminEndpointsTests { @@ -33,9 +30,7 @@ public class AccessConnectorAdminEndpointsTests 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. + // Unregistered handlers would bind as a request body. builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -50,20 +45,14 @@ private static List MaterializeEndpoints() 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. + // Builds the endpoints without starting the request pipeline. return ((IEndpointRouteBuilder)app).DataSources .SelectMany(dataSource => dataSource.Endpoints) .OfType() .ToList(); } - /// - /// Collects the authorization requirements an endpoint carries. They arrive as two shapes of metadata: - /// AuthorizeAttribute<T> contributes , while a policy - /// built inline contributes an . AuthorizationMiddleware combines both, so a - /// test asking "what must this endpoint satisfy" has to read both. - /// + /// AuthorizationMiddleware combines both metadata shapes, so both are read. private static List RequirementsFor(Endpoint endpoint) => [ .. endpoint.Metadata.GetOrderedMetadata().SelectMany(policy => policy.Requirements), @@ -117,8 +106,7 @@ public void MapPamEndpoints_RegistersExpectedRoute(string name, string method, s var endpoint = Assert.Single( endpoints, e => e.Metadata.GetMetadata()?.EndpointName == name); - // Trim slashes: the raw pattern carries routing's leading/trailing slashes that the generated spec path - // does not. + // The raw pattern carries leading and trailing slashes. Assert.Equal($"{AdminRoutePrefix}/{route}".Trim('/'), endpoint.RoutePattern.RawText?.Trim('/')); Assert.Contains(method, endpoint.Metadata.GetMetadata()!.HttpMethods); } @@ -126,8 +114,7 @@ public void MapPamEndpoints_RegistersExpectedRoute(string name, string method, s [Fact] public void MapPamEndpoints_GatesEveryRotationAdminRouteOnManageRotationRequirement() { - // Matching on the route prefix rather than the group's tags is deliberate: it also catches a rotation admin - // route mapped outside the group that WithPamAccessConnectorAdminDefaults gates. + // Matched by route prefix rather than tags, so an admin route mapped outside the gated group is caught too. var endpoints = AdminEndpoints(); Assert.NotEmpty(endpoints); @@ -144,8 +131,7 @@ public void MapPamEndpoints_GatesEveryRotationAdminRouteOnManageRotationRequirem [Fact] public void MapPamEndpoints_RotationAdminRoutesNeverAuthorizeProvidersByMembership() { - // An access connector registered here is handed the organization key, which is not a provider's to hold. - // MemberOrProviderRequirement would let them in, so no rotation admin route may carry it. + // A registered access connector is handed the organization key, which is not a provider's to hold. Assert.All(AdminEndpoints(), endpoint => { var requirements = RequirementsFor(endpoint); diff --git a/bitwarden_license/test/Services/Pam.Test/AccessConnector/Api/Endpoints/AccessConnectorMachineEndpointsTests.cs b/bitwarden_license/test/Services/Pam.Test/AccessConnector/Api/Endpoints/AccessConnectorMachineEndpointsTests.cs index 8f10b3fcb3a2..3e5b6debe9fa 100644 --- a/bitwarden_license/test/Services/Pam.Test/AccessConnector/Api/Endpoints/AccessConnectorMachineEndpointsTests.cs +++ b/bitwarden_license/test/Services/Pam.Test/AccessConnector/Api/Endpoints/AccessConnectorMachineEndpointsTests.cs @@ -1,6 +1,10 @@ using Bit.Api.AdminConsole.Authorization; +using Bit.Core.Auth.Identity; +using Bit.Core.Context; using Bit.Core.Models.Api; using Bit.HttpExtensions; +using Bit.Pam.Repositories; +using Bit.Services.Pam.AccessConnector; using Bit.Services.Pam.AccessConnector.Api.Endpoints.Handlers; using Bit.Services.Pam.AccessConnector.Rotation.Api.Endpoints.Handlers; using Bit.Services.Pam.AccessConnector.Rotation.Api.Models.Response; @@ -12,25 +16,23 @@ using Microsoft.AspNetCore.Http.Metadata; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; +using NSubstitute; using Xunit; namespace Bit.Services.Pam.Test.AccessConnector.Api.Endpoints; /// -/// Locks the connector-facing rotation wire contract that the generated OpenAPI spec — and the access connector built -/// from it — depend on. The endpoint bodies are scaffold stubs; the contract (routes, names, methods, return types, -/// and the machine-credential policy) 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. +/// Locks the connector-facing rotation wire contract (routes, names, methods, return types, and the machine-credential +/// policy) the OpenAPI spec and the access connector depend on. /// public class AccessConnectorMachineEndpointsTests { 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. + // Unregistered handlers would bind as a request body. builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -45,20 +47,14 @@ private static List MaterializeEndpoints() 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. + // Builds the endpoints without starting the request pipeline. return ((IEndpointRouteBuilder)app).DataSources .SelectMany(dataSource => dataSource.Endpoints) .OfType() .ToList(); } - /// - /// Collects the authorization requirements an endpoint carries. They arrive as two shapes of metadata: - /// AuthorizeAttribute<T> contributes , while a policy - /// built inline contributes an . AuthorizationMiddleware combines both, so a - /// test asking "what must this endpoint satisfy" has to read both. - /// + /// AuthorizationMiddleware combines both metadata shapes, so both are read. private static List RequirementsFor(Endpoint endpoint) => [ .. endpoint.Metadata.GetOrderedMetadata().SelectMany(policy => policy.Requirements), @@ -98,9 +94,7 @@ public void MapPamEndpoints_RegistersExpectedRoute(string name, string method, s var endpoint = Assert.Single( endpoints, e => e.Metadata.GetMetadata()?.EndpointName == name); - // Trim slashes: the raw pattern carries routing's leading/trailing slashes - // (e.g. "/access-connectors/rotation/jobs") - // that the generated spec path does not. + // The raw pattern carries leading and trailing slashes. Assert.Equal(route, endpoint.RoutePattern.RawText?.Trim('/')); Assert.Contains(method, endpoint.Metadata.GetMetadata()!.HttpMethods); } @@ -108,16 +102,60 @@ public void MapPamEndpoints_RegistersExpectedRoute(string name, string method, s [Fact] public void MapPamEndpoints_DoesNotGateTheConnectorSurfaceOnAnOrganizationRequirement() { - // The access connector routes carry no {orgId}, and OrganizationRequirementHandler reads the id off the route — - // attaching an IOrganizationRequirement here would throw rather than deny. An access - // connector is scoped to its own - // organization by its token and by the queries underneath, so this holds once the machine-credential - // policy replaces the placeholder Policies.Application these routes carry today. + // No {orgId} in these routes; authorized by Policies.AccessConnector instead of an org requirement. var endpoints = ConnectorEndpoints(); Assert.NotEmpty(endpoints); Assert.All(endpoints, endpoint => - Assert.DoesNotContain(RequirementsFor(endpoint), r => r is IOrganizationRequirement)); + { + Assert.DoesNotContain(RequirementsFor(endpoint), r => r is IOrganizationRequirement); + Assert.Contains(endpoint.Metadata.GetOrderedMetadata(), + data => data.Policy == Policies.AccessConnector); + }); + } + + [Fact] + public async Task MapPamEndpoints_RunsHeartbeatFilterAheadOfEveryConnectorRoute() + { + // AddEndpointFilter() leaves no metadata, so drive each endpoint: without a PamAccessConnectorId the filter + // 404s, while an unfiltered route fails in its handler. + var endpoints = ConnectorEndpoints(); + Assert.NotEmpty(endpoints); + + foreach (var endpoint in endpoints) + { + var httpContext = new DefaultHttpContext + { + RequestServices = ConnectorRequestServices(), + Response = { Body = new MemoryStream() }, + }; + + await endpoint.RequestDelegate!(httpContext); + + Assert.Equal(StatusCodes.Status404NotFound, httpContext.Response.StatusCode); + } + } + + private static IServiceProvider ConnectorRequestServices() + { + var currentContext = Substitute.For(); + currentContext.PamAccessConnectorId.Returns((Guid?)null); + + var hostEnvironment = Substitute.For(); + hostEnvironment.EnvironmentName.Returns(Environments.Production); + + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(hostEnvironment); + services.AddSingleton(currentContext); + services.AddSingleton(Substitute.For()); + services.AddSingleton(Substitute.For()); + services.AddSingleton(Options.Create(new PamRotationOptions())); + services.AddSingleton(TimeProvider.System); + services.AddScoped(); + services.AddScoped(); + + return services.BuildServiceProvider(); } [Fact] diff --git a/bitwarden_license/test/Services/Pam.Test/AccessConnector/Api/Models/RotationDetailResponseModelTests.cs b/bitwarden_license/test/Services/Pam.Test/AccessConnector/Api/Models/RotationDetailResponseModelTests.cs new file mode 100644 index 000000000000..b678cd23f4e5 --- /dev/null +++ b/bitwarden_license/test/Services/Pam.Test/AccessConnector/Api/Models/RotationDetailResponseModelTests.cs @@ -0,0 +1,86 @@ +using System.Text.Json; +using Bit.Pam.Entities; +using Bit.Pam.Enums; +using Bit.Pam.Models; +using Bit.Services.Pam.AccessConnector.Api.Models.Response; +using Bit.Services.Pam.AccessConnector.Models; +using Xunit; + +namespace Bit.Services.Pam.Test.AccessConnector.Api.Models; + +/// +/// Locks the wire shape of both rotation detail reads: subject fields un-nested, history under the server's +/// timestamp names. Client lookup is case-insensitive, so casing itself isn't asserted here. +/// +public class RotationDetailResponseModelTests +{ + private static readonly DateTime _created = new(2026, 8, 25, 9, 0, 0, DateTimeKind.Utc); + private static readonly DateTime _resolved = new(2026, 8, 25, 9, 5, 0, DateTimeKind.Utc); + + [Fact] + public void AccessConnectorDetail_CarriesTheAccessConnectorFieldsAndItsActivityOnOneObject() + { + var accessConnector = new PamAccessConnector + { + Id = Guid.NewGuid(), + OrganizationId = Guid.NewGuid(), + Name = "access-connector-1", + Status = PamAccessConnectorStatus.Enabled, + LastHeartbeatDate = _created, + CreationDate = _created, + RevisionDate = _created, + }; + var targetSystemId = Guid.NewGuid(); + var history = new PamAccessConnectorHistory( + new PamAccessConnectorListItem(accessConnector, IsConnected: true, [targetSystemId]), + [Job(accessConnector.Id)]); + + var json = Serialize(new PamAccessConnectorDetailResponseModel(history)); + + Assert.Equal(accessConnector.Id, json.GetProperty("Id").GetGuid()); + Assert.Equal(accessConnector.Name, json.GetProperty("Name").GetString()); + Assert.True(json.GetProperty("IsConnected").GetBoolean()); + Assert.Equal(targetSystemId, json.GetProperty("AssignedTargetSystemIds")[0].GetGuid()); + AssertJobShape(json); + } + + private static void AssertJobShape(JsonElement json) + { + var job = Assert.Single(json.GetProperty("Jobs").EnumerateArray().ToList()); + Assert.Equal(_created, job.GetProperty("CreationDate").GetDateTime()); + + var attempt = Assert.Single(job.GetProperty("Attempts").EnumerateArray().ToList()); + Assert.Equal(_created, attempt.GetProperty("CreationDate").GetDateTime()); + Assert.Equal(_resolved, attempt.GetProperty("ResolvedDate").GetDateTime()); + } + + private static PamRotationJobDetails Job(Guid accessConnectorId) + { + var job = new PamRotationJob + { + Id = Guid.NewGuid(), + RotationConfigId = Guid.NewGuid(), + Source = PamRotationSource.Scheduled, + Action = PamRotationJobAction.Succeeded, + CreationDate = _created, + NextClaimableDate = _created, + ExpirationDate = _resolved, + }; + return PamRotationJobDetails.From(job, [ + new PamRotationAttempt + { + Id = Guid.NewGuid(), + JobId = job.Id, + ClaimedByAccessConnectorId = accessConnectorId, + CipherUpdated = true, + Action = PamRotationAttemptAction.Rotated, + SessionTermination = PamSessionTerminationOutcome.Terminated, + CreationDate = _created, + ResolvedDate = _resolved, + } + ], _resolved); + } + + private static JsonElement Serialize(T model) => + JsonDocument.Parse(JsonSerializer.Serialize(model)).RootElement; +} diff --git a/bitwarden_license/test/Services/Pam.Test/AccessConnector/Commands/AssignAccessConnectorToTargetCommandTests.cs b/bitwarden_license/test/Services/Pam.Test/AccessConnector/Commands/AssignAccessConnectorToTargetCommandTests.cs new file mode 100644 index 000000000000..5855c3c6a43e --- /dev/null +++ b/bitwarden_license/test/Services/Pam.Test/AccessConnector/Commands/AssignAccessConnectorToTargetCommandTests.cs @@ -0,0 +1,196 @@ +using Bit.Core.Exceptions; +using Bit.Pam.Entities; +using Bit.Pam.Enums; +using Bit.Pam.Models; +using Bit.Pam.Repositories; +using Bit.Services.Pam.AccessConnector.Commands; +using Bit.Services.Pam.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.Extensions.Time.Testing; +using NSubstitute; +using Xunit; + +namespace Bit.Services.Pam.Test.AccessConnector.Commands; + +[SutProviderCustomize] +public class AssignAccessConnectorToTargetCommandTests +{ + private static readonly DateTime _now = new(2026, 7, 6, 12, 0, 0, DateTimeKind.Utc); + + [Theory, BitAutoData] + public async Task AssignAsync_AccessConnectorMissing_ThrowsNotFound( + Guid organizationId, Guid actingUserId, Guid accessConnectorId, Guid targetSystemId) + { + var sutProvider = Setup(); + sutProvider.GetDependency().GetByIdAsync(accessConnectorId) + .Returns((PamAccessConnector?)null); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.AssignAsync(organizationId, actingUserId, accessConnectorId, targetSystemId)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .CreateAssignmentAsync(default!); + } + + [Theory, BitAutoData] + public async Task AssignAsync_AccessConnectorWrongOrg_ThrowsNotFound( + Guid actingUserId, PamAccessConnector accessConnector, Guid targetSystemId) + { + var sutProvider = Setup(); + sutProvider.GetDependency().GetByIdAsync(accessConnector.Id) + .Returns(accessConnector); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.AssignAsync(Guid.NewGuid(), actingUserId, accessConnector.Id, targetSystemId)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .CreateAssignmentAsync(default!); + } + + [Theory, BitAutoData] + public async Task AssignAsync_TargetMissing_ThrowsNotFound( + Guid actingUserId, PamAccessConnector accessConnector, Guid targetSystemId) + { + var sutProvider = Setup(); + sutProvider.GetDependency().GetByIdAsync(accessConnector.Id) + .Returns(accessConnector); + sutProvider.GetDependency().GetByIdAsync(targetSystemId) + .Returns((PamTargetSystem?)null); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.AssignAsync( + accessConnector.OrganizationId, actingUserId, accessConnector.Id, targetSystemId)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .CreateAssignmentAsync(default!); + } + + [Theory, BitAutoData] + public async Task AssignAsync_TargetWrongOrg_ThrowsNotFound(Guid actingUserId, PamAccessConnector accessConnector, PamTargetSystem target) + { + var sutProvider = Setup(); + sutProvider.GetDependency().GetByIdAsync(accessConnector.Id) + .Returns(accessConnector); + sutProvider.GetDependency().GetByIdAsync(target.Id).Returns(target); + + // target.OrganizationId is not the caller's route org either. + await Assert.ThrowsAsync( + () => sutProvider.Sut.AssignAsync( + accessConnector.OrganizationId, actingUserId, accessConnector.Id, target.Id)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .CreateAssignmentAsync(default!); + } + + [Theory, BitAutoData] + public async Task AssignAsync_AccessConnectorDisabled_ThrowsBadRequest(Guid actingUserId, PamAccessConnector accessConnector, PamTargetSystem target) + { + var sutProvider = Setup(); + accessConnector.Status = PamAccessConnectorStatus.Disabled; + target.OrganizationId = accessConnector.OrganizationId; + target.Method = PamTargetSystemMethod.Automatic; + sutProvider.GetDependency().GetByIdAsync(accessConnector.Id) + .Returns(accessConnector); + sutProvider.GetDependency().GetByIdAsync(target.Id).Returns(target); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.AssignAsync( + accessConnector.OrganizationId, actingUserId, accessConnector.Id, target.Id)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .CreateAssignmentAsync(default!); + } + + [Theory, BitAutoData] + public async Task AssignAsync_TargetManual_ThrowsBadRequest(Guid actingUserId, PamAccessConnector accessConnector, PamTargetSystem target) + { + var sutProvider = Setup(); + accessConnector.Status = PamAccessConnectorStatus.Enabled; + target.OrganizationId = accessConnector.OrganizationId; + target.Method = PamTargetSystemMethod.Manual; + sutProvider.GetDependency().GetByIdAsync(accessConnector.Id) + .Returns(accessConnector); + sutProvider.GetDependency().GetByIdAsync(target.Id).Returns(target); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.AssignAsync( + accessConnector.OrganizationId, actingUserId, accessConnector.Id, target.Id)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .CreateAssignmentAsync(default!); + } + + [Theory, BitAutoData] + public async Task AssignAsync_DuplicateAssignment_ThrowsBadRequest(Guid actingUserId, PamAccessConnector accessConnector, PamTargetSystem target) + { + var sutProvider = Setup(); + accessConnector.Status = PamAccessConnectorStatus.Enabled; + target.OrganizationId = accessConnector.OrganizationId; + target.Method = PamTargetSystemMethod.Automatic; + sutProvider.GetDependency().GetByIdAsync(accessConnector.Id) + .Returns(accessConnector); + sutProvider.GetDependency().GetByIdAsync(target.Id).Returns(target); + sutProvider.GetDependency().AssignmentExistsAsync(accessConnector.Id, target.Id) + .Returns(true); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.AssignAsync( + accessConnector.OrganizationId, actingUserId, accessConnector.Id, target.Id)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .CreateAssignmentAsync(default!); + } + + [Theory, BitAutoData] + public async Task AssignAsync_HappyPath_CreatesAssignment(Guid actingUserId, PamAccessConnector accessConnector, PamTargetSystem target) + { + var sutProvider = Setup(); + accessConnector.Status = PamAccessConnectorStatus.Enabled; + target.OrganizationId = accessConnector.OrganizationId; + target.Method = PamTargetSystemMethod.Automatic; + sutProvider.GetDependency().GetByIdAsync(accessConnector.Id) + .Returns(accessConnector); + sutProvider.GetDependency().GetByIdAsync(target.Id).Returns(target); + sutProvider.GetDependency().AssignmentExistsAsync(accessConnector.Id, target.Id) + .Returns(false); + + await sutProvider.Sut.AssignAsync(accessConnector.OrganizationId, actingUserId, accessConnector.Id, target.Id); + + await sutProvider.GetDependency().Received(1).CreateAssignmentAsync(Arg.Is(a => + a.AccessConnectorId == accessConnector.Id && a.TargetSystemId == target.Id + && a.OrganizationId == accessConnector.OrganizationId + && a.Id != Guid.Empty && a.CreationDate == _now)); + } + + [Theory, BitAutoData] + public async Task AssignAsync_HappyPath_EmitsAttemptThenOutcome(Guid actingUserId, PamAccessConnector accessConnector, PamTargetSystem target) + { + var sutProvider = Setup(); + accessConnector.Status = PamAccessConnectorStatus.Enabled; + target.OrganizationId = accessConnector.OrganizationId; + target.Method = PamTargetSystemMethod.Automatic; + sutProvider.GetDependency().GetByIdAsync(accessConnector.Id) + .Returns(accessConnector); + sutProvider.GetDependency().GetByIdAsync(target.Id).Returns(target); + sutProvider.GetDependency().AssignmentExistsAsync(accessConnector.Id, target.Id) + .Returns(false); + + await sutProvider.Sut.AssignAsync(accessConnector.OrganizationId, actingUserId, accessConnector.Id, target.Id); + + var emitter = sutProvider.GetDependency(); + await emitter.Received(1).EmitAsync(Arg.Is(e => + e.Kind == AccessAuditEventKind.AccessConnectorAssignedToTarget && e.Phase == AccessAuditEventPhase.Attempt + && e.AccessConnectorId == accessConnector.Id && e.TargetSystemId == target.Id)); + await emitter.Received(1).EmitAsync(Arg.Is(e => + e.Kind == AccessAuditEventKind.AccessConnectorAssignedToTarget && e.Phase == AccessAuditEventPhase.Outcome + && e.AccessConnectorId == accessConnector.Id && e.TargetSystemId == target.Id)); + } + + private static SutProvider Setup() + { + var sutProvider = new SutProvider().WithFakeTimeProvider().Create(); + sutProvider.GetDependency().SetUtcNow(_now); + return sutProvider; + } +} diff --git a/bitwarden_license/test/Services/Pam.Test/AccessConnector/Commands/DeleteAccessConnectorCommandTests.cs b/bitwarden_license/test/Services/Pam.Test/AccessConnector/Commands/DeleteAccessConnectorCommandTests.cs new file mode 100644 index 000000000000..c829ea503ba3 --- /dev/null +++ b/bitwarden_license/test/Services/Pam.Test/AccessConnector/Commands/DeleteAccessConnectorCommandTests.cs @@ -0,0 +1,89 @@ +using Bit.Core.Exceptions; +using Bit.Pam.Entities; +using Bit.Pam.Enums; +using Bit.Pam.Models; +using Bit.Pam.Repositories; +using Bit.Services.Pam.AccessConnector.Commands; +using Bit.Services.Pam.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.Extensions.Time.Testing; +using NSubstitute; +using Xunit; + +namespace Bit.Services.Pam.Test.AccessConnector.Commands; + +[SutProviderCustomize] +public class DeleteAccessConnectorCommandTests +{ + private static readonly DateTime _now = new(2026, 7, 10, 12, 0, 0, DateTimeKind.Utc); + + [Theory, BitAutoData] + public async Task DeleteAsync_AccessConnectorMissing_ThrowsNotFound( + Guid organizationId, Guid actingUserId, Guid accessConnectorId) + { + var sutProvider = Setup(); + sutProvider.GetDependency().GetByIdAsync(accessConnectorId) + .Returns((PamAccessConnector?)null); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.DeleteAsync(organizationId, actingUserId, accessConnectorId)); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs().DeleteAsync(default!); + } + + [Theory, BitAutoData] + public async Task DeleteAsync_WrongOrg_ThrowsNotFound(Guid actingUserId, PamAccessConnector accessConnector) + { + var sutProvider = Setup(); + sutProvider.GetDependency().GetByIdAsync(accessConnector.Id) + .Returns(accessConnector); + + // A cross-org id is NotFound, so it does not reveal that the connector exists. + await Assert.ThrowsAsync( + () => sutProvider.Sut.DeleteAsync(Guid.NewGuid(), actingUserId, accessConnector.Id)); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs().DeleteAsync(default!); + } + + [Theory, BitAutoData] + public async Task DeleteAsync_DeletesTheAccessConnectorThroughTheRepositoryCascade( + Guid actingUserId, PamAccessConnector accessConnector) + { + var sutProvider = Setup(); + sutProvider.GetDependency().GetByIdAsync(accessConnector.Id) + .Returns(accessConnector); + + await sutProvider.Sut.DeleteAsync(accessConnector.OrganizationId, actingUserId, accessConnector.Id); + + await sutProvider.GetDependency().Received(1).DeleteAsync(accessConnector); + } + + [Theory, BitAutoData] + public async Task DeleteAsync_EmitsAttemptThenOutcome(Guid actingUserId, PamAccessConnector accessConnector) + { + var sutProvider = Setup(); + sutProvider.GetDependency().GetByIdAsync(accessConnector.Id) + .Returns(accessConnector); + + await sutProvider.Sut.DeleteAsync(accessConnector.OrganizationId, actingUserId, accessConnector.Id); + + var emitter = sutProvider.GetDependency(); + await emitter.Received(1).EmitAsync(Arg.Is(e => + e.Kind == AccessAuditEventKind.AccessConnectorDeleted && e.Phase == AccessAuditEventPhase.Attempt + && e.AccessConnectorId == accessConnector.Id && e.AccessConnectorName == accessConnector.Name + && e.ActorId == actingUserId)); + await emitter.Received(1).EmitAsync(Arg.Is(e => + e.Kind == AccessAuditEventKind.AccessConnectorDeleted && e.Phase == AccessAuditEventPhase.Outcome + && e.AccessConnectorId == accessConnector.Id)); + } + + private static SutProvider Setup() + { + var sutProvider = new SutProvider().WithFakeTimeProvider().Create(); + sutProvider.GetDependency().SetUtcNow(_now); + return sutProvider; + } +} diff --git a/bitwarden_license/test/Services/Pam.Test/AccessConnector/Commands/RegisterAccessConnectorCommandTests.cs b/bitwarden_license/test/Services/Pam.Test/AccessConnector/Commands/RegisterAccessConnectorCommandTests.cs new file mode 100644 index 000000000000..c1da43d8f90e --- /dev/null +++ b/bitwarden_license/test/Services/Pam.Test/AccessConnector/Commands/RegisterAccessConnectorCommandTests.cs @@ -0,0 +1,116 @@ +using Bit.Core.Exceptions; +using Bit.Core.SecretsManager.Entities; +using Bit.Core.SecretsManager.Repositories; +using Bit.Pam.Entities; +using Bit.Pam.Enums; +using Bit.Pam.Models; +using Bit.Pam.Repositories; +using Bit.Services.Pam.AccessConnector.Commands; +using Bit.Services.Pam.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.Extensions.Time.Testing; +using NSubstitute; +using Xunit; + +namespace Bit.Services.Pam.Test.AccessConnector.Commands; + +[SutProviderCustomize] +public class RegisterAccessConnectorCommandTests +{ + private static readonly DateTime _now = new(2026, 7, 6, 12, 0, 0, DateTimeKind.Utc); + + [Theory, BitAutoData] + public async Task RegisterAsync_NameMissing_ThrowsBadRequest( + Guid organizationId, Guid actingUserId) + { + var sutProvider = Setup(); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.RegisterAsync(organizationId, actingUserId, " ", "payload", "key")); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().CreateAsync(default!); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs().CreateAsync(default!); + } + + [Theory, BitAutoData] + public async Task RegisterAsync_HappyPath_CreatesApiKeyWithHashedSecretAndPamAccessConnector( + Guid organizationId, Guid actingUserId, string name, string encryptedPayload, string key) + { + var sutProvider = Setup(); + var apiKeyId = Guid.NewGuid(); + var accessConnectorId = Guid.NewGuid(); + SetupCreates(sutProvider, apiKeyId, accessConnectorId); + + var result = await sutProvider.Sut.RegisterAsync(organizationId, actingUserId, name, encryptedPayload, key); + + // Not a Secrets Manager key, so it has no service account. + await sutProvider.GetDependency().Received(1).CreateAsync(Arg.Is(k => + k.ServiceAccountId == null + && k.Name == name + && k.Scope == "[\"api.pam.rotation\"]" + && k.EncryptedPayload == encryptedPayload + && k.Key == key + && !string.IsNullOrEmpty(k.ClientSecretHash) + && k.ClientSecretHash != result.ClientSecret)); + + await sutProvider.GetDependency() + .Received(1).CreateAsync(Arg.Is(d => + d.OrganizationId == organizationId + && d.Name == name + && d.ApiKeyId == apiKeyId + && d.Status == PamAccessConnectorStatus.Enabled + && d.CreationDate == _now + && d.RevisionDate == _now)); + + // The plaintext client secret is only ever available on this one response. + Assert.False(string.IsNullOrEmpty(result.ClientSecret)); + Assert.Equal(accessConnectorId, result.AccessConnector.Id); + } + + [Theory, BitAutoData] + public async Task RegisterAsync_HappyPath_EmitsAttemptThenOutcome( + Guid organizationId, Guid actingUserId, string name, string encryptedPayload, string key) + { + var sutProvider = Setup(); + var apiKeyId = Guid.NewGuid(); + var accessConnectorId = Guid.NewGuid(); + SetupCreates(sutProvider, apiKeyId, accessConnectorId); + + await sutProvider.Sut.RegisterAsync(organizationId, actingUserId, name, encryptedPayload, key); + + var emitter = sutProvider.GetDependency(); + await emitter.Received(1).EmitAsync(Arg.Is(e => + e.Kind == AccessAuditEventKind.AccessConnectorRegistered && e.Phase == AccessAuditEventPhase.Attempt + && e.OrganizationId == organizationId && e.ActorId == actingUserId && e.AccessConnectorName == name)); + await emitter.Received(1).EmitAsync(Arg.Is(e => + e.Kind == AccessAuditEventKind.AccessConnectorRegistered && e.Phase == AccessAuditEventPhase.Outcome + && e.AccessConnectorId == accessConnectorId)); + } + + private static SutProvider Setup() + { + var sutProvider = new SutProvider().WithFakeTimeProvider().Create(); + sutProvider.GetDependency().SetUtcNow(_now); + return sutProvider; + } + + private static void SetupCreates(SutProvider sutProvider, Guid apiKeyId, Guid accessConnectorId) + { + sutProvider.GetDependency().CreateAsync(Arg.Any()) + .Returns(call => + { + var apiKey = call.Arg(); + apiKey.Id = apiKeyId; + return Task.FromResult(apiKey); + }); + sutProvider.GetDependency().CreateAsync(Arg.Any()) + .Returns(call => + { + var accessConnector = call.Arg(); + accessConnector.Id = accessConnectorId; + return Task.FromResult(accessConnector); + }); + } +} diff --git a/bitwarden_license/test/Services/Pam.Test/AccessConnector/Commands/SetAccessConnectorStatusCommandTests.cs b/bitwarden_license/test/Services/Pam.Test/AccessConnector/Commands/SetAccessConnectorStatusCommandTests.cs new file mode 100644 index 000000000000..f52a2c315026 --- /dev/null +++ b/bitwarden_license/test/Services/Pam.Test/AccessConnector/Commands/SetAccessConnectorStatusCommandTests.cs @@ -0,0 +1,163 @@ +using Bit.Core.Exceptions; +using Bit.Pam.Entities; +using Bit.Pam.Enums; +using Bit.Pam.Models; +using Bit.Pam.Repositories; +using Bit.Services.Pam.AccessConnector.Commands; +using Bit.Services.Pam.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.Extensions.Time.Testing; +using NSubstitute; +using Xunit; + +namespace Bit.Services.Pam.Test.AccessConnector.Commands; + +[SutProviderCustomize] +public class SetAccessConnectorStatusCommandTests +{ + private static readonly DateTime _now = new(2026, 7, 10, 12, 0, 0, DateTimeKind.Utc); + + [Theory, BitAutoData] + public async Task SetStatusAsync_AccessConnectorMissing_ThrowsNotFound( + Guid organizationId, Guid actingUserId, Guid accessConnectorId) + { + var sutProvider = Setup(); + sutProvider.GetDependency().GetByIdAsync(accessConnectorId) + .Returns((PamAccessConnector?)null); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.SetStatusAsync(organizationId, actingUserId, accessConnectorId, enable: false)); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs().ReplaceAsync(default!); + } + + [Theory, BitAutoData] + public async Task SetStatusAsync_WrongOrg_ThrowsNotFound(Guid actingUserId, PamAccessConnector accessConnector) + { + var sutProvider = Setup(); + accessConnector.Status = PamAccessConnectorStatus.Enabled; + sutProvider.GetDependency().GetByIdAsync(accessConnector.Id) + .Returns(accessConnector); + + // A cross-org id is NotFound, so it does not reveal that the connector exists. + await Assert.ThrowsAsync( + () => sutProvider.Sut.SetStatusAsync(Guid.NewGuid(), actingUserId, accessConnector.Id, enable: false)); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs().ReplaceAsync(default!); + } + + [Theory, BitAutoData] + public async Task SetStatusAsync_Disable_AlreadyDisabled_ThrowsBadRequest( + Guid actingUserId, PamAccessConnector accessConnector) + { + var sutProvider = Setup(); + accessConnector.Status = PamAccessConnectorStatus.Disabled; + sutProvider.GetDependency().GetByIdAsync(accessConnector.Id) + .Returns(accessConnector); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.SetStatusAsync( + accessConnector.OrganizationId, actingUserId, accessConnector.Id, enable: false)); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs().ReplaceAsync(default!); + } + + [Theory, BitAutoData] + public async Task SetStatusAsync_Enable_AlreadyEnabled_ThrowsBadRequest( + Guid actingUserId, PamAccessConnector accessConnector) + { + var sutProvider = Setup(); + accessConnector.Status = PamAccessConnectorStatus.Enabled; + sutProvider.GetDependency().GetByIdAsync(accessConnector.Id) + .Returns(accessConnector); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.SetStatusAsync( + accessConnector.OrganizationId, actingUserId, accessConnector.Id, enable: true)); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs().ReplaceAsync(default!); + } + + [Theory, BitAutoData] + public async Task SetStatusAsync_Disable_SetsDisabled(Guid actingUserId, PamAccessConnector accessConnector) + { + var sutProvider = Setup(); + accessConnector.Status = PamAccessConnectorStatus.Enabled; + sutProvider.GetDependency().GetByIdAsync(accessConnector.Id) + .Returns(accessConnector); + + await sutProvider.Sut.SetStatusAsync( + accessConnector.OrganizationId, actingUserId, accessConnector.Id, enable: false); + + await sutProvider.GetDependency() + .Received(1).ReplaceAsync(Arg.Is(d => + d.Id == accessConnector.Id && d.Status == PamAccessConnectorStatus.Disabled && d.RevisionDate == _now)); + } + + [Theory, BitAutoData] + public async Task SetStatusAsync_Enable_SetsEnabled(Guid actingUserId, PamAccessConnector accessConnector) + { + var sutProvider = Setup(); + accessConnector.Status = PamAccessConnectorStatus.Disabled; + sutProvider.GetDependency().GetByIdAsync(accessConnector.Id) + .Returns(accessConnector); + + await sutProvider.Sut.SetStatusAsync( + accessConnector.OrganizationId, actingUserId, accessConnector.Id, enable: true); + + await sutProvider.GetDependency() + .Received(1).ReplaceAsync(Arg.Is(d => + d.Id == accessConnector.Id && d.Status == PamAccessConnectorStatus.Enabled && d.RevisionDate == _now)); + } + + [Theory, BitAutoData] + public async Task SetStatusAsync_Disable_EmitsAttemptThenOutcome( + Guid actingUserId, PamAccessConnector accessConnector) + { + var sutProvider = Setup(); + accessConnector.Status = PamAccessConnectorStatus.Enabled; + sutProvider.GetDependency().GetByIdAsync(accessConnector.Id) + .Returns(accessConnector); + + await sutProvider.Sut.SetStatusAsync( + accessConnector.OrganizationId, actingUserId, accessConnector.Id, enable: false); + + var emitter = sutProvider.GetDependency(); + await emitter.Received(1).EmitAsync(Arg.Is(e => + e.Kind == AccessAuditEventKind.AccessConnectorDisabled && e.Phase == AccessAuditEventPhase.Attempt + && e.AccessConnectorId == accessConnector.Id && e.AccessConnectorName == accessConnector.Name + && e.ActorId == actingUserId)); + await emitter.Received(1).EmitAsync(Arg.Is(e => + e.Kind == AccessAuditEventKind.AccessConnectorDisabled && e.Phase == AccessAuditEventPhase.Outcome + && e.AccessConnectorId == accessConnector.Id)); + } + + [Theory, BitAutoData] + public async Task SetStatusAsync_Enable_EmitsEnabledKind(Guid actingUserId, PamAccessConnector accessConnector) + { + var sutProvider = Setup(); + accessConnector.Status = PamAccessConnectorStatus.Disabled; + sutProvider.GetDependency().GetByIdAsync(accessConnector.Id) + .Returns(accessConnector); + + await sutProvider.Sut.SetStatusAsync( + accessConnector.OrganizationId, actingUserId, accessConnector.Id, enable: true); + + await sutProvider.GetDependency().Received(1).EmitAsync( + Arg.Is(e => + e.Kind == AccessAuditEventKind.AccessConnectorEnabled && e.Phase == AccessAuditEventPhase.Outcome + && e.AccessConnectorId == accessConnector.Id)); + } + + private static SutProvider Setup() + { + var sutProvider = new SutProvider().WithFakeTimeProvider().Create(); + sutProvider.GetDependency().SetUtcNow(_now); + return sutProvider; + } +} diff --git a/bitwarden_license/test/Services/Pam.Test/AccessConnector/Commands/UnassignAccessConnectorFromTargetCommandTests.cs b/bitwarden_license/test/Services/Pam.Test/AccessConnector/Commands/UnassignAccessConnectorFromTargetCommandTests.cs new file mode 100644 index 000000000000..1c9f01ecf3f6 --- /dev/null +++ b/bitwarden_license/test/Services/Pam.Test/AccessConnector/Commands/UnassignAccessConnectorFromTargetCommandTests.cs @@ -0,0 +1,149 @@ +using Bit.Core.Exceptions; +using Bit.Pam.Entities; +using Bit.Pam.Enums; +using Bit.Pam.Models; +using Bit.Pam.Repositories; +using Bit.Services.Pam.AccessConnector.Commands; +using Bit.Services.Pam.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.Extensions.Time.Testing; +using NSubstitute; +using Xunit; + +namespace Bit.Services.Pam.Test.AccessConnector.Commands; + +[SutProviderCustomize] +public class UnassignAccessConnectorFromTargetCommandTests +{ + private static readonly DateTime _now = new(2026, 7, 6, 12, 0, 0, DateTimeKind.Utc); + + [Theory, BitAutoData] + public async Task UnassignAsync_AccessConnectorMissing_ThrowsNotFound( + Guid organizationId, Guid actingUserId, Guid accessConnectorId, Guid targetSystemId) + { + var sutProvider = Setup(); + sutProvider.GetDependency().GetByIdAsync(accessConnectorId) + .Returns((PamAccessConnector?)null); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.UnassignAsync(organizationId, actingUserId, accessConnectorId, targetSystemId)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .DeleteAssignmentAsync(default, default); + } + + [Theory, BitAutoData] + public async Task UnassignAsync_AccessConnectorWrongOrg_ThrowsNotFound(Guid actingUserId, PamAccessConnector accessConnector, Guid targetSystemId) + { + var sutProvider = Setup(); + sutProvider.GetDependency().GetByIdAsync(accessConnector.Id) + .Returns(accessConnector); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.UnassignAsync(Guid.NewGuid(), actingUserId, accessConnector.Id, targetSystemId)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .DeleteAssignmentAsync(default, default); + } + + [Theory, BitAutoData] + public async Task UnassignAsync_TargetMissing_ThrowsNotFound(Guid actingUserId, PamAccessConnector accessConnector, Guid targetSystemId) + { + var sutProvider = Setup(); + sutProvider.GetDependency().GetByIdAsync(accessConnector.Id) + .Returns(accessConnector); + sutProvider.GetDependency().GetByIdAsync(targetSystemId) + .Returns((PamTargetSystem?)null); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.UnassignAsync( + accessConnector.OrganizationId, actingUserId, accessConnector.Id, targetSystemId)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .DeleteAssignmentAsync(default, default); + } + + [Theory, BitAutoData] + public async Task UnassignAsync_TargetWrongOrg_ThrowsNotFound(Guid actingUserId, PamAccessConnector accessConnector, PamTargetSystem target) + { + var sutProvider = Setup(); + sutProvider.GetDependency().GetByIdAsync(accessConnector.Id) + .Returns(accessConnector); + sutProvider.GetDependency().GetByIdAsync(target.Id).Returns(target); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.UnassignAsync( + accessConnector.OrganizationId, actingUserId, accessConnector.Id, target.Id)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .DeleteAssignmentAsync(default, default); + } + + [Theory, BitAutoData] + public async Task UnassignAsync_AssignmentMissing_ThrowsNotFound(Guid actingUserId, PamAccessConnector accessConnector, PamTargetSystem target) + { + var sutProvider = Setup(); + target.OrganizationId = accessConnector.OrganizationId; + sutProvider.GetDependency().GetByIdAsync(accessConnector.Id) + .Returns(accessConnector); + sutProvider.GetDependency().GetByIdAsync(target.Id).Returns(target); + sutProvider.GetDependency().AssignmentExistsAsync(accessConnector.Id, target.Id) + .Returns(false); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.UnassignAsync( + accessConnector.OrganizationId, actingUserId, accessConnector.Id, target.Id)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .DeleteAssignmentAsync(default, default); + } + + [Theory, BitAutoData] + public async Task UnassignAsync_HappyPath_DeletesAssignment(Guid actingUserId, PamAccessConnector accessConnector, PamTargetSystem target) + { + var sutProvider = Setup(); + target.OrganizationId = accessConnector.OrganizationId; + sutProvider.GetDependency().GetByIdAsync(accessConnector.Id) + .Returns(accessConnector); + sutProvider.GetDependency().GetByIdAsync(target.Id).Returns(target); + sutProvider.GetDependency().AssignmentExistsAsync(accessConnector.Id, target.Id) + .Returns(true); + + await sutProvider.Sut.UnassignAsync( + accessConnector.OrganizationId, actingUserId, accessConnector.Id, target.Id); + + await sutProvider.GetDependency() + .Received(1).DeleteAssignmentAsync(accessConnector.Id, target.Id); + } + + [Theory, BitAutoData] + public async Task UnassignAsync_HappyPath_EmitsAttemptThenOutcome(Guid actingUserId, PamAccessConnector accessConnector, PamTargetSystem target) + { + var sutProvider = Setup(); + target.OrganizationId = accessConnector.OrganizationId; + sutProvider.GetDependency().GetByIdAsync(accessConnector.Id) + .Returns(accessConnector); + sutProvider.GetDependency().GetByIdAsync(target.Id).Returns(target); + sutProvider.GetDependency().AssignmentExistsAsync(accessConnector.Id, target.Id) + .Returns(true); + + await sutProvider.Sut.UnassignAsync( + accessConnector.OrganizationId, actingUserId, accessConnector.Id, target.Id); + + var emitter = sutProvider.GetDependency(); + await emitter.Received(1).EmitAsync(Arg.Is(e => + e.Kind == AccessAuditEventKind.AccessConnectorUnassignedFromTarget && e.Phase == AccessAuditEventPhase.Attempt + && e.AccessConnectorId == accessConnector.Id && e.TargetSystemId == target.Id)); + await emitter.Received(1).EmitAsync(Arg.Is(e => + e.Kind == AccessAuditEventKind.AccessConnectorUnassignedFromTarget && e.Phase == AccessAuditEventPhase.Outcome + && e.AccessConnectorId == accessConnector.Id && e.TargetSystemId == target.Id)); + } + + private static SutProvider Setup() + { + var sutProvider = new SutProvider().WithFakeTimeProvider().Create(); + sutProvider.GetDependency().SetUtcNow(_now); + return sutProvider; + } +} diff --git a/bitwarden_license/test/Services/Pam.Test/AccessConnector/Queries/GetAccessConnectorDetailsQueryTests.cs b/bitwarden_license/test/Services/Pam.Test/AccessConnector/Queries/GetAccessConnectorDetailsQueryTests.cs new file mode 100644 index 000000000000..b7fdd443e806 --- /dev/null +++ b/bitwarden_license/test/Services/Pam.Test/AccessConnector/Queries/GetAccessConnectorDetailsQueryTests.cs @@ -0,0 +1,125 @@ +using Bit.Core.Exceptions; +using Bit.Core.Utilities; +using Bit.Pam.Entities; +using Bit.Pam.Models; +using Bit.Pam.Repositories; +using Bit.Services.Pam.AccessConnector; +using Bit.Services.Pam.AccessConnector.Queries; +using Bit.Services.Pam.OrganizationFeatures.Queries; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Time.Testing; +using NSubstitute; +using Xunit; + +namespace Bit.Services.Pam.Test.AccessConnector.Queries; + +/// +/// ManageAccessConnectorRequirement only proves the caller administers the route's organization, so this query +/// has to refuse another organization's connector. +/// +public class GetAccessConnectorDetailsQueryTests +{ + private static readonly DateTime _now = new(2026, 8, 25, 12, 0, 0, DateTimeKind.Utc); + private static readonly DateTime _historySince = _now.AddDays(-AccessHistoryWindow.RetentionDays); + + [Theory, BitAutoData] + public async Task GetAsync_AccessConnectorMissing_ThrowsNotFound(Guid organizationId, Guid accessConnectorId) + { + var sutProvider = Setup(); + sutProvider.GetDependency().GetByIdAsync(accessConnectorId) + .Returns((PamAccessConnector?)null); + + await Assert.ThrowsAsync(() => sutProvider.Sut.GetAsync(organizationId, accessConnectorId)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .GetManyRecentByAccessConnectorIdAsync(default, default, default, default); + } + + [Theory, BitAutoData] + public async Task GetAsync_AccessConnectorBelongsToAnotherOrganization_ThrowsNotFound( + Guid organizationId, PamAccessConnector accessConnector) + { + var sutProvider = Setup(); + accessConnector.OrganizationId = Guid.NewGuid(); + sutProvider.GetDependency().GetByIdAsync(accessConnector.Id) + .Returns(accessConnector); + + await Assert.ThrowsAsync(() => sutProvider.Sut.GetAsync(organizationId, accessConnector.Id)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .GetManyRecentByAccessConnectorIdAsync(default, default, default, default); + } + + [Theory, BitAutoData] + public async Task GetAsync_AccessConnectorInTheRouteOrganization_ReturnsConnectionAssignmentsAndActivity( + PamAccessConnector accessConnector, List jobs, Guid otherAccessConnectorId) + { + var sutProvider = Setup(); + accessConnector.LastHeartbeatDate = + _now - new PamRotationOptions().AccessConnectorOfflineAfter + TimeSpan.FromSeconds(1); + sutProvider.GetDependency().GetByIdAsync(accessConnector.Id) + .Returns(accessConnector); + var assigned = Assignment(accessConnector.OrganizationId, accessConnector.Id); + sutProvider.GetDependency() + .GetAssignmentsByOrganizationIdAsync(accessConnector.OrganizationId) + .Returns([assigned, Assignment(accessConnector.OrganizationId, otherAccessConnectorId)]); + sutProvider.GetDependency() + .GetManyRecentByAccessConnectorIdAsync(accessConnector.Id, Arg.Any(), _historySince, _now) + .Returns(jobs); + + var result = await sutProvider.Sut.GetAsync(accessConnector.OrganizationId, accessConnector.Id); + + Assert.Same(accessConnector, result.AccessConnector.AccessConnector); + Assert.True(result.AccessConnector.IsConnected); + // The fleet-wide assignment read is narrowed to this access connector's own targets. + Assert.Equal([assigned.TargetSystemId], result.AccessConnector.AssignedTargetSystemIds); + Assert.Equal(jobs.Count, result.Jobs.Count); + } + + [Theory, BitAutoData] + public async Task GetAsync_HeartbeatOlderThanOfflineAfter_IsNotConnected(PamAccessConnector accessConnector) + { + var sutProvider = Setup(); + accessConnector.LastHeartbeatDate = + _now - new PamRotationOptions().AccessConnectorOfflineAfter - TimeSpan.FromSeconds(1); + sutProvider.GetDependency().GetByIdAsync(accessConnector.Id) + .Returns(accessConnector); + + var result = await sutProvider.Sut.GetAsync(accessConnector.OrganizationId, accessConnector.Id); + + Assert.False(result.AccessConnector.IsConnected); + } + + [Theory, BitAutoData] + public async Task GetAsync_ReadsABoundedNumberOfJobs(PamAccessConnector accessConnector) + { + var sutProvider = Setup(); + sutProvider.GetDependency().GetByIdAsync(accessConnector.Id) + .Returns(accessConnector); + + await sutProvider.Sut.GetAsync(accessConnector.OrganizationId, accessConnector.Id); + + await sutProvider.GetDependency().Received(1) + .GetManyRecentByAccessConnectorIdAsync( + accessConnector.Id, Arg.Is(limit => limit > 0), _historySince, _now); + } + + private static SutProvider Setup() + { + var sutProvider = new SutProvider().WithFakeTimeProvider().Create(); + sutProvider.GetDependency().SetUtcNow(_now); + sutProvider.GetDependency>().Value.Returns(new PamRotationOptions()); + return sutProvider; + } + + private static PamAccessConnectorTargetAssignment Assignment(Guid organizationId, Guid accessConnectorId) => new() + { + Id = CombGuid.Generate(), + AccessConnectorId = accessConnectorId, + TargetSystemId = CombGuid.Generate(), + OrganizationId = organizationId, + CreationDate = _now, + }; +} diff --git a/bitwarden_license/test/Services/Pam.Test/Utilities/ServiceCollectionExtensionsTests.cs b/bitwarden_license/test/Services/Pam.Test/Utilities/ServiceCollectionExtensionsTests.cs index b799a7745177..6d73e13e9d78 100644 --- a/bitwarden_license/test/Services/Pam.Test/Utilities/ServiceCollectionExtensionsTests.cs +++ b/bitwarden_license/test/Services/Pam.Test/Utilities/ServiceCollectionExtensionsTests.cs @@ -1,6 +1,7 @@ using Bit.Core.Pam.Services; using Bit.Services.Pam.Services; using Bit.Services.Pam.Utilities; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Xunit; @@ -11,8 +12,9 @@ namespace Bit.Services.Pam.Test.Utilities; /// public class ServiceCollectionExtensionsTests { + // An empty configuration root leaves PamRotationOptions at its default, which is all these need. private static IServiceCollection PamServices() => - new ServiceCollection().AddPamServices(); + new ServiceCollection().AddPamServices(new ConfigurationBuilder().Build()); [Fact] public void AddPamServices_RegistersEveryPamOwnedDependency() diff --git a/src/Api/Startup.cs b/src/Api/Startup.cs index c07abf10547f..701442dce2ef 100644 --- a/src/Api/Startup.cs +++ b/src/Api/Startup.cs @@ -216,7 +216,7 @@ public void ConfigureServices(IServiceCollection services) services.AddCommercialCoreServices(); services.AddCommercialSecretsManagerServices(); services.AddSecretsManagerEfRepositories(); - services.AddPamServices(); + services.AddPamServices(Configuration); Jobs.JobsHostedService.AddCommercialSecretsManagerJobServices(services); #endif From 65d0769b32adda40e65627ef53c77fc5f759fc84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anders=20=C3=85berg?= Date: Fri, 16 Oct 2026 13:18:14 +0200 Subject: [PATCH 02/12] [PAM-193] Require Edit on target system items to assign an access connector (VULN-1173) --- .../AccessConnectorEndpointsHandler.cs | 14 +++- .../Queries/CanEditRotationCiphersQuery.cs | 27 ++++++++ .../ICanEditRotationCiphersQuery.cs | 10 +++ .../Utilities/ServiceCollectionExtensions.cs | 1 + .../AccessConnectorEndpointsHandlerTests.cs | 58 ++++++++++++++++ .../CanEditRotationCiphersQueryTests.cs | 68 +++++++++++++++++++ 6 files changed, 177 insertions(+), 1 deletion(-) create mode 100644 bitwarden_license/src/Services/Pam/AccessConnector/Queries/CanEditRotationCiphersQuery.cs create mode 100644 bitwarden_license/src/Services/Pam/AccessConnector/Queries/Interfaces/ICanEditRotationCiphersQuery.cs create mode 100644 bitwarden_license/test/Services/Pam.Test/AccessConnector/Api/Endpoints/Handlers/AccessConnectorEndpointsHandlerTests.cs create mode 100644 bitwarden_license/test/Services/Pam.Test/AccessConnector/Queries/CanEditRotationCiphersQueryTests.cs diff --git a/bitwarden_license/src/Services/Pam/AccessConnector/Api/Endpoints/Handlers/AccessConnectorEndpointsHandler.cs b/bitwarden_license/src/Services/Pam/AccessConnector/Api/Endpoints/Handlers/AccessConnectorEndpointsHandler.cs index 9de52ad76e86..bd727e80443d 100644 --- a/bitwarden_license/src/Services/Pam/AccessConnector/Api/Endpoints/Handlers/AccessConnectorEndpointsHandler.cs +++ b/bitwarden_license/src/Services/Pam/AccessConnector/Api/Endpoints/Handlers/AccessConnectorEndpointsHandler.cs @@ -1,5 +1,7 @@ using Bit.Core.Context; +using Bit.Core.Exceptions; using Bit.HttpExtensions; +using Bit.Pam.Repositories; using Bit.Services.Pam.AccessConnector.Api.Models.Request; using Bit.Services.Pam.AccessConnector.Api.Models.Response; using Bit.Services.Pam.AccessConnector.Commands.Interfaces; @@ -19,7 +21,9 @@ public class AccessConnectorEndpointsHandler( ISetAccessConnectorStatusCommand setAccessConnectorStatusCommand, IDeleteAccessConnectorCommand deleteAccessConnectorCommand, IAssignAccessConnectorToTargetCommand assignAccessConnectorToTargetCommand, - IUnassignAccessConnectorFromTargetCommand unassignAccessConnectorFromTargetCommand) + IUnassignAccessConnectorFromTargetCommand unassignAccessConnectorFromTargetCommand, + IPamRotationConfigRepository configRepository, + ICanEditRotationCiphersQuery canEditRotationCiphersQuery) { public async Task> GetAll(Guid orgId) { @@ -58,6 +62,14 @@ public async Task Delete(Guid orgId, Guid id) public async Task AssignTarget(Guid orgId, Guid id, AssignAccessConnectorTargetRequestModel model) { + // The access connector receives every cipher the target system rotates. + var configs = await configRepository.GetManyDetailsByOrganizationIdAsync(orgId); + var cipherIds = configs.Where(c => c.TargetSystemId == model.TargetSystemId).Select(c => c.CipherId); + if (!await canEditRotationCiphersQuery.CanEditAllAsync(orgId, cipherIds)) + { + throw new BadRequestException("You need edit access to every item this target system rotates."); + } + await assignAccessConnectorToTargetCommand.AssignAsync( orgId, currentContext.UserId!.Value, id, model.TargetSystemId); } diff --git a/bitwarden_license/src/Services/Pam/AccessConnector/Queries/CanEditRotationCiphersQuery.cs b/bitwarden_license/src/Services/Pam/AccessConnector/Queries/CanEditRotationCiphersQuery.cs new file mode 100644 index 000000000000..b22e5fd05f7b --- /dev/null +++ b/bitwarden_license/src/Services/Pam/AccessConnector/Queries/CanEditRotationCiphersQuery.cs @@ -0,0 +1,27 @@ +using Bit.Core.Vault.Queries; +using Bit.Services.Pam.AccessConnector.Queries.Interfaces; + +namespace Bit.Services.Pam.AccessConnector.Queries; + +/// +public class CanEditRotationCiphersQuery : ICanEditRotationCiphersQuery +{ + private readonly IGetCipherPermissionsForUserQuery _getCipherPermissionsForUserQuery; + + public CanEditRotationCiphersQuery(IGetCipherPermissionsForUserQuery getCipherPermissionsForUserQuery) + { + _getCipherPermissionsForUserQuery = getCipherPermissionsForUserQuery; + } + + public async Task CanEditAllAsync(Guid organizationId, IEnumerable cipherIds) + { + var ids = cipherIds.ToList(); + if (ids.Count == 0) + { + return true; + } + + var permissions = await _getCipherPermissionsForUserQuery.GetByOrganization(organizationId); + return ids.All(id => permissions.TryGetValue(id, out var p) && p.Edit && p.ViewPassword); + } +} diff --git a/bitwarden_license/src/Services/Pam/AccessConnector/Queries/Interfaces/ICanEditRotationCiphersQuery.cs b/bitwarden_license/src/Services/Pam/AccessConnector/Queries/Interfaces/ICanEditRotationCiphersQuery.cs new file mode 100644 index 000000000000..966dd3042c98 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/AccessConnector/Queries/Interfaces/ICanEditRotationCiphersQuery.cs @@ -0,0 +1,10 @@ +namespace Bit.Services.Pam.AccessConnector.Queries.Interfaces; + +public interface ICanEditRotationCiphersQuery +{ + /// + /// Whether the current user can edit, and view the password of, every listed cipher in the organization. Rotation + /// replaces the password and hands the cipher to an access connector, so managing it takes both. + /// + Task CanEditAllAsync(Guid organizationId, IEnumerable cipherIds); +} diff --git a/bitwarden_license/src/Services/Pam/Utilities/ServiceCollectionExtensions.cs b/bitwarden_license/src/Services/Pam/Utilities/ServiceCollectionExtensions.cs index ca745e19ac8a..9679a74e527d 100644 --- a/bitwarden_license/src/Services/Pam/Utilities/ServiceCollectionExtensions.cs +++ b/bitwarden_license/src/Services/Pam/Utilities/ServiceCollectionExtensions.cs @@ -111,6 +111,7 @@ private static IServiceCollection AddPamRotationServices(this IServiceCollection services.AddScoped(); services.AddScoped(); + services.AddScoped(); return services; } diff --git a/bitwarden_license/test/Services/Pam.Test/AccessConnector/Api/Endpoints/Handlers/AccessConnectorEndpointsHandlerTests.cs b/bitwarden_license/test/Services/Pam.Test/AccessConnector/Api/Endpoints/Handlers/AccessConnectorEndpointsHandlerTests.cs new file mode 100644 index 000000000000..fca87c018e07 --- /dev/null +++ b/bitwarden_license/test/Services/Pam.Test/AccessConnector/Api/Endpoints/Handlers/AccessConnectorEndpointsHandlerTests.cs @@ -0,0 +1,58 @@ +using Bit.Core.Context; +using Bit.Core.Exceptions; +using Bit.Pam.Models; +using Bit.Pam.Repositories; +using Bit.Services.Pam.AccessConnector.Api.Endpoints.Handlers; +using Bit.Services.Pam.AccessConnector.Api.Models.Request; +using Bit.Services.Pam.AccessConnector.Commands.Interfaces; +using Bit.Services.Pam.AccessConnector.Queries.Interfaces; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Services.Pam.Test.AccessConnector.Api.Endpoints.Handlers; + +/// +/// An access connector receives every cipher its target systems rotate, so assigning one needs edit access to each. +/// +[SutProviderCustomize] +public class AccessConnectorEndpointsHandlerTests +{ + [Theory, BitAutoData] + public async Task AssignTarget_TargetRotatesACipherNotEditable_ThrowsBadRequest( + Guid organizationId, Guid accessConnectorId, PamRotationConfigDetails config, + SutProvider sutProvider) + { + var model = new AssignAccessConnectorTargetRequestModel { TargetSystemId = config.TargetSystemId }; + sutProvider.GetDependency().GetManyDetailsByOrganizationIdAsync(organizationId) + .Returns(new List { config }); + sutProvider.GetDependency() + .CanEditAllAsync(organizationId, Arg.Is>(ids => ids.SequenceEqual(new[] { config.CipherId }))) + .Returns(false); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.AssignTarget(organizationId, accessConnectorId, model)); + + Assert.Empty(sutProvider.GetDependency().ReceivedCalls()); + } + + [Theory, BitAutoData] + public async Task AssignTarget_ChecksOnlyTheTargetsCiphers_AndAssigns( + Guid organizationId, Guid accessConnectorId, Guid userId, PamRotationConfigDetails onTarget, + PamRotationConfigDetails onAnotherTarget, SutProvider sutProvider) + { + var model = new AssignAccessConnectorTargetRequestModel { TargetSystemId = onTarget.TargetSystemId }; + sutProvider.GetDependency().UserId.Returns(userId); + sutProvider.GetDependency().GetManyDetailsByOrganizationIdAsync(organizationId) + .Returns(new List { onTarget, onAnotherTarget }); + sutProvider.GetDependency() + .CanEditAllAsync(organizationId, Arg.Is>(ids => ids.SequenceEqual(new[] { onTarget.CipherId }))) + .Returns(true); + + await sutProvider.Sut.AssignTarget(organizationId, accessConnectorId, model); + + await sutProvider.GetDependency().Received(1) + .AssignAsync(organizationId, userId, accessConnectorId, onTarget.TargetSystemId); + } +} diff --git a/bitwarden_license/test/Services/Pam.Test/AccessConnector/Queries/CanEditRotationCiphersQueryTests.cs b/bitwarden_license/test/Services/Pam.Test/AccessConnector/Queries/CanEditRotationCiphersQueryTests.cs new file mode 100644 index 000000000000..9a1b774f57ef --- /dev/null +++ b/bitwarden_license/test/Services/Pam.Test/AccessConnector/Queries/CanEditRotationCiphersQueryTests.cs @@ -0,0 +1,68 @@ +using Bit.Core.Vault.Models.Data; +using Bit.Core.Vault.Queries; +using Bit.Services.Pam.AccessConnector.Queries; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Services.Pam.Test.AccessConnector.Queries; + +[SutProviderCustomize] +public class CanEditRotationCiphersQueryTests +{ + [Theory, BitAutoData] + public async Task CanEditAllAsync_NoCiphers_ReturnsTrueWithoutReadingPermissions( + Guid organizationId, SutProvider sutProvider) + { + Assert.True(await sutProvider.Sut.CanEditAllAsync(organizationId, [])); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .GetByOrganization(default); + } + + [Theory, BitAutoData] + public async Task CanEditAllAsync_EveryCipherEditableWithPassword_ReturnsTrue( + Guid organizationId, Guid firstId, Guid secondId, SutProvider sutProvider) + { + SetPermissions(sutProvider, organizationId, + Permission(firstId, edit: true, viewPassword: true), + Permission(secondId, edit: true, viewPassword: true)); + + Assert.True(await sutProvider.Sut.CanEditAllAsync(organizationId, [firstId, secondId])); + } + + [Theory] + [BitAutoData(false, true)] + [BitAutoData(true, false)] + public async Task CanEditAllAsync_OneCipherNotEditableOrPasswordHidden_ReturnsFalse( + bool edit, bool viewPassword, + Guid organizationId, Guid firstId, Guid secondId, SutProvider sutProvider) + { + SetPermissions(sutProvider, organizationId, + Permission(firstId, edit: true, viewPassword: true), + Permission(secondId, edit, viewPassword)); + + Assert.False(await sutProvider.Sut.CanEditAllAsync(organizationId, [firstId, secondId])); + } + + [Theory, BitAutoData] + public async Task CanEditAllAsync_CipherTheUserCannotSee_ReturnsFalse( + Guid organizationId, Guid visibleId, Guid hiddenId, SutProvider sutProvider) + { + SetPermissions(sutProvider, organizationId, Permission(visibleId, edit: true, viewPassword: true)); + + Assert.False(await sutProvider.Sut.CanEditAllAsync(organizationId, [hiddenId])); + } + + private static OrganizationCipherPermission Permission(Guid id, bool edit, bool viewPassword) => + new() { Id = id, Read = true, Edit = edit, ViewPassword = viewPassword }; + + private static void SetPermissions( + SutProvider sutProvider, Guid organizationId, + params OrganizationCipherPermission[] permissions) + { + sutProvider.GetDependency().GetByOrganization(organizationId) + .Returns(permissions.ToDictionary(p => p.Id)); + } +} From 820ac8c2c4968cbda0c6a08ebbc6591b04a21838 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anders=20=C3=85berg?= Date: Fri, 16 Oct 2026 13:28:37 +0200 Subject: [PATCH 03/12] [PAM-193] Refuse access connector register, enable and assign when the organization has no PAM --- .../AssignAccessConnectorToTargetCommand.cs | 7 +++ .../RegisterAccessConnectorCommand.cs | 7 +++ .../SetAccessConnectorStatusCommand.cs | 11 +++++ .../Services/Pam/Utilities/PamPlanGuard.cs | 30 ++++++++++++ ...signAccessConnectorToTargetCommandTests.cs | 29 +++++++++++- .../RegisterAccessConnectorCommandTests.cs | 43 ++++++++++++++++- .../SetAccessConnectorStatusCommandTests.cs | 47 ++++++++++++++++++- 7 files changed, 171 insertions(+), 3 deletions(-) create mode 100644 bitwarden_license/src/Services/Pam/Utilities/PamPlanGuard.cs diff --git a/bitwarden_license/src/Services/Pam/AccessConnector/Commands/AssignAccessConnectorToTargetCommand.cs b/bitwarden_license/src/Services/Pam/AccessConnector/Commands/AssignAccessConnectorToTargetCommand.cs index a21d57d00c00..d73c9e2de866 100644 --- a/bitwarden_license/src/Services/Pam/AccessConnector/Commands/AssignAccessConnectorToTargetCommand.cs +++ b/bitwarden_license/src/Services/Pam/AccessConnector/Commands/AssignAccessConnectorToTargetCommand.cs @@ -1,10 +1,12 @@ using Bit.Core.Exceptions; +using Bit.Core.Repositories; using Bit.Pam.Entities; using Bit.Pam.Enums; using Bit.Pam.Models; using Bit.Pam.Repositories; using Bit.Services.Pam.AccessConnector.Commands.Interfaces; using Bit.Services.Pam.Services; +using Bit.Services.Pam.Utilities; namespace Bit.Services.Pam.AccessConnector.Commands; @@ -13,17 +15,20 @@ public class AssignAccessConnectorToTargetCommand : IAssignAccessConnectorToTarg { private readonly IPamAccessConnectorRepository _accessConnectorRepository; private readonly IPamTargetSystemRepository _targetSystemRepository; + private readonly IOrganizationRepository _organizationRepository; private readonly IAccessAuditEventEmitter _accessAuditEventEmitter; private readonly TimeProvider _timeProvider; public AssignAccessConnectorToTargetCommand( IPamAccessConnectorRepository accessConnectorRepository, IPamTargetSystemRepository targetSystemRepository, + IOrganizationRepository organizationRepository, IAccessAuditEventEmitter accessAuditEventEmitter, TimeProvider timeProvider) { _accessConnectorRepository = accessConnectorRepository; _targetSystemRepository = targetSystemRepository; + _organizationRepository = organizationRepository; _accessAuditEventEmitter = accessAuditEventEmitter; _timeProvider = timeProvider; } @@ -42,6 +47,8 @@ public async Task AssignAsync(Guid organizationId, Guid actingUserId, Guid acces throw new NotFoundException(); } + await _organizationRepository.RequirePamPlanAsync(organizationId); + // Both rows matched the route organization, so the same-org invariant holds. if (accessConnector.Status != PamAccessConnectorStatus.Enabled) { diff --git a/bitwarden_license/src/Services/Pam/AccessConnector/Commands/RegisterAccessConnectorCommand.cs b/bitwarden_license/src/Services/Pam/AccessConnector/Commands/RegisterAccessConnectorCommand.cs index a722203f3676..938e67af1653 100644 --- a/bitwarden_license/src/Services/Pam/AccessConnector/Commands/RegisterAccessConnectorCommand.cs +++ b/bitwarden_license/src/Services/Pam/AccessConnector/Commands/RegisterAccessConnectorCommand.cs @@ -1,6 +1,7 @@ using System.Security.Cryptography; using System.Text; using Bit.Core.Exceptions; +using Bit.Core.Repositories; using Bit.Core.SecretsManager.Entities; using Bit.Core.SecretsManager.Repositories; using Bit.Core.Utilities; @@ -11,6 +12,7 @@ using Bit.Services.Pam.AccessConnector.Commands.Interfaces; using Bit.Services.Pam.AccessConnector.Models; using Bit.Services.Pam.Services; +using Bit.Services.Pam.Utilities; namespace Bit.Services.Pam.AccessConnector.Commands; @@ -22,17 +24,20 @@ public class RegisterAccessConnectorCommand : IRegisterAccessConnectorCommand private readonly IApiKeyRepository _apiKeyRepository; private readonly IPamAccessConnectorRepository _accessConnectorRepository; + private readonly IOrganizationRepository _organizationRepository; private readonly IAccessAuditEventEmitter _accessAuditEventEmitter; private readonly TimeProvider _timeProvider; public RegisterAccessConnectorCommand( IApiKeyRepository apiKeyRepository, IPamAccessConnectorRepository accessConnectorRepository, + IOrganizationRepository organizationRepository, IAccessAuditEventEmitter accessAuditEventEmitter, TimeProvider timeProvider) { _apiKeyRepository = apiKeyRepository; _accessConnectorRepository = accessConnectorRepository; + _organizationRepository = organizationRepository; _accessAuditEventEmitter = accessAuditEventEmitter; _timeProvider = timeProvider; } @@ -45,6 +50,8 @@ public async Task RegisterAsync( throw new BadRequestException("Name is required."); } + await _organizationRepository.RequirePamPlanAsync(organizationId); + var now = _timeProvider.GetUtcNow().UtcDateTime; // Attempt before either row is written, outcome after both exist. diff --git a/bitwarden_license/src/Services/Pam/AccessConnector/Commands/SetAccessConnectorStatusCommand.cs b/bitwarden_license/src/Services/Pam/AccessConnector/Commands/SetAccessConnectorStatusCommand.cs index 16de6f5ef9a5..062a6a39d4ac 100644 --- a/bitwarden_license/src/Services/Pam/AccessConnector/Commands/SetAccessConnectorStatusCommand.cs +++ b/bitwarden_license/src/Services/Pam/AccessConnector/Commands/SetAccessConnectorStatusCommand.cs @@ -1,9 +1,11 @@ using Bit.Core.Exceptions; +using Bit.Core.Repositories; using Bit.Pam.Enums; using Bit.Pam.Models; using Bit.Pam.Repositories; using Bit.Services.Pam.AccessConnector.Commands.Interfaces; using Bit.Services.Pam.Services; +using Bit.Services.Pam.Utilities; namespace Bit.Services.Pam.AccessConnector.Commands; @@ -11,15 +13,18 @@ namespace Bit.Services.Pam.AccessConnector.Commands; public class SetAccessConnectorStatusCommand : ISetAccessConnectorStatusCommand { private readonly IPamAccessConnectorRepository _accessConnectorRepository; + private readonly IOrganizationRepository _organizationRepository; private readonly IAccessAuditEventEmitter _accessAuditEventEmitter; private readonly TimeProvider _timeProvider; public SetAccessConnectorStatusCommand( IPamAccessConnectorRepository accessConnectorRepository, + IOrganizationRepository organizationRepository, IAccessAuditEventEmitter accessAuditEventEmitter, TimeProvider timeProvider) { _accessConnectorRepository = accessConnectorRepository; + _organizationRepository = organizationRepository; _accessAuditEventEmitter = accessAuditEventEmitter; _timeProvider = timeProvider; } @@ -32,6 +37,12 @@ public async Task SetStatusAsync(Guid organizationId, Guid actingUserId, Guid ac throw new NotFoundException(); } + // Disable stays open, so an admin can still turn a connector off after the organization loses PAM. + if (enable) + { + await _organizationRepository.RequirePamPlanAsync(organizationId); + } + var desired = enable ? PamAccessConnectorStatus.Enabled : PamAccessConnectorStatus.Disabled; if (accessConnector.Status == desired) { diff --git a/bitwarden_license/src/Services/Pam/Utilities/PamPlanGuard.cs b/bitwarden_license/src/Services/Pam/Utilities/PamPlanGuard.cs new file mode 100644 index 000000000000..c12d05681522 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Utilities/PamPlanGuard.cs @@ -0,0 +1,30 @@ +using Bit.Core.Exceptions; +using Bit.Core.Repositories; + +namespace Bit.Services.Pam.Utilities; + +/// +/// The organization plan check (Organization.UsePam) on the access connector paths that grant access. +/// +/// +/// Guards register, enable and assign only; disable, delete, unassign and reads stay open, so an admin can still +/// clean up after the organization loses PAM. +/// +public static class PamPlanGuard +{ + public const string NoPamPlanMessage = "Your organization's plan does not include Privileged Controls."; + + /// + /// Throws with if the organization is missing + /// or its plan does not include PAM. + /// + public static async Task RequirePamPlanAsync( + this IOrganizationRepository organizationRepository, Guid organizationId) + { + var organization = await organizationRepository.GetByIdAsync(organizationId); + if (organization is not { UsePam: true }) + { + throw new BadRequestException(NoPamPlanMessage); + } + } +} diff --git a/bitwarden_license/test/Services/Pam.Test/AccessConnector/Commands/AssignAccessConnectorToTargetCommandTests.cs b/bitwarden_license/test/Services/Pam.Test/AccessConnector/Commands/AssignAccessConnectorToTargetCommandTests.cs index 5855c3c6a43e..3e60e0793bbf 100644 --- a/bitwarden_license/test/Services/Pam.Test/AccessConnector/Commands/AssignAccessConnectorToTargetCommandTests.cs +++ b/bitwarden_license/test/Services/Pam.Test/AccessConnector/Commands/AssignAccessConnectorToTargetCommandTests.cs @@ -1,4 +1,6 @@ -using Bit.Core.Exceptions; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; using Bit.Pam.Entities; using Bit.Pam.Enums; using Bit.Pam.Models; @@ -83,6 +85,29 @@ await sutProvider.GetDependency().DidNotReceiveWi .CreateAssignmentAsync(default!); } + [Theory, BitAutoData] + public async Task AssignAsync_OrganizationWithoutPam_ThrowsBadRequest(Guid actingUserId, PamAccessConnector accessConnector, PamTargetSystem target) + { + var sutProvider = Setup(); + accessConnector.Status = PamAccessConnectorStatus.Enabled; + target.OrganizationId = accessConnector.OrganizationId; + target.Method = PamTargetSystemMethod.Automatic; + sutProvider.GetDependency().GetByIdAsync(accessConnector.Id) + .Returns(accessConnector); + sutProvider.GetDependency().GetByIdAsync(target.Id).Returns(target); + sutProvider.GetDependency().GetByIdAsync(accessConnector.OrganizationId) + .Returns(new Organization { Id = accessConnector.OrganizationId, UsePam = false }); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.AssignAsync( + accessConnector.OrganizationId, actingUserId, accessConnector.Id, target.Id)); + + Assert.Equal("Your organization's plan does not include Privileged Controls.", exception.Message); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .CreateAssignmentAsync(default!); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().EmitAsync(default!); + } + [Theory, BitAutoData] public async Task AssignAsync_AccessConnectorDisabled_ThrowsBadRequest(Guid actingUserId, PamAccessConnector accessConnector, PamTargetSystem target) { @@ -191,6 +216,8 @@ private static SutProvider Setup() { var sutProvider = new SutProvider().WithFakeTimeProvider().Create(); sutProvider.GetDependency().SetUtcNow(_now); + sutProvider.GetDependency().GetByIdAsync(Arg.Any()) + .Returns(call => new Organization { Id = call.Arg(), UsePam = true }); return sutProvider; } } diff --git a/bitwarden_license/test/Services/Pam.Test/AccessConnector/Commands/RegisterAccessConnectorCommandTests.cs b/bitwarden_license/test/Services/Pam.Test/AccessConnector/Commands/RegisterAccessConnectorCommandTests.cs index c1da43d8f90e..d4baed287f2e 100644 --- a/bitwarden_license/test/Services/Pam.Test/AccessConnector/Commands/RegisterAccessConnectorCommandTests.cs +++ b/bitwarden_license/test/Services/Pam.Test/AccessConnector/Commands/RegisterAccessConnectorCommandTests.cs @@ -1,4 +1,6 @@ -using Bit.Core.Exceptions; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; using Bit.Core.SecretsManager.Entities; using Bit.Core.SecretsManager.Repositories; using Bit.Pam.Entities; @@ -34,6 +36,35 @@ await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs().CreateAsync(default!); } + [Theory, BitAutoData] + public async Task RegisterAsync_OrganizationWithoutPam_ThrowsBadRequest( + Guid organizationId, Guid actingUserId, string name, string encryptedPayload, string key) + { + var sutProvider = Setup(); + sutProvider.GetDependency().GetByIdAsync(organizationId) + .Returns(new Organization { Id = organizationId, UsePam = false }); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.RegisterAsync(organizationId, actingUserId, name, encryptedPayload, key)); + + Assert.Equal("Your organization's plan does not include Privileged Controls.", exception.Message); + await AssertNothingWritten(sutProvider); + } + + [Theory, BitAutoData] + public async Task RegisterAsync_OrganizationMissing_ThrowsBadRequest( + Guid organizationId, Guid actingUserId, string name, string encryptedPayload, string key) + { + var sutProvider = Setup(); + sutProvider.GetDependency().GetByIdAsync(organizationId) + .Returns((Organization?)null); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.RegisterAsync(organizationId, actingUserId, name, encryptedPayload, key)); + + await AssertNothingWritten(sutProvider); + } + [Theory, BitAutoData] public async Task RegisterAsync_HappyPath_CreatesApiKeyWithHashedSecretAndPamAccessConnector( Guid organizationId, Guid actingUserId, string name, string encryptedPayload, string key) @@ -93,9 +124,19 @@ private static SutProvider Setup() { var sutProvider = new SutProvider().WithFakeTimeProvider().Create(); sutProvider.GetDependency().SetUtcNow(_now); + sutProvider.GetDependency().GetByIdAsync(Arg.Any()) + .Returns(call => new Organization { Id = call.Arg(), UsePam = true }); return sutProvider; } + private static async Task AssertNothingWritten(SutProvider sutProvider) + { + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().CreateAsync(default!); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs().CreateAsync(default!); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().EmitAsync(default!); + } + private static void SetupCreates(SutProvider sutProvider, Guid apiKeyId, Guid accessConnectorId) { sutProvider.GetDependency().CreateAsync(Arg.Any()) diff --git a/bitwarden_license/test/Services/Pam.Test/AccessConnector/Commands/SetAccessConnectorStatusCommandTests.cs b/bitwarden_license/test/Services/Pam.Test/AccessConnector/Commands/SetAccessConnectorStatusCommandTests.cs index f52a2c315026..6963c972df13 100644 --- a/bitwarden_license/test/Services/Pam.Test/AccessConnector/Commands/SetAccessConnectorStatusCommandTests.cs +++ b/bitwarden_license/test/Services/Pam.Test/AccessConnector/Commands/SetAccessConnectorStatusCommandTests.cs @@ -1,4 +1,6 @@ -using Bit.Core.Exceptions; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; using Bit.Pam.Entities; using Bit.Pam.Enums; using Bit.Pam.Models; @@ -83,6 +85,47 @@ await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs().ReplaceAsync(default!); } + [Theory, BitAutoData] + public async Task SetStatusAsync_Enable_OrganizationWithoutPam_ThrowsBadRequest( + Guid actingUserId, PamAccessConnector accessConnector) + { + var sutProvider = Setup(); + accessConnector.Status = PamAccessConnectorStatus.Disabled; + sutProvider.GetDependency().GetByIdAsync(accessConnector.Id) + .Returns(accessConnector); + sutProvider.GetDependency().GetByIdAsync(accessConnector.OrganizationId) + .Returns(new Organization { Id = accessConnector.OrganizationId, UsePam = false }); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.SetStatusAsync( + accessConnector.OrganizationId, actingUserId, accessConnector.Id, enable: true)); + + Assert.Equal("Your organization's plan does not include Privileged Controls.", exception.Message); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs().ReplaceAsync(default!); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().EmitAsync(default!); + } + + [Theory, BitAutoData] + public async Task SetStatusAsync_Disable_OrganizationWithoutPam_SetsDisabled( + Guid actingUserId, PamAccessConnector accessConnector) + { + var sutProvider = Setup(); + accessConnector.Status = PamAccessConnectorStatus.Enabled; + sutProvider.GetDependency().GetByIdAsync(accessConnector.Id) + .Returns(accessConnector); + sutProvider.GetDependency().GetByIdAsync(accessConnector.OrganizationId) + .Returns(new Organization { Id = accessConnector.OrganizationId, UsePam = false }); + + // Disable stays open, so an admin can clean up after the organization loses PAM. + await sutProvider.Sut.SetStatusAsync( + accessConnector.OrganizationId, actingUserId, accessConnector.Id, enable: false); + + await sutProvider.GetDependency() + .Received(1).ReplaceAsync(Arg.Is(d => + d.Id == accessConnector.Id && d.Status == PamAccessConnectorStatus.Disabled)); + } + [Theory, BitAutoData] public async Task SetStatusAsync_Disable_SetsDisabled(Guid actingUserId, PamAccessConnector accessConnector) { @@ -158,6 +201,8 @@ private static SutProvider Setup() { var sutProvider = new SutProvider().WithFakeTimeProvider().Create(); sutProvider.GetDependency().SetUtcNow(_now); + sutProvider.GetDependency().GetByIdAsync(Arg.Any()) + .Returns(call => new Organization { Id = call.Arg(), UsePam = true }); return sutProvider; } } From d600787cff4776bd455dad2ccb539668e95fd260 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anders=20=C3=85berg?= Date: Fri, 16 Oct 2026 13:31:26 +0200 Subject: [PATCH 04/12] [PAM-193] Stop copying the access connector name into dbo.ApiKey.Name --- .../Commands/RegisterAccessConnectorCommand.cs | 4 +++- .../Commands/RegisterAccessConnectorCommandTests.cs | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/bitwarden_license/src/Services/Pam/AccessConnector/Commands/RegisterAccessConnectorCommand.cs b/bitwarden_license/src/Services/Pam/AccessConnector/Commands/RegisterAccessConnectorCommand.cs index 938e67af1653..827fde077972 100644 --- a/bitwarden_license/src/Services/Pam/AccessConnector/Commands/RegisterAccessConnectorCommand.cs +++ b/bitwarden_license/src/Services/Pam/AccessConnector/Commands/RegisterAccessConnectorCommand.cs @@ -21,6 +21,8 @@ public class RegisterAccessConnectorCommand : IRegisterAccessConnectorCommand { private const string AccessConnectorScope = "[\"api.pam.rotation\"]"; private const int ClientSecretLength = 30; + // dbo.ApiKey.Name is VARCHAR, so a non-Latin connector name would not survive; the name lives on the connector. + private const string ApiKeyName = "PAM access connector"; private readonly IApiKeyRepository _apiKeyRepository; private readonly IPamAccessConnectorRepository _accessConnectorRepository; @@ -71,7 +73,7 @@ public async Task RegisterAsync( var apiKey = new ApiKey { ServiceAccountId = null, - Name = name, + Name = ApiKeyName, ClientSecretHash = Hash(clientSecret), Scope = AccessConnectorScope, EncryptedPayload = encryptedPayload, diff --git a/bitwarden_license/test/Services/Pam.Test/AccessConnector/Commands/RegisterAccessConnectorCommandTests.cs b/bitwarden_license/test/Services/Pam.Test/AccessConnector/Commands/RegisterAccessConnectorCommandTests.cs index d4baed287f2e..e6b993d85755 100644 --- a/bitwarden_license/test/Services/Pam.Test/AccessConnector/Commands/RegisterAccessConnectorCommandTests.cs +++ b/bitwarden_license/test/Services/Pam.Test/AccessConnector/Commands/RegisterAccessConnectorCommandTests.cs @@ -76,10 +76,10 @@ public async Task RegisterAsync_HappyPath_CreatesApiKeyWithHashedSecretAndPamAcc var result = await sutProvider.Sut.RegisterAsync(organizationId, actingUserId, name, encryptedPayload, key); - // Not a Secrets Manager key, so it has no service account. + // Not a Secrets Manager key, so it has no service account. The name stays off the VARCHAR ApiKey row. await sutProvider.GetDependency().Received(1).CreateAsync(Arg.Is(k => k.ServiceAccountId == null - && k.Name == name + && k.Name == "PAM access connector" && k.Scope == "[\"api.pam.rotation\"]" && k.EncryptedPayload == encryptedPayload && k.Key == key From 4bcf8a83d27491731acffc3e792d9d776c751c46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anders=20=C3=85berg?= Date: Fri, 16 Oct 2026 13:51:54 +0200 Subject: [PATCH 05/12] [PAM-193] Read the PAM plan check from the organization ability cache --- .../AssignAccessConnectorToTargetCommand.cs | 12 ++++++------ .../RegisterAccessConnectorCommand.cs | 10 +++++----- .../SetAccessConnectorStatusCommand.cs | 12 ++++++------ .../Services/Pam/Utilities/PamPlanGuard.cs | 14 ++++++++------ ...signAccessConnectorToTargetCommandTests.cs | 14 ++++++++------ .../RegisterAccessConnectorCommandTests.cs | 19 +++++++++++-------- .../SetAccessConnectorStatusCommandTests.cs | 19 +++++++++++-------- 7 files changed, 55 insertions(+), 45 deletions(-) diff --git a/bitwarden_license/src/Services/Pam/AccessConnector/Commands/AssignAccessConnectorToTargetCommand.cs b/bitwarden_license/src/Services/Pam/AccessConnector/Commands/AssignAccessConnectorToTargetCommand.cs index d73c9e2de866..53884ecf8a3b 100644 --- a/bitwarden_license/src/Services/Pam/AccessConnector/Commands/AssignAccessConnectorToTargetCommand.cs +++ b/bitwarden_license/src/Services/Pam/AccessConnector/Commands/AssignAccessConnectorToTargetCommand.cs @@ -1,5 +1,5 @@ -using Bit.Core.Exceptions; -using Bit.Core.Repositories; +using Bit.Core.AdminConsole.AbilitiesCache; +using Bit.Core.Exceptions; using Bit.Pam.Entities; using Bit.Pam.Enums; using Bit.Pam.Models; @@ -15,20 +15,20 @@ public class AssignAccessConnectorToTargetCommand : IAssignAccessConnectorToTarg { private readonly IPamAccessConnectorRepository _accessConnectorRepository; private readonly IPamTargetSystemRepository _targetSystemRepository; - private readonly IOrganizationRepository _organizationRepository; + private readonly IOrganizationAbilityCacheService _organizationAbilityCacheService; private readonly IAccessAuditEventEmitter _accessAuditEventEmitter; private readonly TimeProvider _timeProvider; public AssignAccessConnectorToTargetCommand( IPamAccessConnectorRepository accessConnectorRepository, IPamTargetSystemRepository targetSystemRepository, - IOrganizationRepository organizationRepository, + IOrganizationAbilityCacheService organizationAbilityCacheService, IAccessAuditEventEmitter accessAuditEventEmitter, TimeProvider timeProvider) { _accessConnectorRepository = accessConnectorRepository; _targetSystemRepository = targetSystemRepository; - _organizationRepository = organizationRepository; + _organizationAbilityCacheService = organizationAbilityCacheService; _accessAuditEventEmitter = accessAuditEventEmitter; _timeProvider = timeProvider; } @@ -47,7 +47,7 @@ public async Task AssignAsync(Guid organizationId, Guid actingUserId, Guid acces throw new NotFoundException(); } - await _organizationRepository.RequirePamPlanAsync(organizationId); + await _organizationAbilityCacheService.RequirePamPlanAsync(organizationId); // Both rows matched the route organization, so the same-org invariant holds. if (accessConnector.Status != PamAccessConnectorStatus.Enabled) diff --git a/bitwarden_license/src/Services/Pam/AccessConnector/Commands/RegisterAccessConnectorCommand.cs b/bitwarden_license/src/Services/Pam/AccessConnector/Commands/RegisterAccessConnectorCommand.cs index 827fde077972..3be004227126 100644 --- a/bitwarden_license/src/Services/Pam/AccessConnector/Commands/RegisterAccessConnectorCommand.cs +++ b/bitwarden_license/src/Services/Pam/AccessConnector/Commands/RegisterAccessConnectorCommand.cs @@ -1,7 +1,7 @@ using System.Security.Cryptography; using System.Text; +using Bit.Core.AdminConsole.AbilitiesCache; using Bit.Core.Exceptions; -using Bit.Core.Repositories; using Bit.Core.SecretsManager.Entities; using Bit.Core.SecretsManager.Repositories; using Bit.Core.Utilities; @@ -26,20 +26,20 @@ public class RegisterAccessConnectorCommand : IRegisterAccessConnectorCommand private readonly IApiKeyRepository _apiKeyRepository; private readonly IPamAccessConnectorRepository _accessConnectorRepository; - private readonly IOrganizationRepository _organizationRepository; + private readonly IOrganizationAbilityCacheService _organizationAbilityCacheService; private readonly IAccessAuditEventEmitter _accessAuditEventEmitter; private readonly TimeProvider _timeProvider; public RegisterAccessConnectorCommand( IApiKeyRepository apiKeyRepository, IPamAccessConnectorRepository accessConnectorRepository, - IOrganizationRepository organizationRepository, + IOrganizationAbilityCacheService organizationAbilityCacheService, IAccessAuditEventEmitter accessAuditEventEmitter, TimeProvider timeProvider) { _apiKeyRepository = apiKeyRepository; _accessConnectorRepository = accessConnectorRepository; - _organizationRepository = organizationRepository; + _organizationAbilityCacheService = organizationAbilityCacheService; _accessAuditEventEmitter = accessAuditEventEmitter; _timeProvider = timeProvider; } @@ -52,7 +52,7 @@ public async Task RegisterAsync( throw new BadRequestException("Name is required."); } - await _organizationRepository.RequirePamPlanAsync(organizationId); + await _organizationAbilityCacheService.RequirePamPlanAsync(organizationId); var now = _timeProvider.GetUtcNow().UtcDateTime; diff --git a/bitwarden_license/src/Services/Pam/AccessConnector/Commands/SetAccessConnectorStatusCommand.cs b/bitwarden_license/src/Services/Pam/AccessConnector/Commands/SetAccessConnectorStatusCommand.cs index 062a6a39d4ac..267472ee4a55 100644 --- a/bitwarden_license/src/Services/Pam/AccessConnector/Commands/SetAccessConnectorStatusCommand.cs +++ b/bitwarden_license/src/Services/Pam/AccessConnector/Commands/SetAccessConnectorStatusCommand.cs @@ -1,5 +1,5 @@ -using Bit.Core.Exceptions; -using Bit.Core.Repositories; +using Bit.Core.AdminConsole.AbilitiesCache; +using Bit.Core.Exceptions; using Bit.Pam.Enums; using Bit.Pam.Models; using Bit.Pam.Repositories; @@ -13,18 +13,18 @@ namespace Bit.Services.Pam.AccessConnector.Commands; public class SetAccessConnectorStatusCommand : ISetAccessConnectorStatusCommand { private readonly IPamAccessConnectorRepository _accessConnectorRepository; - private readonly IOrganizationRepository _organizationRepository; + private readonly IOrganizationAbilityCacheService _organizationAbilityCacheService; private readonly IAccessAuditEventEmitter _accessAuditEventEmitter; private readonly TimeProvider _timeProvider; public SetAccessConnectorStatusCommand( IPamAccessConnectorRepository accessConnectorRepository, - IOrganizationRepository organizationRepository, + IOrganizationAbilityCacheService organizationAbilityCacheService, IAccessAuditEventEmitter accessAuditEventEmitter, TimeProvider timeProvider) { _accessConnectorRepository = accessConnectorRepository; - _organizationRepository = organizationRepository; + _organizationAbilityCacheService = organizationAbilityCacheService; _accessAuditEventEmitter = accessAuditEventEmitter; _timeProvider = timeProvider; } @@ -40,7 +40,7 @@ public async Task SetStatusAsync(Guid organizationId, Guid actingUserId, Guid ac // Disable stays open, so an admin can still turn a connector off after the organization loses PAM. if (enable) { - await _organizationRepository.RequirePamPlanAsync(organizationId); + await _organizationAbilityCacheService.RequirePamPlanAsync(organizationId); } var desired = enable ? PamAccessConnectorStatus.Enabled : PamAccessConnectorStatus.Disabled; diff --git a/bitwarden_license/src/Services/Pam/Utilities/PamPlanGuard.cs b/bitwarden_license/src/Services/Pam/Utilities/PamPlanGuard.cs index c12d05681522..4ef420b97126 100644 --- a/bitwarden_license/src/Services/Pam/Utilities/PamPlanGuard.cs +++ b/bitwarden_license/src/Services/Pam/Utilities/PamPlanGuard.cs @@ -1,14 +1,16 @@ -using Bit.Core.Exceptions; -using Bit.Core.Repositories; +using Bit.Core.AdminConsole.AbilitiesCache; +using Bit.Core.Exceptions; namespace Bit.Services.Pam.Utilities; /// -/// The organization plan check (Organization.UsePam) on the access connector paths that grant access. +/// The organization plan check (OrganizationAbility.UsePam) on the access connector paths that grant access. /// /// /// Guards register, enable and assign only; disable, delete, unassign and reads stay open, so an admin can still /// clean up after the organization loses PAM. +/// Reads the organization ability cache, which plan changes update; a direct database edit applies when the cache +/// entry expires. /// public static class PamPlanGuard { @@ -19,10 +21,10 @@ public static class PamPlanGuard /// or its plan does not include PAM. /// public static async Task RequirePamPlanAsync( - this IOrganizationRepository organizationRepository, Guid organizationId) + this IOrganizationAbilityCacheService organizationAbilityCacheService, Guid organizationId) { - var organization = await organizationRepository.GetByIdAsync(organizationId); - if (organization is not { UsePam: true }) + var ability = await organizationAbilityCacheService.GetOrganizationAbilityAsync(organizationId); + if (ability is not { UsePam: true }) { throw new BadRequestException(NoPamPlanMessage); } diff --git a/bitwarden_license/test/Services/Pam.Test/AccessConnector/Commands/AssignAccessConnectorToTargetCommandTests.cs b/bitwarden_license/test/Services/Pam.Test/AccessConnector/Commands/AssignAccessConnectorToTargetCommandTests.cs index 3e60e0793bbf..5d537583f192 100644 --- a/bitwarden_license/test/Services/Pam.Test/AccessConnector/Commands/AssignAccessConnectorToTargetCommandTests.cs +++ b/bitwarden_license/test/Services/Pam.Test/AccessConnector/Commands/AssignAccessConnectorToTargetCommandTests.cs @@ -1,6 +1,6 @@ -using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.AbilitiesCache; using Bit.Core.Exceptions; -using Bit.Core.Repositories; +using Bit.Core.Models.Data.Organizations; using Bit.Pam.Entities; using Bit.Pam.Enums; using Bit.Pam.Models; @@ -95,8 +95,9 @@ public async Task AssignAsync_OrganizationWithoutPam_ThrowsBadRequest(Guid actin sutProvider.GetDependency().GetByIdAsync(accessConnector.Id) .Returns(accessConnector); sutProvider.GetDependency().GetByIdAsync(target.Id).Returns(target); - sutProvider.GetDependency().GetByIdAsync(accessConnector.OrganizationId) - .Returns(new Organization { Id = accessConnector.OrganizationId, UsePam = false }); + sutProvider.GetDependency() + .GetOrganizationAbilityAsync(accessConnector.OrganizationId) + .Returns(new OrganizationAbility { Id = accessConnector.OrganizationId, UsePam = false }); var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.AssignAsync( @@ -216,8 +217,9 @@ private static SutProvider Setup() { var sutProvider = new SutProvider().WithFakeTimeProvider().Create(); sutProvider.GetDependency().SetUtcNow(_now); - sutProvider.GetDependency().GetByIdAsync(Arg.Any()) - .Returns(call => new Organization { Id = call.Arg(), UsePam = true }); + sutProvider.GetDependency() + .GetOrganizationAbilityAsync(Arg.Any()) + .Returns(call => new OrganizationAbility { Id = call.Arg(), UsePam = true }); return sutProvider; } } diff --git a/bitwarden_license/test/Services/Pam.Test/AccessConnector/Commands/RegisterAccessConnectorCommandTests.cs b/bitwarden_license/test/Services/Pam.Test/AccessConnector/Commands/RegisterAccessConnectorCommandTests.cs index e6b993d85755..053dc1ceb7ae 100644 --- a/bitwarden_license/test/Services/Pam.Test/AccessConnector/Commands/RegisterAccessConnectorCommandTests.cs +++ b/bitwarden_license/test/Services/Pam.Test/AccessConnector/Commands/RegisterAccessConnectorCommandTests.cs @@ -1,6 +1,6 @@ -using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.AbilitiesCache; using Bit.Core.Exceptions; -using Bit.Core.Repositories; +using Bit.Core.Models.Data.Organizations; using Bit.Core.SecretsManager.Entities; using Bit.Core.SecretsManager.Repositories; using Bit.Pam.Entities; @@ -41,8 +41,9 @@ public async Task RegisterAsync_OrganizationWithoutPam_ThrowsBadRequest( Guid organizationId, Guid actingUserId, string name, string encryptedPayload, string key) { var sutProvider = Setup(); - sutProvider.GetDependency().GetByIdAsync(organizationId) - .Returns(new Organization { Id = organizationId, UsePam = false }); + sutProvider.GetDependency() + .GetOrganizationAbilityAsync(organizationId) + .Returns(new OrganizationAbility { Id = organizationId, UsePam = false }); var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.RegisterAsync(organizationId, actingUserId, name, encryptedPayload, key)); @@ -56,8 +57,9 @@ public async Task RegisterAsync_OrganizationMissing_ThrowsBadRequest( Guid organizationId, Guid actingUserId, string name, string encryptedPayload, string key) { var sutProvider = Setup(); - sutProvider.GetDependency().GetByIdAsync(organizationId) - .Returns((Organization?)null); + sutProvider.GetDependency() + .GetOrganizationAbilityAsync(organizationId) + .Returns((OrganizationAbility?)null); await Assert.ThrowsAsync( () => sutProvider.Sut.RegisterAsync(organizationId, actingUserId, name, encryptedPayload, key)); @@ -124,8 +126,9 @@ private static SutProvider Setup() { var sutProvider = new SutProvider().WithFakeTimeProvider().Create(); sutProvider.GetDependency().SetUtcNow(_now); - sutProvider.GetDependency().GetByIdAsync(Arg.Any()) - .Returns(call => new Organization { Id = call.Arg(), UsePam = true }); + sutProvider.GetDependency() + .GetOrganizationAbilityAsync(Arg.Any()) + .Returns(call => new OrganizationAbility { Id = call.Arg(), UsePam = true }); return sutProvider; } diff --git a/bitwarden_license/test/Services/Pam.Test/AccessConnector/Commands/SetAccessConnectorStatusCommandTests.cs b/bitwarden_license/test/Services/Pam.Test/AccessConnector/Commands/SetAccessConnectorStatusCommandTests.cs index 6963c972df13..9a29a3bcf72e 100644 --- a/bitwarden_license/test/Services/Pam.Test/AccessConnector/Commands/SetAccessConnectorStatusCommandTests.cs +++ b/bitwarden_license/test/Services/Pam.Test/AccessConnector/Commands/SetAccessConnectorStatusCommandTests.cs @@ -1,6 +1,6 @@ -using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.AbilitiesCache; using Bit.Core.Exceptions; -using Bit.Core.Repositories; +using Bit.Core.Models.Data.Organizations; using Bit.Pam.Entities; using Bit.Pam.Enums; using Bit.Pam.Models; @@ -93,8 +93,9 @@ public async Task SetStatusAsync_Enable_OrganizationWithoutPam_ThrowsBadRequest( accessConnector.Status = PamAccessConnectorStatus.Disabled; sutProvider.GetDependency().GetByIdAsync(accessConnector.Id) .Returns(accessConnector); - sutProvider.GetDependency().GetByIdAsync(accessConnector.OrganizationId) - .Returns(new Organization { Id = accessConnector.OrganizationId, UsePam = false }); + sutProvider.GetDependency() + .GetOrganizationAbilityAsync(accessConnector.OrganizationId) + .Returns(new OrganizationAbility { Id = accessConnector.OrganizationId, UsePam = false }); var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.SetStatusAsync( @@ -114,8 +115,9 @@ public async Task SetStatusAsync_Disable_OrganizationWithoutPam_SetsDisabled( accessConnector.Status = PamAccessConnectorStatus.Enabled; sutProvider.GetDependency().GetByIdAsync(accessConnector.Id) .Returns(accessConnector); - sutProvider.GetDependency().GetByIdAsync(accessConnector.OrganizationId) - .Returns(new Organization { Id = accessConnector.OrganizationId, UsePam = false }); + sutProvider.GetDependency() + .GetOrganizationAbilityAsync(accessConnector.OrganizationId) + .Returns(new OrganizationAbility { Id = accessConnector.OrganizationId, UsePam = false }); // Disable stays open, so an admin can clean up after the organization loses PAM. await sutProvider.Sut.SetStatusAsync( @@ -201,8 +203,9 @@ private static SutProvider Setup() { var sutProvider = new SutProvider().WithFakeTimeProvider().Create(); sutProvider.GetDependency().SetUtcNow(_now); - sutProvider.GetDependency().GetByIdAsync(Arg.Any()) - .Returns(call => new Organization { Id = call.Arg(), UsePam = true }); + sutProvider.GetDependency() + .GetOrganizationAbilityAsync(Arg.Any()) + .Returns(call => new OrganizationAbility { Id = call.Arg(), UsePam = true }); return sutProvider; } } From be251190929b1212efd2078e0f7c49fcd18a1b6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anders=20=C3=85berg?= Date: Fri, 16 Oct 2026 15:24:40 +0200 Subject: [PATCH 06/12] [PAM-193] Check edit access to the target system's items in the assign command --- .../AccessConnectorEndpointsHandler.cs | 16 +---- .../AssignAccessConnectorToTargetCommand.cs | 15 ++++ .../IAssignAccessConnectorToTargetCommand.cs | 3 +- .../Api/Endpoints/PamEndpointsExtensions.cs | 4 +- .../AccessConnectorEndpointsHandlerTests.cs | 58 --------------- ...signAccessConnectorToTargetCommandTests.cs | 71 +++++++++++++++++++ 6 files changed, 92 insertions(+), 75 deletions(-) delete mode 100644 bitwarden_license/test/Services/Pam.Test/AccessConnector/Api/Endpoints/Handlers/AccessConnectorEndpointsHandlerTests.cs diff --git a/bitwarden_license/src/Services/Pam/AccessConnector/Api/Endpoints/Handlers/AccessConnectorEndpointsHandler.cs b/bitwarden_license/src/Services/Pam/AccessConnector/Api/Endpoints/Handlers/AccessConnectorEndpointsHandler.cs index bd727e80443d..12d726db9d24 100644 --- a/bitwarden_license/src/Services/Pam/AccessConnector/Api/Endpoints/Handlers/AccessConnectorEndpointsHandler.cs +++ b/bitwarden_license/src/Services/Pam/AccessConnector/Api/Endpoints/Handlers/AccessConnectorEndpointsHandler.cs @@ -1,7 +1,5 @@ using Bit.Core.Context; -using Bit.Core.Exceptions; using Bit.HttpExtensions; -using Bit.Pam.Repositories; using Bit.Services.Pam.AccessConnector.Api.Models.Request; using Bit.Services.Pam.AccessConnector.Api.Models.Response; using Bit.Services.Pam.AccessConnector.Commands.Interfaces; @@ -11,7 +9,7 @@ namespace Bit.Services.Pam.AccessConnector.Api.Endpoints.Handlers; /// /// Handler for the organizations/{orgId}/access-connectors resource, authorized in the middleware by -/// ManageAccessConnectorRequirement. +/// ManageAccessConnectorRequirement. The commands check the plan and edit access. /// public class AccessConnectorEndpointsHandler( ICurrentContext currentContext, @@ -21,9 +19,7 @@ public class AccessConnectorEndpointsHandler( ISetAccessConnectorStatusCommand setAccessConnectorStatusCommand, IDeleteAccessConnectorCommand deleteAccessConnectorCommand, IAssignAccessConnectorToTargetCommand assignAccessConnectorToTargetCommand, - IUnassignAccessConnectorFromTargetCommand unassignAccessConnectorFromTargetCommand, - IPamRotationConfigRepository configRepository, - ICanEditRotationCiphersQuery canEditRotationCiphersQuery) + IUnassignAccessConnectorFromTargetCommand unassignAccessConnectorFromTargetCommand) { public async Task> GetAll(Guid orgId) { @@ -62,14 +58,6 @@ public async Task Delete(Guid orgId, Guid id) public async Task AssignTarget(Guid orgId, Guid id, AssignAccessConnectorTargetRequestModel model) { - // The access connector receives every cipher the target system rotates. - var configs = await configRepository.GetManyDetailsByOrganizationIdAsync(orgId); - var cipherIds = configs.Where(c => c.TargetSystemId == model.TargetSystemId).Select(c => c.CipherId); - if (!await canEditRotationCiphersQuery.CanEditAllAsync(orgId, cipherIds)) - { - throw new BadRequestException("You need edit access to every item this target system rotates."); - } - await assignAccessConnectorToTargetCommand.AssignAsync( orgId, currentContext.UserId!.Value, id, model.TargetSystemId); } diff --git a/bitwarden_license/src/Services/Pam/AccessConnector/Commands/AssignAccessConnectorToTargetCommand.cs b/bitwarden_license/src/Services/Pam/AccessConnector/Commands/AssignAccessConnectorToTargetCommand.cs index 53884ecf8a3b..fcefa25631eb 100644 --- a/bitwarden_license/src/Services/Pam/AccessConnector/Commands/AssignAccessConnectorToTargetCommand.cs +++ b/bitwarden_license/src/Services/Pam/AccessConnector/Commands/AssignAccessConnectorToTargetCommand.cs @@ -5,6 +5,7 @@ using Bit.Pam.Models; using Bit.Pam.Repositories; using Bit.Services.Pam.AccessConnector.Commands.Interfaces; +using Bit.Services.Pam.AccessConnector.Queries.Interfaces; using Bit.Services.Pam.Services; using Bit.Services.Pam.Utilities; @@ -15,6 +16,8 @@ public class AssignAccessConnectorToTargetCommand : IAssignAccessConnectorToTarg { private readonly IPamAccessConnectorRepository _accessConnectorRepository; private readonly IPamTargetSystemRepository _targetSystemRepository; + private readonly IPamRotationConfigRepository _rotationConfigRepository; + private readonly ICanEditRotationCiphersQuery _canEditRotationCiphersQuery; private readonly IOrganizationAbilityCacheService _organizationAbilityCacheService; private readonly IAccessAuditEventEmitter _accessAuditEventEmitter; private readonly TimeProvider _timeProvider; @@ -22,12 +25,16 @@ public class AssignAccessConnectorToTargetCommand : IAssignAccessConnectorToTarg public AssignAccessConnectorToTargetCommand( IPamAccessConnectorRepository accessConnectorRepository, IPamTargetSystemRepository targetSystemRepository, + IPamRotationConfigRepository rotationConfigRepository, + ICanEditRotationCiphersQuery canEditRotationCiphersQuery, IOrganizationAbilityCacheService organizationAbilityCacheService, IAccessAuditEventEmitter accessAuditEventEmitter, TimeProvider timeProvider) { _accessConnectorRepository = accessConnectorRepository; _targetSystemRepository = targetSystemRepository; + _rotationConfigRepository = rotationConfigRepository; + _canEditRotationCiphersQuery = canEditRotationCiphersQuery; _organizationAbilityCacheService = organizationAbilityCacheService; _accessAuditEventEmitter = accessAuditEventEmitter; _timeProvider = timeProvider; @@ -65,6 +72,14 @@ public async Task AssignAsync(Guid organizationId, Guid actingUserId, Guid acces throw new BadRequestException("This access connector is already assigned to this target system."); } + // The access connector receives every cipher the target system rotates. + var configs = await _rotationConfigRepository.GetManyDetailsByOrganizationIdAsync(organizationId); + var cipherIds = configs.Where(c => c.TargetSystemId == targetSystemId).Select(c => c.CipherId); + if (!await _canEditRotationCiphersQuery.CanEditAllAsync(organizationId, cipherIds)) + { + throw new BadRequestException("You need edit access to every item this target system rotates."); + } + var now = _timeProvider.GetUtcNow().UtcDateTime; var assignment = new PamAccessConnectorTargetAssignment { diff --git a/bitwarden_license/src/Services/Pam/AccessConnector/Commands/Interfaces/IAssignAccessConnectorToTargetCommand.cs b/bitwarden_license/src/Services/Pam/AccessConnector/Commands/Interfaces/IAssignAccessConnectorToTargetCommand.cs index 49ab3dadcdaa..a315e2d13e2f 100644 --- a/bitwarden_license/src/Services/Pam/AccessConnector/Commands/Interfaces/IAssignAccessConnectorToTargetCommand.cs +++ b/bitwarden_license/src/Services/Pam/AccessConnector/Commands/Interfaces/IAssignAccessConnectorToTargetCommand.cs @@ -4,7 +4,8 @@ public interface IAssignAccessConnectorToTargetCommand { /// /// Assigns an access connector to a target system (invariant OneAssignmentPerConnectorTarget). Requires an - /// enabled access connector and an target. + /// enabled access connector, an target, and edit + /// access to every item the target rotates. /// Task AssignAsync(Guid organizationId, Guid actingUserId, Guid accessConnectorId, Guid targetSystemId); } diff --git a/bitwarden_license/src/Services/Pam/Api/Endpoints/PamEndpointsExtensions.cs b/bitwarden_license/src/Services/Pam/Api/Endpoints/PamEndpointsExtensions.cs index 12b0cba72efb..fb21ca05152a 100644 --- a/bitwarden_license/src/Services/Pam/Api/Endpoints/PamEndpointsExtensions.cs +++ b/bitwarden_license/src/Services/Pam/Api/Endpoints/PamEndpointsExtensions.cs @@ -47,8 +47,8 @@ private static RouteGroupBuilder WithPamDefaults(this RouteGroupBuilder group) = /// /// The access connector's admin surface: behind the connector flag rather than the base PAM flag, and authorized /// in the middleware by rather than in the handlers. Handlers and - /// commands are left with resource scoping only -- confirming an id reached by route belongs to the route - /// organization. + /// commands are left with resource scoping -- confirming an id reached by route belongs to the route + /// organization -- and the commands also check the plan and edit access. /// private static RouteGroupBuilder WithPamAccessConnectorAdminDefaults(this RouteGroupBuilder group) { diff --git a/bitwarden_license/test/Services/Pam.Test/AccessConnector/Api/Endpoints/Handlers/AccessConnectorEndpointsHandlerTests.cs b/bitwarden_license/test/Services/Pam.Test/AccessConnector/Api/Endpoints/Handlers/AccessConnectorEndpointsHandlerTests.cs deleted file mode 100644 index fca87c018e07..000000000000 --- a/bitwarden_license/test/Services/Pam.Test/AccessConnector/Api/Endpoints/Handlers/AccessConnectorEndpointsHandlerTests.cs +++ /dev/null @@ -1,58 +0,0 @@ -using Bit.Core.Context; -using Bit.Core.Exceptions; -using Bit.Pam.Models; -using Bit.Pam.Repositories; -using Bit.Services.Pam.AccessConnector.Api.Endpoints.Handlers; -using Bit.Services.Pam.AccessConnector.Api.Models.Request; -using Bit.Services.Pam.AccessConnector.Commands.Interfaces; -using Bit.Services.Pam.AccessConnector.Queries.Interfaces; -using Bit.Test.Common.AutoFixture; -using Bit.Test.Common.AutoFixture.Attributes; -using NSubstitute; -using Xunit; - -namespace Bit.Services.Pam.Test.AccessConnector.Api.Endpoints.Handlers; - -/// -/// An access connector receives every cipher its target systems rotate, so assigning one needs edit access to each. -/// -[SutProviderCustomize] -public class AccessConnectorEndpointsHandlerTests -{ - [Theory, BitAutoData] - public async Task AssignTarget_TargetRotatesACipherNotEditable_ThrowsBadRequest( - Guid organizationId, Guid accessConnectorId, PamRotationConfigDetails config, - SutProvider sutProvider) - { - var model = new AssignAccessConnectorTargetRequestModel { TargetSystemId = config.TargetSystemId }; - sutProvider.GetDependency().GetManyDetailsByOrganizationIdAsync(organizationId) - .Returns(new List { config }); - sutProvider.GetDependency() - .CanEditAllAsync(organizationId, Arg.Is>(ids => ids.SequenceEqual(new[] { config.CipherId }))) - .Returns(false); - - await Assert.ThrowsAsync( - () => sutProvider.Sut.AssignTarget(organizationId, accessConnectorId, model)); - - Assert.Empty(sutProvider.GetDependency().ReceivedCalls()); - } - - [Theory, BitAutoData] - public async Task AssignTarget_ChecksOnlyTheTargetsCiphers_AndAssigns( - Guid organizationId, Guid accessConnectorId, Guid userId, PamRotationConfigDetails onTarget, - PamRotationConfigDetails onAnotherTarget, SutProvider sutProvider) - { - var model = new AssignAccessConnectorTargetRequestModel { TargetSystemId = onTarget.TargetSystemId }; - sutProvider.GetDependency().UserId.Returns(userId); - sutProvider.GetDependency().GetManyDetailsByOrganizationIdAsync(organizationId) - .Returns(new List { onTarget, onAnotherTarget }); - sutProvider.GetDependency() - .CanEditAllAsync(organizationId, Arg.Is>(ids => ids.SequenceEqual(new[] { onTarget.CipherId }))) - .Returns(true); - - await sutProvider.Sut.AssignTarget(organizationId, accessConnectorId, model); - - await sutProvider.GetDependency().Received(1) - .AssignAsync(organizationId, userId, accessConnectorId, onTarget.TargetSystemId); - } -} diff --git a/bitwarden_license/test/Services/Pam.Test/AccessConnector/Commands/AssignAccessConnectorToTargetCommandTests.cs b/bitwarden_license/test/Services/Pam.Test/AccessConnector/Commands/AssignAccessConnectorToTargetCommandTests.cs index 5d537583f192..3480ab570ddf 100644 --- a/bitwarden_license/test/Services/Pam.Test/AccessConnector/Commands/AssignAccessConnectorToTargetCommandTests.cs +++ b/bitwarden_license/test/Services/Pam.Test/AccessConnector/Commands/AssignAccessConnectorToTargetCommandTests.cs @@ -6,6 +6,7 @@ using Bit.Pam.Models; using Bit.Pam.Repositories; using Bit.Services.Pam.AccessConnector.Commands; +using Bit.Services.Pam.AccessConnector.Queries.Interfaces; using Bit.Services.Pam.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; @@ -126,6 +127,10 @@ await Assert.ThrowsAsync( await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() .CreateAssignmentAsync(default!); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .GetManyDetailsByOrganizationIdAsync(default); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .CanEditAllAsync(default, default!); } [Theory, BitAutoData] @@ -166,6 +171,66 @@ await Assert.ThrowsAsync( await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() .CreateAssignmentAsync(default!); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .GetManyDetailsByOrganizationIdAsync(default); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .CanEditAllAsync(default, default!); + } + + [Theory, BitAutoData] + public async Task AssignAsync_TargetRotatesACipherNotEditable_ThrowsBadRequest( + Guid actingUserId, PamAccessConnector accessConnector, PamTargetSystem target, PamRotationConfigDetails config) + { + var sutProvider = Setup(); + accessConnector.Status = PamAccessConnectorStatus.Enabled; + target.OrganizationId = accessConnector.OrganizationId; + target.Method = PamTargetSystemMethod.Automatic; + sutProvider.GetDependency().GetByIdAsync(accessConnector.Id) + .Returns(accessConnector); + sutProvider.GetDependency().GetByIdAsync(target.Id).Returns(target); + config.TargetSystemId = target.Id; + sutProvider.GetDependency() + .GetManyDetailsByOrganizationIdAsync(accessConnector.OrganizationId) + .Returns(new List { config }); + sutProvider.GetDependency() + .CanEditAllAsync(accessConnector.OrganizationId, + Arg.Is>(ids => ids.SequenceEqual(new[] { config.CipherId }))) + .Returns(false); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.AssignAsync( + accessConnector.OrganizationId, actingUserId, accessConnector.Id, target.Id)); + + Assert.Equal("You need edit access to every item this target system rotates.", exception.Message); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .CreateAssignmentAsync(default!); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().EmitAsync(default!); + } + + [Theory, BitAutoData] + public async Task AssignAsync_ChecksOnlyTheTargetsCiphers( + Guid actingUserId, PamAccessConnector accessConnector, PamTargetSystem target, + PamRotationConfigDetails onTarget, PamRotationConfigDetails onAnotherTarget) + { + var sutProvider = Setup(); + accessConnector.Status = PamAccessConnectorStatus.Enabled; + target.OrganizationId = accessConnector.OrganizationId; + target.Method = PamTargetSystemMethod.Automatic; + sutProvider.GetDependency().GetByIdAsync(accessConnector.Id) + .Returns(accessConnector); + sutProvider.GetDependency().GetByIdAsync(target.Id).Returns(target); + onTarget.TargetSystemId = target.Id; + sutProvider.GetDependency() + .GetManyDetailsByOrganizationIdAsync(accessConnector.OrganizationId) + .Returns(new List { onTarget, onAnotherTarget }); + + await sutProvider.Sut.AssignAsync(accessConnector.OrganizationId, actingUserId, accessConnector.Id, target.Id); + + await sutProvider.GetDependency().Received(1) + .CanEditAllAsync(accessConnector.OrganizationId, + Arg.Is>(ids => ids.SequenceEqual(new[] { onTarget.CipherId }))); + await sutProvider.GetDependency().ReceivedWithAnyArgs(1) + .CreateAssignmentAsync(default!); } [Theory, BitAutoData] @@ -220,6 +285,12 @@ private static SutProvider Setup() sutProvider.GetDependency() .GetOrganizationAbilityAsync(Arg.Any()) .Returns(call => new OrganizationAbility { Id = call.Arg(), UsePam = true }); + sutProvider.GetDependency() + .GetManyDetailsByOrganizationIdAsync(Arg.Any()) + .Returns(new List()); + sutProvider.GetDependency() + .CanEditAllAsync(Arg.Any(), Arg.Any>()) + .Returns(true); return sutProvider; } } From 4dcaa2efcac2c0307e878733a0171b8f69202ac4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anders=20=C3=85berg?= Date: Fri, 16 Oct 2026 15:27:21 +0200 Subject: [PATCH 07/12] [PAM-193] Skip the cipher permission read when the user can edit every item --- .../Queries/CanEditRotationCiphersQuery.cs | 43 +++++++++- .../CanEditRotationCiphersQueryTests.cs | 85 ++++++++++++++++++- 2 files changed, 125 insertions(+), 3 deletions(-) diff --git a/bitwarden_license/src/Services/Pam/AccessConnector/Queries/CanEditRotationCiphersQuery.cs b/bitwarden_license/src/Services/Pam/AccessConnector/Queries/CanEditRotationCiphersQuery.cs index b22e5fd05f7b..ea7f289af194 100644 --- a/bitwarden_license/src/Services/Pam/AccessConnector/Queries/CanEditRotationCiphersQuery.cs +++ b/bitwarden_license/src/Services/Pam/AccessConnector/Queries/CanEditRotationCiphersQuery.cs @@ -1,4 +1,7 @@ -using Bit.Core.Vault.Queries; +using Bit.Core.AdminConsole.AbilitiesCache; +using Bit.Core.Context; +using Bit.Core.Enums; +using Bit.Core.Vault.Queries; using Bit.Services.Pam.AccessConnector.Queries.Interfaces; namespace Bit.Services.Pam.AccessConnector.Queries; @@ -6,10 +9,17 @@ namespace Bit.Services.Pam.AccessConnector.Queries; /// public class CanEditRotationCiphersQuery : ICanEditRotationCiphersQuery { + private readonly ICurrentContext _currentContext; + private readonly IOrganizationAbilityCacheService _organizationAbilityCacheService; private readonly IGetCipherPermissionsForUserQuery _getCipherPermissionsForUserQuery; - public CanEditRotationCiphersQuery(IGetCipherPermissionsForUserQuery getCipherPermissionsForUserQuery) + public CanEditRotationCiphersQuery( + ICurrentContext currentContext, + IOrganizationAbilityCacheService organizationAbilityCacheService, + IGetCipherPermissionsForUserQuery getCipherPermissionsForUserQuery) { + _currentContext = currentContext; + _organizationAbilityCacheService = organizationAbilityCacheService; _getCipherPermissionsForUserQuery = getCipherPermissionsForUserQuery; } @@ -21,7 +31,36 @@ public async Task CanEditAllAsync(Guid organizationId, IEnumerable c return true; } + if (await CanEditEveryCipherAsync(organizationId)) + { + return true; + } + var permissions = await _getCipherPermissionsForUserQuery.GetByOrganization(organizationId); return ids.All(id => permissions.TryGetValue(id, out var p) && p.Edit && p.ViewPassword); } + + /// + /// Whether grants Edit and ViewPassword on every + /// cipher, so the read cannot change the answer. Mirrors its CanEditAllCiphersAsync; the read returns no + /// ciphers for a disabled organization. + /// + private async Task CanEditEveryCipherAsync(Guid organizationId) + { + var org = _currentContext.GetOrganization(organizationId); + if (org is null || !_currentContext.UserId.HasValue) + { + return false; + } + + var orgAbility = await _organizationAbilityCacheService.GetOrganizationAbilityAsync(org.Id); + if (orgAbility is not { Enabled: true }) + { + return false; + } + + return org is { Type: OrganizationUserType.Custom, Permissions.EditAnyCollection: true } + || (orgAbility.AllowAdminAccessToAllCollectionItems + && org is { Type: OrganizationUserType.Admin or OrganizationUserType.Owner }); + } } diff --git a/bitwarden_license/test/Services/Pam.Test/AccessConnector/Queries/CanEditRotationCiphersQueryTests.cs b/bitwarden_license/test/Services/Pam.Test/AccessConnector/Queries/CanEditRotationCiphersQueryTests.cs index 9a1b774f57ef..2ec00dc7d21b 100644 --- a/bitwarden_license/test/Services/Pam.Test/AccessConnector/Queries/CanEditRotationCiphersQueryTests.cs +++ b/bitwarden_license/test/Services/Pam.Test/AccessConnector/Queries/CanEditRotationCiphersQueryTests.cs @@ -1,4 +1,9 @@ -using Bit.Core.Vault.Models.Data; +using Bit.Core.AdminConsole.AbilitiesCache; +using Bit.Core.Context; +using Bit.Core.Enums; +using Bit.Core.Models.Data; +using Bit.Core.Models.Data.Organizations; +using Bit.Core.Vault.Models.Data; using Bit.Core.Vault.Queries; using Bit.Services.Pam.AccessConnector.Queries; using Bit.Test.Common.AutoFixture; @@ -55,6 +60,84 @@ public async Task CanEditAllAsync_CipherTheUserCannotSee_ReturnsFalse( Assert.False(await sutProvider.Sut.CanEditAllAsync(organizationId, [hiddenId])); } + [Theory] + [BitAutoData(OrganizationUserType.Owner)] + [BitAutoData(OrganizationUserType.Admin)] + public async Task CanEditAllAsync_OwnerOrAdminWithAccessToAllItems_ReturnsTrueWithoutReadingPermissions( + OrganizationUserType type, Guid organizationId, Guid userId, Guid cipherId, + SutProvider sutProvider) + { + SetMembership(sutProvider, organizationId, userId, + new CurrentContextOrganization { Id = organizationId, Type = type }); + SetAbility(sutProvider, organizationId, enabled: true, allowAdminAccessToAllCollectionItems: true); + + Assert.True(await sutProvider.Sut.CanEditAllAsync(organizationId, [cipherId])); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .GetByOrganization(default); + } + + [Theory, BitAutoData] + public async Task CanEditAllAsync_CustomUserWithEditAnyCollection_ReturnsTrueWithoutReadingPermissions( + Guid organizationId, Guid userId, Guid cipherId, SutProvider sutProvider) + { + SetMembership(sutProvider, organizationId, userId, new CurrentContextOrganization + { + Id = organizationId, + Type = OrganizationUserType.Custom, + Permissions = new Permissions { EditAnyCollection = true }, + }); + SetAbility(sutProvider, organizationId, enabled: true, allowAdminAccessToAllCollectionItems: false); + + Assert.True(await sutProvider.Sut.CanEditAllAsync(organizationId, [cipherId])); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .GetByOrganization(default); + } + + [Theory] + [BitAutoData(OrganizationUserType.Admin, true, false)] + [BitAutoData(OrganizationUserType.User, true, true)] + [BitAutoData(OrganizationUserType.Custom, true, true)] + [BitAutoData(OrganizationUserType.Owner, false, true)] + public async Task CanEditAllAsync_NotGrantedEveryCipher_ReadsPermissions( + OrganizationUserType type, bool enabled, bool allowAdminAccessToAllCollectionItems, + Guid organizationId, Guid userId, Guid visibleId, Guid hiddenId, + SutProvider sutProvider) + { + SetMembership(sutProvider, organizationId, userId, + new CurrentContextOrganization { Id = organizationId, Type = type }); + SetAbility(sutProvider, organizationId, enabled, allowAdminAccessToAllCollectionItems); + SetPermissions(sutProvider, organizationId, Permission(visibleId, edit: true, viewPassword: true)); + + Assert.False(await sutProvider.Sut.CanEditAllAsync(organizationId, [hiddenId])); + + await sutProvider.GetDependency().Received(1) + .GetByOrganization(organizationId); + } + + private static void SetMembership( + SutProvider sutProvider, Guid organizationId, Guid userId, + CurrentContextOrganization organization) + { + var currentContext = sutProvider.GetDependency(); + currentContext.UserId.Returns(userId); + currentContext.GetOrganization(organizationId).Returns(organization); + } + + private static void SetAbility( + SutProvider sutProvider, Guid organizationId, bool enabled, + bool allowAdminAccessToAllCollectionItems) + { + sutProvider.GetDependency().GetOrganizationAbilityAsync(organizationId) + .Returns(new OrganizationAbility + { + Id = organizationId, + Enabled = enabled, + AllowAdminAccessToAllCollectionItems = allowAdminAccessToAllCollectionItems, + }); + } + private static OrganizationCipherPermission Permission(Guid id, bool edit, bool viewPassword) => new() { Id = id, Read = true, Edit = edit, ViewPassword = viewPassword }; From 7ad58ea2798ed0eb13cd47ca13daaab561e347e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anders=20=C3=85berg?= Date: Fri, 16 Oct 2026 15:28:51 +0200 Subject: [PATCH 08/12] [PAM-193] Give the access connector registration response its own object name --- .../Api/Models/Response/RegisterAccessConnectorResponseModel.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bitwarden_license/src/Services/Pam/AccessConnector/Api/Models/Response/RegisterAccessConnectorResponseModel.cs b/bitwarden_license/src/Services/Pam/AccessConnector/Api/Models/Response/RegisterAccessConnectorResponseModel.cs index 1ac49dd781b4..841a4897ef1e 100644 --- a/bitwarden_license/src/Services/Pam/AccessConnector/Api/Models/Response/RegisterAccessConnectorResponseModel.cs +++ b/bitwarden_license/src/Services/Pam/AccessConnector/Api/Models/Response/RegisterAccessConnectorResponseModel.cs @@ -9,7 +9,7 @@ namespace Bit.Services.Pam.AccessConnector.Api.Models.Response; public class RegisterAccessConnectorResponseModel : ResponseModel { public RegisterAccessConnectorResponseModel(PamAccessConnectorRegistrationResult result) - : base("pamAccessConnector") + : base("pamAccessConnectorRegistration") { ArgumentNullException.ThrowIfNull(result); From 6af3eca4eb04499afe6b4c50aa418ac45d08ec14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anders=20=C3=85berg?= Date: Fri, 16 Oct 2026 15:29:08 +0200 Subject: [PATCH 09/12] [PAM-193] Get the acting user id from the ClaimsPrincipal in the access connector handler --- .../Api/Endpoints/AccessConnectorEndpoints.cs | 25 +++++++------- .../AccessConnectorEndpointsHandler.cs | 33 +++++++++++-------- 2 files changed, 32 insertions(+), 26 deletions(-) diff --git a/bitwarden_license/src/Services/Pam/AccessConnector/Api/Endpoints/AccessConnectorEndpoints.cs b/bitwarden_license/src/Services/Pam/AccessConnector/Api/Endpoints/AccessConnectorEndpoints.cs index a28631f47a72..2de4232083c9 100644 --- a/bitwarden_license/src/Services/Pam/AccessConnector/Api/Endpoints/AccessConnectorEndpoints.cs +++ b/bitwarden_license/src/Services/Pam/AccessConnector/Api/Endpoints/AccessConnectorEndpoints.cs @@ -1,4 +1,5 @@ -using Bit.Services.Pam.AccessConnector.Api.Endpoints.Handlers; +using System.Security.Claims; +using Bit.Services.Pam.AccessConnector.Api.Endpoints.Handlers; using Bit.Services.Pam.AccessConnector.Api.Models.Request; using Microsoft.AspNetCore.Mvc; @@ -20,25 +21,25 @@ public static RouteGroupBuilder MapAccessConnectorEndpoints(this RouteGroupBuild "Returns one access connector with its recent rotation activity -- the jobs it has worked and " + "the attempts it recorded against them, newest first."); - group.MapPost("", ([FromRoute] Guid orgId, RegisterAccessConnectorRequestModel model, AccessConnectorEndpointsHandler handler) => handler.Post(orgId, model)) + group.MapPost("", ([FromRoute] Guid orgId, RegisterAccessConnectorRequestModel model, AccessConnectorEndpointsHandler handler, ClaimsPrincipal user) => handler.Post(user, orgId, model)) .WithName("Pam_AccessConnectors_Post") .WithDescription( "Registers an access connector and returns its client secret. The secret is shown exactly once here " + "-- the server hashes it for storage and cannot return it again."); group.MapPost("{id:guid}/enable", - async ([FromRoute] Guid orgId, Guid id, AccessConnectorEndpointsHandler handler) => + async ([FromRoute] Guid orgId, Guid id, AccessConnectorEndpointsHandler handler, ClaimsPrincipal user) => { - await handler.Enable(orgId, id); + await handler.Enable(user, orgId, id); return TypedResults.NoContent(); }) .WithName("Pam_AccessConnectors_Enable") .WithDescription("Re-enables a disabled access connector so it can authenticate and claim jobs again."); group.MapPost("{id:guid}/disable", - async ([FromRoute] Guid orgId, Guid id, AccessConnectorEndpointsHandler handler) => + async ([FromRoute] Guid orgId, Guid id, AccessConnectorEndpointsHandler handler, ClaimsPrincipal user) => { - await handler.Disable(orgId, id); + await handler.Disable(user, orgId, id); return TypedResults.NoContent(); }) .WithName("Pam_AccessConnectors_Disable") @@ -47,9 +48,9 @@ public static RouteGroupBuilder MapAccessConnectorEndpoints(this RouteGroupBuild "are released, but its credential is retained so it can be re-enabled later."); group.MapDelete("{id:guid}", - async ([FromRoute] Guid orgId, Guid id, AccessConnectorEndpointsHandler handler) => + async ([FromRoute] Guid orgId, Guid id, AccessConnectorEndpointsHandler handler, ClaimsPrincipal user) => { - await handler.Delete(orgId, id); + await handler.Delete(user, orgId, id); return TypedResults.NoContent(); }) .WithName("Pam_AccessConnectors_Delete") @@ -59,17 +60,17 @@ public static RouteGroupBuilder MapAccessConnectorEndpoints(this RouteGroupBuild "remediation for a suspected compromise."); group.MapPost("{id:guid}/assignments", - async ([FromRoute] Guid orgId, Guid id, AssignAccessConnectorTargetRequestModel model, AccessConnectorEndpointsHandler handler) => + async ([FromRoute] Guid orgId, Guid id, AssignAccessConnectorTargetRequestModel model, AccessConnectorEndpointsHandler handler, ClaimsPrincipal user) => { - await handler.AssignTarget(orgId, id, model); + await handler.AssignTarget(user, orgId, id, model); return TypedResults.NoContent(); }) .WithName("Pam_AccessConnectors_AssignTarget"); group.MapDelete("{id:guid}/assignments/{targetSystemId:guid}", - async ([FromRoute] Guid orgId, Guid id, Guid targetSystemId, AccessConnectorEndpointsHandler handler) => + async ([FromRoute] Guid orgId, Guid id, Guid targetSystemId, AccessConnectorEndpointsHandler handler, ClaimsPrincipal user) => { - await handler.UnassignTarget(orgId, id, targetSystemId); + await handler.UnassignTarget(user, orgId, id, targetSystemId); return TypedResults.NoContent(); }) .WithName("Pam_AccessConnectors_UnassignTarget"); diff --git a/bitwarden_license/src/Services/Pam/AccessConnector/Api/Endpoints/Handlers/AccessConnectorEndpointsHandler.cs b/bitwarden_license/src/Services/Pam/AccessConnector/Api/Endpoints/Handlers/AccessConnectorEndpointsHandler.cs index 12d726db9d24..788d2e697420 100644 --- a/bitwarden_license/src/Services/Pam/AccessConnector/Api/Endpoints/Handlers/AccessConnectorEndpointsHandler.cs +++ b/bitwarden_license/src/Services/Pam/AccessConnector/Api/Endpoints/Handlers/AccessConnectorEndpointsHandler.cs @@ -1,4 +1,5 @@ -using Bit.Core.Context; +using System.Security.Claims; +using Bit.Core.Services; using Bit.HttpExtensions; using Bit.Services.Pam.AccessConnector.Api.Models.Request; using Bit.Services.Pam.AccessConnector.Api.Models.Response; @@ -12,7 +13,7 @@ namespace Bit.Services.Pam.AccessConnector.Api.Endpoints.Handlers; /// ManageAccessConnectorRequirement. The commands check the plan and edit access. /// public class AccessConnectorEndpointsHandler( - ICurrentContext currentContext, + IUserService userService, IListAccessConnectorsQuery listAccessConnectorsQuery, IGetAccessConnectorDetailsQuery getAccessConnectorDetailsQuery, IRegisterAccessConnectorCommand registerAccessConnectorCommand, @@ -34,37 +35,41 @@ public async Task Get(Guid orgId, Guid id return new PamAccessConnectorDetailResponseModel(history); } - public async Task Post(Guid orgId, RegisterAccessConnectorRequestModel model) + public async Task Post( + ClaimsPrincipal user, Guid orgId, RegisterAccessConnectorRequestModel model) { var result = await registerAccessConnectorCommand.RegisterAsync( - orgId, currentContext.UserId!.Value, model.Name, model.EncryptedPayload, model.Key); + orgId, userService.GetProperUserId(user)!.Value, model.Name, model.EncryptedPayload, model.Key); return new RegisterAccessConnectorResponseModel(result); } - public async Task Enable(Guid orgId, Guid id) + public async Task Enable(ClaimsPrincipal user, Guid orgId, Guid id) { - await setAccessConnectorStatusCommand.SetStatusAsync(orgId, currentContext.UserId!.Value, id, enable: true); + await setAccessConnectorStatusCommand.SetStatusAsync( + orgId, userService.GetProperUserId(user)!.Value, id, enable: true); } - public async Task Disable(Guid orgId, Guid id) + public async Task Disable(ClaimsPrincipal user, Guid orgId, Guid id) { - await setAccessConnectorStatusCommand.SetStatusAsync(orgId, currentContext.UserId!.Value, id, enable: false); + await setAccessConnectorStatusCommand.SetStatusAsync( + orgId, userService.GetProperUserId(user)!.Value, id, enable: false); } - public async Task Delete(Guid orgId, Guid id) + public async Task Delete(ClaimsPrincipal user, Guid orgId, Guid id) { - await deleteAccessConnectorCommand.DeleteAsync(orgId, currentContext.UserId!.Value, id); + await deleteAccessConnectorCommand.DeleteAsync(orgId, userService.GetProperUserId(user)!.Value, id); } - public async Task AssignTarget(Guid orgId, Guid id, AssignAccessConnectorTargetRequestModel model) + public async Task AssignTarget( + ClaimsPrincipal user, Guid orgId, Guid id, AssignAccessConnectorTargetRequestModel model) { await assignAccessConnectorToTargetCommand.AssignAsync( - orgId, currentContext.UserId!.Value, id, model.TargetSystemId); + orgId, userService.GetProperUserId(user)!.Value, id, model.TargetSystemId); } - public async Task UnassignTarget(Guid orgId, Guid id, Guid targetSystemId) + public async Task UnassignTarget(ClaimsPrincipal user, Guid orgId, Guid id, Guid targetSystemId) { await unassignAccessConnectorFromTargetCommand.UnassignAsync( - orgId, currentContext.UserId!.Value, id, targetSystemId); + orgId, userService.GetProperUserId(user)!.Value, id, targetSystemId); } } From 265d24ec971f5e3e96fd1ceee8c8f2d2124e036b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anders=20=C3=85berg?= Date: Fri, 16 Oct 2026 15:29:40 +0200 Subject: [PATCH 10/12] [PAM-193] Bind the PAM rotation options in the API host --- .../Pam/Utilities/ServiceCollectionExtensions.cs | 16 ++++------------ .../ServiceCollectionExtensionsTests.cs | 4 +--- src/Api/Startup.cs | 4 +++- 3 files changed, 8 insertions(+), 16 deletions(-) diff --git a/bitwarden_license/src/Services/Pam/Utilities/ServiceCollectionExtensions.cs b/bitwarden_license/src/Services/Pam/Utilities/ServiceCollectionExtensions.cs index 9679a74e527d..209cda92326b 100644 --- a/bitwarden_license/src/Services/Pam/Utilities/ServiceCollectionExtensions.cs +++ b/bitwarden_license/src/Services/Pam/Utilities/ServiceCollectionExtensions.cs @@ -1,6 +1,5 @@ using Bit.Core.Pam.Services; using Bit.HttpExtensions; -using Bit.Services.Pam.AccessConnector; using Bit.Services.Pam.AccessConnector.Api.Endpoints.Filters; using Bit.Services.Pam.AccessConnector.Api.Endpoints.Handlers; using Bit.Services.Pam.AccessConnector.Commands; @@ -16,18 +15,13 @@ using Bit.Services.Pam.OrganizationFeatures.Queries; using Bit.Services.Pam.OrganizationFeatures.Queries.Interfaces; using Bit.Services.Pam.Services; -using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection.Extensions; namespace Bit.Services.Pam.Utilities; public static class ServiceCollectionExtensions { - /// - /// Registers PAM's commercial services, including credential rotation. binds - /// from globalSettings:pam:rotation. - /// - public static IServiceCollection AddPamServices(this IServiceCollection services, IConfiguration configuration) + public static IServiceCollection AddPamServices(this IServiceCollection services) { services.AddScoped(); services.AddScoped(); @@ -90,19 +84,17 @@ public static IServiceCollection AddPamServices(this IServiceCollection services services.AddScoped(); - services.AddPamRotationServices(configuration); + services.AddPamRotationServices(); services.AddPamOpenApiEndpointDataSource(); return services; } /// - /// Registers credential rotation. The repositories are registered elsewhere. + /// Registers credential rotation. The host registers the repositories and binds PamRotationOptions. /// - private static IServiceCollection AddPamRotationServices(this IServiceCollection services, IConfiguration configuration) + private static IServiceCollection AddPamRotationServices(this IServiceCollection services) { - services.Configure(configuration.GetSection("globalSettings:pam:rotation")); - services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/bitwarden_license/test/Services/Pam.Test/Utilities/ServiceCollectionExtensionsTests.cs b/bitwarden_license/test/Services/Pam.Test/Utilities/ServiceCollectionExtensionsTests.cs index 6d73e13e9d78..b799a7745177 100644 --- a/bitwarden_license/test/Services/Pam.Test/Utilities/ServiceCollectionExtensionsTests.cs +++ b/bitwarden_license/test/Services/Pam.Test/Utilities/ServiceCollectionExtensionsTests.cs @@ -1,7 +1,6 @@ using Bit.Core.Pam.Services; using Bit.Services.Pam.Services; using Bit.Services.Pam.Utilities; -using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Xunit; @@ -12,9 +11,8 @@ namespace Bit.Services.Pam.Test.Utilities; /// public class ServiceCollectionExtensionsTests { - // An empty configuration root leaves PamRotationOptions at its default, which is all these need. private static IServiceCollection PamServices() => - new ServiceCollection().AddPamServices(new ConfigurationBuilder().Build()); + new ServiceCollection().AddPamServices(); [Fact] public void AddPamServices_RegistersEveryPamOwnedDependency() diff --git a/src/Api/Startup.cs b/src/Api/Startup.cs index 701442dce2ef..398e56c6f9c1 100644 --- a/src/Api/Startup.cs +++ b/src/Api/Startup.cs @@ -43,6 +43,7 @@ using Bit.Commercial.Core.SecretsManager; using Bit.Commercial.Core.Utilities; using Bit.Commercial.Infrastructure.EntityFramework.SecretsManager; +using Bit.Services.Pam.AccessConnector; using Bit.Services.Pam.Api.Endpoints; using Bit.Services.Pam.Utilities; #endif @@ -216,7 +217,8 @@ public void ConfigureServices(IServiceCollection services) services.AddCommercialCoreServices(); services.AddCommercialSecretsManagerServices(); services.AddSecretsManagerEfRepositories(); - services.AddPamServices(Configuration); + services.AddPamServices(); + services.Configure(Configuration.GetSection("globalSettings:pam:rotation")); Jobs.JobsHostedService.AddCommercialSecretsManagerJobServices(services); #endif From 568cefcbb11f575d0cd5fca730ce542d894a3c4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anders=20=C3=85berg?= Date: Fri, 16 Oct 2026 15:30:02 +0200 Subject: [PATCH 11/12] [PAM-193] Move the heartbeat filter tests to the folder of the filter --- .../Filters}/AccessConnectorHeartbeatEndpointFilterTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename bitwarden_license/test/Services/Pam.Test/AccessConnector/Api/{ => Endpoints/Filters}/AccessConnectorHeartbeatEndpointFilterTests.cs (98%) diff --git a/bitwarden_license/test/Services/Pam.Test/AccessConnector/Api/AccessConnectorHeartbeatEndpointFilterTests.cs b/bitwarden_license/test/Services/Pam.Test/AccessConnector/Api/Endpoints/Filters/AccessConnectorHeartbeatEndpointFilterTests.cs similarity index 98% rename from bitwarden_license/test/Services/Pam.Test/AccessConnector/Api/AccessConnectorHeartbeatEndpointFilterTests.cs rename to bitwarden_license/test/Services/Pam.Test/AccessConnector/Api/Endpoints/Filters/AccessConnectorHeartbeatEndpointFilterTests.cs index 0b9d3fcbec3d..9c678553c273 100644 --- a/bitwarden_license/test/Services/Pam.Test/AccessConnector/Api/AccessConnectorHeartbeatEndpointFilterTests.cs +++ b/bitwarden_license/test/Services/Pam.Test/AccessConnector/Api/Endpoints/Filters/AccessConnectorHeartbeatEndpointFilterTests.cs @@ -11,7 +11,7 @@ using NSubstitute; using Xunit; -namespace Bit.Services.Pam.Test.AccessConnector.Api; +namespace Bit.Services.Pam.Test.AccessConnector.Api.Endpoints.Filters; /// /// The filter does not check connector eligibility; PamAccessConnectorClientProviderTests and From 74b7413ec7d8bf6510588ff8492860757a202417 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anders=20=C3=85berg?= Date: Mon, 19 Oct 2026 08:37:20 -0700 Subject: [PATCH 12/12] Update bitwarden_license/src/Services/Pam/AccessConnector/Commands/RegisterAccessConnectorCommand.cs Co-authored-by: Oscar Hinton --- .../AccessConnector/Commands/RegisterAccessConnectorCommand.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bitwarden_license/src/Services/Pam/AccessConnector/Commands/RegisterAccessConnectorCommand.cs b/bitwarden_license/src/Services/Pam/AccessConnector/Commands/RegisterAccessConnectorCommand.cs index 3be004227126..8af51d71559a 100644 --- a/bitwarden_license/src/Services/Pam/AccessConnector/Commands/RegisterAccessConnectorCommand.cs +++ b/bitwarden_license/src/Services/Pam/AccessConnector/Commands/RegisterAccessConnectorCommand.cs @@ -101,9 +101,8 @@ await _accessAuditEventEmitter.EmitAsync( private static string Hash(string input) { - using var sha = SHA256.Create(); var bytes = Encoding.UTF8.GetBytes(input); - var hash = sha.ComputeHash(bytes); + var hash = SHA256.HashData(bytes); return Convert.ToBase64String(hash); } }