From 8a34d5fea7dbb964864a193cc619cefa61c49921 Mon Sep 17 00:00:00 2001 From: maxkpower Date: Tue, 1 Sep 2026 00:05:27 +0200 Subject: [PATCH] Add Active Directory as a rotation target-system kind On-premises Active Directory joins Entra, MSSQL and CustomScript as a connector kind the access connector can rotate against. It is distinct from Entra, which is Entra ID. The server does not branch on Kind: the value is stored and handed to the daemon on the rotation claim, so no connector-specific server logic is needed. dbo.PamTargetSystem.Kind is a bare TINYINT NULL with no check constraint, so the new value needs no migration -- an integration test now round-trips every kind to hold that. --- .../src/Services/Pam/rotation-server.allium | 23 +++++----- .../RegisterTargetSystemCommandTests.cs | 12 +++-- src/Pam.Domain/Entities/PamTargetSystem.cs | 6 +-- src/Pam.Domain/Enums/PamTargetSystemKind.cs | 3 ++ .../PamTargetSystemRepositoryTests.cs | 46 +++++++++++++++---- 5 files changed, 62 insertions(+), 28 deletions(-) diff --git a/bitwarden_license/src/Services/Pam/rotation-server.allium b/bitwarden_license/src/Services/Pam/rotation-server.allium index f079f6ed73f7..66962fe8a403 100644 --- a/bitwarden_license/src/Services/Pam/rotation-server.allium +++ b/bitwarden_license/src/Services/Pam/rotation-server.allium @@ -181,12 +181,12 @@ enum FailureSyncState { target_unchanged | target_updated | indeterminate } -- Entities -- A target system the access connector can rotate credentials in (Entra, a SQL Server, a custom --- script, ...). Defined and owned server-side: the admin registers it with a name and a --- rotation method, and the server holds NO connection details -- those live in the --- access connector's local resolver, keyed by this id (see access-connector.allium). Status enables or --- disables the whole target. Status literals (active|disabled) are deliberately distinct --- from ConnectorRegistration's (enrolled|revoked) so untyped trigger-param inference stays --- unambiguous. +-- script, an on-premises Active Directory domain, ...). Defined and owned server-side: the admin +-- registers it with a name and a rotation method, and the server holds NO connection details -- +-- those live in the access connector's local resolver, keyed by this id (see +-- access-connector.allium). Status enables or disables the whole target. Status literals +-- (active|disabled) are deliberately distinct from ConnectorRegistration's (enrolled|revoked) so +-- untyped trigger-param inference stays unambiguous. entity TargetSystem { id: String name: String @@ -196,8 +196,9 @@ entity TargetSystem { -- manual: no access connector integration -- rotations are recorded out of band (Goal 3); kind, -- password_policy and supports_session_termination do not apply. method: automatic | manual - -- Which integration the access connector runs (Entra, MSSQL, or a custom script). - kind: entra | mssql | custom_script when method = automatic + -- Which integration the access connector runs (Entra, MSSQL, a custom script, or + -- on-premises Active Directory -- distinct from entra, which is Entra ID). + kind: entra | mssql | custom_script | active_directory when method = automatic password_policy: PasswordPolicy when method = automatic -- Whether the target can terminate active sessions for a rotated account. Used at -- config time to reject terminate_sessions on a target that cannot honour it @@ -294,9 +295,9 @@ entity RotationConfig { target_system: TargetSystem -- account_identity is an opaque String: the account's identifier in the target system, -- interpreted by the target's kind (an Entra UPN/email, a SQL Server login, a - -- custom-script argument). No type or kind discriminator -- the access connector's kind - -- integration knows how to read it; it is carried verbatim to the access connector on the claim - -- and never parsed server-side. + -- custom-script argument, an Active Directory sAMAccountName). No type or kind + -- discriminator -- the access connector's kind integration knows how to read it; it is + -- carried verbatim to the access connector on the claim and never parsed server-side. account_identity: String terminate_sessions: Boolean -- Whether this credential rotates automatically or by hand is a property of its target diff --git a/bitwarden_license/test/Services/Pam.Test/AccessConnector/Commands/RegisterTargetSystemCommandTests.cs b/bitwarden_license/test/Services/Pam.Test/AccessConnector/Commands/RegisterTargetSystemCommandTests.cs index 1c928c49c53f..77d1e56ada06 100644 --- a/bitwarden_license/test/Services/Pam.Test/AccessConnector/Commands/RegisterTargetSystemCommandTests.cs +++ b/bitwarden_license/test/Services/Pam.Test/AccessConnector/Commands/RegisterTargetSystemCommandTests.cs @@ -53,19 +53,23 @@ await Assert.ThrowsAsync(() => sutProvider.Sut.RegisterAsyn await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().CreateAsync(default!); } - [Theory, BitAutoData] + [Theory] + [BitAutoData(PamTargetSystemKind.Entra)] + [BitAutoData(PamTargetSystemKind.Mssql)] + [BitAutoData(PamTargetSystemKind.CustomScript)] + [BitAutoData(PamTargetSystemKind.ActiveDirectory)] public async Task RegisterAsync_AutomaticHappyPath_CreatesTargetWithSerializedPolicy( - Guid organizationId, Guid actingUserId, string name) + PamTargetSystemKind kind, Guid organizationId, Guid actingUserId, string name) { var sutProvider = Setup(); sutProvider.GetDependency().CreateAsync(Arg.Any()) .Returns(call => Task.FromResult(call.Arg())); var result = await sutProvider.Sut.RegisterAsync( - organizationId, actingUserId, name, PamTargetSystemMethod.Automatic, PamTargetSystemKind.Mssql, _policy, true); + organizationId, actingUserId, name, PamTargetSystemMethod.Automatic, kind, _policy, true); Assert.Equal(PamTargetSystemMethod.Automatic, result.Method); - Assert.Equal(PamTargetSystemKind.Mssql, result.Kind); + Assert.Equal(kind, result.Kind); Assert.True(result.SupportsSessionTermination); Assert.Equal(PamTargetSystemStatus.Active, result.Status); Assert.Equal(PamPasswordPolicy.Serialize(_policy), result.PasswordPolicy); diff --git a/src/Pam.Domain/Entities/PamTargetSystem.cs b/src/Pam.Domain/Entities/PamTargetSystem.cs index b8c16ded8faf..af655ea5ee2d 100644 --- a/src/Pam.Domain/Entities/PamTargetSystem.cs +++ b/src/Pam.Domain/Entities/PamTargetSystem.cs @@ -7,9 +7,9 @@ namespace Bit.Pam.Entities; /// /// A system PAM can rotate credentials against: an target driven by a -/// (Entra, MSSQL, or a custom script), or a target -/// that only tracks a schedule and records rotations a human performs out of band. and -/// are set only for an automatic target. +/// (Entra, MSSQL, a custom script, or Active Directory), or a +/// target that only tracks a schedule and records rotations a human +/// performs out of band. and are set only for an automatic target. /// public class PamTargetSystem : ITableObject { diff --git a/src/Pam.Domain/Enums/PamTargetSystemKind.cs b/src/Pam.Domain/Enums/PamTargetSystemKind.cs index 66771d716f78..e3a3905841fe 100644 --- a/src/Pam.Domain/Enums/PamTargetSystemKind.cs +++ b/src/Pam.Domain/Enums/PamTargetSystemKind.cs @@ -9,4 +9,7 @@ public enum PamTargetSystemKind : byte Entra = 0, Mssql = 1, CustomScript = 2, + + /// On-premises Active Directory, not Entra ID — see . + ActiveDirectory = 3, } diff --git a/test/Infrastructure.IntegrationTest/Pam/Repositories/PamTargetSystemRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Pam/Repositories/PamTargetSystemRepositoryTests.cs index 344e05ee8476..12672be927e1 100644 --- a/test/Infrastructure.IntegrationTest/Pam/Repositories/PamTargetSystemRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/Pam/Repositories/PamTargetSystemRepositoryTests.cs @@ -49,6 +49,28 @@ public async Task CreateAsync_ThenRead_RoundTripsFields( Assert.Equal(PamTargetSystemStatus.Active, persisted.Status); } + // Kind is a bare TINYINT with no check constraint: every connector kind persists on every provider without a + // schema change. + [DatabaseTheory, DatabaseData] + public async Task CreateAsync_ThenRead_RoundTripsEveryAutomaticKind( + IOrganizationRepository organizationRepository, + IPamTargetSystemRepository pamTargetSystemRepository) + { + var organization = await organizationRepository.CreateTestOrganizationAsync(); + var now = DateTime.UtcNow; + + foreach (var kind in Enum.GetValues()) + { + var target = await pamTargetSystemRepository.CreateAsync( + BuildTarget(organization.Id, $"target-{kind}", now, kind)); + + var persisted = await pamTargetSystemRepository.GetByIdAsync(target.Id); + + Assert.NotNull(persisted); + Assert.Equal(kind, persisted!.Kind); + } + } + // A manual target carries no connector; Kind/PasswordPolicy stay null through the round trip. [DatabaseTheory, DatabaseData] public async Task ReplaceAsync_UpdatesFields( @@ -192,14 +214,18 @@ private static async Task CreateEnrolledDaemonAsync( }); } - private static PamTargetSystem BuildTarget(Guid organizationId, string name, DateTime now) => new() - { - OrganizationId = organizationId, - Name = name, - Method = PamTargetSystemMethod.Automatic, - Kind = PamTargetSystemKind.Entra, - Status = PamTargetSystemStatus.Active, - CreationDate = now, - RevisionDate = now, - }; + private static PamTargetSystem BuildTarget( + Guid organizationId, + string name, + DateTime now, + PamTargetSystemKind kind = PamTargetSystemKind.Entra) => new() + { + OrganizationId = organizationId, + Name = name, + Method = PamTargetSystemMethod.Automatic, + Kind = kind, + Status = PamTargetSystemStatus.Active, + CreationDate = now, + RevisionDate = now, + }; }