From f85bdfc1629c1dab3ae019807d9d8764fceb1e34 Mon Sep 17 00:00:00 2001 From: Hinton Date: Mon, 6 Jul 2026 14:27:32 +0200 Subject: [PATCH 01/10] [PM-39040] Add PAM rotation domain layer Entities, enums, models, and repository interfaces for credential rotation: target systems, daemon registrations and assignments, rotation configs, jobs (claim-lease semantics), and server-owned attempts, per rotation-server.allium. --- src/Pam.Domain/Entities/PamDaemon.cs | 41 +++++++++ .../Entities/PamDaemonTargetAssignment.cs | 24 +++++ src/Pam.Domain/Entities/PamRotationAttempt.cs | 51 ++++++++++ src/Pam.Domain/Entities/PamRotationConfig.cs | 62 +++++++++++++ src/Pam.Domain/Entities/PamRotationJob.cs | 47 ++++++++++ src/Pam.Domain/Entities/PamTargetSystem.cs | 50 ++++++++++ src/Pam.Domain/Enums/PamDaemonStatus.cs | 12 +++ .../Enums/PamRotationAttemptResolveOutcome.cs | 20 ++++ .../Enums/PamRotationAttemptStatus.cs | 19 ++++ .../Enums/PamRotationCipherWriteOutcome.cs | 30 ++++++ .../Enums/PamRotationClaimOutcome.cs | 28 ++++++ .../Enums/PamRotationJobCreateOutcome.cs | 25 +++++ src/Pam.Domain/Enums/PamRotationJobStatus.cs | 24 +++++ src/Pam.Domain/Enums/PamRotationSource.cs | 16 ++++ src/Pam.Domain/Enums/PamRotationSyncState.cs | 17 ++++ .../Enums/PamSessionTerminationOutcome.cs | 15 +++ src/Pam.Domain/Enums/PamTargetSystemKind.cs | 12 +++ src/Pam.Domain/Enums/PamTargetSystemMethod.cs | 11 +++ src/Pam.Domain/Enums/PamTargetSystemStatus.cs | 11 +++ src/Pam.Domain/Models/PamDaemonDetails.cs | 30 ++++++ src/Pam.Domain/Models/PamPasswordPolicy.cs | 25 +++++ src/Pam.Domain/Models/PamReleasedJob.cs | 20 ++++ .../Models/PamRotationClaimResult.cs | 32 +++++++ .../Models/PamRotationConfigDetails.cs | 38 ++++++++ .../Models/PamRotationFailureResult.cs | 20 ++++ .../Models/PamRotationJobDetails.cs | 27 ++++++ src/Pam.Domain/Models/PamTimedOutJob.cs | 23 +++++ src/Pam.Domain/PamRotationRules.cs | 50 ++++++++++ .../Repositories/IPamDaemonRepository.cs | 36 ++++++++ .../IPamRotationConfigRepository.cs | 41 +++++++++ .../Repositories/IPamRotationJobRepository.cs | 92 +++++++++++++++++++ .../IPamTargetSystemRepository.cs | 9 ++ 32 files changed, 958 insertions(+) create mode 100644 src/Pam.Domain/Entities/PamDaemon.cs create mode 100644 src/Pam.Domain/Entities/PamDaemonTargetAssignment.cs create mode 100644 src/Pam.Domain/Entities/PamRotationAttempt.cs create mode 100644 src/Pam.Domain/Entities/PamRotationConfig.cs create mode 100644 src/Pam.Domain/Entities/PamRotationJob.cs create mode 100644 src/Pam.Domain/Entities/PamTargetSystem.cs create mode 100644 src/Pam.Domain/Enums/PamDaemonStatus.cs create mode 100644 src/Pam.Domain/Enums/PamRotationAttemptResolveOutcome.cs create mode 100644 src/Pam.Domain/Enums/PamRotationAttemptStatus.cs create mode 100644 src/Pam.Domain/Enums/PamRotationCipherWriteOutcome.cs create mode 100644 src/Pam.Domain/Enums/PamRotationClaimOutcome.cs create mode 100644 src/Pam.Domain/Enums/PamRotationJobCreateOutcome.cs create mode 100644 src/Pam.Domain/Enums/PamRotationJobStatus.cs create mode 100644 src/Pam.Domain/Enums/PamRotationSource.cs create mode 100644 src/Pam.Domain/Enums/PamRotationSyncState.cs create mode 100644 src/Pam.Domain/Enums/PamSessionTerminationOutcome.cs create mode 100644 src/Pam.Domain/Enums/PamTargetSystemKind.cs create mode 100644 src/Pam.Domain/Enums/PamTargetSystemMethod.cs create mode 100644 src/Pam.Domain/Enums/PamTargetSystemStatus.cs create mode 100644 src/Pam.Domain/Models/PamDaemonDetails.cs create mode 100644 src/Pam.Domain/Models/PamPasswordPolicy.cs create mode 100644 src/Pam.Domain/Models/PamReleasedJob.cs create mode 100644 src/Pam.Domain/Models/PamRotationClaimResult.cs create mode 100644 src/Pam.Domain/Models/PamRotationConfigDetails.cs create mode 100644 src/Pam.Domain/Models/PamRotationFailureResult.cs create mode 100644 src/Pam.Domain/Models/PamRotationJobDetails.cs create mode 100644 src/Pam.Domain/Models/PamTimedOutJob.cs create mode 100644 src/Pam.Domain/PamRotationRules.cs create mode 100644 src/Pam.Domain/Repositories/IPamDaemonRepository.cs create mode 100644 src/Pam.Domain/Repositories/IPamRotationConfigRepository.cs create mode 100644 src/Pam.Domain/Repositories/IPamRotationJobRepository.cs create mode 100644 src/Pam.Domain/Repositories/IPamTargetSystemRepository.cs diff --git a/src/Pam.Domain/Entities/PamDaemon.cs b/src/Pam.Domain/Entities/PamDaemon.cs new file mode 100644 index 000000000000..fbe02c0f5771 --- /dev/null +++ b/src/Pam.Domain/Entities/PamDaemon.cs @@ -0,0 +1,41 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Core.Entities; +using Bit.Core.Utilities; +using Bit.Pam.Enums; + +namespace Bit.Pam.Entities; + +/// +/// An on-prem rotation daemon registered against an organization (spec DaemonRegistration). The daemon's +/// machine credential is a generic dbo.ApiKey row referenced by — PAM reuses the +/// Secrets Manager credential store rather than minting a parallel one; the owner link is inverted relative to +/// ApiKey.ServiceAccountId. There is no persisted connection row: liveness is derived from +/// (see PamRotationRules.IsConnected). +/// +public class PamDaemon : ITableObject +{ + public Guid Id { get; set; } + public Guid OrganizationId { get; set; } + + [MaxLength(200)] + public string Name { get; set; } = null!; + + /// The daemon's machine credential — a dbo.ApiKey row with a null ServiceAccountId. + public Guid ApiKeyId { get; set; } + + public PamDaemonStatus Status { get; set; } + + /// + /// The last time the daemon polled or reported, bumped at most once per HeartbeatMinInterval. Null until + /// its first request. Never bumped by a sweep — only by the daemon's own requests. + /// + public DateTime? LastHeartbeatAt { get; set; } + + public DateTime CreationDate { get; set; } = DateTime.UtcNow; + public DateTime RevisionDate { get; set; } = DateTime.UtcNow; + + public void SetNewId() + { + Id = CombGuid.Generate(); + } +} diff --git a/src/Pam.Domain/Entities/PamDaemonTargetAssignment.cs b/src/Pam.Domain/Entities/PamDaemonTargetAssignment.cs new file mode 100644 index 000000000000..22c6d3efd03b --- /dev/null +++ b/src/Pam.Domain/Entities/PamDaemonTargetAssignment.cs @@ -0,0 +1,24 @@ +using Bit.Core.Entities; +using Bit.Core.Utilities; + +namespace Bit.Pam.Entities; + +/// +/// Grants a the ability to claim rotation jobs against a . +/// Invariant OneAssignmentPerDaemonTarget — at most one assignment may exist for a given daemon/target pair. +/// +public class PamDaemonTargetAssignment : ITableObject +{ + public Guid Id { get; set; } + + public Guid DaemonId { get; set; } + public Guid TargetSystemId { get; set; } + public Guid OrganizationId { get; set; } + + public DateTime CreationDate { get; set; } = DateTime.UtcNow; + + public void SetNewId() + { + Id = CombGuid.Generate(); + } +} diff --git a/src/Pam.Domain/Entities/PamRotationAttempt.cs b/src/Pam.Domain/Entities/PamRotationAttempt.cs new file mode 100644 index 000000000000..1d8c6acae77c --- /dev/null +++ b/src/Pam.Domain/Entities/PamRotationAttempt.cs @@ -0,0 +1,51 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Core.Entities; +using Bit.Core.Utilities; +using Bit.Pam.Enums; + +namespace Bit.Pam.Entities; + +/// +/// One daemon's try at executing a . Invariant AtMostOneInFlightAttemptPerJob — a +/// job has at most one attempt at a time, inserted atomically with +/// the claim that creates it. Reaching requires both a written cipher +/// () and a claimant-verified success report — the VerifiedBeforeSuccess backstop. +/// +public class PamRotationAttempt : ITableObject +{ + public Guid Id { get; set; } + + public Guid JobId { get; set; } + + /// The daemon executing this attempt, fixed for its lifetime (unlike the job's claim fields, this is never cleared). + public Guid ClaimedByDaemonId { get; set; } + + /// Whether the daemon has written the rotated secret back to the cipher via the atomic accept-write path. + public bool CipherUpdated { get; set; } + + public PamRotationAttemptStatus Status { get; set; } + + /// + /// A bounded, human-readable failure reason, truncated to 500 characters server-side (never rejected). The + /// contract forbids forwarding raw target-system error output, since it can echo credentials. Null unless + /// is . + /// + [MaxLength(500)] + public string? FailureReason { get; set; } + + /// Whether the target system's password was left changed by a failed attempt. Null unless is Errored. + public PamRotationSyncState? SyncState { get; set; } + + /// The outcome of the requested session termination, if any. Set only when a Rotated attempt reports it. + public PamSessionTerminationOutcome? SessionTermination { get; set; } + + public DateTime CreationDate { get; set; } = DateTime.UtcNow; + + /// When the attempt left . Null while still executing. + public DateTime? ResolvedDate { get; set; } + + public void SetNewId() + { + Id = CombGuid.Generate(); + } +} diff --git a/src/Pam.Domain/Entities/PamRotationConfig.cs b/src/Pam.Domain/Entities/PamRotationConfig.cs new file mode 100644 index 000000000000..ba41ae8f7c95 --- /dev/null +++ b/src/Pam.Domain/Entities/PamRotationConfig.cs @@ -0,0 +1,62 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Core.Entities; +using Bit.Core.Utilities; + +namespace Bit.Pam.Entities; + +/// +/// The rotation setup for a single vault cipher (invariant OneConfigPerCipher — a cipher has at most one +/// config): which it rotates against, the account it rotates, and when it is next due. +/// A null means the config never rotates on a schedule (on-demand and/or access-end only, +/// or — on a manual target — awaiting a human to record a rotation). +/// +public class PamRotationConfig : ITableObject +{ + public Guid Id { get; set; } + public Guid OrganizationId { get; set; } + + /// The cipher whose credential this config rotates. Unique across configs (OneConfigPerCipher). + public Guid CipherId { get; set; } + + public Guid TargetSystemId { get; set; } + + /// + /// The account this config rotates on the target system. Opaque to the server — never parsed; only the daemon + /// interprets it. + /// + [MaxLength(500)] + public string AccountIdentity { get; set; } = null!; + + /// + /// Whether a successful rotation should also terminate the account's existing sessions on the target. May only + /// be true when the target is automatic and reports . + /// + public bool TerminateSessions { get; set; } + + /// Quartz 6-field cron expression. Null means no scheduled rotation for this config. + [MaxLength(100)] + public string? ScheduleCron { get; set; } + + /// Whether a lease ending on this config's cipher should trigger a rotation (spec RotateOnAccessEnd). + public bool RotateOnAccessEnd { get; set; } + + /// + /// When this config is next due. On an automatic target, the sweep offers a job once this is reached (spec + /// RotationDue). On a manual target, reaching this instead marks the config + /// awaiting_manual_rotation — there is no job, only an operator obligation. Null means nothing is due. + /// + public DateTime? NextRotationAt { get; set; } + + public bool Enabled { get; set; } = true; + + /// When the last rotation for this config completed successfully. Null until the first success. + public DateTime? LastRotationAt { get; set; } + + public DateTime CreationDate { get; set; } = DateTime.UtcNow; + public DateTime RevisionDate { get; set; } = DateTime.UtcNow; + + public void SetNewId() + { + Id = CombGuid.Generate(); + } +} diff --git a/src/Pam.Domain/Entities/PamRotationJob.cs b/src/Pam.Domain/Entities/PamRotationJob.cs new file mode 100644 index 000000000000..7fe4892364b2 --- /dev/null +++ b/src/Pam.Domain/Entities/PamRotationJob.cs @@ -0,0 +1,47 @@ +using Bit.Core.Entities; +using Bit.Core.Utilities; +using Bit.Pam.Enums; + +namespace Bit.Pam.Entities; + +/// +/// One offer of rotation work for a . Invariant AtMostOneActiveJobPerConfig — +/// a config has at most one or +/// job at a time; OfferRotationCommand is the single creation point. Every transition out of +/// — retry, release, success, or timeout — clears +/// and ; the executing daemon's history lives on the +/// instead. +/// +public class PamRotationJob : ITableObject +{ + public Guid Id { get; set; } + + public Guid RotationConfigId { get; set; } + + public PamRotationSource Source { get; set; } + + public PamRotationJobStatus Status { get; set; } + + /// The daemon currently holding this job's claim. Null unless is . + public Guid? ClaimedByDaemonId { get; set; } + + /// When the current claim was taken. Null unless is . + public DateTime? ClaimedAt { get; set; } + + public DateTime CreationDate { get; set; } = DateTime.UtcNow; + + /// The earliest time this job can be claimed — pushed out on retry (exponential backoff) or release. + public DateTime NextClaimableAt { get; set; } + + /// + /// CreationDate + JobTtl, persisted at creation. Once past this point with the job still Pending or + /// Claimed and no attempt, the sweep times the job out (spec + /// JobTimesOut). + /// + public DateTime ExpiresAt { get; set; } + + public void SetNewId() + { + Id = CombGuid.Generate(); + } +} diff --git a/src/Pam.Domain/Entities/PamTargetSystem.cs b/src/Pam.Domain/Entities/PamTargetSystem.cs new file mode 100644 index 000000000000..79e2b48a6b1c --- /dev/null +++ b/src/Pam.Domain/Entities/PamTargetSystem.cs @@ -0,0 +1,50 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Core.Entities; +using Bit.Core.Utilities; +using Bit.Pam.Enums; + +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. +/// +public class PamTargetSystem : ITableObject +{ + public Guid Id { get; set; } + public Guid OrganizationId { get; set; } + + [MaxLength(200)] + public string Name { get; set; } = null!; + + public PamTargetSystemMethod Method { get; set; } + + /// The automatic connector kind. Null for a target. + public PamTargetSystemKind? Kind { get; set; } + + /// + /// JSON document of the the rotation daemon generates candidate passwords + /// against. Null for a target. + /// + [MaxLength(2000)] + public string? PasswordPolicy { get; set; } + + /// + /// Whether the target can terminate an account's existing sessions after a rotation. Null until a daemon + /// reports its capability; a may only set + /// once this is true. + /// + public bool? SupportsSessionTermination { get; set; } + + public PamTargetSystemStatus Status { get; set; } + + public DateTime CreationDate { get; set; } = DateTime.UtcNow; + public DateTime RevisionDate { get; set; } = DateTime.UtcNow; + + public void SetNewId() + { + Id = CombGuid.Generate(); + } +} diff --git a/src/Pam.Domain/Enums/PamDaemonStatus.cs b/src/Pam.Domain/Enums/PamDaemonStatus.cs new file mode 100644 index 000000000000..9a00818988d7 --- /dev/null +++ b/src/Pam.Domain/Enums/PamDaemonStatus.cs @@ -0,0 +1,12 @@ +namespace Bit.Pam.Enums; + +/// +/// Lifecycle of a . Only an daemon may authenticate, poll, or +/// claim jobs; is terminal for this credential (re-enrollment mints a new one — the deferred +/// spec rule ReissueDaemonCredential). +/// +public enum PamDaemonStatus : byte +{ + Enrolled = 0, + Revoked = 1, +} diff --git a/src/Pam.Domain/Enums/PamRotationAttemptResolveOutcome.cs b/src/Pam.Domain/Enums/PamRotationAttemptResolveOutcome.cs new file mode 100644 index 000000000000..054106fb8840 --- /dev/null +++ b/src/Pam.Domain/Enums/PamRotationAttemptResolveOutcome.cs @@ -0,0 +1,20 @@ +namespace Bit.Pam.Enums; + +/// +/// The result of resolving a (the _MarkRotated / _MarkErrored +/// stored procedures). Both guard on the attempt still being and +/// claimed by the reporting daemon (spec RejectStaleSuccess / RejectStaleFailureReport) and return a +/// distinct integer code so the caller takes the reject-stale path instead of resolving. +/// +public enum PamRotationAttemptResolveOutcome +{ + /// The guard held and the attempt was resolved (stored proc returned 1). + Resolved = 1, + + /// + /// The attempt was not , or its + /// did not match the reporting daemon (stored proc + /// returned 0) — the report is stale. Audited as report_rejected; nothing changed. + /// + Rejected = 0, +} diff --git a/src/Pam.Domain/Enums/PamRotationAttemptStatus.cs b/src/Pam.Domain/Enums/PamRotationAttemptStatus.cs new file mode 100644 index 000000000000..41db40a86e41 --- /dev/null +++ b/src/Pam.Domain/Enums/PamRotationAttemptStatus.cs @@ -0,0 +1,19 @@ +namespace Bit.Pam.Enums; + +/// +/// Lifecycle of a . +/// +public enum PamRotationAttemptStatus : byte +{ + /// The claiming daemon is working the job. At most one per job — invariant AtMostOneInFlightAttemptPerJob. + Executing = 0, + + /// The daemon reported success and the VerifiedBeforeSuccess backstop () held. + Rotated = 1, + + /// The daemon reported failure. + Errored = 2, + + /// The job was released or timed out while this attempt was executing; the retry budget is not charged for it. + Abandoned = 3, +} diff --git a/src/Pam.Domain/Enums/PamRotationCipherWriteOutcome.cs b/src/Pam.Domain/Enums/PamRotationCipherWriteOutcome.cs new file mode 100644 index 000000000000..f8f26b7698b8 --- /dev/null +++ b/src/Pam.Domain/Enums/PamRotationCipherWriteOutcome.cs @@ -0,0 +1,30 @@ +namespace Bit.Pam.Enums; + +/// +/// The result of the atomic write-capability check in PamRotationAttempt_AcceptCipherWrite — the security +/// backstop that re-verifies the claim, the attempt, and the cipher's revision date under a single lock before +/// writing the rotated secret. +/// +public enum PamRotationCipherWriteOutcome +{ + /// + /// The write capability held and the cipher's Data and revision date were replaced (stored proc returned + /// 1). is set. + /// + Accepted = 1, + + /// + /// The job is not Claimed by the calling daemon, or the attempt is not + /// (stored proc returned 0) — the complement of spec + /// AcceptCipherUpdate, audited as write_rejected. Nothing was persisted. + /// + Rejected = 0, + + /// + /// The write capability held but the caller's supplied last-known revision date no longer matched the cipher's + /// current revision date (stored proc returned -1) — a concurrent user edit won. Outside spec + /// RejectCipherUpdate's exact complement; added to protect concurrent user edits. Audited as + /// write_rejected, nothing was persisted. + /// + RevisionMismatch = -1, +} diff --git a/src/Pam.Domain/Enums/PamRotationClaimOutcome.cs b/src/Pam.Domain/Enums/PamRotationClaimOutcome.cs new file mode 100644 index 000000000000..e69926719e45 --- /dev/null +++ b/src/Pam.Domain/Enums/PamRotationClaimOutcome.cs @@ -0,0 +1,28 @@ +namespace Bit.Pam.Enums; + +/// +/// The result of the atomic first-claim-wins PamRotationJob_Claim update. The stored procedure returns a +/// distinct integer code so ClaimRotationJobCommand can tell a lost race apart from a daemon that was never +/// eligible to claim the job. +/// +public enum PamRotationClaimOutcome +{ + /// + /// The claim succeeded and an Executing was inserted in the same + /// transaction (stored proc returned 1). + /// + Claimed = 1, + + /// + /// The job was not Pending, or its had not yet arrived, + /// when the update ran (stored proc returned 0) — another daemon likely won the race. + /// + NotClaimable = 0, + + /// + /// The guard EligibleClaimsOnly failed: the config is disabled, the target is not + /// , the daemon has no assignment to the target, or the daemon's + /// organization does not match the config's organization (stored proc returned -1). Nothing was persisted. + /// + NotEligible = -1, +} diff --git a/src/Pam.Domain/Enums/PamRotationJobCreateOutcome.cs b/src/Pam.Domain/Enums/PamRotationJobCreateOutcome.cs new file mode 100644 index 000000000000..113a050b5a13 --- /dev/null +++ b/src/Pam.Domain/Enums/PamRotationJobCreateOutcome.cs @@ -0,0 +1,25 @@ +namespace Bit.Pam.Enums; + +/// +/// The result of the guarded PamRotationJob_Create insert — invariant AtMostOneActiveJobPerConfig, +/// enforced under UPDLOCK, HOLDLOCK on the config. The stored procedure returns a distinct integer code so +/// OfferRotationCommand can tell an existing active job apart from a config that is no longer offerable. +/// +public enum PamRotationJobCreateOutcome +{ + /// The job was inserted (stored proc returned 1). + Created = 1, + + /// + /// The config already has a Pending or Claimed job (stored proc returned 0) — the guard + /// AtMostOneActiveJobPerConfig held against this insert. Nothing was persisted. + /// + ActiveJobExists = 0, + + /// + /// The re-checked can_offer guard (enabled ∧ automatic ∧ target active) no longer held when the insert + /// ran (stored proc returned -1) — a concurrent pause, disable, or method change likely won. Nothing was + /// persisted. + /// + ConfigNotOfferable = -1, +} diff --git a/src/Pam.Domain/Enums/PamRotationJobStatus.cs b/src/Pam.Domain/Enums/PamRotationJobStatus.cs new file mode 100644 index 000000000000..4f917f4d5cf5 --- /dev/null +++ b/src/Pam.Domain/Enums/PamRotationJobStatus.cs @@ -0,0 +1,24 @@ +namespace Bit.Pam.Enums; + +/// +/// Lifecycle of a . and are the +/// active statuses a config's invariant AtMostOneActiveJobPerConfig binds on (see +/// PamRotationRules.IsActiveJobStatus); every other value is terminal. +/// +public enum PamRotationJobStatus : byte +{ + /// Offered and claimable, or returned to claimable after a retry or a release. + Pending = 0, + + /// Held by until it succeeds, is released, retried, or times out. + Claimed = 1, + + /// An attempt against this job reported success. + Succeeded = 2, + + /// Every attempt errored and the retry budget (MaxAttempts) is exhausted. + Failed = 3, + + /// Still Pending or Claimed past with no successful attempt. + TimedOut = 4, +} diff --git a/src/Pam.Domain/Enums/PamRotationSource.cs b/src/Pam.Domain/Enums/PamRotationSource.cs new file mode 100644 index 000000000000..14a598bcff79 --- /dev/null +++ b/src/Pam.Domain/Enums/PamRotationSource.cs @@ -0,0 +1,16 @@ +namespace Bit.Pam.Enums; + +/// +/// What caused a to be offered. +/// +public enum PamRotationSource : byte +{ + /// The config's ScheduleCron came due (spec RotationDue). + Scheduled = 0, + + /// An admin called TriggerRotationNow. + OnDemand = 1, + + /// A lease on the config's cipher ended (revoke, self-end, or natural expiry) and RotateOnAccessEnd is set. + AccessEnd = 2, +} diff --git a/src/Pam.Domain/Enums/PamRotationSyncState.cs b/src/Pam.Domain/Enums/PamRotationSyncState.cs new file mode 100644 index 000000000000..60570db1f9af --- /dev/null +++ b/src/Pam.Domain/Enums/PamRotationSyncState.cs @@ -0,0 +1,17 @@ +namespace Bit.Pam.Enums; + +/// +/// Whether a failed rotation attempt left the target system's password changed, reported alongside a failure so an +/// operator can tell whether the vault credential now disagrees with the target. +/// +public enum PamRotationSyncState : byte +{ + /// The target's password is unchanged; the vault credential is still correct. + TargetUnchanged = 0, + + /// The target's password changed but the write to the cipher did not complete; the vault credential is now wrong. + TargetUpdated = 1, + + /// The daemon could not determine whether the target's password changed. + Indeterminate = 2, +} diff --git a/src/Pam.Domain/Enums/PamSessionTerminationOutcome.cs b/src/Pam.Domain/Enums/PamSessionTerminationOutcome.cs new file mode 100644 index 000000000000..46acfdf39bd2 --- /dev/null +++ b/src/Pam.Domain/Enums/PamSessionTerminationOutcome.cs @@ -0,0 +1,15 @@ +namespace Bit.Pam.Enums; + +/// +/// The result of a rotation's optional session-termination step, reported alongside a resolved attempt. +/// +public enum PamSessionTerminationOutcome : byte +{ + /// The config's was false; termination was not attempted. + NotRequested = 0, + + Terminated = 1, + + /// Termination was requested but failed; the rotation itself still succeeded. + TermFailed = 2, +} diff --git a/src/Pam.Domain/Enums/PamTargetSystemKind.cs b/src/Pam.Domain/Enums/PamTargetSystemKind.cs new file mode 100644 index 000000000000..30c784f2d827 --- /dev/null +++ b/src/Pam.Domain/Enums/PamTargetSystemKind.cs @@ -0,0 +1,12 @@ +namespace Bit.Pam.Enums; + +/// +/// The connector an is rotated +/// through. Null on a target, which has no connector. +/// +public enum PamTargetSystemKind : byte +{ + Entra = 0, + Mssql = 1, + CustomScript = 2, +} diff --git a/src/Pam.Domain/Enums/PamTargetSystemMethod.cs b/src/Pam.Domain/Enums/PamTargetSystemMethod.cs new file mode 100644 index 000000000000..03152b71fa04 --- /dev/null +++ b/src/Pam.Domain/Enums/PamTargetSystemMethod.cs @@ -0,0 +1,11 @@ +namespace Bit.Pam.Enums; + +/// +/// How a is rotated: by a rotation daemon () or by a +/// human acting out of band ( — tracked by PAM but never executed by it). +/// +public enum PamTargetSystemMethod : byte +{ + Automatic = 0, + Manual = 1, +} diff --git a/src/Pam.Domain/Enums/PamTargetSystemStatus.cs b/src/Pam.Domain/Enums/PamTargetSystemStatus.cs new file mode 100644 index 000000000000..402aa68b7f00 --- /dev/null +++ b/src/Pam.Domain/Enums/PamTargetSystemStatus.cs @@ -0,0 +1,11 @@ +namespace Bit.Pam.Enums; + +/// +/// Lifecycle of a . Only an target is offerable for +/// rotation (spec can_offer) or assignable to a daemon. +/// +public enum PamTargetSystemStatus : byte +{ + Active = 0, + Disabled = 1, +} diff --git a/src/Pam.Domain/Models/PamDaemonDetails.cs b/src/Pam.Domain/Models/PamDaemonDetails.cs new file mode 100644 index 000000000000..92c939d6f204 --- /dev/null +++ b/src/Pam.Domain/Models/PamDaemonDetails.cs @@ -0,0 +1,30 @@ +using Bit.Pam.Entities; + +namespace Bit.Pam.Models; + +/// +/// A together with its owning organization's licensing state — the read model +/// PamDaemonClientProvider loads by on every token request to +/// decide whether the daemon may authenticate. A daemon may authenticate only when is +/// and both and +/// are true. +/// +public class PamDaemonDetails : PamDaemon +{ + public bool OrganizationEnabled { get; set; } + public bool OrganizationUsePam { get; set; } + + public static PamDaemonDetails From(PamDaemon daemon, bool organizationEnabled, bool organizationUsePam) => new() + { + Id = daemon.Id, + OrganizationId = daemon.OrganizationId, + Name = daemon.Name, + ApiKeyId = daemon.ApiKeyId, + Status = daemon.Status, + LastHeartbeatAt = daemon.LastHeartbeatAt, + CreationDate = daemon.CreationDate, + RevisionDate = daemon.RevisionDate, + OrganizationEnabled = organizationEnabled, + OrganizationUsePam = organizationUsePam, + }; +} diff --git a/src/Pam.Domain/Models/PamPasswordPolicy.cs b/src/Pam.Domain/Models/PamPasswordPolicy.cs new file mode 100644 index 000000000000..0e4807719704 --- /dev/null +++ b/src/Pam.Domain/Models/PamPasswordPolicy.cs @@ -0,0 +1,25 @@ +using System.Text.Json; + +namespace Bit.Pam.Models; + +/// +/// The password-generation policy for an automatic , persisted as the JSON +/// document in . The rotation daemon generates a candidate +/// password against these constraints before writing it to the target. +/// +public record PamPasswordPolicy +{ + public required int MinLength { get; init; } + public required int MaxLength { get; init; } + public bool IncludeUppercase { get; init; } + public bool IncludeLowercase { get; init; } + public bool IncludeDigits { get; init; } + public bool IncludeSymbols { get; init; } + + /// Serializes a policy for storage in . + public static string Serialize(PamPasswordPolicy policy) => JsonSerializer.Serialize(policy); + + /// Deserializes a target system's stored PasswordPolicy JSON. Null in, null out. + public static PamPasswordPolicy? Parse(string? json) => + string.IsNullOrWhiteSpace(json) ? null : JsonSerializer.Deserialize(json); +} diff --git a/src/Pam.Domain/Models/PamReleasedJob.cs b/src/Pam.Domain/Models/PamReleasedJob.cs new file mode 100644 index 000000000000..63bceb621a9d --- /dev/null +++ b/src/Pam.Domain/Models/PamReleasedJob.cs @@ -0,0 +1,20 @@ +using Bit.Pam.Enums; + +namespace Bit.Pam.Models; + +/// +/// One job the release sweep returned to because its claiming daemon's +/// heartbeat had gone stale and its claim lease (ExecuteBy) had expired — the row the sweep needs to emit the +/// released audit event, since the job's own claim fields are cleared by the same update. +/// +public record PamReleasedJob +{ + public required Guid JobId { get; init; } + public required Guid RotationConfigId { get; init; } + public required Guid OrganizationId { get; init; } + public required Guid CipherId { get; init; } + public required PamRotationSource Source { get; init; } + + /// The daemon whose claim was released — the job's pre-clear ClaimedByDaemonId. + public required Guid ClaimedByDaemonId { get; init; } +} diff --git a/src/Pam.Domain/Models/PamRotationClaimResult.cs b/src/Pam.Domain/Models/PamRotationClaimResult.cs new file mode 100644 index 000000000000..0bdc852aebe4 --- /dev/null +++ b/src/Pam.Domain/Models/PamRotationClaimResult.cs @@ -0,0 +1,32 @@ +using Bit.Pam.Enums; + +namespace Bit.Pam.Models; + +/// +/// The result of IPamRotationJobRepository.ClaimAsync. On the +/// remaining fields carry the work snapshot handed back to the daemon (spec ClaimRotation's snapshot); they +/// are null for any other . +/// +public class PamRotationClaimResult +{ + public required PamRotationClaimOutcome Outcome { get; init; } + + /// The Executing attempt created by the claim. + public Guid? AttemptId { get; init; } + + public Guid? JobId { get; init; } + public PamRotationSource? Source { get; init; } + public Guid? TargetSystemId { get; init; } + public string? TargetSystemName { get; init; } + public PamTargetSystemKind? Kind { get; init; } + + /// The target's password policy, JSON — see . + public string? PasswordPolicy { get; init; } + + public Guid? CipherId { get; init; } + public string? AccountIdentity { get; init; } + public bool? TerminateSessions { get; init; } + + /// The claim's lease deadline (ClaimedAt + ReleaseDelay) — the daemon should finish before this. + public DateTime? ExecuteBy { get; init; } +} diff --git a/src/Pam.Domain/Models/PamRotationConfigDetails.cs b/src/Pam.Domain/Models/PamRotationConfigDetails.cs new file mode 100644 index 000000000000..a1191626cac1 --- /dev/null +++ b/src/Pam.Domain/Models/PamRotationConfigDetails.cs @@ -0,0 +1,38 @@ +using Bit.Pam.Entities; +using Bit.Pam.Enums; + +namespace Bit.Pam.Models; + +/// +/// A together with its target system's display fields and whether it currently has +/// an active job — the list/detail view model for the rotation-configs admin surface. +/// +public class PamRotationConfigDetails : PamRotationConfig +{ + public string TargetSystemName { get; set; } = null!; + public PamTargetSystemMethod TargetSystemMethod { get; set; } + + /// Whether the config has a Pending or Claimed job — spec has_active_job, see PamRotationRules.IsActiveJobStatus. + public bool HasActiveJob { get; set; } + + public static PamRotationConfigDetails From(PamRotationConfig config, string targetSystemName, + PamTargetSystemMethod targetSystemMethod, bool hasActiveJob) => new() + { + Id = config.Id, + OrganizationId = config.OrganizationId, + CipherId = config.CipherId, + TargetSystemId = config.TargetSystemId, + AccountIdentity = config.AccountIdentity, + TerminateSessions = config.TerminateSessions, + ScheduleCron = config.ScheduleCron, + RotateOnAccessEnd = config.RotateOnAccessEnd, + NextRotationAt = config.NextRotationAt, + Enabled = config.Enabled, + LastRotationAt = config.LastRotationAt, + CreationDate = config.CreationDate, + RevisionDate = config.RevisionDate, + TargetSystemName = targetSystemName, + TargetSystemMethod = targetSystemMethod, + HasActiveJob = hasActiveJob, + }; +} diff --git a/src/Pam.Domain/Models/PamRotationFailureResult.cs b/src/Pam.Domain/Models/PamRotationFailureResult.cs new file mode 100644 index 000000000000..52e05ce4653b --- /dev/null +++ b/src/Pam.Domain/Models/PamRotationFailureResult.cs @@ -0,0 +1,20 @@ +using Bit.Pam.Enums; + +namespace Bit.Pam.Models; + +/// +/// The result of IPamRotationJobRepository.MarkAttemptErroredAsync. On +/// , reports whether the job was +/// retried (, retry budget remaining) or failed outright +/// (, retry budget exhausted). +/// +public class PamRotationFailureResult +{ + public required PamRotationAttemptResolveOutcome Outcome { get; init; } + + /// Null when is (a stale report). + public PamRotationJobStatus? JobStatus { get; init; } + + /// The number of Errored attempts recorded against the job, including this one — checked against MaxAttempts. + public int ErroredAttemptCount { get; init; } +} diff --git a/src/Pam.Domain/Models/PamRotationJobDetails.cs b/src/Pam.Domain/Models/PamRotationJobDetails.cs new file mode 100644 index 000000000000..0664b38af30d --- /dev/null +++ b/src/Pam.Domain/Models/PamRotationJobDetails.cs @@ -0,0 +1,27 @@ +using Bit.Pam.Entities; + +namespace Bit.Pam.Models; + +/// +/// A together with every recorded against it, oldest +/// first — the read model for a rotation config's attempt-history display (GET configs/{id}), so the caller +/// avoids an N+1 fetching each job's attempts individually. +/// +public class PamRotationJobDetails : PamRotationJob +{ + public IReadOnlyList Attempts { get; set; } = []; + + public static PamRotationJobDetails From(PamRotationJob job, IReadOnlyList attempts) => new() + { + Id = job.Id, + RotationConfigId = job.RotationConfigId, + Source = job.Source, + Status = job.Status, + ClaimedByDaemonId = job.ClaimedByDaemonId, + ClaimedAt = job.ClaimedAt, + CreationDate = job.CreationDate, + NextClaimableAt = job.NextClaimableAt, + ExpiresAt = job.ExpiresAt, + Attempts = attempts, + }; +} diff --git a/src/Pam.Domain/Models/PamTimedOutJob.cs b/src/Pam.Domain/Models/PamTimedOutJob.cs new file mode 100644 index 000000000000..0edfcd2c5c92 --- /dev/null +++ b/src/Pam.Domain/Models/PamTimedOutJob.cs @@ -0,0 +1,23 @@ +using Bit.Pam.Enums; + +namespace Bit.Pam.Models; + +/// +/// One job the sweep moved to because it was still Pending or Claimed +/// past ExpiresAt with no successful attempt — the row the sweep needs to emit the timed_out audit +/// event with its unroutable-vs-stuck reason. +/// +public record PamTimedOutJob +{ + public required Guid JobId { get; init; } + public required Guid RotationConfigId { get; init; } + public required Guid OrganizationId { get; init; } + public required Guid CipherId { get; init; } + public required PamRotationSource Source { get; init; } + + /// The daemon holding the claim at timeout, or null if the job was never claimed. + public Guid? ClaimedByDaemonId { get; init; } + + /// The number of attempts recorded against the job: zero means unroutable (never claimed), nonzero means stuck. + public required int AttemptCount { get; init; } +} diff --git a/src/Pam.Domain/PamRotationRules.cs b/src/Pam.Domain/PamRotationRules.cs new file mode 100644 index 000000000000..84c9d91126e0 --- /dev/null +++ b/src/Pam.Domain/PamRotationRules.cs @@ -0,0 +1,50 @@ +using Bit.Pam.Entities; +using Bit.Pam.Enums; + +namespace Bit.Pam; + +/// +/// Derived predicates over PAM rotation entities, implemented once so admin commands, the daemon-facing endpoints, +/// and the sweep jobs cannot drift on a guard's definition (mirrors the Allium spec's own predicate refactor). +/// +public static class PamRotationRules +{ + /// + /// Spec DaemonConnection: a daemon is connected when it has heartbeated within + /// of . A daemon that has never heartbeated is never connected. + /// + public static bool IsConnected(PamDaemon daemon, DateTime now, TimeSpan offlineAfter) => + daemon.LastHeartbeatAt is { } lastHeartbeatAt && lastHeartbeatAt >= now - offlineAfter; + + /// + /// The "active" job statuses invariant AtMostOneActiveJobPerConfig binds on: a job is active while it is + /// still claimable or being worked. + /// + public static bool IsActiveJobStatus(PamRotationJobStatus status) => + status is PamRotationJobStatus.Pending or PamRotationJobStatus.Claimed; + + /// + /// Spec can_offer, minus the has-active-job check — callers combine this with a repository lookup, since + /// that check needs a query this pure predicate can't make. The config must be enabled, on an + /// target, and that target must be + /// . + /// + public static bool CanOffer(PamRotationConfig config, PamTargetSystemMethod method, PamTargetSystemStatus targetStatus) => + config.Enabled && method == PamTargetSystemMethod.Automatic && targetStatus == PamTargetSystemStatus.Active; + + /// + /// Spec awaiting_manual_rotation: a manual-target config surfaces an operator obligation once its + /// schedule comes due, since there is no daemon to offer a job to. + /// + public static bool AwaitingManualRotation(PamRotationConfig config, PamTargetSystemMethod method, DateTime now) => + method == PamTargetSystemMethod.Manual && config.Enabled + && config.NextRotationAt is { } nextRotationAt && nextRotationAt <= now; + + /// + /// The claim's lease deadline — plus — + /// the point at which the release sweep may reclaim the job from a daemon whose heartbeat has gone stale. Null + /// unless the job is currently claimed. + /// + public static DateTime? ExecuteBy(PamRotationJob job, TimeSpan releaseDelay) => + job.ClaimedAt is { } claimedAt ? claimedAt + releaseDelay : null; +} diff --git a/src/Pam.Domain/Repositories/IPamDaemonRepository.cs b/src/Pam.Domain/Repositories/IPamDaemonRepository.cs new file mode 100644 index 000000000000..9f46cd83b1a7 --- /dev/null +++ b/src/Pam.Domain/Repositories/IPamDaemonRepository.cs @@ -0,0 +1,36 @@ +using Bit.Core.Repositories; +using Bit.Pam.Entities; +using Bit.Pam.Models; + +namespace Bit.Pam.Repositories; + +public interface IPamDaemonRepository : IRepository +{ + Task> GetManyByOrganizationIdAsync(Guid organizationId); + + /// + /// Returns the daemon's — including its organization's licensing state — by the + /// id of its credential, or null if no daemon references that key. + /// PamDaemonClientProvider loads this on every token request. + /// + Task GetDetailsByApiKeyIdAsync(Guid apiKeyId); + + /// + /// Bumps to , but only when the stored value is + /// null or older than minus — a conditional write so a + /// polling daemon does not hammer the row on every request. + /// + Task UpdateHeartbeatAsync(Guid daemonId, DateTime now, TimeSpan minInterval); + + /// + /// Records a daemon's assignment to a target system (invariant OneAssignmentPerDaemonTarget). The + /// assignment must already have its id assigned. + /// + Task CreateAssignmentAsync(PamDaemonTargetAssignment assignment); + + Task DeleteAssignmentAsync(Guid daemonId, Guid targetSystemId); + + Task> GetAssignmentsByOrganizationIdAsync(Guid organizationId); + + Task AssignmentExistsAsync(Guid daemonId, Guid targetSystemId); +} diff --git a/src/Pam.Domain/Repositories/IPamRotationConfigRepository.cs b/src/Pam.Domain/Repositories/IPamRotationConfigRepository.cs new file mode 100644 index 000000000000..4e4c378bd485 --- /dev/null +++ b/src/Pam.Domain/Repositories/IPamRotationConfigRepository.cs @@ -0,0 +1,41 @@ +using Bit.Core.Repositories; +using Bit.Pam.Entities; +using Bit.Pam.Models; + +namespace Bit.Pam.Repositories; + +public interface IPamRotationConfigRepository : IRepository +{ + /// Returns the config for the cipher (invariant OneConfigPerCipher), or null if none exists. + Task GetByCipherIdAsync(Guid cipherId); + + /// + /// Returns a single config's projection (target display fields plus + /// whether it has an active job), or null if no config has the id. + /// + Task GetDetailsByIdAsync(Guid id); + + Task> GetManyDetailsByOrganizationIdAsync(Guid organizationId); + + /// + /// Returns the configs due for a scheduled offer (spec RotationDue): enabled, on an + /// target that is , + /// with at or before , and with no active + /// job. Read by the sweep's due phase, one OfferRotationCommand call per row. + /// + Task> GetManyDueAsync(DateTime now); + + /// + /// Whether any config on the target system has set — the + /// guard UpdateTargetSystemPolicyCommand checks before a target may withdraw + /// . + /// + Task AnyByTargetSystemWithTerminateSessionsAsync(Guid targetSystemId); + + /// + /// Deletes the config's jobs and attempts, then the config itself, in one transaction — the durable history + /// stays in the audit trail, not here. Called by DeleteRotationConfigCommand after it has confirmed the + /// config has no active job. + /// + Task DeleteWithJobsAsync(Guid configId); +} diff --git a/src/Pam.Domain/Repositories/IPamRotationJobRepository.cs b/src/Pam.Domain/Repositories/IPamRotationJobRepository.cs new file mode 100644 index 000000000000..45305fcf0647 --- /dev/null +++ b/src/Pam.Domain/Repositories/IPamRotationJobRepository.cs @@ -0,0 +1,92 @@ +using Bit.Pam.Entities; +using Bit.Pam.Enums; +using Bit.Pam.Models; + +namespace Bit.Pam.Repositories; + +/// +/// Repository for and its children. Deliberately does +/// not extend IRepository<PamRotationJob, Guid>: every write is a guarded transition (creation, claim, +/// cipher write, resolution, sweep), never a plain insert/replace, so a generic CRUD surface would invite a +/// check-then-act race around the invariants below. +/// +public interface IPamRotationJobRepository +{ + /// + /// Guarded insert-if-no-active-job for the job's config, under UPDLOCK, HOLDLOCK (invariant + /// AtMostOneActiveJobPerConfig; spec OfferRotation's single creation point). The job must already + /// have its id assigned. + /// + Task CreateGuardedAsync(PamRotationJob job); + + Task GetByIdAsync(Guid id); + + /// + /// Atomic first-claim-wins update: flips the job Pending → Claimed and inserts its Executing + /// in the same transaction (invariant AtMostOneInFlightAttemptPerJob). + /// Re-checks EligibleClaimsOnly (config enabled, target active, the daemon is assigned to the target, and + /// the daemon's organization matches the config's) before claiming. On success the result carries the work + /// snapshot (spec ClaimRotation), including ExecuteBy = now + releaseDelay. + /// + Task ClaimAsync(Guid jobId, Guid daemonId, DateTime now, TimeSpan releaseDelay); + + /// Returns the daemon's currently claimable jobs — jobs on targets it is assigned to that are Pending and past NextClaimableAt. The daemon's poll. + Task> GetManyClaimableByDaemonIdAsync(Guid daemonId, DateTime now); + + /// Returns every job recorded against the config, each with its attempts, oldest first — the config detail page's attempt history. + Task> GetManyByConfigIdAsync(Guid configId); + + Task GetAttemptByIdAsync(Guid attemptId); + + /// + /// Atomic write-capability check and write: under one lock, re-verifies the job is Claimed by + /// , the attempt is Executing, and still + /// matches the cipher's current revision date, then replaces the cipher's Data, bumps its revision date, + /// and sets . Serializes against the release/timeout sweeps so + /// there is no check-then-act window between them and this write (spec AcceptCipherUpdate / + /// RejectCipherUpdate, plus the revision-date guard added to protect concurrent user edits). + /// + Task AcceptCipherWriteAsync(Guid attemptId, Guid daemonId, string cipherData, + DateTime lastKnownRevisionDate, DateTime now); + + /// + /// Resolves a successful attempt (guards: Executing ∧ claimed by ∧ + /// — the VerifiedBeforeSuccess backstop). On success also + /// flips the job to Succeeded and clears its claim fields. Guard failure is a stale report (spec + /// RejectStaleSuccess) — nothing changes, audit it as report_rejected. + /// + Task MarkAttemptRotatedAsync(Guid attemptId, Guid daemonId, + PamSessionTerminationOutcome sessionTermination, DateTime now); + + /// + /// Resolves a failed attempt (guards: Executing ∧ claimed by ). On success, marks the + /// attempt Errored with the (already truncated) and , + /// then either retries the job — back to Pending, claim fields cleared, + /// NextClaimableAt = now + retryBaseDelay·2^(erroredCount−1) — when the errored-attempt count is under + /// , or fails it outright once the budget is exhausted. Guard failure is a stale + /// report (spec RejectStaleFailureReport) — nothing changes, audit it as report_rejected. + /// + Task MarkAttemptErroredAsync(Guid attemptId, Guid daemonId, string? failureReason, + PamRotationSyncState syncState, DateTime now, int maxAttempts, TimeSpan retryBaseDelay); + + /// + /// Set-based sweep (spec JobTimesOut): moves every job still Pending or Claimed past + /// with no Rotated attempt to + /// (clearing claim fields) and abandons any Executing attempt against it. Returns one row per timed-out job for + /// audit emission — distinguishes unroutable (never claimed) from + /// stuck (claimed at least once). + /// + Task> TimeoutDueAsync(DateTime now); + + /// + /// Set-based sweep: releases claimed jobs back to Pending when their daemon's heartbeat has gone stale + /// () AND their claim lease has expired + /// (now >= ClaimedAt + releaseDelay) AND no Rotated attempt exists — releasing only at lease expiry, + /// not at stale detection, preserves success-wins for a slow-but-live daemon. NextClaimableAt is set to + /// the pre-clear ClaimedAt + releaseDelay in the same update; claim fields are cleared and the Executing + /// attempt is abandoned (budget not charged). Keys on heartbeat staleness only, never on daemon status, so a + /// revoked daemon's jobs release too. Returns one row per released job for audit emission. + /// + Task> ReleaseExpiredLeasesAsync(DateTime now, TimeSpan offlineAfter, + TimeSpan releaseDelay); +} diff --git a/src/Pam.Domain/Repositories/IPamTargetSystemRepository.cs b/src/Pam.Domain/Repositories/IPamTargetSystemRepository.cs new file mode 100644 index 000000000000..e892de35697f --- /dev/null +++ b/src/Pam.Domain/Repositories/IPamTargetSystemRepository.cs @@ -0,0 +1,9 @@ +using Bit.Core.Repositories; +using Bit.Pam.Entities; + +namespace Bit.Pam.Repositories; + +public interface IPamTargetSystemRepository : IRepository +{ + Task> GetManyByOrganizationIdAsync(Guid organizationId); +} From 7a4e0930a87a1a149d59fdbe5900d33069c05a8b Mon Sep 17 00:00:00 2001 From: Hinton Date: Mon, 6 Jul 2026 14:27:58 +0200 Subject: [PATCH 02/10] [PM-39040] Extend PAM audit event store with rotation kinds 26 new AccessAuditEventKind values (rotation lifecycle 50-66, fleet and target administration 70-78) plus nullable rotation subject columns (target system, daemon, config, job, source, sync state) on the append-only event store; names snapshotted at write like RuleName. --- .../Response/AccessAuditEventKindNames.cs | 58 +++++- .../Response/AccessAuditEventResponseModel.cs | 24 +++ .../AccessAuditEventRepository.cs | 8 + src/Pam.Domain/Enums/AccessAuditEventKind.cs | 87 +++++++++ src/Pam.Domain/Models/AccessAuditEvent.cs | 18 ++ src/Pam.Domain/Models/AccessAuditEventData.cs | 25 +++ .../AccessAuditEvent_Create.sql | 35 +++- ...essAuditEvent_ReadManyByOrganizationId.sql | 24 ++- src/Sql/dbo/Pam/Tables/AccessAuditEvent.sql | 8 + .../2026-07-06_01_PamRotationAudit.sql | 184 ++++++++++++++++++ 10 files changed, 456 insertions(+), 15 deletions(-) create mode 100644 util/Migrator/DbScripts/2026-07-06_01_PamRotationAudit.sql diff --git a/bitwarden_license/src/Services/Pam/Api/Models/Response/AccessAuditEventKindNames.cs b/bitwarden_license/src/Services/Pam/Api/Models/Response/AccessAuditEventKindNames.cs index 42dd102e4104..663d11422876 100644 --- a/bitwarden_license/src/Services/Pam/Api/Models/Response/AccessAuditEventKindNames.cs +++ b/bitwarden_license/src/Services/Pam/Api/Models/Response/AccessAuditEventKindNames.cs @@ -4,9 +4,9 @@ namespace Bit.Services.Pam.Api.Models.Response; /// /// Maps to the string vocabulary the governance client expects. The projection -/// emits the request, lease, and rule administration -/// kinds today; the remaining names (credential access, system controls) are defined so the contract stays stable as -/// those kinds come online. +/// emits the request, lease, rule administration, and rotation-lifecycle/fleet-administration kinds today; the +/// remaining names (credential access, system controls) are defined so the contract stays stable as those kinds come +/// online. /// public static class AccessAuditEventKindNames { @@ -29,6 +29,32 @@ public static class AccessAuditEventKindNames public const string LeasingKillSwitchTriggered = "leasingKillSwitchTriggered"; public const string LeasingFreezeEnabled = "leasingFreezeEnabled"; public const string LeasingFreezeLifted = "leasingFreezeLifted"; + public const string RotationConfigCreated = "rotationConfigCreated"; + public const string RotationSettingsUpdated = "rotationSettingsUpdated"; + public const string RotationAccountUpdated = "rotationAccountUpdated"; + public const string RotationPaused = "rotationPaused"; + public const string RotationResumed = "rotationResumed"; + public const string RotationConfigDeleted = "rotationConfigDeleted"; + public const string RotationOffered = "rotationOffered"; + public const string RotationDispatched = "rotationDispatched"; + public const string RotationSucceeded = "rotationSucceeded"; + public const string RotationAttemptFailed = "rotationAttemptFailed"; + public const string RotationFailed = "rotationFailed"; + public const string RotationJobReleased = "rotationJobReleased"; + public const string RotationJobTimedOut = "rotationJobTimedOut"; + public const string RotationCipherWriteRejected = "rotationCipherWriteRejected"; + public const string RotationReportRejected = "rotationReportRejected"; + public const string ManualRotationDue = "manualRotationDue"; + public const string ManualRotationRecorded = "manualRotationRecorded"; + public const string DaemonRegistered = "daemonRegistered"; + public const string DaemonRevoked = "daemonRevoked"; + public const string DaemonAssignedToTarget = "daemonAssignedToTarget"; + public const string DaemonUnassignedFromTarget = "daemonUnassignedFromTarget"; + public const string TargetSystemRegistered = "targetSystemRegistered"; + public const string TargetSystemDisabled = "targetSystemDisabled"; + public const string TargetSystemEnabled = "targetSystemEnabled"; + public const string TargetSystemRenamed = "targetSystemRenamed"; + public const string TargetSystemPolicyUpdated = "targetSystemPolicyUpdated"; public static string From(AccessAuditEventKind kind) => kind switch { @@ -51,6 +77,32 @@ public static class AccessAuditEventKindNames AccessAuditEventKind.LeasingKillSwitchTriggered => LeasingKillSwitchTriggered, AccessAuditEventKind.LeasingFreezeEnabled => LeasingFreezeEnabled, AccessAuditEventKind.LeasingFreezeLifted => LeasingFreezeLifted, + AccessAuditEventKind.RotationConfigCreated => RotationConfigCreated, + AccessAuditEventKind.RotationSettingsUpdated => RotationSettingsUpdated, + AccessAuditEventKind.RotationAccountUpdated => RotationAccountUpdated, + AccessAuditEventKind.RotationPaused => RotationPaused, + AccessAuditEventKind.RotationResumed => RotationResumed, + AccessAuditEventKind.RotationConfigDeleted => RotationConfigDeleted, + AccessAuditEventKind.RotationOffered => RotationOffered, + AccessAuditEventKind.RotationDispatched => RotationDispatched, + AccessAuditEventKind.RotationSucceeded => RotationSucceeded, + AccessAuditEventKind.RotationAttemptFailed => RotationAttemptFailed, + AccessAuditEventKind.RotationFailed => RotationFailed, + AccessAuditEventKind.RotationJobReleased => RotationJobReleased, + AccessAuditEventKind.RotationJobTimedOut => RotationJobTimedOut, + AccessAuditEventKind.RotationCipherWriteRejected => RotationCipherWriteRejected, + AccessAuditEventKind.RotationReportRejected => RotationReportRejected, + AccessAuditEventKind.ManualRotationDue => ManualRotationDue, + AccessAuditEventKind.ManualRotationRecorded => ManualRotationRecorded, + AccessAuditEventKind.DaemonRegistered => DaemonRegistered, + AccessAuditEventKind.DaemonRevoked => DaemonRevoked, + AccessAuditEventKind.DaemonAssignedToTarget => DaemonAssignedToTarget, + AccessAuditEventKind.DaemonUnassignedFromTarget => DaemonUnassignedFromTarget, + AccessAuditEventKind.TargetSystemRegistered => TargetSystemRegistered, + AccessAuditEventKind.TargetSystemDisabled => TargetSystemDisabled, + AccessAuditEventKind.TargetSystemEnabled => TargetSystemEnabled, + AccessAuditEventKind.TargetSystemRenamed => TargetSystemRenamed, + AccessAuditEventKind.TargetSystemPolicyUpdated => TargetSystemPolicyUpdated, _ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null), }; } diff --git a/bitwarden_license/src/Services/Pam/Api/Models/Response/AccessAuditEventResponseModel.cs b/bitwarden_license/src/Services/Pam/Api/Models/Response/AccessAuditEventResponseModel.cs index fec4048d48eb..9269a5886376 100644 --- a/bitwarden_license/src/Services/Pam/Api/Models/Response/AccessAuditEventResponseModel.cs +++ b/bitwarden_license/src/Services/Pam/Api/Models/Response/AccessAuditEventResponseModel.cs @@ -28,6 +28,12 @@ public AccessAuditEventResponseModel(AccessAuditEvent auditEvent) RequestId = auditEvent.AccessRequestId; LeaseId = auditEvent.AccessLeaseId; RuleId = auditEvent.AccessRuleId; + TargetSystemId = auditEvent.TargetSystemId; + DaemonId = auditEvent.DaemonId; + RotationConfigId = auditEvent.RotationConfigId; + RotationJobId = auditEvent.RotationJobId; + RotationSource = auditEvent.RotationSource; + SyncState = auditEvent.SyncState; Detail = auditEvent.Detail; LeaseNotBefore = auditEvent.LeaseNotBefore.AsUtc(); LeaseNotAfter = auditEvent.LeaseNotAfter.AsUtc(); @@ -38,6 +44,8 @@ public AccessAuditEventResponseModel(AccessAuditEvent auditEvent) CipherName = auditEvent.CipherName; CollectionName = auditEvent.CollectionName; RuleName = auditEvent.RuleName; + TargetSystemName = auditEvent.TargetSystemName; + DaemonName = auditEvent.DaemonName; Automated = auditEvent.Automated; Incomplete = auditEvent.Phase == AccessAuditEventPhase.Attempt; } @@ -59,6 +67,16 @@ public AccessAuditEventResponseModel(AccessAuditEvent auditEvent) public Guid? RequestId { get; } public Guid? LeaseId { get; } public Guid? RuleId { get; } + public Guid? TargetSystemId { get; } + public Guid? DaemonId { get; } + public Guid? RotationConfigId { get; } + public Guid? RotationJobId { get; } + + /// What triggered the rotation job (scheduled, on-demand, or access-end); set on job/attempt-scoped events. + public PamRotationSource? RotationSource { get; } + + /// Whether a failed attempt left the target system's password changed; set on failure/report events. + public PamRotationSyncState? SyncState { get; } /// An approver comment or a revoke reason, if the source carried one. public string? Detail { get; } @@ -81,6 +99,12 @@ public AccessAuditEventResponseModel(AccessAuditEvent auditEvent) /// The access rule's name — plaintext org configuration (not vault data), for rule administration events. public string? RuleName { get; } + /// The target system's name — plaintext org configuration, snapshotted at write, for rotation/target events. + public string? TargetSystemName { get; } + + /// The daemon's name — plaintext org configuration, snapshotted at write, for rotation/daemon events. + public string? DaemonName { get; } + /// True when there is no human actor — a system / automatic event. public bool Automated { get; } diff --git a/src/Infrastructure.Dapper/Pam/Repositories/AccessAuditEventRepository.cs b/src/Infrastructure.Dapper/Pam/Repositories/AccessAuditEventRepository.cs index 64ab947fa2b3..8a2b65bffbf8 100644 --- a/src/Infrastructure.Dapper/Pam/Repositories/AccessAuditEventRepository.cs +++ b/src/Infrastructure.Dapper/Pam/Repositories/AccessAuditEventRepository.cs @@ -45,6 +45,14 @@ await connection.ExecuteAsync( auditEvent.Detail, auditEvent.LeaseNotBefore, auditEvent.LeaseNotAfter, + auditEvent.TargetSystemId, + auditEvent.TargetSystemName, + auditEvent.DaemonId, + auditEvent.DaemonName, + auditEvent.RotationConfigId, + auditEvent.RotationJobId, + RotationSource = (byte?)auditEvent.RotationSource, + SyncState = (byte?)auditEvent.SyncState, }, commandType: CommandType.StoredProcedure); } diff --git a/src/Pam.Domain/Enums/AccessAuditEventKind.cs b/src/Pam.Domain/Enums/AccessAuditEventKind.cs index 2044d7d3a21e..1084d6bbe5fe 100644 --- a/src/Pam.Domain/Enums/AccessAuditEventKind.cs +++ b/src/Pam.Domain/Enums/AccessAuditEventKind.cs @@ -55,4 +55,91 @@ public enum AccessAuditEventKind : byte /// Deferred: out of scope this pass. LeasingFreezeLifted = 42, + + // Rotation lifecycle + /// A rotation config was created for a cipher. Spec outcome config_created. + RotationConfigCreated = 50, + + /// A rotation config's schedule/rotate-on-access-end settings were updated. Spec outcome settings_updated. + RotationSettingsUpdated = 51, + + /// A rotation config's target/account/termination settings were updated. Spec outcome account_updated. + RotationAccountUpdated = 52, + + /// A rotation config was paused. Spec outcome paused. + RotationPaused = 53, + + /// A rotation config was resumed. Spec outcome resumed. + RotationResumed = 54, + + /// A rotation config was deleted. Spec outcome config_deleted. + RotationConfigDeleted = 55, + + /// A rotation job was created for a config (the single creation point, OfferRotation). Spec outcome offered. + RotationOffered = 56, + + /// A rotation job was claimed by a daemon. Spec outcome dispatched. + RotationDispatched = 57, + + /// A rotation job succeeded. Spec outcome succeeded. + RotationSucceeded = 58, + + /// A rotation attempt failed but the job still has retry budget left. Spec outcome attempt_failed. + RotationAttemptFailed = 59, + + /// A rotation job failed after exhausting its retry budget. Spec outcome failed. + RotationFailed = 60, + + /// A claimed rotation job was released back to Pending by the sweep (stale daemon heartbeat past the claim lease). Spec outcome released. + RotationJobReleased = 61, + + /// A rotation job timed out past its TTL with no successful attempt. Spec outcome timed_out. + RotationJobTimedOut = 62, + + /// A daemon's cipher write was rejected by the atomic write-capability check. Spec outcome write_rejected. + RotationCipherWriteRejected = 63, + + /// A stale success/failure report was rejected (attempt no longer executing, or claimant mismatch). Spec outcome report_rejected. + RotationReportRejected = 64, + + /// A manual-method rotation config's obligation became due. Spec outcome manual_rotation_due. + ManualRotationDue = 65, + + /// An admin recorded a manual rotation as completed. Spec outcome manual_recorded. + ManualRotationRecorded = 66, + + // 67-69 reserved for rotation-lifecycle growth. + + // Deferred: no kind is allocated yet for the spec's access_end_deferred (the pending-access-end latch), + // auto_paused (§6.8 failure-policy auto-pause), or daemon_credential_reissued (ReissueDaemonCredential) outcomes — + // all out of scope this pass. Values are intentionally left unassigned rather than reserved, since the shape of + // that work (and which range it belongs in) isn't settled yet. + + // Fleet / target administration + /// A rotation daemon was registered. Spec outcome daemon_registered. + DaemonRegistered = 70, + + /// A rotation daemon was revoked. Spec outcome daemon_revoked. + DaemonRevoked = 71, + + /// A daemon was assigned to a target system. Spec outcome daemon_assigned. + DaemonAssignedToTarget = 72, + + /// A daemon was unassigned from a target system. Spec outcome daemon_unassigned. + DaemonUnassignedFromTarget = 73, + + /// A target system was registered (automatic or manual). Spec outcome target_registered. + TargetSystemRegistered = 74, + + /// A target system was disabled. Spec outcome target_disabled. + TargetSystemDisabled = 75, + + /// A target system was enabled. Spec outcome target_enabled. + TargetSystemEnabled = 76, + + /// A target system was renamed. Spec outcome target_renamed. + TargetSystemRenamed = 77, + + /// A target system's password policy or session-termination capability was updated. Spec outcome target_policy_updated. + TargetSystemPolicyUpdated = 78, } diff --git a/src/Pam.Domain/Models/AccessAuditEvent.cs b/src/Pam.Domain/Models/AccessAuditEvent.cs index b7bb5628801e..7dd511291a25 100644 --- a/src/Pam.Domain/Models/AccessAuditEvent.cs +++ b/src/Pam.Domain/Models/AccessAuditEvent.cs @@ -33,6 +33,16 @@ public class AccessAuditEvent public Guid? AccessRequestId { get; set; } public Guid? AccessLeaseId { get; set; } public Guid? AccessRuleId { get; set; } + public Guid? TargetSystemId { get; set; } + public Guid? DaemonId { get; set; } + public Guid? RotationConfigId { get; set; } + public Guid? RotationJobId { get; set; } + + /// What triggered the rotation job (scheduled, on-demand, or access-end); set on job/attempt-scoped events. + public PamRotationSource? RotationSource { get; set; } + + /// Whether a failed attempt left the target system's password changed; set on failure/report events. + public PamRotationSyncState? SyncState { get; set; } /// An approver comment, an auto-denial reason, or a revoke reason — whatever the source row carried. public string? Detail { get; set; } @@ -54,6 +64,14 @@ public class AccessAuditEvent /// (created / updated / deleted). Null for non-rule events, or when the rule row is gone. public string? RuleName { get; set; } + /// The target system's name — snapshotted at write by the rotation commands (same pattern as + /// , not a read-time JOIN). Null for non-target events. + public string? TargetSystemName { get; set; } + + /// The daemon's name — snapshotted at write by the rotation commands (same pattern as + /// , not a read-time JOIN). Null for non-daemon events. + public string? DaemonName { get; set; } + /// True when there is no human actor — a system / automatic event. Drives the automated-vs-human filter. public bool Automated => ActorId is null; } diff --git a/src/Pam.Domain/Models/AccessAuditEventData.cs b/src/Pam.Domain/Models/AccessAuditEventData.cs index 997c275fb335..3e2650818472 100644 --- a/src/Pam.Domain/Models/AccessAuditEventData.cs +++ b/src/Pam.Domain/Models/AccessAuditEventData.cs @@ -46,6 +46,31 @@ public record AccessAuditEventData /// public string? RuleName { get; init; } + public Guid? TargetSystemId { get; init; } + + /// + /// The target system's name, supplied by the rotation commands (which hold the entity) — same snapshot-at-write + /// pattern as , not a read-time JOIN. Null for non-target events. + /// + public string? TargetSystemName { get; init; } + + public Guid? DaemonId { get; init; } + + /// + /// The daemon's name, supplied by the rotation commands (which hold the entity) — same snapshot-at-write pattern + /// as , not a read-time JOIN. Null for non-daemon events. + /// + public string? DaemonName { get; init; } + + public Guid? RotationConfigId { get; init; } + public Guid? RotationJobId { get; init; } + + /// What triggered the rotation job (scheduled, on-demand, or access-end); set on job/attempt-scoped events. + public PamRotationSource? RotationSource { get; init; } + + /// Whether a failed attempt left the target system's password changed; set on failure/report events. + public PamRotationSyncState? SyncState { get; init; } + /// An approver comment, an auto-denial reason, or a revoke reason — whatever the action carried. public string? Detail { get; init; } diff --git a/src/Sql/dbo/Pam/Stored Procedures/AccessAuditEvent_Create.sql b/src/Sql/dbo/Pam/Stored Procedures/AccessAuditEvent_Create.sql index 6eb3e8b824c6..6a1f9952cc88 100644 --- a/src/Sql/dbo/Pam/Stored Procedures/AccessAuditEvent_Create.sql +++ b/src/Sql/dbo/Pam/Stored Procedures/AccessAuditEvent_Create.sql @@ -15,7 +15,15 @@ CREATE PROCEDURE [dbo].[AccessAuditEvent_Create] @RuleName NVARCHAR(256) = NULL, @Detail NVARCHAR(MAX) = NULL, @LeaseNotBefore DATETIME2(7) = NULL, - @LeaseNotAfter DATETIME2(7) = NULL + @LeaseNotAfter DATETIME2(7) = NULL, + @TargetSystemId UNIQUEIDENTIFIER = NULL, + @TargetSystemName NVARCHAR(200) = NULL, + @DaemonId UNIQUEIDENTIFIER = NULL, + @DaemonName NVARCHAR(200) = NULL, + @RotationConfigId UNIQUEIDENTIFIER = NULL, + @RotationJobId UNIQUEIDENTIFIER = NULL, + @RotationSource TINYINT = NULL, + @SyncState TINYINT = NULL AS BEGIN SET NOCOUNT ON @@ -23,8 +31,9 @@ BEGIN -- Snapshot the display names into the row at write time so the audit event is self-contained: a later delete or -- rename cannot change what this event says. Actor/requester/cipher/collection names are resolved by id from the -- live tables once, here, and frozen (cipher/collection names are encrypted EncString, stored as-is for the client - -- to decrypt); a name is NULL where its id is NULL or the row is gone. The rule name is supplied by the caller - -- (@RuleName), not JOINed -- a rule can be hard-deleted in the same action, so its name is captured before then. + -- to decrypt); a name is NULL where its id is NULL or the row is gone. The rule/target-system/daemon names are + -- supplied by the caller (@RuleName/@TargetSystemName/@DaemonName), not JOINed -- those entities can be deleted or + -- renamed in the same action, so their names are captured by the command before then. INSERT INTO [dbo].[AccessAuditEvent] ( [Id], @@ -49,7 +58,15 @@ BEGIN [RequesterEmail], [CipherName], [CollectionName], - [RuleName] + [RuleName], + [TargetSystemId], + [TargetSystemName], + [DaemonId], + [DaemonName], + [RotationConfigId], + [RotationJobId], + [RotationSource], + [SyncState] ) SELECT @Id, @@ -74,7 +91,15 @@ BEGIN RU.[Email], JSON_VALUE(C.[Data], '$.Name'), COL.[Name], - @RuleName + @RuleName, + @TargetSystemId, + @TargetSystemName, + @DaemonId, + @DaemonName, + @RotationConfigId, + @RotationJobId, + @RotationSource, + @SyncState FROM (SELECT 1 AS [X]) Seed LEFT JOIN [dbo].[User] AU ON AU.[Id] = @ActorId LEFT JOIN [dbo].[User] RU ON RU.[Id] = @RequesterId diff --git a/src/Sql/dbo/Pam/Stored Procedures/AccessAuditEvent_ReadManyByOrganizationId.sql b/src/Sql/dbo/Pam/Stored Procedures/AccessAuditEvent_ReadManyByOrganizationId.sql index 343c669fc0b6..fbbcc6d9772d 100644 --- a/src/Sql/dbo/Pam/Stored Procedures/AccessAuditEvent_ReadManyByOrganizationId.sql +++ b/src/Sql/dbo/Pam/Stored Procedures/AccessAuditEvent_ReadManyByOrganizationId.sql @@ -6,12 +6,14 @@ BEGIN SET NOCOUNT ON -- Reads the PAM access-audit trail for an entire organization from the append-only [AccessAuditEvent] store: every - -- stored event on or after @Since, newest first. Fully SELF-CONTAINED -- the actor/requester/cipher/collection/rule - -- display names were resolved and frozen into the row at write time (see AccessAuditEvent_Create), so this read - -- touches no other table and a later delete or rename of a referenced entity cannot erase or rewrite the event. - -- Cipher/collection names are encrypted (EncString), decrypted client-side. Org-scoped: the caller is authorized by - -- the AccessEventLogs permission at the endpoint. Kind matches Bit.Pam.Enums.AccessAuditEventKind; Phase matches - -- Bit.Pam.Enums.AccessAuditEventPhase. Time-derived expiry kinds are not written by any action yet (deferred). + -- stored event on or after @Since, newest first. Fully SELF-CONTAINED -- the actor/requester/cipher/collection/rule/ + -- target-system/daemon display names were resolved and frozen into the row at write time (see + -- AccessAuditEvent_Create), so this read touches no other table and a later delete or rename of a referenced entity + -- cannot erase or rewrite the event. Cipher/collection names are encrypted (EncString), decrypted client-side. + -- Org-scoped: the caller is authorized by the AccessEventLogs permission at the endpoint. Kind matches + -- Bit.Pam.Enums.AccessAuditEventKind; Phase matches Bit.Pam.Enums.AccessAuditEventPhase; RotationSource matches + -- Bit.Pam.Enums.PamRotationSource; SyncState matches Bit.Pam.Enums.PamRotationSyncState. Time-derived expiry kinds + -- are not written by any action yet (deferred). SELECT [Kind], [Phase], @@ -34,7 +36,15 @@ BEGIN [RequesterEmail], [CipherName], [CollectionName], - [RuleName] + [RuleName], + [TargetSystemId], + [TargetSystemName], + [DaemonId], + [DaemonName], + [RotationConfigId], + [RotationJobId], + [RotationSource], + [SyncState] FROM [dbo].[AccessAuditEvent] WHERE [OrganizationId] = @OrganizationId AND [OccurredAt] >= @Since diff --git a/src/Sql/dbo/Pam/Tables/AccessAuditEvent.sql b/src/Sql/dbo/Pam/Tables/AccessAuditEvent.sql index c38e7a505338..485f8400ba1d 100644 --- a/src/Sql/dbo/Pam/Tables/AccessAuditEvent.sql +++ b/src/Sql/dbo/Pam/Tables/AccessAuditEvent.sql @@ -22,6 +22,14 @@ CREATE TABLE [dbo].[AccessAuditEvent] ( [CollectionName] NVARCHAR(MAX) NULL, [RuleName] NVARCHAR(256) NULL, [CorrelationId] UNIQUEIDENTIFIER NOT NULL CONSTRAINT [DF_AccessAuditEvent_CorrelationId] DEFAULT NEWID(), + [TargetSystemId] UNIQUEIDENTIFIER NULL, + [TargetSystemName] NVARCHAR(200) NULL, + [DaemonId] UNIQUEIDENTIFIER NULL, + [DaemonName] NVARCHAR(200) NULL, + [RotationConfigId] UNIQUEIDENTIFIER NULL, + [RotationJobId] UNIQUEIDENTIFIER NULL, + [RotationSource] TINYINT NULL, + [SyncState] TINYINT NULL, CONSTRAINT [PK_AccessAuditEvent] PRIMARY KEY CLUSTERED ([Id] ASC), CONSTRAINT [FK_AccessAuditEvent_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]) ON DELETE CASCADE diff --git a/util/Migrator/DbScripts/2026-07-06_01_PamRotationAudit.sql b/util/Migrator/DbScripts/2026-07-06_01_PamRotationAudit.sql new file mode 100644 index 000000000000..c7454c5592ee --- /dev/null +++ b/util/Migrator/DbScripts/2026-07-06_01_PamRotationAudit.sql @@ -0,0 +1,184 @@ +-- Extend the PAM append-only access-audit event store ([dbo].[AccessAuditEvent]) to carry credential-rotation events +-- (rotation lifecycle + fleet/target administration -- see Bit.Pam.Enums.AccessAuditEventKind 50-79). Same +-- self-contained model as the existing store: TargetSystemName/DaemonName are supplied by the rotation commands +-- (snapshotted at write, same pattern as RuleName), not JOINed -- a target system or daemon can be deleted in the same +-- action. RotationConfigId/RotationJobId/RotationSource/SyncState are stored as-is (SyncState/RotationSource match +-- Bit.Pam.Enums.PamRotationSyncState/PamRotationSource). Dapper/MSSQL only, like the rest of the PAM POC. + +IF COL_LENGTH('[dbo].[AccessAuditEvent]', 'TargetSystemId') IS NULL +BEGIN + ALTER TABLE [dbo].[AccessAuditEvent] ADD + [TargetSystemId] UNIQUEIDENTIFIER NULL, + [TargetSystemName] NVARCHAR(200) NULL, + [DaemonId] UNIQUEIDENTIFIER NULL, + [DaemonName] NVARCHAR(200) NULL, + [RotationConfigId] UNIQUEIDENTIFIER NULL, + [RotationJobId] UNIQUEIDENTIFIER NULL, + [RotationSource] TINYINT NULL, + [SyncState] TINYINT NULL; +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[AccessAuditEvent_Create] + @Id UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @CorrelationId UNIQUEIDENTIFIER, + @Kind TINYINT, + @Phase TINYINT, + @OccurredAt DATETIME2(7), + @ActorId UNIQUEIDENTIFIER = NULL, + @RequesterId UNIQUEIDENTIFIER = NULL, + @CollectionId UNIQUEIDENTIFIER = NULL, + @CipherId UNIQUEIDENTIFIER = NULL, + @AccessRequestId UNIQUEIDENTIFIER = NULL, + @AccessLeaseId UNIQUEIDENTIFIER = NULL, + @AccessRuleId UNIQUEIDENTIFIER = NULL, + @RuleName NVARCHAR(256) = NULL, + @Detail NVARCHAR(MAX) = NULL, + @LeaseNotBefore DATETIME2(7) = NULL, + @LeaseNotAfter DATETIME2(7) = NULL, + @TargetSystemId UNIQUEIDENTIFIER = NULL, + @TargetSystemName NVARCHAR(200) = NULL, + @DaemonId UNIQUEIDENTIFIER = NULL, + @DaemonName NVARCHAR(200) = NULL, + @RotationConfigId UNIQUEIDENTIFIER = NULL, + @RotationJobId UNIQUEIDENTIFIER = NULL, + @RotationSource TINYINT = NULL, + @SyncState TINYINT = NULL +AS +BEGIN + SET NOCOUNT ON + + -- Snapshot the display names into the row at write time so the audit event is self-contained: a later delete or + -- rename cannot change what this event says. Actor/requester/cipher/collection names are resolved by id from the + -- live tables once, here, and frozen (cipher/collection names are encrypted EncString, stored as-is for the client + -- to decrypt); a name is NULL where its id is NULL or the row is gone. The rule/target-system/daemon names are + -- supplied by the caller (@RuleName/@TargetSystemName/@DaemonName), not JOINed -- those entities can be deleted or + -- renamed in the same action, so their names are captured by the command before then. + INSERT INTO [dbo].[AccessAuditEvent] + ( + [Id], + [OrganizationId], + [CorrelationId], + [Kind], + [Phase], + [OccurredAt], + [ActorId], + [RequesterId], + [CollectionId], + [CipherId], + [AccessRequestId], + [AccessLeaseId], + [AccessRuleId], + [Detail], + [LeaseNotBefore], + [LeaseNotAfter], + [ActorName], + [ActorEmail], + [RequesterName], + [RequesterEmail], + [CipherName], + [CollectionName], + [RuleName], + [TargetSystemId], + [TargetSystemName], + [DaemonId], + [DaemonName], + [RotationConfigId], + [RotationJobId], + [RotationSource], + [SyncState] + ) + SELECT + @Id, + @OrganizationId, + @CorrelationId, + @Kind, + @Phase, + @OccurredAt, + @ActorId, + @RequesterId, + @CollectionId, + @CipherId, + @AccessRequestId, + @AccessLeaseId, + @AccessRuleId, + @Detail, + @LeaseNotBefore, + @LeaseNotAfter, + AU.[Name], + AU.[Email], + RU.[Name], + RU.[Email], + JSON_VALUE(C.[Data], '$.Name'), + COL.[Name], + @RuleName, + @TargetSystemId, + @TargetSystemName, + @DaemonId, + @DaemonName, + @RotationConfigId, + @RotationJobId, + @RotationSource, + @SyncState + FROM (SELECT 1 AS [X]) Seed + LEFT JOIN [dbo].[User] AU ON AU.[Id] = @ActorId + LEFT JOIN [dbo].[User] RU ON RU.[Id] = @RequesterId + LEFT JOIN [dbo].[Cipher] C ON C.[Id] = @CipherId + LEFT JOIN [dbo].[Collection] COL ON COL.[Id] = @CollectionId +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[AccessAuditEvent_ReadManyByOrganizationId] + @OrganizationId UNIQUEIDENTIFIER, + @Since DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + -- Reads the PAM access-audit trail for an entire organization from the append-only [AccessAuditEvent] store: every + -- stored event on or after @Since, newest first. Fully SELF-CONTAINED -- the actor/requester/cipher/collection/rule/ + -- target-system/daemon display names were resolved and frozen into the row at write time (see + -- AccessAuditEvent_Create), so this read touches no other table and a later delete or rename of a referenced entity + -- cannot erase or rewrite the event. Cipher/collection names are encrypted (EncString), decrypted client-side. + -- Org-scoped: the caller is authorized by the AccessEventLogs permission at the endpoint. Kind matches + -- Bit.Pam.Enums.AccessAuditEventKind; Phase matches Bit.Pam.Enums.AccessAuditEventPhase; RotationSource matches + -- Bit.Pam.Enums.PamRotationSource; SyncState matches Bit.Pam.Enums.PamRotationSyncState. Time-derived expiry kinds + -- are not written by any action yet (deferred). + SELECT + [Kind], + [Phase], + [CorrelationId], + [OccurredAt], + [OrganizationId], + [ActorId], + [RequesterId], + [CollectionId], + [CipherId], + [AccessRequestId], + [AccessLeaseId], + [AccessRuleId], + [Detail], + [LeaseNotBefore], + [LeaseNotAfter], + [ActorName], + [ActorEmail], + [RequesterName], + [RequesterEmail], + [CipherName], + [CollectionName], + [RuleName], + [TargetSystemId], + [TargetSystemName], + [DaemonId], + [DaemonName], + [RotationConfigId], + [RotationJobId], + [RotationSource], + [SyncState] + FROM [dbo].[AccessAuditEvent] + WHERE [OrganizationId] = @OrganizationId + AND [OccurredAt] >= @Since + ORDER BY [OccurredAt] DESC +END +GO From 8f24fc808dc8ad5e7e6dba1c26806ba63e0f4ec5 Mon Sep 17 00:00:00 2001 From: Hinton Date: Mon, 6 Jul 2026 14:28:20 +0200 Subject: [PATCH 03/10] [PM-39040] Add rotation SQL schema and stored procedures Six tables and 38 stored procedures, including the concurrency-critical paths: guarded job creation (one active job per config), atomic first-claim-wins with an org/assignment eligibility join, the atomic cipher-write capability check serialized against the sweeps via a job row lock, retry-budget errored-attempt math, success-wins timeout and lease-respecting release sweeps, and the lease natural-expiry sweep. --- .../AccessLease_ExpireDue.sql | 24 + .../PamDaemonDetails_ReadByApiKeyId.sql | 16 + .../PamDaemonTargetAssignment_Create.sql | 30 + ...ignment_DeleteByDaemonIdTargetSystemId.sql | 10 + ...ignment_ExistsByDaemonIdTargetSystemId.sql | 11 + ...mDaemonTargetAssignment_ReadByDaemonId.sql | 10 + ...nTargetAssignment_ReadByOrganizationId.sql | 10 + .../Stored Procedures/PamDaemon_Create.sql | 36 + .../Stored Procedures/PamDaemon_ReadById.sql | 10 + .../PamDaemon_ReadByOrganizationId.sql | 10 + .../Stored Procedures/PamDaemon_Update.sql | 23 + .../PamDaemon_UpdateHeartbeat.sql | 17 + .../PamRotationAttempt_AcceptCipherWrite.sql | 66 + .../PamRotationAttempt_MarkErrored.sql | 79 + .../PamRotationAttempt_MarkRotated.sql | 53 + .../PamRotationAttempt_ReadById.sql | 10 + ...AnyByTargetSystemWithTerminateSessions.sql | 12 + .../PamRotationConfig_Create.sql | 51 + .../PamRotationConfig_DeleteWithJobs.sql | 26 + .../PamRotationConfig_ReadByCipherId.sql | 11 + .../PamRotationConfig_ReadById.sql | 10 + .../PamRotationConfig_ReadDetailsById.sql | 22 + ...otationConfig_ReadManyByOrganizationId.sql | 22 + .../PamRotationConfig_ReadManyDue.sql | 24 + .../PamRotationConfig_Update.sql | 36 + .../PamRotationJob_Claim.sql | 106 ++ .../PamRotationJob_Create.sql | 71 + .../PamRotationJob_ReadById.sql | 10 + .../PamRotationJob_ReadManyByConfigId.sql | 21 + ...otationJob_ReadManyClaimableByDaemonId.sql | 22 + .../PamRotationJob_ReleaseExpiredLeases.sql | 64 + .../PamRotationJob_TimeoutDue.sql | 58 + .../PamTargetSystem_Create.sql | 42 + .../PamTargetSystem_DeleteById.sql | 11 + .../PamTargetSystem_ReadById.sql | 10 + .../PamTargetSystem_ReadByOrganizationId.sql | 10 + .../PamTargetSystem_Update.sql | 30 + src/Sql/dbo/Pam/Tables/PamDaemon.sql | 29 + .../Pam/Tables/PamDaemonTargetAssignment.sql | 29 + src/Sql/dbo/Pam/Tables/PamRotationAttempt.sql | 27 + src/Sql/dbo/Pam/Tables/PamRotationConfig.sql | 39 + src/Sql/dbo/Pam/Tables/PamRotationJob.sql | 37 + src/Sql/dbo/Pam/Tables/PamTargetSystem.sql | 19 + .../DbScripts/2026-07-06_00_PamRotation.sql | 1318 +++++++++++++++++ 44 files changed, 2582 insertions(+) create mode 100644 src/Sql/dbo/Pam/Stored Procedures/AccessLease_ExpireDue.sql create mode 100644 src/Sql/dbo/Pam/Stored Procedures/PamDaemonDetails_ReadByApiKeyId.sql create mode 100644 src/Sql/dbo/Pam/Stored Procedures/PamDaemonTargetAssignment_Create.sql create mode 100644 src/Sql/dbo/Pam/Stored Procedures/PamDaemonTargetAssignment_DeleteByDaemonIdTargetSystemId.sql create mode 100644 src/Sql/dbo/Pam/Stored Procedures/PamDaemonTargetAssignment_ExistsByDaemonIdTargetSystemId.sql create mode 100644 src/Sql/dbo/Pam/Stored Procedures/PamDaemonTargetAssignment_ReadByDaemonId.sql create mode 100644 src/Sql/dbo/Pam/Stored Procedures/PamDaemonTargetAssignment_ReadByOrganizationId.sql create mode 100644 src/Sql/dbo/Pam/Stored Procedures/PamDaemon_Create.sql create mode 100644 src/Sql/dbo/Pam/Stored Procedures/PamDaemon_ReadById.sql create mode 100644 src/Sql/dbo/Pam/Stored Procedures/PamDaemon_ReadByOrganizationId.sql create mode 100644 src/Sql/dbo/Pam/Stored Procedures/PamDaemon_Update.sql create mode 100644 src/Sql/dbo/Pam/Stored Procedures/PamDaemon_UpdateHeartbeat.sql create mode 100644 src/Sql/dbo/Pam/Stored Procedures/PamRotationAttempt_AcceptCipherWrite.sql create mode 100644 src/Sql/dbo/Pam/Stored Procedures/PamRotationAttempt_MarkErrored.sql create mode 100644 src/Sql/dbo/Pam/Stored Procedures/PamRotationAttempt_MarkRotated.sql create mode 100644 src/Sql/dbo/Pam/Stored Procedures/PamRotationAttempt_ReadById.sql create mode 100644 src/Sql/dbo/Pam/Stored Procedures/PamRotationConfig_AnyByTargetSystemWithTerminateSessions.sql create mode 100644 src/Sql/dbo/Pam/Stored Procedures/PamRotationConfig_Create.sql create mode 100644 src/Sql/dbo/Pam/Stored Procedures/PamRotationConfig_DeleteWithJobs.sql create mode 100644 src/Sql/dbo/Pam/Stored Procedures/PamRotationConfig_ReadByCipherId.sql create mode 100644 src/Sql/dbo/Pam/Stored Procedures/PamRotationConfig_ReadById.sql create mode 100644 src/Sql/dbo/Pam/Stored Procedures/PamRotationConfig_ReadDetailsById.sql create mode 100644 src/Sql/dbo/Pam/Stored Procedures/PamRotationConfig_ReadManyByOrganizationId.sql create mode 100644 src/Sql/dbo/Pam/Stored Procedures/PamRotationConfig_ReadManyDue.sql create mode 100644 src/Sql/dbo/Pam/Stored Procedures/PamRotationConfig_Update.sql create mode 100644 src/Sql/dbo/Pam/Stored Procedures/PamRotationJob_Claim.sql create mode 100644 src/Sql/dbo/Pam/Stored Procedures/PamRotationJob_Create.sql create mode 100644 src/Sql/dbo/Pam/Stored Procedures/PamRotationJob_ReadById.sql create mode 100644 src/Sql/dbo/Pam/Stored Procedures/PamRotationJob_ReadManyByConfigId.sql create mode 100644 src/Sql/dbo/Pam/Stored Procedures/PamRotationJob_ReadManyClaimableByDaemonId.sql create mode 100644 src/Sql/dbo/Pam/Stored Procedures/PamRotationJob_ReleaseExpiredLeases.sql create mode 100644 src/Sql/dbo/Pam/Stored Procedures/PamRotationJob_TimeoutDue.sql create mode 100644 src/Sql/dbo/Pam/Stored Procedures/PamTargetSystem_Create.sql create mode 100644 src/Sql/dbo/Pam/Stored Procedures/PamTargetSystem_DeleteById.sql create mode 100644 src/Sql/dbo/Pam/Stored Procedures/PamTargetSystem_ReadById.sql create mode 100644 src/Sql/dbo/Pam/Stored Procedures/PamTargetSystem_ReadByOrganizationId.sql create mode 100644 src/Sql/dbo/Pam/Stored Procedures/PamTargetSystem_Update.sql create mode 100644 src/Sql/dbo/Pam/Tables/PamDaemon.sql create mode 100644 src/Sql/dbo/Pam/Tables/PamDaemonTargetAssignment.sql create mode 100644 src/Sql/dbo/Pam/Tables/PamRotationAttempt.sql create mode 100644 src/Sql/dbo/Pam/Tables/PamRotationConfig.sql create mode 100644 src/Sql/dbo/Pam/Tables/PamRotationJob.sql create mode 100644 src/Sql/dbo/Pam/Tables/PamTargetSystem.sql create mode 100644 util/Migrator/DbScripts/2026-07-06_00_PamRotation.sql diff --git a/src/Sql/dbo/Pam/Stored Procedures/AccessLease_ExpireDue.sql b/src/Sql/dbo/Pam/Stored Procedures/AccessLease_ExpireDue.sql new file mode 100644 index 000000000000..59eb11f96f23 --- /dev/null +++ b/src/Sql/dbo/Pam/Stored Procedures/AccessLease_ExpireDue.sql @@ -0,0 +1,24 @@ +CREATE PROCEDURE [dbo].[AccessLease_ExpireDue] + @Now DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + -- The anticipated lease natural-expiry sweep (plan decision 4): flips Active -> Expired for leases whose window + -- closed on its own (no revoke/cancel involved), so the deferred LeaseExpired audit kind and the rotation + -- access-end trigger both have something to fire from. [IX_AccessLease_NotAfter_Status] makes this a narrow + -- range seek. No join is needed for the projection -- every column the caller audits/triggers on already lives + -- on the row itself. + UPDATE [dbo].[AccessLease] + SET [Status] = 1 -- Expired + OUTPUT + deleted.[Id], + deleted.[OrganizationId], + deleted.[CollectionId], + deleted.[CipherId], + deleted.[RequesterId], + deleted.[NotBefore], + deleted.[NotAfter] + WHERE [Status] = 0 -- Active + AND [NotAfter] <= @Now +END diff --git a/src/Sql/dbo/Pam/Stored Procedures/PamDaemonDetails_ReadByApiKeyId.sql b/src/Sql/dbo/Pam/Stored Procedures/PamDaemonDetails_ReadByApiKeyId.sql new file mode 100644 index 000000000000..0d28d13d5105 --- /dev/null +++ b/src/Sql/dbo/Pam/Stored Procedures/PamDaemonDetails_ReadByApiKeyId.sql @@ -0,0 +1,16 @@ +CREATE PROCEDURE [dbo].[PamDaemonDetails_ReadByApiKeyId] + @ApiKeyId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + -- The client provider's lookup at token-issuance time: the daemon row plus the two organization flags that gate + -- issuance (Enabled, UsePam) so a lapsed/disabled org's daemon cannot mint a token without an extra round trip. + SELECT + D.*, + O.[Enabled] AS [OrganizationEnabled], + O.[UsePam] AS [OrganizationUsePam] + FROM [dbo].[PamDaemon] D + INNER JOIN [dbo].[Organization] O ON O.[Id] = D.[OrganizationId] + WHERE D.[ApiKeyId] = @ApiKeyId +END diff --git a/src/Sql/dbo/Pam/Stored Procedures/PamDaemonTargetAssignment_Create.sql b/src/Sql/dbo/Pam/Stored Procedures/PamDaemonTargetAssignment_Create.sql new file mode 100644 index 000000000000..50a912a4b9a2 --- /dev/null +++ b/src/Sql/dbo/Pam/Stored Procedures/PamDaemonTargetAssignment_Create.sql @@ -0,0 +1,30 @@ +CREATE PROCEDURE [dbo].[PamDaemonTargetAssignment_Create] + @Id UNIQUEIDENTIFIER, + @DaemonId UNIQUEIDENTIFIER, + @TargetSystemId UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @CreationDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + -- @Id is a plain input, not OUTPUT: unlike the generic Create sprocs, the caller (IPamDaemonRepository. + -- CreateAssignmentAsync) always assigns the id before calling this. [IX_PamDaemonTargetAssignment_DaemonId_TargetSystemId] + -- is the unique-index backstop for OneAssignmentPerDaemonTarget if two callers race. + INSERT INTO [dbo].[PamDaemonTargetAssignment] + ( + [Id], + [DaemonId], + [TargetSystemId], + [OrganizationId], + [CreationDate] + ) + VALUES + ( + @Id, + @DaemonId, + @TargetSystemId, + @OrganizationId, + @CreationDate + ) +END diff --git a/src/Sql/dbo/Pam/Stored Procedures/PamDaemonTargetAssignment_DeleteByDaemonIdTargetSystemId.sql b/src/Sql/dbo/Pam/Stored Procedures/PamDaemonTargetAssignment_DeleteByDaemonIdTargetSystemId.sql new file mode 100644 index 000000000000..798441bc8661 --- /dev/null +++ b/src/Sql/dbo/Pam/Stored Procedures/PamDaemonTargetAssignment_DeleteByDaemonIdTargetSystemId.sql @@ -0,0 +1,10 @@ +CREATE PROCEDURE [dbo].[PamDaemonTargetAssignment_DeleteByDaemonIdTargetSystemId] + @DaemonId UNIQUEIDENTIFIER, + @TargetSystemId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + DELETE FROM [dbo].[PamDaemonTargetAssignment] + WHERE [DaemonId] = @DaemonId AND [TargetSystemId] = @TargetSystemId +END diff --git a/src/Sql/dbo/Pam/Stored Procedures/PamDaemonTargetAssignment_ExistsByDaemonIdTargetSystemId.sql b/src/Sql/dbo/Pam/Stored Procedures/PamDaemonTargetAssignment_ExistsByDaemonIdTargetSystemId.sql new file mode 100644 index 000000000000..9a3667062522 --- /dev/null +++ b/src/Sql/dbo/Pam/Stored Procedures/PamDaemonTargetAssignment_ExistsByDaemonIdTargetSystemId.sql @@ -0,0 +1,11 @@ +CREATE PROCEDURE [dbo].[PamDaemonTargetAssignment_ExistsByDaemonIdTargetSystemId] + @DaemonId UNIQUEIDENTIFIER, + @TargetSystemId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT 1 + FROM [dbo].[PamDaemonTargetAssignment] + WHERE [DaemonId] = @DaemonId AND [TargetSystemId] = @TargetSystemId +END diff --git a/src/Sql/dbo/Pam/Stored Procedures/PamDaemonTargetAssignment_ReadByDaemonId.sql b/src/Sql/dbo/Pam/Stored Procedures/PamDaemonTargetAssignment_ReadByDaemonId.sql new file mode 100644 index 000000000000..5a156499f8b5 --- /dev/null +++ b/src/Sql/dbo/Pam/Stored Procedures/PamDaemonTargetAssignment_ReadByDaemonId.sql @@ -0,0 +1,10 @@ +CREATE PROCEDURE [dbo].[PamDaemonTargetAssignment_ReadByDaemonId] + @DaemonId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT * + FROM [dbo].[PamDaemonTargetAssignment] + WHERE [DaemonId] = @DaemonId +END diff --git a/src/Sql/dbo/Pam/Stored Procedures/PamDaemonTargetAssignment_ReadByOrganizationId.sql b/src/Sql/dbo/Pam/Stored Procedures/PamDaemonTargetAssignment_ReadByOrganizationId.sql new file mode 100644 index 000000000000..e5ba2655a7a1 --- /dev/null +++ b/src/Sql/dbo/Pam/Stored Procedures/PamDaemonTargetAssignment_ReadByOrganizationId.sql @@ -0,0 +1,10 @@ +CREATE PROCEDURE [dbo].[PamDaemonTargetAssignment_ReadByOrganizationId] + @OrganizationId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT * + FROM [dbo].[PamDaemonTargetAssignment] + WHERE [OrganizationId] = @OrganizationId +END diff --git a/src/Sql/dbo/Pam/Stored Procedures/PamDaemon_Create.sql b/src/Sql/dbo/Pam/Stored Procedures/PamDaemon_Create.sql new file mode 100644 index 000000000000..81654a9f5fed --- /dev/null +++ b/src/Sql/dbo/Pam/Stored Procedures/PamDaemon_Create.sql @@ -0,0 +1,36 @@ +CREATE PROCEDURE [dbo].[PamDaemon_Create] + @Id UNIQUEIDENTIFIER OUTPUT, + @OrganizationId UNIQUEIDENTIFIER, + @Name NVARCHAR(200), + @ApiKeyId UNIQUEIDENTIFIER, + @Status TINYINT, + @LastHeartbeatAt DATETIME2(7) = NULL, + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + INSERT INTO [dbo].[PamDaemon] + ( + [Id], + [OrganizationId], + [Name], + [ApiKeyId], + [Status], + [LastHeartbeatAt], + [CreationDate], + [RevisionDate] + ) + VALUES + ( + @Id, + @OrganizationId, + @Name, + @ApiKeyId, + @Status, + @LastHeartbeatAt, + @CreationDate, + @RevisionDate + ) +END diff --git a/src/Sql/dbo/Pam/Stored Procedures/PamDaemon_ReadById.sql b/src/Sql/dbo/Pam/Stored Procedures/PamDaemon_ReadById.sql new file mode 100644 index 000000000000..71af12c3b0ec --- /dev/null +++ b/src/Sql/dbo/Pam/Stored Procedures/PamDaemon_ReadById.sql @@ -0,0 +1,10 @@ +CREATE PROCEDURE [dbo].[PamDaemon_ReadById] + @Id UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT * + FROM [dbo].[PamDaemon] + WHERE [Id] = @Id +END diff --git a/src/Sql/dbo/Pam/Stored Procedures/PamDaemon_ReadByOrganizationId.sql b/src/Sql/dbo/Pam/Stored Procedures/PamDaemon_ReadByOrganizationId.sql new file mode 100644 index 000000000000..10ce5bf14369 --- /dev/null +++ b/src/Sql/dbo/Pam/Stored Procedures/PamDaemon_ReadByOrganizationId.sql @@ -0,0 +1,10 @@ +CREATE PROCEDURE [dbo].[PamDaemon_ReadByOrganizationId] + @OrganizationId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT * + FROM [dbo].[PamDaemon] + WHERE [OrganizationId] = @OrganizationId +END diff --git a/src/Sql/dbo/Pam/Stored Procedures/PamDaemon_Update.sql b/src/Sql/dbo/Pam/Stored Procedures/PamDaemon_Update.sql new file mode 100644 index 000000000000..3808b8195f98 --- /dev/null +++ b/src/Sql/dbo/Pam/Stored Procedures/PamDaemon_Update.sql @@ -0,0 +1,23 @@ +CREATE PROCEDURE [dbo].[PamDaemon_Update] + @Id UNIQUEIDENTIFIER, + @Name NVARCHAR(200), + @Status TINYINT, + @RevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + -- Name + Status only: ApiKeyId is set once at registration (reissue is deferred -- see the plan's deferrals), + -- OrganizationId/CreationDate never change, and LastHeartbeatAt has its own conditional-bump sproc + -- (PamDaemon_UpdateHeartbeat) so a routine admin edit never races a daemon's own poll. The repository must call + -- this with an explicit narrow parameter set rather than the generic whole-entity Update -- passing the full + -- PamDaemon entity here would fail (this sproc does not declare an ApiKeyId/LastHeartbeatAt/etc. parameter). + UPDATE + [dbo].[PamDaemon] + SET + [Name] = @Name, + [Status] = @Status, + [RevisionDate] = @RevisionDate + WHERE + [Id] = @Id +END diff --git a/src/Sql/dbo/Pam/Stored Procedures/PamDaemon_UpdateHeartbeat.sql b/src/Sql/dbo/Pam/Stored Procedures/PamDaemon_UpdateHeartbeat.sql new file mode 100644 index 000000000000..faf93312644f --- /dev/null +++ b/src/Sql/dbo/Pam/Stored Procedures/PamDaemon_UpdateHeartbeat.sql @@ -0,0 +1,17 @@ +CREATE PROCEDURE [dbo].[PamDaemon_UpdateHeartbeat] + @Id UNIQUEIDENTIFIER, + @Now DATETIME2(7), + @MinIntervalSeconds INT +AS +BEGIN + SET NOCOUNT ON + + -- Conditional bump: the daemon-facing request filter calls this on every request, so the WHERE guard turns + -- most calls into a no-op write instead of hammering the row -- only a poll arriving after @MinIntervalSeconds + -- since the last recorded heartbeat actually updates it. Never called by a sweep -- only by the daemon's own + -- requests. + UPDATE [dbo].[PamDaemon] + SET [LastHeartbeatAt] = @Now + WHERE [Id] = @Id + AND ([LastHeartbeatAt] IS NULL OR [LastHeartbeatAt] < DATEADD(SECOND, -@MinIntervalSeconds, @Now)) +END diff --git a/src/Sql/dbo/Pam/Stored Procedures/PamRotationAttempt_AcceptCipherWrite.sql b/src/Sql/dbo/Pam/Stored Procedures/PamRotationAttempt_AcceptCipherWrite.sql new file mode 100644 index 000000000000..135af67a01aa --- /dev/null +++ b/src/Sql/dbo/Pam/Stored Procedures/PamRotationAttempt_AcceptCipherWrite.sql @@ -0,0 +1,66 @@ +CREATE PROCEDURE [dbo].[PamRotationAttempt_AcceptCipherWrite] + @AttemptId UNIQUEIDENTIFIER, + @DaemonId UNIQUEIDENTIFIER, + @CipherData NVARCHAR(MAX), + @LastKnownRevisionDate DATETIME2(7), + @Now DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + -- AcceptCipherUpdate's atomic write-capability check (security finding, plan §1): the job row is locked here + -- WITH (UPDLOCK) for the life of the transaction, so a concurrent release/timeout sweep -- which updates the same + -- job row -- blocks until this commits (or vice versa), closing the check-then-act window between "is this + -- attempt still allowed to write" and "write the cipher". XACT_ABORT guarantees rollback (and a clean pooled + -- connection) on any error. + SET XACT_ABORT ON + + BEGIN TRANSACTION + + DECLARE @CipherId UNIQUEIDENTIFIER + DECLARE @VerifiedJobId UNIQUEIDENTIFIER + + SELECT + @CipherId = C.[CipherId], + @VerifiedJobId = J.[Id] + FROM [dbo].[PamRotationAttempt] AT + INNER JOIN [dbo].[PamRotationJob] J WITH (UPDLOCK) ON J.[Id] = AT.[JobId] + INNER JOIN [dbo].[PamRotationConfig] C ON C.[Id] = J.[RotationConfigId] + WHERE AT.[Id] = @AttemptId + AND AT.[Status] = 0 -- Executing + AND AT.[ClaimedByDaemonId] = @DaemonId + AND J.[Status] = 1 -- Claimed + AND J.[ClaimedByDaemonId] = @DaemonId + + IF @VerifiedJobId IS NULL + BEGIN + -- The complement of spec AcceptCipherUpdate: unknown attempt, wrong claimant, or the job/attempt has already + -- moved on (released/timed out/resolved). Audited by the caller as write_rejected. + ROLLBACK TRANSACTION + SELECT 0 -- Rejected + RETURN + END + + -- Outside RejectCipherUpdate's exact complement (plan §10 divergence): a drifted LastKnownRevisionDate means the + -- vault item changed since the daemon last read it, so the write is rejected to protect a concurrent user edit + -- rather than silently clobbering it. The 1-second tolerance mirrors CipherService's own last-known-revision + -- check. + IF ABS(DATEDIFF(MILLISECOND, (SELECT [RevisionDate] FROM [dbo].[Cipher] WHERE [Id] = @CipherId), @LastKnownRevisionDate)) > 1000 + BEGIN + ROLLBACK TRANSACTION + SELECT -1 -- RevisionMismatch + RETURN + END + + UPDATE [dbo].[Cipher] + SET [Data] = @CipherData, + [RevisionDate] = @Now + WHERE [Id] = @CipherId + + UPDATE [dbo].[PamRotationAttempt] + SET [CipherUpdated] = 1 + WHERE [Id] = @AttemptId + + COMMIT TRANSACTION + + SELECT 1 -- Accepted +END diff --git a/src/Sql/dbo/Pam/Stored Procedures/PamRotationAttempt_MarkErrored.sql b/src/Sql/dbo/Pam/Stored Procedures/PamRotationAttempt_MarkErrored.sql new file mode 100644 index 000000000000..084c76f4c7b1 --- /dev/null +++ b/src/Sql/dbo/Pam/Stored Procedures/PamRotationAttempt_MarkErrored.sql @@ -0,0 +1,79 @@ +CREATE PROCEDURE [dbo].[PamRotationAttempt_MarkErrored] + @AttemptId UNIQUEIDENTIFIER, + @DaemonId UNIQUEIDENTIFIER, + @FailureReason NVARCHAR(500) = NULL, + @SyncState TINYINT, + @Now DATETIME2(7), + @MaxAttempts INT, + @RetryBaseDelaySeconds INT +AS +BEGIN + SET NOCOUNT ON + -- RecordRotationFailed -> RetryJob / FailJob. @FailureReason is already bounded/truncated by the caller before + -- this call (the zero-knowledge failure-reason contract forbids forwarding raw target-system error output), so + -- this sproc only stores it. Guard failure (unknown/stale attempt, wrong claimant, or the job already moved on) + -- takes the RejectStaleFailureReport path -- the caller audits report_rejected, nothing changes. The result shape + -- mirrors PamRotationFailureResult (Outcome + JobStatus + ErroredAttemptCount) on every path, success or not. + -- XACT_ABORT guarantees rollback (and a clean pooled connection) on any error. + SET XACT_ABORT ON + + BEGIN TRANSACTION + + DECLARE @JobId UNIQUEIDENTIFIER + + SELECT @JobId = J.[Id] + FROM [dbo].[PamRotationAttempt] AT + INNER JOIN [dbo].[PamRotationJob] J WITH (UPDLOCK) ON J.[Id] = AT.[JobId] + WHERE AT.[Id] = @AttemptId + AND AT.[Status] = 0 -- Executing + AND AT.[ClaimedByDaemonId] = @DaemonId + AND J.[Status] = 1 -- Claimed + + IF @JobId IS NULL + BEGIN + ROLLBACK TRANSACTION + SELECT 0 AS [Outcome], NULL AS [JobStatus], NULL AS [ErroredAttemptCount] -- Rejected + RETURN + END + + UPDATE [dbo].[PamRotationAttempt] + SET [Status] = 2, -- Errored + [FailureReason] = @FailureReason, + [SyncState] = @SyncState, + [ResolvedDate] = @Now + WHERE [Id] = @AttemptId + + -- Retry-budget math: only Errored attempts count (Abandoned -- released/timed-out tries -- are never charged + -- against the budget, per the plan's success-wins-on-timeout+release semantics). + DECLARE @ErroredCount INT + + SELECT @ErroredCount = COUNT(*) + FROM [dbo].[PamRotationAttempt] + WHERE [JobId] = @JobId AND [Status] = 2 -- Errored + + DECLARE @JobStatus TINYINT + + IF @ErroredCount < @MaxAttempts + BEGIN + SET @JobStatus = 0 -- Pending + UPDATE [dbo].[PamRotationJob] + SET [Status] = @JobStatus, + [ClaimedByDaemonId] = NULL, + [ClaimedAt] = NULL, + [NextClaimableAt] = DATEADD(SECOND, CAST(@RetryBaseDelaySeconds * POWER(2, @ErroredCount - 1) AS INT), @Now) + WHERE [Id] = @JobId + END + ELSE + BEGIN + SET @JobStatus = 3 -- Failed + UPDATE [dbo].[PamRotationJob] + SET [Status] = @JobStatus, + [ClaimedByDaemonId] = NULL, + [ClaimedAt] = NULL + WHERE [Id] = @JobId + END + + COMMIT TRANSACTION + + SELECT 1 AS [Outcome], @JobStatus AS [JobStatus], @ErroredCount AS [ErroredAttemptCount] -- Resolved +END diff --git a/src/Sql/dbo/Pam/Stored Procedures/PamRotationAttempt_MarkRotated.sql b/src/Sql/dbo/Pam/Stored Procedures/PamRotationAttempt_MarkRotated.sql new file mode 100644 index 000000000000..0d4bbc0c85b2 --- /dev/null +++ b/src/Sql/dbo/Pam/Stored Procedures/PamRotationAttempt_MarkRotated.sql @@ -0,0 +1,53 @@ +CREATE PROCEDURE [dbo].[PamRotationAttempt_MarkRotated] + @AttemptId UNIQUEIDENTIFIER, + @DaemonId UNIQUEIDENTIFIER, + @SessionTermination TINYINT, + @Now DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + -- RecordRotationSucceeded -> MarkJobSucceeded. CipherUpdated = 1 is the VerifiedBeforeSuccess backstop: a success + -- report cannot resolve an attempt whose cipher write was never accepted. Guard failure (unknown/stale attempt, + -- wrong claimant, no cipher write, or the job already moved on) takes the RejectStaleSuccess path -- the caller + -- audits report_rejected, nothing changes. XACT_ABORT guarantees rollback (and a clean pooled connection) on any + -- error. + SET XACT_ABORT ON + + BEGIN TRANSACTION + + DECLARE @JobId UNIQUEIDENTIFIER + + SELECT @JobId = J.[Id] + FROM [dbo].[PamRotationAttempt] AT + INNER JOIN [dbo].[PamRotationJob] J WITH (UPDLOCK) ON J.[Id] = AT.[JobId] + WHERE AT.[Id] = @AttemptId + AND AT.[Status] = 0 -- Executing + AND AT.[ClaimedByDaemonId] = @DaemonId + AND AT.[CipherUpdated] = 1 + AND J.[Status] = 1 -- Claimed + + IF @JobId IS NULL + BEGIN + ROLLBACK TRANSACTION + SELECT 0 -- Rejected + RETURN + END + + UPDATE [dbo].[PamRotationAttempt] + SET [Status] = 1, -- Rotated + [SessionTermination] = @SessionTermination, + [ResolvedDate] = @Now + WHERE [Id] = @AttemptId + + -- Every transition out of Claimed nulls the claim fields; the executing daemon's identity for this try is + -- already permanently recorded on the attempt above. + UPDATE [dbo].[PamRotationJob] + SET [Status] = 2, -- Succeeded + [ClaimedByDaemonId] = NULL, + [ClaimedAt] = NULL + WHERE [Id] = @JobId + + COMMIT TRANSACTION + + SELECT 1 -- Resolved +END diff --git a/src/Sql/dbo/Pam/Stored Procedures/PamRotationAttempt_ReadById.sql b/src/Sql/dbo/Pam/Stored Procedures/PamRotationAttempt_ReadById.sql new file mode 100644 index 000000000000..7c096de9c26a --- /dev/null +++ b/src/Sql/dbo/Pam/Stored Procedures/PamRotationAttempt_ReadById.sql @@ -0,0 +1,10 @@ +CREATE PROCEDURE [dbo].[PamRotationAttempt_ReadById] + @Id UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT * + FROM [dbo].[PamRotationAttempt] + WHERE [Id] = @Id +END diff --git a/src/Sql/dbo/Pam/Stored Procedures/PamRotationConfig_AnyByTargetSystemWithTerminateSessions.sql b/src/Sql/dbo/Pam/Stored Procedures/PamRotationConfig_AnyByTargetSystemWithTerminateSessions.sql new file mode 100644 index 000000000000..83517f0a8f2c --- /dev/null +++ b/src/Sql/dbo/Pam/Stored Procedures/PamRotationConfig_AnyByTargetSystemWithTerminateSessions.sql @@ -0,0 +1,12 @@ +CREATE PROCEDURE [dbo].[PamRotationConfig_AnyByTargetSystemWithTerminateSessions] + @TargetSystemId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + -- UpdateTargetSystemPolicyCommand's capability-withdrawal guard: SupportsSessionTermination may only be turned + -- off when no config on the target still opts into TerminateSessions. + SELECT 1 + FROM [dbo].[PamRotationConfig] + WHERE [TargetSystemId] = @TargetSystemId AND [TerminateSessions] = 1 +END diff --git a/src/Sql/dbo/Pam/Stored Procedures/PamRotationConfig_Create.sql b/src/Sql/dbo/Pam/Stored Procedures/PamRotationConfig_Create.sql new file mode 100644 index 000000000000..0fa5f10727dd --- /dev/null +++ b/src/Sql/dbo/Pam/Stored Procedures/PamRotationConfig_Create.sql @@ -0,0 +1,51 @@ +CREATE PROCEDURE [dbo].[PamRotationConfig_Create] + @Id UNIQUEIDENTIFIER OUTPUT, + @OrganizationId UNIQUEIDENTIFIER, + @CipherId UNIQUEIDENTIFIER, + @TargetSystemId UNIQUEIDENTIFIER, + @AccountIdentity NVARCHAR(500), + @TerminateSessions BIT, + @ScheduleCron NVARCHAR(100) = NULL, + @RotateOnAccessEnd BIT, + @NextRotationAt DATETIME2(7) = NULL, + @Enabled BIT, + @LastRotationAt DATETIME2(7) = NULL, + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + INSERT INTO [dbo].[PamRotationConfig] + ( + [Id], + [OrganizationId], + [CipherId], + [TargetSystemId], + [AccountIdentity], + [TerminateSessions], + [ScheduleCron], + [RotateOnAccessEnd], + [NextRotationAt], + [Enabled], + [LastRotationAt], + [CreationDate], + [RevisionDate] + ) + VALUES + ( + @Id, + @OrganizationId, + @CipherId, + @TargetSystemId, + @AccountIdentity, + @TerminateSessions, + @ScheduleCron, + @RotateOnAccessEnd, + @NextRotationAt, + @Enabled, + @LastRotationAt, + @CreationDate, + @RevisionDate + ) +END diff --git a/src/Sql/dbo/Pam/Stored Procedures/PamRotationConfig_DeleteWithJobs.sql b/src/Sql/dbo/Pam/Stored Procedures/PamRotationConfig_DeleteWithJobs.sql new file mode 100644 index 000000000000..61fa9829c6aa --- /dev/null +++ b/src/Sql/dbo/Pam/Stored Procedures/PamRotationConfig_DeleteWithJobs.sql @@ -0,0 +1,26 @@ +CREATE PROCEDURE [dbo].[PamRotationConfig_DeleteWithJobs] + @Id UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + -- DeleteRotationConfigCommand's cascade: the audit trail (AccessAuditEvent) is the durable history of a config's + -- rotations, so jobs/attempts are hard-deleted here rather than soft-retired. Order matters -- attempts reference + -- jobs, jobs reference the config, and both FKs are ON DELETE NO ACTION -- so children must go first. The caller + -- has already confirmed the config has no active job. + SET XACT_ABORT ON + + BEGIN TRANSACTION + + DELETE A + FROM [dbo].[PamRotationAttempt] A + INNER JOIN [dbo].[PamRotationJob] J ON J.[Id] = A.[JobId] + WHERE J.[RotationConfigId] = @Id + + DELETE FROM [dbo].[PamRotationJob] + WHERE [RotationConfigId] = @Id + + DELETE FROM [dbo].[PamRotationConfig] + WHERE [Id] = @Id + + COMMIT TRANSACTION +END diff --git a/src/Sql/dbo/Pam/Stored Procedures/PamRotationConfig_ReadByCipherId.sql b/src/Sql/dbo/Pam/Stored Procedures/PamRotationConfig_ReadByCipherId.sql new file mode 100644 index 000000000000..01fa76b2d791 --- /dev/null +++ b/src/Sql/dbo/Pam/Stored Procedures/PamRotationConfig_ReadByCipherId.sql @@ -0,0 +1,11 @@ +CREATE PROCEDURE [dbo].[PamRotationConfig_ReadByCipherId] + @CipherId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + -- OneConfigPerCipher: at most one row can ever match. + SELECT * + FROM [dbo].[PamRotationConfig] + WHERE [CipherId] = @CipherId +END diff --git a/src/Sql/dbo/Pam/Stored Procedures/PamRotationConfig_ReadById.sql b/src/Sql/dbo/Pam/Stored Procedures/PamRotationConfig_ReadById.sql new file mode 100644 index 000000000000..028ff44cf177 --- /dev/null +++ b/src/Sql/dbo/Pam/Stored Procedures/PamRotationConfig_ReadById.sql @@ -0,0 +1,10 @@ +CREATE PROCEDURE [dbo].[PamRotationConfig_ReadById] + @Id UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT * + FROM [dbo].[PamRotationConfig] + WHERE [Id] = @Id +END diff --git a/src/Sql/dbo/Pam/Stored Procedures/PamRotationConfig_ReadDetailsById.sql b/src/Sql/dbo/Pam/Stored Procedures/PamRotationConfig_ReadDetailsById.sql new file mode 100644 index 000000000000..fb77dc4e0b32 --- /dev/null +++ b/src/Sql/dbo/Pam/Stored Procedures/PamRotationConfig_ReadDetailsById.sql @@ -0,0 +1,22 @@ +CREATE PROCEDURE [dbo].[PamRotationConfig_ReadDetailsById] + @Id UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + -- The config detail page's header projection (IPamRotationConfigRepository.GetDetailsByIdAsync): the target's + -- display name/method denormalized, plus a computed HasActiveJob so the caller can gate Delete/UpdateAccount + -- without a second round trip. "Active" mirrors PamRotationJob_Create's guard: Pending or Claimed. + SELECT + C.*, + T.[Name] AS [TargetSystemName], + T.[Method] AS [TargetSystemMethod], + CASE WHEN EXISTS ( + SELECT 1 + FROM [dbo].[PamRotationJob] J + WHERE J.[RotationConfigId] = C.[Id] AND J.[Status] IN (0, 1) -- Pending, Claimed + ) THEN 1 ELSE 0 END AS [HasActiveJob] + FROM [dbo].[PamRotationConfig] C + INNER JOIN [dbo].[PamTargetSystem] T ON T.[Id] = C.[TargetSystemId] + WHERE C.[Id] = @Id +END diff --git a/src/Sql/dbo/Pam/Stored Procedures/PamRotationConfig_ReadManyByOrganizationId.sql b/src/Sql/dbo/Pam/Stored Procedures/PamRotationConfig_ReadManyByOrganizationId.sql new file mode 100644 index 000000000000..7d153cb7aaef --- /dev/null +++ b/src/Sql/dbo/Pam/Stored Procedures/PamRotationConfig_ReadManyByOrganizationId.sql @@ -0,0 +1,22 @@ +CREATE PROCEDURE [dbo].[PamRotationConfig_ReadManyByOrganizationId] + @OrganizationId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + -- The schedule-list view: every config for the org, with the target's display name/method denormalized (so the + -- client avoids an N+1) and a computed HasActiveJob so the UI can gate Delete/UpdateAccount without a second + -- round trip. "Active" mirrors PamRotationJob_Create's guard: Pending or Claimed. + SELECT + C.*, + T.[Name] AS [TargetSystemName], + T.[Method] AS [TargetSystemMethod], + CASE WHEN EXISTS ( + SELECT 1 + FROM [dbo].[PamRotationJob] J + WHERE J.[RotationConfigId] = C.[Id] AND J.[Status] IN (0, 1) -- Pending, Claimed + ) THEN 1 ELSE 0 END AS [HasActiveJob] + FROM [dbo].[PamRotationConfig] C + INNER JOIN [dbo].[PamTargetSystem] T ON T.[Id] = C.[TargetSystemId] + WHERE C.[OrganizationId] = @OrganizationId +END diff --git a/src/Sql/dbo/Pam/Stored Procedures/PamRotationConfig_ReadManyDue.sql b/src/Sql/dbo/Pam/Stored Procedures/PamRotationConfig_ReadManyDue.sql new file mode 100644 index 000000000000..75bfb9687733 --- /dev/null +++ b/src/Sql/dbo/Pam/Stored Procedures/PamRotationConfig_ReadManyDue.sql @@ -0,0 +1,24 @@ +CREATE PROCEDURE [dbo].[PamRotationConfig_ReadManyDue] + @Now DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + -- The sweep's due phase (spec RotationDue): enabled, automatic, active-target configs whose schedule has come + -- due, with no job already in flight (OfferRotation is the single creation point -- this feeds it, one + -- OfferRotationCommand call per row). Enabled + NextRotationAt IS NOT NULL matches + -- [IX_PamRotationConfig_NextRotationAt] so the scan is a narrow range seek, not a table scan. + SELECT C.* + FROM [dbo].[PamRotationConfig] C + INNER JOIN [dbo].[PamTargetSystem] T ON T.[Id] = C.[TargetSystemId] + WHERE C.[Enabled] = 1 + AND C.[NextRotationAt] IS NOT NULL + AND C.[NextRotationAt] <= @Now + AND T.[Method] = 0 -- Automatic + AND T.[Status] = 0 -- Active + AND NOT EXISTS ( + SELECT 1 + FROM [dbo].[PamRotationJob] J + WHERE J.[RotationConfigId] = C.[Id] AND J.[Status] IN (0, 1) -- Pending, Claimed + ) +END diff --git a/src/Sql/dbo/Pam/Stored Procedures/PamRotationConfig_Update.sql b/src/Sql/dbo/Pam/Stored Procedures/PamRotationConfig_Update.sql new file mode 100644 index 000000000000..0e0c945d92f9 --- /dev/null +++ b/src/Sql/dbo/Pam/Stored Procedures/PamRotationConfig_Update.sql @@ -0,0 +1,36 @@ +CREATE PROCEDURE [dbo].[PamRotationConfig_Update] + @Id UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @CipherId UNIQUEIDENTIFIER, + @TargetSystemId UNIQUEIDENTIFIER, + @AccountIdentity NVARCHAR(500), + @TerminateSessions BIT, + @ScheduleCron NVARCHAR(100) = NULL, + @RotateOnAccessEnd BIT, + @NextRotationAt DATETIME2(7) = NULL, + @Enabled BIT, + @LastRotationAt DATETIME2(7) = NULL, + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + UPDATE + [dbo].[PamRotationConfig] + SET + [OrganizationId] = @OrganizationId, + [CipherId] = @CipherId, + [TargetSystemId] = @TargetSystemId, + [AccountIdentity] = @AccountIdentity, + [TerminateSessions] = @TerminateSessions, + [ScheduleCron] = @ScheduleCron, + [RotateOnAccessEnd] = @RotateOnAccessEnd, + [NextRotationAt] = @NextRotationAt, + [Enabled] = @Enabled, + [LastRotationAt] = @LastRotationAt, + [CreationDate] = @CreationDate, + [RevisionDate] = @RevisionDate + WHERE + [Id] = @Id +END diff --git a/src/Sql/dbo/Pam/Stored Procedures/PamRotationJob_Claim.sql b/src/Sql/dbo/Pam/Stored Procedures/PamRotationJob_Claim.sql new file mode 100644 index 000000000000..da4b290dd42c --- /dev/null +++ b/src/Sql/dbo/Pam/Stored Procedures/PamRotationJob_Claim.sql @@ -0,0 +1,106 @@ +CREATE PROCEDURE [dbo].[PamRotationJob_Claim] + @JobId UNIQUEIDENTIFIER, + @AttemptId UNIQUEIDENTIFIER, + @DaemonId UNIQUEIDENTIFIER, + @Now DATETIME2(7), + @ReleaseDelaySeconds INT +AS +BEGIN + SET NOCOUNT ON + -- First-claim-wins is enforced by the UPDATE's own WHERE J.Status = 0 clause: SQL Server takes the row lock + -- needed to satisfy that predicate as part of the UPDATE itself, so two concurrent claims of the same job + -- serialize on the row and only the first can flip Status Pending -> Claimed. The result shape mirrors + -- PamRotationClaimResult exactly (an Outcome column plus the work-snapshot columns, null on any non-Claimed + -- outcome) so the caller can map every path with a single row read. XACT_ABORT guarantees rollback (and a clean + -- pooled connection) on any error. + SET XACT_ABORT ON + + BEGIN TRANSACTION + + UPDATE J + SET J.[Status] = 1, -- Claimed + J.[ClaimedByDaemonId] = @DaemonId, + J.[ClaimedAt] = @Now + FROM [dbo].[PamRotationJob] J + INNER JOIN [dbo].[PamRotationConfig] C ON C.[Id] = J.[RotationConfigId] + INNER JOIN [dbo].[PamTargetSystem] T ON T.[Id] = C.[TargetSystemId] + INNER JOIN [dbo].[PamDaemonTargetAssignment] A ON A.[DaemonId] = @DaemonId AND A.[TargetSystemId] = C.[TargetSystemId] + -- Defense in depth: the daemon must be Enrolled AND in the same org as the config, even though the caller + -- (ClaimRotationJobCommand) already checked both from the bearer token's claims. + INNER JOIN [dbo].[PamDaemon] D ON D.[Id] = @DaemonId AND D.[OrganizationId] = C.[OrganizationId] AND D.[Status] = 0 -- Enrolled + WHERE J.[Id] = @JobId + AND J.[Status] = 0 -- Pending + AND J.[NextClaimableAt] <= @Now + AND C.[Enabled] = 1 + AND T.[Status] = 0 -- Active + + IF @@ROWCOUNT = 0 + BEGIN + -- Eligibility is classified FIRST so a job that does not exist and a job this daemon may not claim + -- (unassigned target, cross-org, revoked daemon) produce the same NotEligible outcome -- the caller maps it + -- to 404, leaving no existence oracle. Only an eligible daemon that lost the race / hit backoff / hit the + -- paused-config or disabled-target hold gets NotClaimable (mapped to 409). + DECLARE @Outcome INT = CASE + WHEN NOT EXISTS ( + SELECT 1 + FROM [dbo].[PamRotationJob] J2 + INNER JOIN [dbo].[PamRotationConfig] C2 ON C2.[Id] = J2.[RotationConfigId] + INNER JOIN [dbo].[PamDaemonTargetAssignment] A2 ON A2.[DaemonId] = @DaemonId AND A2.[TargetSystemId] = C2.[TargetSystemId] + INNER JOIN [dbo].[PamDaemon] D2 ON D2.[Id] = @DaemonId AND D2.[OrganizationId] = C2.[OrganizationId] AND D2.[Status] = 0 -- Enrolled + WHERE J2.[Id] = @JobId + ) THEN -1 -- NotEligible (unknown job, or a job outside this daemon's assignment/org) + ELSE 0 -- NotClaimable (eligible, but not pending / in backoff / held by a paused config or disabled target) + END + + ROLLBACK TRANSACTION + + SELECT + @Outcome AS [Outcome], + CAST(NULL AS UNIQUEIDENTIFIER) AS [AttemptId], + CAST(NULL AS UNIQUEIDENTIFIER) AS [JobId], + CAST(NULL AS TINYINT) AS [Source], + CAST(NULL AS UNIQUEIDENTIFIER) AS [TargetSystemId], + CAST(NULL AS NVARCHAR(200)) AS [TargetSystemName], + CAST(NULL AS TINYINT) AS [Kind], + CAST(NULL AS NVARCHAR(2000)) AS [PasswordPolicy], + CAST(NULL AS UNIQUEIDENTIFIER) AS [CipherId], + CAST(NULL AS NVARCHAR(500)) AS [AccountIdentity], + CAST(NULL AS BIT) AS [TerminateSessions], + CAST(NULL AS DATETIME2(7)) AS [ExecuteBy] + RETURN + END + + -- AtMostOneInFlightAttemptPerJob: the Executing attempt is created in the same transaction as the claim, so a + -- claimed job always has exactly one in-flight attempt from the moment it is claimed. + INSERT INTO [dbo].[PamRotationAttempt] + ( + [Id], [JobId], [ClaimedByDaemonId], [CipherUpdated], [Status], [FailureReason], [SyncState], + [SessionTermination], [CreationDate], [ResolvedDate] + ) + VALUES + ( + @AttemptId, @JobId, @DaemonId, 0, 0 /* Executing */, NULL, NULL, + NULL, @Now, NULL + ) + + COMMIT TRANSACTION + + -- The work snapshot the daemon executes against; ExecuteBy is this claim's lease end (ClaimedAt + ReleaseDelay). + SELECT + 1 AS [Outcome], -- Claimed + @AttemptId AS [AttemptId], + J.[Id] AS [JobId], + J.[Source], + T.[Id] AS [TargetSystemId], + T.[Name] AS [TargetSystemName], + T.[Kind], + T.[PasswordPolicy], + C.[CipherId], + C.[AccountIdentity], + C.[TerminateSessions], + DATEADD(SECOND, @ReleaseDelaySeconds, @Now) AS [ExecuteBy] + FROM [dbo].[PamRotationJob] J + INNER JOIN [dbo].[PamRotationConfig] C ON C.[Id] = J.[RotationConfigId] + INNER JOIN [dbo].[PamTargetSystem] T ON T.[Id] = C.[TargetSystemId] + WHERE J.[Id] = @JobId +END diff --git a/src/Sql/dbo/Pam/Stored Procedures/PamRotationJob_Create.sql b/src/Sql/dbo/Pam/Stored Procedures/PamRotationJob_Create.sql new file mode 100644 index 000000000000..f39d9d5d070e --- /dev/null +++ b/src/Sql/dbo/Pam/Stored Procedures/PamRotationJob_Create.sql @@ -0,0 +1,71 @@ +CREATE PROCEDURE [dbo].[PamRotationJob_Create] + @Id UNIQUEIDENTIFIER, + @RotationConfigId UNIQUEIDENTIFIER, + @Source TINYINT, + @Status TINYINT, + @ClaimedByDaemonId UNIQUEIDENTIFIER = NULL, + @ClaimedAt DATETIME2(7) = NULL, + @CreationDate DATETIME2(7), + @NextClaimableAt DATETIME2(7), + @ExpiresAt DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + -- IPamRotationJobRepository.CreateGuardedAsync passes an already fully-populated PamRotationJob (Status = + -- Pending, claim fields null, NextClaimableAt/ExpiresAt already computed by the caller) -- this sproc only + -- re-validates can_offer's eligibility half and the AtMostOneActiveJobPerConfig guard before inserting it as-is + -- (spec OfferRotation's single creation point). An explicit transaction is required so the range lock below is + -- held until the INSERT commits; XACT_ABORT guarantees rollback (and a clean pooled connection) on any error. + SET XACT_ABORT ON + + BEGIN TRANSACTION + + -- can_offer's eligibility half, re-checked here (not just by the caller) so a config disabled or a target + -- disabled/switched to Manual between the caller's read and this write cannot mint a job. Outcome -1 + -- (ConfigNotOfferable) is distinct from the active-job conflict (0, ActiveJobExists) so the caller can tell + -- "not offerable" apart from "already has one". + IF NOT EXISTS ( + SELECT 1 + FROM [dbo].[PamRotationConfig] C WITH (UPDLOCK, HOLDLOCK) + INNER JOIN [dbo].[PamTargetSystem] T ON T.[Id] = C.[TargetSystemId] + WHERE C.[Id] = @RotationConfigId + AND C.[Enabled] = 1 + AND T.[Method] = 0 -- Automatic + AND T.[Status] = 0 -- Active + ) + BEGIN + ROLLBACK TRANSACTION + SELECT -1 -- ConfigNotOfferable + RETURN + END + + -- AtMostOneActiveJobPerConfig. The UPDLOCK, HOLDLOCK range lock on [IX_PamRotationJob_RotationConfigId_Status] is + -- held for the life of this transaction, so a concurrent creation attempt for the same config blocks here until + -- this transaction commits, then sees the new job and is rejected. + IF EXISTS ( + SELECT 1 + FROM [dbo].[PamRotationJob] WITH (UPDLOCK, HOLDLOCK) + WHERE [RotationConfigId] = @RotationConfigId + AND [Status] IN (0, 1) -- Pending, Claimed + ) + BEGIN + ROLLBACK TRANSACTION + SELECT 0 -- ActiveJobExists + RETURN + END + + INSERT INTO [dbo].[PamRotationJob] + ( + [Id], [RotationConfigId], [Source], [Status], [ClaimedByDaemonId], [ClaimedAt], + [CreationDate], [NextClaimableAt], [ExpiresAt] + ) + VALUES + ( + @Id, @RotationConfigId, @Source, @Status, @ClaimedByDaemonId, @ClaimedAt, + @CreationDate, @NextClaimableAt, @ExpiresAt + ) + + COMMIT TRANSACTION + + SELECT 1 -- Created +END diff --git a/src/Sql/dbo/Pam/Stored Procedures/PamRotationJob_ReadById.sql b/src/Sql/dbo/Pam/Stored Procedures/PamRotationJob_ReadById.sql new file mode 100644 index 000000000000..45055645bdd2 --- /dev/null +++ b/src/Sql/dbo/Pam/Stored Procedures/PamRotationJob_ReadById.sql @@ -0,0 +1,10 @@ +CREATE PROCEDURE [dbo].[PamRotationJob_ReadById] + @Id UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT * + FROM [dbo].[PamRotationJob] + WHERE [Id] = @Id +END diff --git a/src/Sql/dbo/Pam/Stored Procedures/PamRotationJob_ReadManyByConfigId.sql b/src/Sql/dbo/Pam/Stored Procedures/PamRotationJob_ReadManyByConfigId.sql new file mode 100644 index 000000000000..2b6923976764 --- /dev/null +++ b/src/Sql/dbo/Pam/Stored Procedures/PamRotationJob_ReadManyByConfigId.sql @@ -0,0 +1,21 @@ +CREATE PROCEDURE [dbo].[PamRotationJob_ReadManyByConfigId] + @RotationConfigId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + -- The config detail page's attempt history, returned as two result sets so the caller can zip each job to its + -- attempts (IPamRotationJobRepository.GetManyByConfigIdAsync, grouping the second set by JobId) without an N+1: + -- 1) every job for the config, newest first. + -- 2) every attempt belonging to those jobs, oldest-first within a job. + SELECT * + FROM [dbo].[PamRotationJob] + WHERE [RotationConfigId] = @RotationConfigId + ORDER BY [CreationDate] DESC + + SELECT A.* + FROM [dbo].[PamRotationAttempt] A + INNER JOIN [dbo].[PamRotationJob] J ON J.[Id] = A.[JobId] + WHERE J.[RotationConfigId] = @RotationConfigId + ORDER BY A.[JobId], A.[CreationDate] ASC +END diff --git a/src/Sql/dbo/Pam/Stored Procedures/PamRotationJob_ReadManyClaimableByDaemonId.sql b/src/Sql/dbo/Pam/Stored Procedures/PamRotationJob_ReadManyClaimableByDaemonId.sql new file mode 100644 index 000000000000..52557b370312 --- /dev/null +++ b/src/Sql/dbo/Pam/Stored Procedures/PamRotationJob_ReadManyClaimableByDaemonId.sql @@ -0,0 +1,22 @@ +CREATE PROCEDURE [dbo].[PamRotationJob_ReadManyClaimableByDaemonId] + @DaemonId UNIQUEIDENTIFIER, + @Now DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + -- The daemon poll: jobs this daemon may claim right now. Re-derives every eligibility condition + -- PamRotationJob_Claim itself re-checks (config enabled, target active, an assignment exists, and -- defense in + -- depth -- the daemon's own org matches the config's org) so the list a daemon sees and what it can actually + -- claim never diverge. + SELECT J.* + FROM [dbo].[PamRotationJob] J + INNER JOIN [dbo].[PamRotationConfig] C ON C.[Id] = J.[RotationConfigId] + INNER JOIN [dbo].[PamTargetSystem] T ON T.[Id] = C.[TargetSystemId] + INNER JOIN [dbo].[PamDaemonTargetAssignment] A ON A.[DaemonId] = @DaemonId AND A.[TargetSystemId] = C.[TargetSystemId] + INNER JOIN [dbo].[PamDaemon] D ON D.[Id] = @DaemonId AND D.[OrganizationId] = C.[OrganizationId] + WHERE J.[Status] = 0 -- Pending + AND J.[NextClaimableAt] <= @Now + AND C.[Enabled] = 1 + AND T.[Status] = 0 -- Active +END diff --git a/src/Sql/dbo/Pam/Stored Procedures/PamRotationJob_ReleaseExpiredLeases.sql b/src/Sql/dbo/Pam/Stored Procedures/PamRotationJob_ReleaseExpiredLeases.sql new file mode 100644 index 000000000000..11c627998af2 --- /dev/null +++ b/src/Sql/dbo/Pam/Stored Procedures/PamRotationJob_ReleaseExpiredLeases.sql @@ -0,0 +1,64 @@ +CREATE PROCEDURE [dbo].[PamRotationJob_ReleaseExpiredLeases] + @Now DATETIME2(7), + @OfflineAfterSeconds INT, + @ReleaseDelaySeconds INT +AS +BEGIN + SET NOCOUNT ON + -- DaemonConnectionDropsReleaseJobs -> ReleaseJob -> AbandonAttempt, with the lease-respecting timing from plan §5: + -- release only fires once BOTH the claim's lease has expired (now >= ExecuteBy, i.e. ClaimedAt + ReleaseDelay) AND + -- the claimant's heartbeat is stale -- never on daemon Status alone, so a revoked daemon's jobs release too once + -- its heartbeats actually stop. A job with a Rotated attempt is excluded ("success wins", same as the timeout + -- sweep): a slow-but-live daemon whose report lands inside its lease still wins. OUTPUT can't reach through the + -- joins needed for the audit projection, so affected ids are captured in @Affected first and joined afterward. + -- XACT_ABORT guarantees rollback (and a clean pooled connection) on any error. + SET XACT_ABORT ON + + BEGIN TRANSACTION + + DECLARE @Affected TABLE ( + [JobId] UNIQUEIDENTIFIER NOT NULL, + [PreviousClaimedByDaemonId] UNIQUEIDENTIFIER NULL + ) + + UPDATE J + SET J.[Status] = 0, -- Pending + -- Computed from the pre-clear ClaimedAt (this UPDATE's FROM/JOIN still sees the old value here), so the + -- re-claim time is exactly ExecuteBy regardless of whether release fires at that instant or later. + J.[NextClaimableAt] = DATEADD(SECOND, @ReleaseDelaySeconds, J.[ClaimedAt]), + J.[ClaimedByDaemonId] = NULL, + J.[ClaimedAt] = NULL + OUTPUT deleted.[Id], deleted.[ClaimedByDaemonId] INTO @Affected ([JobId], [PreviousClaimedByDaemonId]) + FROM [dbo].[PamRotationJob] J + INNER JOIN [dbo].[PamDaemon] D ON D.[Id] = J.[ClaimedByDaemonId] + WHERE J.[Status] = 1 -- Claimed + AND DATEADD(SECOND, @ReleaseDelaySeconds, J.[ClaimedAt]) <= @Now + AND (D.[LastHeartbeatAt] IS NULL OR D.[LastHeartbeatAt] < DATEADD(SECOND, -@OfflineAfterSeconds, @Now)) + AND NOT EXISTS ( + SELECT 1 + FROM [dbo].[PamRotationAttempt] AT + WHERE AT.[JobId] = J.[Id] AND AT.[Status] = 1 -- Rotated + ) + + -- Abandoned attempts are never charged against the retry budget. + UPDATE [dbo].[PamRotationAttempt] + SET [Status] = 3, -- Abandoned + [ResolvedDate] = @Now + WHERE [JobId] IN (SELECT [JobId] FROM @Affected) + AND [Status] = 0 -- Executing + + -- One row per released job for audit emission. ClaimedByDaemonId here is the pre-clear claimant (always + -- non-null: only Claimed jobs are released). + SELECT + AF.[JobId], + C.[Id] AS [RotationConfigId], + C.[OrganizationId], + C.[CipherId], + J.[Source], + AF.[PreviousClaimedByDaemonId] AS [ClaimedByDaemonId] + FROM @Affected AF + INNER JOIN [dbo].[PamRotationJob] J ON J.[Id] = AF.[JobId] + INNER JOIN [dbo].[PamRotationConfig] C ON C.[Id] = J.[RotationConfigId] + + COMMIT TRANSACTION +END diff --git a/src/Sql/dbo/Pam/Stored Procedures/PamRotationJob_TimeoutDue.sql b/src/Sql/dbo/Pam/Stored Procedures/PamRotationJob_TimeoutDue.sql new file mode 100644 index 000000000000..541c8d540bb2 --- /dev/null +++ b/src/Sql/dbo/Pam/Stored Procedures/PamRotationJob_TimeoutDue.sql @@ -0,0 +1,58 @@ +CREATE PROCEDURE [dbo].[PamRotationJob_TimeoutDue] + @Now DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + -- JobTimesOut ("success wins"): a job with a Rotated attempt is excluded even if it is otherwise past ExpiresAt -- + -- a slow-but-successful report still wins the race against the timeout sweep. OUTPUT can't reach through the + -- joins needed for the audit projection (config/org/cipher), so affected ids are captured in @Affected first and + -- joined afterward. The job update and its attempt's Abandoned transition commit together so a crash between the + -- two can never leave a stale Executing attempt behind a job that already moved on. XACT_ABORT guarantees + -- rollback (and a clean pooled connection) on any error. + SET XACT_ABORT ON + + BEGIN TRANSACTION + + DECLARE @Affected TABLE ( + [JobId] UNIQUEIDENTIFIER NOT NULL, + [PreviousClaimedByDaemonId] UNIQUEIDENTIFIER NULL + ) + + UPDATE J + SET J.[Status] = 4, -- TimedOut + J.[ClaimedByDaemonId] = NULL, + J.[ClaimedAt] = NULL + OUTPUT deleted.[Id], deleted.[ClaimedByDaemonId] INTO @Affected ([JobId], [PreviousClaimedByDaemonId]) + FROM [dbo].[PamRotationJob] J + WHERE J.[Status] IN (0, 1) -- Pending, Claimed + AND J.[ExpiresAt] <= @Now + AND NOT EXISTS ( + SELECT 1 + FROM [dbo].[PamRotationAttempt] AT + WHERE AT.[JobId] = J.[Id] AND AT.[Status] = 1 -- Rotated + ) + + -- Abandon the executing attempt (if any) on each timed-out job; a Pending job that never got claimed has none. + -- Abandoned attempts are never charged against the retry budget. + UPDATE [dbo].[PamRotationAttempt] + SET [Status] = 3, -- Abandoned + [ResolvedDate] = @Now + WHERE [JobId] IN (SELECT [JobId] FROM @Affected) + AND [Status] = 0 -- Executing + + -- One row per timed-out job for audit emission; AttemptCount distinguishes unroutable (never claimed, zero + -- attempts) from stuck (claimed at least once). + SELECT + AF.[JobId], + C.[Id] AS [RotationConfigId], + C.[OrganizationId], + C.[CipherId], + J.[Source], + AF.[PreviousClaimedByDaemonId] AS [ClaimedByDaemonId], + (SELECT COUNT(*) FROM [dbo].[PamRotationAttempt] AT WHERE AT.[JobId] = AF.[JobId]) AS [AttemptCount] + FROM @Affected AF + INNER JOIN [dbo].[PamRotationJob] J ON J.[Id] = AF.[JobId] + INNER JOIN [dbo].[PamRotationConfig] C ON C.[Id] = J.[RotationConfigId] + + COMMIT TRANSACTION +END diff --git a/src/Sql/dbo/Pam/Stored Procedures/PamTargetSystem_Create.sql b/src/Sql/dbo/Pam/Stored Procedures/PamTargetSystem_Create.sql new file mode 100644 index 000000000000..e9bbda8e0ff6 --- /dev/null +++ b/src/Sql/dbo/Pam/Stored Procedures/PamTargetSystem_Create.sql @@ -0,0 +1,42 @@ +CREATE PROCEDURE [dbo].[PamTargetSystem_Create] + @Id UNIQUEIDENTIFIER OUTPUT, + @OrganizationId UNIQUEIDENTIFIER, + @Name NVARCHAR(200), + @Method TINYINT, + @Kind TINYINT = NULL, + @PasswordPolicy NVARCHAR(2000) = NULL, + @SupportsSessionTermination BIT = NULL, + @Status TINYINT, + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + INSERT INTO [dbo].[PamTargetSystem] + ( + [Id], + [OrganizationId], + [Name], + [Method], + [Kind], + [PasswordPolicy], + [SupportsSessionTermination], + [Status], + [CreationDate], + [RevisionDate] + ) + VALUES + ( + @Id, + @OrganizationId, + @Name, + @Method, + @Kind, + @PasswordPolicy, + @SupportsSessionTermination, + @Status, + @CreationDate, + @RevisionDate + ) +END diff --git a/src/Sql/dbo/Pam/Stored Procedures/PamTargetSystem_DeleteById.sql b/src/Sql/dbo/Pam/Stored Procedures/PamTargetSystem_DeleteById.sql new file mode 100644 index 000000000000..9488dd96d136 --- /dev/null +++ b/src/Sql/dbo/Pam/Stored Procedures/PamTargetSystem_DeleteById.sql @@ -0,0 +1,11 @@ +CREATE PROCEDURE [dbo].[PamTargetSystem_DeleteById] + @Id UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + -- No cascade cleanup here: a target system with rotation configs or daemon assignments still referencing it is + -- blocked by their NO ACTION FKs (detach or delete those first). Deleting an already-gone row is a no-op. + DELETE FROM [dbo].[PamTargetSystem] + WHERE [Id] = @Id +END diff --git a/src/Sql/dbo/Pam/Stored Procedures/PamTargetSystem_ReadById.sql b/src/Sql/dbo/Pam/Stored Procedures/PamTargetSystem_ReadById.sql new file mode 100644 index 000000000000..92588cb3d990 --- /dev/null +++ b/src/Sql/dbo/Pam/Stored Procedures/PamTargetSystem_ReadById.sql @@ -0,0 +1,10 @@ +CREATE PROCEDURE [dbo].[PamTargetSystem_ReadById] + @Id UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT * + FROM [dbo].[PamTargetSystem] + WHERE [Id] = @Id +END diff --git a/src/Sql/dbo/Pam/Stored Procedures/PamTargetSystem_ReadByOrganizationId.sql b/src/Sql/dbo/Pam/Stored Procedures/PamTargetSystem_ReadByOrganizationId.sql new file mode 100644 index 000000000000..38c5a0862418 --- /dev/null +++ b/src/Sql/dbo/Pam/Stored Procedures/PamTargetSystem_ReadByOrganizationId.sql @@ -0,0 +1,10 @@ +CREATE PROCEDURE [dbo].[PamTargetSystem_ReadByOrganizationId] + @OrganizationId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT * + FROM [dbo].[PamTargetSystem] + WHERE [OrganizationId] = @OrganizationId +END diff --git a/src/Sql/dbo/Pam/Stored Procedures/PamTargetSystem_Update.sql b/src/Sql/dbo/Pam/Stored Procedures/PamTargetSystem_Update.sql new file mode 100644 index 000000000000..1a18b2d81e7d --- /dev/null +++ b/src/Sql/dbo/Pam/Stored Procedures/PamTargetSystem_Update.sql @@ -0,0 +1,30 @@ +CREATE PROCEDURE [dbo].[PamTargetSystem_Update] + @Id UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @Name NVARCHAR(200), + @Method TINYINT, + @Kind TINYINT = NULL, + @PasswordPolicy NVARCHAR(2000) = NULL, + @SupportsSessionTermination BIT = NULL, + @Status TINYINT, + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + UPDATE + [dbo].[PamTargetSystem] + SET + [OrganizationId] = @OrganizationId, + [Name] = @Name, + [Method] = @Method, + [Kind] = @Kind, + [PasswordPolicy] = @PasswordPolicy, + [SupportsSessionTermination] = @SupportsSessionTermination, + [Status] = @Status, + [CreationDate] = @CreationDate, + [RevisionDate] = @RevisionDate + WHERE + [Id] = @Id +END diff --git a/src/Sql/dbo/Pam/Tables/PamDaemon.sql b/src/Sql/dbo/Pam/Tables/PamDaemon.sql new file mode 100644 index 000000000000..a3b6dfe24792 --- /dev/null +++ b/src/Sql/dbo/Pam/Tables/PamDaemon.sql @@ -0,0 +1,29 @@ +-- A registered rotation daemon: the credential itself lives on the shared dbo.ApiKey machine-credential store +-- (ApiKeyId, unique -- one credential per daemon), not duplicated here. LastHeartbeatAt is bumped on every daemon +-- poll; there is no separate connection-state table -- "connected" is derived (LastHeartbeatAt within +-- DaemonOfflineAfter of now). +CREATE TABLE [dbo].[PamDaemon] ( + [Id] UNIQUEIDENTIFIER NOT NULL, + [OrganizationId] UNIQUEIDENTIFIER NOT NULL, + [Name] NVARCHAR(200) NOT NULL, + [ApiKeyId] UNIQUEIDENTIFIER NOT NULL, + [Status] TINYINT NOT NULL, + [LastHeartbeatAt] DATETIME2(7) NULL, + [CreationDate] DATETIME2(7) NOT NULL, + [RevisionDate] DATETIME2(7) NOT NULL, + CONSTRAINT [PK_PamDaemon] PRIMARY KEY CLUSTERED ([Id] ASC), + CONSTRAINT [FK_PamDaemon_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]) ON DELETE CASCADE, + -- No cascade: RevokeDaemonCommand deletes the ApiKey row explicitly (SM-style credential revocation) while + -- keeping this row for history/audit -- an implicit cascade here would delete the daemon out from under it. + CONSTRAINT [FK_PamDaemon_ApiKey] FOREIGN KEY ([ApiKeyId]) REFERENCES [dbo].[ApiKey] ([Id]) ON DELETE NO ACTION +); +GO + +-- OneKeyPerDaemon; also the lookup path PamDaemonDetails_ReadByApiKeyId uses at token time. +CREATE UNIQUE NONCLUSTERED INDEX [IX_PamDaemon_ApiKeyId] + ON [dbo].[PamDaemon] ([ApiKeyId] ASC); +GO + +CREATE NONCLUSTERED INDEX [IX_PamDaemon_OrganizationId] + ON [dbo].[PamDaemon] ([OrganizationId] ASC); +GO diff --git a/src/Sql/dbo/Pam/Tables/PamDaemonTargetAssignment.sql b/src/Sql/dbo/Pam/Tables/PamDaemonTargetAssignment.sql new file mode 100644 index 000000000000..99f1a65bc56f --- /dev/null +++ b/src/Sql/dbo/Pam/Tables/PamDaemonTargetAssignment.sql @@ -0,0 +1,29 @@ +-- Which daemon is responsible for rotating credentials on which target system (invariant OneAssignmentPerDaemonTarget). +-- DaemonId and TargetSystemId are deliberately ON DELETE NO ACTION: both already reach Organization via their own +-- cascading FK, and also letting this table cascade from either of them would create multiple cascade paths back to +-- Organization -- a combination SQL Server refuses at CREATE TABLE time. OrganizationId therefore carries the only +-- cascade path, so deleting an organization still removes its assignments; a PamDaemon/PamTargetSystem row that still +-- has an assignment must be detached first (or its own delete will hit this table's NO ACTION FK). +CREATE TABLE [dbo].[PamDaemonTargetAssignment] ( + [Id] UNIQUEIDENTIFIER NOT NULL, + [DaemonId] UNIQUEIDENTIFIER NOT NULL, + [TargetSystemId] UNIQUEIDENTIFIER NOT NULL, + [OrganizationId] UNIQUEIDENTIFIER NOT NULL, + [CreationDate] DATETIME2(7) NOT NULL, + CONSTRAINT [PK_PamDaemonTargetAssignment] PRIMARY KEY CLUSTERED ([Id] ASC), + CONSTRAINT [FK_PamDaemonTargetAssignment_Daemon] FOREIGN KEY ([DaemonId]) REFERENCES [dbo].[PamDaemon] ([Id]) ON DELETE NO ACTION, + CONSTRAINT [FK_PamDaemonTargetAssignment_TargetSystem] FOREIGN KEY ([TargetSystemId]) REFERENCES [dbo].[PamTargetSystem] ([Id]) ON DELETE NO ACTION, + CONSTRAINT [FK_PamDaemonTargetAssignment_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]) ON DELETE CASCADE +); +GO + +-- OneAssignmentPerDaemonTarget. +CREATE UNIQUE NONCLUSTERED INDEX [IX_PamDaemonTargetAssignment_DaemonId_TargetSystemId] + ON [dbo].[PamDaemonTargetAssignment] ([DaemonId] ASC, [TargetSystemId] ASC); +GO + +-- Supports the reverse lookup (which daemons cover a given target) and the claim sproc's join from target -> +-- assignment -> daemon. +CREATE NONCLUSTERED INDEX [IX_PamDaemonTargetAssignment_TargetSystemId] + ON [dbo].[PamDaemonTargetAssignment] ([TargetSystemId] ASC); +GO diff --git a/src/Sql/dbo/Pam/Tables/PamRotationAttempt.sql b/src/Sql/dbo/Pam/Tables/PamRotationAttempt.sql new file mode 100644 index 000000000000..959451609501 --- /dev/null +++ b/src/Sql/dbo/Pam/Tables/PamRotationAttempt.sql @@ -0,0 +1,27 @@ +-- One daemon's try at executing a PamRotationJob (invariant AtMostOneInFlightAttemptPerJob is enforced by +-- PamRotationJob_Claim, which inserts the Executing attempt in the same transaction as the claim). Unlike +-- PamRotationJob.ClaimedByDaemonId, this ClaimedByDaemonId is never cleared -- it is the permanent record of who +-- executed this particular try, kept even after the job moves on. FailureReason is bounded and truncated (never +-- rejected) by the caller before write -- the zero-knowledge failure-reason contract forbids forwarding raw +-- target-system error output, since it can echo credentials -- so this column only ever stores the bounded text. +CREATE TABLE [dbo].[PamRotationAttempt] ( + [Id] UNIQUEIDENTIFIER NOT NULL, + [JobId] UNIQUEIDENTIFIER NOT NULL, + [ClaimedByDaemonId] UNIQUEIDENTIFIER NOT NULL, + [CipherUpdated] BIT NOT NULL, + [Status] TINYINT NOT NULL, + [FailureReason] NVARCHAR(500) NULL, + [SyncState] TINYINT NULL, + [SessionTermination] TINYINT NULL, + [CreationDate] DATETIME2(7) NOT NULL, + [ResolvedDate] DATETIME2(7) NULL, + CONSTRAINT [PK_PamRotationAttempt] PRIMARY KEY CLUSTERED ([Id] ASC), + CONSTRAINT [FK_PamRotationAttempt_RotationJob] FOREIGN KEY ([JobId]) REFERENCES [dbo].[PamRotationJob] ([Id]) +); +GO + +-- PamRotationJob_ReadManyByConfigId's attempt result set, and the "no Rotated attempt" checks in the timeout/release +-- sweeps. +CREATE NONCLUSTERED INDEX [IX_PamRotationAttempt_JobId_Status] + ON [dbo].[PamRotationAttempt] ([JobId] ASC, [Status] ASC); +GO diff --git a/src/Sql/dbo/Pam/Tables/PamRotationConfig.sql b/src/Sql/dbo/Pam/Tables/PamRotationConfig.sql new file mode 100644 index 000000000000..e91c4700cb00 --- /dev/null +++ b/src/Sql/dbo/Pam/Tables/PamRotationConfig.sql @@ -0,0 +1,39 @@ +-- The rotation policy for one managed credential (one cipher, invariant OneConfigPerCipher). AccountIdentity is +-- opaque and never parsed by the server -- it is whatever the target-specific daemon connector needs to locate the +-- account (e.g. a UPN or a DB login name). ScheduleCron is nullable: a config that only rotates on access-end +-- (RotateOnAccessEnd = 1), only on demand, or -- on a manual target -- only via a recorded human rotation, need not +-- carry a periodic schedule. PasswordPolicy is NOT duplicated here -- it lives on the target system, shared by every +-- config that rotates against it. +CREATE TABLE [dbo].[PamRotationConfig] ( + [Id] UNIQUEIDENTIFIER NOT NULL, + [OrganizationId] UNIQUEIDENTIFIER NOT NULL, + [CipherId] UNIQUEIDENTIFIER NOT NULL, + [TargetSystemId] UNIQUEIDENTIFIER NOT NULL, + [AccountIdentity] NVARCHAR(500) NOT NULL, + [TerminateSessions] BIT NOT NULL, + [ScheduleCron] NVARCHAR(100) NULL, + [RotateOnAccessEnd] BIT NOT NULL, + [NextRotationAt] DATETIME2(7) NULL, + [Enabled] BIT NOT NULL, + [LastRotationAt] DATETIME2(7) NULL, + [CreationDate] DATETIME2(7) NOT NULL, + [RevisionDate] DATETIME2(7) NOT NULL, + CONSTRAINT [PK_PamRotationConfig] PRIMARY KEY CLUSTERED ([Id] ASC), + CONSTRAINT [FK_PamRotationConfig_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]) ON DELETE CASCADE, + -- No cascade: a target system with configs against it must be detached (or the configs deleted via + -- PamRotationConfig_DeleteWithJobs) before it can be removed. + CONSTRAINT [FK_PamRotationConfig_TargetSystem] FOREIGN KEY ([TargetSystemId]) REFERENCES [dbo].[PamTargetSystem] ([Id]) ON DELETE NO ACTION +); +GO + +-- OneConfigPerCipher. +CREATE UNIQUE NONCLUSTERED INDEX [IX_PamRotationConfig_CipherId] + ON [dbo].[PamRotationConfig] ([CipherId] ASC); +GO + +-- Backs the due-rotation sweep (PamRotationConfig_ReadManyDue): filtered so paused/one-shot/access-end-only configs +-- never enter the scan. +CREATE NONCLUSTERED INDEX [IX_PamRotationConfig_NextRotationAt] + ON [dbo].[PamRotationConfig] ([NextRotationAt] ASC) + WHERE [Enabled] = 1 AND [NextRotationAt] IS NOT NULL; +GO diff --git a/src/Sql/dbo/Pam/Tables/PamRotationJob.sql b/src/Sql/dbo/Pam/Tables/PamRotationJob.sql new file mode 100644 index 000000000000..33e6b6568ecd --- /dev/null +++ b/src/Sql/dbo/Pam/Tables/PamRotationJob.sql @@ -0,0 +1,37 @@ +-- One offer of rotation work for a config (invariant AtMostOneActiveJobPerConfig -- enforced by PamRotationJob_Create +-- under a range lock, not by a unique index, since Pending/Claimed jobs may still co-exist with terminal ones for the +-- same config across its history). Every transition out of Claimed (retry, release, success, failure, timeout) nulls +-- ClaimedByDaemonId/ClaimedAt -- the executing daemon's identity for a given try lives on PamRotationAttempt instead. +-- ExpiresAt is persisted at creation (CreationDate + JobTtl) rather than derived, so the timeout sweep is a plain +-- range scan. +CREATE TABLE [dbo].[PamRotationJob] ( + [Id] UNIQUEIDENTIFIER NOT NULL, + [RotationConfigId] UNIQUEIDENTIFIER NOT NULL, + [Source] TINYINT NOT NULL, + [Status] TINYINT NOT NULL, + [ClaimedByDaemonId] UNIQUEIDENTIFIER NULL, + [ClaimedAt] DATETIME2(7) NULL, + [CreationDate] DATETIME2(7) NOT NULL, + [NextClaimableAt] DATETIME2(7) NOT NULL, + [ExpiresAt] DATETIME2(7) NOT NULL, + CONSTRAINT [PK_PamRotationJob] PRIMARY KEY CLUSTERED ([Id] ASC), + -- No cascade: PamRotationConfig_DeleteWithJobs deletes a config's jobs (and their attempts) explicitly, in order, + -- inside one transaction -- deletion is a sproc concern, not a schema-level cascade. + CONSTRAINT [FK_PamRotationJob_RotationConfig] FOREIGN KEY ([RotationConfigId]) REFERENCES [dbo].[PamRotationConfig] ([Id]) ON DELETE NO ACTION +); +GO + +-- PamRotationJob_ReadManyByConfigId and the active-job checks (PamRotationJob_Create's guard, PamRotationConfig_ReadManyDue). +CREATE NONCLUSTERED INDEX [IX_PamRotationJob_RotationConfigId_Status] + ON [dbo].[PamRotationJob] ([RotationConfigId] ASC, [Status] ASC); +GO + +-- The timeout sweep (PamRotationJob_TimeoutDue): Pending/Claimed jobs past ExpiresAt. +CREATE NONCLUSTERED INDEX [IX_PamRotationJob_Status_ExpiresAt] + ON [dbo].[PamRotationJob] ([Status] ASC, [ExpiresAt] ASC); +GO + +-- The release sweep (PamRotationJob_ReleaseExpiredLeases): claimed jobs by claimant, joined to daemon heartbeat. +CREATE NONCLUSTERED INDEX [IX_PamRotationJob_ClaimedByDaemonId_Status] + ON [dbo].[PamRotationJob] ([ClaimedByDaemonId] ASC, [Status] ASC); +GO diff --git a/src/Sql/dbo/Pam/Tables/PamTargetSystem.sql b/src/Sql/dbo/Pam/Tables/PamTargetSystem.sql new file mode 100644 index 000000000000..61105b77a8a4 --- /dev/null +++ b/src/Sql/dbo/Pam/Tables/PamTargetSystem.sql @@ -0,0 +1,19 @@ +-- A target system is where a managed credential's password is rotated: either automatically by a daemon (Method = +-- Automatic, Kind identifies which connector) or manually by a person (Method = Manual, Kind/PasswordPolicy null -- +-- there is no connector to configure). PasswordPolicy is opaque JSON (the PasswordPolicy value object) the daemon +-- applies when generating a new secret; the server never inspects it. +CREATE TABLE [dbo].[PamTargetSystem] ( + [Id] UNIQUEIDENTIFIER NOT NULL, + [OrganizationId] UNIQUEIDENTIFIER NOT NULL, + [Name] NVARCHAR(200) NOT NULL, + [Method] TINYINT NOT NULL, + [Kind] TINYINT NULL, + [PasswordPolicy] NVARCHAR(2000) NULL, + [SupportsSessionTermination] BIT NULL, + [Status] TINYINT NOT NULL, + [CreationDate] DATETIME2(7) NOT NULL, + [RevisionDate] DATETIME2(7) NOT NULL, + CONSTRAINT [PK_PamTargetSystem] PRIMARY KEY CLUSTERED ([Id] ASC), + CONSTRAINT [FK_PamTargetSystem_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]) ON DELETE CASCADE +); +GO diff --git a/util/Migrator/DbScripts/2026-07-06_00_PamRotation.sql b/util/Migrator/DbScripts/2026-07-06_00_PamRotation.sql new file mode 100644 index 000000000000..512f62b73d76 --- /dev/null +++ b/util/Migrator/DbScripts/2026-07-06_00_PamRotation.sql @@ -0,0 +1,1318 @@ +-- PAM Credential Rotation: PamTargetSystem / PamDaemon / PamDaemonTargetAssignment / PamRotationConfig / +-- PamRotationJob / PamRotationAttempt tables + procedures, plus the AccessLease natural-expiry sweep procedure +-- (AccessLease itself is untouched -- see src/Sql/dbo/Pam/Tables/AccessLease.sql). + +-- PamTargetSystem +IF OBJECT_ID('[dbo].[PamTargetSystem]') IS NULL +BEGIN + CREATE TABLE [dbo].[PamTargetSystem] ( + [Id] UNIQUEIDENTIFIER NOT NULL, + [OrganizationId] UNIQUEIDENTIFIER NOT NULL, + [Name] NVARCHAR(200) NOT NULL, + [Method] TINYINT NOT NULL, + [Kind] TINYINT NULL, + [PasswordPolicy] NVARCHAR(2000) NULL, + [SupportsSessionTermination] BIT NULL, + [Status] TINYINT NOT NULL, + [CreationDate] DATETIME2(7) NOT NULL, + [RevisionDate] DATETIME2(7) NOT NULL, + CONSTRAINT [PK_PamTargetSystem] PRIMARY KEY CLUSTERED ([Id] ASC), + CONSTRAINT [FK_PamTargetSystem_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]) ON DELETE CASCADE + ); +END +GO + +-- PamDaemon +IF OBJECT_ID('[dbo].[PamDaemon]') IS NULL +BEGIN + CREATE TABLE [dbo].[PamDaemon] ( + [Id] UNIQUEIDENTIFIER NOT NULL, + [OrganizationId] UNIQUEIDENTIFIER NOT NULL, + [Name] NVARCHAR(200) NOT NULL, + [ApiKeyId] UNIQUEIDENTIFIER NOT NULL, + [Status] TINYINT NOT NULL, + [LastHeartbeatAt] DATETIME2(7) NULL, + [CreationDate] DATETIME2(7) NOT NULL, + [RevisionDate] DATETIME2(7) NOT NULL, + CONSTRAINT [PK_PamDaemon] PRIMARY KEY CLUSTERED ([Id] ASC), + CONSTRAINT [FK_PamDaemon_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]) ON DELETE CASCADE, + CONSTRAINT [FK_PamDaemon_ApiKey] FOREIGN KEY ([ApiKeyId]) REFERENCES [dbo].[ApiKey] ([Id]) ON DELETE NO ACTION + ); + + CREATE UNIQUE NONCLUSTERED INDEX [IX_PamDaemon_ApiKeyId] + ON [dbo].[PamDaemon] ([ApiKeyId] ASC); + + CREATE NONCLUSTERED INDEX [IX_PamDaemon_OrganizationId] + ON [dbo].[PamDaemon] ([OrganizationId] ASC); +END +GO + +-- PamDaemonTargetAssignment +IF OBJECT_ID('[dbo].[PamDaemonTargetAssignment]') IS NULL +BEGIN + CREATE TABLE [dbo].[PamDaemonTargetAssignment] ( + [Id] UNIQUEIDENTIFIER NOT NULL, + [DaemonId] UNIQUEIDENTIFIER NOT NULL, + [TargetSystemId] UNIQUEIDENTIFIER NOT NULL, + [OrganizationId] UNIQUEIDENTIFIER NOT NULL, + [CreationDate] DATETIME2(7) NOT NULL, + CONSTRAINT [PK_PamDaemonTargetAssignment] PRIMARY KEY CLUSTERED ([Id] ASC), + CONSTRAINT [FK_PamDaemonTargetAssignment_Daemon] FOREIGN KEY ([DaemonId]) REFERENCES [dbo].[PamDaemon] ([Id]) ON DELETE NO ACTION, + CONSTRAINT [FK_PamDaemonTargetAssignment_TargetSystem] FOREIGN KEY ([TargetSystemId]) REFERENCES [dbo].[PamTargetSystem] ([Id]) ON DELETE NO ACTION, + CONSTRAINT [FK_PamDaemonTargetAssignment_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]) ON DELETE CASCADE + ); + + -- OneAssignmentPerDaemonTarget. + CREATE UNIQUE NONCLUSTERED INDEX [IX_PamDaemonTargetAssignment_DaemonId_TargetSystemId] + ON [dbo].[PamDaemonTargetAssignment] ([DaemonId] ASC, [TargetSystemId] ASC); + + CREATE NONCLUSTERED INDEX [IX_PamDaemonTargetAssignment_TargetSystemId] + ON [dbo].[PamDaemonTargetAssignment] ([TargetSystemId] ASC); +END +GO + +-- PamRotationConfig +IF OBJECT_ID('[dbo].[PamRotationConfig]') IS NULL +BEGIN + CREATE TABLE [dbo].[PamRotationConfig] ( + [Id] UNIQUEIDENTIFIER NOT NULL, + [OrganizationId] UNIQUEIDENTIFIER NOT NULL, + [CipherId] UNIQUEIDENTIFIER NOT NULL, + [TargetSystemId] UNIQUEIDENTIFIER NOT NULL, + [AccountIdentity] NVARCHAR(500) NOT NULL, + [TerminateSessions] BIT NOT NULL, + [ScheduleCron] NVARCHAR(100) NULL, + [RotateOnAccessEnd] BIT NOT NULL, + [NextRotationAt] DATETIME2(7) NULL, + [Enabled] BIT NOT NULL, + [LastRotationAt] DATETIME2(7) NULL, + [CreationDate] DATETIME2(7) NOT NULL, + [RevisionDate] DATETIME2(7) NOT NULL, + CONSTRAINT [PK_PamRotationConfig] PRIMARY KEY CLUSTERED ([Id] ASC), + CONSTRAINT [FK_PamRotationConfig_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]) ON DELETE CASCADE, + CONSTRAINT [FK_PamRotationConfig_TargetSystem] FOREIGN KEY ([TargetSystemId]) REFERENCES [dbo].[PamTargetSystem] ([Id]) ON DELETE NO ACTION + ); + + -- OneConfigPerCipher. + CREATE UNIQUE NONCLUSTERED INDEX [IX_PamRotationConfig_CipherId] + ON [dbo].[PamRotationConfig] ([CipherId] ASC); + + -- Backs the due-rotation sweep (PamRotationConfig_ReadManyDue). + CREATE NONCLUSTERED INDEX [IX_PamRotationConfig_NextRotationAt] + ON [dbo].[PamRotationConfig] ([NextRotationAt] ASC) + WHERE [Enabled] = 1 AND [NextRotationAt] IS NOT NULL; +END +GO + +-- PamRotationJob +IF OBJECT_ID('[dbo].[PamRotationJob]') IS NULL +BEGIN + CREATE TABLE [dbo].[PamRotationJob] ( + [Id] UNIQUEIDENTIFIER NOT NULL, + [RotationConfigId] UNIQUEIDENTIFIER NOT NULL, + [Source] TINYINT NOT NULL, + [Status] TINYINT NOT NULL, + [ClaimedByDaemonId] UNIQUEIDENTIFIER NULL, + [ClaimedAt] DATETIME2(7) NULL, + [CreationDate] DATETIME2(7) NOT NULL, + [NextClaimableAt] DATETIME2(7) NOT NULL, + [ExpiresAt] DATETIME2(7) NOT NULL, + CONSTRAINT [PK_PamRotationJob] PRIMARY KEY CLUSTERED ([Id] ASC), + CONSTRAINT [FK_PamRotationJob_RotationConfig] FOREIGN KEY ([RotationConfigId]) REFERENCES [dbo].[PamRotationConfig] ([Id]) ON DELETE NO ACTION + ); + + CREATE NONCLUSTERED INDEX [IX_PamRotationJob_RotationConfigId_Status] + ON [dbo].[PamRotationJob] ([RotationConfigId] ASC, [Status] ASC); + + CREATE NONCLUSTERED INDEX [IX_PamRotationJob_Status_ExpiresAt] + ON [dbo].[PamRotationJob] ([Status] ASC, [ExpiresAt] ASC); + + CREATE NONCLUSTERED INDEX [IX_PamRotationJob_ClaimedByDaemonId_Status] + ON [dbo].[PamRotationJob] ([ClaimedByDaemonId] ASC, [Status] ASC); +END +GO + +-- PamRotationAttempt +IF OBJECT_ID('[dbo].[PamRotationAttempt]') IS NULL +BEGIN + CREATE TABLE [dbo].[PamRotationAttempt] ( + [Id] UNIQUEIDENTIFIER NOT NULL, + [JobId] UNIQUEIDENTIFIER NOT NULL, + [ClaimedByDaemonId] UNIQUEIDENTIFIER NOT NULL, + [CipherUpdated] BIT NOT NULL, + [Status] TINYINT NOT NULL, + [FailureReason] NVARCHAR(500) NULL, + [SyncState] TINYINT NULL, + [SessionTermination] TINYINT NULL, + [CreationDate] DATETIME2(7) NOT NULL, + [ResolvedDate] DATETIME2(7) NULL, + CONSTRAINT [PK_PamRotationAttempt] PRIMARY KEY CLUSTERED ([Id] ASC), + CONSTRAINT [FK_PamRotationAttempt_RotationJob] FOREIGN KEY ([JobId]) REFERENCES [dbo].[PamRotationJob] ([Id]) + ); + + CREATE NONCLUSTERED INDEX [IX_PamRotationAttempt_JobId_Status] + ON [dbo].[PamRotationAttempt] ([JobId] ASC, [Status] ASC); +END +GO + + +-- Stored procedures + +CREATE OR ALTER PROCEDURE [dbo].[PamTargetSystem_Create] + @Id UNIQUEIDENTIFIER OUTPUT, + @OrganizationId UNIQUEIDENTIFIER, + @Name NVARCHAR(200), + @Method TINYINT, + @Kind TINYINT = NULL, + @PasswordPolicy NVARCHAR(2000) = NULL, + @SupportsSessionTermination BIT = NULL, + @Status TINYINT, + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + INSERT INTO [dbo].[PamTargetSystem] + ( + [Id], + [OrganizationId], + [Name], + [Method], + [Kind], + [PasswordPolicy], + [SupportsSessionTermination], + [Status], + [CreationDate], + [RevisionDate] + ) + VALUES + ( + @Id, + @OrganizationId, + @Name, + @Method, + @Kind, + @PasswordPolicy, + @SupportsSessionTermination, + @Status, + @CreationDate, + @RevisionDate + ) +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[PamTargetSystem_Update] + @Id UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @Name NVARCHAR(200), + @Method TINYINT, + @Kind TINYINT = NULL, + @PasswordPolicy NVARCHAR(2000) = NULL, + @SupportsSessionTermination BIT = NULL, + @Status TINYINT, + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + UPDATE + [dbo].[PamTargetSystem] + SET + [OrganizationId] = @OrganizationId, + [Name] = @Name, + [Method] = @Method, + [Kind] = @Kind, + [PasswordPolicy] = @PasswordPolicy, + [SupportsSessionTermination] = @SupportsSessionTermination, + [Status] = @Status, + [CreationDate] = @CreationDate, + [RevisionDate] = @RevisionDate + WHERE + [Id] = @Id +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[PamTargetSystem_ReadById] + @Id UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT * + FROM [dbo].[PamTargetSystem] + WHERE [Id] = @Id +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[PamTargetSystem_ReadByOrganizationId] + @OrganizationId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT * + FROM [dbo].[PamTargetSystem] + WHERE [OrganizationId] = @OrganizationId +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[PamTargetSystem_DeleteById] + @Id UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + -- No cascade cleanup here: a target system with rotation configs or daemon assignments still referencing it is + -- blocked by their NO ACTION FKs (detach or delete those first). Deleting an already-gone row is a no-op. + DELETE FROM [dbo].[PamTargetSystem] + WHERE [Id] = @Id +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[PamDaemon_Create] + @Id UNIQUEIDENTIFIER OUTPUT, + @OrganizationId UNIQUEIDENTIFIER, + @Name NVARCHAR(200), + @ApiKeyId UNIQUEIDENTIFIER, + @Status TINYINT, + @LastHeartbeatAt DATETIME2(7) = NULL, + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + INSERT INTO [dbo].[PamDaemon] + ( + [Id], + [OrganizationId], + [Name], + [ApiKeyId], + [Status], + [LastHeartbeatAt], + [CreationDate], + [RevisionDate] + ) + VALUES + ( + @Id, + @OrganizationId, + @Name, + @ApiKeyId, + @Status, + @LastHeartbeatAt, + @CreationDate, + @RevisionDate + ) +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[PamDaemon_Update] + @Id UNIQUEIDENTIFIER, + @Name NVARCHAR(200), + @Status TINYINT, + @RevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + -- Name + Status only: ApiKeyId is set once at registration (reissue is deferred -- see the plan's deferrals), + -- OrganizationId/CreationDate never change, and LastHeartbeatAt has its own conditional-bump sproc + -- (PamDaemon_UpdateHeartbeat) so a routine admin edit never races a daemon's own poll. The repository must call + -- this with an explicit narrow parameter set rather than the generic whole-entity Update -- passing the full + -- PamDaemon entity here would fail (this sproc does not declare an ApiKeyId/LastHeartbeatAt/etc. parameter). + UPDATE + [dbo].[PamDaemon] + SET + [Name] = @Name, + [Status] = @Status, + [RevisionDate] = @RevisionDate + WHERE + [Id] = @Id +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[PamDaemon_ReadById] + @Id UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT * + FROM [dbo].[PamDaemon] + WHERE [Id] = @Id +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[PamDaemon_ReadByOrganizationId] + @OrganizationId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT * + FROM [dbo].[PamDaemon] + WHERE [OrganizationId] = @OrganizationId +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[PamDaemon_UpdateHeartbeat] + @Id UNIQUEIDENTIFIER, + @Now DATETIME2(7), + @MinIntervalSeconds INT +AS +BEGIN + SET NOCOUNT ON + + -- Conditional bump: the daemon-facing request filter calls this on every request, so the WHERE guard turns + -- most calls into a no-op write instead of hammering the row -- only a poll arriving after @MinIntervalSeconds + -- since the last recorded heartbeat actually updates it. Never called by a sweep -- only by the daemon's own + -- requests. + UPDATE [dbo].[PamDaemon] + SET [LastHeartbeatAt] = @Now + WHERE [Id] = @Id + AND ([LastHeartbeatAt] IS NULL OR [LastHeartbeatAt] < DATEADD(SECOND, -@MinIntervalSeconds, @Now)) +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[PamDaemonDetails_ReadByApiKeyId] + @ApiKeyId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + -- The client provider's lookup at token-issuance time: the daemon row plus the two organization flags that gate + -- issuance (Enabled, UsePam) so a lapsed/disabled org's daemon cannot mint a token without an extra round trip. + SELECT + D.*, + O.[Enabled] AS [OrganizationEnabled], + O.[UsePam] AS [OrganizationUsePam] + FROM [dbo].[PamDaemon] D + INNER JOIN [dbo].[Organization] O ON O.[Id] = D.[OrganizationId] + WHERE D.[ApiKeyId] = @ApiKeyId +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[PamDaemonTargetAssignment_Create] + @Id UNIQUEIDENTIFIER, + @DaemonId UNIQUEIDENTIFIER, + @TargetSystemId UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @CreationDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + -- @Id is a plain input, not OUTPUT: unlike the generic Create sprocs, the caller (IPamDaemonRepository. + -- CreateAssignmentAsync) always assigns the id before calling this. [IX_PamDaemonTargetAssignment_DaemonId_TargetSystemId] + -- is the unique-index backstop for OneAssignmentPerDaemonTarget if two callers race. + INSERT INTO [dbo].[PamDaemonTargetAssignment] + ( + [Id], + [DaemonId], + [TargetSystemId], + [OrganizationId], + [CreationDate] + ) + VALUES + ( + @Id, + @DaemonId, + @TargetSystemId, + @OrganizationId, + @CreationDate + ) +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[PamDaemonTargetAssignment_DeleteByDaemonIdTargetSystemId] + @DaemonId UNIQUEIDENTIFIER, + @TargetSystemId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + DELETE FROM [dbo].[PamDaemonTargetAssignment] + WHERE [DaemonId] = @DaemonId AND [TargetSystemId] = @TargetSystemId +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[PamDaemonTargetAssignment_ReadByOrganizationId] + @OrganizationId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT * + FROM [dbo].[PamDaemonTargetAssignment] + WHERE [OrganizationId] = @OrganizationId +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[PamDaemonTargetAssignment_ReadByDaemonId] + @DaemonId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT * + FROM [dbo].[PamDaemonTargetAssignment] + WHERE [DaemonId] = @DaemonId +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[PamDaemonTargetAssignment_ExistsByDaemonIdTargetSystemId] + @DaemonId UNIQUEIDENTIFIER, + @TargetSystemId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT 1 + FROM [dbo].[PamDaemonTargetAssignment] + WHERE [DaemonId] = @DaemonId AND [TargetSystemId] = @TargetSystemId +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[PamRotationConfig_Create] + @Id UNIQUEIDENTIFIER OUTPUT, + @OrganizationId UNIQUEIDENTIFIER, + @CipherId UNIQUEIDENTIFIER, + @TargetSystemId UNIQUEIDENTIFIER, + @AccountIdentity NVARCHAR(500), + @TerminateSessions BIT, + @ScheduleCron NVARCHAR(100) = NULL, + @RotateOnAccessEnd BIT, + @NextRotationAt DATETIME2(7) = NULL, + @Enabled BIT, + @LastRotationAt DATETIME2(7) = NULL, + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + INSERT INTO [dbo].[PamRotationConfig] + ( + [Id], + [OrganizationId], + [CipherId], + [TargetSystemId], + [AccountIdentity], + [TerminateSessions], + [ScheduleCron], + [RotateOnAccessEnd], + [NextRotationAt], + [Enabled], + [LastRotationAt], + [CreationDate], + [RevisionDate] + ) + VALUES + ( + @Id, + @OrganizationId, + @CipherId, + @TargetSystemId, + @AccountIdentity, + @TerminateSessions, + @ScheduleCron, + @RotateOnAccessEnd, + @NextRotationAt, + @Enabled, + @LastRotationAt, + @CreationDate, + @RevisionDate + ) +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[PamRotationConfig_Update] + @Id UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @CipherId UNIQUEIDENTIFIER, + @TargetSystemId UNIQUEIDENTIFIER, + @AccountIdentity NVARCHAR(500), + @TerminateSessions BIT, + @ScheduleCron NVARCHAR(100) = NULL, + @RotateOnAccessEnd BIT, + @NextRotationAt DATETIME2(7) = NULL, + @Enabled BIT, + @LastRotationAt DATETIME2(7) = NULL, + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + UPDATE + [dbo].[PamRotationConfig] + SET + [OrganizationId] = @OrganizationId, + [CipherId] = @CipherId, + [TargetSystemId] = @TargetSystemId, + [AccountIdentity] = @AccountIdentity, + [TerminateSessions] = @TerminateSessions, + [ScheduleCron] = @ScheduleCron, + [RotateOnAccessEnd] = @RotateOnAccessEnd, + [NextRotationAt] = @NextRotationAt, + [Enabled] = @Enabled, + [LastRotationAt] = @LastRotationAt, + [CreationDate] = @CreationDate, + [RevisionDate] = @RevisionDate + WHERE + [Id] = @Id +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[PamRotationConfig_ReadById] + @Id UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT * + FROM [dbo].[PamRotationConfig] + WHERE [Id] = @Id +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[PamRotationConfig_ReadByCipherId] + @CipherId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + -- OneConfigPerCipher: at most one row can ever match. + SELECT * + FROM [dbo].[PamRotationConfig] + WHERE [CipherId] = @CipherId +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[PamRotationConfig_ReadDetailsById] + @Id UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + -- The config detail page's header projection (IPamRotationConfigRepository.GetDetailsByIdAsync): the target's + -- display name/method denormalized, plus a computed HasActiveJob so the caller can gate Delete/UpdateAccount + -- without a second round trip. "Active" mirrors PamRotationJob_Create's guard: Pending or Claimed. + SELECT + C.*, + T.[Name] AS [TargetSystemName], + T.[Method] AS [TargetSystemMethod], + CASE WHEN EXISTS ( + SELECT 1 + FROM [dbo].[PamRotationJob] J + WHERE J.[RotationConfigId] = C.[Id] AND J.[Status] IN (0, 1) -- Pending, Claimed + ) THEN 1 ELSE 0 END AS [HasActiveJob] + FROM [dbo].[PamRotationConfig] C + INNER JOIN [dbo].[PamTargetSystem] T ON T.[Id] = C.[TargetSystemId] + WHERE C.[Id] = @Id +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[PamRotationConfig_ReadManyByOrganizationId] + @OrganizationId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + -- The schedule-list view: every config for the org, with the target's display name/method denormalized (so the + -- client avoids an N+1) and a computed HasActiveJob so the UI can gate Delete/UpdateAccount without a second + -- round trip. "Active" mirrors PamRotationJob_Create's guard: Pending or Claimed. + SELECT + C.*, + T.[Name] AS [TargetSystemName], + T.[Method] AS [TargetSystemMethod], + CASE WHEN EXISTS ( + SELECT 1 + FROM [dbo].[PamRotationJob] J + WHERE J.[RotationConfigId] = C.[Id] AND J.[Status] IN (0, 1) -- Pending, Claimed + ) THEN 1 ELSE 0 END AS [HasActiveJob] + FROM [dbo].[PamRotationConfig] C + INNER JOIN [dbo].[PamTargetSystem] T ON T.[Id] = C.[TargetSystemId] + WHERE C.[OrganizationId] = @OrganizationId +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[PamRotationConfig_ReadManyDue] + @Now DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + -- The sweep's due phase (spec RotationDue): enabled, automatic, active-target configs whose schedule has come + -- due, with no job already in flight (OfferRotation is the single creation point -- this feeds it, one + -- OfferRotationCommand call per row). Enabled + NextRotationAt IS NOT NULL matches + -- [IX_PamRotationConfig_NextRotationAt] so the scan is a narrow range seek, not a table scan. + SELECT C.* + FROM [dbo].[PamRotationConfig] C + INNER JOIN [dbo].[PamTargetSystem] T ON T.[Id] = C.[TargetSystemId] + WHERE C.[Enabled] = 1 + AND C.[NextRotationAt] IS NOT NULL + AND C.[NextRotationAt] <= @Now + AND T.[Method] = 0 -- Automatic + AND T.[Status] = 0 -- Active + AND NOT EXISTS ( + SELECT 1 + FROM [dbo].[PamRotationJob] J + WHERE J.[RotationConfigId] = C.[Id] AND J.[Status] IN (0, 1) -- Pending, Claimed + ) +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[PamRotationConfig_DeleteWithJobs] + @Id UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + -- DeleteRotationConfigCommand's cascade: the audit trail (AccessAuditEvent) is the durable history of a config's + -- rotations, so jobs/attempts are hard-deleted here rather than soft-retired. Order matters -- attempts reference + -- jobs, jobs reference the config, and both FKs are ON DELETE NO ACTION -- so children must go first. The caller + -- has already confirmed the config has no active job. + SET XACT_ABORT ON + + BEGIN TRANSACTION + + DELETE A + FROM [dbo].[PamRotationAttempt] A + INNER JOIN [dbo].[PamRotationJob] J ON J.[Id] = A.[JobId] + WHERE J.[RotationConfigId] = @Id + + DELETE FROM [dbo].[PamRotationJob] + WHERE [RotationConfigId] = @Id + + DELETE FROM [dbo].[PamRotationConfig] + WHERE [Id] = @Id + + COMMIT TRANSACTION +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[PamRotationConfig_AnyByTargetSystemWithTerminateSessions] + @TargetSystemId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + -- UpdateTargetSystemPolicyCommand's capability-withdrawal guard: SupportsSessionTermination may only be turned + -- off when no config on the target still opts into TerminateSessions. + SELECT 1 + FROM [dbo].[PamRotationConfig] + WHERE [TargetSystemId] = @TargetSystemId AND [TerminateSessions] = 1 +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[PamRotationJob_ReadById] + @Id UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT * + FROM [dbo].[PamRotationJob] + WHERE [Id] = @Id +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[PamRotationJob_ReadManyByConfigId] + @RotationConfigId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + -- The config detail page's attempt history, returned as two result sets so the caller can zip each job to its + -- attempts (IPamRotationJobRepository.GetManyByConfigIdAsync, grouping the second set by JobId) without an N+1: + -- 1) every job for the config, newest first. + -- 2) every attempt belonging to those jobs, oldest-first within a job. + SELECT * + FROM [dbo].[PamRotationJob] + WHERE [RotationConfigId] = @RotationConfigId + ORDER BY [CreationDate] DESC + + SELECT A.* + FROM [dbo].[PamRotationAttempt] A + INNER JOIN [dbo].[PamRotationJob] J ON J.[Id] = A.[JobId] + WHERE J.[RotationConfigId] = @RotationConfigId + ORDER BY A.[JobId], A.[CreationDate] ASC +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[PamRotationAttempt_ReadById] + @Id UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT * + FROM [dbo].[PamRotationAttempt] + WHERE [Id] = @Id +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[PamRotationJob_ReadManyClaimableByDaemonId] + @DaemonId UNIQUEIDENTIFIER, + @Now DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + -- The daemon poll: jobs this daemon may claim right now. Re-derives every eligibility condition + -- PamRotationJob_Claim itself re-checks (config enabled, target active, an assignment exists, and -- defense in + -- depth -- the daemon's own org matches the config's org) so the list a daemon sees and what it can actually + -- claim never diverge. + SELECT J.* + FROM [dbo].[PamRotationJob] J + INNER JOIN [dbo].[PamRotationConfig] C ON C.[Id] = J.[RotationConfigId] + INNER JOIN [dbo].[PamTargetSystem] T ON T.[Id] = C.[TargetSystemId] + INNER JOIN [dbo].[PamDaemonTargetAssignment] A ON A.[DaemonId] = @DaemonId AND A.[TargetSystemId] = C.[TargetSystemId] + INNER JOIN [dbo].[PamDaemon] D ON D.[Id] = @DaemonId AND D.[OrganizationId] = C.[OrganizationId] + WHERE J.[Status] = 0 -- Pending + AND J.[NextClaimableAt] <= @Now + AND C.[Enabled] = 1 + AND T.[Status] = 0 -- Active +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[PamRotationJob_Create] + @Id UNIQUEIDENTIFIER, + @RotationConfigId UNIQUEIDENTIFIER, + @Source TINYINT, + @Status TINYINT, + @ClaimedByDaemonId UNIQUEIDENTIFIER = NULL, + @ClaimedAt DATETIME2(7) = NULL, + @CreationDate DATETIME2(7), + @NextClaimableAt DATETIME2(7), + @ExpiresAt DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + -- IPamRotationJobRepository.CreateGuardedAsync passes an already fully-populated PamRotationJob (Status = + -- Pending, claim fields null, NextClaimableAt/ExpiresAt already computed by the caller) -- this sproc only + -- re-validates can_offer's eligibility half and the AtMostOneActiveJobPerConfig guard before inserting it as-is + -- (spec OfferRotation's single creation point). An explicit transaction is required so the range lock below is + -- held until the INSERT commits; XACT_ABORT guarantees rollback (and a clean pooled connection) on any error. + SET XACT_ABORT ON + + BEGIN TRANSACTION + + -- can_offer's eligibility half, re-checked here (not just by the caller) so a config disabled or a target + -- disabled/switched to Manual between the caller's read and this write cannot mint a job. Outcome -1 + -- (ConfigNotOfferable) is distinct from the active-job conflict (0, ActiveJobExists) so the caller can tell + -- "not offerable" apart from "already has one". + IF NOT EXISTS ( + SELECT 1 + FROM [dbo].[PamRotationConfig] C WITH (UPDLOCK, HOLDLOCK) + INNER JOIN [dbo].[PamTargetSystem] T ON T.[Id] = C.[TargetSystemId] + WHERE C.[Id] = @RotationConfigId + AND C.[Enabled] = 1 + AND T.[Method] = 0 -- Automatic + AND T.[Status] = 0 -- Active + ) + BEGIN + ROLLBACK TRANSACTION + SELECT -1 -- ConfigNotOfferable + RETURN + END + + -- AtMostOneActiveJobPerConfig. The UPDLOCK, HOLDLOCK range lock on [IX_PamRotationJob_RotationConfigId_Status] is + -- held for the life of this transaction, so a concurrent creation attempt for the same config blocks here until + -- this transaction commits, then sees the new job and is rejected. + IF EXISTS ( + SELECT 1 + FROM [dbo].[PamRotationJob] WITH (UPDLOCK, HOLDLOCK) + WHERE [RotationConfigId] = @RotationConfigId + AND [Status] IN (0, 1) -- Pending, Claimed + ) + BEGIN + ROLLBACK TRANSACTION + SELECT 0 -- ActiveJobExists + RETURN + END + + INSERT INTO [dbo].[PamRotationJob] + ( + [Id], [RotationConfigId], [Source], [Status], [ClaimedByDaemonId], [ClaimedAt], + [CreationDate], [NextClaimableAt], [ExpiresAt] + ) + VALUES + ( + @Id, @RotationConfigId, @Source, @Status, @ClaimedByDaemonId, @ClaimedAt, + @CreationDate, @NextClaimableAt, @ExpiresAt + ) + + COMMIT TRANSACTION + + SELECT 1 -- Created +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[PamRotationJob_Claim] + @JobId UNIQUEIDENTIFIER, + @AttemptId UNIQUEIDENTIFIER, + @DaemonId UNIQUEIDENTIFIER, + @Now DATETIME2(7), + @ReleaseDelaySeconds INT +AS +BEGIN + SET NOCOUNT ON + -- First-claim-wins is enforced by the UPDATE's own WHERE J.Status = 0 clause: SQL Server takes the row lock + -- needed to satisfy that predicate as part of the UPDATE itself, so two concurrent claims of the same job + -- serialize on the row and only the first can flip Status Pending -> Claimed. The result shape mirrors + -- PamRotationClaimResult exactly (an Outcome column plus the work-snapshot columns, null on any non-Claimed + -- outcome) so the caller can map every path with a single row read. XACT_ABORT guarantees rollback (and a clean + -- pooled connection) on any error. + SET XACT_ABORT ON + + BEGIN TRANSACTION + + UPDATE J + SET J.[Status] = 1, -- Claimed + J.[ClaimedByDaemonId] = @DaemonId, + J.[ClaimedAt] = @Now + FROM [dbo].[PamRotationJob] J + INNER JOIN [dbo].[PamRotationConfig] C ON C.[Id] = J.[RotationConfigId] + INNER JOIN [dbo].[PamTargetSystem] T ON T.[Id] = C.[TargetSystemId] + INNER JOIN [dbo].[PamDaemonTargetAssignment] A ON A.[DaemonId] = @DaemonId AND A.[TargetSystemId] = C.[TargetSystemId] + -- Defense in depth: the daemon must be Enrolled AND in the same org as the config, even though the caller + -- (ClaimRotationJobCommand) already checked both from the bearer token's claims. + INNER JOIN [dbo].[PamDaemon] D ON D.[Id] = @DaemonId AND D.[OrganizationId] = C.[OrganizationId] AND D.[Status] = 0 -- Enrolled + WHERE J.[Id] = @JobId + AND J.[Status] = 0 -- Pending + AND J.[NextClaimableAt] <= @Now + AND C.[Enabled] = 1 + AND T.[Status] = 0 -- Active + + IF @@ROWCOUNT = 0 + BEGIN + -- Eligibility is classified FIRST so a job that does not exist and a job this daemon may not claim + -- (unassigned target, cross-org, revoked daemon) produce the same NotEligible outcome -- the caller maps it + -- to 404, leaving no existence oracle. Only an eligible daemon that lost the race / hit backoff / hit the + -- paused-config or disabled-target hold gets NotClaimable (mapped to 409). + DECLARE @Outcome INT = CASE + WHEN NOT EXISTS ( + SELECT 1 + FROM [dbo].[PamRotationJob] J2 + INNER JOIN [dbo].[PamRotationConfig] C2 ON C2.[Id] = J2.[RotationConfigId] + INNER JOIN [dbo].[PamDaemonTargetAssignment] A2 ON A2.[DaemonId] = @DaemonId AND A2.[TargetSystemId] = C2.[TargetSystemId] + INNER JOIN [dbo].[PamDaemon] D2 ON D2.[Id] = @DaemonId AND D2.[OrganizationId] = C2.[OrganizationId] AND D2.[Status] = 0 -- Enrolled + WHERE J2.[Id] = @JobId + ) THEN -1 -- NotEligible (unknown job, or a job outside this daemon's assignment/org) + ELSE 0 -- NotClaimable (eligible, but not pending / in backoff / held by a paused config or disabled target) + END + + ROLLBACK TRANSACTION + + SELECT + @Outcome AS [Outcome], + CAST(NULL AS UNIQUEIDENTIFIER) AS [AttemptId], + CAST(NULL AS UNIQUEIDENTIFIER) AS [JobId], + CAST(NULL AS TINYINT) AS [Source], + CAST(NULL AS UNIQUEIDENTIFIER) AS [TargetSystemId], + CAST(NULL AS NVARCHAR(200)) AS [TargetSystemName], + CAST(NULL AS TINYINT) AS [Kind], + CAST(NULL AS NVARCHAR(2000)) AS [PasswordPolicy], + CAST(NULL AS UNIQUEIDENTIFIER) AS [CipherId], + CAST(NULL AS NVARCHAR(500)) AS [AccountIdentity], + CAST(NULL AS BIT) AS [TerminateSessions], + CAST(NULL AS DATETIME2(7)) AS [ExecuteBy] + RETURN + END + + -- AtMostOneInFlightAttemptPerJob: the Executing attempt is created in the same transaction as the claim, so a + -- claimed job always has exactly one in-flight attempt from the moment it is claimed. + INSERT INTO [dbo].[PamRotationAttempt] + ( + [Id], [JobId], [ClaimedByDaemonId], [CipherUpdated], [Status], [FailureReason], [SyncState], + [SessionTermination], [CreationDate], [ResolvedDate] + ) + VALUES + ( + @AttemptId, @JobId, @DaemonId, 0, 0 /* Executing */, NULL, NULL, + NULL, @Now, NULL + ) + + COMMIT TRANSACTION + + -- The work snapshot the daemon executes against; ExecuteBy is this claim's lease end (ClaimedAt + ReleaseDelay). + SELECT + 1 AS [Outcome], -- Claimed + @AttemptId AS [AttemptId], + J.[Id] AS [JobId], + J.[Source], + T.[Id] AS [TargetSystemId], + T.[Name] AS [TargetSystemName], + T.[Kind], + T.[PasswordPolicy], + C.[CipherId], + C.[AccountIdentity], + C.[TerminateSessions], + DATEADD(SECOND, @ReleaseDelaySeconds, @Now) AS [ExecuteBy] + FROM [dbo].[PamRotationJob] J + INNER JOIN [dbo].[PamRotationConfig] C ON C.[Id] = J.[RotationConfigId] + INNER JOIN [dbo].[PamTargetSystem] T ON T.[Id] = C.[TargetSystemId] + WHERE J.[Id] = @JobId +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[PamRotationAttempt_AcceptCipherWrite] + @AttemptId UNIQUEIDENTIFIER, + @DaemonId UNIQUEIDENTIFIER, + @CipherData NVARCHAR(MAX), + @LastKnownRevisionDate DATETIME2(7), + @Now DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + -- AcceptCipherUpdate's atomic write-capability check (security finding, plan §1): the job row is locked here + -- WITH (UPDLOCK) for the life of the transaction, so a concurrent release/timeout sweep -- which updates the same + -- job row -- blocks until this commits (or vice versa), closing the check-then-act window between "is this + -- attempt still allowed to write" and "write the cipher". XACT_ABORT guarantees rollback (and a clean pooled + -- connection) on any error. + SET XACT_ABORT ON + + BEGIN TRANSACTION + + DECLARE @CipherId UNIQUEIDENTIFIER + DECLARE @VerifiedJobId UNIQUEIDENTIFIER + + SELECT + @CipherId = C.[CipherId], + @VerifiedJobId = J.[Id] + FROM [dbo].[PamRotationAttempt] AT + INNER JOIN [dbo].[PamRotationJob] J WITH (UPDLOCK) ON J.[Id] = AT.[JobId] + INNER JOIN [dbo].[PamRotationConfig] C ON C.[Id] = J.[RotationConfigId] + WHERE AT.[Id] = @AttemptId + AND AT.[Status] = 0 -- Executing + AND AT.[ClaimedByDaemonId] = @DaemonId + AND J.[Status] = 1 -- Claimed + AND J.[ClaimedByDaemonId] = @DaemonId + + IF @VerifiedJobId IS NULL + BEGIN + -- The complement of spec AcceptCipherUpdate: unknown attempt, wrong claimant, or the job/attempt has already + -- moved on (released/timed out/resolved). Audited by the caller as write_rejected. + ROLLBACK TRANSACTION + SELECT 0 -- Rejected + RETURN + END + + -- Outside RejectCipherUpdate's exact complement (plan §10 divergence): a drifted LastKnownRevisionDate means the + -- vault item changed since the daemon last read it, so the write is rejected to protect a concurrent user edit + -- rather than silently clobbering it. The 1-second tolerance mirrors CipherService's own last-known-revision + -- check. + IF ABS(DATEDIFF(MILLISECOND, (SELECT [RevisionDate] FROM [dbo].[Cipher] WHERE [Id] = @CipherId), @LastKnownRevisionDate)) > 1000 + BEGIN + ROLLBACK TRANSACTION + SELECT -1 -- RevisionMismatch + RETURN + END + + UPDATE [dbo].[Cipher] + SET [Data] = @CipherData, + [RevisionDate] = @Now + WHERE [Id] = @CipherId + + UPDATE [dbo].[PamRotationAttempt] + SET [CipherUpdated] = 1 + WHERE [Id] = @AttemptId + + COMMIT TRANSACTION + + SELECT 1 -- Accepted +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[PamRotationAttempt_MarkRotated] + @AttemptId UNIQUEIDENTIFIER, + @DaemonId UNIQUEIDENTIFIER, + @SessionTermination TINYINT, + @Now DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + -- RecordRotationSucceeded -> MarkJobSucceeded. CipherUpdated = 1 is the VerifiedBeforeSuccess backstop: a success + -- report cannot resolve an attempt whose cipher write was never accepted. Guard failure (unknown/stale attempt, + -- wrong claimant, no cipher write, or the job already moved on) takes the RejectStaleSuccess path -- the caller + -- audits report_rejected, nothing changes. XACT_ABORT guarantees rollback (and a clean pooled connection) on any + -- error. + SET XACT_ABORT ON + + BEGIN TRANSACTION + + DECLARE @JobId UNIQUEIDENTIFIER + + SELECT @JobId = J.[Id] + FROM [dbo].[PamRotationAttempt] AT + INNER JOIN [dbo].[PamRotationJob] J WITH (UPDLOCK) ON J.[Id] = AT.[JobId] + WHERE AT.[Id] = @AttemptId + AND AT.[Status] = 0 -- Executing + AND AT.[ClaimedByDaemonId] = @DaemonId + AND AT.[CipherUpdated] = 1 + AND J.[Status] = 1 -- Claimed + + IF @JobId IS NULL + BEGIN + ROLLBACK TRANSACTION + SELECT 0 -- Rejected + RETURN + END + + UPDATE [dbo].[PamRotationAttempt] + SET [Status] = 1, -- Rotated + [SessionTermination] = @SessionTermination, + [ResolvedDate] = @Now + WHERE [Id] = @AttemptId + + -- Every transition out of Claimed nulls the claim fields; the executing daemon's identity for this try is + -- already permanently recorded on the attempt above. + UPDATE [dbo].[PamRotationJob] + SET [Status] = 2, -- Succeeded + [ClaimedByDaemonId] = NULL, + [ClaimedAt] = NULL + WHERE [Id] = @JobId + + COMMIT TRANSACTION + + SELECT 1 -- Resolved +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[PamRotationAttempt_MarkErrored] + @AttemptId UNIQUEIDENTIFIER, + @DaemonId UNIQUEIDENTIFIER, + @FailureReason NVARCHAR(500) = NULL, + @SyncState TINYINT, + @Now DATETIME2(7), + @MaxAttempts INT, + @RetryBaseDelaySeconds INT +AS +BEGIN + SET NOCOUNT ON + -- RecordRotationFailed -> RetryJob / FailJob. @FailureReason is already bounded/truncated by the caller before + -- this call (the zero-knowledge failure-reason contract forbids forwarding raw target-system error output), so + -- this sproc only stores it. Guard failure (unknown/stale attempt, wrong claimant, or the job already moved on) + -- takes the RejectStaleFailureReport path -- the caller audits report_rejected, nothing changes. The result shape + -- mirrors PamRotationFailureResult (Outcome + JobStatus + ErroredAttemptCount) on every path, success or not. + -- XACT_ABORT guarantees rollback (and a clean pooled connection) on any error. + SET XACT_ABORT ON + + BEGIN TRANSACTION + + DECLARE @JobId UNIQUEIDENTIFIER + + SELECT @JobId = J.[Id] + FROM [dbo].[PamRotationAttempt] AT + INNER JOIN [dbo].[PamRotationJob] J WITH (UPDLOCK) ON J.[Id] = AT.[JobId] + WHERE AT.[Id] = @AttemptId + AND AT.[Status] = 0 -- Executing + AND AT.[ClaimedByDaemonId] = @DaemonId + AND J.[Status] = 1 -- Claimed + + IF @JobId IS NULL + BEGIN + ROLLBACK TRANSACTION + SELECT 0 AS [Outcome], NULL AS [JobStatus], NULL AS [ErroredAttemptCount] -- Rejected + RETURN + END + + UPDATE [dbo].[PamRotationAttempt] + SET [Status] = 2, -- Errored + [FailureReason] = @FailureReason, + [SyncState] = @SyncState, + [ResolvedDate] = @Now + WHERE [Id] = @AttemptId + + -- Retry-budget math: only Errored attempts count (Abandoned -- released/timed-out tries -- are never charged + -- against the budget, per the plan's success-wins-on-timeout+release semantics). + DECLARE @ErroredCount INT + + SELECT @ErroredCount = COUNT(*) + FROM [dbo].[PamRotationAttempt] + WHERE [JobId] = @JobId AND [Status] = 2 -- Errored + + DECLARE @JobStatus TINYINT + + IF @ErroredCount < @MaxAttempts + BEGIN + SET @JobStatus = 0 -- Pending + UPDATE [dbo].[PamRotationJob] + SET [Status] = @JobStatus, + [ClaimedByDaemonId] = NULL, + [ClaimedAt] = NULL, + [NextClaimableAt] = DATEADD(SECOND, CAST(@RetryBaseDelaySeconds * POWER(2, @ErroredCount - 1) AS INT), @Now) + WHERE [Id] = @JobId + END + ELSE + BEGIN + SET @JobStatus = 3 -- Failed + UPDATE [dbo].[PamRotationJob] + SET [Status] = @JobStatus, + [ClaimedByDaemonId] = NULL, + [ClaimedAt] = NULL + WHERE [Id] = @JobId + END + + COMMIT TRANSACTION + + SELECT 1 AS [Outcome], @JobStatus AS [JobStatus], @ErroredCount AS [ErroredAttemptCount] -- Resolved +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[PamRotationJob_TimeoutDue] + @Now DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + -- JobTimesOut ("success wins"): a job with a Rotated attempt is excluded even if it is otherwise past ExpiresAt -- + -- a slow-but-successful report still wins the race against the timeout sweep. OUTPUT can't reach through the + -- joins needed for the audit projection (config/org/cipher), so affected ids are captured in @Affected first and + -- joined afterward. The job update and its attempt's Abandoned transition commit together so a crash between the + -- two can never leave a stale Executing attempt behind a job that already moved on. XACT_ABORT guarantees + -- rollback (and a clean pooled connection) on any error. + SET XACT_ABORT ON + + BEGIN TRANSACTION + + DECLARE @Affected TABLE ( + [JobId] UNIQUEIDENTIFIER NOT NULL, + [PreviousClaimedByDaemonId] UNIQUEIDENTIFIER NULL + ) + + UPDATE J + SET J.[Status] = 4, -- TimedOut + J.[ClaimedByDaemonId] = NULL, + J.[ClaimedAt] = NULL + OUTPUT deleted.[Id], deleted.[ClaimedByDaemonId] INTO @Affected ([JobId], [PreviousClaimedByDaemonId]) + FROM [dbo].[PamRotationJob] J + WHERE J.[Status] IN (0, 1) -- Pending, Claimed + AND J.[ExpiresAt] <= @Now + AND NOT EXISTS ( + SELECT 1 + FROM [dbo].[PamRotationAttempt] AT + WHERE AT.[JobId] = J.[Id] AND AT.[Status] = 1 -- Rotated + ) + + -- Abandon the executing attempt (if any) on each timed-out job; a Pending job that never got claimed has none. + -- Abandoned attempts are never charged against the retry budget. + UPDATE [dbo].[PamRotationAttempt] + SET [Status] = 3, -- Abandoned + [ResolvedDate] = @Now + WHERE [JobId] IN (SELECT [JobId] FROM @Affected) + AND [Status] = 0 -- Executing + + -- One row per timed-out job for audit emission; AttemptCount distinguishes unroutable (never claimed, zero + -- attempts) from stuck (claimed at least once). + SELECT + AF.[JobId], + C.[Id] AS [RotationConfigId], + C.[OrganizationId], + C.[CipherId], + J.[Source], + AF.[PreviousClaimedByDaemonId] AS [ClaimedByDaemonId], + (SELECT COUNT(*) FROM [dbo].[PamRotationAttempt] AT WHERE AT.[JobId] = AF.[JobId]) AS [AttemptCount] + FROM @Affected AF + INNER JOIN [dbo].[PamRotationJob] J ON J.[Id] = AF.[JobId] + INNER JOIN [dbo].[PamRotationConfig] C ON C.[Id] = J.[RotationConfigId] + + COMMIT TRANSACTION +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[PamRotationJob_ReleaseExpiredLeases] + @Now DATETIME2(7), + @OfflineAfterSeconds INT, + @ReleaseDelaySeconds INT +AS +BEGIN + SET NOCOUNT ON + -- DaemonConnectionDropsReleaseJobs -> ReleaseJob -> AbandonAttempt, with the lease-respecting timing from plan §5: + -- release only fires once BOTH the claim's lease has expired (now >= ExecuteBy, i.e. ClaimedAt + ReleaseDelay) AND + -- the claimant's heartbeat is stale -- never on daemon Status alone, so a revoked daemon's jobs release too once + -- its heartbeats actually stop. A job with a Rotated attempt is excluded ("success wins", same as the timeout + -- sweep): a slow-but-live daemon whose report lands inside its lease still wins. OUTPUT can't reach through the + -- joins needed for the audit projection, so affected ids are captured in @Affected first and joined afterward. + -- XACT_ABORT guarantees rollback (and a clean pooled connection) on any error. + SET XACT_ABORT ON + + BEGIN TRANSACTION + + DECLARE @Affected TABLE ( + [JobId] UNIQUEIDENTIFIER NOT NULL, + [PreviousClaimedByDaemonId] UNIQUEIDENTIFIER NULL + ) + + UPDATE J + SET J.[Status] = 0, -- Pending + -- Computed from the pre-clear ClaimedAt (this UPDATE's FROM/JOIN still sees the old value here), so the + -- re-claim time is exactly ExecuteBy regardless of whether release fires at that instant or later. + J.[NextClaimableAt] = DATEADD(SECOND, @ReleaseDelaySeconds, J.[ClaimedAt]), + J.[ClaimedByDaemonId] = NULL, + J.[ClaimedAt] = NULL + OUTPUT deleted.[Id], deleted.[ClaimedByDaemonId] INTO @Affected ([JobId], [PreviousClaimedByDaemonId]) + FROM [dbo].[PamRotationJob] J + INNER JOIN [dbo].[PamDaemon] D ON D.[Id] = J.[ClaimedByDaemonId] + WHERE J.[Status] = 1 -- Claimed + AND DATEADD(SECOND, @ReleaseDelaySeconds, J.[ClaimedAt]) <= @Now + AND (D.[LastHeartbeatAt] IS NULL OR D.[LastHeartbeatAt] < DATEADD(SECOND, -@OfflineAfterSeconds, @Now)) + AND NOT EXISTS ( + SELECT 1 + FROM [dbo].[PamRotationAttempt] AT + WHERE AT.[JobId] = J.[Id] AND AT.[Status] = 1 -- Rotated + ) + + -- Abandoned attempts are never charged against the retry budget. + UPDATE [dbo].[PamRotationAttempt] + SET [Status] = 3, -- Abandoned + [ResolvedDate] = @Now + WHERE [JobId] IN (SELECT [JobId] FROM @Affected) + AND [Status] = 0 -- Executing + + -- One row per released job for audit emission. ClaimedByDaemonId here is the pre-clear claimant (always + -- non-null: only Claimed jobs are released). + SELECT + AF.[JobId], + C.[Id] AS [RotationConfigId], + C.[OrganizationId], + C.[CipherId], + J.[Source], + AF.[PreviousClaimedByDaemonId] AS [ClaimedByDaemonId] + FROM @Affected AF + INNER JOIN [dbo].[PamRotationJob] J ON J.[Id] = AF.[JobId] + INNER JOIN [dbo].[PamRotationConfig] C ON C.[Id] = J.[RotationConfigId] + + COMMIT TRANSACTION +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[AccessLease_ExpireDue] + @Now DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + -- The anticipated lease natural-expiry sweep (plan decision 4): flips Active -> Expired for leases whose window + -- closed on its own (no revoke/cancel involved), so the deferred LeaseExpired audit kind and the rotation + -- access-end trigger both have something to fire from. [IX_AccessLease_NotAfter_Status] makes this a narrow + -- range seek. No join is needed for the projection -- every column the caller audits/triggers on already lives + -- on the row itself. + UPDATE [dbo].[AccessLease] + SET [Status] = 1 -- Expired + OUTPUT + deleted.[Id], + deleted.[OrganizationId], + deleted.[CollectionId], + deleted.[CipherId], + deleted.[RequesterId], + deleted.[NotBefore], + deleted.[NotAfter] + WHERE [Status] = 0 -- Active + AND [NotAfter] <= @Now +END +GO + From 2a37f4ed4c83311c82a07ec2aaf6aac36c794a3e Mon Sep 17 00:00:00 2001 From: Hinton Date: Mon, 6 Jul 2026 14:28:31 +0200 Subject: [PATCH 04/10] [PM-39040] Add rotation Dapper repositories MSSQL-only implementations of the four rotation repository interfaces (matching the PAM POC's Dapper-only scope), plus IAccessLeaseRepository.ExpireDueAsync backing the lease natural-expiry sweep. --- .../DapperServiceCollectionExtensions.cs | 4 + .../Pam/Repositories/AccessLeaseRepository.cs | 17 ++ .../Pam/Repositories/PamDaemonRepository.cs | 119 ++++++++++ .../PamRotationConfigRepository.cs | 87 +++++++ .../Repositories/PamRotationJobRepository.cs | 213 ++++++++++++++++++ .../Repositories/PamTargetSystemRepository.cs | 33 +++ src/Pam.Domain/Models/PamExpiredLease.cs | 18 ++ .../Repositories/IAccessLeaseRepository.cs | 12 + 8 files changed, 503 insertions(+) create mode 100644 src/Infrastructure.Dapper/Pam/Repositories/PamDaemonRepository.cs create mode 100644 src/Infrastructure.Dapper/Pam/Repositories/PamRotationConfigRepository.cs create mode 100644 src/Infrastructure.Dapper/Pam/Repositories/PamRotationJobRepository.cs create mode 100644 src/Infrastructure.Dapper/Pam/Repositories/PamTargetSystemRepository.cs create mode 100644 src/Pam.Domain/Models/PamExpiredLease.cs diff --git a/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs b/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs index 2c6738094eff..3bba2618e8fd 100644 --- a/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs +++ b/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs @@ -59,6 +59,10 @@ public static void AddDapperRepositories(this IServiceCollection services, bool services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Infrastructure.Dapper/Pam/Repositories/AccessLeaseRepository.cs b/src/Infrastructure.Dapper/Pam/Repositories/AccessLeaseRepository.cs index 14816311a1bd..877d31c69001 100644 --- a/src/Infrastructure.Dapper/Pam/Repositories/AccessLeaseRepository.cs +++ b/src/Infrastructure.Dapper/Pam/Repositories/AccessLeaseRepository.cs @@ -3,6 +3,7 @@ using Bit.Infrastructure.Dapper.Repositories; using Bit.Pam.Entities; using Bit.Pam.Enums; +using Bit.Pam.Models; using Bit.Pam.Repositories; using Dapper; using Microsoft.Data.SqlClient; @@ -133,4 +134,20 @@ await connection.ExecuteAsync( }, commandType: CommandType.StoredProcedure); } + + /// + /// Deviation: was added to the interface alongside this + /// implementation — see the interface's doc comment for why it lives here rather than on + /// IPamRotationJobRepository. + /// + public async Task> ExpireDueAsync(DateTime now) + { + await using var connection = new SqlConnection(ConnectionString); + var results = await connection.QueryAsync( + $"[{Schema}].[AccessLease_ExpireDue]", + new { Now = now }, + commandType: CommandType.StoredProcedure); + + return results.ToList(); + } } diff --git a/src/Infrastructure.Dapper/Pam/Repositories/PamDaemonRepository.cs b/src/Infrastructure.Dapper/Pam/Repositories/PamDaemonRepository.cs new file mode 100644 index 000000000000..d4904b949d1b --- /dev/null +++ b/src/Infrastructure.Dapper/Pam/Repositories/PamDaemonRepository.cs @@ -0,0 +1,119 @@ +using System.Data; +using Bit.Core.Settings; +using Bit.Infrastructure.Dapper.Repositories; +using Bit.Pam.Entities; +using Bit.Pam.Models; +using Bit.Pam.Repositories; +using Dapper; +using Microsoft.Data.SqlClient; + +#nullable enable + +namespace Bit.Infrastructure.Dapper.Pam.Repositories; + +public class PamDaemonRepository : Repository, IPamDaemonRepository +{ + public PamDaemonRepository(GlobalSettings globalSettings) + : this(globalSettings.SqlServer.ConnectionString, globalSettings.SqlServer.ReadOnlyConnectionString) + { } + + public PamDaemonRepository(string connectionString, string readOnlyConnectionString) + : base(connectionString, readOnlyConnectionString) + { } + + /// + /// PamDaemon_Update is narrow (Name/Status/RevisionDate only — ApiKeyId, OrganizationId, CreationDate never + /// change post-registration, and LastHeartbeatAt has its own conditional-bump sproc), so the generic + /// whole-entity would pass parameters the sproc does not declare. + /// + public override async Task ReplaceAsync(PamDaemon obj) + { + await using var connection = new SqlConnection(ConnectionString); + await connection.ExecuteAsync( + $"[{Schema}].[PamDaemon_Update]", + new + { + obj.Id, + obj.Name, + Status = (byte)obj.Status, + obj.RevisionDate, + }, + commandType: CommandType.StoredProcedure); + } + + public async Task> GetManyByOrganizationIdAsync(Guid organizationId) + { + await using var connection = new SqlConnection(ConnectionString); + var results = await connection.QueryAsync( + $"[{Schema}].[PamDaemon_ReadByOrganizationId]", + new { OrganizationId = organizationId }, + commandType: CommandType.StoredProcedure); + + return results.ToList(); + } + + public async Task GetDetailsByApiKeyIdAsync(Guid apiKeyId) + { + await using var connection = new SqlConnection(ConnectionString); + var results = await connection.QueryAsync( + $"[{Schema}].[PamDaemonDetails_ReadByApiKeyId]", + new { ApiKeyId = apiKeyId }, + commandType: CommandType.StoredProcedure); + + return results.SingleOrDefault(); + } + + public async Task UpdateHeartbeatAsync(Guid daemonId, DateTime now, TimeSpan minInterval) + { + await using var connection = new SqlConnection(ConnectionString); + await connection.ExecuteAsync( + $"[{Schema}].[PamDaemon_UpdateHeartbeat]", + new + { + Id = daemonId, + Now = now, + MinIntervalSeconds = (int)minInterval.TotalSeconds, + }, + commandType: CommandType.StoredProcedure); + } + + public async Task CreateAssignmentAsync(PamDaemonTargetAssignment assignment) + { + await using var connection = new SqlConnection(ConnectionString); + await connection.ExecuteAsync( + $"[{Schema}].[PamDaemonTargetAssignment_Create]", + assignment, + commandType: CommandType.StoredProcedure); + } + + public async Task DeleteAssignmentAsync(Guid daemonId, Guid targetSystemId) + { + await using var connection = new SqlConnection(ConnectionString); + await connection.ExecuteAsync( + $"[{Schema}].[PamDaemonTargetAssignment_DeleteByDaemonIdTargetSystemId]", + new { DaemonId = daemonId, TargetSystemId = targetSystemId }, + commandType: CommandType.StoredProcedure); + } + + public async Task> GetAssignmentsByOrganizationIdAsync(Guid organizationId) + { + await using var connection = new SqlConnection(ConnectionString); + var results = await connection.QueryAsync( + $"[{Schema}].[PamDaemonTargetAssignment_ReadByOrganizationId]", + new { OrganizationId = organizationId }, + commandType: CommandType.StoredProcedure); + + return results.ToList(); + } + + public async Task AssignmentExistsAsync(Guid daemonId, Guid targetSystemId) + { + await using var connection = new SqlConnection(ConnectionString); + var result = await connection.ExecuteScalarAsync( + $"[{Schema}].[PamDaemonTargetAssignment_ExistsByDaemonIdTargetSystemId]", + new { DaemonId = daemonId, TargetSystemId = targetSystemId }, + commandType: CommandType.StoredProcedure); + + return result.HasValue; + } +} diff --git a/src/Infrastructure.Dapper/Pam/Repositories/PamRotationConfigRepository.cs b/src/Infrastructure.Dapper/Pam/Repositories/PamRotationConfigRepository.cs new file mode 100644 index 000000000000..27c0d3f43d28 --- /dev/null +++ b/src/Infrastructure.Dapper/Pam/Repositories/PamRotationConfigRepository.cs @@ -0,0 +1,87 @@ +using System.Data; +using Bit.Core.Settings; +using Bit.Infrastructure.Dapper.Repositories; +using Bit.Pam.Entities; +using Bit.Pam.Models; +using Bit.Pam.Repositories; +using Dapper; +using Microsoft.Data.SqlClient; + +#nullable enable + +namespace Bit.Infrastructure.Dapper.Pam.Repositories; + +public class PamRotationConfigRepository : Repository, IPamRotationConfigRepository +{ + public PamRotationConfigRepository(GlobalSettings globalSettings) + : this(globalSettings.SqlServer.ConnectionString, globalSettings.SqlServer.ReadOnlyConnectionString) + { } + + public PamRotationConfigRepository(string connectionString, string readOnlyConnectionString) + : base(connectionString, readOnlyConnectionString) + { } + + public async Task GetByCipherIdAsync(Guid cipherId) + { + await using var connection = new SqlConnection(ConnectionString); + var results = await connection.QueryAsync( + $"[{Schema}].[PamRotationConfig_ReadByCipherId]", + new { CipherId = cipherId }, + commandType: CommandType.StoredProcedure); + + return results.SingleOrDefault(); + } + + public async Task GetDetailsByIdAsync(Guid id) + { + await using var connection = new SqlConnection(ConnectionString); + var results = await connection.QueryAsync( + $"[{Schema}].[PamRotationConfig_ReadDetailsById]", + new { Id = id }, + commandType: CommandType.StoredProcedure); + + return results.SingleOrDefault(); + } + + public async Task> GetManyDetailsByOrganizationIdAsync(Guid organizationId) + { + await using var connection = new SqlConnection(ConnectionString); + var results = await connection.QueryAsync( + $"[{Schema}].[PamRotationConfig_ReadManyByOrganizationId]", + new { OrganizationId = organizationId }, + commandType: CommandType.StoredProcedure); + + return results.ToList(); + } + + public async Task> GetManyDueAsync(DateTime now) + { + await using var connection = new SqlConnection(ConnectionString); + var results = await connection.QueryAsync( + $"[{Schema}].[PamRotationConfig_ReadManyDue]", + new { Now = now }, + commandType: CommandType.StoredProcedure); + + return results.ToList(); + } + + public async Task AnyByTargetSystemWithTerminateSessionsAsync(Guid targetSystemId) + { + await using var connection = new SqlConnection(ConnectionString); + var result = await connection.ExecuteScalarAsync( + $"[{Schema}].[PamRotationConfig_AnyByTargetSystemWithTerminateSessions]", + new { TargetSystemId = targetSystemId }, + commandType: CommandType.StoredProcedure); + + return result.HasValue; + } + + public async Task DeleteWithJobsAsync(Guid configId) + { + await using var connection = new SqlConnection(ConnectionString); + await connection.ExecuteAsync( + $"[{Schema}].[PamRotationConfig_DeleteWithJobs]", + new { Id = configId }, + commandType: CommandType.StoredProcedure); + } +} diff --git a/src/Infrastructure.Dapper/Pam/Repositories/PamRotationJobRepository.cs b/src/Infrastructure.Dapper/Pam/Repositories/PamRotationJobRepository.cs new file mode 100644 index 000000000000..d6f95dc59f07 --- /dev/null +++ b/src/Infrastructure.Dapper/Pam/Repositories/PamRotationJobRepository.cs @@ -0,0 +1,213 @@ +using System.Data; +using Bit.Core.Settings; +using Bit.Core.Utilities; +using Bit.Infrastructure.Dapper.Repositories; +using Bit.Pam.Entities; +using Bit.Pam.Enums; +using Bit.Pam.Models; +using Bit.Pam.Repositories; +using Dapper; +using Microsoft.Data.SqlClient; + +#nullable enable + +namespace Bit.Infrastructure.Dapper.Pam.Repositories; + +public class PamRotationJobRepository : BaseRepository, IPamRotationJobRepository +{ + public PamRotationJobRepository(GlobalSettings globalSettings) + : this(globalSettings.SqlServer.ConnectionString, globalSettings.SqlServer.ReadOnlyConnectionString) + { } + + public PamRotationJobRepository(string connectionString, string readOnlyConnectionString) + : base(connectionString, readOnlyConnectionString) + { } + + public async Task CreateGuardedAsync(PamRotationJob job) + { + await using var connection = new SqlConnection(ConnectionString); + // job's property names line up 1:1 with the sproc's parameters (including the plain, non-OUTPUT @Id -- + // the caller has already assigned the job's id), so it is passed straight through like the generic + // Repository base does for a whole-entity write. + var result = await connection.ExecuteScalarAsync( + "[dbo].[PamRotationJob_Create]", + job, + commandType: CommandType.StoredProcedure); + + return (PamRotationJobCreateOutcome)result; + } + + public async Task GetByIdAsync(Guid id) + { + await using var connection = new SqlConnection(ConnectionString); + var results = await connection.QueryAsync( + "[dbo].[PamRotationJob_ReadById]", + new { Id = id }, + commandType: CommandType.StoredProcedure); + + return results.SingleOrDefault(); + } + + public async Task ClaimAsync(Guid jobId, Guid daemonId, DateTime now, TimeSpan releaseDelay) + { + await using var connection = new SqlConnection(ConnectionString); + // The sproc always returns exactly one row (the Outcome column plus a uniform set of nullable snapshot + // columns) whose names match PamRotationClaimResult's properties exactly, so it maps directly. + return await connection.QuerySingleAsync( + "[dbo].[PamRotationJob_Claim]", + new + { + JobId = jobId, + AttemptId = CoreHelpers.GenerateComb(), + DaemonId = daemonId, + Now = now, + ReleaseDelaySeconds = (int)releaseDelay.TotalSeconds, + }, + commandType: CommandType.StoredProcedure); + } + + public async Task> GetManyClaimableByDaemonIdAsync(Guid daemonId, DateTime now) + { + await using var connection = new SqlConnection(ConnectionString); + var results = await connection.QueryAsync( + "[dbo].[PamRotationJob_ReadManyClaimableByDaemonId]", + new { DaemonId = daemonId, Now = now }, + commandType: CommandType.StoredProcedure); + + return results.ToList(); + } + + public async Task> GetManyByConfigIdAsync(Guid configId) + { + await using var connection = new SqlConnection(ConnectionString); + using var results = await connection.QueryMultipleAsync( + "[dbo].[PamRotationJob_ReadManyByConfigId]", + new { RotationConfigId = configId }, + commandType: CommandType.StoredProcedure); + + var jobs = (await results.ReadAsync()).ToList(); + var attemptsByJobId = (await results.ReadAsync()) + .GroupBy(attempt => attempt.JobId) + .ToDictionary(group => group.Key, group => group.ToList()); + + return jobs + .Select(job => PamRotationJobDetails.From( + job, + attemptsByJobId.TryGetValue(job.Id, out var attempts) ? attempts : new List())) + .ToList(); + } + + public async Task GetAttemptByIdAsync(Guid attemptId) + { + await using var connection = new SqlConnection(ConnectionString); + var results = await connection.QueryAsync( + "[dbo].[PamRotationAttempt_ReadById]", + new { Id = attemptId }, + commandType: CommandType.StoredProcedure); + + return results.SingleOrDefault(); + } + + public async Task AcceptCipherWriteAsync(Guid attemptId, Guid daemonId, string cipherData, + DateTime lastKnownRevisionDate, DateTime now) + { + await using var connection = new SqlConnection(ConnectionString); + var result = await connection.ExecuteScalarAsync( + "[dbo].[PamRotationAttempt_AcceptCipherWrite]", + new + { + AttemptId = attemptId, + DaemonId = daemonId, + CipherData = cipherData, + LastKnownRevisionDate = lastKnownRevisionDate, + Now = now, + }, + commandType: CommandType.StoredProcedure); + + return (PamRotationCipherWriteOutcome)result; + } + + public async Task MarkAttemptRotatedAsync(Guid attemptId, Guid daemonId, + PamSessionTerminationOutcome sessionTermination, DateTime now) + { + await using var connection = new SqlConnection(ConnectionString); + var result = await connection.ExecuteScalarAsync( + "[dbo].[PamRotationAttempt_MarkRotated]", + new + { + AttemptId = attemptId, + DaemonId = daemonId, + SessionTermination = (byte)sessionTermination, + Now = now, + }, + commandType: CommandType.StoredProcedure); + + return (PamRotationAttemptResolveOutcome)result; + } + + public async Task MarkAttemptErroredAsync(Guid attemptId, Guid daemonId, string? failureReason, + PamRotationSyncState syncState, DateTime now, int maxAttempts, TimeSpan retryBaseDelay) + { + await using var connection = new SqlConnection(ConnectionString); + // Mapped through a nullable-safe intermediate row rather than straight onto PamRotationFailureResult: on the + // stale-report (Rejected) path the sproc returns NULL for JobStatus *and* ErroredAttemptCount, but + // PamRotationFailureResult.ErroredAttemptCount is a non-nullable int -- Dapper cannot bind a DB NULL onto + // that member. Coalesce to 0, matching "no errored-attempt count applies to a rejected report". + var row = await connection.QuerySingleAsync( + "[dbo].[PamRotationAttempt_MarkErrored]", + new + { + AttemptId = attemptId, + DaemonId = daemonId, + FailureReason = failureReason, + SyncState = (byte)syncState, + Now = now, + MaxAttempts = maxAttempts, + RetryBaseDelaySeconds = (int)retryBaseDelay.TotalSeconds, + }, + commandType: CommandType.StoredProcedure); + + return new PamRotationFailureResult + { + Outcome = (PamRotationAttemptResolveOutcome)row.Outcome, + JobStatus = row.JobStatus.HasValue ? (PamRotationJobStatus)row.JobStatus.Value : null, + ErroredAttemptCount = row.ErroredAttemptCount ?? 0, + }; + } + + public async Task> TimeoutDueAsync(DateTime now) + { + await using var connection = new SqlConnection(ConnectionString); + var results = await connection.QueryAsync( + "[dbo].[PamRotationJob_TimeoutDue]", + new { Now = now }, + commandType: CommandType.StoredProcedure); + + return results.ToList(); + } + + public async Task> ReleaseExpiredLeasesAsync(DateTime now, TimeSpan offlineAfter, + TimeSpan releaseDelay) + { + await using var connection = new SqlConnection(ConnectionString); + var results = await connection.QueryAsync( + "[dbo].[PamRotationJob_ReleaseExpiredLeases]", + new + { + Now = now, + OfflineAfterSeconds = (int)offlineAfter.TotalSeconds, + ReleaseDelaySeconds = (int)releaseDelay.TotalSeconds, + }, + commandType: CommandType.StoredProcedure); + + return results.ToList(); + } + + /// Raw shape of PamRotationAttempt_MarkErrored's result row — see the null-handling note in . + private sealed class MarkErroredRow + { + public int Outcome { get; set; } + public byte? JobStatus { get; set; } + public int? ErroredAttemptCount { get; set; } + } +} diff --git a/src/Infrastructure.Dapper/Pam/Repositories/PamTargetSystemRepository.cs b/src/Infrastructure.Dapper/Pam/Repositories/PamTargetSystemRepository.cs new file mode 100644 index 000000000000..caeedc693a9b --- /dev/null +++ b/src/Infrastructure.Dapper/Pam/Repositories/PamTargetSystemRepository.cs @@ -0,0 +1,33 @@ +using System.Data; +using Bit.Core.Settings; +using Bit.Infrastructure.Dapper.Repositories; +using Bit.Pam.Entities; +using Bit.Pam.Repositories; +using Dapper; +using Microsoft.Data.SqlClient; + +#nullable enable + +namespace Bit.Infrastructure.Dapper.Pam.Repositories; + +public class PamTargetSystemRepository : Repository, IPamTargetSystemRepository +{ + public PamTargetSystemRepository(GlobalSettings globalSettings) + : this(globalSettings.SqlServer.ConnectionString, globalSettings.SqlServer.ReadOnlyConnectionString) + { } + + public PamTargetSystemRepository(string connectionString, string readOnlyConnectionString) + : base(connectionString, readOnlyConnectionString) + { } + + public async Task> GetManyByOrganizationIdAsync(Guid organizationId) + { + await using var connection = new SqlConnection(ConnectionString); + var results = await connection.QueryAsync( + $"[{Schema}].[PamTargetSystem_ReadByOrganizationId]", + new { OrganizationId = organizationId }, + commandType: CommandType.StoredProcedure); + + return results.ToList(); + } +} diff --git a/src/Pam.Domain/Models/PamExpiredLease.cs b/src/Pam.Domain/Models/PamExpiredLease.cs new file mode 100644 index 000000000000..8b0c45fcc259 --- /dev/null +++ b/src/Pam.Domain/Models/PamExpiredLease.cs @@ -0,0 +1,18 @@ +namespace Bit.Pam.Models; + +/// +/// One lease the natural-expiry sweep flipped from to +/// because its window closed on its own (no revoke or cancel +/// involved) — the row IAccessLeaseRepository.ExpireDueAsync returns for the deferred LeaseExpired audit +/// event and the rotation access-end trigger. +/// +public record PamExpiredLease +{ + public required Guid Id { get; init; } + public required Guid OrganizationId { get; init; } + public required Guid CollectionId { get; init; } + public required Guid CipherId { get; init; } + public required Guid RequesterId { get; init; } + public required DateTime NotBefore { get; init; } + public required DateTime NotAfter { get; init; } +} diff --git a/src/Pam.Domain/Repositories/IAccessLeaseRepository.cs b/src/Pam.Domain/Repositories/IAccessLeaseRepository.cs index 91659843b77b..80a2cdf61431 100644 --- a/src/Pam.Domain/Repositories/IAccessLeaseRepository.cs +++ b/src/Pam.Domain/Repositories/IAccessLeaseRepository.cs @@ -1,5 +1,6 @@ using Bit.Pam.Entities; using Bit.Pam.Enums; +using Bit.Pam.Models; namespace Bit.Pam.Repositories; @@ -56,4 +57,15 @@ Task CreateFromApprovedRequestAsync(AccessLease lease, D /// already have its id assigned. /// Task RevokeAsync(AccessLease lease, AccessLeaseStatus endStatus, AccessDecision auditDecision, DateTime now); + + /// + /// Deviation: no interface in the ground-truth contract declared the natural-expiry sweep + /// (AccessLease_ExpireDue), even though the sproc exists. Added here — rather than on + /// IPamRotationJobRepository, whose sweeps are all rotation-job-shaped — because the sproc operates + /// purely on and sits naturally alongside , the other + /// lease-ending write. Flips every lease whose window closed on its own + /// (NotAfter <= now) to , returning one row per expired lease + /// for the caller's (deferred) LeaseExpired audit emission / access-end rotation trigger. + /// + Task> ExpireDueAsync(DateTime now); } From 0527fc7c938dbc4c092b2e22dc99025ea89eb034 Mon Sep 17 00:00:00 2001 From: Hinton Date: Mon, 6 Jul 2026 14:29:01 +0200 Subject: [PATCH 05/10] [PM-39040] Wire rotation daemon identity on the generic ApiKey credential Daemons authenticate via OAuth client-credentials exactly like Secrets Manager machine accounts, reusing dbo.ApiKey as the credential store (ServiceAccountId null; PamDaemon.ApiKeyId owns the link). The daemon. client-id prefix resolves a new client provider that gates on daemon enrollment and org Enabled/UsePam; every token response returns the wrapped org key (encrypted_payload, zero-knowledge). Adds the api.pam.rotation scope, RotationDaemon client type and claims parsing, an exact-claims authorization policy, a LaunchDarkly context for daemon principals, and the previously missing ApiKey_ReadById procedure. --- src/Core/Auth/Identity/IdentityClientType.cs | 3 +- src/Core/Auth/Identity/Policies.cs | 13 ++++ src/Core/Auth/IdentityServer/ApiScopes.cs | 2 + src/Core/Context/CurrentContext.cs | 8 ++ src/Core/Context/ICurrentContext.cs | 2 + .../LaunchDarklyFeatureService.cs | 27 +++++++ src/Identity/Identity.csproj | 1 + src/Identity/IdentityServer/ApiResources.cs | 1 + .../PamDaemonClientProvider.cs | 77 +++++++++++++++++++ .../CustomTokenRequestValidator.cs | 3 +- .../Utilities/ServiceCollectionExtensions.cs | 1 + .../ApiKey/ApiKey_ReadById.sql | 13 ++++ .../2026-07-06_02_ApiKeyReadById.sql | 27 +++++++ 13 files changed, 176 insertions(+), 2 deletions(-) create mode 100644 src/Identity/IdentityServer/ClientProviders/PamDaemonClientProvider.cs create mode 100644 src/Sql/dbo/SecretsManager/Stored Procedures/ApiKey/ApiKey_ReadById.sql create mode 100644 util/Migrator/DbScripts/2026-07-06_02_ApiKeyReadById.sql diff --git a/src/Core/Auth/Identity/IdentityClientType.cs b/src/Core/Auth/Identity/IdentityClientType.cs index 113877135dee..3b1902bff64c 100644 --- a/src/Core/Auth/Identity/IdentityClientType.cs +++ b/src/Core/Auth/Identity/IdentityClientType.cs @@ -5,5 +5,6 @@ public enum IdentityClientType : byte User = 0, Organization = 1, ServiceAccount = 2, - Send = 3 + Send = 3, + RotationDaemon = 4 } diff --git a/src/Core/Auth/Identity/Policies.cs b/src/Core/Auth/Identity/Policies.cs index 698a89000627..24a600694270 100644 --- a/src/Core/Auth/Identity/Policies.cs +++ b/src/Core/Auth/Identity/Policies.cs @@ -95,4 +95,17 @@ public static class Policies /// /// public const string Secrets = "Secrets"; + + /// + /// Policy to restrict access to API endpoints intended for use by PAM rotation daemons only. + /// + /// + /// + /// Can be used with the Authorize attribute, for example: + /// + /// [Authorize(Policy = Policies.PamRotationDaemon)] + /// + /// + /// + public const string PamRotationDaemon = "PamRotationDaemon"; } diff --git a/src/Core/Auth/IdentityServer/ApiScopes.cs b/src/Core/Auth/IdentityServer/ApiScopes.cs index 8836a168b666..1133ff3a9ec1 100644 --- a/src/Core/Auth/IdentityServer/ApiScopes.cs +++ b/src/Core/Auth/IdentityServer/ApiScopes.cs @@ -10,6 +10,7 @@ public static class ApiScopes public const string ApiOrganization = "api.organization"; public const string ApiPush = "api.push"; public const string ApiSecrets = "api.secrets"; + public const string ApiPamRotation = "api.pam.rotation"; public const string Internal = "internal"; public const string ApiSendAccess = "api.send.access"; @@ -24,6 +25,7 @@ public static IEnumerable GetApiScopes() new(ApiInstallation, "API Installation Access"), new(Internal, "Internal Access"), new(ApiSecrets, "Secrets Manager Access"), + new(ApiPamRotation, "PAM Rotation Daemon Access"), new(ApiSendAccess, "API Send Access"), }; } diff --git a/src/Core/Context/CurrentContext.cs b/src/Core/Context/CurrentContext.cs index f7d098e54086..7105e84a9629 100644 --- a/src/Core/Context/CurrentContext.cs +++ b/src/Core/Context/CurrentContext.cs @@ -43,6 +43,8 @@ public class CurrentContext( public virtual bool ClientVersionIsPrerelease { get; set; } public virtual IdentityClientType IdentityClientType { get; set; } public virtual Guid? ServiceAccountOrganizationId { get; set; } + public virtual Guid? PamDaemonId { get; set; } + public virtual Guid? PamDaemonOrganizationId { get; set; } public async virtual Task BuildAsync(HttpContext httpContext, GlobalSettings globalSettings) { @@ -155,6 +157,12 @@ public virtual Task SetContextAsync(ClaimsPrincipal user) ServiceAccountOrganizationId = new Guid(GetClaimValue(claimsDict, Claims.Organization)); } + if (IdentityClientType == IdentityClientType.RotationDaemon) + { + PamDaemonId = subIdGuid; + PamDaemonOrganizationId = new Guid(GetClaimValue(claimsDict, Claims.Organization)); + } + DeviceIdentifier = GetClaimValue(claimsDict, Claims.Device); if (Enum.TryParse(GetClaimValue(claimsDict, Claims.DeviceType), out DeviceType deviceType)) diff --git a/src/Core/Context/ICurrentContext.cs b/src/Core/Context/ICurrentContext.cs index 14abfdcaccff..b5f0b08b8f19 100644 --- a/src/Core/Context/ICurrentContext.cs +++ b/src/Core/Context/ICurrentContext.cs @@ -31,6 +31,8 @@ public interface ICurrentContext Guid? InstallationId { get; set; } Guid? OrganizationId { get; set; } IdentityClientType IdentityClientType { get; set; } + Guid? PamDaemonId { get; set; } + Guid? PamDaemonOrganizationId { get; set; } string ClientId { get; set; } Version? ClientVersion { get; set; } bool ClientVersionIsPrerelease { get; set; } diff --git a/src/Core/Services/Implementations/LaunchDarklyFeatureService.cs b/src/Core/Services/Implementations/LaunchDarklyFeatureService.cs index f118146cb17f..00bc5c557615 100644 --- a/src/Core/Services/Implementations/LaunchDarklyFeatureService.cs +++ b/src/Core/Services/Implementations/LaunchDarklyFeatureService.cs @@ -22,6 +22,7 @@ public class LaunchDarklyFeatureService : IFeatureService private const string _contextKindDevice = "device"; private const string _contextKindOrganization = "organization"; private const string _contextKindServiceAccount = "service-account"; + private const string _contextKindPamRotationDaemon = "pam-rotation-daemon"; private const string _contextAttributeClientVersion = "client-version"; private const string _contextAttributeClientVersionIsPrerelease = "client-version-is-prerelease"; @@ -237,6 +238,32 @@ void SetCommonContextAttributes(ContextBuilder builder) } } break; + + case IdentityClientType.RotationDaemon: + { + // A PAM rotation daemon's bearer token carries no device/user/organization claim recognized by + // the other branches above, so without this case the multi-context builder ends up empty -- + // BoolVariation then receives an invalid Context and silently falls back to defaultValue (false) + // for every flag, on every daemon-facing request. That looked like the PamRotation flag being + // off even when explicitly enabled, so give the daemon its own context kind (mirrors + // ServiceAccount's organization-keyed fallback). + if (_currentContext.PamDaemonId.HasValue) + { + var ldDaemon = LaunchDarkly.Sdk.Context.Builder(_currentContext.PamDaemonId.Value.ToString()); + + ldDaemon.Kind(_contextKindPamRotationDaemon); + SetCommonContextAttributes(ldDaemon); + + if (_currentContext.PamDaemonOrganizationId.HasValue) + { + ldDaemon.Set(_contextAttributeOrganizations, + LdValue.ArrayOf(LdValue.Of(_currentContext.PamDaemonOrganizationId.Value.ToString()))); + } + + builder.Add(ldDaemon.Build()); + } + } + break; } return builder.Build(); diff --git a/src/Identity/Identity.csproj b/src/Identity/Identity.csproj index f31d8c005e05..4d322e26434a 100644 --- a/src/Identity/Identity.csproj +++ b/src/Identity/Identity.csproj @@ -13,6 +13,7 @@ + diff --git a/src/Identity/IdentityServer/ApiResources.cs b/src/Identity/IdentityServer/ApiResources.cs index d225a7ea33a2..cbf762871d37 100644 --- a/src/Identity/IdentityServer/ApiResources.cs +++ b/src/Identity/IdentityServer/ApiResources.cs @@ -37,6 +37,7 @@ public static IEnumerable GetApiResources() new(ApiScopes.ApiOrganization, new[] { JwtClaimTypes.Subject }), new(ApiScopes.ApiInstallation, new[] { JwtClaimTypes.Subject }), new(ApiScopes.ApiSecrets, new[] { JwtClaimTypes.Subject, Claims.Organization }), + new(ApiScopes.ApiPamRotation, new[] { JwtClaimTypes.Subject, Claims.Organization, Claims.Type }), }; } } diff --git a/src/Identity/IdentityServer/ClientProviders/PamDaemonClientProvider.cs b/src/Identity/IdentityServer/ClientProviders/PamDaemonClientProvider.cs new file mode 100644 index 000000000000..7703d134c959 --- /dev/null +++ b/src/Identity/IdentityServer/ClientProviders/PamDaemonClientProvider.cs @@ -0,0 +1,77 @@ +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Auth.Identity; +using Bit.Core.SecretsManager.Repositories; +using Bit.Pam.Enums; +using Bit.Pam.Repositories; +using Duende.IdentityModel; +using Duende.IdentityServer.Models; + +namespace Bit.Identity.IdentityServer.ClientProviders; + +/// +/// Resolves the OAuth client-credentials for a PAM rotation daemon. The daemon's machine +/// credential is a generic dbo.ApiKey row (mirrors Secrets Manager's machine-account mechanic in +/// ) with a null ServiceAccountId; the owner link is inverted via +/// PamDaemon.ApiKeyId. Authentication is denied unless the daemon is Enrolled and its organization has PAM +/// enabled and licensed — the server never holds the daemon's plaintext org key, only the ciphertext +/// EncryptedPayload handed back on every token response (zero-knowledge; see +/// "encryptedPayload"). +/// +internal class PamDaemonClientProvider : IClientProvider +{ + public const string DaemonPrefix = "daemon"; + + private readonly IApiKeyRepository _apiKeyRepository; + private readonly IPamDaemonRepository _pamDaemonRepository; + + public PamDaemonClientProvider(IApiKeyRepository apiKeyRepository, IPamDaemonRepository pamDaemonRepository) + { + _apiKeyRepository = apiKeyRepository; + _pamDaemonRepository = pamDaemonRepository; + } + + public async Task GetAsync(string identifier) + { + if (!Guid.TryParse(identifier, out var apiKeyId)) + { + return null; + } + + var apiKey = await _apiKeyRepository.GetByIdAsync(apiKeyId); + if (apiKey == null || apiKey.ExpireAt <= DateTime.UtcNow) + { + return null; + } + + var daemonDetails = await _pamDaemonRepository.GetDetailsByApiKeyIdAsync(apiKeyId); + if (daemonDetails == null + || daemonDetails.Status != PamDaemonStatus.Enrolled + || !daemonDetails.OrganizationEnabled + || !daemonDetails.OrganizationUsePam) + { + return null; + } + + return new Client + { + ClientId = $"{DaemonPrefix}.{apiKeyId}", + RequireClientSecret = true, + ClientSecrets = { new Secret(apiKey.ClientSecretHash) }, + AllowedScopes = apiKey.GetScopes(), + AllowedGrantTypes = GrantTypes.ClientCredentials, + AccessTokenLifetime = 3600 * 1, + ClientClaimsPrefix = null, + Properties = new Dictionary { + {"encryptedPayload", apiKey.EncryptedPayload}, + }, + Claims = new List + { + new(JwtClaimTypes.Subject, daemonDetails.Id.ToString()), + new(Claims.Type, IdentityClientType.RotationDaemon.ToString()), + new(Claims.Organization, daemonDetails.OrganizationId.ToString()), + }, + }; + } +} diff --git a/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs b/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs index ab49e802bd3d..58940b5da9a8 100644 --- a/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs @@ -106,7 +106,8 @@ public async Task ValidateAsync(CustomTokenRequestValidationContext context) || clientId.StartsWith("organization") || clientId.StartsWith("installation") || clientId.StartsWith("internal") - || context.Result.ValidatedRequest.Client.AllowedScopes.Contains(ApiScopes.ApiSecrets)) + || context.Result.ValidatedRequest.Client.AllowedScopes.Contains(ApiScopes.ApiSecrets) + || context.Result.ValidatedRequest.Client.AllowedScopes.Contains(ApiScopes.ApiPamRotation)) { if (context.Result.ValidatedRequest.Client.Properties.TryGetValue("encryptedPayload", out var payload) && !string.IsNullOrWhiteSpace(payload)) diff --git a/src/Identity/Utilities/ServiceCollectionExtensions.cs b/src/Identity/Utilities/ServiceCollectionExtensions.cs index 049191dad4ed..b6f9dbee0a22 100644 --- a/src/Identity/Utilities/ServiceCollectionExtensions.cs +++ b/src/Identity/Utilities/ServiceCollectionExtensions.cs @@ -80,6 +80,7 @@ public static IIdentityServerBuilder AddCustomIdentityServerServices(this IServi services.AddClientProvider("user"); services.AddClientProvider("organization"); services.AddClientProvider(SecretsManagerApiKeyProvider.ApiKeyPrefix); + services.AddClientProvider(PamDaemonClientProvider.DaemonPrefix); if (CoreHelpers.SettingHasValue(globalSettings.IdentityServer.CosmosConnectionString)) { diff --git a/src/Sql/dbo/SecretsManager/Stored Procedures/ApiKey/ApiKey_ReadById.sql b/src/Sql/dbo/SecretsManager/Stored Procedures/ApiKey/ApiKey_ReadById.sql new file mode 100644 index 000000000000..ca4be7db6aad --- /dev/null +++ b/src/Sql/dbo/SecretsManager/Stored Procedures/ApiKey/ApiKey_ReadById.sql @@ -0,0 +1,13 @@ +CREATE PROCEDURE [dbo].[ApiKey_ReadById] + @Id UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT + * + FROM + [dbo].[ApiKeyView] + WHERE + [Id] = @Id +END diff --git a/util/Migrator/DbScripts/2026-07-06_02_ApiKeyReadById.sql b/util/Migrator/DbScripts/2026-07-06_02_ApiKeyReadById.sql new file mode 100644 index 000000000000..479e7a67aef7 --- /dev/null +++ b/util/Migrator/DbScripts/2026-07-06_02_ApiKeyReadById.sql @@ -0,0 +1,27 @@ +-- The generic Repository.GetByIdAsync convention calls [dbo].[{Table}_ReadById] -- [dbo].[ApiKey] never had +-- one (only ApiKey_ReadByServiceAccountId and the ServiceAccount-joined ApiKeyDetails_ReadById), because every prior +-- caller looked ApiKey up by ServiceAccountId or ApiKeyDetails. PAM's rotation-daemon credential is a bare ApiKey row +-- (ServiceAccountId NULL, owner link inverted via PamDaemon.ApiKeyId -- see Bit.Pam.Entities.PamDaemon), and +-- PamDaemonClientProvider (src/Identity/IdentityServer/ClientProviders/PamDaemonClientProvider.cs) resolves the +-- daemon's OAuth client via IApiKeyRepository.GetByIdAsync(apiKeyId), so this sproc is required for daemon token +-- issuance to work at all. + +IF OBJECT_ID('[dbo].[ApiKey_ReadById]') IS NULL +BEGIN + EXECUTE (' + CREATE PROCEDURE [dbo].[ApiKey_ReadById] + @Id UNIQUEIDENTIFIER + AS + BEGIN + SET NOCOUNT ON + + SELECT + * + FROM + [dbo].[ApiKeyView] + WHERE + [Id] = @Id + END + ') +END +GO From 272a3103f6820c8edeb68b30a817f93f6d097000 Mon Sep 17 00:00:00 2001 From: Hinton Date: Mon, 6 Jul 2026 14:29:35 +0200 Subject: [PATCH 06/10] [PM-39040] Add rotation application layer Commands and queries implementing rotation-server.allium's rules: target/daemon/config administration with per-row org checks and audit emission, OfferRotation as the single job-creation point, the claim/report/cipher-write daemon paths with exact-complement rejection auditing, the access-end trigger handler, cron scheduling via Quartz (UTC, interval floor), and PamRotationOptions with spec defaults. Feature-flagged under pm-39040-pam-rotation. --- .../Commands/AssignDaemonToTargetCommand.cs | 93 +++++++++++ .../Commands/ClaimRotationJobCommand.cs | 74 +++++++++ .../Commands/CreateRotationConfigCommand.cs | 127 +++++++++++++++ .../Commands/DeleteRotationConfigCommand.cs | 63 ++++++++ .../Commands/HandleAccessGrantEndedCommand.cs | 105 +++++++++++++ .../IAssignDaemonToTargetCommand.cs | 11 ++ .../Interfaces/IClaimRotationJobCommand.cs | 17 +++ .../ICreateRotationConfigCommand.cs | 24 +++ .../IDeleteRotationConfigCommand.cs | 10 ++ .../IHandleAccessGrantEndedCommand.cs | 15 ++ .../Interfaces/IOfferRotationCommand.cs | 16 ++ .../Interfaces/IPauseRotationCommand.cs | 7 + .../IRecordManualRotationCommand.cs | 11 ++ .../Interfaces/IRegisterDaemonCommand.cs | 15 ++ .../IRegisterTargetSystemCommand.cs | 23 +++ .../Interfaces/IRenameTargetSystemCommand.cs | 7 + .../IReportRotationFailedCommand.cs | 20 +++ .../IReportRotationSucceededCommand.cs | 17 +++ .../Interfaces/IResumeRotationCommand.cs | 12 ++ .../Interfaces/IRevokeDaemonCommand.cs | 11 ++ .../ISetTargetSystemStatusCommand.cs | 10 ++ .../Interfaces/ISubmitCipherUpdateCommand.cs | 14 ++ .../Interfaces/ITriggerRotationCommand.cs | 12 ++ .../IUnassignDaemonFromTargetCommand.cs | 7 + .../IUpdateRotationAccountCommand.cs | 15 ++ .../IUpdateRotationSettingsCommand.cs | 14 ++ .../IUpdateTargetSystemPolicyCommand.cs | 17 +++ .../Rotation/Commands/OfferRotationCommand.cs | 92 +++++++++++ .../Rotation/Commands/PauseRotationCommand.cs | 58 +++++++ .../Commands/RecordManualRotationCommand.cs | 79 ++++++++++ .../Commands/RegisterDaemonCommand.cs | 103 +++++++++++++ .../Commands/RegisterTargetSystemCommand.cs | 97 ++++++++++++ .../Commands/RenameTargetSystemCommand.cs | 62 ++++++++ .../Commands/ReportRotationFailedCommand.cs | 144 ++++++++++++++++++ .../ReportRotationSucceededCommand.cs | 103 +++++++++++++ .../Commands/ResumeRotationCommand.cs | 85 +++++++++++ .../Rotation/Commands/RevokeDaemonCommand.cs | 72 +++++++++ .../Commands/SetTargetSystemStatusCommand.cs | 61 ++++++++ .../Commands/SubmitCipherUpdateCommand.cs | 93 +++++++++++ .../Commands/TriggerRotationCommand.cs | 66 ++++++++ .../UnassignDaemonFromTargetCommand.cs | 67 ++++++++ .../Commands/UpdateRotationAccountCommand.cs | 105 +++++++++++++ .../Commands/UpdateRotationSettingsCommand.cs | 70 +++++++++ .../UpdateTargetSystemPolicyCommand.cs | 72 +++++++++ .../Rotation/IRotationScheduleCalculator.cs | 23 +++ .../Pam/Rotation/Models/PamDaemonListItem.cs | 13 ++ .../Models/PamDaemonRegistrationResult.cs | 10 ++ .../Models/PamRotationConfigHistory.cs | 12 ++ .../Pam/Rotation/PamRotationOptions.cs | 37 +++++ .../Queries/GetRotationCipherQuery.cs | 57 +++++++ .../Queries/GetRotationConfigDetailsQuery.cs | 33 ++++ .../Interfaces/IGetRotationCipherQuery.cs | 14 ++ .../IGetRotationConfigDetailsQuery.cs | 13 ++ .../Interfaces/IListClaimableJobsQuery.cs | 12 ++ .../Queries/Interfaces/IListDaemonsQuery.cs | 9 ++ .../Interfaces/IListRotationConfigsQuery.cs | 9 ++ .../Interfaces/IListTargetSystemsQuery.cs | 9 ++ .../Queries/ListClaimableJobsQuery.cs | 21 +++ .../Pam/Rotation/Queries/ListDaemonsQuery.cs | 42 +++++ .../Queries/ListRotationConfigsQuery.cs | 19 +++ .../Queries/ListTargetSystemsQuery.cs | 19 +++ .../Rotation/RotationScheduleCalculator.cs | 64 ++++++++ src/Core/Constants.cs | 2 + 63 files changed, 2614 insertions(+) create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Commands/AssignDaemonToTargetCommand.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Commands/ClaimRotationJobCommand.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Commands/CreateRotationConfigCommand.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Commands/DeleteRotationConfigCommand.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Commands/HandleAccessGrantEndedCommand.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/IAssignDaemonToTargetCommand.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/IClaimRotationJobCommand.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/ICreateRotationConfigCommand.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/IDeleteRotationConfigCommand.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/IHandleAccessGrantEndedCommand.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/IOfferRotationCommand.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/IPauseRotationCommand.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/IRecordManualRotationCommand.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/IRegisterDaemonCommand.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/IRegisterTargetSystemCommand.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/IRenameTargetSystemCommand.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/IReportRotationFailedCommand.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/IReportRotationSucceededCommand.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/IResumeRotationCommand.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/IRevokeDaemonCommand.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/ISetTargetSystemStatusCommand.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/ISubmitCipherUpdateCommand.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/ITriggerRotationCommand.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/IUnassignDaemonFromTargetCommand.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/IUpdateRotationAccountCommand.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/IUpdateRotationSettingsCommand.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/IUpdateTargetSystemPolicyCommand.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Commands/OfferRotationCommand.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Commands/PauseRotationCommand.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Commands/RecordManualRotationCommand.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Commands/RegisterDaemonCommand.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Commands/RegisterTargetSystemCommand.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Commands/RenameTargetSystemCommand.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Commands/ReportRotationFailedCommand.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Commands/ReportRotationSucceededCommand.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Commands/ResumeRotationCommand.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Commands/RevokeDaemonCommand.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Commands/SetTargetSystemStatusCommand.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Commands/SubmitCipherUpdateCommand.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Commands/TriggerRotationCommand.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Commands/UnassignDaemonFromTargetCommand.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Commands/UpdateRotationAccountCommand.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Commands/UpdateRotationSettingsCommand.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Commands/UpdateTargetSystemPolicyCommand.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/IRotationScheduleCalculator.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Models/PamDaemonListItem.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Models/PamDaemonRegistrationResult.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Models/PamRotationConfigHistory.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/PamRotationOptions.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Queries/GetRotationCipherQuery.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Queries/GetRotationConfigDetailsQuery.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Queries/Interfaces/IGetRotationCipherQuery.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Queries/Interfaces/IGetRotationConfigDetailsQuery.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Queries/Interfaces/IListClaimableJobsQuery.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Queries/Interfaces/IListDaemonsQuery.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Queries/Interfaces/IListRotationConfigsQuery.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Queries/Interfaces/IListTargetSystemsQuery.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Queries/ListClaimableJobsQuery.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Queries/ListDaemonsQuery.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Queries/ListRotationConfigsQuery.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Queries/ListTargetSystemsQuery.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/RotationScheduleCalculator.cs diff --git a/bitwarden_license/src/Services/Pam/Rotation/Commands/AssignDaemonToTargetCommand.cs b/bitwarden_license/src/Services/Pam/Rotation/Commands/AssignDaemonToTargetCommand.cs new file mode 100644 index 000000000000..787b4ccf09ec --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Commands/AssignDaemonToTargetCommand.cs @@ -0,0 +1,93 @@ +using Bit.Core.Exceptions; +using Bit.Pam.Entities; +using Bit.Pam.Enums; +using Bit.Pam.Models; +using Bit.Pam.Repositories; +using Bit.Pam.Services; +using Bit.Services.Pam.Rotation.Commands.Interfaces; + +namespace Bit.Services.Pam.Rotation.Commands; + +/// +public class AssignDaemonToTargetCommand : IAssignDaemonToTargetCommand +{ + private readonly IPamDaemonRepository _daemonRepository; + private readonly IPamTargetSystemRepository _targetSystemRepository; + private readonly IAccessAuditEventEmitter _accessAuditEventEmitter; + private readonly TimeProvider _timeProvider; + + public AssignDaemonToTargetCommand( + IPamDaemonRepository daemonRepository, + IPamTargetSystemRepository targetSystemRepository, + IAccessAuditEventEmitter accessAuditEventEmitter, + TimeProvider timeProvider) + { + _daemonRepository = daemonRepository; + _targetSystemRepository = targetSystemRepository; + _accessAuditEventEmitter = accessAuditEventEmitter; + _timeProvider = timeProvider; + } + + public async Task AssignAsync(Guid organizationId, Guid actingUserId, Guid daemonId, Guid targetSystemId) + { + var daemon = await _daemonRepository.GetByIdAsync(daemonId); + if (daemon is null || daemon.OrganizationId != organizationId) + { + throw new NotFoundException(); + } + + var target = await _targetSystemRepository.GetByIdAsync(targetSystemId); + if (target is null || target.OrganizationId != organizationId) + { + throw new NotFoundException(); + } + + // Both rows were just loaded against the same route organization, so daemon.OrganizationId == + // target.OrganizationId == organizationId holds by construction (the cross-cutting same-org invariant). + if (daemon.Status != PamDaemonStatus.Enrolled) + { + throw new BadRequestException("This daemon has been revoked."); + } + + if (target.Method != PamTargetSystemMethod.Automatic) + { + throw new BadRequestException("Only automatic target systems can be assigned a daemon."); + } + + if (await _daemonRepository.AssignmentExistsAsync(daemonId, targetSystemId)) + { + throw new BadRequestException("This daemon is already assigned to this target system."); + } + + var now = _timeProvider.GetUtcNow().UtcDateTime; + var assignment = new PamDaemonTargetAssignment + { + DaemonId = daemonId, + TargetSystemId = targetSystemId, + OrganizationId = organizationId, + CreationDate = now, + }; + // CreateAssignmentAsync is a guarded custom insert, not the generic single-object CreateAsync -- it expects + // the id to already be assigned. + assignment.SetNewId(); + + // audit (before/after): both names are snapshotted here (the commands hold the entities) rather than joined + // on read. + var audit = new AccessAuditEventData + { + Kind = AccessAuditEventKind.DaemonAssignedToTarget, + OccurredAt = now, + OrganizationId = organizationId, + ActorId = actingUserId, + DaemonId = daemon.Id, + DaemonName = daemon.Name, + TargetSystemId = target.Id, + TargetSystemName = target.Name, + }; + await _accessAuditEventEmitter.EmitAsync(audit with { Phase = AccessAuditEventPhase.Attempt }); + + await _daemonRepository.CreateAssignmentAsync(assignment); + + await _accessAuditEventEmitter.EmitAsync(audit with { Phase = AccessAuditEventPhase.Outcome }); + } +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Commands/ClaimRotationJobCommand.cs b/bitwarden_license/src/Services/Pam/Rotation/Commands/ClaimRotationJobCommand.cs new file mode 100644 index 000000000000..a3b164a7df75 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Commands/ClaimRotationJobCommand.cs @@ -0,0 +1,74 @@ +using Bit.Core.Exceptions; +using Bit.Pam.Enums; +using Bit.Pam.Models; +using Bit.Pam.Repositories; +using Bit.Pam.Services; +using Bit.Services.Pam.Rotation.Commands.Interfaces; +using Microsoft.Extensions.Options; + +namespace Bit.Services.Pam.Rotation.Commands; + +/// +public class ClaimRotationJobCommand : IClaimRotationJobCommand +{ + private readonly IPamRotationJobRepository _jobRepository; + private readonly IPamDaemonRepository _daemonRepository; + private readonly IAccessAuditEventEmitter _accessAuditEventEmitter; + private readonly IOptions _options; + private readonly TimeProvider _timeProvider; + + public ClaimRotationJobCommand( + IPamRotationJobRepository jobRepository, + IPamDaemonRepository daemonRepository, + IAccessAuditEventEmitter accessAuditEventEmitter, + IOptions options, + TimeProvider timeProvider) + { + _jobRepository = jobRepository; + _daemonRepository = daemonRepository; + _accessAuditEventEmitter = accessAuditEventEmitter; + _options = options; + _timeProvider = timeProvider; + } + + public async Task ClaimAsync(Guid daemonId, Guid jobId) + { + var now = _timeProvider.GetUtcNow().UtcDateTime; + var result = await _jobRepository.ClaimAsync(jobId, daemonId, now, _options.Value.ReleaseDelay); + + switch (result.Outcome) + { + case PamRotationClaimOutcome.Claimed: + // The daemon's organization is the config's organization by construction of EligibleClaimsOnly, so + // it is a cheap, correct stand-in for the audit's required OrganizationId. + var daemon = await _daemonRepository.GetByIdAsync(daemonId); + var job = await _jobRepository.GetByIdAsync(jobId); + + var audit = new AccessAuditEventData + { + Kind = AccessAuditEventKind.RotationDispatched, + OccurredAt = now, + OrganizationId = daemon?.OrganizationId ?? Guid.Empty, + ActorId = null, + DaemonId = daemonId, + DaemonName = daemon?.Name, + RotationJobId = jobId, + RotationConfigId = job?.RotationConfigId, + CipherId = result.CipherId, + TargetSystemId = result.TargetSystemId, + TargetSystemName = result.TargetSystemName, + RotationSource = result.Source, + }; + await _accessAuditEventEmitter.EmitAsync(audit); + return result; + + case PamRotationClaimOutcome.NotClaimable: + // Another daemon likely won the race -- 409, retry a different job. + throw new ConflictException("This job is no longer claimable."); + + default: + // NotEligible: never leak *why* -- looks the same as a job that never existed. + throw new NotFoundException(); + } + } +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Commands/CreateRotationConfigCommand.cs b/bitwarden_license/src/Services/Pam/Rotation/Commands/CreateRotationConfigCommand.cs new file mode 100644 index 000000000000..9fa82e260ee5 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Commands/CreateRotationConfigCommand.cs @@ -0,0 +1,127 @@ +using Bit.Core.Exceptions; +using Bit.Core.Vault.Repositories; +using Bit.Pam.Entities; +using Bit.Pam.Enums; +using Bit.Pam.Models; +using Bit.Pam.Repositories; +using Bit.Pam.Services; +using Bit.Services.Pam.Rotation.Commands.Interfaces; +using Microsoft.Extensions.Options; + +namespace Bit.Services.Pam.Rotation.Commands; + +/// +public class CreateRotationConfigCommand : ICreateRotationConfigCommand +{ + private readonly IPamTargetSystemRepository _targetSystemRepository; + private readonly IPamRotationConfigRepository _configRepository; + private readonly ICipherRepository _cipherRepository; + private readonly IRotationScheduleCalculator _scheduleCalculator; + private readonly IAccessAuditEventEmitter _accessAuditEventEmitter; + private readonly IOptions _options; + private readonly TimeProvider _timeProvider; + + public CreateRotationConfigCommand( + IPamTargetSystemRepository targetSystemRepository, + IPamRotationConfigRepository configRepository, + ICipherRepository cipherRepository, + IRotationScheduleCalculator scheduleCalculator, + IAccessAuditEventEmitter accessAuditEventEmitter, + IOptions options, + TimeProvider timeProvider) + { + _targetSystemRepository = targetSystemRepository; + _configRepository = configRepository; + _cipherRepository = cipherRepository; + _scheduleCalculator = scheduleCalculator; + _accessAuditEventEmitter = accessAuditEventEmitter; + _options = options; + _timeProvider = timeProvider; + } + + public async Task CreateAsync( + Guid organizationId, + Guid actingUserId, + Guid cipherId, + Guid targetSystemId, + string accountIdentity, + bool terminateSessions, + string? scheduleCron, + bool rotateOnAccessEnd) + { + if (string.IsNullOrWhiteSpace(accountIdentity)) + { + throw new BadRequestException("Account identity is required."); + } + + var target = await _targetSystemRepository.GetByIdAsync(targetSystemId); + if (target is null || target.OrganizationId != organizationId) + { + throw new NotFoundException(); + } + + if (target.Status != PamTargetSystemStatus.Active) + { + throw new BadRequestException("The target system is not active."); + } + + // The cipher-in-org check is the generic (unfiltered) lookup, not the user-permission-scoped one -- this is + // an org-admin operation, not a per-user access check. + var cipher = await _cipherRepository.GetByIdAsync(cipherId); + if (cipher is null || cipher.OrganizationId != organizationId) + { + throw new NotFoundException(); + } + + if (await _configRepository.GetByCipherIdAsync(cipherId) is not null) + { + throw new BadRequestException("This cipher already has a rotation config."); + } + + if (terminateSessions && + !(target.Method == PamTargetSystemMethod.Automatic && target.SupportsSessionTermination == true)) + { + throw new BadRequestException( + "Session termination requires an automatic target system that supports it."); + } + + _scheduleCalculator.ValidateSchedule(scheduleCron, _options.Value.MinScheduleInterval); + + var now = _timeProvider.GetUtcNow().UtcDateTime; + var config = new PamRotationConfig + { + OrganizationId = organizationId, + CipherId = cipherId, + TargetSystemId = targetSystemId, + AccountIdentity = accountIdentity, + TerminateSessions = terminateSessions, + ScheduleCron = scheduleCron, + RotateOnAccessEnd = rotateOnAccessEnd, + NextRotationAt = _scheduleCalculator.GetNextOccurrence(scheduleCron, now), + Enabled = true, + CreationDate = now, + RevisionDate = now, + }; + + // audit (before/after): use an Attempt/Outcome pair for consistency with the other admin commands, even + // though the spec models this as a single reaction. + var audit = new AccessAuditEventData + { + Kind = AccessAuditEventKind.RotationConfigCreated, + OccurredAt = now, + OrganizationId = organizationId, + ActorId = actingUserId, + CipherId = cipherId, + TargetSystemId = target.Id, + TargetSystemName = target.Name, + }; + await _accessAuditEventEmitter.EmitAsync(audit with { Phase = AccessAuditEventPhase.Attempt }); + + var created = await _configRepository.CreateAsync(config); + + await _accessAuditEventEmitter.EmitAsync( + audit with { Phase = AccessAuditEventPhase.Outcome, RotationConfigId = created.Id }); + + return created; + } +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Commands/DeleteRotationConfigCommand.cs b/bitwarden_license/src/Services/Pam/Rotation/Commands/DeleteRotationConfigCommand.cs new file mode 100644 index 000000000000..ae850d1f3a40 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Commands/DeleteRotationConfigCommand.cs @@ -0,0 +1,63 @@ +using Bit.Core.Exceptions; +using Bit.Pam.Enums; +using Bit.Pam.Models; +using Bit.Pam.Repositories; +using Bit.Pam.Services; +using Bit.Services.Pam.Rotation.Commands.Interfaces; + +namespace Bit.Services.Pam.Rotation.Commands; + +/// +public class DeleteRotationConfigCommand : IDeleteRotationConfigCommand +{ + private readonly IPamRotationConfigRepository _configRepository; + private readonly IAccessAuditEventEmitter _accessAuditEventEmitter; + private readonly TimeProvider _timeProvider; + + public DeleteRotationConfigCommand( + IPamRotationConfigRepository configRepository, + IAccessAuditEventEmitter accessAuditEventEmitter, + TimeProvider timeProvider) + { + _configRepository = configRepository; + _accessAuditEventEmitter = accessAuditEventEmitter; + _timeProvider = timeProvider; + } + + public async Task DeleteAsync(Guid organizationId, Guid actingUserId, Guid configId) + { + var details = await _configRepository.GetDetailsByIdAsync(configId); + if (details is null || details.OrganizationId != organizationId) + { + throw new NotFoundException(); + } + + if (details.HasActiveJob) + { + throw new BadRequestException("This rotation config has an active job."); + } + + var now = _timeProvider.GetUtcNow().UtcDateTime; + + // audit (before/after): names are captured before the delete, since the durable record of them is this + // event, not the row. + var audit = new AccessAuditEventData + { + Kind = AccessAuditEventKind.RotationConfigDeleted, + OccurredAt = now, + OrganizationId = organizationId, + ActorId = actingUserId, + CipherId = details.CipherId, + RotationConfigId = details.Id, + TargetSystemId = details.TargetSystemId, + TargetSystemName = details.TargetSystemName, + }; + await _accessAuditEventEmitter.EmitAsync(audit with { Phase = AccessAuditEventPhase.Attempt }); + + // Cascades the config's jobs and attempts in the same transaction -- the audit trail above is the durable + // history, not these rows. + await _configRepository.DeleteWithJobsAsync(configId); + + await _accessAuditEventEmitter.EmitAsync(audit with { Phase = AccessAuditEventPhase.Outcome }); + } +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Commands/HandleAccessGrantEndedCommand.cs b/bitwarden_license/src/Services/Pam/Rotation/Commands/HandleAccessGrantEndedCommand.cs new file mode 100644 index 000000000000..f65f9f1db541 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Commands/HandleAccessGrantEndedCommand.cs @@ -0,0 +1,105 @@ +using Bit.Core; +using Bit.Core.Services; +using Bit.Pam.Entities; +using Bit.Pam.Enums; +using Bit.Pam.Models; +using Bit.Pam.Repositories; +using Bit.Pam.Services; +using Bit.Services.Pam.Rotation.Commands.Interfaces; + +namespace Bit.Services.Pam.Rotation.Commands; + +/// +public class HandleAccessGrantEndedCommand : IHandleAccessGrantEndedCommand +{ + private readonly IFeatureService _featureService; + private readonly IPamRotationConfigRepository _configRepository; + private readonly IPamTargetSystemRepository _targetSystemRepository; + private readonly IOfferRotationCommand _offerRotationCommand; + private readonly IAccessAuditEventEmitter _accessAuditEventEmitter; + private readonly TimeProvider _timeProvider; + + public HandleAccessGrantEndedCommand( + IFeatureService featureService, + IPamRotationConfigRepository configRepository, + IPamTargetSystemRepository targetSystemRepository, + IOfferRotationCommand offerRotationCommand, + IAccessAuditEventEmitter accessAuditEventEmitter, + TimeProvider timeProvider) + { + _featureService = featureService; + _configRepository = configRepository; + _targetSystemRepository = targetSystemRepository; + _offerRotationCommand = offerRotationCommand; + _accessAuditEventEmitter = accessAuditEventEmitter; + _timeProvider = timeProvider; + } + + public async Task HandleAsync(Guid cipherId) + { + if (!_featureService.IsEnabled(FeatureFlagKeys.PamRotation)) + { + return; + } + + var config = await _configRepository.GetByCipherIdAsync(cipherId); + if (config is null || !config.RotateOnAccessEnd) + { + return; + } + + // Paused/disabled is a no-op this iteration -- the deferred access-end latch (pending_access_end) would + // otherwise remember this and discharge it on Enable/Resume. + if (!config.Enabled) + { + return; + } + + var target = await _targetSystemRepository.GetByIdAsync(config.TargetSystemId); + if (target is null) + { + return; + } + + if (target.Method == PamTargetSystemMethod.Automatic) + { + // OfferRotationCommand re-checks can_offer (including target Active and no active job) and no-ops + // silently when it no longer holds -- no need to duplicate that guard here. + await _offerRotationCommand.OfferAsync(config.Id, PamRotationSource.AccessEnd); + return; + } + + // Manual target: there is no daemon to offer a job to, so the obligation is pulled due immediately. + var now = _timeProvider.GetUtcNow().UtcDateTime; + var toPersist = new PamRotationConfig + { + Id = config.Id, + OrganizationId = config.OrganizationId, + CipherId = config.CipherId, + TargetSystemId = config.TargetSystemId, + AccountIdentity = config.AccountIdentity, + TerminateSessions = config.TerminateSessions, + ScheduleCron = config.ScheduleCron, + RotateOnAccessEnd = config.RotateOnAccessEnd, + NextRotationAt = now, + Enabled = config.Enabled, + LastRotationAt = config.LastRotationAt, + CreationDate = config.CreationDate, + RevisionDate = now, + }; + await _configRepository.ReplaceAsync(toPersist); + + // Machinery event: single Outcome-phase, no human actor. + var audit = new AccessAuditEventData + { + Kind = AccessAuditEventKind.ManualRotationDue, + OccurredAt = now, + OrganizationId = config.OrganizationId, + ActorId = null, + CipherId = config.CipherId, + RotationConfigId = config.Id, + RotationSource = PamRotationSource.AccessEnd, + }; + await _accessAuditEventEmitter.EmitAsync(audit); + } +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/IAssignDaemonToTargetCommand.cs b/bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/IAssignDaemonToTargetCommand.cs new file mode 100644 index 000000000000..10ea85b09140 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/IAssignDaemonToTargetCommand.cs @@ -0,0 +1,11 @@ +namespace Bit.Services.Pam.Rotation.Commands.Interfaces; + +public interface IAssignDaemonToTargetCommand +{ + /// + /// Assigns a daemon to a target system (invariant OneAssignmentPerDaemonTarget). Guards: the daemon must + /// be Enrolled; the target must be an target; no + /// assignment may already exist for the pair. + /// + Task AssignAsync(Guid organizationId, Guid actingUserId, Guid daemonId, Guid targetSystemId); +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/IClaimRotationJobCommand.cs b/bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/IClaimRotationJobCommand.cs new file mode 100644 index 000000000000..e624444f1d78 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/IClaimRotationJobCommand.cs @@ -0,0 +1,17 @@ +using Bit.Pam.Models; + +namespace Bit.Services.Pam.Rotation.Commands.Interfaces; + +public interface IClaimRotationJobCommand +{ + /// + /// Claims a rotation job for a daemon (spec ClaimRotation) — atomic first-claim-wins, inserting the + /// Executing attempt in the same transaction. Callers are expected to already be authenticated as the daemon + /// (bearer token per request is the eligibility re-validation). Throws + /// when the job was not claimable (lost race — 409, retry a + /// different job) and when the daemon was never eligible to + /// claim it (no assignment, wrong organization, disabled target/config — 404, never leaked as a distinct + /// error). + /// + Task ClaimAsync(Guid daemonId, Guid jobId); +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/ICreateRotationConfigCommand.cs b/bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/ICreateRotationConfigCommand.cs new file mode 100644 index 000000000000..0c3a45374706 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/ICreateRotationConfigCommand.cs @@ -0,0 +1,24 @@ +using Bit.Pam.Entities; + +namespace Bit.Services.Pam.Rotation.Commands.Interfaces; + +public interface ICreateRotationConfigCommand +{ + /// + /// Creates a rotation config for a cipher (spec CreateRotationConfig). Guards: the target system exists, + /// belongs to the organization, and is ; the cipher + /// belongs to the organization and has no existing config (invariant OneConfigPerCipher); + /// may only be true on an automatic target that supports it; the schedule + /// is parseable and respects the interval floor. Effects: NextRotationAt is computed from + /// and the config starts enabled. + /// + Task CreateAsync( + Guid organizationId, + Guid actingUserId, + Guid cipherId, + Guid targetSystemId, + string accountIdentity, + bool terminateSessions, + string? scheduleCron, + bool rotateOnAccessEnd); +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/IDeleteRotationConfigCommand.cs b/bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/IDeleteRotationConfigCommand.cs new file mode 100644 index 000000000000..18e0f39034b4 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/IDeleteRotationConfigCommand.cs @@ -0,0 +1,10 @@ +namespace Bit.Services.Pam.Rotation.Commands.Interfaces; + +public interface IDeleteRotationConfigCommand +{ + /// + /// Deletes a rotation config, cascading its jobs and attempts (spec DeleteRotationConfig) — the durable + /// history stays in the audit trail, not the deleted rows. Guard: the config must have no active job. + /// + Task DeleteAsync(Guid organizationId, Guid actingUserId, Guid configId); +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/IHandleAccessGrantEndedCommand.cs b/bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/IHandleAccessGrantEndedCommand.cs new file mode 100644 index 000000000000..866fde791d88 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/IHandleAccessGrantEndedCommand.cs @@ -0,0 +1,15 @@ +namespace Bit.Services.Pam.Rotation.Commands.Interfaces; + +public interface IHandleAccessGrantEndedCommand +{ + /// + /// Reacts to a lease on ending — revoke, self-end, or natural expiry (spec + /// RotateOnAccessEnd / RaiseManualObligationOnAccessEnd). No-op when the + /// flag is off, when the cipher has no config, when the + /// config does not opt in (RotateOnAccessEnd), or when the config is paused/disabled (the deferred + /// access-end latch is out of scope this pass). On an automatic target, offers a job + /// (); on a manual target, pulls the obligation due + /// (NextRotationAt = now). + /// + Task HandleAsync(Guid cipherId); +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/IOfferRotationCommand.cs b/bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/IOfferRotationCommand.cs new file mode 100644 index 000000000000..7ad7329c028f --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/IOfferRotationCommand.cs @@ -0,0 +1,16 @@ +using Bit.Pam.Enums; + +namespace Bit.Services.Pam.Rotation.Commands.Interfaces; + +public interface IOfferRotationCommand +{ + /// + /// The single creation point for rotation jobs (spec OfferRotation). Internal — no organization or user + /// context; called by , the due-schedule sweep, and the access-end handler. + /// Re-checks can_offer (enabled, automatic target, target active) before the guarded insert (invariant + /// AtMostOneActiveJobPerConfig). and + /// are returned silently — callers race benignly + /// and never treat them as errors. + /// + Task OfferAsync(Guid configId, PamRotationSource source); +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/IPauseRotationCommand.cs b/bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/IPauseRotationCommand.cs new file mode 100644 index 000000000000..8b1f27dcbf88 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/IPauseRotationCommand.cs @@ -0,0 +1,7 @@ +namespace Bit.Services.Pam.Rotation.Commands.Interfaces; + +public interface IPauseRotationCommand +{ + /// Pauses a rotation config (spec PauseRotation). Guard: the config must currently be enabled. + Task PauseAsync(Guid organizationId, Guid actingUserId, Guid configId); +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/IRecordManualRotationCommand.cs b/bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/IRecordManualRotationCommand.cs new file mode 100644 index 000000000000..eb861d9e3aaa --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/IRecordManualRotationCommand.cs @@ -0,0 +1,11 @@ +namespace Bit.Services.Pam.Rotation.Commands.Interfaces; + +public interface IRecordManualRotationCommand +{ + /// + /// Records that an operator rotated a manual-target config's credential out of band (spec + /// RecordManualRotation) — clears awaiting_manual_rotation. Guard: the config's target system must + /// be . + /// + Task RecordAsync(Guid organizationId, Guid actingUserId, Guid configId); +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/IRegisterDaemonCommand.cs b/bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/IRegisterDaemonCommand.cs new file mode 100644 index 000000000000..025cfeb1fa8f --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/IRegisterDaemonCommand.cs @@ -0,0 +1,15 @@ +using Bit.Services.Pam.Rotation.Models; + +namespace Bit.Services.Pam.Rotation.Commands.Interfaces; + +public interface IRegisterDaemonCommand +{ + /// + /// Registers a new rotation daemon (spec DaemonRegistration): mints a dbo.ApiKey credential scoped + /// to api.pam.rotation and a row referencing it. + /// and are the client-wrapped org key (zero-knowledge + /// — the server never sees the plaintext key); the returned client secret is surfaced exactly once. + /// + Task RegisterAsync( + Guid organizationId, Guid actingUserId, string name, string encryptedPayload, string key); +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/IRegisterTargetSystemCommand.cs b/bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/IRegisterTargetSystemCommand.cs new file mode 100644 index 000000000000..a729c6e3aab2 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/IRegisterTargetSystemCommand.cs @@ -0,0 +1,23 @@ +using Bit.Pam.Entities; +using Bit.Pam.Enums; +using Bit.Pam.Models; + +namespace Bit.Services.Pam.Rotation.Commands.Interfaces; + +public interface IRegisterTargetSystemCommand +{ + /// + /// Registers a new target system, automatic or manual (spec RegisterAutomaticTargetSystem / + /// RegisterManualTargetSystem). An target requires + /// , , and ; + /// a target requires all three to be null. + /// + Task RegisterAsync( + Guid organizationId, + Guid actingUserId, + string name, + PamTargetSystemMethod method, + PamTargetSystemKind? kind, + PamPasswordPolicy? passwordPolicy, + bool? supportsSessionTermination); +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/IRenameTargetSystemCommand.cs b/bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/IRenameTargetSystemCommand.cs new file mode 100644 index 000000000000..5ff85dd642b6 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/IRenameTargetSystemCommand.cs @@ -0,0 +1,7 @@ +namespace Bit.Services.Pam.Rotation.Commands.Interfaces; + +public interface IRenameTargetSystemCommand +{ + /// Renames a target system. Display-only — the id keys the daemon's connector resolver. + Task RenameAsync(Guid organizationId, Guid actingUserId, Guid targetSystemId, string name); +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/IReportRotationFailedCommand.cs b/bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/IReportRotationFailedCommand.cs new file mode 100644 index 000000000000..8ca2ace0fcee --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/IReportRotationFailedCommand.cs @@ -0,0 +1,20 @@ +using Bit.Pam.Entities; +using Bit.Pam.Enums; + +namespace Bit.Services.Pam.Rotation.Commands.Interfaces; + +public interface IReportRotationFailedCommand +{ + /// + /// Records a failed rotation attempt (spec RecordRotationFailedRetryJob / FailJob). + /// is truncated to 500 characters before anything else happens — never + /// rejected — since the contract forbids forwarding raw target-system error output (it can echo credentials). + /// Retries the job while the retry budget (MaxAttempts) remains, otherwise fails it outright and pushes + /// the config's next rotation out by FailureRetryDelay. Throws + /// for an unknown attempt id (no audit) and + /// for a stale report (spec + /// RejectStaleFailureReport — audited as report_rejected). + /// + Task ReportFailedAsync( + Guid daemonId, Guid attemptId, string? failureReason, PamRotationSyncState syncState); +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/IReportRotationSucceededCommand.cs b/bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/IReportRotationSucceededCommand.cs new file mode 100644 index 000000000000..4e1280cb00ae --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/IReportRotationSucceededCommand.cs @@ -0,0 +1,17 @@ +using Bit.Pam.Entities; +using Bit.Pam.Enums; + +namespace Bit.Services.Pam.Rotation.Commands.Interfaces; + +public interface IReportRotationSucceededCommand +{ + /// + /// Records a successful rotation attempt (spec RecordRotationSucceededMarkJobSucceeded). + /// Requires the attempt to already have a written cipher (CipherUpdated — the VerifiedBeforeSuccess + /// backstop). Throws for an unknown attempt id (no audit — + /// nothing to audit against) and for a stale report (spec + /// RejectStaleSuccess — audited as report_rejected). + /// + Task ReportSucceededAsync( + Guid daemonId, Guid attemptId, PamSessionTerminationOutcome sessionTermination); +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/IResumeRotationCommand.cs b/bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/IResumeRotationCommand.cs new file mode 100644 index 000000000000..e649e3d681c3 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/IResumeRotationCommand.cs @@ -0,0 +1,12 @@ +namespace Bit.Services.Pam.Rotation.Commands.Interfaces; + +public interface IResumeRotationCommand +{ + /// + /// Resumes a paused rotation config (spec ResumeRotation). Guard: the config must currently be disabled. + /// A manual-target config with a due obligation (NextRotationAt <= now, read while paused) has that + /// obligation pulled due (NextRotationAt = now); otherwise NextRotationAt is recomputed from the + /// schedule. + /// + Task ResumeAsync(Guid organizationId, Guid actingUserId, Guid configId); +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/IRevokeDaemonCommand.cs b/bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/IRevokeDaemonCommand.cs new file mode 100644 index 000000000000..59667b88754d --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/IRevokeDaemonCommand.cs @@ -0,0 +1,11 @@ +namespace Bit.Services.Pam.Rotation.Commands.Interfaces; + +public interface IRevokeDaemonCommand +{ + /// + /// Revokes a rotation daemon (spec RevokeDaemon): flips its status to Revoked and deletes its + /// dbo.ApiKey credential row, closing the credential the way Secrets Manager revokes an access token. + /// Guard: the daemon must currently be Enrolled. + /// + Task RevokeAsync(Guid organizationId, Guid actingUserId, Guid daemonId); +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/ISetTargetSystemStatusCommand.cs b/bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/ISetTargetSystemStatusCommand.cs new file mode 100644 index 000000000000..3c2e3eb7165b --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/ISetTargetSystemStatusCommand.cs @@ -0,0 +1,10 @@ +namespace Bit.Services.Pam.Rotation.Commands.Interfaces; + +public interface ISetTargetSystemStatusCommand +{ + /// + /// Enables or disables a target system (spec EnableTargetSystem / DisableTargetSystem). Guard: the + /// target's current status must be the opposite of the requested one. + /// + Task SetStatusAsync(Guid organizationId, Guid actingUserId, Guid targetSystemId, bool enable); +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/ISubmitCipherUpdateCommand.cs b/bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/ISubmitCipherUpdateCommand.cs new file mode 100644 index 000000000000..d62fd8e72873 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/ISubmitCipherUpdateCommand.cs @@ -0,0 +1,14 @@ +namespace Bit.Services.Pam.Rotation.Commands.Interfaces; + +public interface ISubmitCipherUpdateCommand +{ + /// + /// Writes a daemon's rotated secret back to the cipher (spec AcceptCipherUpdate / + /// RejectCipherUpdate) via the atomic write-capability check, then pushes a resync. Throws + /// for an unknown attempt id (no audit) and + /// when the write capability no longer holds or + /// no longer matches the cipher's current revision (a concurrent user + /// edit won) — both audited as write_rejected. + /// + Task SubmitAsync(Guid daemonId, Guid attemptId, string cipherDataJson, DateTime lastKnownRevisionDate); +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/ITriggerRotationCommand.cs b/bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/ITriggerRotationCommand.cs new file mode 100644 index 000000000000..d5c183a7e76c --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/ITriggerRotationCommand.cs @@ -0,0 +1,12 @@ +namespace Bit.Services.Pam.Rotation.Commands.Interfaces; + +public interface ITriggerRotationCommand +{ + /// + /// Triggers an on-demand rotation for a config (spec TriggerRotationNow). Guards: the surface guard + /// can_offer (enabled, automatic target, target active, no active job) and the on-demand cooldown since + /// LastRotationAt. Delegates the actual offer to with + /// — the audit trail is written there. + /// + Task TriggerAsync(Guid organizationId, Guid actingUserId, Guid configId); +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/IUnassignDaemonFromTargetCommand.cs b/bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/IUnassignDaemonFromTargetCommand.cs new file mode 100644 index 000000000000..7e3772aa398d --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/IUnassignDaemonFromTargetCommand.cs @@ -0,0 +1,7 @@ +namespace Bit.Services.Pam.Rotation.Commands.Interfaces; + +public interface IUnassignDaemonFromTargetCommand +{ + /// Removes a daemon's assignment to a target system. Guard: the assignment must exist. + Task UnassignAsync(Guid organizationId, Guid actingUserId, Guid daemonId, Guid targetSystemId); +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/IUpdateRotationAccountCommand.cs b/bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/IUpdateRotationAccountCommand.cs new file mode 100644 index 000000000000..612fa05e509d --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/IUpdateRotationAccountCommand.cs @@ -0,0 +1,15 @@ +using Bit.Pam.Entities; + +namespace Bit.Services.Pam.Rotation.Commands.Interfaces; + +public interface IUpdateRotationAccountCommand +{ + /// + /// Updates the account a rotation config rotates and its termination setting (spec + /// UpdateRotationAccount). Guards: the config must have no active job; the same termination-capability + /// guard as create ( may only be true on an automatic target that supports + /// it). + /// + Task UpdateAsync( + Guid organizationId, Guid actingUserId, Guid configId, string accountIdentity, bool terminateSessions); +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/IUpdateRotationSettingsCommand.cs b/bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/IUpdateRotationSettingsCommand.cs new file mode 100644 index 000000000000..4373936dfa5d --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/IUpdateRotationSettingsCommand.cs @@ -0,0 +1,14 @@ +using Bit.Pam.Entities; + +namespace Bit.Services.Pam.Rotation.Commands.Interfaces; + +public interface IUpdateRotationSettingsCommand +{ + /// + /// Updates a rotation config's schedule and access-end trigger (spec UpdateRotationSettings). The + /// schedule is re-validated and NextRotationAt is recomputed from + /// (recompute-on-edit; a null cron clears it). + /// + Task UpdateAsync( + Guid organizationId, Guid actingUserId, Guid configId, string? scheduleCron, bool rotateOnAccessEnd); +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/IUpdateTargetSystemPolicyCommand.cs b/bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/IUpdateTargetSystemPolicyCommand.cs new file mode 100644 index 000000000000..1b5e4870db63 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Commands/Interfaces/IUpdateTargetSystemPolicyCommand.cs @@ -0,0 +1,17 @@ +using Bit.Pam.Models; + +namespace Bit.Services.Pam.Rotation.Commands.Interfaces; + +public interface IUpdateTargetSystemPolicyCommand +{ + /// + /// Updates an automatic target system's password policy and session-termination capability (spec + /// UpdateTargetSystemPolicy). Guards: the target must be + /// ; may + /// only be withdrawn (true to false) when no rotation config on the target has + /// set. + /// + Task UpdateAsync( + Guid organizationId, Guid actingUserId, Guid targetSystemId, PamPasswordPolicy passwordPolicy, + bool supportsSessionTermination); +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Commands/OfferRotationCommand.cs b/bitwarden_license/src/Services/Pam/Rotation/Commands/OfferRotationCommand.cs new file mode 100644 index 000000000000..966a54f55532 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Commands/OfferRotationCommand.cs @@ -0,0 +1,92 @@ +using Bit.Pam; +using Bit.Pam.Entities; +using Bit.Pam.Enums; +using Bit.Pam.Models; +using Bit.Pam.Repositories; +using Bit.Pam.Services; +using Bit.Services.Pam.Rotation.Commands.Interfaces; +using Microsoft.Extensions.Options; + +namespace Bit.Services.Pam.Rotation.Commands; + +/// +public class OfferRotationCommand : IOfferRotationCommand +{ + private readonly IPamRotationConfigRepository _configRepository; + private readonly IPamTargetSystemRepository _targetSystemRepository; + private readonly IPamRotationJobRepository _jobRepository; + private readonly IAccessAuditEventEmitter _accessAuditEventEmitter; + private readonly IOptions _options; + private readonly TimeProvider _timeProvider; + + public OfferRotationCommand( + IPamRotationConfigRepository configRepository, + IPamTargetSystemRepository targetSystemRepository, + IPamRotationJobRepository jobRepository, + IAccessAuditEventEmitter accessAuditEventEmitter, + IOptions options, + TimeProvider timeProvider) + { + _configRepository = configRepository; + _targetSystemRepository = targetSystemRepository; + _jobRepository = jobRepository; + _accessAuditEventEmitter = accessAuditEventEmitter; + _options = options; + _timeProvider = timeProvider; + } + + public async Task OfferAsync(Guid configId, PamRotationSource source) + { + var config = await _configRepository.GetByIdAsync(configId); + if (config is null) + { + // A concurrent delete raced this offer. Callers (the sweep, TriggerRotationCommand, the access-end + // handler) all treat this the same as any other not-offerable outcome and move on silently. + return PamRotationJobCreateOutcome.ConfigNotOfferable; + } + + var target = await _targetSystemRepository.GetByIdAsync(config.TargetSystemId); + if (target is null || !PamRotationRules.CanOffer(config, target.Method, target.Status)) + { + return PamRotationJobCreateOutcome.ConfigNotOfferable; + } + + var now = _timeProvider.GetUtcNow().UtcDateTime; + var job = new PamRotationJob + { + RotationConfigId = configId, + Source = source, + Status = PamRotationJobStatus.Pending, + ClaimedByDaemonId = null, + ClaimedAt = null, + CreationDate = now, + NextClaimableAt = now, + ExpiresAt = now + _options.Value.JobTtl, + }; + // CreateGuardedAsync is a guarded custom insert, not the generic single-object CreateAsync -- it expects the + // id to already be assigned. + job.SetNewId(); + + var outcome = await _jobRepository.CreateGuardedAsync(job); + if (outcome == PamRotationJobCreateOutcome.Created) + { + // Machinery event: single Outcome-phase, no human actor. + var audit = new AccessAuditEventData + { + Kind = AccessAuditEventKind.RotationOffered, + OccurredAt = now, + OrganizationId = config.OrganizationId, + ActorId = null, + CipherId = config.CipherId, + RotationConfigId = config.Id, + RotationJobId = job.Id, + TargetSystemId = target.Id, + TargetSystemName = target.Name, + RotationSource = source, + }; + await _accessAuditEventEmitter.EmitAsync(audit); + } + + return outcome; + } +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Commands/PauseRotationCommand.cs b/bitwarden_license/src/Services/Pam/Rotation/Commands/PauseRotationCommand.cs new file mode 100644 index 000000000000..99d1004f9620 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Commands/PauseRotationCommand.cs @@ -0,0 +1,58 @@ +using Bit.Core.Exceptions; +using Bit.Pam.Enums; +using Bit.Pam.Models; +using Bit.Pam.Repositories; +using Bit.Pam.Services; +using Bit.Services.Pam.Rotation.Commands.Interfaces; + +namespace Bit.Services.Pam.Rotation.Commands; + +/// +public class PauseRotationCommand : IPauseRotationCommand +{ + private readonly IPamRotationConfigRepository _configRepository; + private readonly IAccessAuditEventEmitter _accessAuditEventEmitter; + private readonly TimeProvider _timeProvider; + + public PauseRotationCommand( + IPamRotationConfigRepository configRepository, + IAccessAuditEventEmitter accessAuditEventEmitter, + TimeProvider timeProvider) + { + _configRepository = configRepository; + _accessAuditEventEmitter = accessAuditEventEmitter; + _timeProvider = timeProvider; + } + + public async Task PauseAsync(Guid organizationId, Guid actingUserId, Guid configId) + { + var config = await _configRepository.GetByIdAsync(configId); + if (config is null || config.OrganizationId != organizationId) + { + throw new NotFoundException(); + } + + if (!config.Enabled) + { + throw new BadRequestException("This rotation config is already paused."); + } + + var now = _timeProvider.GetUtcNow().UtcDateTime; + var audit = new AccessAuditEventData + { + Kind = AccessAuditEventKind.RotationPaused, + OccurredAt = now, + OrganizationId = organizationId, + ActorId = actingUserId, + CipherId = config.CipherId, + RotationConfigId = config.Id, + }; + await _accessAuditEventEmitter.EmitAsync(audit with { Phase = AccessAuditEventPhase.Attempt }); + + config.Enabled = false; + config.RevisionDate = now; + await _configRepository.ReplaceAsync(config); + + await _accessAuditEventEmitter.EmitAsync(audit with { Phase = AccessAuditEventPhase.Outcome }); + } +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Commands/RecordManualRotationCommand.cs b/bitwarden_license/src/Services/Pam/Rotation/Commands/RecordManualRotationCommand.cs new file mode 100644 index 000000000000..b7d4cac5cae5 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Commands/RecordManualRotationCommand.cs @@ -0,0 +1,79 @@ +using Bit.Core.Exceptions; +using Bit.Pam.Entities; +using Bit.Pam.Enums; +using Bit.Pam.Models; +using Bit.Pam.Repositories; +using Bit.Pam.Services; +using Bit.Services.Pam.Rotation.Commands.Interfaces; + +namespace Bit.Services.Pam.Rotation.Commands; + +/// +public class RecordManualRotationCommand : IRecordManualRotationCommand +{ + private readonly IPamRotationConfigRepository _configRepository; + private readonly IRotationScheduleCalculator _scheduleCalculator; + private readonly IAccessAuditEventEmitter _accessAuditEventEmitter; + private readonly TimeProvider _timeProvider; + + public RecordManualRotationCommand( + IPamRotationConfigRepository configRepository, + IRotationScheduleCalculator scheduleCalculator, + IAccessAuditEventEmitter accessAuditEventEmitter, + TimeProvider timeProvider) + { + _configRepository = configRepository; + _scheduleCalculator = scheduleCalculator; + _accessAuditEventEmitter = accessAuditEventEmitter; + _timeProvider = timeProvider; + } + + public async Task RecordAsync(Guid organizationId, Guid actingUserId, Guid configId) + { + var details = await _configRepository.GetDetailsByIdAsync(configId); + if (details is null || details.OrganizationId != organizationId) + { + throw new NotFoundException(); + } + + if (details.TargetSystemMethod != PamTargetSystemMethod.Manual) + { + throw new BadRequestException("This rotation config's target system is not manual."); + } + + var now = _timeProvider.GetUtcNow().UtcDateTime; + var nextRotationAt = _scheduleCalculator.GetNextOccurrence(details.ScheduleCron, now); + + var audit = new AccessAuditEventData + { + Kind = AccessAuditEventKind.ManualRotationRecorded, + OccurredAt = now, + OrganizationId = organizationId, + ActorId = actingUserId, + CipherId = details.CipherId, + RotationConfigId = details.Id, + }; + await _accessAuditEventEmitter.EmitAsync(audit with { Phase = AccessAuditEventPhase.Attempt }); + + var toPersist = new PamRotationConfig + { + Id = details.Id, + OrganizationId = details.OrganizationId, + CipherId = details.CipherId, + TargetSystemId = details.TargetSystemId, + AccountIdentity = details.AccountIdentity, + TerminateSessions = details.TerminateSessions, + ScheduleCron = details.ScheduleCron, + RotateOnAccessEnd = details.RotateOnAccessEnd, + // Clears awaiting_manual_rotation: the obligation just discharged. + NextRotationAt = nextRotationAt, + Enabled = details.Enabled, + LastRotationAt = now, + CreationDate = details.CreationDate, + RevisionDate = now, + }; + await _configRepository.ReplaceAsync(toPersist); + + await _accessAuditEventEmitter.EmitAsync(audit with { Phase = AccessAuditEventPhase.Outcome }); + } +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Commands/RegisterDaemonCommand.cs b/bitwarden_license/src/Services/Pam/Rotation/Commands/RegisterDaemonCommand.cs new file mode 100644 index 000000000000..c3afa00e8f8e --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Commands/RegisterDaemonCommand.cs @@ -0,0 +1,103 @@ +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.Pam.Services; +using Bit.Services.Pam.Rotation.Commands.Interfaces; +using Bit.Services.Pam.Rotation.Models; + +namespace Bit.Services.Pam.Rotation.Commands; + +/// +public class RegisterDaemonCommand : IRegisterDaemonCommand +{ + /// The scope every daemon credential carries — mirrors Secrets Manager's access-token scope shape. + private const string DaemonScope = "[\"api.pam.rotation\"]"; + private const int ClientSecretLength = 30; + + private readonly IApiKeyRepository _apiKeyRepository; + private readonly IPamDaemonRepository _daemonRepository; + private readonly IAccessAuditEventEmitter _accessAuditEventEmitter; + private readonly TimeProvider _timeProvider; + + public RegisterDaemonCommand( + IApiKeyRepository apiKeyRepository, + IPamDaemonRepository daemonRepository, + IAccessAuditEventEmitter accessAuditEventEmitter, + TimeProvider timeProvider) + { + _apiKeyRepository = apiKeyRepository; + _daemonRepository = daemonRepository; + _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; + + // audit (before/after): record the registration attempt before either row is written, then the outcome once + // both the credential and the daemon exist. + var audit = new AccessAuditEventData + { + Kind = AccessAuditEventKind.DaemonRegistered, + OccurredAt = now, + OrganizationId = organizationId, + ActorId = actingUserId, + DaemonName = name, + }; + await _accessAuditEventEmitter.EmitAsync(audit with { Phase = AccessAuditEventPhase.Attempt }); + + // The daemon's machine credential is a generic dbo.ApiKey row (ServiceAccountId null) -- PAM reuses the + // Secrets Manager credential store rather than minting a parallel one. Hashing mirrors + // CreateAccessTokenCommand exactly, since the same provider-side verification reads this hash. + var clientSecret = CoreHelpers.SecureRandomString(ClientSecretLength); + var apiKey = new ApiKey + { + ServiceAccountId = null, + Name = name, + ClientSecretHash = Hash(clientSecret), + Scope = DaemonScope, + EncryptedPayload = encryptedPayload, + Key = key, + }; + var createdApiKey = await _apiKeyRepository.CreateAsync(apiKey); + + var daemon = new PamDaemon + { + OrganizationId = organizationId, + Name = name, + ApiKeyId = createdApiKey.Id, + Status = PamDaemonStatus.Enrolled, + CreationDate = now, + RevisionDate = now, + }; + var createdDaemon = await _daemonRepository.CreateAsync(daemon); + + await _accessAuditEventEmitter.EmitAsync( + audit with { Phase = AccessAuditEventPhase.Outcome, DaemonId = createdDaemon.Id }); + + // The plaintext client secret is surfaced exactly once -- the server never persists or logs it again. + return new PamDaemonRegistrationResult(createdDaemon, 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/Rotation/Commands/RegisterTargetSystemCommand.cs b/bitwarden_license/src/Services/Pam/Rotation/Commands/RegisterTargetSystemCommand.cs new file mode 100644 index 000000000000..66bfd89fbb71 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Commands/RegisterTargetSystemCommand.cs @@ -0,0 +1,97 @@ +using Bit.Core.Exceptions; +using Bit.Pam.Entities; +using Bit.Pam.Enums; +using Bit.Pam.Models; +using Bit.Pam.Repositories; +using Bit.Pam.Services; +using Bit.Services.Pam.Rotation.Commands.Interfaces; + +namespace Bit.Services.Pam.Rotation.Commands; + +/// +public class RegisterTargetSystemCommand : IRegisterTargetSystemCommand +{ + private readonly IPamTargetSystemRepository _targetSystemRepository; + private readonly IAccessAuditEventEmitter _accessAuditEventEmitter; + private readonly TimeProvider _timeProvider; + + public RegisterTargetSystemCommand( + IPamTargetSystemRepository targetSystemRepository, + IAccessAuditEventEmitter accessAuditEventEmitter, + TimeProvider timeProvider) + { + _targetSystemRepository = targetSystemRepository; + _accessAuditEventEmitter = accessAuditEventEmitter; + _timeProvider = timeProvider; + } + + public async Task RegisterAsync( + Guid organizationId, + Guid actingUserId, + string name, + PamTargetSystemMethod method, + PamTargetSystemKind? kind, + PamPasswordPolicy? passwordPolicy, + bool? supportsSessionTermination) + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new BadRequestException("Name is required."); + } + + string? passwordPolicyJson; + if (method == PamTargetSystemMethod.Automatic) + { + if (kind is null || passwordPolicy is null || supportsSessionTermination is null) + { + throw new BadRequestException( + "Kind, password policy, and session-termination capability are required for an automatic target system."); + } + + passwordPolicyJson = PamPasswordPolicy.Serialize(passwordPolicy); + } + else + { + if (kind is not null || passwordPolicy is not null || supportsSessionTermination is not null) + { + throw new BadRequestException( + "Kind, password policy, and session-termination capability must not be set for a manual target system."); + } + + kind = null; + passwordPolicyJson = null; + } + + var now = _timeProvider.GetUtcNow().UtcDateTime; + var target = new PamTargetSystem + { + OrganizationId = organizationId, + Name = name, + Method = method, + Kind = kind, + PasswordPolicy = passwordPolicyJson, + SupportsSessionTermination = method == PamTargetSystemMethod.Automatic ? supportsSessionTermination : null, + Status = PamTargetSystemStatus.Active, + CreationDate = now, + RevisionDate = now, + }; + + // audit (before/after): the target has no id until it is created, so the outcome carries it. + var audit = new AccessAuditEventData + { + Kind = AccessAuditEventKind.TargetSystemRegistered, + OccurredAt = now, + OrganizationId = organizationId, + ActorId = actingUserId, + TargetSystemName = name, + }; + await _accessAuditEventEmitter.EmitAsync(audit with { Phase = AccessAuditEventPhase.Attempt }); + + var created = await _targetSystemRepository.CreateAsync(target); + + await _accessAuditEventEmitter.EmitAsync( + audit with { Phase = AccessAuditEventPhase.Outcome, TargetSystemId = created.Id }); + + return created; + } +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Commands/RenameTargetSystemCommand.cs b/bitwarden_license/src/Services/Pam/Rotation/Commands/RenameTargetSystemCommand.cs new file mode 100644 index 000000000000..63da74c94201 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Commands/RenameTargetSystemCommand.cs @@ -0,0 +1,62 @@ +using Bit.Core.Exceptions; +using Bit.Pam.Enums; +using Bit.Pam.Models; +using Bit.Pam.Repositories; +using Bit.Pam.Services; +using Bit.Services.Pam.Rotation.Commands.Interfaces; + +namespace Bit.Services.Pam.Rotation.Commands; + +/// +public class RenameTargetSystemCommand : IRenameTargetSystemCommand +{ + private readonly IPamTargetSystemRepository _targetSystemRepository; + private readonly IAccessAuditEventEmitter _accessAuditEventEmitter; + private readonly TimeProvider _timeProvider; + + public RenameTargetSystemCommand( + IPamTargetSystemRepository targetSystemRepository, + IAccessAuditEventEmitter accessAuditEventEmitter, + TimeProvider timeProvider) + { + _targetSystemRepository = targetSystemRepository; + _accessAuditEventEmitter = accessAuditEventEmitter; + _timeProvider = timeProvider; + } + + public async Task RenameAsync(Guid organizationId, Guid actingUserId, Guid targetSystemId, string name) + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new BadRequestException("Name is required."); + } + + var target = await _targetSystemRepository.GetByIdAsync(targetSystemId); + if (target is null || target.OrganizationId != organizationId) + { + throw new NotFoundException(); + } + + var now = _timeProvider.GetUtcNow().UtcDateTime; + + // The rename is display-only (the id keys the daemon's connector resolver); the prior name is preserved in + // Detail since the target row itself will no longer carry it after the update. + var audit = new AccessAuditEventData + { + Kind = AccessAuditEventKind.TargetSystemRenamed, + OccurredAt = now, + OrganizationId = organizationId, + ActorId = actingUserId, + TargetSystemId = target.Id, + TargetSystemName = name, + Detail = $"Renamed from '{target.Name}'.", + }; + await _accessAuditEventEmitter.EmitAsync(audit with { Phase = AccessAuditEventPhase.Attempt }); + + target.Name = name; + target.RevisionDate = now; + await _targetSystemRepository.ReplaceAsync(target); + + await _accessAuditEventEmitter.EmitAsync(audit with { Phase = AccessAuditEventPhase.Outcome }); + } +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Commands/ReportRotationFailedCommand.cs b/bitwarden_license/src/Services/Pam/Rotation/Commands/ReportRotationFailedCommand.cs new file mode 100644 index 000000000000..1bce0a8e81cc --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Commands/ReportRotationFailedCommand.cs @@ -0,0 +1,144 @@ +using Bit.Core.Exceptions; +using Bit.Pam.Entities; +using Bit.Pam.Enums; +using Bit.Pam.Models; +using Bit.Pam.Repositories; +using Bit.Pam.Services; +using Bit.Services.Pam.Rotation.Commands.Interfaces; +using Microsoft.Extensions.Options; + +namespace Bit.Services.Pam.Rotation.Commands; + +/// +public class ReportRotationFailedCommand : IReportRotationFailedCommand +{ + private const int FailureReasonMaxLength = 500; + + private readonly IPamRotationJobRepository _jobRepository; + private readonly IPamRotationConfigRepository _configRepository; + private readonly IPamDaemonRepository _daemonRepository; + private readonly IAccessAuditEventEmitter _accessAuditEventEmitter; + private readonly IOptions _options; + private readonly TimeProvider _timeProvider; + + public ReportRotationFailedCommand( + IPamRotationJobRepository jobRepository, + IPamRotationConfigRepository configRepository, + IPamDaemonRepository daemonRepository, + IAccessAuditEventEmitter accessAuditEventEmitter, + IOptions options, + TimeProvider timeProvider) + { + _jobRepository = jobRepository; + _configRepository = configRepository; + _daemonRepository = daemonRepository; + _accessAuditEventEmitter = accessAuditEventEmitter; + _options = options; + _timeProvider = timeProvider; + } + + public async Task ReportFailedAsync( + Guid daemonId, Guid attemptId, string? failureReason, PamRotationSyncState syncState) + { + // Truncate before anything else -- the contract forbids forwarding raw target-system error output (it can + // echo credentials), and truncation never rejects the report. + var truncatedReason = Truncate(failureReason); + + // Unknown attempt id: nothing to audit against (spec's `exists attempt` precondition). + var attempt = await _jobRepository.GetAttemptByIdAsync(attemptId); + if (attempt is null) + { + throw new NotFoundException(); + } + + var now = _timeProvider.GetUtcNow().UtcDateTime; + var result = await _jobRepository.MarkAttemptErroredAsync( + attemptId, daemonId, truncatedReason, syncState, now, _options.Value.MaxAttempts, + _options.Value.RetryBaseDelay); + + var job = await _jobRepository.GetByIdAsync(attempt.JobId); + var config = job is null ? null : await _configRepository.GetByIdAsync(job.RotationConfigId); + var daemon = await _daemonRepository.GetByIdAsync(daemonId); + + if (result.Outcome != PamRotationAttemptResolveOutcome.Resolved) + { + // Stale report (spec RejectStaleFailureReport): nothing changed, but the report itself is worth auditing. + var rejectedAudit = new AccessAuditEventData + { + Kind = AccessAuditEventKind.RotationReportRejected, + OccurredAt = now, + OrganizationId = config?.OrganizationId ?? Guid.Empty, + ActorId = null, + DaemonId = daemonId, + DaemonName = daemon?.Name, + RotationJobId = job?.Id, + RotationConfigId = config?.Id, + CipherId = config?.CipherId, + Detail = "Stale failure report: the attempt is no longer executing under this daemon's claim.", + }; + await _accessAuditEventEmitter.EmitAsync(rejectedAudit); + + throw new ConflictException("This attempt is no longer executing."); + } + + var organizationId = config?.OrganizationId ?? daemon?.OrganizationId ?? Guid.Empty; + + if (result.JobStatus == PamRotationJobStatus.Failed) + { + // Retry budget exhausted: the job failed outright, so the config's next rotation is pushed out rather + // than immediately retried. + if (config is not null) + { + config.NextRotationAt = now + _options.Value.FailureRetryDelay; + config.RevisionDate = now; + await _configRepository.ReplaceAsync(config); + } + + var failedAudit = new AccessAuditEventData + { + Kind = AccessAuditEventKind.RotationFailed, + OccurredAt = now, + OrganizationId = organizationId, + ActorId = null, + DaemonId = daemonId, + DaemonName = daemon?.Name, + RotationJobId = job?.Id, + RotationConfigId = config?.Id, + CipherId = config?.CipherId, + RotationSource = job?.Source, + SyncState = syncState, + Detail = truncatedReason, + }; + await _accessAuditEventEmitter.EmitAsync(failedAudit); + } + else + { + // Retry budget remains: the job went back to Pending for another attempt. + var attemptFailedAudit = new AccessAuditEventData + { + Kind = AccessAuditEventKind.RotationAttemptFailed, + OccurredAt = now, + OrganizationId = organizationId, + ActorId = null, + DaemonId = daemonId, + DaemonName = daemon?.Name, + RotationJobId = job?.Id, + RotationConfigId = config?.Id, + CipherId = config?.CipherId, + RotationSource = job?.Source, + SyncState = syncState, + Detail = truncatedReason, + }; + await _accessAuditEventEmitter.EmitAsync(attemptFailedAudit); + } + + // Re-fetch: the repository just mutated the attempt's Status/FailureReason/SyncState/ResolvedDate under the + // hood, and the caller expects the resolved snapshot back. + return await _jobRepository.GetAttemptByIdAsync(attemptId) ?? attempt; + } + + private static string? Truncate(string? failureReason) => + failureReason is not null && failureReason.Length > FailureReasonMaxLength + ? failureReason[..FailureReasonMaxLength] + : failureReason; +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Commands/ReportRotationSucceededCommand.cs b/bitwarden_license/src/Services/Pam/Rotation/Commands/ReportRotationSucceededCommand.cs new file mode 100644 index 000000000000..b741b9f69371 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Commands/ReportRotationSucceededCommand.cs @@ -0,0 +1,103 @@ +using Bit.Core.Exceptions; +using Bit.Pam.Entities; +using Bit.Pam.Enums; +using Bit.Pam.Models; +using Bit.Pam.Repositories; +using Bit.Pam.Services; +using Bit.Services.Pam.Rotation.Commands.Interfaces; + +namespace Bit.Services.Pam.Rotation.Commands; + +/// +public class ReportRotationSucceededCommand : IReportRotationSucceededCommand +{ + private readonly IPamRotationJobRepository _jobRepository; + private readonly IPamRotationConfigRepository _configRepository; + private readonly IPamDaemonRepository _daemonRepository; + private readonly IRotationScheduleCalculator _scheduleCalculator; + private readonly IAccessAuditEventEmitter _accessAuditEventEmitter; + private readonly TimeProvider _timeProvider; + + public ReportRotationSucceededCommand( + IPamRotationJobRepository jobRepository, + IPamRotationConfigRepository configRepository, + IPamDaemonRepository daemonRepository, + IRotationScheduleCalculator scheduleCalculator, + IAccessAuditEventEmitter accessAuditEventEmitter, + TimeProvider timeProvider) + { + _jobRepository = jobRepository; + _configRepository = configRepository; + _daemonRepository = daemonRepository; + _scheduleCalculator = scheduleCalculator; + _accessAuditEventEmitter = accessAuditEventEmitter; + _timeProvider = timeProvider; + } + + public async Task ReportSucceededAsync( + Guid daemonId, Guid attemptId, PamSessionTerminationOutcome sessionTermination) + { + // Unknown attempt id: nothing to audit against (spec's `exists attempt` precondition). + var attempt = await _jobRepository.GetAttemptByIdAsync(attemptId); + if (attempt is null) + { + throw new NotFoundException(); + } + + var now = _timeProvider.GetUtcNow().UtcDateTime; + var outcome = await _jobRepository.MarkAttemptRotatedAsync(attemptId, daemonId, sessionTermination, now); + + var job = await _jobRepository.GetByIdAsync(attempt.JobId); + var config = job is null ? null : await _configRepository.GetByIdAsync(job.RotationConfigId); + var daemon = await _daemonRepository.GetByIdAsync(daemonId); + + if (outcome != PamRotationAttemptResolveOutcome.Resolved) + { + // Stale report (spec RejectStaleSuccess): nothing changed, but the report itself is worth auditing. + var rejectedAudit = new AccessAuditEventData + { + Kind = AccessAuditEventKind.RotationReportRejected, + OccurredAt = now, + OrganizationId = config?.OrganizationId ?? Guid.Empty, + ActorId = null, + DaemonId = daemonId, + DaemonName = daemon?.Name, + RotationJobId = job?.Id, + RotationConfigId = config?.Id, + CipherId = config?.CipherId, + Detail = "Stale success report: the attempt is no longer executing under this daemon's claim.", + }; + await _accessAuditEventEmitter.EmitAsync(rejectedAudit); + + throw new ConflictException("This attempt is no longer executing."); + } + + if (config is not null) + { + config.LastRotationAt = now; + config.NextRotationAt = _scheduleCalculator.GetNextOccurrence(config.ScheduleCron, now); + config.RevisionDate = now; + await _configRepository.ReplaceAsync(config); + } + + // Machinery event: single Outcome-phase, no human actor. + var audit = new AccessAuditEventData + { + Kind = AccessAuditEventKind.RotationSucceeded, + OccurredAt = now, + OrganizationId = config?.OrganizationId ?? daemon?.OrganizationId ?? Guid.Empty, + ActorId = null, + DaemonId = daemonId, + DaemonName = daemon?.Name, + RotationJobId = job?.Id, + RotationConfigId = config?.Id, + CipherId = config?.CipherId, + RotationSource = job?.Source, + }; + await _accessAuditEventEmitter.EmitAsync(audit); + + // Re-fetch: the repository just mutated the attempt's Status/ResolvedDate/SessionTermination under the + // hood, and the caller expects the resolved snapshot back. + return await _jobRepository.GetAttemptByIdAsync(attemptId) ?? attempt; + } +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Commands/ResumeRotationCommand.cs b/bitwarden_license/src/Services/Pam/Rotation/Commands/ResumeRotationCommand.cs new file mode 100644 index 000000000000..0adcb02ba95e --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Commands/ResumeRotationCommand.cs @@ -0,0 +1,85 @@ +using Bit.Core.Exceptions; +using Bit.Pam.Entities; +using Bit.Pam.Enums; +using Bit.Pam.Models; +using Bit.Pam.Repositories; +using Bit.Pam.Services; +using Bit.Services.Pam.Rotation.Commands.Interfaces; + +namespace Bit.Services.Pam.Rotation.Commands; + +/// +public class ResumeRotationCommand : IResumeRotationCommand +{ + private readonly IPamRotationConfigRepository _configRepository; + private readonly IRotationScheduleCalculator _scheduleCalculator; + private readonly IAccessAuditEventEmitter _accessAuditEventEmitter; + private readonly TimeProvider _timeProvider; + + public ResumeRotationCommand( + IPamRotationConfigRepository configRepository, + IRotationScheduleCalculator scheduleCalculator, + IAccessAuditEventEmitter accessAuditEventEmitter, + TimeProvider timeProvider) + { + _configRepository = configRepository; + _scheduleCalculator = scheduleCalculator; + _accessAuditEventEmitter = accessAuditEventEmitter; + _timeProvider = timeProvider; + } + + public async Task ResumeAsync(Guid organizationId, Guid actingUserId, Guid configId) + { + var details = await _configRepository.GetDetailsByIdAsync(configId); + if (details is null || details.OrganizationId != organizationId) + { + throw new NotFoundException(); + } + + if (details.Enabled) + { + throw new BadRequestException("This rotation config is already active."); + } + + var now = _timeProvider.GetUtcNow().UtcDateTime; + + // A manual-target config with a due obligation, read while paused, has that obligation pulled due rather + // than pushed further out by recomputing from the schedule. + var nextRotationAt = + details.TargetSystemMethod == PamTargetSystemMethod.Manual + && details.NextRotationAt is { } nextRotationDue && nextRotationDue <= now + ? now + : _scheduleCalculator.GetNextOccurrence(details.ScheduleCron, now); + + var audit = new AccessAuditEventData + { + Kind = AccessAuditEventKind.RotationResumed, + OccurredAt = now, + OrganizationId = organizationId, + ActorId = actingUserId, + CipherId = details.CipherId, + RotationConfigId = details.Id, + }; + await _accessAuditEventEmitter.EmitAsync(audit with { Phase = AccessAuditEventPhase.Attempt }); + + var toPersist = new PamRotationConfig + { + Id = details.Id, + OrganizationId = details.OrganizationId, + CipherId = details.CipherId, + TargetSystemId = details.TargetSystemId, + AccountIdentity = details.AccountIdentity, + TerminateSessions = details.TerminateSessions, + ScheduleCron = details.ScheduleCron, + RotateOnAccessEnd = details.RotateOnAccessEnd, + NextRotationAt = nextRotationAt, + Enabled = true, + LastRotationAt = details.LastRotationAt, + CreationDate = details.CreationDate, + RevisionDate = now, + }; + await _configRepository.ReplaceAsync(toPersist); + + await _accessAuditEventEmitter.EmitAsync(audit with { Phase = AccessAuditEventPhase.Outcome }); + } +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Commands/RevokeDaemonCommand.cs b/bitwarden_license/src/Services/Pam/Rotation/Commands/RevokeDaemonCommand.cs new file mode 100644 index 000000000000..19f92e47b752 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Commands/RevokeDaemonCommand.cs @@ -0,0 +1,72 @@ +using Bit.Core.Exceptions; +using Bit.Core.SecretsManager.Repositories; +using Bit.Pam.Enums; +using Bit.Pam.Models; +using Bit.Pam.Repositories; +using Bit.Pam.Services; +using Bit.Services.Pam.Rotation.Commands.Interfaces; + +namespace Bit.Services.Pam.Rotation.Commands; + +/// +public class RevokeDaemonCommand : IRevokeDaemonCommand +{ + private readonly IPamDaemonRepository _daemonRepository; + private readonly IApiKeyRepository _apiKeyRepository; + private readonly IAccessAuditEventEmitter _accessAuditEventEmitter; + private readonly TimeProvider _timeProvider; + + public RevokeDaemonCommand( + IPamDaemonRepository daemonRepository, + IApiKeyRepository apiKeyRepository, + IAccessAuditEventEmitter accessAuditEventEmitter, + TimeProvider timeProvider) + { + _daemonRepository = daemonRepository; + _apiKeyRepository = apiKeyRepository; + _accessAuditEventEmitter = accessAuditEventEmitter; + _timeProvider = timeProvider; + } + + public async Task RevokeAsync(Guid organizationId, Guid actingUserId, Guid daemonId) + { + var daemon = await _daemonRepository.GetByIdAsync(daemonId); + if (daemon is null || daemon.OrganizationId != organizationId) + { + throw new NotFoundException(); + } + + if (daemon.Status != PamDaemonStatus.Enrolled) + { + throw new BadRequestException("This daemon has already been revoked."); + } + + var now = _timeProvider.GetUtcNow().UtcDateTime; + + // audit (before/after): record the revoke attempt, then the outcome around the point of no return. + var audit = new AccessAuditEventData + { + Kind = AccessAuditEventKind.DaemonRevoked, + OccurredAt = now, + OrganizationId = organizationId, + ActorId = actingUserId, + DaemonId = daemon.Id, + DaemonName = daemon.Name, + }; + await _accessAuditEventEmitter.EmitAsync(audit with { Phase = AccessAuditEventPhase.Attempt }); + + daemon.Status = PamDaemonStatus.Revoked; + daemon.RevisionDate = now; + await _daemonRepository.ReplaceAsync(daemon); + + // Delete the credential itself (SM's revocation semantics) -- the daemon row stays, since assignments and + // the audit trail reference it, and re-enrollment (the deferred ReissueDaemonCredential) mints a new one. + var apiKey = await _apiKeyRepository.GetByIdAsync(daemon.ApiKeyId); + if (apiKey is not null) + { + await _apiKeyRepository.DeleteAsync(apiKey); + } + + await _accessAuditEventEmitter.EmitAsync(audit with { Phase = AccessAuditEventPhase.Outcome }); + } +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Commands/SetTargetSystemStatusCommand.cs b/bitwarden_license/src/Services/Pam/Rotation/Commands/SetTargetSystemStatusCommand.cs new file mode 100644 index 000000000000..d560a8c081cc --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Commands/SetTargetSystemStatusCommand.cs @@ -0,0 +1,61 @@ +using Bit.Core.Exceptions; +using Bit.Pam.Enums; +using Bit.Pam.Models; +using Bit.Pam.Repositories; +using Bit.Pam.Services; +using Bit.Services.Pam.Rotation.Commands.Interfaces; + +namespace Bit.Services.Pam.Rotation.Commands; + +/// +public class SetTargetSystemStatusCommand : ISetTargetSystemStatusCommand +{ + private readonly IPamTargetSystemRepository _targetSystemRepository; + private readonly IAccessAuditEventEmitter _accessAuditEventEmitter; + private readonly TimeProvider _timeProvider; + + public SetTargetSystemStatusCommand( + IPamTargetSystemRepository targetSystemRepository, + IAccessAuditEventEmitter accessAuditEventEmitter, + TimeProvider timeProvider) + { + _targetSystemRepository = targetSystemRepository; + _accessAuditEventEmitter = accessAuditEventEmitter; + _timeProvider = timeProvider; + } + + public async Task SetStatusAsync(Guid organizationId, Guid actingUserId, Guid targetSystemId, bool enable) + { + var target = await _targetSystemRepository.GetByIdAsync(targetSystemId); + if (target is null || target.OrganizationId != organizationId) + { + throw new NotFoundException(); + } + + var desired = enable ? PamTargetSystemStatus.Active : PamTargetSystemStatus.Disabled; + if (target.Status == desired) + { + throw new BadRequestException(enable + ? "This target system is already enabled." + : "This target system is already disabled."); + } + + var now = _timeProvider.GetUtcNow().UtcDateTime; + var audit = new AccessAuditEventData + { + Kind = enable ? AccessAuditEventKind.TargetSystemEnabled : AccessAuditEventKind.TargetSystemDisabled, + OccurredAt = now, + OrganizationId = organizationId, + ActorId = actingUserId, + TargetSystemId = target.Id, + TargetSystemName = target.Name, + }; + await _accessAuditEventEmitter.EmitAsync(audit with { Phase = AccessAuditEventPhase.Attempt }); + + target.Status = desired; + target.RevisionDate = now; + await _targetSystemRepository.ReplaceAsync(target); + + await _accessAuditEventEmitter.EmitAsync(audit with { Phase = AccessAuditEventPhase.Outcome }); + } +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Commands/SubmitCipherUpdateCommand.cs b/bitwarden_license/src/Services/Pam/Rotation/Commands/SubmitCipherUpdateCommand.cs new file mode 100644 index 000000000000..106564be03f1 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Commands/SubmitCipherUpdateCommand.cs @@ -0,0 +1,93 @@ +using Bit.Core.Exceptions; +using Bit.Core.Vault.Repositories; +using Bit.Core.Vault.Services; +using Bit.Pam.Enums; +using Bit.Pam.Models; +using Bit.Pam.Repositories; +using Bit.Pam.Services; +using Bit.Services.Pam.Rotation.Commands.Interfaces; + +namespace Bit.Services.Pam.Rotation.Commands; + +/// +public class SubmitCipherUpdateCommand : ISubmitCipherUpdateCommand +{ + private readonly IPamRotationJobRepository _jobRepository; + private readonly IPamRotationConfigRepository _configRepository; + private readonly IPamDaemonRepository _daemonRepository; + private readonly ICipherRepository _cipherRepository; + private readonly ICipherSyncPushService _cipherSyncPushService; + private readonly IAccessAuditEventEmitter _accessAuditEventEmitter; + private readonly TimeProvider _timeProvider; + + public SubmitCipherUpdateCommand( + IPamRotationJobRepository jobRepository, + IPamRotationConfigRepository configRepository, + IPamDaemonRepository daemonRepository, + ICipherRepository cipherRepository, + ICipherSyncPushService cipherSyncPushService, + IAccessAuditEventEmitter accessAuditEventEmitter, + TimeProvider timeProvider) + { + _jobRepository = jobRepository; + _configRepository = configRepository; + _daemonRepository = daemonRepository; + _cipherRepository = cipherRepository; + _cipherSyncPushService = cipherSyncPushService; + _accessAuditEventEmitter = accessAuditEventEmitter; + _timeProvider = timeProvider; + } + + public async Task SubmitAsync(Guid daemonId, Guid attemptId, string cipherDataJson, DateTime lastKnownRevisionDate) + { + // Unknown attempt id: nothing to audit against (spec's `exists attempt` precondition). + var attempt = await _jobRepository.GetAttemptByIdAsync(attemptId); + if (attempt is null) + { + throw new NotFoundException(); + } + + var now = _timeProvider.GetUtcNow().UtcDateTime; + var outcome = await _jobRepository.AcceptCipherWriteAsync( + attemptId, daemonId, cipherDataJson, lastKnownRevisionDate, now); + + var job = await _jobRepository.GetByIdAsync(attempt.JobId); + var config = job is null ? null : await _configRepository.GetByIdAsync(job.RotationConfigId); + + if (outcome != PamRotationCipherWriteOutcome.Accepted) + { + var daemon = await _daemonRepository.GetByIdAsync(daemonId); + var audit = new AccessAuditEventData + { + Kind = AccessAuditEventKind.RotationCipherWriteRejected, + OccurredAt = now, + OrganizationId = config?.OrganizationId ?? daemon?.OrganizationId ?? Guid.Empty, + ActorId = null, + DaemonId = daemonId, + DaemonName = daemon?.Name, + RotationJobId = job?.Id, + RotationConfigId = config?.Id, + CipherId = config?.CipherId, + Detail = outcome == PamRotationCipherWriteOutcome.RevisionMismatch + ? "The cipher was modified since it was last read; the write capability held but the revision date no longer matched." + : "The write capability no longer held: the job is not claimed by this daemon, or the attempt is not executing.", + }; + await _accessAuditEventEmitter.EmitAsync(audit); + + throw new ConflictException(outcome == PamRotationCipherWriteOutcome.RevisionMismatch + ? "The cipher has been modified since it was last read." + : "This attempt can no longer write to the cipher."); + } + + // Accepted has no dedicated audit kind of its own -- the eventual success/failure report is what the trail + // records. Push a resync so open clients pick up the rotated secret. + if (config is not null) + { + var cipher = await _cipherRepository.GetByIdAsync(config.CipherId); + if (cipher is not null) + { + await _cipherSyncPushService.PushSyncCipherUpdateAsync(cipher, []); + } + } + } +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Commands/TriggerRotationCommand.cs b/bitwarden_license/src/Services/Pam/Rotation/Commands/TriggerRotationCommand.cs new file mode 100644 index 000000000000..9687f0751776 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Commands/TriggerRotationCommand.cs @@ -0,0 +1,66 @@ +using Bit.Core.Exceptions; +using Bit.Pam; +using Bit.Pam.Enums; +using Bit.Pam.Repositories; +using Bit.Services.Pam.Rotation.Commands.Interfaces; +using Microsoft.Extensions.Options; + +namespace Bit.Services.Pam.Rotation.Commands; + +/// +public class TriggerRotationCommand : ITriggerRotationCommand +{ + private readonly IPamRotationConfigRepository _configRepository; + private readonly IPamTargetSystemRepository _targetSystemRepository; + private readonly IOfferRotationCommand _offerRotationCommand; + private readonly IOptions _options; + private readonly TimeProvider _timeProvider; + + public TriggerRotationCommand( + IPamRotationConfigRepository configRepository, + IPamTargetSystemRepository targetSystemRepository, + IOfferRotationCommand offerRotationCommand, + IOptions options, + TimeProvider timeProvider) + { + _configRepository = configRepository; + _targetSystemRepository = targetSystemRepository; + _offerRotationCommand = offerRotationCommand; + _options = options; + _timeProvider = timeProvider; + } + + public async Task TriggerAsync(Guid organizationId, Guid actingUserId, Guid configId) + { + var details = await _configRepository.GetDetailsByIdAsync(configId); + if (details is null || details.OrganizationId != organizationId) + { + throw new NotFoundException(); + } + + var target = await _targetSystemRepository.GetByIdAsync(details.TargetSystemId); + if (target is null) + { + throw new NotFoundException(); + } + + // Surface guard can_offer: enabled, automatic, target active, and no active job (the last of which is not + // part of the pure PamRotationRules.CanOffer predicate, since it needs a repository lookup). + var canOffer = PamRotationRules.CanOffer(details, details.TargetSystemMethod, target.Status) + && !details.HasActiveJob; + if (!canOffer) + { + throw new BadRequestException("This rotation config cannot be triggered right now."); + } + + var now = _timeProvider.GetUtcNow().UtcDateTime; + if (details.LastRotationAt is { } lastRotationAt && now - lastRotationAt < _options.Value.OnDemandCooldown) + { + throw new BadRequestException("This rotation config was rotated recently; try again later."); + } + + // No audit here -- OfferRotationCommand is the single creation point and writes the `offered` audit event + // itself (RotationSource.OnDemand distinguishes this from a scheduled or access-end offer). + await _offerRotationCommand.OfferAsync(configId, PamRotationSource.OnDemand); + } +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Commands/UnassignDaemonFromTargetCommand.cs b/bitwarden_license/src/Services/Pam/Rotation/Commands/UnassignDaemonFromTargetCommand.cs new file mode 100644 index 000000000000..9ac79dc3306c --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Commands/UnassignDaemonFromTargetCommand.cs @@ -0,0 +1,67 @@ +using Bit.Core.Exceptions; +using Bit.Pam.Enums; +using Bit.Pam.Models; +using Bit.Pam.Repositories; +using Bit.Pam.Services; +using Bit.Services.Pam.Rotation.Commands.Interfaces; + +namespace Bit.Services.Pam.Rotation.Commands; + +/// +public class UnassignDaemonFromTargetCommand : IUnassignDaemonFromTargetCommand +{ + private readonly IPamDaemonRepository _daemonRepository; + private readonly IPamTargetSystemRepository _targetSystemRepository; + private readonly IAccessAuditEventEmitter _accessAuditEventEmitter; + private readonly TimeProvider _timeProvider; + + public UnassignDaemonFromTargetCommand( + IPamDaemonRepository daemonRepository, + IPamTargetSystemRepository targetSystemRepository, + IAccessAuditEventEmitter accessAuditEventEmitter, + TimeProvider timeProvider) + { + _daemonRepository = daemonRepository; + _targetSystemRepository = targetSystemRepository; + _accessAuditEventEmitter = accessAuditEventEmitter; + _timeProvider = timeProvider; + } + + public async Task UnassignAsync(Guid organizationId, Guid actingUserId, Guid daemonId, Guid targetSystemId) + { + var daemon = await _daemonRepository.GetByIdAsync(daemonId); + if (daemon is null || daemon.OrganizationId != organizationId) + { + throw new NotFoundException(); + } + + var target = await _targetSystemRepository.GetByIdAsync(targetSystemId); + if (target is null || target.OrganizationId != organizationId) + { + throw new NotFoundException(); + } + + if (!await _daemonRepository.AssignmentExistsAsync(daemonId, targetSystemId)) + { + throw new NotFoundException(); + } + + var now = _timeProvider.GetUtcNow().UtcDateTime; + var audit = new AccessAuditEventData + { + Kind = AccessAuditEventKind.DaemonUnassignedFromTarget, + OccurredAt = now, + OrganizationId = organizationId, + ActorId = actingUserId, + DaemonId = daemon.Id, + DaemonName = daemon.Name, + TargetSystemId = target.Id, + TargetSystemName = target.Name, + }; + await _accessAuditEventEmitter.EmitAsync(audit with { Phase = AccessAuditEventPhase.Attempt }); + + await _daemonRepository.DeleteAssignmentAsync(daemonId, targetSystemId); + + await _accessAuditEventEmitter.EmitAsync(audit with { Phase = AccessAuditEventPhase.Outcome }); + } +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Commands/UpdateRotationAccountCommand.cs b/bitwarden_license/src/Services/Pam/Rotation/Commands/UpdateRotationAccountCommand.cs new file mode 100644 index 000000000000..14183c7c871a --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Commands/UpdateRotationAccountCommand.cs @@ -0,0 +1,105 @@ +using Bit.Core.Exceptions; +using Bit.Pam.Entities; +using Bit.Pam.Enums; +using Bit.Pam.Models; +using Bit.Pam.Repositories; +using Bit.Pam.Services; +using Bit.Services.Pam.Rotation.Commands.Interfaces; + +namespace Bit.Services.Pam.Rotation.Commands; + +/// +public class UpdateRotationAccountCommand : IUpdateRotationAccountCommand +{ + private readonly IPamRotationConfigRepository _configRepository; + private readonly IPamTargetSystemRepository _targetSystemRepository; + private readonly IAccessAuditEventEmitter _accessAuditEventEmitter; + private readonly TimeProvider _timeProvider; + + public UpdateRotationAccountCommand( + IPamRotationConfigRepository configRepository, + IPamTargetSystemRepository targetSystemRepository, + IAccessAuditEventEmitter accessAuditEventEmitter, + TimeProvider timeProvider) + { + _configRepository = configRepository; + _targetSystemRepository = targetSystemRepository; + _accessAuditEventEmitter = accessAuditEventEmitter; + _timeProvider = timeProvider; + } + + public async Task UpdateAsync( + Guid organizationId, Guid actingUserId, Guid configId, string accountIdentity, bool terminateSessions) + { + if (string.IsNullOrWhiteSpace(accountIdentity)) + { + throw new BadRequestException("Account identity is required."); + } + + var details = await _configRepository.GetDetailsByIdAsync(configId); + if (details is null || details.OrganizationId != organizationId) + { + throw new NotFoundException(); + } + + if (details.HasActiveJob) + { + throw new BadRequestException("This rotation config has an active job."); + } + + var target = await _targetSystemRepository.GetByIdAsync(details.TargetSystemId); + if (target is null) + { + throw new NotFoundException(); + } + + // Same termination-capability guard as create: only an automatic target reporting the capability may have + // TerminateSessions set. + if (terminateSessions && + !(details.TargetSystemMethod == PamTargetSystemMethod.Automatic && target.SupportsSessionTermination == true)) + { + throw new BadRequestException( + "Session termination requires an automatic target system that supports it."); + } + + var now = _timeProvider.GetUtcNow().UtcDateTime; + + var audit = new AccessAuditEventData + { + Kind = AccessAuditEventKind.RotationAccountUpdated, + OccurredAt = now, + OrganizationId = organizationId, + ActorId = actingUserId, + CipherId = details.CipherId, + RotationConfigId = details.Id, + TargetSystemId = details.TargetSystemId, + TargetSystemName = details.TargetSystemName, + }; + await _accessAuditEventEmitter.EmitAsync(audit with { Phase = AccessAuditEventPhase.Attempt }); + + // Persist a plain PamRotationConfig: the PamRotationConfigDetails projection carries extra display-only + // properties (TargetSystemName, TargetSystemMethod, HasActiveJob) that the base ReplaceAsync would otherwise + // forward. + var toPersist = new PamRotationConfig + { + Id = details.Id, + OrganizationId = details.OrganizationId, + CipherId = details.CipherId, + TargetSystemId = details.TargetSystemId, + AccountIdentity = accountIdentity, + TerminateSessions = terminateSessions, + ScheduleCron = details.ScheduleCron, + RotateOnAccessEnd = details.RotateOnAccessEnd, + NextRotationAt = details.NextRotationAt, + Enabled = details.Enabled, + LastRotationAt = details.LastRotationAt, + CreationDate = details.CreationDate, + RevisionDate = now, + }; + await _configRepository.ReplaceAsync(toPersist); + + await _accessAuditEventEmitter.EmitAsync(audit with { Phase = AccessAuditEventPhase.Outcome }); + + return toPersist; + } +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Commands/UpdateRotationSettingsCommand.cs b/bitwarden_license/src/Services/Pam/Rotation/Commands/UpdateRotationSettingsCommand.cs new file mode 100644 index 000000000000..1daf540b6794 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Commands/UpdateRotationSettingsCommand.cs @@ -0,0 +1,70 @@ +using Bit.Core.Exceptions; +using Bit.Pam.Entities; +using Bit.Pam.Enums; +using Bit.Pam.Models; +using Bit.Pam.Repositories; +using Bit.Pam.Services; +using Bit.Services.Pam.Rotation.Commands.Interfaces; +using Microsoft.Extensions.Options; + +namespace Bit.Services.Pam.Rotation.Commands; + +/// +public class UpdateRotationSettingsCommand : IUpdateRotationSettingsCommand +{ + private readonly IPamRotationConfigRepository _configRepository; + private readonly IRotationScheduleCalculator _scheduleCalculator; + private readonly IAccessAuditEventEmitter _accessAuditEventEmitter; + private readonly IOptions _options; + private readonly TimeProvider _timeProvider; + + public UpdateRotationSettingsCommand( + IPamRotationConfigRepository configRepository, + IRotationScheduleCalculator scheduleCalculator, + IAccessAuditEventEmitter accessAuditEventEmitter, + IOptions options, + TimeProvider timeProvider) + { + _configRepository = configRepository; + _scheduleCalculator = scheduleCalculator; + _accessAuditEventEmitter = accessAuditEventEmitter; + _options = options; + _timeProvider = timeProvider; + } + + public async Task UpdateAsync( + Guid organizationId, Guid actingUserId, Guid configId, string? scheduleCron, bool rotateOnAccessEnd) + { + var config = await _configRepository.GetByIdAsync(configId); + if (config is null || config.OrganizationId != organizationId) + { + throw new NotFoundException(); + } + + _scheduleCalculator.ValidateSchedule(scheduleCron, _options.Value.MinScheduleInterval); + + var now = _timeProvider.GetUtcNow().UtcDateTime; + + var audit = new AccessAuditEventData + { + Kind = AccessAuditEventKind.RotationSettingsUpdated, + OccurredAt = now, + OrganizationId = organizationId, + ActorId = actingUserId, + CipherId = config.CipherId, + RotationConfigId = config.Id, + }; + await _accessAuditEventEmitter.EmitAsync(audit with { Phase = AccessAuditEventPhase.Attempt }); + + // Recompute-on-edit: a new cron re-derives NextRotationAt from now; a cleared cron clears it. + config.ScheduleCron = scheduleCron; + config.RotateOnAccessEnd = rotateOnAccessEnd; + config.NextRotationAt = _scheduleCalculator.GetNextOccurrence(scheduleCron, now); + config.RevisionDate = now; + await _configRepository.ReplaceAsync(config); + + await _accessAuditEventEmitter.EmitAsync(audit with { Phase = AccessAuditEventPhase.Outcome }); + + return config; + } +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Commands/UpdateTargetSystemPolicyCommand.cs b/bitwarden_license/src/Services/Pam/Rotation/Commands/UpdateTargetSystemPolicyCommand.cs new file mode 100644 index 000000000000..9a395c26ad77 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Commands/UpdateTargetSystemPolicyCommand.cs @@ -0,0 +1,72 @@ +using Bit.Core.Exceptions; +using Bit.Pam.Enums; +using Bit.Pam.Models; +using Bit.Pam.Repositories; +using Bit.Pam.Services; +using Bit.Services.Pam.Rotation.Commands.Interfaces; + +namespace Bit.Services.Pam.Rotation.Commands; + +/// +public class UpdateTargetSystemPolicyCommand : IUpdateTargetSystemPolicyCommand +{ + private readonly IPamTargetSystemRepository _targetSystemRepository; + private readonly IPamRotationConfigRepository _configRepository; + private readonly IAccessAuditEventEmitter _accessAuditEventEmitter; + private readonly TimeProvider _timeProvider; + + public UpdateTargetSystemPolicyCommand( + IPamTargetSystemRepository targetSystemRepository, + IPamRotationConfigRepository configRepository, + IAccessAuditEventEmitter accessAuditEventEmitter, + TimeProvider timeProvider) + { + _targetSystemRepository = targetSystemRepository; + _configRepository = configRepository; + _accessAuditEventEmitter = accessAuditEventEmitter; + _timeProvider = timeProvider; + } + + public async Task UpdateAsync( + Guid organizationId, Guid actingUserId, Guid targetSystemId, PamPasswordPolicy passwordPolicy, + bool supportsSessionTermination) + { + var target = await _targetSystemRepository.GetByIdAsync(targetSystemId); + if (target is null || target.OrganizationId != organizationId) + { + throw new NotFoundException(); + } + + if (target.Method != PamTargetSystemMethod.Automatic) + { + throw new BadRequestException("Only automatic target systems have a password policy."); + } + + var isWithdrawingTermination = target.SupportsSessionTermination == true && !supportsSessionTermination; + if (isWithdrawingTermination && + await _configRepository.AnyByTargetSystemWithTerminateSessionsAsync(targetSystemId)) + { + throw new BadRequestException( + "This target system cannot withdraw session-termination support while a rotation config requires it."); + } + + var now = _timeProvider.GetUtcNow().UtcDateTime; + var audit = new AccessAuditEventData + { + Kind = AccessAuditEventKind.TargetSystemPolicyUpdated, + OccurredAt = now, + OrganizationId = organizationId, + ActorId = actingUserId, + TargetSystemId = target.Id, + TargetSystemName = target.Name, + }; + await _accessAuditEventEmitter.EmitAsync(audit with { Phase = AccessAuditEventPhase.Attempt }); + + target.PasswordPolicy = PamPasswordPolicy.Serialize(passwordPolicy); + target.SupportsSessionTermination = supportsSessionTermination; + target.RevisionDate = now; + await _targetSystemRepository.ReplaceAsync(target); + + await _accessAuditEventEmitter.EmitAsync(audit with { Phase = AccessAuditEventPhase.Outcome }); + } +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/IRotationScheduleCalculator.cs b/bitwarden_license/src/Services/Pam/Rotation/IRotationScheduleCalculator.cs new file mode 100644 index 000000000000..10a76b22ba67 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/IRotationScheduleCalculator.cs @@ -0,0 +1,23 @@ +namespace Bit.Services.Pam.Rotation; + +/// +/// Computes and validates a rotation config's Quartz 6-field cron schedule (spec NextScheduledTime). All +/// times are UTC. +/// +public interface IRotationScheduleCalculator +{ + /// + /// Returns the next occurrence of strictly after , or null + /// when is null (a config with no scheduled rotation). Throws + /// when is not a parseable cron + /// expression. + /// + DateTime? GetNextOccurrence(string? cron, DateTime afterUtc); + + /// + /// Validates that is parseable and that the gap between its next two occurrences is at + /// least (the abuse floor). A null is always valid — it + /// means no scheduled rotation. Throws otherwise. + /// + void ValidateSchedule(string? cron, TimeSpan minInterval); +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Models/PamDaemonListItem.cs b/bitwarden_license/src/Services/Pam/Rotation/Models/PamDaemonListItem.cs new file mode 100644 index 000000000000..d381de4d9359 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Models/PamDaemonListItem.cs @@ -0,0 +1,13 @@ +using Bit.Pam.Entities; + +namespace Bit.Services.Pam.Rotation.Models; + +/// +/// A rotation daemon together with its derived liveness (spec DaemonConnection, see +/// PamRotationRules.IsConnected) and the target systems it is assigned to — the list view model for the +/// daemons admin surface. +/// +public sealed record PamDaemonListItem( + PamDaemon Daemon, + bool IsConnected, + IReadOnlyList AssignedTargetSystemIds); diff --git a/bitwarden_license/src/Services/Pam/Rotation/Models/PamDaemonRegistrationResult.cs b/bitwarden_license/src/Services/Pam/Rotation/Models/PamDaemonRegistrationResult.cs new file mode 100644 index 000000000000..b8e6b707769d --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Models/PamDaemonRegistrationResult.cs @@ -0,0 +1,10 @@ +using Bit.Pam.Entities; + +namespace Bit.Services.Pam.Rotation.Models; + +/// +/// The result of registering a rotation daemon (spec DaemonRegistration). is the +/// plaintext client secret for the daemon's dbo.ApiKey credential — it is generated once here, hashed for +/// storage, and surfaced to the caller exactly once; the server never persists or logs it. +/// +public sealed record PamDaemonRegistrationResult(PamDaemon Daemon, string ClientSecret); diff --git a/bitwarden_license/src/Services/Pam/Rotation/Models/PamRotationConfigHistory.cs b/bitwarden_license/src/Services/Pam/Rotation/Models/PamRotationConfigHistory.cs new file mode 100644 index 000000000000..b9b6158ed6a1 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Models/PamRotationConfigHistory.cs @@ -0,0 +1,12 @@ +using Bit.Pam.Models; + +namespace Bit.Services.Pam.Rotation.Models; + +/// +/// A rotation config's detail view: its projection together with every job +/// recorded against it (each carrying its own attempts, oldest first) — the read model for +/// GET configs/{id}. +/// +public sealed record PamRotationConfigHistory( + PamRotationConfigDetails Config, + IReadOnlyList Jobs); diff --git a/bitwarden_license/src/Services/Pam/Rotation/PamRotationOptions.cs b/bitwarden_license/src/Services/Pam/Rotation/PamRotationOptions.cs new file mode 100644 index 000000000000..0ab6f2d96a45 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/PamRotationOptions.cs @@ -0,0 +1,37 @@ +namespace Bit.Services.Pam.Rotation; + +/// +/// Timing knobs for PAM credential rotation, bound from globalSettings:pam:rotation (see +/// AddPamServices). Every consumer injects of +/// this type rather than reading configuration directly, so the defaults below are the single source of truth for +/// 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: how long a claiming daemon has before the release sweep may reclaim its job. + public TimeSpan ReleaseDelay { get; set; } = TimeSpan.FromMinutes(15); + + /// How far out a config's next rotation is pushed after its job fails outright (budget exhausted). + public TimeSpan FailureRetryDelay { get; set; } = TimeSpan.FromHours(1); + + /// How long since its last heartbeat a daemon is still considered connected (spec DaemonConnection). + public TimeSpan DaemonOfflineAfter { get; set; } = TimeSpan.FromMinutes(2); + + /// The minimum gap between conditional heartbeat writes, so a polling daemon does not hammer its row. + public TimeSpan HeartbeatMinInterval { get; set; } = TimeSpan.FromSeconds(15); + + /// The minimum gap the schedule calculator enforces between two consecutive occurrences of a config's cron. + public TimeSpan MinScheduleInterval { get; set; } = TimeSpan.FromMinutes(15); + + /// The minimum gap between two on-demand triggers of the same config (abuse floor). + public TimeSpan OnDemandCooldown { get; set; } = TimeSpan.FromMinutes(1); +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Queries/GetRotationCipherQuery.cs b/bitwarden_license/src/Services/Pam/Rotation/Queries/GetRotationCipherQuery.cs new file mode 100644 index 000000000000..4ef6030e17da --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Queries/GetRotationCipherQuery.cs @@ -0,0 +1,57 @@ +using Bit.Core.Exceptions; +using Bit.Core.Vault.Entities; +using Bit.Core.Vault.Repositories; +using Bit.Pam.Enums; +using Bit.Pam.Repositories; +using Bit.Services.Pam.Rotation.Queries.Interfaces; + +namespace Bit.Services.Pam.Rotation.Queries; + +/// +public class GetRotationCipherQuery : IGetRotationCipherQuery +{ + private readonly IPamRotationJobRepository _jobRepository; + private readonly IPamRotationConfigRepository _configRepository; + private readonly ICipherRepository _cipherRepository; + + public GetRotationCipherQuery( + IPamRotationJobRepository jobRepository, + IPamRotationConfigRepository configRepository, + ICipherRepository cipherRepository) + { + _jobRepository = jobRepository; + _configRepository = configRepository; + _cipherRepository = cipherRepository; + } + + public async Task GetAsync(Guid daemonId, Guid attemptId) + { + var attempt = await _jobRepository.GetAttemptByIdAsync(attemptId); + if (attempt is null + || attempt.ClaimedByDaemonId != daemonId + || attempt.Status != PamRotationAttemptStatus.Executing) + { + throw new NotFoundException(); + } + + var job = await _jobRepository.GetByIdAsync(attempt.JobId); + if (job is null || job.Status != PamRotationJobStatus.Claimed || job.ClaimedByDaemonId != daemonId) + { + throw new NotFoundException(); + } + + var config = await _configRepository.GetByIdAsync(job.RotationConfigId); + if (config is null) + { + throw new NotFoundException(); + } + + var cipher = await _cipherRepository.GetByIdAsync(config.CipherId); + if (cipher is null) + { + throw new NotFoundException(); + } + + return cipher; + } +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Queries/GetRotationConfigDetailsQuery.cs b/bitwarden_license/src/Services/Pam/Rotation/Queries/GetRotationConfigDetailsQuery.cs new file mode 100644 index 000000000000..cac0165d8778 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Queries/GetRotationConfigDetailsQuery.cs @@ -0,0 +1,33 @@ +using Bit.Core.Exceptions; +using Bit.Pam.Repositories; +using Bit.Services.Pam.Rotation.Models; +using Bit.Services.Pam.Rotation.Queries.Interfaces; + +namespace Bit.Services.Pam.Rotation.Queries; + +/// +public class GetRotationConfigDetailsQuery : IGetRotationConfigDetailsQuery +{ + private readonly IPamRotationConfigRepository _configRepository; + private readonly IPamRotationJobRepository _jobRepository; + + public GetRotationConfigDetailsQuery( + IPamRotationConfigRepository configRepository, IPamRotationJobRepository jobRepository) + { + _configRepository = configRepository; + _jobRepository = jobRepository; + } + + public async Task GetAsync(Guid organizationId, Guid configId) + { + var details = await _configRepository.GetDetailsByIdAsync(configId); + if (details is null || details.OrganizationId != organizationId) + { + throw new NotFoundException(); + } + + var jobs = await _jobRepository.GetManyByConfigIdAsync(configId); + + return new PamRotationConfigHistory(details, jobs.ToList()); + } +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Queries/Interfaces/IGetRotationCipherQuery.cs b/bitwarden_license/src/Services/Pam/Rotation/Queries/Interfaces/IGetRotationCipherQuery.cs new file mode 100644 index 000000000000..fc8d7e7a7678 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Queries/Interfaces/IGetRotationCipherQuery.cs @@ -0,0 +1,14 @@ +using Bit.Core.Vault.Entities; + +namespace Bit.Services.Pam.Rotation.Queries.Interfaces; + +public interface IGetRotationCipherQuery +{ + /// + /// Returns the cipher for a daemon's claimed, executing attempt — deliberately narrow (only this daemon's + /// in-flight attempt, never a general cipher read). Throws + /// when the attempt does not exist, is not claimed by + /// , is not Executing, or its job is not Claimed by the same daemon. + /// + Task GetAsync(Guid daemonId, Guid attemptId); +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Queries/Interfaces/IGetRotationConfigDetailsQuery.cs b/bitwarden_license/src/Services/Pam/Rotation/Queries/Interfaces/IGetRotationConfigDetailsQuery.cs new file mode 100644 index 000000000000..95ca307de8aa --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Queries/Interfaces/IGetRotationConfigDetailsQuery.cs @@ -0,0 +1,13 @@ +using Bit.Services.Pam.Rotation.Models; + +namespace Bit.Services.Pam.Rotation.Queries.Interfaces; + +public interface IGetRotationConfigDetailsQuery +{ + /// + /// A single rotation config's detail view, including its job/attempt history. Throws + /// when the config does not exist or belongs to a different + /// organization. + /// + Task GetAsync(Guid organizationId, Guid configId); +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Queries/Interfaces/IListClaimableJobsQuery.cs b/bitwarden_license/src/Services/Pam/Rotation/Queries/Interfaces/IListClaimableJobsQuery.cs new file mode 100644 index 000000000000..a25c7d578445 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Queries/Interfaces/IListClaimableJobsQuery.cs @@ -0,0 +1,12 @@ +using Bit.Pam.Entities; + +namespace Bit.Services.Pam.Rotation.Queries.Interfaces; + +public interface IListClaimableJobsQuery +{ + /// + /// A daemon's currently claimable jobs — its poll (spec ClaimRotation's candidate set). Doubles as the + /// daemon's heartbeat when idle. + /// + Task> ListAsync(Guid daemonId); +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Queries/Interfaces/IListDaemonsQuery.cs b/bitwarden_license/src/Services/Pam/Rotation/Queries/Interfaces/IListDaemonsQuery.cs new file mode 100644 index 000000000000..66f4caabdc73 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Queries/Interfaces/IListDaemonsQuery.cs @@ -0,0 +1,9 @@ +using Bit.Services.Pam.Rotation.Models; + +namespace Bit.Services.Pam.Rotation.Queries.Interfaces; + +public interface IListDaemonsQuery +{ + /// The daemons list view for an organization, with derived connection state and target assignments. + Task> ListAsync(Guid organizationId); +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Queries/Interfaces/IListRotationConfigsQuery.cs b/bitwarden_license/src/Services/Pam/Rotation/Queries/Interfaces/IListRotationConfigsQuery.cs new file mode 100644 index 000000000000..e593f2c4d8f9 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Queries/Interfaces/IListRotationConfigsQuery.cs @@ -0,0 +1,9 @@ +using Bit.Pam.Models; + +namespace Bit.Services.Pam.Rotation.Queries.Interfaces; + +public interface IListRotationConfigsQuery +{ + /// The rotation-configs schedule list view for an organization. + Task> ListAsync(Guid organizationId); +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Queries/Interfaces/IListTargetSystemsQuery.cs b/bitwarden_license/src/Services/Pam/Rotation/Queries/Interfaces/IListTargetSystemsQuery.cs new file mode 100644 index 000000000000..0f9a422bc41c --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Queries/Interfaces/IListTargetSystemsQuery.cs @@ -0,0 +1,9 @@ +using Bit.Pam.Entities; + +namespace Bit.Services.Pam.Rotation.Queries.Interfaces; + +public interface IListTargetSystemsQuery +{ + /// The target systems registered for an organization. + Task> ListAsync(Guid organizationId); +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Queries/ListClaimableJobsQuery.cs b/bitwarden_license/src/Services/Pam/Rotation/Queries/ListClaimableJobsQuery.cs new file mode 100644 index 000000000000..371848bf06f8 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Queries/ListClaimableJobsQuery.cs @@ -0,0 +1,21 @@ +using Bit.Pam.Entities; +using Bit.Pam.Repositories; +using Bit.Services.Pam.Rotation.Queries.Interfaces; + +namespace Bit.Services.Pam.Rotation.Queries; + +/// +public class ListClaimableJobsQuery : IListClaimableJobsQuery +{ + private readonly IPamRotationJobRepository _jobRepository; + private readonly TimeProvider _timeProvider; + + public ListClaimableJobsQuery(IPamRotationJobRepository jobRepository, TimeProvider timeProvider) + { + _jobRepository = jobRepository; + _timeProvider = timeProvider; + } + + public Task> ListAsync(Guid daemonId) => + _jobRepository.GetManyClaimableByDaemonIdAsync(daemonId, _timeProvider.GetUtcNow().UtcDateTime); +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Queries/ListDaemonsQuery.cs b/bitwarden_license/src/Services/Pam/Rotation/Queries/ListDaemonsQuery.cs new file mode 100644 index 000000000000..f8d3780232df --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Queries/ListDaemonsQuery.cs @@ -0,0 +1,42 @@ +using Bit.Pam; +using Bit.Pam.Repositories; +using Bit.Services.Pam.Rotation.Models; +using Bit.Services.Pam.Rotation.Queries.Interfaces; +using Microsoft.Extensions.Options; + +namespace Bit.Services.Pam.Rotation.Queries; + +/// +public class ListDaemonsQuery : IListDaemonsQuery +{ + private readonly IPamDaemonRepository _daemonRepository; + private readonly IOptions _options; + private readonly TimeProvider _timeProvider; + + public ListDaemonsQuery( + IPamDaemonRepository daemonRepository, IOptions options, TimeProvider timeProvider) + { + _daemonRepository = daemonRepository; + _options = options; + _timeProvider = timeProvider; + } + + public async Task> ListAsync(Guid organizationId) + { + var daemons = await _daemonRepository.GetManyByOrganizationIdAsync(organizationId); + var assignments = await _daemonRepository.GetAssignmentsByOrganizationIdAsync(organizationId); + var assignmentsByDaemon = assignments + .GroupBy(a => a.DaemonId) + .ToDictionary(g => g.Key, g => (IReadOnlyList)g.Select(a => a.TargetSystemId).ToList()); + + var now = _timeProvider.GetUtcNow().UtcDateTime; + var offlineAfter = _options.Value.DaemonOfflineAfter; + + return daemons + .Select(daemon => new PamDaemonListItem( + daemon, + PamRotationRules.IsConnected(daemon, now, offlineAfter), + assignmentsByDaemon.TryGetValue(daemon.Id, out var targetIds) ? targetIds : [])) + .ToList(); + } +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Queries/ListRotationConfigsQuery.cs b/bitwarden_license/src/Services/Pam/Rotation/Queries/ListRotationConfigsQuery.cs new file mode 100644 index 000000000000..581691c701d9 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Queries/ListRotationConfigsQuery.cs @@ -0,0 +1,19 @@ +using Bit.Pam.Models; +using Bit.Pam.Repositories; +using Bit.Services.Pam.Rotation.Queries.Interfaces; + +namespace Bit.Services.Pam.Rotation.Queries; + +/// +public class ListRotationConfigsQuery : IListRotationConfigsQuery +{ + private readonly IPamRotationConfigRepository _configRepository; + + public ListRotationConfigsQuery(IPamRotationConfigRepository configRepository) + { + _configRepository = configRepository; + } + + public Task> ListAsync(Guid organizationId) => + _configRepository.GetManyDetailsByOrganizationIdAsync(organizationId); +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Queries/ListTargetSystemsQuery.cs b/bitwarden_license/src/Services/Pam/Rotation/Queries/ListTargetSystemsQuery.cs new file mode 100644 index 000000000000..656c483ab97f --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Queries/ListTargetSystemsQuery.cs @@ -0,0 +1,19 @@ +using Bit.Pam.Entities; +using Bit.Pam.Repositories; +using Bit.Services.Pam.Rotation.Queries.Interfaces; + +namespace Bit.Services.Pam.Rotation.Queries; + +/// +public class ListTargetSystemsQuery : IListTargetSystemsQuery +{ + private readonly IPamTargetSystemRepository _targetSystemRepository; + + public ListTargetSystemsQuery(IPamTargetSystemRepository targetSystemRepository) + { + _targetSystemRepository = targetSystemRepository; + } + + public Task> ListAsync(Guid organizationId) => + _targetSystemRepository.GetManyByOrganizationIdAsync(organizationId); +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/RotationScheduleCalculator.cs b/bitwarden_license/src/Services/Pam/Rotation/RotationScheduleCalculator.cs new file mode 100644 index 000000000000..b5025cd7fd55 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/RotationScheduleCalculator.cs @@ -0,0 +1,64 @@ +using Bit.Core.Exceptions; +using Quartz; + +namespace Bit.Services.Pam.Rotation; + +/// +public class RotationScheduleCalculator : IRotationScheduleCalculator +{ + public DateTime? GetNextOccurrence(string? cron, DateTime afterUtc) + { + if (string.IsNullOrWhiteSpace(cron)) + { + return null; + } + + var expression = Parse(cron); + var next = expression.GetNextValidTimeAfter(new DateTimeOffset(DateTime.SpecifyKind(afterUtc, DateTimeKind.Utc))); + return next?.UtcDateTime; + } + + public void ValidateSchedule(string? cron, TimeSpan minInterval) + { + if (string.IsNullOrWhiteSpace(cron)) + { + return; + } + + var expression = Parse(cron); + + // The schedule must occur at least twice so the interval floor can be checked; a cron that fires only once + // (or never) is rejected the same way a too-frequent one is. + var first = expression.GetNextValidTimeAfter(DateTimeOffset.UtcNow); + if (first is null) + { + throw new BadRequestException("The schedule does not occur."); + } + + var second = expression.GetNextValidTimeAfter(first.Value); + if (second is null) + { + throw new BadRequestException("The schedule does not occur."); + } + + if (second.Value - first.Value < minInterval) + { + throw new BadRequestException( + $"The schedule must run no more often than every {minInterval.TotalMinutes:0} minutes."); + } + } + + private static CronExpression Parse(string cron) + { + try + { + // Quartz evaluates crons in TimeZoneInfo.Local by default; schedules are contractually UTC + // (a day-anchored cron must not shift with the server's deployment time zone or DST). + return new CronExpression(cron) { TimeZone = TimeZoneInfo.Utc }; + } + catch (FormatException ex) + { + throw new BadRequestException($"The schedule is not a valid cron expression: {ex.Message}"); + } + } +} diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 35da7647042f..1e4da8af73ab 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -301,6 +301,7 @@ public static class FeatureFlagKeys /* PAM */ public const string Pam = "pm-37044-pam-v-0"; + public const string PamRotation = "pm-39040-pam-rotation"; public static List GetAllKeys() { @@ -318,6 +319,7 @@ public static Dictionary GetLocalOverrideFlagValues() // PAM is enabled by default for dev/demo purposes only. // This MUST be set to false (or removed) before going to production. { Pam, "true" }, + { PamRotation, "true" }, }; } } From 7cdf3672d805cf226493036eace78a096d6b61e8 Mon Sep 17 00:00:00 2001 From: Hinton Date: Mon, 6 Jul 2026 14:30:07 +0200 Subject: [PATCH 07/10] [PM-39040] Map rotation admin and daemon API endpoints Admin groups for daemons/target-systems/configs and daemon-facing poll/claim/cipher/report routes behind the PamRotationDaemon policy. Every daemon request re-checks enrollment and org Enabled/UsePam and doubles as a conditional heartbeat. Also fixes PamValidationEndpointFilter's exact-namespace match, which silently skipped DataAnnotations validation for models outside the original Pam.Api.Models.Request namespace. --- .../Filters/PamValidationEndpointFilter.cs | 10 +- .../Api/Endpoints/PamEndpointsExtensions.cs | 68 ++++++++- .../Filters/DaemonRequestEndpointFilter.cs | 66 +++++++++ .../RotationAttemptEndpointsHandler.cs | 51 +++++++ .../RotationConfigEndpointsHandler.cs | 139 ++++++++++++++++++ .../RotationDaemonEndpointsHandler.cs | 71 +++++++++ .../RotationDaemonJobsEndpointsHandler.cs | 41 ++++++ .../Handlers/RotationJobEndpointsHandler.cs | 20 +++ .../RotationTargetSystemEndpointsHandler.cs | 84 +++++++++++ .../Api/Endpoints/RotationAttemptEndpoints.cs | 61 ++++++++ .../Api/Endpoints/RotationConfigEndpoints.cs | 78 ++++++++++ .../Api/Endpoints/RotationDaemonEndpoints.cs | 54 +++++++ .../Endpoints/RotationDaemonJobsEndpoints.cs | 24 +++ .../Api/Endpoints/RotationJobEndpoints.cs | 22 +++ .../RotationTargetSystemEndpoints.cs | 56 +++++++ .../Request/AssignDaemonTargetRequestModel.cs | 10 ++ .../CreateRotationConfigRequestModel.cs | 28 ++++ .../Request/PamPasswordPolicyRequestModel.cs | 51 +++++++ .../Request/RegisterDaemonRequestModel.cs | 28 ++++ .../RegisterTargetSystemRequestModel.cs | 78 ++++++++++ .../Request/RenameTargetSystemRequestModel.cs | 11 ++ .../ReportRotationFailedRequestModel.cs | 27 ++++ .../ReportRotationSucceededRequestModel.cs | 11 ++ .../Request/SubmitCipherUpdateRequestModel.cs | 19 +++ .../UpdateRotationAccountRequestModel.cs | 13 ++ .../UpdateRotationSettingsRequestModel.cs | 15 ++ .../UpdateTargetSystemPolicyRequestModel.cs | 17 +++ .../ClaimableRotationJobResponseModel.cs | 32 ++++ .../Models/Response/PamDaemonResponseModel.cs | 42 ++++++ .../PamPasswordPolicyResponseModel.cs | 26 ++++ .../PamRotationAttemptResponseModel.cs | 39 +++++ .../PamRotationConfigDetailResponseModel.cs | 20 +++ .../PamRotationConfigResponseModel.cs | 67 +++++++++ .../Response/PamRotationJobResponseModel.cs | 41 ++++++ .../Response/PamTargetSystemResponseModel.cs | 40 +++++ .../Response/RegisterDaemonResponseModel.cs | 44 ++++++ .../Response/RotationCipherResponseModel.cs | 36 +++++ .../Response/RotationClaimResponseModel.cs | 52 +++++++ .../Utilities/ServiceCollectionExtensions.cs | 82 ++++++++++- 39 files changed, 1665 insertions(+), 9 deletions(-) create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Api/Endpoints/Filters/DaemonRequestEndpointFilter.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Api/Endpoints/Handlers/RotationAttemptEndpointsHandler.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Api/Endpoints/Handlers/RotationConfigEndpointsHandler.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Api/Endpoints/Handlers/RotationDaemonEndpointsHandler.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Api/Endpoints/Handlers/RotationDaemonJobsEndpointsHandler.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Api/Endpoints/Handlers/RotationJobEndpointsHandler.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Api/Endpoints/Handlers/RotationTargetSystemEndpointsHandler.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Api/Endpoints/RotationAttemptEndpoints.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Api/Endpoints/RotationConfigEndpoints.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Api/Endpoints/RotationDaemonEndpoints.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Api/Endpoints/RotationDaemonJobsEndpoints.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Api/Endpoints/RotationJobEndpoints.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Api/Endpoints/RotationTargetSystemEndpoints.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Api/Models/Request/AssignDaemonTargetRequestModel.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Api/Models/Request/CreateRotationConfigRequestModel.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Api/Models/Request/PamPasswordPolicyRequestModel.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Api/Models/Request/RegisterDaemonRequestModel.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Api/Models/Request/RegisterTargetSystemRequestModel.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Api/Models/Request/RenameTargetSystemRequestModel.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Api/Models/Request/ReportRotationFailedRequestModel.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Api/Models/Request/ReportRotationSucceededRequestModel.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Api/Models/Request/SubmitCipherUpdateRequestModel.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Api/Models/Request/UpdateRotationAccountRequestModel.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Api/Models/Request/UpdateRotationSettingsRequestModel.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Api/Models/Request/UpdateTargetSystemPolicyRequestModel.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Api/Models/Response/ClaimableRotationJobResponseModel.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Api/Models/Response/PamDaemonResponseModel.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Api/Models/Response/PamPasswordPolicyResponseModel.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Api/Models/Response/PamRotationAttemptResponseModel.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Api/Models/Response/PamRotationConfigDetailResponseModel.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Api/Models/Response/PamRotationConfigResponseModel.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Api/Models/Response/PamRotationJobResponseModel.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Api/Models/Response/PamTargetSystemResponseModel.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Api/Models/Response/RegisterDaemonResponseModel.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Api/Models/Response/RotationCipherResponseModel.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Api/Models/Response/RotationClaimResponseModel.cs diff --git a/bitwarden_license/src/Services/Pam/Api/Endpoints/Filters/PamValidationEndpointFilter.cs b/bitwarden_license/src/Services/Pam/Api/Endpoints/Filters/PamValidationEndpointFilter.cs index 8e5278d6889c..191b517cce96 100644 --- a/bitwarden_license/src/Services/Pam/Api/Endpoints/Filters/PamValidationEndpointFilter.cs +++ b/bitwarden_license/src/Services/Pam/Api/Endpoints/Filters/PamValidationEndpointFilter.cs @@ -10,13 +10,19 @@ namespace Bit.Services.Pam.Api.Endpoints.Filters; /// public class PamValidationEndpointFilter : IEndpointFilter { - private const string RequestModelNamespace = "Bit.Services.Pam.Api.Models.Request"; + // A prefix/suffix match rather than an exact one, so nested feature subtrees that mirror the same + // Api/Models/Request folder convention -- e.g. Rotation's Bit.Services.Pam.Rotation.Api.Models.Request -- + // are covered without this filter needing to know about every subtree by name. + private const string RequestModelNamespacePrefix = "Bit.Services.Pam."; + private const string RequestModelNamespaceSuffix = ".Api.Models.Request"; public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) { foreach (var argument in context.Arguments) { - if (argument is null || argument.GetType().Namespace != RequestModelNamespace) + if (argument is null || argument.GetType().Namespace is not { } ns + || !ns.StartsWith(RequestModelNamespacePrefix, StringComparison.Ordinal) + || !ns.EndsWith(RequestModelNamespaceSuffix, StringComparison.Ordinal)) { continue; } diff --git a/bitwarden_license/src/Services/Pam/Api/Endpoints/PamEndpointsExtensions.cs b/bitwarden_license/src/Services/Pam/Api/Endpoints/PamEndpointsExtensions.cs index 89ecb37b6186..4e7a62a0d8c5 100644 --- a/bitwarden_license/src/Services/Pam/Api/Endpoints/PamEndpointsExtensions.cs +++ b/bitwarden_license/src/Services/Pam/Api/Endpoints/PamEndpointsExtensions.cs @@ -2,6 +2,8 @@ using Bit.Core.Auth.Identity; using Bit.Core.Models.Api; using Bit.Services.Pam.Api.Endpoints.Filters; +using Bit.Services.Pam.Rotation.Api.Endpoints; +using Bit.Services.Pam.Rotation.Api.Endpoints.Filters; namespace Bit.Services.Pam.Api.Endpoints; @@ -19,18 +21,60 @@ public static void MapPamEndpoints(this IEndpointRouteBuilder endpoints) endpoints.MapGroup("/access-requests").WithPamDefaults().MapAccessRequestEndpoints(); endpoints.MapGroup("/organizations/{orgId:guid}/access-rules").WithPamDefaults().MapAccessRuleEndpoints(); endpoints.MapGroup("/leases/ciphers/{id:guid}").WithPamDefaults().MapCipherLeaseEndpoints(); + + // Credential rotation -- admin fleet/config management, gated behind the PamRotation flag on top of the + // same org-scoped Policies.Application every other admin group uses. + endpoints.MapGroup("/organizations/{orgId:guid}/rotation/daemons").WithPamRotationDefaults().MapRotationDaemonEndpoints(); + endpoints.MapGroup("/organizations/{orgId:guid}/rotation/target-systems").WithPamRotationDefaults().MapRotationTargetSystemEndpoints(); + endpoints.MapGroup("/organizations/{orgId:guid}/rotation/configs").WithPamRotationDefaults().MapRotationConfigEndpoints(); + + // Credential rotation -- the daemon-facing surface. Policies.PamRotationDaemon replaces Policies.Application + // (a machine-credential bearer token, not a user's), and DaemonRequestEndpointFilter re-verifies the daemon + // end to end on every request and bumps its heartbeat. + endpoints.MapGroup("/rotation/daemon").WithPamDaemonDefaults().MapRotationDaemonJobsEndpoints(); + endpoints.MapGroup("/rotation/jobs").WithPamDaemonDefaults().MapRotationJobEndpoints(); + endpoints.MapGroup("/rotation/attempts").WithPamDaemonDefaults().MapRotationAttemptEndpoints(); } + /// Applies the shared PAM endpoint chain with the surface's usual authorization policy and feature flag. + private static RouteGroupBuilder WithPamDefaults(this RouteGroupBuilder group) => + group.WithPamDefaults(Policies.Application, FeatureFlagKeys.Pam); + + /// + /// Rotation's admin surface: org admin/owner-gated (per-row org checks happen in the commands), behind the + /// rotation flag rather than the base PAM flag. + /// + private static RouteGroupBuilder WithPamRotationDefaults(this RouteGroupBuilder group) => + group.WithPamDefaults(Policies.Application, FeatureFlagKeys.PamRotation) + .WithConflictResponseMetadata(); + + /// + /// Rotation's daemon-facing surface: instead of the user-token + /// , plus on every route to + /// re-verify the daemon and bump its heartbeat. Runs after the feature/validation filters so a disabled flag or + /// an invalid body short-circuits before the extra daemon lookup. + /// + /// TODO(PM-39040): rate-limit this group by client_id -- no endpoint-group rate-limit primitive exists + /// elsewhere in this codebase yet to reuse, so this is deliberately left as a follow-up rather than a bespoke + /// mechanism invented for rotation alone. + /// + private static RouteGroupBuilder WithPamDaemonDefaults(this RouteGroupBuilder group) => + group.WithPamDefaults(Policies.PamRotationDaemon, FeatureFlagKeys.PamRotation) + .WithConflictResponseMetadata() + .AddEndpointFilter(); + /// - /// Applies the shared PAM endpoint chain to a group. Order matters: the exception filter is outermost so it - /// translates throws from the feature filter (), - /// the validation filter, and the handlers into the ErrorResponseModel contract. + /// Applies the shared PAM endpoint chain to a group for the given authorization policy and feature flag. Order + /// matters: the exception filter is outermost so it translates throws from the feature filter + /// (), the validation filter, and the handlers into + /// the ErrorResponseModel contract. The zero-argument + /// overload delegates here with the original policy/flag, so every pre-existing group is unaffected. /// - private static RouteGroupBuilder WithPamDefaults(this RouteGroupBuilder group) + private static RouteGroupBuilder WithPamDefaults(this RouteGroupBuilder group, string policy, string featureFlagKey) { - group.RequireAuthorization(Policies.Application); + group.RequireAuthorization(policy); group.AddEndpointFilter(); - group.RequireFeature(FeatureFlagKeys.Pam); + group.RequireFeature(featureFlagKey); group.AddEndpointFilter(); group.WithGroupName("internal"); @@ -43,6 +87,18 @@ private static RouteGroupBuilder WithPamDefaults(this RouteGroupBuilder group) return group; } + /// + /// Adds the 409 Conflict case to a group's documented responses -- rotation is the first PAM surface where + /// commands throw in the ordinary course of business (lost + /// claim races, stale reports, concurrent cipher writes), so the pre-existing groups' metadata is left as-is. + /// + private static RouteGroupBuilder WithConflictResponseMetadata(this RouteGroupBuilder group) + { + group.WithMetadata( + new ProducesResponseTypeMetadata(StatusCodes.Status409Conflict, typeof(ErrorResponseModel), ["application/json"])); + return group; + } + /// /// Minimal API equivalent of [RequireFeature(key)]: gates every endpoint in the group behind the flag. /// diff --git a/bitwarden_license/src/Services/Pam/Rotation/Api/Endpoints/Filters/DaemonRequestEndpointFilter.cs b/bitwarden_license/src/Services/Pam/Rotation/Api/Endpoints/Filters/DaemonRequestEndpointFilter.cs new file mode 100644 index 000000000000..c834613eaaec --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Api/Endpoints/Filters/DaemonRequestEndpointFilter.cs @@ -0,0 +1,66 @@ +using Bit.Core.AdminConsole.AbilitiesCache; +using Bit.Core.Context; +using Bit.Core.Exceptions; +using Bit.Pam.Entities; +using Bit.Pam.Enums; +using Bit.Pam.Repositories; +using Microsoft.Extensions.Options; + +namespace Bit.Services.Pam.Rotation.Api.Endpoints.Filters; + +/// +/// Runs on every daemon-facing rotation route, after +/// authorization has already established the caller as a RotationDaemon-scoped bearer token. A bearer JWT can +/// outlive a revocation or a license lapse by up to its lifetime, so this filter re-verifies the daemon end to end on +/// every request: the token's sub claim () must still name a +/// daemon whose organization is both licensed for PAM and not suspended. +/// Every rejection is a (404) -- never a distinct error -- so a revoked daemon or a +/// disabled organization cannot be distinguished from an unknown one. +/// +/// On success this also doubles as the daemon's heartbeat (spec DaemonConnection): it conditionally bumps +/// (the repository only writes when the existing value is stale, so a +/// tightly polling daemon does not hammer its row) and stashes the loaded daemon on +/// under so handlers avoid a second lookup. +/// +public class DaemonRequestEndpointFilter : IEndpointFilter +{ + /// The key handlers read to get the this filter already loaded. + public const string PamDaemonHttpContextKey = "PamDaemon"; + + public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) + { + // Endpoint filters registered via the generic AddEndpointFilter() are resolved per invocation from the + // request's scoped provider (mirrors RequireFeatureEndpointFilter's IFeatureService lookup) rather than + // constructor-injected, since a filter instance can otherwise be built once and outlive any single request. + var services = context.HttpContext.RequestServices; + var currentContext = services.GetRequiredService(); + var daemonRepository = services.GetRequiredService(); + var organizationAbilityCacheService = services.GetRequiredService(); + var options = services.GetRequiredService>(); + var timeProvider = services.GetRequiredService(); + + var daemonId = currentContext.PamDaemonId ?? throw new NotFoundException(); + + var daemon = await daemonRepository.GetByIdAsync(daemonId); + if (daemon is null || daemon.Status != PamDaemonStatus.Enrolled) + { + throw new NotFoundException(); + } + + // Closes the revocation/license-lapse latency window a short-lived JWT + client token cache would otherwise + // leave open: the daemon's organization must currently be enabled and licensed for PAM, not merely at the + // time the token was issued. + var organizationAbility = await organizationAbilityCacheService.GetOrganizationAbilityAsync(daemon.OrganizationId); + if (organizationAbility is not { Enabled: true, UsePam: true }) + { + throw new NotFoundException(); + } + + var now = timeProvider.GetUtcNow().UtcDateTime; + await daemonRepository.UpdateHeartbeatAsync(daemon.Id, now, options.Value.HeartbeatMinInterval); + + context.HttpContext.Items[PamDaemonHttpContextKey] = daemon; + + return await next(context); + } +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Api/Endpoints/Handlers/RotationAttemptEndpointsHandler.cs b/bitwarden_license/src/Services/Pam/Rotation/Api/Endpoints/Handlers/RotationAttemptEndpointsHandler.cs new file mode 100644 index 000000000000..df192c5d500a --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Api/Endpoints/Handlers/RotationAttemptEndpointsHandler.cs @@ -0,0 +1,51 @@ +using Bit.Core.Context; +using Bit.Services.Pam.Rotation.Api.Models.Request; +using Bit.Services.Pam.Rotation.Api.Models.Response; +using Bit.Services.Pam.Rotation.Commands.Interfaces; +using Bit.Services.Pam.Rotation.Queries.Interfaces; + +namespace Bit.Services.Pam.Rotation.Api.Endpoints.Handlers; + +/// +/// Handler for the rotation/attempts/{id} daemon-facing actions: reading and writing back the claimed +/// attempt's cipher, and reporting its outcome. Runs behind Policies.PamRotationDaemon; every command throws +/// 404 for an unknown attempt id (no audit -- nothing to audit against) and 409 for a stale report or a lost write +/// race (audited as report_rejected / write_rejected). +/// +public class RotationAttemptEndpointsHandler( + ICurrentContext currentContext, + IGetRotationCipherQuery getRotationCipherQuery, + ISubmitCipherUpdateCommand submitCipherUpdateCommand, + IReportRotationSucceededCommand reportRotationSucceededCommand, + IReportRotationFailedCommand reportRotationFailedCommand) +{ + public async Task GetCipher(Guid id) + { + var cipher = await getRotationCipherQuery.GetAsync(currentContext.PamDaemonId!.Value, id); + return new RotationCipherResponseModel(cipher); + } + + public async Task PutCipher(Guid id, SubmitCipherUpdateRequestModel model) + { + await submitCipherUpdateCommand.SubmitAsync( + currentContext.PamDaemonId!.Value, id, model.Data, model.LastKnownRevisionDate); + } + + public async Task Success(Guid id, ReportRotationSucceededRequestModel model) + { + await reportRotationSucceededCommand.ReportSucceededAsync( + currentContext.PamDaemonId!.Value, id, model.SessionTermination); + } + + /// + /// The contract forbids forwarding raw target-system error output (it can echo credentials) -- the daemon sends + /// only a bounded plus optional + /// , combined here and truncated to 500 characters by the + /// command regardless (never rejected). + /// + public async Task Failure(Guid id, ReportRotationFailedRequestModel model) + { + await reportRotationFailedCommand.ReportFailedAsync( + currentContext.PamDaemonId!.Value, id, model.ToFailureReason(), model.SyncState); + } +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Api/Endpoints/Handlers/RotationConfigEndpointsHandler.cs b/bitwarden_license/src/Services/Pam/Rotation/Api/Endpoints/Handlers/RotationConfigEndpointsHandler.cs new file mode 100644 index 000000000000..0533e850ab25 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Api/Endpoints/Handlers/RotationConfigEndpointsHandler.cs @@ -0,0 +1,139 @@ +using Bit.Core.Context; +using Bit.Core.Exceptions; +using Bit.HttpExtensions; +using Bit.Pam; +using Bit.Services.Pam.Rotation.Api.Models.Request; +using Bit.Services.Pam.Rotation.Api.Models.Response; +using Bit.Services.Pam.Rotation.Commands.Interfaces; +using Bit.Services.Pam.Rotation.Queries.Interfaces; + +namespace Bit.Services.Pam.Rotation.Api.Endpoints.Handlers; + +/// +/// Handler for the organizations/{orgId}/rotation/configs resource. Every method is org admin/owner-gated +/// (see , copied from AccessRuleEndpointsHandler); the commands underneath +/// additionally re-verify every id argument belongs to the route organization. +/// +/// and / +/// return the bare entity, not the list/detail projection, so this +/// handler re-reads through after a write to respond with the same +/// shape GET configs/{id} uses -- one extra round trip on writes, in exchange for a single enrichment path. +/// +public class RotationConfigEndpointsHandler( + ICurrentContext currentContext, + TimeProvider timeProvider, + IListRotationConfigsQuery listRotationConfigsQuery, + IGetRotationConfigDetailsQuery getRotationConfigDetailsQuery, + ICreateRotationConfigCommand createRotationConfigCommand, + IUpdateRotationSettingsCommand updateRotationSettingsCommand, + IUpdateRotationAccountCommand updateRotationAccountCommand, + IPauseRotationCommand pauseRotationCommand, + IResumeRotationCommand resumeRotationCommand, + ITriggerRotationCommand triggerRotationCommand, + IRecordManualRotationCommand recordManualRotationCommand, + IDeleteRotationConfigCommand deleteRotationConfigCommand) +{ + public async Task> GetAll(Guid orgId) + { + await EnsureAdminAsync(orgId); + + var configs = await listRotationConfigsQuery.ListAsync(orgId); + var now = timeProvider.GetUtcNow().UtcDateTime; + return new ListResponseModel( + configs.Select(config => new PamRotationConfigResponseModel( + config, PamRotationRules.AwaitingManualRotation(config, config.TargetSystemMethod, now)))); + } + + public async Task Get(Guid orgId, Guid id) + { + await EnsureAdminAsync(orgId); + + return await GetDetailAsync(orgId, id); + } + + public async Task Post(Guid orgId, CreateRotationConfigRequestModel model) + { + await EnsureAdminAsync(orgId); + + var created = await createRotationConfigCommand.CreateAsync( + orgId, + currentContext.UserId!.Value, + model.CipherId, + model.TargetSystemId, + model.AccountIdentity, + model.TerminateSessions, + model.ScheduleCron, + model.RotateOnAccessEnd); + return await GetDetailAsync(orgId, created.Id); + } + + public async Task PutSettings(Guid orgId, Guid id, UpdateRotationSettingsRequestModel model) + { + await EnsureAdminAsync(orgId); + + var updated = await updateRotationSettingsCommand.UpdateAsync( + orgId, currentContext.UserId!.Value, id, model.ScheduleCron, model.RotateOnAccessEnd); + return await GetDetailAsync(orgId, updated.Id); + } + + public async Task PutAccount(Guid orgId, Guid id, UpdateRotationAccountRequestModel model) + { + await EnsureAdminAsync(orgId); + + var updated = await updateRotationAccountCommand.UpdateAsync( + orgId, currentContext.UserId!.Value, id, model.AccountIdentity, model.TerminateSessions); + return await GetDetailAsync(orgId, updated.Id); + } + + public async Task Pause(Guid orgId, Guid id) + { + await EnsureAdminAsync(orgId); + + await pauseRotationCommand.PauseAsync(orgId, currentContext.UserId!.Value, id); + } + + public async Task Resume(Guid orgId, Guid id) + { + await EnsureAdminAsync(orgId); + + await resumeRotationCommand.ResumeAsync(orgId, currentContext.UserId!.Value, id); + } + + public async Task Rotate(Guid orgId, Guid id) + { + await EnsureAdminAsync(orgId); + + await triggerRotationCommand.TriggerAsync(orgId, currentContext.UserId!.Value, id); + } + + public async Task RecordManual(Guid orgId, Guid id) + { + await EnsureAdminAsync(orgId); + + await recordManualRotationCommand.RecordAsync(orgId, currentContext.UserId!.Value, id); + } + + public async Task Delete(Guid orgId, Guid id) + { + await EnsureAdminAsync(orgId); + + await deleteRotationConfigCommand.DeleteAsync(orgId, currentContext.UserId!.Value, id); + } + + private async Task GetDetailAsync(Guid orgId, Guid id) + { + var history = await getRotationConfigDetailsQuery.GetAsync(orgId, id); + var now = timeProvider.GetUtcNow().UtcDateTime; + var awaitingManualRotation = PamRotationRules.AwaitingManualRotation( + history.Config, history.Config.TargetSystemMethod, now); + return new PamRotationConfigDetailResponseModel(history, awaitingManualRotation); + } + + private async Task EnsureAdminAsync(Guid orgId) + { + if (!await currentContext.OrganizationAdmin(orgId) && !await currentContext.OrganizationOwner(orgId)) + { + throw new NotFoundException(); + } + } +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Api/Endpoints/Handlers/RotationDaemonEndpointsHandler.cs b/bitwarden_license/src/Services/Pam/Rotation/Api/Endpoints/Handlers/RotationDaemonEndpointsHandler.cs new file mode 100644 index 000000000000..f06c25e7dc94 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Api/Endpoints/Handlers/RotationDaemonEndpointsHandler.cs @@ -0,0 +1,71 @@ +using Bit.Core.Context; +using Bit.Core.Exceptions; +using Bit.HttpExtensions; +using Bit.Services.Pam.Rotation.Api.Models.Request; +using Bit.Services.Pam.Rotation.Api.Models.Response; +using Bit.Services.Pam.Rotation.Commands.Interfaces; +using Bit.Services.Pam.Rotation.Queries.Interfaces; + +namespace Bit.Services.Pam.Rotation.Api.Endpoints.Handlers; + +/// +/// Handler for the organizations/{orgId}/rotation/daemons resource: fleet registration, revocation, and +/// target assignment. Every method is org admin/owner-gated (see , copied from +/// AccessRuleEndpointsHandler); the commands underneath additionally re-verify every id argument belongs to +/// the route organization (404, never 403 -- no existence oracle over comb GUIDs). +/// +public class RotationDaemonEndpointsHandler( + ICurrentContext currentContext, + IListDaemonsQuery listDaemonsQuery, + IRegisterDaemonCommand registerDaemonCommand, + IRevokeDaemonCommand revokeDaemonCommand, + IAssignDaemonToTargetCommand assignDaemonToTargetCommand, + IUnassignDaemonFromTargetCommand unassignDaemonFromTargetCommand) +{ + public async Task> GetAll(Guid orgId) + { + await EnsureAdminAsync(orgId); + + var daemons = await listDaemonsQuery.ListAsync(orgId); + return new ListResponseModel( + daemons.Select(daemon => new PamDaemonResponseModel(daemon))); + } + + public async Task Post(Guid orgId, RegisterDaemonRequestModel model) + { + await EnsureAdminAsync(orgId); + + var result = await registerDaemonCommand.RegisterAsync( + orgId, currentContext.UserId!.Value, model.Name, model.EncryptedPayload, model.Key); + return new RegisterDaemonResponseModel(result); + } + + public async Task Revoke(Guid orgId, Guid id) + { + await EnsureAdminAsync(orgId); + + await revokeDaemonCommand.RevokeAsync(orgId, currentContext.UserId!.Value, id); + } + + public async Task AssignTarget(Guid orgId, Guid id, AssignDaemonTargetRequestModel model) + { + await EnsureAdminAsync(orgId); + + await assignDaemonToTargetCommand.AssignAsync(orgId, currentContext.UserId!.Value, id, model.TargetSystemId); + } + + public async Task UnassignTarget(Guid orgId, Guid id, Guid targetSystemId) + { + await EnsureAdminAsync(orgId); + + await unassignDaemonFromTargetCommand.UnassignAsync(orgId, currentContext.UserId!.Value, id, targetSystemId); + } + + private async Task EnsureAdminAsync(Guid orgId) + { + if (!await currentContext.OrganizationAdmin(orgId) && !await currentContext.OrganizationOwner(orgId)) + { + throw new NotFoundException(); + } + } +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Api/Endpoints/Handlers/RotationDaemonJobsEndpointsHandler.cs b/bitwarden_license/src/Services/Pam/Rotation/Api/Endpoints/Handlers/RotationDaemonJobsEndpointsHandler.cs new file mode 100644 index 000000000000..299cb75ad1f0 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Api/Endpoints/Handlers/RotationDaemonJobsEndpointsHandler.cs @@ -0,0 +1,41 @@ +using Bit.Core.Context; +using Bit.HttpExtensions; +using Bit.Pam.Repositories; +using Bit.Services.Pam.Rotation.Api.Models.Response; +using Bit.Services.Pam.Rotation.Queries.Interfaces; + +namespace Bit.Services.Pam.Rotation.Api.Endpoints.Handlers; + +/// +/// Handler for GET rotation/daemon/jobs -- the daemon's poll (spec ClaimRotation's candidate set), +/// which doubles as its heartbeat when idle (the heartbeat write itself happens in +/// , ahead of every daemon +/// route, not here). Runs behind Policies.PamRotationDaemon; the daemon's identity comes from +/// , already re-verified Enrolled by the filter. +/// +public class RotationDaemonJobsEndpointsHandler( + ICurrentContext currentContext, + IListClaimableJobsQuery listClaimableJobsQuery, + IPamRotationConfigRepository configRepository) +{ + public async Task> GetJobs() + { + var daemonId = currentContext.PamDaemonId!.Value; + var jobs = await listClaimableJobsQuery.ListAsync(daemonId); + + // A claimable job carries only its RotationConfigId; the wire shape wants the target system directly, so + // resolve it per job through the config. Jobs and their config are deleted together (DeleteRotationConfig + // cascades), so a missing config here would mean the config disappeared mid-poll -- skip it rather than 500. + var models = new List(jobs.Count); + foreach (var job in jobs) + { + var config = await configRepository.GetByIdAsync(job.RotationConfigId); + if (config is not null) + { + models.Add(new ClaimableRotationJobResponseModel(job, config.TargetSystemId)); + } + } + + return new ListResponseModel(models); + } +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Api/Endpoints/Handlers/RotationJobEndpointsHandler.cs b/bitwarden_license/src/Services/Pam/Rotation/Api/Endpoints/Handlers/RotationJobEndpointsHandler.cs new file mode 100644 index 000000000000..fafdaeae4a18 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Api/Endpoints/Handlers/RotationJobEndpointsHandler.cs @@ -0,0 +1,20 @@ +using Bit.Core.Context; +using Bit.Services.Pam.Rotation.Api.Models.Response; +using Bit.Services.Pam.Rotation.Commands.Interfaces; + +namespace Bit.Services.Pam.Rotation.Api.Endpoints.Handlers; + +/// +/// Handler for POST rotation/jobs/{id}/claim (spec ClaimRotation). Runs behind +/// Policies.PamRotationDaemon; throws 409 on a lost race and 404 when +/// the daemon was never eligible to claim the job (no assignment, wrong organization, disabled target/config). +/// +public class RotationJobEndpointsHandler(ICurrentContext currentContext, IClaimRotationJobCommand claimRotationJobCommand) +{ + public async Task Claim(Guid id) + { + var daemonId = currentContext.PamDaemonId!.Value; + var result = await claimRotationJobCommand.ClaimAsync(daemonId, id); + return new RotationClaimResponseModel(result); + } +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Api/Endpoints/Handlers/RotationTargetSystemEndpointsHandler.cs b/bitwarden_license/src/Services/Pam/Rotation/Api/Endpoints/Handlers/RotationTargetSystemEndpointsHandler.cs new file mode 100644 index 000000000000..6e82aca776c2 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Api/Endpoints/Handlers/RotationTargetSystemEndpointsHandler.cs @@ -0,0 +1,84 @@ +using Bit.Core.Context; +using Bit.Core.Exceptions; +using Bit.HttpExtensions; +using Bit.Services.Pam.Rotation.Api.Models.Request; +using Bit.Services.Pam.Rotation.Api.Models.Response; +using Bit.Services.Pam.Rotation.Commands.Interfaces; +using Bit.Services.Pam.Rotation.Queries.Interfaces; + +namespace Bit.Services.Pam.Rotation.Api.Endpoints.Handlers; + +/// +/// Handler for the organizations/{orgId}/rotation/target-systems resource. Every method is org admin/owner-gated +/// (see , copied from AccessRuleEndpointsHandler); the commands underneath +/// additionally re-verify every id argument belongs to the route organization. +/// +public class RotationTargetSystemEndpointsHandler( + ICurrentContext currentContext, + IListTargetSystemsQuery listTargetSystemsQuery, + IRegisterTargetSystemCommand registerTargetSystemCommand, + ISetTargetSystemStatusCommand setTargetSystemStatusCommand, + IRenameTargetSystemCommand renameTargetSystemCommand, + IUpdateTargetSystemPolicyCommand updateTargetSystemPolicyCommand) +{ + public async Task> GetAll(Guid orgId) + { + await EnsureAdminAsync(orgId); + + var targetSystems = await listTargetSystemsQuery.ListAsync(orgId); + return new ListResponseModel( + targetSystems.Select(targetSystem => new PamTargetSystemResponseModel(targetSystem))); + } + + public async Task Post(Guid orgId, RegisterTargetSystemRequestModel model) + { + await EnsureAdminAsync(orgId); + + var targetSystem = await registerTargetSystemCommand.RegisterAsync( + orgId, + currentContext.UserId!.Value, + model.Name, + model.Method, + model.Kind, + model.PasswordPolicy?.ToPasswordPolicy(), + model.SupportsSessionTermination); + return new PamTargetSystemResponseModel(targetSystem); + } + + public async Task Enable(Guid orgId, Guid id) + { + await EnsureAdminAsync(orgId); + + await setTargetSystemStatusCommand.SetStatusAsync(orgId, currentContext.UserId!.Value, id, enable: true); + } + + public async Task Disable(Guid orgId, Guid id) + { + await EnsureAdminAsync(orgId); + + await setTargetSystemStatusCommand.SetStatusAsync(orgId, currentContext.UserId!.Value, id, enable: false); + } + + public async Task Rename(Guid orgId, Guid id, RenameTargetSystemRequestModel model) + { + await EnsureAdminAsync(orgId); + + await renameTargetSystemCommand.RenameAsync(orgId, currentContext.UserId!.Value, id, model.Name); + } + + public async Task UpdatePolicy(Guid orgId, Guid id, UpdateTargetSystemPolicyRequestModel model) + { + await EnsureAdminAsync(orgId); + + await updateTargetSystemPolicyCommand.UpdateAsync( + orgId, currentContext.UserId!.Value, id, model.PasswordPolicy.ToPasswordPolicy(), model.SupportsSessionTermination); + } + + private async Task EnsureAdminAsync(Guid orgId) + { + if (!await currentContext.OrganizationAdmin(orgId) && !await currentContext.OrganizationOwner(orgId)) + { + throw new NotFoundException(); + } + } +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Api/Endpoints/RotationAttemptEndpoints.cs b/bitwarden_license/src/Services/Pam/Rotation/Api/Endpoints/RotationAttemptEndpoints.cs new file mode 100644 index 000000000000..5089bc26afa1 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Api/Endpoints/RotationAttemptEndpoints.cs @@ -0,0 +1,61 @@ +using Bit.Services.Pam.Rotation.Api.Endpoints.Handlers; +using Bit.Services.Pam.Rotation.Api.Models.Request; + +namespace Bit.Services.Pam.Rotation.Api.Endpoints; + +/// +/// The daemon-facing rotation/attempts resource: reading and writing back a claimed attempt's cipher, and +/// reporting its outcome. +/// +internal static class RotationAttemptEndpoints +{ + public static RouteGroupBuilder MapRotationAttemptEndpoints(this RouteGroupBuilder group) + { + group.WithTags("PamRotationAttempts"); + + group.MapGet("{id:guid}/cipher", (Guid id, RotationAttemptEndpointsHandler handler) => handler.GetCipher(id)) + .WithName("Pam_Rotation_Attempts_GetCipher") + .WithDescription( + "Returns the cipher for this daemon's claimed, executing attempt only -- never a general cipher " + + "read. Data is returned exactly as stored: opaque ciphertext the server never decrypts."); + + group.MapPut("{id:guid}/cipher", + async (Guid id, SubmitCipherUpdateRequestModel model, RotationAttemptEndpointsHandler handler) => + { + await handler.PutCipher(id, model); + return TypedResults.Ok(); + }) + .WithName("Pam_Rotation_Attempts_PutCipher") + .WithDescription( + "Writes the rotated secret back to the cipher (spec AcceptCipherUpdate) via an atomic capability " + + "check. 409 means the claim/attempt no longer holds, or LastKnownRevisionDate no longer matches " + + "the cipher's current revision (a concurrent user edit won) -- audited as write_rejected. 404 means " + + "the attempt id is unknown (no audit)."); + + group.MapPost("{id:guid}/success", + async (Guid id, ReportRotationSucceededRequestModel model, RotationAttemptEndpointsHandler handler) => + { + await handler.Success(id, model); + return TypedResults.NoContent(); + }) + .WithName("Pam_Rotation_Attempts_Success") + .WithDescription( + "Reports a successful rotation (spec RecordRotationSucceeded). Requires the attempt to already " + + "have a written cipher (the VerifiedBeforeSuccess backstop); otherwise the report is treated as " + + "stale (409, audited as report_rejected)."); + + group.MapPost("{id:guid}/failure", + async (Guid id, ReportRotationFailedRequestModel model, RotationAttemptEndpointsHandler handler) => + { + await handler.Failure(id, model); + return TypedResults.NoContent(); + }) + .WithName("Pam_Rotation_Attempts_Failure") + .WithDescription( + "Reports a failed rotation attempt (spec RecordRotationFailed). Never forward raw target-system " + + "error output as ErrorCode/Detail -- it can echo credentials. Send a bounded error code plus an " + + "optional short detail instead; both are truncated (never rejected) server-side."); + + return group; + } +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Api/Endpoints/RotationConfigEndpoints.cs b/bitwarden_license/src/Services/Pam/Rotation/Api/Endpoints/RotationConfigEndpoints.cs new file mode 100644 index 000000000000..af3a328d7d9e --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Api/Endpoints/RotationConfigEndpoints.cs @@ -0,0 +1,78 @@ +using Bit.Services.Pam.Rotation.Api.Endpoints.Handlers; +using Bit.Services.Pam.Rotation.Api.Models.Request; + +namespace Bit.Services.Pam.Rotation.Api.Endpoints; + +/// +/// The organizations/{orgId}/rotation/configs resource. orgId is bound from the group's route prefix. +/// +internal static class RotationConfigEndpoints +{ + public static RouteGroupBuilder MapRotationConfigEndpoints(this RouteGroupBuilder group) + { + group.WithTags("PamRotationConfigs"); + + group.MapGet("", (Guid orgId, RotationConfigEndpointsHandler handler) => handler.GetAll(orgId)) + .WithName("Pam_Rotation_Configs_GetAll"); + + group.MapGet("{id:guid}", (Guid orgId, Guid id, RotationConfigEndpointsHandler handler) => handler.Get(orgId, id)) + .WithName("Pam_Rotation_Configs_Get"); + + group.MapPost("", (Guid orgId, CreateRotationConfigRequestModel model, RotationConfigEndpointsHandler handler) => handler.Post(orgId, model)) + .WithName("Pam_Rotation_Configs_Post"); + + group.MapPut("{id:guid}/settings", + (Guid orgId, Guid id, UpdateRotationSettingsRequestModel model, RotationConfigEndpointsHandler handler) => + handler.PutSettings(orgId, id, model)) + .WithName("Pam_Rotation_Configs_PutSettings"); + + group.MapPut("{id:guid}/account", + (Guid orgId, Guid id, UpdateRotationAccountRequestModel model, RotationConfigEndpointsHandler handler) => + handler.PutAccount(orgId, id, model)) + .WithName("Pam_Rotation_Configs_PutAccount"); + + group.MapPost("{id:guid}/pause", + async (Guid orgId, Guid id, RotationConfigEndpointsHandler handler) => + { + await handler.Pause(orgId, id); + return TypedResults.NoContent(); + }) + .WithName("Pam_Rotation_Configs_Pause"); + + group.MapPost("{id:guid}/resume", + async (Guid orgId, Guid id, RotationConfigEndpointsHandler handler) => + { + await handler.Resume(orgId, id); + return TypedResults.NoContent(); + }) + .WithName("Pam_Rotation_Configs_Resume"); + + group.MapPost("{id:guid}/rotate", + async (Guid orgId, Guid id, RotationConfigEndpointsHandler handler) => + { + await handler.Rotate(orgId, id); + return TypedResults.NoContent(); + }) + .WithName("Pam_Rotation_Configs_Rotate") + .WithDescription("Triggers an on-demand rotation now (spec TriggerRotationNow), subject to the per-config on-demand cooldown."); + + group.MapPost("{id:guid}/record-manual", + async (Guid orgId, Guid id, RotationConfigEndpointsHandler handler) => + { + await handler.RecordManual(orgId, id); + return TypedResults.NoContent(); + }) + .WithName("Pam_Rotation_Configs_RecordManual") + .WithDescription("Records that an operator rotated a manual-target config's credential out of band, clearing its due obligation."); + + group.MapDelete("{id:guid}", + async (Guid orgId, Guid id, RotationConfigEndpointsHandler handler) => + { + await handler.Delete(orgId, id); + return TypedResults.NoContent(); + }) + .WithName("Pam_Rotation_Configs_Delete"); + + return group; + } +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Api/Endpoints/RotationDaemonEndpoints.cs b/bitwarden_license/src/Services/Pam/Rotation/Api/Endpoints/RotationDaemonEndpoints.cs new file mode 100644 index 000000000000..b6da945b8d77 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Api/Endpoints/RotationDaemonEndpoints.cs @@ -0,0 +1,54 @@ +using Bit.Services.Pam.Rotation.Api.Endpoints.Handlers; +using Bit.Services.Pam.Rotation.Api.Models.Request; + +namespace Bit.Services.Pam.Rotation.Api.Endpoints; + +/// +/// The organizations/{orgId}/rotation/daemons resource: fleet registration, revocation, and target +/// assignment. orgId is bound from the group's route prefix. +/// +internal static class RotationDaemonEndpoints +{ + public static RouteGroupBuilder MapRotationDaemonEndpoints(this RouteGroupBuilder group) + { + group.WithTags("PamRotationDaemons"); + + group.MapGet("", (Guid orgId, RotationDaemonEndpointsHandler handler) => handler.GetAll(orgId)) + .WithName("Pam_Rotation_Daemons_GetAll"); + + group.MapPost("", (Guid orgId, RegisterDaemonRequestModel model, RotationDaemonEndpointsHandler handler) => handler.Post(orgId, model)) + .WithName("Pam_Rotation_Daemons_Post") + .WithDescription( + "Registers a rotation daemon 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}/revoke", + async (Guid orgId, Guid id, RotationDaemonEndpointsHandler handler) => + { + await handler.Revoke(orgId, id); + return TypedResults.NoContent(); + }) + .WithName("Pam_Rotation_Daemons_Revoke") + .WithDescription( + "Revokes the daemon's credential. A revoked daemon has held the plaintext organization key -- " + + "rotating the organization key is the remediation for a suspected compromise."); + + group.MapPost("{id:guid}/assignments", + async (Guid orgId, Guid id, AssignDaemonTargetRequestModel model, RotationDaemonEndpointsHandler handler) => + { + await handler.AssignTarget(orgId, id, model); + return TypedResults.NoContent(); + }) + .WithName("Pam_Rotation_Daemons_AssignTarget"); + + group.MapDelete("{id:guid}/assignments/{targetSystemId:guid}", + async (Guid orgId, Guid id, Guid targetSystemId, RotationDaemonEndpointsHandler handler) => + { + await handler.UnassignTarget(orgId, id, targetSystemId); + return TypedResults.NoContent(); + }) + .WithName("Pam_Rotation_Daemons_UnassignTarget"); + + return group; + } +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Api/Endpoints/RotationDaemonJobsEndpoints.cs b/bitwarden_license/src/Services/Pam/Rotation/Api/Endpoints/RotationDaemonJobsEndpoints.cs new file mode 100644 index 000000000000..9107835decb4 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Api/Endpoints/RotationDaemonJobsEndpoints.cs @@ -0,0 +1,24 @@ +using Bit.Services.Pam.Rotation.Api.Endpoints.Handlers; + +namespace Bit.Services.Pam.Rotation.Api.Endpoints; + +/// The daemon-facing rotation/daemon resource: the poll. +internal static class RotationDaemonJobsEndpoints +{ + public static RouteGroupBuilder MapRotationDaemonJobsEndpoints(this RouteGroupBuilder group) + { + group.WithTags("PamRotationDaemonJobs"); + + group.MapGet("jobs", (RotationDaemonJobsEndpointsHandler handler) => handler.GetJobs()) + .WithName("Pam_Rotation_DaemonJobs_GetAll") + .WithDescription( + "A daemon's currently claimable jobs on its assigned targets -- the poll. Doubles as a heartbeat " + + "when idle. Heartbeat contract: a daemon MUST call some daemon-facing rotation endpoint at an " + + "interval shorter than DaemonOfflineAfter for as long as it holds a claim, or the release sweep " + + "may reclaim the job once the claim's lease also expires; a daemon SHOULD poll no more often than " + + "HeartbeatMinInterval, since the heartbeat write is conditional on that interval and polling faster " + + "gains nothing."); + + return group; + } +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Api/Endpoints/RotationJobEndpoints.cs b/bitwarden_license/src/Services/Pam/Rotation/Api/Endpoints/RotationJobEndpoints.cs new file mode 100644 index 000000000000..d4471226bc68 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Api/Endpoints/RotationJobEndpoints.cs @@ -0,0 +1,22 @@ +using Bit.Services.Pam.Rotation.Api.Endpoints.Handlers; + +namespace Bit.Services.Pam.Rotation.Api.Endpoints; + +/// The daemon-facing rotation/jobs resource: claiming. +internal static class RotationJobEndpoints +{ + public static RouteGroupBuilder MapRotationJobEndpoints(this RouteGroupBuilder group) + { + group.WithTags("PamRotationJobs"); + + group.MapPost("{id:guid}/claim", (Guid id, RotationJobEndpointsHandler handler) => handler.Claim(id)) + .WithName("Pam_Rotation_Jobs_Claim") + .WithDescription( + "Atomically claims a job -- first-claim-wins -- and returns the work snapshot needed to execute " + + "the rotation. 409 means another daemon won the race (claim a different job); 404 means this " + + "daemon was never eligible to claim it (no assignment, wrong organization, or a disabled " + + "target/config)."); + + return group; + } +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Api/Endpoints/RotationTargetSystemEndpoints.cs b/bitwarden_license/src/Services/Pam/Rotation/Api/Endpoints/RotationTargetSystemEndpoints.cs new file mode 100644 index 000000000000..71975a48b614 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Api/Endpoints/RotationTargetSystemEndpoints.cs @@ -0,0 +1,56 @@ +using Bit.Services.Pam.Rotation.Api.Endpoints.Handlers; +using Bit.Services.Pam.Rotation.Api.Models.Request; + +namespace Bit.Services.Pam.Rotation.Api.Endpoints; + +/// +/// The organizations/{orgId}/rotation/target-systems resource. orgId is bound from the group's route +/// prefix. +/// +internal static class RotationTargetSystemEndpoints +{ + public static RouteGroupBuilder MapRotationTargetSystemEndpoints(this RouteGroupBuilder group) + { + group.WithTags("PamRotationTargetSystems"); + + group.MapGet("", (Guid orgId, RotationTargetSystemEndpointsHandler handler) => handler.GetAll(orgId)) + .WithName("Pam_Rotation_TargetSystems_GetAll"); + + group.MapPost("", (Guid orgId, RegisterTargetSystemRequestModel model, RotationTargetSystemEndpointsHandler handler) => handler.Post(orgId, model)) + .WithName("Pam_Rotation_TargetSystems_Post"); + + group.MapPost("{id:guid}/enable", + async (Guid orgId, Guid id, RotationTargetSystemEndpointsHandler handler) => + { + await handler.Enable(orgId, id); + return TypedResults.NoContent(); + }) + .WithName("Pam_Rotation_TargetSystems_Enable"); + + group.MapPost("{id:guid}/disable", + async (Guid orgId, Guid id, RotationTargetSystemEndpointsHandler handler) => + { + await handler.Disable(orgId, id); + return TypedResults.NoContent(); + }) + .WithName("Pam_Rotation_TargetSystems_Disable"); + + group.MapPut("{id:guid}/name", + async (Guid orgId, Guid id, RenameTargetSystemRequestModel model, RotationTargetSystemEndpointsHandler handler) => + { + await handler.Rename(orgId, id, model); + return TypedResults.NoContent(); + }) + .WithName("Pam_Rotation_TargetSystems_Rename"); + + group.MapPut("{id:guid}/policy", + async (Guid orgId, Guid id, UpdateTargetSystemPolicyRequestModel model, RotationTargetSystemEndpointsHandler handler) => + { + await handler.UpdatePolicy(orgId, id, model); + return TypedResults.NoContent(); + }) + .WithName("Pam_Rotation_TargetSystems_UpdatePolicy"); + + return group; + } +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Api/Models/Request/AssignDaemonTargetRequestModel.cs b/bitwarden_license/src/Services/Pam/Rotation/Api/Models/Request/AssignDaemonTargetRequestModel.cs new file mode 100644 index 000000000000..2efcb67aa839 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Api/Models/Request/AssignDaemonTargetRequestModel.cs @@ -0,0 +1,10 @@ +using System.ComponentModel.DataAnnotations; + +namespace Bit.Services.Pam.Rotation.Api.Models.Request; + +/// The body of POST daemons/{id}/assignments: the target system to assign the daemon to. +public class AssignDaemonTargetRequestModel +{ + [Required] + public Guid TargetSystemId { get; set; } +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Api/Models/Request/CreateRotationConfigRequestModel.cs b/bitwarden_license/src/Services/Pam/Rotation/Api/Models/Request/CreateRotationConfigRequestModel.cs new file mode 100644 index 000000000000..0afd2c822f4a --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Api/Models/Request/CreateRotationConfigRequestModel.cs @@ -0,0 +1,28 @@ +using System.ComponentModel.DataAnnotations; + +namespace Bit.Services.Pam.Rotation.Api.Models.Request; + +/// +/// Creates a rotation config for a cipher (spec CreateRotationConfig). is a Quartz +/// 6-field cron expression; null means no scheduled rotation (on-demand and/or access-end only). +/// +public class CreateRotationConfigRequestModel +{ + [Required] + public Guid CipherId { get; set; } + + [Required] + public Guid TargetSystemId { get; set; } + + /// The account this config rotates on the target system. Opaque to the server -- never parsed. + [Required] + [StringLength(500)] + public string AccountIdentity { get; set; } = null!; + + public bool TerminateSessions { get; set; } + + [StringLength(100)] + public string? ScheduleCron { get; set; } + + public bool RotateOnAccessEnd { get; set; } +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Api/Models/Request/PamPasswordPolicyRequestModel.cs b/bitwarden_license/src/Services/Pam/Rotation/Api/Models/Request/PamPasswordPolicyRequestModel.cs new file mode 100644 index 000000000000..0bf588403999 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Api/Models/Request/PamPasswordPolicyRequestModel.cs @@ -0,0 +1,51 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Pam.Models; + +namespace Bit.Services.Pam.Rotation.Api.Models.Request; + +/// +/// The wire shape of -- the password-generation constraints an automatic target +/// system's rotation daemon must satisfy. Embedded in and +/// . +/// +public class PamPasswordPolicyRequestModel : IValidatableObject +{ + [Required] + [Range(1, 128)] + public int MinLength { get; set; } + + [Required] + [Range(1, 128)] + public int MaxLength { get; set; } + + public bool IncludeUppercase { get; set; } + public bool IncludeLowercase { get; set; } + public bool IncludeDigits { get; set; } + public bool IncludeSymbols { get; set; } + + public PamPasswordPolicy ToPasswordPolicy() => new() + { + MinLength = MinLength, + MaxLength = MaxLength, + IncludeUppercase = IncludeUppercase, + IncludeLowercase = IncludeLowercase, + IncludeDigits = IncludeDigits, + IncludeSymbols = IncludeSymbols, + }; + + public IEnumerable Validate(ValidationContext validationContext) + { + if (MinLength > MaxLength) + { + yield return new ValidationResult( + "MinLength must not be greater than MaxLength.", [nameof(MinLength), nameof(MaxLength)]); + } + + if (!IncludeUppercase && !IncludeLowercase && !IncludeDigits && !IncludeSymbols) + { + yield return new ValidationResult( + "At least one character class must be included.", + [nameof(IncludeUppercase), nameof(IncludeLowercase), nameof(IncludeDigits), nameof(IncludeSymbols)]); + } + } +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Api/Models/Request/RegisterDaemonRequestModel.cs b/bitwarden_license/src/Services/Pam/Rotation/Api/Models/Request/RegisterDaemonRequestModel.cs new file mode 100644 index 000000000000..3e936b734aa6 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Api/Models/Request/RegisterDaemonRequestModel.cs @@ -0,0 +1,28 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Core.Utilities; + +namespace Bit.Services.Pam.Rotation.Api.Models.Request; + +/// +/// Registers a new rotation daemon (spec DaemonRegistration). and +/// are the client-wrapped org key -- the admin's client wraps the org key and uploads only +/// ciphertext, exactly as Secrets Manager's AccessTokenCreateRequestModel does for a service account token; +/// the server never sees the plaintext key (zero-knowledge). Unlike that model's Name, this daemon's +/// is a plaintext display label (mirrors Bit.Pam.Entities.PamDaemon.Name), not an +/// encrypted field. +/// +public class RegisterDaemonRequestModel +{ + [Required] + [StringLength(200)] + public string Name { get; set; } = null!; + + [Required] + [EncryptedString] + [EncryptedStringLength(4000)] + public string EncryptedPayload { get; set; } = null!; + + [Required] + [EncryptedString] + public string Key { get; set; } = null!; +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Api/Models/Request/RegisterTargetSystemRequestModel.cs b/bitwarden_license/src/Services/Pam/Rotation/Api/Models/Request/RegisterTargetSystemRequestModel.cs new file mode 100644 index 000000000000..449ba794186f --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Api/Models/Request/RegisterTargetSystemRequestModel.cs @@ -0,0 +1,78 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Pam.Enums; + +namespace Bit.Services.Pam.Rotation.Api.Models.Request; + +/// +/// Registers a target system, automatic or manual (spec RegisterAutomaticTargetSystem / +/// RegisterManualTargetSystem) -- method-discriminated on : an +/// target requires , , +/// and ; a target requires all +/// three to be absent. RegisterTargetSystemCommand re-checks this shape server-side as defense in depth +/// (see ); this validation exists so a +/// shape mismatch comes back as a field-level 400 instead of a generic one. +/// +public class RegisterTargetSystemRequestModel : IValidatableObject +{ + [Required] + [StringLength(200)] + public string Name { get; set; } = null!; + + [Required] + public PamTargetSystemMethod Method { get; set; } + + /// The automatic connector kind. Required when is Automatic; must be absent otherwise. + public PamTargetSystemKind? Kind { get; set; } + + /// Required when is Automatic; must be absent otherwise. + public PamPasswordPolicyRequestModel? PasswordPolicy { get; set; } + + /// Required when is Automatic; must be absent otherwise. + public bool? SupportsSessionTermination { get; set; } + + public IEnumerable Validate(ValidationContext validationContext) + { + if (Method == PamTargetSystemMethod.Automatic) + { + if (Kind is null) + { + yield return new ValidationResult( + "Kind is required for an automatic target system.", [nameof(Kind)]); + } + + if (PasswordPolicy is null) + { + yield return new ValidationResult( + "PasswordPolicy is required for an automatic target system.", [nameof(PasswordPolicy)]); + } + + if (SupportsSessionTermination is null) + { + yield return new ValidationResult( + "SupportsSessionTermination is required for an automatic target system.", + [nameof(SupportsSessionTermination)]); + } + } + else + { + if (Kind is not null) + { + yield return new ValidationResult( + "Kind must not be set for a manual target system.", [nameof(Kind)]); + } + + if (PasswordPolicy is not null) + { + yield return new ValidationResult( + "PasswordPolicy must not be set for a manual target system.", [nameof(PasswordPolicy)]); + } + + if (SupportsSessionTermination is not null) + { + yield return new ValidationResult( + "SupportsSessionTermination must not be set for a manual target system.", + [nameof(SupportsSessionTermination)]); + } + } + } +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Api/Models/Request/RenameTargetSystemRequestModel.cs b/bitwarden_license/src/Services/Pam/Rotation/Api/Models/Request/RenameTargetSystemRequestModel.cs new file mode 100644 index 000000000000..286dc8ec981a --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Api/Models/Request/RenameTargetSystemRequestModel.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; + +namespace Bit.Services.Pam.Rotation.Api.Models.Request; + +/// The body of PUT target-systems/{id}/name. Display-only -- the id keys the daemon's connector resolver. +public class RenameTargetSystemRequestModel +{ + [Required] + [StringLength(200)] + public string Name { get; set; } = null!; +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Api/Models/Request/ReportRotationFailedRequestModel.cs b/bitwarden_license/src/Services/Pam/Rotation/Api/Models/Request/ReportRotationFailedRequestModel.cs new file mode 100644 index 000000000000..a3e16e0bf546 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Api/Models/Request/ReportRotationFailedRequestModel.cs @@ -0,0 +1,27 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Pam.Enums; + +namespace Bit.Services.Pam.Rotation.Api.Models.Request; + +/// +/// The body of POST rotation/attempts/{id}/failure (spec RecordRotationFailed). The contract forbids +/// forwarding raw target-system error output -- it can echo credentials -- so the daemon reports a bounded +/// (an enum-ish string token it defines) plus an optional, separately-capped +/// ; +/// truncates the combined reason to 500 characters server-side regardless (never rejected). +/// +public class ReportRotationFailedRequestModel +{ + [Required] + public PamRotationSyncState SyncState { get; set; } + + [Required] + [StringLength(100)] + public string ErrorCode { get; set; } = null!; + + [StringLength(500)] + public string? Detail { get; set; } + + /// Combines and into the single reason string the command records. + public string ToFailureReason() => Detail is null ? ErrorCode : $"{ErrorCode}: {Detail}"; +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Api/Models/Request/ReportRotationSucceededRequestModel.cs b/bitwarden_license/src/Services/Pam/Rotation/Api/Models/Request/ReportRotationSucceededRequestModel.cs new file mode 100644 index 000000000000..6fa74a7beee4 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Api/Models/Request/ReportRotationSucceededRequestModel.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Pam.Enums; + +namespace Bit.Services.Pam.Rotation.Api.Models.Request; + +/// The body of POST rotation/attempts/{id}/success (spec RecordRotationSucceeded). +public class ReportRotationSucceededRequestModel +{ + [Required] + public PamSessionTerminationOutcome SessionTermination { get; set; } +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Api/Models/Request/SubmitCipherUpdateRequestModel.cs b/bitwarden_license/src/Services/Pam/Rotation/Api/Models/Request/SubmitCipherUpdateRequestModel.cs new file mode 100644 index 000000000000..e0eb7d8356f5 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Api/Models/Request/SubmitCipherUpdateRequestModel.cs @@ -0,0 +1,19 @@ +using System.ComponentModel.DataAnnotations; + +namespace Bit.Services.Pam.Rotation.Api.Models.Request; + +/// +/// The body of PUT rotation/attempts/{id}/cipher (spec AcceptCipherUpdate). is the +/// rotated cipher's encrypted JSON blob, written back verbatim -- opaque ciphertext to the server. +/// must still match the cipher's current revision date at write time or the +/// write is rejected (409) as a concurrent user edit. +/// +public class SubmitCipherUpdateRequestModel +{ + [Required] + [StringLength(500000)] + public string Data { get; set; } = null!; + + [Required] + public DateTime LastKnownRevisionDate { get; set; } +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Api/Models/Request/UpdateRotationAccountRequestModel.cs b/bitwarden_license/src/Services/Pam/Rotation/Api/Models/Request/UpdateRotationAccountRequestModel.cs new file mode 100644 index 000000000000..446a1eebf55d --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Api/Models/Request/UpdateRotationAccountRequestModel.cs @@ -0,0 +1,13 @@ +using System.ComponentModel.DataAnnotations; + +namespace Bit.Services.Pam.Rotation.Api.Models.Request; + +/// The body of PUT configs/{id}/account (spec UpdateRotationAccount). +public class UpdateRotationAccountRequestModel +{ + [Required] + [StringLength(500)] + public string AccountIdentity { get; set; } = null!; + + public bool TerminateSessions { get; set; } +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Api/Models/Request/UpdateRotationSettingsRequestModel.cs b/bitwarden_license/src/Services/Pam/Rotation/Api/Models/Request/UpdateRotationSettingsRequestModel.cs new file mode 100644 index 000000000000..f1b006895f49 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Api/Models/Request/UpdateRotationSettingsRequestModel.cs @@ -0,0 +1,15 @@ +using System.ComponentModel.DataAnnotations; + +namespace Bit.Services.Pam.Rotation.Api.Models.Request; + +/// +/// The body of PUT configs/{id}/settings (spec UpdateRotationSettings). A null +/// clears the config's schedule (recompute-on-edit). +/// +public class UpdateRotationSettingsRequestModel +{ + [StringLength(100)] + public string? ScheduleCron { get; set; } + + public bool RotateOnAccessEnd { get; set; } +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Api/Models/Request/UpdateTargetSystemPolicyRequestModel.cs b/bitwarden_license/src/Services/Pam/Rotation/Api/Models/Request/UpdateTargetSystemPolicyRequestModel.cs new file mode 100644 index 000000000000..ea6ad9f002d4 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Api/Models/Request/UpdateTargetSystemPolicyRequestModel.cs @@ -0,0 +1,17 @@ +using System.ComponentModel.DataAnnotations; + +namespace Bit.Services.Pam.Rotation.Api.Models.Request; + +/// +/// The body of PUT target-systems/{id}/policy (spec UpdateTargetSystemPolicy). Only valid on an +/// automatic target; UpdateTargetSystemPolicyCommand guards that may +/// only be withdrawn (true to false) when no rotation config on the target currently has TerminateSessions set. +/// +public class UpdateTargetSystemPolicyRequestModel +{ + [Required] + public PamPasswordPolicyRequestModel PasswordPolicy { get; set; } = null!; + + [Required] + public bool SupportsSessionTermination { get; set; } +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Api/Models/Response/ClaimableRotationJobResponseModel.cs b/bitwarden_license/src/Services/Pam/Rotation/Api/Models/Response/ClaimableRotationJobResponseModel.cs new file mode 100644 index 000000000000..50627f065564 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Api/Models/Response/ClaimableRotationJobResponseModel.cs @@ -0,0 +1,32 @@ +using Bit.HttpExtensions; +using Bit.Pam.Entities; +using Bit.Pam.Enums; +using Bit.Services.Pam.Api.Models.Response; + +namespace Bit.Services.Pam.Rotation.Api.Models.Response; + +/// +/// A single claimable rotation job, as a daemon's poll (GET rotation/daemon/jobs) sees it -- the candidate set +/// spec ClaimRotation claims from. is resolved from the job's config, since the +/// job row itself does not carry it (only RotationConfigId). +/// +public class ClaimableRotationJobResponseModel : ResponseModel +{ + public ClaimableRotationJobResponseModel(PamRotationJob job, Guid targetSystemId) + : base("pamRotationJob") + { + ArgumentNullException.ThrowIfNull(job); + + JobId = job.Id; + Source = job.Source; + NextClaimableAt = job.NextClaimableAt.AsUtc(); + ExpiresAt = job.ExpiresAt.AsUtc(); + TargetSystemId = targetSystemId; + } + + public Guid JobId { get; } + public PamRotationSource Source { get; } + public DateTime NextClaimableAt { get; } + public DateTime ExpiresAt { get; } + public Guid TargetSystemId { get; } +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Api/Models/Response/PamDaemonResponseModel.cs b/bitwarden_license/src/Services/Pam/Rotation/Api/Models/Response/PamDaemonResponseModel.cs new file mode 100644 index 000000000000..de2cc7884c50 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Api/Models/Response/PamDaemonResponseModel.cs @@ -0,0 +1,42 @@ +using Bit.HttpExtensions; +using Bit.Pam.Enums; +using Bit.Services.Pam.Api.Models.Response; +using Bit.Services.Pam.Rotation.Models; + +namespace Bit.Services.Pam.Rotation.Api.Models.Response; + +/// +/// A rotation daemon as the fleet-admin surface renders it: its derived liveness (spec DaemonConnection) and +/// the target systems it is assigned to. The list view model for GET rotation/daemons. +/// +public class PamDaemonResponseModel : ResponseModel +{ + public PamDaemonResponseModel(PamDaemonListItem item) + : base("pamDaemon") + { + ArgumentNullException.ThrowIfNull(item); + + Id = item.Daemon.Id; + OrganizationId = item.Daemon.OrganizationId; + Name = item.Daemon.Name; + Status = item.Daemon.Status; + IsConnected = item.IsConnected; + LastHeartbeatAt = item.Daemon.LastHeartbeatAt.AsUtc(); + AssignedTargetSystemIds = item.AssignedTargetSystemIds; + CreationDate = item.Daemon.CreationDate.AsUtc(); + RevisionDate = item.Daemon.RevisionDate.AsUtc(); + } + + public Guid Id { get; } + public Guid OrganizationId { get; } + public string Name { get; } + public PamDaemonStatus Status { get; } + + /// Derived from against PamRotationOptions.DaemonOfflineAfter -- spec DaemonConnection. + public bool IsConnected { get; } + + public DateTime? LastHeartbeatAt { get; } + public IReadOnlyList AssignedTargetSystemIds { get; } + public DateTime CreationDate { get; } + public DateTime RevisionDate { get; } +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Api/Models/Response/PamPasswordPolicyResponseModel.cs b/bitwarden_license/src/Services/Pam/Rotation/Api/Models/Response/PamPasswordPolicyResponseModel.cs new file mode 100644 index 000000000000..0ce256a10844 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Api/Models/Response/PamPasswordPolicyResponseModel.cs @@ -0,0 +1,26 @@ +using Bit.Pam.Models; + +namespace Bit.Services.Pam.Rotation.Api.Models.Response; + +/// The wire shape of , embedded wherever a target system's policy is surfaced. +public class PamPasswordPolicyResponseModel +{ + public PamPasswordPolicyResponseModel(PamPasswordPolicy policy) + { + ArgumentNullException.ThrowIfNull(policy); + + MinLength = policy.MinLength; + MaxLength = policy.MaxLength; + IncludeUppercase = policy.IncludeUppercase; + IncludeLowercase = policy.IncludeLowercase; + IncludeDigits = policy.IncludeDigits; + IncludeSymbols = policy.IncludeSymbols; + } + + public int MinLength { get; } + public int MaxLength { get; } + public bool IncludeUppercase { get; } + public bool IncludeLowercase { get; } + public bool IncludeDigits { get; } + public bool IncludeSymbols { get; } +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Api/Models/Response/PamRotationAttemptResponseModel.cs b/bitwarden_license/src/Services/Pam/Rotation/Api/Models/Response/PamRotationAttemptResponseModel.cs new file mode 100644 index 000000000000..486bba8d6618 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Api/Models/Response/PamRotationAttemptResponseModel.cs @@ -0,0 +1,39 @@ +using Bit.Pam.Entities; +using Bit.Pam.Enums; +using Bit.Services.Pam.Api.Models.Response; + +namespace Bit.Services.Pam.Rotation.Api.Models.Response; + +/// One daemon's try at executing a rotation job. An element of . +public class PamRotationAttemptResponseModel +{ + public PamRotationAttemptResponseModel(PamRotationAttempt attempt) + { + ArgumentNullException.ThrowIfNull(attempt); + + Id = attempt.Id; + JobId = attempt.JobId; + ClaimedByDaemonId = attempt.ClaimedByDaemonId; + 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; } + public Guid JobId { get; } + public Guid ClaimedByDaemonId { get; } + public bool CipherUpdated { get; } + public PamRotationAttemptStatus Status { get; } + + /// Bounded, truncated to 500 characters at write time. Null unless is Errored. + public string? FailureReason { get; } + + public PamRotationSyncState? SyncState { get; } + public PamSessionTerminationOutcome? SessionTermination { get; } + public DateTime CreationDate { get; } + public DateTime? ResolvedDate { get; } +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Api/Models/Response/PamRotationConfigDetailResponseModel.cs b/bitwarden_license/src/Services/Pam/Rotation/Api/Models/Response/PamRotationConfigDetailResponseModel.cs new file mode 100644 index 000000000000..38d8c5d4ba14 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Api/Models/Response/PamRotationConfigDetailResponseModel.cs @@ -0,0 +1,20 @@ +using Bit.Services.Pam.Rotation.Models; + +namespace Bit.Services.Pam.Rotation.Api.Models.Response; + +/// A rotation config's detail view: the config summary together with its full job/attempt history, oldest first. +public class PamRotationConfigDetailResponseModel +{ + public PamRotationConfigDetailResponseModel(PamRotationConfigHistory history, bool awaitingManualRotation) + { + ArgumentNullException.ThrowIfNull(history); + + Config = new PamRotationConfigResponseModel(history.Config, awaitingManualRotation); + Jobs = history.Jobs.Select(job => new PamRotationJobResponseModel(job)).ToList(); + } + + public PamRotationConfigResponseModel Config { get; } + + /// Every job recorded against the config, oldest first, each carrying its own attempts. + public IReadOnlyList Jobs { get; } +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Api/Models/Response/PamRotationConfigResponseModel.cs b/bitwarden_license/src/Services/Pam/Rotation/Api/Models/Response/PamRotationConfigResponseModel.cs new file mode 100644 index 000000000000..e96a3b1b7deb --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Api/Models/Response/PamRotationConfigResponseModel.cs @@ -0,0 +1,67 @@ +using Bit.HttpExtensions; +using Bit.Pam.Enums; +using Bit.Pam.Models; +using Bit.Services.Pam.Api.Models.Response; + +namespace Bit.Services.Pam.Rotation.Api.Models.Response; + +/// +/// A rotation config's schedule-list view (spec derived predicates has_active_job / +/// awaiting_manual_rotation folded in) -- the view model for GET rotation/configs and the summary +/// embedded in . +/// +public class PamRotationConfigResponseModel : ResponseModel +{ + public PamRotationConfigResponseModel(PamRotationConfigDetails config, bool awaitingManualRotation) + : base("pamRotationConfig") + { + ArgumentNullException.ThrowIfNull(config); + + Id = config.Id; + OrganizationId = config.OrganizationId; + CipherId = config.CipherId; + TargetSystemId = config.TargetSystemId; + TargetSystemName = config.TargetSystemName; + TargetSystemMethod = config.TargetSystemMethod; + AccountIdentity = config.AccountIdentity; + TerminateSessions = config.TerminateSessions; + ScheduleCron = config.ScheduleCron; + RotateOnAccessEnd = config.RotateOnAccessEnd; + NextRotationAt = config.NextRotationAt.AsUtc(); + Enabled = config.Enabled; + LastRotationAt = config.LastRotationAt.AsUtc(); + HasActiveJob = config.HasActiveJob; + AwaitingManualRotation = awaitingManualRotation; + CreationDate = config.CreationDate.AsUtc(); + RevisionDate = config.RevisionDate.AsUtc(); + } + + public Guid Id { get; } + public Guid OrganizationId { get; } + public Guid CipherId { get; } + public Guid TargetSystemId { get; } + public string TargetSystemName { get; } + public PamTargetSystemMethod TargetSystemMethod { get; } + + /// Opaque to the server -- never parsed; only the daemon interprets it. + public string AccountIdentity { get; } + + public bool TerminateSessions { get; } + public string? ScheduleCron { get; } + public bool RotateOnAccessEnd { get; } + public DateTime? NextRotationAt { get; } + public bool Enabled { get; } + public DateTime? LastRotationAt { get; } + + /// Whether the config has a Pending or Claimed job -- spec has_active_job. + public bool HasActiveJob { get; } + + /// + /// A manual-target config whose schedule has come due -- spec awaiting_manual_rotation. There is no job + /// to claim; an operator must record the out-of-band rotation via POST configs/{id}/record-manual. + /// + public bool AwaitingManualRotation { get; } + + public DateTime CreationDate { get; } + public DateTime RevisionDate { get; } +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Api/Models/Response/PamRotationJobResponseModel.cs b/bitwarden_license/src/Services/Pam/Rotation/Api/Models/Response/PamRotationJobResponseModel.cs new file mode 100644 index 000000000000..e2bd3edf3977 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Api/Models/Response/PamRotationJobResponseModel.cs @@ -0,0 +1,41 @@ +using Bit.Pam.Enums; +using Bit.Pam.Models; +using Bit.Services.Pam.Api.Models.Response; + +namespace Bit.Services.Pam.Rotation.Api.Models.Response; + +/// +/// One offer of rotation work for a config, together with every attempt recorded against it -- an element of +/// (the config detail page's attempt history). +/// +public class PamRotationJobResponseModel +{ + public PamRotationJobResponseModel(PamRotationJobDetails job) + { + ArgumentNullException.ThrowIfNull(job); + + Id = job.Id; + RotationConfigId = job.RotationConfigId; + Source = job.Source; + Status = job.Status; + ClaimedByDaemonId = job.ClaimedByDaemonId; + ClaimedAt = job.ClaimedAt.AsUtc(); + CreationDate = job.CreationDate.AsUtc(); + NextClaimableAt = job.NextClaimableAt.AsUtc(); + ExpiresAt = job.ExpiresAt.AsUtc(); + Attempts = job.Attempts.Select(attempt => new PamRotationAttemptResponseModel(attempt)).ToList(); + } + + public Guid Id { get; } + public Guid RotationConfigId { get; } + public PamRotationSource Source { get; } + public PamRotationJobStatus Status { get; } + public Guid? ClaimedByDaemonId { get; } + public DateTime? ClaimedAt { get; } + public DateTime CreationDate { get; } + public DateTime NextClaimableAt { get; } + public DateTime ExpiresAt { get; } + + /// Oldest first. + public IReadOnlyList Attempts { get; } +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Api/Models/Response/PamTargetSystemResponseModel.cs b/bitwarden_license/src/Services/Pam/Rotation/Api/Models/Response/PamTargetSystemResponseModel.cs new file mode 100644 index 000000000000..0de7f6262a0f --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Api/Models/Response/PamTargetSystemResponseModel.cs @@ -0,0 +1,40 @@ +using Bit.HttpExtensions; +using Bit.Pam.Entities; +using Bit.Pam.Enums; +using Bit.Pam.Models; +using Bit.Services.Pam.Api.Models.Response; + +namespace Bit.Services.Pam.Rotation.Api.Models.Response; + +/// A registered target system, as the fleet-admin surface renders it. The view model for GET rotation/target-systems. +public class PamTargetSystemResponseModel : ResponseModel +{ + public PamTargetSystemResponseModel(PamTargetSystem targetSystem) + : base("pamTargetSystem") + { + ArgumentNullException.ThrowIfNull(targetSystem); + + Id = targetSystem.Id; + OrganizationId = targetSystem.OrganizationId; + Name = targetSystem.Name; + Method = targetSystem.Method; + Kind = targetSystem.Kind; + var policy = PamPasswordPolicy.Parse(targetSystem.PasswordPolicy); + PasswordPolicy = policy is null ? null : new PamPasswordPolicyResponseModel(policy); + SupportsSessionTermination = targetSystem.SupportsSessionTermination; + Status = targetSystem.Status; + CreationDate = targetSystem.CreationDate.AsUtc(); + RevisionDate = targetSystem.RevisionDate.AsUtc(); + } + + public Guid Id { get; } + public Guid OrganizationId { get; } + public string Name { get; } + public PamTargetSystemMethod Method { get; } + public PamTargetSystemKind? Kind { get; } + public PamPasswordPolicyResponseModel? PasswordPolicy { get; } + public bool? SupportsSessionTermination { get; } + public PamTargetSystemStatus Status { get; } + public DateTime CreationDate { get; } + public DateTime RevisionDate { get; } +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Api/Models/Response/RegisterDaemonResponseModel.cs b/bitwarden_license/src/Services/Pam/Rotation/Api/Models/Response/RegisterDaemonResponseModel.cs new file mode 100644 index 000000000000..86b3e6267817 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Api/Models/Response/RegisterDaemonResponseModel.cs @@ -0,0 +1,44 @@ +using Bit.HttpExtensions; +using Bit.Pam.Enums; +using Bit.Services.Pam.Api.Models.Response; +using Bit.Services.Pam.Rotation.Models; + +namespace Bit.Services.Pam.Rotation.Api.Models.Response; + +/// The response to POST rotation/daemons (spec DaemonRegistration). +public class RegisterDaemonResponseModel : ResponseModel +{ + public RegisterDaemonResponseModel(PamDaemonRegistrationResult result) + : base("pamDaemon") + { + ArgumentNullException.ThrowIfNull(result); + + Id = result.Daemon.Id; + OrganizationId = result.Daemon.OrganizationId; + Name = result.Daemon.Name; + Status = result.Daemon.Status; + CreationDate = result.Daemon.CreationDate.AsUtc(); + ApiKeyId = result.Daemon.ApiKeyId; + ClientSecret = result.ClientSecret; + } + + public Guid Id { get; } + public Guid OrganizationId { get; } + public string Name { get; } + public PamDaemonStatus Status { get; } + public DateTime CreationDate { get; } + + /// + /// The id of the daemon's dbo.ApiKey credential. Required by the operator to assemble the daemon's + /// OAuth client id (daemon.<ApiKeyId>, resolved server-side by PamDaemonClientProvider in + /// Identity) -- without it there is no way to derive the client id from the admin API surface. + /// + public Guid ApiKeyId { get; } + + /// + /// WARNING: shown exactly once. The plaintext client secret for the daemon'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 daemon's token (0.daemon.<apiKeyId>.<client_secret>:<encryption_key>). + /// + public string ClientSecret { get; } +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Api/Models/Response/RotationCipherResponseModel.cs b/bitwarden_license/src/Services/Pam/Rotation/Api/Models/Response/RotationCipherResponseModel.cs new file mode 100644 index 000000000000..91ba2c6eacb2 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Api/Models/Response/RotationCipherResponseModel.cs @@ -0,0 +1,36 @@ +using Bit.Core.Vault.Entities; +using Bit.Core.Vault.Enums; +using Bit.Services.Pam.Api.Models.Response; + +namespace Bit.Services.Pam.Rotation.Api.Models.Response; + +/// +/// The response to GET rotation/attempts/{id}/cipher -- purpose-built for the daemon's narrow read (only +/// this daemon's claimed, executing attempt; see GetRotationCipherQuery), deliberately not the general +/// CipherResponseModel (which is user-principal-bound). is the cipher's encrypted JSON +/// blob exactly as stored -- opaque ciphertext the server never decrypts. +/// +public class RotationCipherResponseModel +{ + public RotationCipherResponseModel(Cipher cipher) + { + ArgumentNullException.ThrowIfNull(cipher); + + CipherId = cipher.Id; + OrganizationId = cipher.OrganizationId!.Value; + Type = cipher.Type; + Data = cipher.Data; + Key = cipher.Key; + RevisionDate = cipher.RevisionDate.AsUtc(); + } + + public Guid CipherId { get; } + public Guid OrganizationId { get; } + public CipherType Type { get; } + + /// The cipher's encrypted JSON blob, verbatim -- opaque ciphertext. + public string Data { get; } + + public string? Key { get; } + public DateTime RevisionDate { get; } +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Api/Models/Response/RotationClaimResponseModel.cs b/bitwarden_license/src/Services/Pam/Rotation/Api/Models/Response/RotationClaimResponseModel.cs new file mode 100644 index 000000000000..457e09f7fb65 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Api/Models/Response/RotationClaimResponseModel.cs @@ -0,0 +1,52 @@ +using Bit.Pam.Enums; +using Bit.Pam.Models; +using Bit.Services.Pam.Api.Models.Response; + +namespace Bit.Services.Pam.Rotation.Api.Models.Response; + +/// +/// The work snapshot handed back on a successful claim (spec ClaimRotation) -- everything the daemon needs +/// to execute the rotation without another round trip. Only meaningful when the claim command returns a +/// result; the command throws otherwise (409 lost race, +/// 404 never eligible), so every field here is populated. +/// +public class RotationClaimResponseModel +{ + public RotationClaimResponseModel(PamRotationClaimResult result) + { + ArgumentNullException.ThrowIfNull(result); + + AttemptId = result.AttemptId!.Value; + JobId = result.JobId!.Value; + Source = result.Source!.Value; + TargetSystemId = result.TargetSystemId!.Value; + TargetSystemName = result.TargetSystemName!; + Kind = result.Kind; + var policy = PamPasswordPolicy.Parse(result.PasswordPolicy); + PasswordPolicy = policy is null ? null : new PamPasswordPolicyResponseModel(policy); + CipherId = result.CipherId!.Value; + AccountIdentity = result.AccountIdentity!; + TerminateSessions = result.TerminateSessions!.Value; + ExecuteBy = result.ExecuteBy!.Value.AsUtc(); + } + + public Guid AttemptId { get; } + public Guid JobId { get; } + public PamRotationSource Source { get; } + public Guid TargetSystemId { get; } + public string TargetSystemName { get; } + public PamTargetSystemKind? Kind { get; } + public PamPasswordPolicyResponseModel? PasswordPolicy { get; } + public Guid CipherId { get; } + + /// Opaque to the server -- never parsed; only the daemon interprets it. + public string AccountIdentity { get; } + + public bool TerminateSessions { get; } + + /// + /// The claim's lease deadline. The daemon must finish -- or at least keep heartbeating -- before this, or the + /// release sweep may reclaim the job out from under it once its heartbeat also goes stale. + /// + public DateTime ExecuteBy { get; } +} diff --git a/bitwarden_license/src/Services/Pam/Utilities/ServiceCollectionExtensions.cs b/bitwarden_license/src/Services/Pam/Utilities/ServiceCollectionExtensions.cs index 11ae3dcc6752..c4bf948791d3 100644 --- a/bitwarden_license/src/Services/Pam/Utilities/ServiceCollectionExtensions.cs +++ b/bitwarden_license/src/Services/Pam/Utilities/ServiceCollectionExtensions.cs @@ -7,14 +7,28 @@ using Bit.Services.Pam.OrganizationFeatures.Commands.Interfaces; using Bit.Services.Pam.OrganizationFeatures.Queries; using Bit.Services.Pam.OrganizationFeatures.Queries.Interfaces; +using Bit.Services.Pam.Rotation; +using Bit.Services.Pam.Rotation.Api.Endpoints.Filters; +using Bit.Services.Pam.Rotation.Api.Endpoints.Handlers; +using Bit.Services.Pam.Rotation.Commands; +using Bit.Services.Pam.Rotation.Commands.Interfaces; +using Bit.Services.Pam.Rotation.Queries; +using Bit.Services.Pam.Rotation.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 -- AddPamServices previously took no + /// configuration, so this is a new parameter on an existing call site (see Startup.ConfigureServices) + /// rather than a pre-existing pattern in this file. + /// + public static IServiceCollection AddPamServices(this IServiceCollection services, IConfiguration configuration) { services.AddSingleton(); services.AddScoped(); @@ -54,11 +68,77 @@ public static IServiceCollection AddPamServices(this IServiceCollection services services.AddScoped(); services.AddScoped(); + // Credential rotation's Minimal API endpoint handlers (see Rotation/Api/Endpoints). + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + // Runs on every daemon-facing rotation route (see PamEndpointsExtensions.WithPamDaemonDefaults). Registered + // explicitly even though its parameterless constructor would let AddEndpointFilter() construct it + // unregistered (as PamExceptionHandlerEndpointFilter/PamValidationEndpointFilter already do) -- being + // explicit here documents that it participates in the request pipeline, since unlike those two it resolves + // several other services from the request's provider inside InvokeAsync. + services.AddScoped(); + + services.AddPamRotationServices(configuration); services.AddPamOpenApiEndpointDataSource(); return services; } + /// + /// Registers PAM credential rotation: the schedule calculator, the admin/dispatch commands, and the read + /// queries under Rotation/. Options are bound from globalSettings:pam:rotation (see + /// for defaults); the Quartz sweep jobs and Dapper repositories are registered + /// elsewhere (commercial job host / DapperServiceCollectionExtensions). + /// + private static IServiceCollection AddPamRotationServices(this IServiceCollection services, IConfiguration configuration) + { + services.Configure(configuration.GetSection("globalSettings:pam:rotation")); + + // Stateless and cheap to construct; shared across the process like IAccessRuleEngine. + services.AddSingleton(); + + // Admin commands + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + // Dispatch / daemon commands + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + // Read queries + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + return services; + } + /// /// Registers the PAM Minimal API endpoints (see MapPamEndpoints) so the offline OpenAPI generator /// (dotnet swagger tofile) can discover them — it never runs the Configure pipeline where the From bee7665a3e28711f3f8e8bcc69ddde5b57dc359a Mon Sep 17 00:00:00 2001 From: Hinton Date: Mon, 6 Jul 2026 14:30:38 +0200 Subject: [PATCH 08/10] [PM-39040] Add rotation sweep jobs and access-end trigger Two commercial-gated Quartz minute sweeps: rotation (due offers, success-wins timeouts, lease-respecting releases of stale daemons' claims) and lease natural expiry (flips Active leases past NotAfter, emits the long-deferred LeaseExpired audit kind, fires the rotation access-end trigger). RevokeAccessLeaseCommand fires the same trigger on explicit lease ends, isolated so it can never fail the revoke. --- .../Commands/RevokeAccessLeaseCommand.cs | 23 ++- .../Jobs/IPamLeaseExpirySweepService.cs | 21 +++ .../Rotation/Jobs/IPamRotationSweepService.cs | 18 ++ .../PamJobsServiceCollectionExtensions.cs | 26 +++ .../Rotation/Jobs/PamLeaseExpirySweepJob.cs | 43 +++++ .../Jobs/PamLeaseExpirySweepService.cs | 69 ++++++++ .../Pam/Rotation/Jobs/PamRotationSweepJob.cs | 39 +++++ .../Rotation/Jobs/PamRotationSweepService.cs | 155 ++++++++++++++++++ src/Api/Jobs/JobsHostedService.cs | 15 ++ src/Api/Startup.cs | 10 +- 10 files changed, 417 insertions(+), 2 deletions(-) create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Jobs/IPamLeaseExpirySweepService.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Jobs/IPamRotationSweepService.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Jobs/PamJobsServiceCollectionExtensions.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Jobs/PamLeaseExpirySweepJob.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Jobs/PamLeaseExpirySweepService.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Jobs/PamRotationSweepJob.cs create mode 100644 bitwarden_license/src/Services/Pam/Rotation/Jobs/PamRotationSweepService.cs diff --git a/bitwarden_license/src/Services/Pam/OrganizationFeatures/Commands/RevokeAccessLeaseCommand.cs b/bitwarden_license/src/Services/Pam/OrganizationFeatures/Commands/RevokeAccessLeaseCommand.cs index f800d5fd1d09..b0f24a75ae37 100644 --- a/bitwarden_license/src/Services/Pam/OrganizationFeatures/Commands/RevokeAccessLeaseCommand.cs +++ b/bitwarden_license/src/Services/Pam/OrganizationFeatures/Commands/RevokeAccessLeaseCommand.cs @@ -5,6 +5,7 @@ using Bit.Pam.Repositories; using Bit.Pam.Services; using Bit.Services.Pam.OrganizationFeatures.Commands.Interfaces; +using Bit.Services.Pam.Rotation.Commands.Interfaces; using Bit.Services.Pam.Services; namespace Bit.Services.Pam.OrganizationFeatures.Commands; @@ -16,7 +17,9 @@ public class RevokeAccessLeaseCommand : IRevokeAccessLeaseCommand private readonly IApproverInboxNotifier _approverInboxNotifier; private readonly IRequesterNotifier _requesterNotifier; private readonly IAccessAuditEventEmitter _accessAuditEventEmitter; + private readonly IHandleAccessGrantEndedCommand _handleAccessGrantEndedCommand; private readonly TimeProvider _timeProvider; + private readonly ILogger _logger; public RevokeAccessLeaseCommand( IAccessLeaseRepository accessLeaseRepository, @@ -24,14 +27,18 @@ public RevokeAccessLeaseCommand( IApproverInboxNotifier approverInboxNotifier, IRequesterNotifier requesterNotifier, IAccessAuditEventEmitter accessAuditEventEmitter, - TimeProvider timeProvider) + IHandleAccessGrantEndedCommand handleAccessGrantEndedCommand, + TimeProvider timeProvider, + ILogger logger) { _accessLeaseRepository = accessLeaseRepository; _approverCollectionAccessQuery = approverCollectionAccessQuery; _approverInboxNotifier = approverInboxNotifier; _requesterNotifier = requesterNotifier; _accessAuditEventEmitter = accessAuditEventEmitter; + _handleAccessGrantEndedCommand = handleAccessGrantEndedCommand; _timeProvider = timeProvider; + _logger = logger; } public async Task RevokeAsync(Guid userId, Guid leaseId, string? reason) @@ -93,6 +100,20 @@ public async Task RevokeAsync(Guid userId, Guid leaseId, string? reason) await _accessAuditEventEmitter.EmitAsync(audit with { Phase = AccessAuditEventPhase.Outcome }); + // Both a holder self-end and an operator revoke are grant-ends (spec RotateOnAccessEnd / + // RaiseManualObligationOnAccessEnd); the handler self-gates on the PamRotation flag. A failure here must + // never fail the revoke itself -- the lease has already ended -- so it is logged and swallowed. + try + { + await _handleAccessGrantEndedCommand.HandleAsync(lease.CipherId); + } + catch (Exception ex) + { + _logger.LogError(ex, + "Failed to trigger the rotation access-end handler for cipher {CipherId} after revoking lease {AccessLeaseId}.", + lease.CipherId, lease.Id); + } + // The active lease just drained; tell every approver of this collection to re-fetch. await _approverInboxNotifier.NotifyCollectionApproversAsync(lease.CollectionId); diff --git a/bitwarden_license/src/Services/Pam/Rotation/Jobs/IPamLeaseExpirySweepService.cs b/bitwarden_license/src/Services/Pam/Rotation/Jobs/IPamLeaseExpirySweepService.cs new file mode 100644 index 000000000000..7c2e8862ea35 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Jobs/IPamLeaseExpirySweepService.cs @@ -0,0 +1,21 @@ +namespace Bit.Services.Pam.Rotation.Jobs; + +/// +/// Runs the lease natural-expiry sweep: flips every lease whose +/// window has closed on its own to , emits the deferred +/// audit event, and fires the rotation access-end +/// trigger for each -- closing the standing PAM gap where a lease's own expiry never produced a +/// -shaped record. Invoked on a Quartz cron by +/// ; kept separate from the job class so the sweep logic itself is testable +/// without a Quartz.IJobExecutionContext. +/// +public interface IPamLeaseExpirySweepService +{ + /// + /// Expires every due lease and, per lease, emits its audit event then calls + /// -- a failure + /// against one lease is logged and swallowed rather than propagated, so it never prevents the rest of the batch + /// from being processed. + /// + Task SweepAsync(); +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Jobs/IPamRotationSweepService.cs b/bitwarden_license/src/Services/Pam/Rotation/Jobs/IPamRotationSweepService.cs new file mode 100644 index 000000000000..4d0095afadb1 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Jobs/IPamRotationSweepService.cs @@ -0,0 +1,18 @@ +namespace Bit.Services.Pam.Rotation.Jobs; + +/// +/// Runs the three time-derived rotation sweeps (spec RotationDue, JobTimesOut, +/// DaemonConnectionDropsReleaseJobs): offering due scheduled configs, timing out expired jobs, and releasing +/// jobs whose claiming daemon has gone stale past its claim lease. Invoked on a Quartz cron by +/// ; kept separate from the job class so the sweep logic itself is testable without +/// a Quartz.IJobExecutionContext. +/// +public interface IPamRotationSweepService +{ + /// + /// Runs all three phases in sequence. Each phase is independently fault-isolated: an exception in one (or in one + /// row within one) is logged and swallowed rather than propagated, so a failure in an earlier phase never + /// prevents the later phases -- or later rows in the same phase -- from running. + /// + Task SweepAsync(); +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Jobs/PamJobsServiceCollectionExtensions.cs b/bitwarden_license/src/Services/Pam/Rotation/Jobs/PamJobsServiceCollectionExtensions.cs new file mode 100644 index 000000000000..4ee7041b91db --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Jobs/PamJobsServiceCollectionExtensions.cs @@ -0,0 +1,26 @@ +namespace Bit.Services.Pam.Rotation.Jobs; + +/// +/// Registers the PAM rotation Quartz sweep services and jobs. Kept separate from +/// Bit.Services.Pam.Utilities.ServiceCollectionExtensions.AddPamServices because the jobs are commercial-gated +/// (see JobsHostedService's #if !OSS block) and registered from Startup's non-OSS branch +/// alongside JobsHostedService.AddCommercialSecretsManagerJobServices, not from AddPamServices itself. +/// +public static class PamJobsServiceCollectionExtensions +{ + /// + /// Registers the two sweep services (scoped -- they depend on scoped repositories/commands) and the two Quartz + /// job classes (transient -- Quartz's DI job factory resolves a new instance per firing, the same lifetime + /// every other job in this codebase uses). + /// + public static IServiceCollection AddPamJobServices(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + + services.AddTransient(); + services.AddTransient(); + + return services; + } +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Jobs/PamLeaseExpirySweepJob.cs b/bitwarden_license/src/Services/Pam/Rotation/Jobs/PamLeaseExpirySweepJob.cs new file mode 100644 index 000000000000..0e6d5571d3c7 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Jobs/PamLeaseExpirySweepJob.cs @@ -0,0 +1,43 @@ +using Bit.Core; +using Bit.Core.Jobs; +using Bit.Core.Services; +using Quartz; + +namespace Bit.Services.Pam.Rotation.Jobs; + +/// +/// Quartz entry point for (the lease natural-expiry sweep). Gated on +/// rather than : flipping an +/// lease to +/// and emitting the deferred +/// event is a leasing fix that belongs to PAM v0, not +/// rotation; the rotation trigger it also fires (via calling +/// ) self-gates on +/// further down. Registered from JobsHostedService inside +/// #if !OSS, since the sweep depends on commercial PAM commands. +/// +public class PamLeaseExpirySweepJob : BaseJob +{ + private readonly IFeatureService _featureService; + private readonly IPamLeaseExpirySweepService _sweepService; + + public PamLeaseExpirySweepJob( + IFeatureService featureService, + IPamLeaseExpirySweepService sweepService, + ILogger logger) + : base(logger) + { + _featureService = featureService; + _sweepService = sweepService; + } + + protected override async Task ExecuteJobAsync(IJobExecutionContext context) + { + if (!_featureService.IsEnabled(FeatureFlagKeys.Pam)) + { + return; + } + + await _sweepService.SweepAsync(); + } +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Jobs/PamLeaseExpirySweepService.cs b/bitwarden_license/src/Services/Pam/Rotation/Jobs/PamLeaseExpirySweepService.cs new file mode 100644 index 000000000000..ba01b3ed9851 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Jobs/PamLeaseExpirySweepService.cs @@ -0,0 +1,69 @@ +using Bit.Pam.Enums; +using Bit.Pam.Models; +using Bit.Pam.Repositories; +using Bit.Pam.Services; +using Bit.Services.Pam.Rotation.Commands.Interfaces; + +namespace Bit.Services.Pam.Rotation.Jobs; + +/// +public class PamLeaseExpirySweepService : IPamLeaseExpirySweepService +{ + private readonly IAccessLeaseRepository _accessLeaseRepository; + private readonly IAccessAuditEventEmitter _accessAuditEventEmitter; + private readonly IHandleAccessGrantEndedCommand _handleAccessGrantEndedCommand; + private readonly TimeProvider _timeProvider; + private readonly ILogger _logger; + + public PamLeaseExpirySweepService( + IAccessLeaseRepository accessLeaseRepository, + IAccessAuditEventEmitter accessAuditEventEmitter, + IHandleAccessGrantEndedCommand handleAccessGrantEndedCommand, + TimeProvider timeProvider, + ILogger logger) + { + _accessLeaseRepository = accessLeaseRepository; + _accessAuditEventEmitter = accessAuditEventEmitter; + _handleAccessGrantEndedCommand = handleAccessGrantEndedCommand; + _timeProvider = timeProvider; + _logger = logger; + } + + public async Task SweepAsync() + { + var now = _timeProvider.GetUtcNow().UtcDateTime; + var expiredLeases = await _accessLeaseRepository.ExpireDueAsync(now); + + foreach (var lease in expiredLeases) + { + try + { + // Machinery event: single Outcome-phase, no human actor -- mirrors RevokeAccessLeaseCommand's + // LeaseRevoked construction, adapted for a lease that ended on its own rather than by a decision. + var audit = new AccessAuditEventData + { + Kind = AccessAuditEventKind.LeaseExpired, + OccurredAt = now, + OrganizationId = lease.OrganizationId, + ActorId = null, + RequesterId = lease.RequesterId, + CollectionId = lease.CollectionId, + CipherId = lease.CipherId, + AccessLeaseId = lease.Id, + LeaseNotBefore = lease.NotBefore, + LeaseNotAfter = lease.NotAfter, + }; + await _accessAuditEventEmitter.EmitAsync(audit); + + // Self-gates on the PamRotation flag -- safe to call unconditionally here, the same as the + // RevokeAccessLeaseCommand hook. + await _handleAccessGrantEndedCommand.HandleAsync(lease.CipherId); + } + catch (Exception ex) + { + _logger.LogError(ex, + "PamLeaseExpirySweepService: failed to process expired lease {AccessLeaseId}.", lease.Id); + } + } + } +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Jobs/PamRotationSweepJob.cs b/bitwarden_license/src/Services/Pam/Rotation/Jobs/PamRotationSweepJob.cs new file mode 100644 index 000000000000..212b463344ac --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Jobs/PamRotationSweepJob.cs @@ -0,0 +1,39 @@ +using Bit.Core; +using Bit.Core.Jobs; +using Bit.Core.Services; +using Quartz; + +namespace Bit.Services.Pam.Rotation.Jobs; + +/// +/// Quartz entry point for (spec RotationDue, JobTimesOut, +/// DaemonConnectionDropsReleaseJobs). Gated on -- when the flag is +/// off the job no-ops on its first line, matching every other rotation entry point (see +/// ). Registered from +/// JobsHostedService inside #if !OSS, since the sweep depends on commercial PAM commands. +/// +public class PamRotationSweepJob : BaseJob +{ + private readonly IFeatureService _featureService; + private readonly IPamRotationSweepService _sweepService; + + public PamRotationSweepJob( + IFeatureService featureService, + IPamRotationSweepService sweepService, + ILogger logger) + : base(logger) + { + _featureService = featureService; + _sweepService = sweepService; + } + + protected override async Task ExecuteJobAsync(IJobExecutionContext context) + { + if (!_featureService.IsEnabled(FeatureFlagKeys.PamRotation)) + { + return; + } + + await _sweepService.SweepAsync(); + } +} diff --git a/bitwarden_license/src/Services/Pam/Rotation/Jobs/PamRotationSweepService.cs b/bitwarden_license/src/Services/Pam/Rotation/Jobs/PamRotationSweepService.cs new file mode 100644 index 000000000000..e387f9e903b4 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Rotation/Jobs/PamRotationSweepService.cs @@ -0,0 +1,155 @@ +using Bit.Pam.Enums; +using Bit.Pam.Models; +using Bit.Pam.Repositories; +using Bit.Pam.Services; +using Bit.Services.Pam.Rotation.Commands.Interfaces; +using Microsoft.Extensions.Options; + +namespace Bit.Services.Pam.Rotation.Jobs; + +/// +public class PamRotationSweepService : IPamRotationSweepService +{ + private readonly IPamRotationConfigRepository _configRepository; + private readonly IPamRotationJobRepository _jobRepository; + private readonly IOfferRotationCommand _offerRotationCommand; + private readonly IAccessAuditEventEmitter _accessAuditEventEmitter; + private readonly IOptions _options; + private readonly TimeProvider _timeProvider; + private readonly ILogger _logger; + + public PamRotationSweepService( + IPamRotationConfigRepository configRepository, + IPamRotationJobRepository jobRepository, + IOfferRotationCommand offerRotationCommand, + IAccessAuditEventEmitter accessAuditEventEmitter, + IOptions options, + TimeProvider timeProvider, + ILogger logger) + { + _configRepository = configRepository; + _jobRepository = jobRepository; + _offerRotationCommand = offerRotationCommand; + _accessAuditEventEmitter = accessAuditEventEmitter; + _options = options; + _timeProvider = timeProvider; + _logger = logger; + } + + public async Task SweepAsync() + { + var now = _timeProvider.GetUtcNow().UtcDateTime; + + // Each phase sweeps a disjoint set of rows -- a bug or transient failure in one (including the repository + // call itself) must never prevent the other two from running, mirroring BaseJob's swallow-and-log philosophy + // one level down. + await RunPhaseAsync("due", () => SweepDueAsync(now)); + await RunPhaseAsync("timeouts", () => SweepTimeoutsAsync(now)); + await RunPhaseAsync("releases", () => SweepReleasesAsync(now)); + } + + private async Task RunPhaseAsync(string phase, Func runPhaseAsync) + { + try + { + await runPhaseAsync(); + } + catch (Exception ex) + { + _logger.LogError(ex, "PamRotationSweepService: the {Phase} phase failed.", phase); + } + } + + private async Task SweepDueAsync(DateTime now) + { + var dueConfigs = await _configRepository.GetManyDueAsync(now); + foreach (var config in dueConfigs) + { + try + { + // OfferAsync re-checks can_offer and no-ops silently on ActiveJobExists/ConfigNotOfferable -- the + // sweep does not need to inspect the outcome, only isolate genuine failures per config. + await _offerRotationCommand.OfferAsync(config.Id, PamRotationSource.Scheduled); + } + catch (Exception ex) + { + _logger.LogError(ex, + "PamRotationSweepService: failed to offer a due rotation for config {RotationConfigId}.", + config.Id); + } + } + } + + private async Task SweepTimeoutsAsync(DateTime now) + { + var timedOutJobs = await _jobRepository.TimeoutDueAsync(now); + foreach (var job in timedOutJobs) + { + try + { + var config = await _configRepository.GetByIdAsync(job.RotationConfigId); + if (config is not null) + { + config.NextRotationAt = now + _options.Value.FailureRetryDelay; + config.RevisionDate = now; + await _configRepository.ReplaceAsync(config); + } + + // Machinery event: single Outcome-phase, no human actor. + var audit = new AccessAuditEventData + { + Kind = AccessAuditEventKind.RotationJobTimedOut, + OccurredAt = now, + OrganizationId = job.OrganizationId, + ActorId = null, + CipherId = job.CipherId, + RotationConfigId = job.RotationConfigId, + RotationJobId = job.JobId, + RotationSource = job.Source, + DaemonId = job.ClaimedByDaemonId, + Detail = job.AttemptCount == 0 + ? "rotation job timed out (unroutable: no eligible daemon)" + : "rotation job timed out (stuck daemon)", + }; + await _accessAuditEventEmitter.EmitAsync(audit); + } + catch (Exception ex) + { + _logger.LogError(ex, + "PamRotationSweepService: failed to process timed-out rotation job {RotationJobId}.", job.JobId); + } + } + } + + private async Task SweepReleasesAsync(DateTime now) + { + var releasedJobs = await _jobRepository.ReleaseExpiredLeasesAsync( + now, _options.Value.DaemonOfflineAfter, _options.Value.ReleaseDelay); + foreach (var job in releasedJobs) + { + try + { + // Machinery event: single Outcome-phase, no human actor. The job's claim fields were already + // cleared by ReleaseExpiredLeasesAsync -- ClaimedByDaemonId here is the pre-clear value it returned. + var audit = new AccessAuditEventData + { + Kind = AccessAuditEventKind.RotationJobReleased, + OccurredAt = now, + OrganizationId = job.OrganizationId, + ActorId = null, + CipherId = job.CipherId, + RotationConfigId = job.RotationConfigId, + RotationJobId = job.JobId, + RotationSource = job.Source, + DaemonId = job.ClaimedByDaemonId, + }; + await _accessAuditEventEmitter.EmitAsync(audit); + } + catch (Exception ex) + { + _logger.LogError(ex, + "PamRotationSweepService: failed to process released rotation job {RotationJobId}.", job.JobId); + } + } + } +} diff --git a/src/Api/Jobs/JobsHostedService.cs b/src/Api/Jobs/JobsHostedService.cs index a9626dc90e84..d1849ea5c6ed 100644 --- a/src/Api/Jobs/JobsHostedService.cs +++ b/src/Api/Jobs/JobsHostedService.cs @@ -3,6 +3,9 @@ using Bit.Core.Jobs; using Bit.Core.Settings; using Quartz; +#if !OSS +using Bit.Services.Pam.Rotation.Jobs; +#endif namespace Bit.Api.Jobs; @@ -47,6 +50,16 @@ public override async Task StartAsync(CancellationToken cancellationToken) .StartNow() .WithCronSchedule("0 0 22 * * ?") .Build(); + var pamRotationSweepTrigger = TriggerBuilder.Create() + .WithIdentity("PamRotationSweepTrigger") + .StartNow() + .WithCronSchedule("0 * * * * ?") + .Build(); + var pamLeaseExpirySweepTrigger = TriggerBuilder.Create() + .WithIdentity("PamLeaseExpirySweepTrigger") + .StartNow() + .WithCronSchedule("0 * * * * ?") + .Build(); var randomDailySponsorshipSyncTrigger = TriggerBuilder.Create() .WithIdentity("RandomDailySponsorshipSyncTrigger") .StartAt(DateBuilder.FutureDate(new Random().Next(24), IntervalUnit.Hour)) @@ -84,6 +97,8 @@ public override async Task StartAsync(CancellationToken cancellationToken) #if !OSS jobs.Add(new Tuple(typeof(EmptySecretsManagerTrashJob), smTrashCleanupTrigger)); + jobs.Add(new Tuple(typeof(PamRotationSweepJob), pamRotationSweepTrigger)); + jobs.Add(new Tuple(typeof(PamLeaseExpirySweepJob), pamLeaseExpirySweepTrigger)); #endif Jobs = jobs; diff --git a/src/Api/Startup.cs b/src/Api/Startup.cs index 85f9008763c2..330efab0cd24 100644 --- a/src/Api/Startup.cs +++ b/src/Api/Startup.cs @@ -41,6 +41,7 @@ using Bit.Commercial.Core.Utilities; using Bit.Commercial.Infrastructure.EntityFramework.SecretsManager; using Bit.Services.Pam.Api.Endpoints; +using Bit.Services.Pam.Rotation.Jobs; using Bit.Services.Pam.Utilities; #endif @@ -146,6 +147,12 @@ public void ConfigureServices(IServiceCollection services) (c.Value.Contains(ApiScopes.Api) || c.Value.Contains(ApiScopes.ApiSecrets)) )); }); + config.AddPolicy(Policies.PamRotationDaemon, policy => + { + policy.RequireAuthenticatedUser(); + policy.RequireClaim(JwtClaimTypes.Scope, ApiScopes.ApiPamRotation); + policy.RequireClaim(Claims.Type, IdentityClientType.RotationDaemon.ToString()); + }); config.AddPolicy(Policies.Send, configurePolicy: policy => { policy.RequireAuthenticatedUser(); @@ -207,8 +214,9 @@ public void ConfigureServices(IServiceCollection services) services.AddCommercialCoreServices(); services.AddCommercialSecretsManagerServices(); services.AddSecretsManagerEfRepositories(); - services.AddPamServices(); + services.AddPamServices(Configuration); Jobs.JobsHostedService.AddCommercialSecretsManagerJobServices(services); + services.AddPamJobServices(); #endif // MVC From 6d190e9cee7a188242e001389e90fec2e8ba40d9 Mon Sep 17 00:00:00 2001 From: Hinton Date: Mon, 6 Jul 2026 14:31:32 +0200 Subject: [PATCH 09/10] [PM-39040] Add rotation unit and integration tests 171 unit tests (guard matrices with mandatory cross-org 404 cases, retry-budget and success-wins semantics, exact-complement rejections, UTC cron evaluation, sweep phases, daemon filter and client provider) plus 43 database integration tests covering the concurrency paths: concurrent double-claim single-winner, forged cross-org assignment refusal, atomic cipher-write vs release interleaving, and lease-expiry idempotence. Lockfiles pick up the Pam.Domain reference and internal version bump. --- .../Scim.IntegrationTest/packages.lock.json | 49 +- .../Api/DaemonRequestEndpointFilterTests.cs | 167 ++++ .../AssignDaemonToTargetCommandTests.cs | 178 ++++ .../Commands/ClaimRotationJobCommandTests.cs | 101 ++ .../CreateRotationConfigCommandTests.cs | 250 +++++ .../DeleteRotationConfigCommandTests.cs | 99 ++ .../Commands/GetRotationCipherQueryTests.cs | 93 ++ .../HandleAccessGrantEndedCommandTests.cs | 148 +++ .../Commands/OfferRotationCommandTests.cs | 189 ++++ .../Commands/PauseRotationCommandTests.cs | 95 ++ .../RecordManualRotationCommandTests.cs | 122 +++ .../Commands/RegisterDaemonCommandTests.cs | 115 +++ .../RegisterTargetSystemCommandTests.cs | 123 +++ .../RenameTargetSystemCommandTests.cs | 95 ++ .../ReportRotationFailedCommandTests.cs | 164 ++++ .../ReportRotationSucceededCommandTests.cs | 117 +++ .../Commands/ResumeRotationCommandTests.cs | 140 +++ .../Commands/RevokeDaemonCommandTests.cs | 119 +++ .../SetTargetSystemStatusCommandTests.cs | 113 +++ .../SubmitCipherUpdateCommandTests.cs | 131 +++ .../Commands/TriggerRotationCommandTests.cs | 176 ++++ .../UnassignDaemonFromTargetCommandTests.cs | 133 +++ .../UpdateRotationAccountCommandTests.cs | 154 +++ .../UpdateRotationSettingsCommandTests.cs | 120 +++ .../UpdateTargetSystemPolicyCommandTests.cs | 137 +++ .../Jobs/PamLeaseExpirySweepServiceTests.cs | 85 ++ .../Jobs/PamRotationSweepServiceTests.cs | 164 ++++ .../RotationScheduleCalculatorTests.cs | 112 +++ .../Sso.IntegrationTest/packages.lock.json | 49 +- perf/MicroBenchmarks/packages.lock.json | 25 +- test/Api.IntegrationTest/packages.lock.json | 1 + .../packages.lock.json | 1 + .../Events.IntegrationTest/packages.lock.json | 49 +- .../packages.lock.json | 51 +- .../PamDaemonClientProviderTests.cs | 179 ++++ test/Identity.Test/packages.lock.json | 27 +- .../Repositories/AccessLeaseExpiryTests.cs | 155 +++ .../Repositories/PamDaemonRepositoryTests.cs | 229 +++++ .../PamRotationConfigRepositoryTests.cs | 324 +++++++ .../PamRotationJobRepositoryTests.cs | 901 ++++++++++++++++++ .../PamTargetSystemRepositoryTests.cs | 109 +++ test/IntegrationTestCommon/packages.lock.json | 37 +- .../packages.lock.json | 53 +- 43 files changed, 5713 insertions(+), 166 deletions(-) create mode 100644 bitwarden_license/test/Services/Pam.Test/Rotation/Api/DaemonRequestEndpointFilterTests.cs create mode 100644 bitwarden_license/test/Services/Pam.Test/Rotation/Commands/AssignDaemonToTargetCommandTests.cs create mode 100644 bitwarden_license/test/Services/Pam.Test/Rotation/Commands/ClaimRotationJobCommandTests.cs create mode 100644 bitwarden_license/test/Services/Pam.Test/Rotation/Commands/CreateRotationConfigCommandTests.cs create mode 100644 bitwarden_license/test/Services/Pam.Test/Rotation/Commands/DeleteRotationConfigCommandTests.cs create mode 100644 bitwarden_license/test/Services/Pam.Test/Rotation/Commands/GetRotationCipherQueryTests.cs create mode 100644 bitwarden_license/test/Services/Pam.Test/Rotation/Commands/HandleAccessGrantEndedCommandTests.cs create mode 100644 bitwarden_license/test/Services/Pam.Test/Rotation/Commands/OfferRotationCommandTests.cs create mode 100644 bitwarden_license/test/Services/Pam.Test/Rotation/Commands/PauseRotationCommandTests.cs create mode 100644 bitwarden_license/test/Services/Pam.Test/Rotation/Commands/RecordManualRotationCommandTests.cs create mode 100644 bitwarden_license/test/Services/Pam.Test/Rotation/Commands/RegisterDaemonCommandTests.cs create mode 100644 bitwarden_license/test/Services/Pam.Test/Rotation/Commands/RegisterTargetSystemCommandTests.cs create mode 100644 bitwarden_license/test/Services/Pam.Test/Rotation/Commands/RenameTargetSystemCommandTests.cs create mode 100644 bitwarden_license/test/Services/Pam.Test/Rotation/Commands/ReportRotationFailedCommandTests.cs create mode 100644 bitwarden_license/test/Services/Pam.Test/Rotation/Commands/ReportRotationSucceededCommandTests.cs create mode 100644 bitwarden_license/test/Services/Pam.Test/Rotation/Commands/ResumeRotationCommandTests.cs create mode 100644 bitwarden_license/test/Services/Pam.Test/Rotation/Commands/RevokeDaemonCommandTests.cs create mode 100644 bitwarden_license/test/Services/Pam.Test/Rotation/Commands/SetTargetSystemStatusCommandTests.cs create mode 100644 bitwarden_license/test/Services/Pam.Test/Rotation/Commands/SubmitCipherUpdateCommandTests.cs create mode 100644 bitwarden_license/test/Services/Pam.Test/Rotation/Commands/TriggerRotationCommandTests.cs create mode 100644 bitwarden_license/test/Services/Pam.Test/Rotation/Commands/UnassignDaemonFromTargetCommandTests.cs create mode 100644 bitwarden_license/test/Services/Pam.Test/Rotation/Commands/UpdateRotationAccountCommandTests.cs create mode 100644 bitwarden_license/test/Services/Pam.Test/Rotation/Commands/UpdateRotationSettingsCommandTests.cs create mode 100644 bitwarden_license/test/Services/Pam.Test/Rotation/Commands/UpdateTargetSystemPolicyCommandTests.cs create mode 100644 bitwarden_license/test/Services/Pam.Test/Rotation/Jobs/PamLeaseExpirySweepServiceTests.cs create mode 100644 bitwarden_license/test/Services/Pam.Test/Rotation/Jobs/PamRotationSweepServiceTests.cs create mode 100644 bitwarden_license/test/Services/Pam.Test/Rotation/Services/RotationScheduleCalculatorTests.cs create mode 100644 test/Identity.Test/IdentityServer/ClientProviders/PamDaemonClientProviderTests.cs create mode 100644 test/Infrastructure.IntegrationTest/Pam/Repositories/AccessLeaseExpiryTests.cs create mode 100644 test/Infrastructure.IntegrationTest/Pam/Repositories/PamDaemonRepositoryTests.cs create mode 100644 test/Infrastructure.IntegrationTest/Pam/Repositories/PamRotationConfigRepositoryTests.cs create mode 100644 test/Infrastructure.IntegrationTest/Pam/Repositories/PamRotationJobRepositoryTests.cs create mode 100644 test/Infrastructure.IntegrationTest/Pam/Repositories/PamTargetSystemRepositoryTests.cs diff --git a/bitwarden_license/test/Scim.IntegrationTest/packages.lock.json b/bitwarden_license/test/Scim.IntegrationTest/packages.lock.json index aca999974981..02c0a9a133bb 100644 --- a/bitwarden_license/test/Scim.IntegrationTest/packages.lock.json +++ b/bitwarden_license/test/Scim.IntegrationTest/packages.lock.json @@ -1278,7 +1278,7 @@ "dependencies": { "AutoFixture.AutoNSubstitute": "[4.18.1, )", "AutoFixture.Xunit2": "[4.18.1, )", - "Core": "[2026.6.1, )", + "Core": "[2026.6.2, )", "Kralizek.AutoFixture.Extensions.MockHttp": "[2.2.1, 2.2.1]", "Microsoft.Extensions.TimeProvider.Testing": "[10.6.0, 10.6.0]", "Microsoft.NET.Test.Sdk": "[18.0.1, )", @@ -1302,7 +1302,7 @@ "BitPay.Light": "[1.0.1907, 1.0.1907]", "Braintree": "[5.36.0, 5.36.0]", "CsvHelper": "[33.1.0, 33.1.0]", - "Data": "[2026.6.1, )", + "Data": "[2026.6.2, )", "DnsClient": "[1.8.0, 1.8.0]", "Duende.IdentityServer": "[7.4.6, 7.4.6]", "DuoUniversal": "[1.3.1, 1.3.1]", @@ -1327,7 +1327,7 @@ "Newtonsoft.Json": "[13.0.3, 13.0.3]", "OneOf": "[3.0.271, 3.0.271]", "Otp.NET": "[1.4.0, 1.4.0]", - "Pam.Domain": "[2026.6.1, )", + "Pam.Domain": "[2026.6.2, )", "Quartz": "[3.15.1, 3.15.1]", "Quartz.Extensions.DependencyInjection": "[3.15.1, 3.15.1]", "Quartz.Extensions.Hosting": "[3.15.1, 3.15.1]", @@ -1347,7 +1347,7 @@ "identity": { "type": "Project", "dependencies": { - "Core": "[2026.6.1, )", + "Core": "[2026.6.2, )", "OpenTelemetry.Exporter.OpenTelemetryProtocol": "[1.15.3, )", "OpenTelemetry.Extensions.Hosting": "[1.15.3, )", "OpenTelemetry.Instrumentation.AspNetCore": "[1.15.0, )", @@ -1355,27 +1355,28 @@ "OpenTelemetry.Instrumentation.Http": "[1.15.0, )", "OpenTelemetry.Instrumentation.Runtime": "[1.15.0, )", "OpenTelemetry.Instrumentation.SqlClient": "[1.12.0-beta.3, )", - "SharedWeb": "[2026.6.1, )" + "Pam.Domain": "[2026.6.2, )", + "SharedWeb": "[2026.6.2, )" } }, "infrastructure.dapper": { "type": "Project", "dependencies": { - "Core": "[2026.6.1, )", + "Core": "[2026.6.2, )", "Dapper": "[2.1.66, 2.1.66]", - "Pam.Domain": "[2026.6.1, )" + "Pam.Domain": "[2026.6.2, )" } }, "infrastructure.entityframework": { "type": "Project", "dependencies": { "AutoMapper": "[14.0.0, 14.0.0]", - "Core": "[2026.6.1, )", + "Core": "[2026.6.2, )", "Microsoft.EntityFrameworkCore.Relational": "[8.0.8, 8.0.8]", "Microsoft.EntityFrameworkCore.SqlServer": "[8.0.8, 8.0.8]", "Microsoft.EntityFrameworkCore.Sqlite": "[8.0.8, 8.0.8]", "Npgsql.EntityFrameworkCore.PostgreSQL": "[8.0.4, 8.0.4]", - "Pam.Domain": "[2026.6.1, )", + "Pam.Domain": "[2026.6.2, )", "Pomelo.EntityFrameworkCore.MySql": "[8.0.2, 8.0.2]", "linq2db": "[5.4.1, 5.4.1]", "linq2db.EntityFrameworkCore": "[8.1.0, 8.1.0]" @@ -1384,17 +1385,17 @@ "integrationtestcommon": { "type": "Project", "dependencies": { - "Common": "[2026.6.1, )", - "Identity": "[2026.6.1, )", + "Common": "[2026.6.2, )", + "Identity": "[2026.6.2, )", "Microsoft.AspNetCore.Mvc.Testing": "[10.0.8, 10.0.8]", - "Migrator": "[2026.6.1, )", - "Seeder": "[2026.6.1, )" + "Migrator": "[2026.6.2, )", + "Seeder": "[2026.6.2, )" } }, "migrator": { "type": "Project", "dependencies": { - "Core": "[2026.6.1, )", + "Core": "[2026.6.2, )", "Microsoft.Extensions.Logging": "[10.0.8, 10.0.8]", "dbup-sqlserver": "[7.2.0, 7.2.0]" } @@ -1402,7 +1403,7 @@ "pam.domain": { "type": "Project", "dependencies": { - "Data": "[2026.6.1, )" + "Data": "[2026.6.2, )" } }, "rustsdk": { @@ -1411,7 +1412,7 @@ "scim": { "type": "Project", "dependencies": { - "Core": "[2026.6.1, )", + "Core": "[2026.6.2, )", "OpenTelemetry.Exporter.OpenTelemetryProtocol": "[1.15.3, )", "OpenTelemetry.Extensions.Hosting": "[1.15.3, )", "OpenTelemetry.Instrumentation.AspNetCore": "[1.15.0, )", @@ -1419,25 +1420,25 @@ "OpenTelemetry.Instrumentation.Http": "[1.15.0, )", "OpenTelemetry.Instrumentation.Runtime": "[1.15.0, )", "OpenTelemetry.Instrumentation.SqlClient": "[1.12.0-beta.3, )", - "SharedWeb": "[2026.6.1, )" + "SharedWeb": "[2026.6.2, )" } }, "seeder": { "type": "Project", "dependencies": { "Bogus": "[35.6.5, 35.6.5]", - "Core": "[2026.6.1, )", - "Infrastructure.EntityFramework": "[2026.6.1, )", - "RustSdk": "[2026.6.1, )", - "SharedWeb": "[2026.6.1, )" + "Core": "[2026.6.2, )", + "Infrastructure.EntityFramework": "[2026.6.2, )", + "RustSdk": "[2026.6.2, )", + "SharedWeb": "[2026.6.2, )" } }, "sharedweb": { "type": "Project", "dependencies": { - "Core": "[2026.6.1, )", - "Infrastructure.Dapper": "[2026.6.1, )", - "Infrastructure.EntityFramework": "[2026.6.1, )", + "Core": "[2026.6.2, )", + "Infrastructure.Dapper": "[2026.6.2, )", + "Infrastructure.EntityFramework": "[2026.6.2, )", "Microsoft.Bot.Builder.Integration.AspNet.Core": "[4.23.0, 4.23.0]", "Swashbuckle.AspNetCore.SwaggerGen": "[10.1.7, 10.1.7]" } diff --git a/bitwarden_license/test/Services/Pam.Test/Rotation/Api/DaemonRequestEndpointFilterTests.cs b/bitwarden_license/test/Services/Pam.Test/Rotation/Api/DaemonRequestEndpointFilterTests.cs new file mode 100644 index 000000000000..871819ecf547 --- /dev/null +++ b/bitwarden_license/test/Services/Pam.Test/Rotation/Api/DaemonRequestEndpointFilterTests.cs @@ -0,0 +1,167 @@ +using System.Runtime.CompilerServices; +using Bit.Core.AdminConsole.AbilitiesCache; +using Bit.Core.Context; +using Bit.Core.Exceptions; +using Bit.Core.Models.Data.Organizations; +using Bit.Pam.Entities; +using Bit.Pam.Enums; +using Bit.Pam.Repositories; +using Bit.Services.Pam.Rotation; +using Bit.Services.Pam.Rotation.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.Rotation.Api; + +public class DaemonRequestEndpointFilterTests +{ + 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_NoPamDaemonIdInContext_ThrowsNotFound_SkipsNext() + { + var currentContext = Substitute.For(); + currentContext.PamDaemonId.Returns((Guid?)null); + var daemonRepository = Substitute.For(); + var organizationAbilityCacheService = Substitute.For(); + var (context, nextCalled) = CreateContext(currentContext, daemonRepository, organizationAbilityCacheService); + + await Assert.ThrowsAsync( + () => new DaemonRequestEndpointFilter().InvokeAsync(context, NextDelegate(nextCalled)).AsTask()); + + Assert.False(nextCalled.Value); + await daemonRepository.DidNotReceiveWithAnyArgs().UpdateHeartbeatAsync(default, default, default); + } + + [Fact] + public async Task InvokeAsync_DaemonMissing_ThrowsNotFound_SkipsNext() + { + var daemonId = Guid.NewGuid(); + var currentContext = Substitute.For(); + currentContext.PamDaemonId.Returns(daemonId); + var daemonRepository = Substitute.For(); + daemonRepository.GetByIdAsync(daemonId).Returns((PamDaemon?)null); + var organizationAbilityCacheService = Substitute.For(); + var (context, nextCalled) = CreateContext(currentContext, daemonRepository, organizationAbilityCacheService); + + await Assert.ThrowsAsync( + () => new DaemonRequestEndpointFilter().InvokeAsync(context, NextDelegate(nextCalled)).AsTask()); + + Assert.False(nextCalled.Value); + await daemonRepository.DidNotReceiveWithAnyArgs().UpdateHeartbeatAsync(default, default, default); + } + + [Fact] + public async Task InvokeAsync_DaemonRevoked_ThrowsNotFound_SkipsNext() + { + var daemonId = Guid.NewGuid(); + var daemon = new PamDaemon { Id = daemonId, OrganizationId = Guid.NewGuid(), Status = PamDaemonStatus.Revoked }; + var currentContext = Substitute.For(); + currentContext.PamDaemonId.Returns(daemonId); + var daemonRepository = Substitute.For(); + daemonRepository.GetByIdAsync(daemonId).Returns(daemon); + var organizationAbilityCacheService = Substitute.For(); + var (context, nextCalled) = CreateContext(currentContext, daemonRepository, organizationAbilityCacheService); + + await Assert.ThrowsAsync( + () => new DaemonRequestEndpointFilter().InvokeAsync(context, NextDelegate(nextCalled)).AsTask()); + + Assert.False(nextCalled.Value); + await daemonRepository.DidNotReceiveWithAnyArgs().UpdateHeartbeatAsync(default, default, default); + } + + [Fact] + public async Task InvokeAsync_OrganizationDisabled_ThrowsNotFound_SkipsNext() + { + var daemonId = Guid.NewGuid(); + var orgId = Guid.NewGuid(); + var daemon = new PamDaemon { Id = daemonId, OrganizationId = orgId, Status = PamDaemonStatus.Enrolled }; + var currentContext = Substitute.For(); + currentContext.PamDaemonId.Returns(daemonId); + var daemonRepository = Substitute.For(); + daemonRepository.GetByIdAsync(daemonId).Returns(daemon); + var organizationAbilityCacheService = Substitute.For(); + organizationAbilityCacheService.GetOrganizationAbilityAsync(orgId, Arg.Any()) + .Returns(new OrganizationAbility { Id = orgId, Enabled = false, UsePam = true }); + var (context, nextCalled) = CreateContext(currentContext, daemonRepository, organizationAbilityCacheService); + + await Assert.ThrowsAsync( + () => new DaemonRequestEndpointFilter().InvokeAsync(context, NextDelegate(nextCalled)).AsTask()); + + Assert.False(nextCalled.Value); + await daemonRepository.DidNotReceiveWithAnyArgs().UpdateHeartbeatAsync(default, default, default); + } + + [Fact] + public async Task InvokeAsync_OrganizationUsePamFalse_ThrowsNotFound_SkipsNext() + { + var daemonId = Guid.NewGuid(); + var orgId = Guid.NewGuid(); + var daemon = new PamDaemon { Id = daemonId, OrganizationId = orgId, Status = PamDaemonStatus.Enrolled }; + var currentContext = Substitute.For(); + currentContext.PamDaemonId.Returns(daemonId); + var daemonRepository = Substitute.For(); + daemonRepository.GetByIdAsync(daemonId).Returns(daemon); + var organizationAbilityCacheService = Substitute.For(); + organizationAbilityCacheService.GetOrganizationAbilityAsync(orgId, Arg.Any()) + .Returns(new OrganizationAbility { Id = orgId, Enabled = true, UsePam = false }); + var (context, nextCalled) = CreateContext(currentContext, daemonRepository, organizationAbilityCacheService); + + await Assert.ThrowsAsync( + () => new DaemonRequestEndpointFilter().InvokeAsync(context, NextDelegate(nextCalled)).AsTask()); + + Assert.False(nextCalled.Value); + await daemonRepository.DidNotReceiveWithAnyArgs().UpdateHeartbeatAsync(default, default, default); + } + + [Fact] + public async Task InvokeAsync_HappyPath_CallsNextAndUpdatesHeartbeatWithMinInterval() + { + var daemonId = Guid.NewGuid(); + var orgId = Guid.NewGuid(); + var daemon = new PamDaemon { Id = daemonId, OrganizationId = orgId, Status = PamDaemonStatus.Enrolled }; + var currentContext = Substitute.For(); + currentContext.PamDaemonId.Returns(daemonId); + var daemonRepository = Substitute.For(); + daemonRepository.GetByIdAsync(daemonId).Returns(daemon); + var organizationAbilityCacheService = Substitute.For(); + organizationAbilityCacheService.GetOrganizationAbilityAsync(orgId, Arg.Any()) + .Returns(new OrganizationAbility { Id = orgId, Enabled = true, UsePam = true }); + var (context, nextCalled) = CreateContext(currentContext, daemonRepository, organizationAbilityCacheService); + + var result = await new DaemonRequestEndpointFilter().InvokeAsync(context, NextDelegate(nextCalled)); + + Assert.True(nextCalled.Value); + Assert.Equal("ok", result); + await daemonRepository.Received(1).UpdateHeartbeatAsync(daemonId, _now, _heartbeatMinInterval); + Assert.Same(daemon, context.HttpContext.Items[DaemonRequestEndpointFilter.PamDaemonHttpContextKey]); + } + + private static EndpointFilterDelegate NextDelegate(StrongBox nextCalled) => _ => + { + nextCalled.Value = true; + return ValueTask.FromResult("ok"); + }; + + private static (EndpointFilterInvocationContext Context, StrongBox NextCalled) CreateContext( + ICurrentContext currentContext, IPamDaemonRepository daemonRepository, + IOrganizationAbilityCacheService organizationAbilityCacheService) + { + var timeProvider = new FakeTimeProvider(); + timeProvider.SetUtcNow(_now); + var services = new ServiceCollection(); + services.AddSingleton(currentContext); + services.AddSingleton(daemonRepository); + services.AddSingleton(organizationAbilityCacheService); + 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/Rotation/Commands/AssignDaemonToTargetCommandTests.cs b/bitwarden_license/test/Services/Pam.Test/Rotation/Commands/AssignDaemonToTargetCommandTests.cs new file mode 100644 index 000000000000..f9bcee91e695 --- /dev/null +++ b/bitwarden_license/test/Services/Pam.Test/Rotation/Commands/AssignDaemonToTargetCommandTests.cs @@ -0,0 +1,178 @@ +using Bit.Core.Exceptions; +using Bit.Pam.Entities; +using Bit.Pam.Enums; +using Bit.Pam.Models; +using Bit.Pam.Repositories; +using Bit.Pam.Services; +using Bit.Services.Pam.Rotation.Commands; +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.Rotation.Commands; + +[SutProviderCustomize] +public class AssignDaemonToTargetCommandTests +{ + private static readonly DateTime _now = new(2026, 7, 6, 12, 0, 0, DateTimeKind.Utc); + + [Theory, BitAutoData] + public async Task AssignAsync_DaemonMissing_ThrowsNotFound( + Guid organizationId, Guid actingUserId, Guid daemonId, Guid targetSystemId) + { + var sutProvider = Setup(); + sutProvider.GetDependency().GetByIdAsync(daemonId).Returns((PamDaemon?)null); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.AssignAsync(organizationId, actingUserId, daemonId, targetSystemId)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .CreateAssignmentAsync(default!); + } + + [Theory, BitAutoData] + public async Task AssignAsync_DaemonWrongOrg_ThrowsNotFound( + Guid actingUserId, PamDaemon daemon, Guid targetSystemId) + { + var sutProvider = Setup(); + sutProvider.GetDependency().GetByIdAsync(daemon.Id).Returns(daemon); + + // daemon.OrganizationId is an unrelated AutoFixture Guid. + await Assert.ThrowsAsync( + () => sutProvider.Sut.AssignAsync(Guid.NewGuid(), actingUserId, daemon.Id, targetSystemId)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .CreateAssignmentAsync(default!); + } + + [Theory, BitAutoData] + public async Task AssignAsync_TargetMissing_ThrowsNotFound(Guid actingUserId, PamDaemon daemon, Guid targetSystemId) + { + var sutProvider = Setup(); + sutProvider.GetDependency().GetByIdAsync(daemon.Id).Returns(daemon); + sutProvider.GetDependency().GetByIdAsync(targetSystemId) + .Returns((PamTargetSystem?)null); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.AssignAsync(daemon.OrganizationId, actingUserId, daemon.Id, targetSystemId)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .CreateAssignmentAsync(default!); + } + + [Theory, BitAutoData] + public async Task AssignAsync_TargetWrongOrg_ThrowsNotFound(Guid actingUserId, PamDaemon daemon, PamTargetSystem target) + { + var sutProvider = Setup(); + sutProvider.GetDependency().GetByIdAsync(daemon.Id).Returns(daemon); + sutProvider.GetDependency().GetByIdAsync(target.Id).Returns(target); + + // Same org as the daemon, but not the caller's route org -- target.OrganizationId is unrelated to daemon.OrganizationId too. + await Assert.ThrowsAsync( + () => sutProvider.Sut.AssignAsync(daemon.OrganizationId, actingUserId, daemon.Id, target.Id)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .CreateAssignmentAsync(default!); + } + + [Theory, BitAutoData] + public async Task AssignAsync_DaemonRevoked_ThrowsBadRequest(Guid actingUserId, PamDaemon daemon, PamTargetSystem target) + { + var sutProvider = Setup(); + daemon.Status = PamDaemonStatus.Revoked; + target.OrganizationId = daemon.OrganizationId; + target.Method = PamTargetSystemMethod.Automatic; + sutProvider.GetDependency().GetByIdAsync(daemon.Id).Returns(daemon); + sutProvider.GetDependency().GetByIdAsync(target.Id).Returns(target); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.AssignAsync(daemon.OrganizationId, actingUserId, daemon.Id, target.Id)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .CreateAssignmentAsync(default!); + } + + [Theory, BitAutoData] + public async Task AssignAsync_TargetManual_ThrowsBadRequest(Guid actingUserId, PamDaemon daemon, PamTargetSystem target) + { + var sutProvider = Setup(); + daemon.Status = PamDaemonStatus.Enrolled; + target.OrganizationId = daemon.OrganizationId; + target.Method = PamTargetSystemMethod.Manual; + sutProvider.GetDependency().GetByIdAsync(daemon.Id).Returns(daemon); + sutProvider.GetDependency().GetByIdAsync(target.Id).Returns(target); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.AssignAsync(daemon.OrganizationId, actingUserId, daemon.Id, target.Id)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .CreateAssignmentAsync(default!); + } + + [Theory, BitAutoData] + public async Task AssignAsync_DuplicateAssignment_ThrowsBadRequest(Guid actingUserId, PamDaemon daemon, PamTargetSystem target) + { + var sutProvider = Setup(); + daemon.Status = PamDaemonStatus.Enrolled; + target.OrganizationId = daemon.OrganizationId; + target.Method = PamTargetSystemMethod.Automatic; + sutProvider.GetDependency().GetByIdAsync(daemon.Id).Returns(daemon); + sutProvider.GetDependency().GetByIdAsync(target.Id).Returns(target); + sutProvider.GetDependency().AssignmentExistsAsync(daemon.Id, target.Id).Returns(true); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.AssignAsync(daemon.OrganizationId, actingUserId, daemon.Id, target.Id)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .CreateAssignmentAsync(default!); + } + + [Theory, BitAutoData] + public async Task AssignAsync_HappyPath_CreatesAssignment(Guid actingUserId, PamDaemon daemon, PamTargetSystem target) + { + var sutProvider = Setup(); + daemon.Status = PamDaemonStatus.Enrolled; + target.OrganizationId = daemon.OrganizationId; + target.Method = PamTargetSystemMethod.Automatic; + sutProvider.GetDependency().GetByIdAsync(daemon.Id).Returns(daemon); + sutProvider.GetDependency().GetByIdAsync(target.Id).Returns(target); + sutProvider.GetDependency().AssignmentExistsAsync(daemon.Id, target.Id).Returns(false); + + await sutProvider.Sut.AssignAsync(daemon.OrganizationId, actingUserId, daemon.Id, target.Id); + + await sutProvider.GetDependency().Received(1).CreateAssignmentAsync(Arg.Is(a => + a.DaemonId == daemon.Id && a.TargetSystemId == target.Id && a.OrganizationId == daemon.OrganizationId + && a.Id != Guid.Empty && a.CreationDate == _now)); + } + + [Theory, BitAutoData] + public async Task AssignAsync_HappyPath_EmitsAttemptThenOutcome(Guid actingUserId, PamDaemon daemon, PamTargetSystem target) + { + var sutProvider = Setup(); + daemon.Status = PamDaemonStatus.Enrolled; + target.OrganizationId = daemon.OrganizationId; + target.Method = PamTargetSystemMethod.Automatic; + sutProvider.GetDependency().GetByIdAsync(daemon.Id).Returns(daemon); + sutProvider.GetDependency().GetByIdAsync(target.Id).Returns(target); + sutProvider.GetDependency().AssignmentExistsAsync(daemon.Id, target.Id).Returns(false); + + await sutProvider.Sut.AssignAsync(daemon.OrganizationId, actingUserId, daemon.Id, target.Id); + + var emitter = sutProvider.GetDependency(); + await emitter.Received(1).EmitAsync(Arg.Is(e => + e.Kind == AccessAuditEventKind.DaemonAssignedToTarget && e.Phase == AccessAuditEventPhase.Attempt + && e.DaemonId == daemon.Id && e.TargetSystemId == target.Id)); + await emitter.Received(1).EmitAsync(Arg.Is(e => + e.Kind == AccessAuditEventKind.DaemonAssignedToTarget && e.Phase == AccessAuditEventPhase.Outcome + && e.DaemonId == daemon.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/Rotation/Commands/ClaimRotationJobCommandTests.cs b/bitwarden_license/test/Services/Pam.Test/Rotation/Commands/ClaimRotationJobCommandTests.cs new file mode 100644 index 000000000000..85253b32bf56 --- /dev/null +++ b/bitwarden_license/test/Services/Pam.Test/Rotation/Commands/ClaimRotationJobCommandTests.cs @@ -0,0 +1,101 @@ +using Bit.Core.Exceptions; +using Bit.Pam.Entities; +using Bit.Pam.Enums; +using Bit.Pam.Models; +using Bit.Pam.Repositories; +using Bit.Pam.Services; +using Bit.Services.Pam.Rotation; +using Bit.Services.Pam.Rotation.Commands; +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.Rotation.Commands; + +[SutProviderCustomize] +public class ClaimRotationJobCommandTests +{ + private static readonly DateTime _now = new(2026, 6, 5, 12, 0, 0, DateTimeKind.Utc); + private static readonly TimeSpan _releaseDelay = TimeSpan.FromMinutes(15); + + [Theory, BitAutoData] + public async Task ClaimAsync_Claimed_ReturnsSnapshotAndEmitsRotationDispatchedAudit( + Guid daemonId, Guid jobId, PamDaemon daemon, PamRotationJob job) + { + var sutProvider = Setup(); + daemon.Id = daemonId; + job.Id = jobId; + var result = new PamRotationClaimResult + { + Outcome = PamRotationClaimOutcome.Claimed, + AttemptId = Guid.NewGuid(), + JobId = jobId, + Source = PamRotationSource.Scheduled, + TargetSystemId = Guid.NewGuid(), + TargetSystemName = "Prod SQL", + CipherId = Guid.NewGuid(), + AccountIdentity = "svc-account", + TerminateSessions = true, + ExecuteBy = _now + _releaseDelay, + }; + sutProvider.GetDependency() + .ClaimAsync(jobId, daemonId, _now, _releaseDelay) + .Returns(result); + sutProvider.GetDependency().GetByIdAsync(daemonId).Returns(daemon); + sutProvider.GetDependency().GetByIdAsync(jobId).Returns(job); + + var returned = await sutProvider.Sut.ClaimAsync(daemonId, jobId); + + Assert.Same(result, returned); + await sutProvider.GetDependency().Received(1).EmitAsync( + Arg.Is(a => a.Kind == AccessAuditEventKind.RotationDispatched + && a.OrganizationId == daemon.OrganizationId + && a.ActorId == null + && a.DaemonId == daemonId + && a.DaemonName == daemon.Name + && a.RotationJobId == jobId + && a.RotationConfigId == job.RotationConfigId + && a.CipherId == result.CipherId + && a.TargetSystemId == result.TargetSystemId + && a.TargetSystemName == result.TargetSystemName + && a.RotationSource == result.Source)); + } + + [Theory, BitAutoData] + public async Task ClaimAsync_NotClaimable_ThrowsConflict_NoAudit(Guid daemonId, Guid jobId) + { + var sutProvider = Setup(); + sutProvider.GetDependency() + .ClaimAsync(jobId, daemonId, _now, _releaseDelay) + .Returns(new PamRotationClaimResult { Outcome = PamRotationClaimOutcome.NotClaimable }); + + await Assert.ThrowsAsync(() => sutProvider.Sut.ClaimAsync(daemonId, jobId)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().EmitAsync(default!); + } + + [Theory, BitAutoData] + public async Task ClaimAsync_NotEligible_ThrowsNotFound_NoAudit(Guid daemonId, Guid jobId) + { + var sutProvider = Setup(); + sutProvider.GetDependency() + .ClaimAsync(jobId, daemonId, _now, _releaseDelay) + .Returns(new PamRotationClaimResult { Outcome = PamRotationClaimOutcome.NotEligible }); + + await Assert.ThrowsAsync(() => sutProvider.Sut.ClaimAsync(daemonId, jobId)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().EmitAsync(default!); + } + + private static SutProvider Setup() + { + var sutProvider = new SutProvider().WithFakeTimeProvider().Create(); + sutProvider.GetDependency().SetUtcNow(_now); + sutProvider.GetDependency>().Value + .Returns(new PamRotationOptions { ReleaseDelay = _releaseDelay }); + return sutProvider; + } +} diff --git a/bitwarden_license/test/Services/Pam.Test/Rotation/Commands/CreateRotationConfigCommandTests.cs b/bitwarden_license/test/Services/Pam.Test/Rotation/Commands/CreateRotationConfigCommandTests.cs new file mode 100644 index 000000000000..71fabba40461 --- /dev/null +++ b/bitwarden_license/test/Services/Pam.Test/Rotation/Commands/CreateRotationConfigCommandTests.cs @@ -0,0 +1,250 @@ +using Bit.Core.Exceptions; +using Bit.Core.Vault.Entities; +using Bit.Core.Vault.Repositories; +using Bit.Pam.Entities; +using Bit.Pam.Enums; +using Bit.Pam.Models; +using Bit.Pam.Repositories; +using Bit.Pam.Services; +using Bit.Services.Pam.Rotation; +using Bit.Services.Pam.Rotation.Commands; +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.Rotation.Commands; + +[SutProviderCustomize] +public class CreateRotationConfigCommandTests +{ + private static readonly DateTime _now = new(2026, 7, 6, 12, 0, 0, DateTimeKind.Utc); + + [Theory, BitAutoData] + public async Task CreateAsync_AccountIdentityMissing_ThrowsBadRequest( + Guid organizationId, Guid actingUserId, Guid cipherId, Guid targetSystemId) + { + var sutProvider = Setup(); + + await Assert.ThrowsAsync(() => sutProvider.Sut.CreateAsync( + organizationId, actingUserId, cipherId, targetSystemId, " ", false, null, false)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().CreateAsync(default!); + } + + [Theory, BitAutoData] + public async Task CreateAsync_TargetMissing_ThrowsNotFound( + Guid organizationId, Guid actingUserId, Guid cipherId, Guid targetSystemId, string accountIdentity) + { + var sutProvider = Setup(); + sutProvider.GetDependency().GetByIdAsync(targetSystemId).Returns((PamTargetSystem?)null); + + await Assert.ThrowsAsync(() => sutProvider.Sut.CreateAsync( + organizationId, actingUserId, cipherId, targetSystemId, accountIdentity, false, null, false)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().CreateAsync(default!); + } + + [Theory, BitAutoData] + public async Task CreateAsync_TargetWrongOrg_ThrowsNotFound( + Guid actingUserId, Guid cipherId, PamTargetSystem target, string accountIdentity) + { + var sutProvider = Setup(); + sutProvider.GetDependency().GetByIdAsync(target.Id).Returns(target); + + // target.OrganizationId is an unrelated AutoFixture Guid. + await Assert.ThrowsAsync(() => sutProvider.Sut.CreateAsync( + Guid.NewGuid(), actingUserId, cipherId, target.Id, accountIdentity, false, null, false)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().CreateAsync(default!); + } + + [Theory, BitAutoData] + public async Task CreateAsync_TargetDisabled_ThrowsBadRequest( + Guid actingUserId, Guid cipherId, PamTargetSystem target, string accountIdentity) + { + var sutProvider = Setup(); + target.Status = PamTargetSystemStatus.Disabled; + sutProvider.GetDependency().GetByIdAsync(target.Id).Returns(target); + + await Assert.ThrowsAsync(() => sutProvider.Sut.CreateAsync( + target.OrganizationId, actingUserId, cipherId, target.Id, accountIdentity, false, null, false)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().CreateAsync(default!); + } + + [Theory, BitAutoData] + public async Task CreateAsync_CipherMissing_ThrowsNotFound( + Guid actingUserId, PamTargetSystem target, Guid cipherId, string accountIdentity) + { + var sutProvider = Setup(); + target.Status = PamTargetSystemStatus.Active; + sutProvider.GetDependency().GetByIdAsync(target.Id).Returns(target); + sutProvider.GetDependency().GetByIdAsync(cipherId).Returns((Cipher?)null); + + await Assert.ThrowsAsync(() => sutProvider.Sut.CreateAsync( + target.OrganizationId, actingUserId, cipherId, target.Id, accountIdentity, false, null, false)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().CreateAsync(default!); + } + + [Theory, BitAutoData] + public async Task CreateAsync_CipherWrongOrg_ThrowsNotFound( + Guid actingUserId, PamTargetSystem target, Cipher cipher, string accountIdentity) + { + var sutProvider = Setup(); + target.Status = PamTargetSystemStatus.Active; + sutProvider.GetDependency().GetByIdAsync(target.Id).Returns(target); + // cipher.OrganizationId is an unrelated AutoFixture Guid, distinct from target.OrganizationId. + sutProvider.GetDependency().GetByIdAsync(cipher.Id).Returns(cipher); + + await Assert.ThrowsAsync(() => sutProvider.Sut.CreateAsync( + target.OrganizationId, actingUserId, cipher.Id, target.Id, accountIdentity, false, null, false)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().CreateAsync(default!); + } + + [Theory, BitAutoData] + public async Task CreateAsync_DuplicateCipherConfig_ThrowsBadRequest( + Guid actingUserId, PamTargetSystem target, Cipher cipher, PamRotationConfig existing, string accountIdentity) + { + var sutProvider = Setup(); + target.Status = PamTargetSystemStatus.Active; + cipher.OrganizationId = target.OrganizationId; + sutProvider.GetDependency().GetByIdAsync(target.Id).Returns(target); + sutProvider.GetDependency().GetByIdAsync(cipher.Id).Returns(cipher); + sutProvider.GetDependency().GetByCipherIdAsync(cipher.Id).Returns(existing); + + await Assert.ThrowsAsync(() => sutProvider.Sut.CreateAsync( + target.OrganizationId, actingUserId, cipher.Id, target.Id, accountIdentity, false, null, false)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().CreateAsync(default!); + } + + [Theory, BitAutoData] + public async Task CreateAsync_TerminateSessionsOnManualTarget_ThrowsBadRequest( + Guid actingUserId, PamTargetSystem target, Cipher cipher, string accountIdentity) + { + var sutProvider = Setup(); + target.Status = PamTargetSystemStatus.Active; + target.Method = PamTargetSystemMethod.Manual; + cipher.OrganizationId = target.OrganizationId; + SetupOfferableTarget(sutProvider, target, cipher, null); + + await Assert.ThrowsAsync(() => sutProvider.Sut.CreateAsync( + target.OrganizationId, actingUserId, cipher.Id, target.Id, accountIdentity, true, null, false)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().CreateAsync(default!); + } + + [Theory, BitAutoData] + public async Task CreateAsync_TerminateSessionsOnUnsupportingAutomaticTarget_ThrowsBadRequest( + Guid actingUserId, PamTargetSystem target, Cipher cipher, string accountIdentity) + { + var sutProvider = Setup(); + target.Status = PamTargetSystemStatus.Active; + target.Method = PamTargetSystemMethod.Automatic; + target.SupportsSessionTermination = false; + cipher.OrganizationId = target.OrganizationId; + SetupOfferableTarget(sutProvider, target, cipher, null); + + await Assert.ThrowsAsync(() => sutProvider.Sut.CreateAsync( + target.OrganizationId, actingUserId, cipher.Id, target.Id, accountIdentity, true, null, false)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().CreateAsync(default!); + } + + [Theory, BitAutoData] + public async Task CreateAsync_CronFloorViolation_ThrowsBadRequest( + Guid actingUserId, PamTargetSystem target, Cipher cipher, string accountIdentity, string scheduleCron) + { + var sutProvider = Setup(); + target.Status = PamTargetSystemStatus.Active; + cipher.OrganizationId = target.OrganizationId; + SetupOfferableTarget(sutProvider, target, cipher, null); + sutProvider.GetDependency() + .When(c => c.ValidateSchedule(scheduleCron, Arg.Any())) + .Do(_ => throw new BadRequestException("The schedule must run no more often than every 15 minutes.")); + + await Assert.ThrowsAsync(() => sutProvider.Sut.CreateAsync( + target.OrganizationId, actingUserId, cipher.Id, target.Id, accountIdentity, false, scheduleCron, false)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().CreateAsync(default!); + } + + [Theory, BitAutoData] + public async Task CreateAsync_HappyPath_SetsNextRotationAtFromCalculatorAndEnables( + Guid actingUserId, PamTargetSystem target, Cipher cipher, string accountIdentity, string scheduleCron) + { + var sutProvider = Setup(); + target.Status = PamTargetSystemStatus.Active; + target.Method = PamTargetSystemMethod.Automatic; + target.SupportsSessionTermination = true; + cipher.OrganizationId = target.OrganizationId; + SetupOfferableTarget(sutProvider, target, cipher, null); + var nextOccurrence = _now.AddDays(1); + sutProvider.GetDependency().GetNextOccurrence(scheduleCron, _now) + .Returns(nextOccurrence); + sutProvider.GetDependency().CreateAsync(Arg.Any()) + .Returns(call => Task.FromResult(call.Arg())); + + var result = await sutProvider.Sut.CreateAsync( + target.OrganizationId, actingUserId, cipher.Id, target.Id, accountIdentity, true, scheduleCron, true); + + Assert.Equal(nextOccurrence, result.NextRotationAt); + Assert.True(result.Enabled); + Assert.Equal(accountIdentity, result.AccountIdentity); + Assert.True(result.TerminateSessions); + Assert.True(result.RotateOnAccessEnd); + await sutProvider.GetDependency().Received(1).CreateAsync(Arg.Is(c => + c.OrganizationId == target.OrganizationId && c.CipherId == cipher.Id && c.TargetSystemId == target.Id + && c.NextRotationAt == nextOccurrence && c.Enabled)); + } + + [Theory, BitAutoData] + public async Task CreateAsync_HappyPath_EmitsAttemptThenOutcome( + Guid actingUserId, PamTargetSystem target, Cipher cipher, string accountIdentity) + { + var sutProvider = Setup(); + target.Status = PamTargetSystemStatus.Active; + cipher.OrganizationId = target.OrganizationId; + SetupOfferableTarget(sutProvider, target, cipher, null); + var createdId = Guid.NewGuid(); + sutProvider.GetDependency().CreateAsync(Arg.Any()) + .Returns(call => + { + var config = call.Arg(); + config.Id = createdId; + return Task.FromResult(config); + }); + + await sutProvider.Sut.CreateAsync( + target.OrganizationId, actingUserId, cipher.Id, target.Id, accountIdentity, false, null, false); + + var emitter = sutProvider.GetDependency(); + await emitter.Received(1).EmitAsync(Arg.Is(e => + e.Kind == AccessAuditEventKind.RotationConfigCreated && e.Phase == AccessAuditEventPhase.Attempt + && e.CipherId == cipher.Id && e.TargetSystemId == target.Id)); + await emitter.Received(1).EmitAsync(Arg.Is(e => + e.Kind == AccessAuditEventKind.RotationConfigCreated && e.Phase == AccessAuditEventPhase.Outcome + && e.RotationConfigId == createdId)); + } + + private static void SetupOfferableTarget( + SutProvider sutProvider, PamTargetSystem target, Cipher cipher, PamRotationConfig? existing) + { + sutProvider.GetDependency().GetByIdAsync(target.Id).Returns(target); + sutProvider.GetDependency().GetByIdAsync(cipher.Id).Returns(cipher); + sutProvider.GetDependency().GetByCipherIdAsync(cipher.Id).Returns(existing); + } + + private static SutProvider Setup() + { + var sutProvider = new SutProvider().WithFakeTimeProvider().Create(); + sutProvider.GetDependency().SetUtcNow(_now); + sutProvider.GetDependency>().Value.Returns(new PamRotationOptions()); + return sutProvider; + } +} diff --git a/bitwarden_license/test/Services/Pam.Test/Rotation/Commands/DeleteRotationConfigCommandTests.cs b/bitwarden_license/test/Services/Pam.Test/Rotation/Commands/DeleteRotationConfigCommandTests.cs new file mode 100644 index 000000000000..83289c10353a --- /dev/null +++ b/bitwarden_license/test/Services/Pam.Test/Rotation/Commands/DeleteRotationConfigCommandTests.cs @@ -0,0 +1,99 @@ +using Bit.Core.Exceptions; +using Bit.Pam.Enums; +using Bit.Pam.Models; +using Bit.Pam.Repositories; +using Bit.Pam.Services; +using Bit.Services.Pam.Rotation.Commands; +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.Rotation.Commands; + +[SutProviderCustomize] +public class DeleteRotationConfigCommandTests +{ + private static readonly DateTime _now = new(2026, 7, 6, 12, 0, 0, DateTimeKind.Utc); + + [Theory, BitAutoData] + public async Task DeleteAsync_ConfigMissing_ThrowsNotFound(Guid organizationId, Guid actingUserId, Guid configId) + { + var sutProvider = Setup(); + sutProvider.GetDependency().GetDetailsByIdAsync(configId) + .Returns((PamRotationConfigDetails?)null); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.DeleteAsync(organizationId, actingUserId, configId)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .DeleteWithJobsAsync(default); + } + + [Theory, BitAutoData] + public async Task DeleteAsync_WrongOrg_ThrowsNotFound(Guid actingUserId, PamRotationConfigDetails details) + { + var sutProvider = Setup(); + sutProvider.GetDependency().GetDetailsByIdAsync(details.Id).Returns(details); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.DeleteAsync(Guid.NewGuid(), actingUserId, details.Id)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .DeleteWithJobsAsync(default); + } + + [Theory, BitAutoData] + public async Task DeleteAsync_ActiveJob_ThrowsBadRequest(Guid actingUserId, PamRotationConfigDetails details) + { + var sutProvider = Setup(); + details.HasActiveJob = true; + sutProvider.GetDependency().GetDetailsByIdAsync(details.Id).Returns(details); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.DeleteAsync(details.OrganizationId, actingUserId, details.Id)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .DeleteWithJobsAsync(default); + } + + [Theory, BitAutoData] + public async Task DeleteAsync_HappyPath_CallsDeleteWithJobsAsync(Guid actingUserId, PamRotationConfigDetails details) + { + var sutProvider = Setup(); + details.HasActiveJob = false; + sutProvider.GetDependency().GetDetailsByIdAsync(details.Id).Returns(details); + + await sutProvider.Sut.DeleteAsync(details.OrganizationId, actingUserId, details.Id); + + await sutProvider.GetDependency().Received(1).DeleteWithJobsAsync(details.Id); + } + + [Theory, BitAutoData] + public async Task DeleteAsync_HappyPath_EmitsAttemptThenOutcomeWithPreCapturedNames( + Guid actingUserId, PamRotationConfigDetails details) + { + var sutProvider = Setup(); + details.HasActiveJob = false; + sutProvider.GetDependency().GetDetailsByIdAsync(details.Id).Returns(details); + + await sutProvider.Sut.DeleteAsync(details.OrganizationId, actingUserId, details.Id); + + var emitter = sutProvider.GetDependency(); + await emitter.Received(1).EmitAsync(Arg.Is(e => + e.Kind == AccessAuditEventKind.RotationConfigDeleted && e.Phase == AccessAuditEventPhase.Attempt + && e.RotationConfigId == details.Id && e.TargetSystemId == details.TargetSystemId + && e.TargetSystemName == details.TargetSystemName && e.CipherId == details.CipherId)); + await emitter.Received(1).EmitAsync(Arg.Is(e => + e.Kind == AccessAuditEventKind.RotationConfigDeleted && e.Phase == AccessAuditEventPhase.Outcome + && e.RotationConfigId == details.Id && e.TargetSystemName == details.TargetSystemName)); + } + + 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/Rotation/Commands/GetRotationCipherQueryTests.cs b/bitwarden_license/test/Services/Pam.Test/Rotation/Commands/GetRotationCipherQueryTests.cs new file mode 100644 index 000000000000..846b2720da73 --- /dev/null +++ b/bitwarden_license/test/Services/Pam.Test/Rotation/Commands/GetRotationCipherQueryTests.cs @@ -0,0 +1,93 @@ +using Bit.Core.Exceptions; +using Bit.Core.Vault.Entities; +using Bit.Core.Vault.Repositories; +using Bit.Pam.Entities; +using Bit.Pam.Enums; +using Bit.Pam.Repositories; +using Bit.Services.Pam.Rotation.Queries; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Services.Pam.Test.Rotation.Commands; + +[SutProviderCustomize] +public class GetRotationCipherQueryTests +{ + [Theory, BitAutoData] + public async Task GetAsync_WrongDaemon_ThrowsNotFound( + Guid daemonId, PamRotationAttempt attempt) + { + var sutProvider = new SutProvider().Create(); + attempt.Status = PamRotationAttemptStatus.Executing; + // attempt.ClaimedByDaemonId is a different AutoFixture guid than daemonId. + sutProvider.GetDependency().GetAttemptByIdAsync(attempt.Id).Returns(attempt); + + await Assert.ThrowsAsync(() => sutProvider.Sut.GetAsync(daemonId, attempt.Id)); + } + + [Theory, BitAutoData] + public async Task GetAsync_AttemptNotExecuting_ThrowsNotFound(Guid daemonId, PamRotationAttempt attempt) + { + var sutProvider = new SutProvider().Create(); + attempt.ClaimedByDaemonId = daemonId; + attempt.Status = PamRotationAttemptStatus.Errored; + sutProvider.GetDependency().GetAttemptByIdAsync(attempt.Id).Returns(attempt); + + await Assert.ThrowsAsync(() => sutProvider.Sut.GetAsync(daemonId, attempt.Id)); + } + + [Theory, BitAutoData] + public async Task GetAsync_JobNotClaimed_ThrowsNotFound( + Guid daemonId, PamRotationAttempt attempt, PamRotationJob job) + { + var sutProvider = new SutProvider().Create(); + attempt.ClaimedByDaemonId = daemonId; + attempt.Status = PamRotationAttemptStatus.Executing; + attempt.JobId = job.Id; + job.Status = PamRotationJobStatus.Pending; + sutProvider.GetDependency().GetAttemptByIdAsync(attempt.Id).Returns(attempt); + sutProvider.GetDependency().GetByIdAsync(job.Id).Returns(job); + + await Assert.ThrowsAsync(() => sutProvider.Sut.GetAsync(daemonId, attempt.Id)); + } + + [Theory, BitAutoData] + public async Task GetAsync_JobClaimedByDifferentDaemon_ThrowsNotFound( + Guid daemonId, PamRotationAttempt attempt, PamRotationJob job) + { + var sutProvider = new SutProvider().Create(); + attempt.ClaimedByDaemonId = daemonId; + attempt.Status = PamRotationAttemptStatus.Executing; + attempt.JobId = job.Id; + job.Status = PamRotationJobStatus.Claimed; + // job.ClaimedByDaemonId is a different AutoFixture guid than daemonId. + sutProvider.GetDependency().GetAttemptByIdAsync(attempt.Id).Returns(attempt); + sutProvider.GetDependency().GetByIdAsync(job.Id).Returns(job); + + await Assert.ThrowsAsync(() => sutProvider.Sut.GetAsync(daemonId, attempt.Id)); + } + + [Theory, BitAutoData] + public async Task GetAsync_HappyPath_ReturnsCipherOfConfigsCipherId( + Guid daemonId, PamRotationAttempt attempt, PamRotationJob job, PamRotationConfig config, Cipher cipher) + { + var sutProvider = new SutProvider().Create(); + attempt.ClaimedByDaemonId = daemonId; + attempt.Status = PamRotationAttemptStatus.Executing; + attempt.JobId = job.Id; + job.Status = PamRotationJobStatus.Claimed; + job.ClaimedByDaemonId = daemonId; + job.RotationConfigId = config.Id; + cipher.Id = config.CipherId; + sutProvider.GetDependency().GetAttemptByIdAsync(attempt.Id).Returns(attempt); + sutProvider.GetDependency().GetByIdAsync(job.Id).Returns(job); + sutProvider.GetDependency().GetByIdAsync(config.Id).Returns(config); + sutProvider.GetDependency().GetByIdAsync(config.CipherId).Returns(cipher); + + var result = await sutProvider.Sut.GetAsync(daemonId, attempt.Id); + + Assert.Same(cipher, result); + } +} diff --git a/bitwarden_license/test/Services/Pam.Test/Rotation/Commands/HandleAccessGrantEndedCommandTests.cs b/bitwarden_license/test/Services/Pam.Test/Rotation/Commands/HandleAccessGrantEndedCommandTests.cs new file mode 100644 index 000000000000..6649ff812401 --- /dev/null +++ b/bitwarden_license/test/Services/Pam.Test/Rotation/Commands/HandleAccessGrantEndedCommandTests.cs @@ -0,0 +1,148 @@ +using Bit.Core; +using Bit.Core.Services; +using Bit.Pam.Entities; +using Bit.Pam.Enums; +using Bit.Pam.Models; +using Bit.Pam.Repositories; +using Bit.Pam.Services; +using Bit.Services.Pam.Rotation.Commands; +using Bit.Services.Pam.Rotation.Commands.Interfaces; +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.Rotation.Commands; + +[SutProviderCustomize] +public class HandleAccessGrantEndedCommandTests +{ + private static readonly DateTime _now = new(2026, 6, 5, 12, 0, 0, DateTimeKind.Utc); + + [Theory, BitAutoData] + public async Task HandleAsync_FlagOff_NoOp(Guid cipherId) + { + var sutProvider = Setup(); + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.PamRotation).Returns(false); + + await sutProvider.Sut.HandleAsync(cipherId); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .GetByCipherIdAsync(default); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .OfferAsync(default, default); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .EmitAsync(default!); + } + + [Theory, BitAutoData] + public async Task HandleAsync_NoConfig_NoOp(Guid cipherId) + { + var sutProvider = Setup(); + sutProvider.GetDependency().GetByCipherIdAsync(cipherId) + .Returns((PamRotationConfig?)null); + + await sutProvider.Sut.HandleAsync(cipherId); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .OfferAsync(default, default); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .EmitAsync(default!); + } + + [Theory, BitAutoData] + public async Task HandleAsync_NotOptedIn_NoOp(PamRotationConfig config) + { + var sutProvider = Setup(); + config.RotateOnAccessEnd = false; + sutProvider.GetDependency().GetByCipherIdAsync(config.CipherId) + .Returns(config); + + await sutProvider.Sut.HandleAsync(config.CipherId); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .OfferAsync(default, default); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .EmitAsync(default!); + } + + [Theory, BitAutoData] + public async Task HandleAsync_Paused_NoOp(PamRotationConfig config) + { + var sutProvider = Setup(); + config.RotateOnAccessEnd = true; + config.Enabled = false; + sutProvider.GetDependency().GetByCipherIdAsync(config.CipherId) + .Returns(config); + + await sutProvider.Sut.HandleAsync(config.CipherId); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .OfferAsync(default, default); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .ReplaceAsync(default!); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .EmitAsync(default!); + } + + [Theory, BitAutoData] + public async Task HandleAsync_AutomaticOptedIn_OffersWithAccessEndSource( + PamRotationConfig config, PamTargetSystem target) + { + var sutProvider = Setup(); + config.RotateOnAccessEnd = true; + config.Enabled = true; + target.Id = config.TargetSystemId; + target.Method = PamTargetSystemMethod.Automatic; + sutProvider.GetDependency().GetByCipherIdAsync(config.CipherId) + .Returns(config); + sutProvider.GetDependency().GetByIdAsync(config.TargetSystemId).Returns(target); + + await sutProvider.Sut.HandleAsync(config.CipherId); + + await sutProvider.GetDependency().Received(1) + .OfferAsync(config.Id, PamRotationSource.AccessEnd); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .ReplaceAsync(default!); + // OfferRotationCommand owns its own audit; the automatic branch here emits nothing directly. + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .EmitAsync(default!); + } + + [Theory, BitAutoData] + public async Task HandleAsync_ManualOptedInEnabled_SetsNextRotationAtNowAndEmitsManualRotationDueAudit( + PamRotationConfig config, PamTargetSystem target) + { + var sutProvider = Setup(); + config.RotateOnAccessEnd = true; + config.Enabled = true; + target.Id = config.TargetSystemId; + target.Method = PamTargetSystemMethod.Manual; + sutProvider.GetDependency().GetByCipherIdAsync(config.CipherId) + .Returns(config); + sutProvider.GetDependency().GetByIdAsync(config.TargetSystemId).Returns(target); + + await sutProvider.Sut.HandleAsync(config.CipherId); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .OfferAsync(default, default); + await sutProvider.GetDependency().Received(1).ReplaceAsync( + Arg.Is(c => c.Id == config.Id && c.NextRotationAt == _now)); + await sutProvider.GetDependency().Received(1).EmitAsync( + Arg.Is(a => a.Kind == AccessAuditEventKind.ManualRotationDue + && a.OrganizationId == config.OrganizationId + && a.ActorId == null + && a.CipherId == config.CipherId + && a.RotationConfigId == config.Id + && a.RotationSource == PamRotationSource.AccessEnd)); + } + + private static SutProvider Setup() + { + var sutProvider = new SutProvider().WithFakeTimeProvider().Create(); + sutProvider.GetDependency().SetUtcNow(_now); + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.PamRotation).Returns(true); + return sutProvider; + } +} diff --git a/bitwarden_license/test/Services/Pam.Test/Rotation/Commands/OfferRotationCommandTests.cs b/bitwarden_license/test/Services/Pam.Test/Rotation/Commands/OfferRotationCommandTests.cs new file mode 100644 index 000000000000..6aeaf9fce06d --- /dev/null +++ b/bitwarden_license/test/Services/Pam.Test/Rotation/Commands/OfferRotationCommandTests.cs @@ -0,0 +1,189 @@ +using Bit.Pam.Entities; +using Bit.Pam.Enums; +using Bit.Pam.Models; +using Bit.Pam.Repositories; +using Bit.Pam.Services; +using Bit.Services.Pam.Rotation; +using Bit.Services.Pam.Rotation.Commands; +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.Rotation.Commands; + +[SutProviderCustomize] +public class OfferRotationCommandTests +{ + private static readonly DateTime _now = new(2026, 6, 5, 12, 0, 0, DateTimeKind.Utc); + + [Theory, BitAutoData] + public async Task OfferAsync_ConfigMissing_ReturnsConfigNotOfferable_NoCreateGuardedCallNoAudit( + Guid configId, PamRotationSource source) + { + var sutProvider = Setup(); + sutProvider.GetDependency().GetByIdAsync(configId) + .Returns((PamRotationConfig?)null); + + var outcome = await sutProvider.Sut.OfferAsync(configId, source); + + Assert.Equal(PamRotationJobCreateOutcome.ConfigNotOfferable, outcome); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .CreateGuardedAsync(default!); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .EmitAsync(default!); + } + + [Theory, BitAutoData] + public async Task OfferAsync_TargetMissing_ReturnsConfigNotOfferable_NoCreateGuardedCallNoAudit( + PamRotationConfig config, PamRotationSource source) + { + var sutProvider = Setup(); + SetupConfig(sutProvider, config); + sutProvider.GetDependency().GetByIdAsync(config.TargetSystemId) + .Returns((PamTargetSystem?)null); + + var outcome = await sutProvider.Sut.OfferAsync(config.Id, source); + + Assert.Equal(PamRotationJobCreateOutcome.ConfigNotOfferable, outcome); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .CreateGuardedAsync(default!); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .EmitAsync(default!); + } + + [Theory, BitAutoData] + public async Task OfferAsync_ConfigDisabled_ReturnsConfigNotOfferable_NoCreateGuardedCallNoAudit( + PamRotationConfig config, PamTargetSystem target, PamRotationSource source) + { + var sutProvider = Setup(); + config.Enabled = false; + target.Method = PamTargetSystemMethod.Automatic; + target.Status = PamTargetSystemStatus.Active; + SetupConfig(sutProvider, config); + SetupTarget(sutProvider, config, target); + + var outcome = await sutProvider.Sut.OfferAsync(config.Id, source); + + Assert.Equal(PamRotationJobCreateOutcome.ConfigNotOfferable, outcome); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .CreateGuardedAsync(default!); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .EmitAsync(default!); + } + + [Theory, BitAutoData] + public async Task OfferAsync_TargetManual_ReturnsConfigNotOfferable_NoCreateGuardedCallNoAudit( + PamRotationConfig config, PamTargetSystem target, PamRotationSource source) + { + var sutProvider = Setup(); + config.Enabled = true; + target.Method = PamTargetSystemMethod.Manual; + target.Status = PamTargetSystemStatus.Active; + SetupConfig(sutProvider, config); + SetupTarget(sutProvider, config, target); + + var outcome = await sutProvider.Sut.OfferAsync(config.Id, source); + + Assert.Equal(PamRotationJobCreateOutcome.ConfigNotOfferable, outcome); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .CreateGuardedAsync(default!); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .EmitAsync(default!); + } + + [Theory, BitAutoData] + public async Task OfferAsync_TargetInactive_ReturnsConfigNotOfferable_NoCreateGuardedCallNoAudit( + PamRotationConfig config, PamTargetSystem target, PamRotationSource source) + { + var sutProvider = Setup(); + config.Enabled = true; + target.Method = PamTargetSystemMethod.Automatic; + target.Status = PamTargetSystemStatus.Disabled; + SetupConfig(sutProvider, config); + SetupTarget(sutProvider, config, target); + + var outcome = await sutProvider.Sut.OfferAsync(config.Id, source); + + Assert.Equal(PamRotationJobCreateOutcome.ConfigNotOfferable, outcome); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .CreateGuardedAsync(default!); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .EmitAsync(default!); + } + + [Theory, BitAutoData] + public async Task OfferAsync_Created_EmitsRotationOfferedAuditWithSource( + PamRotationConfig config, PamTargetSystem target, PamRotationSource source) + { + var sutProvider = Setup(); + config.Enabled = true; + target.Method = PamTargetSystemMethod.Automatic; + target.Status = PamTargetSystemStatus.Active; + SetupConfig(sutProvider, config); + SetupTarget(sutProvider, config, target); + sutProvider.GetDependency() + .CreateGuardedAsync(Arg.Any()) + .Returns(PamRotationJobCreateOutcome.Created); + + var outcome = await sutProvider.Sut.OfferAsync(config.Id, source); + + Assert.Equal(PamRotationJobCreateOutcome.Created, outcome); + await sutProvider.GetDependency().Received(1).CreateGuardedAsync( + Arg.Is(j => j.RotationConfigId == config.Id + && j.Source == source + && j.Status == PamRotationJobStatus.Pending + && j.ClaimedByDaemonId == null + && j.ClaimedAt == null)); + await sutProvider.GetDependency().Received(1).EmitAsync( + Arg.Is(a => a.Kind == AccessAuditEventKind.RotationOffered + && a.OrganizationId == config.OrganizationId + && a.ActorId == null + && a.CipherId == config.CipherId + && a.RotationConfigId == config.Id + && a.TargetSystemId == target.Id + && a.TargetSystemName == target.Name + && a.RotationSource == source)); + } + + [Theory, BitAutoData] + public async Task OfferAsync_ActiveJobExists_ReturnsSilentlyNoAudit( + PamRotationConfig config, PamTargetSystem target, PamRotationSource source) + { + var sutProvider = Setup(); + config.Enabled = true; + target.Method = PamTargetSystemMethod.Automatic; + target.Status = PamTargetSystemStatus.Active; + SetupConfig(sutProvider, config); + SetupTarget(sutProvider, config, target); + sutProvider.GetDependency() + .CreateGuardedAsync(Arg.Any()) + .Returns(PamRotationJobCreateOutcome.ActiveJobExists); + + var outcome = await sutProvider.Sut.OfferAsync(config.Id, source); + + Assert.Equal(PamRotationJobCreateOutcome.ActiveJobExists, outcome); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .EmitAsync(default!); + } + + private static SutProvider Setup() + { + var sutProvider = new SutProvider().WithFakeTimeProvider().Create(); + sutProvider.GetDependency().SetUtcNow(_now); + sutProvider.GetDependency>().Value.Returns(new PamRotationOptions()); + return sutProvider; + } + + private static void SetupConfig(SutProvider sutProvider, PamRotationConfig config) => + sutProvider.GetDependency().GetByIdAsync(config.Id).Returns(config); + + private static void SetupTarget(SutProvider sutProvider, PamRotationConfig config, + PamTargetSystem target) + { + target.Id = config.TargetSystemId; + sutProvider.GetDependency().GetByIdAsync(config.TargetSystemId).Returns(target); + } +} diff --git a/bitwarden_license/test/Services/Pam.Test/Rotation/Commands/PauseRotationCommandTests.cs b/bitwarden_license/test/Services/Pam.Test/Rotation/Commands/PauseRotationCommandTests.cs new file mode 100644 index 000000000000..e74dcdbd0e8b --- /dev/null +++ b/bitwarden_license/test/Services/Pam.Test/Rotation/Commands/PauseRotationCommandTests.cs @@ -0,0 +1,95 @@ +using Bit.Core.Exceptions; +using Bit.Pam.Entities; +using Bit.Pam.Enums; +using Bit.Pam.Models; +using Bit.Pam.Repositories; +using Bit.Pam.Services; +using Bit.Services.Pam.Rotation.Commands; +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.Rotation.Commands; + +[SutProviderCustomize] +public class PauseRotationCommandTests +{ + private static readonly DateTime _now = new(2026, 7, 6, 12, 0, 0, DateTimeKind.Utc); + + [Theory, BitAutoData] + public async Task PauseAsync_ConfigMissing_ThrowsNotFound(Guid organizationId, Guid actingUserId, Guid configId) + { + var sutProvider = Setup(); + sutProvider.GetDependency().GetByIdAsync(configId).Returns((PamRotationConfig?)null); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.PauseAsync(organizationId, actingUserId, configId)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().ReplaceAsync(default!); + } + + [Theory, BitAutoData] + public async Task PauseAsync_WrongOrg_ThrowsNotFound(Guid actingUserId, PamRotationConfig config) + { + var sutProvider = Setup(); + sutProvider.GetDependency().GetByIdAsync(config.Id).Returns(config); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.PauseAsync(Guid.NewGuid(), actingUserId, config.Id)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().ReplaceAsync(default!); + } + + [Theory, BitAutoData] + public async Task PauseAsync_AlreadyPaused_ThrowsBadRequest(Guid actingUserId, PamRotationConfig config) + { + var sutProvider = Setup(); + config.Enabled = false; + sutProvider.GetDependency().GetByIdAsync(config.Id).Returns(config); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.PauseAsync(config.OrganizationId, actingUserId, config.Id)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().ReplaceAsync(default!); + } + + [Theory, BitAutoData] + public async Task PauseAsync_Enabled_DisablesConfig(Guid actingUserId, PamRotationConfig config) + { + var sutProvider = Setup(); + config.Enabled = true; + sutProvider.GetDependency().GetByIdAsync(config.Id).Returns(config); + + await sutProvider.Sut.PauseAsync(config.OrganizationId, actingUserId, config.Id); + + await sutProvider.GetDependency().Received(1).ReplaceAsync(Arg.Is(c => + c.Id == config.Id && !c.Enabled && c.RevisionDate == _now)); + } + + [Theory, BitAutoData] + public async Task PauseAsync_Enabled_EmitsAttemptThenOutcome(Guid actingUserId, PamRotationConfig config) + { + var sutProvider = Setup(); + config.Enabled = true; + sutProvider.GetDependency().GetByIdAsync(config.Id).Returns(config); + + await sutProvider.Sut.PauseAsync(config.OrganizationId, actingUserId, config.Id); + + var emitter = sutProvider.GetDependency(); + await emitter.Received(1).EmitAsync(Arg.Is(e => + e.Kind == AccessAuditEventKind.RotationPaused && e.Phase == AccessAuditEventPhase.Attempt + && e.RotationConfigId == config.Id)); + await emitter.Received(1).EmitAsync(Arg.Is(e => + e.Kind == AccessAuditEventKind.RotationPaused && e.Phase == AccessAuditEventPhase.Outcome + && e.RotationConfigId == config.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/Rotation/Commands/RecordManualRotationCommandTests.cs b/bitwarden_license/test/Services/Pam.Test/Rotation/Commands/RecordManualRotationCommandTests.cs new file mode 100644 index 000000000000..31bb90640a6b --- /dev/null +++ b/bitwarden_license/test/Services/Pam.Test/Rotation/Commands/RecordManualRotationCommandTests.cs @@ -0,0 +1,122 @@ +using Bit.Core.Exceptions; +using Bit.Pam.Entities; +using Bit.Pam.Enums; +using Bit.Pam.Models; +using Bit.Pam.Repositories; +using Bit.Pam.Services; +using Bit.Services.Pam.Rotation; +using Bit.Services.Pam.Rotation.Commands; +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.Rotation.Commands; + +[SutProviderCustomize] +public class RecordManualRotationCommandTests +{ + private static readonly DateTime _now = new(2026, 7, 6, 12, 0, 0, DateTimeKind.Utc); + + [Theory, BitAutoData] + public async Task RecordAsync_ConfigMissing_ThrowsNotFound(Guid organizationId, Guid actingUserId, Guid configId) + { + var sutProvider = Setup(); + sutProvider.GetDependency().GetDetailsByIdAsync(configId) + .Returns((PamRotationConfigDetails?)null); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.RecordAsync(organizationId, actingUserId, configId)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().ReplaceAsync(default!); + } + + [Theory, BitAutoData] + public async Task RecordAsync_WrongOrg_ThrowsNotFound(Guid actingUserId, PamRotationConfigDetails details) + { + var sutProvider = Setup(); + details.TargetSystemMethod = PamTargetSystemMethod.Manual; + sutProvider.GetDependency().GetDetailsByIdAsync(details.Id).Returns(details); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.RecordAsync(Guid.NewGuid(), actingUserId, details.Id)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().ReplaceAsync(default!); + } + + [Theory, BitAutoData] + public async Task RecordAsync_AutomaticTarget_ThrowsBadRequest(Guid actingUserId, PamRotationConfigDetails details) + { + var sutProvider = Setup(); + details.TargetSystemMethod = PamTargetSystemMethod.Automatic; + sutProvider.GetDependency().GetDetailsByIdAsync(details.Id).Returns(details); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.RecordAsync(details.OrganizationId, actingUserId, details.Id)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().ReplaceAsync(default!); + } + + [Theory, BitAutoData] + public async Task RecordAsync_HappyPath_SetsLastRotationToNowAndNextFromSchedule( + Guid actingUserId, PamRotationConfigDetails details) + { + var sutProvider = Setup(); + details.TargetSystemMethod = PamTargetSystemMethod.Manual; + details.NextRotationAt = _now.AddMinutes(-10); // an overdue obligation, about to be discharged + sutProvider.GetDependency().GetDetailsByIdAsync(details.Id).Returns(details); + var nextOccurrence = _now.AddDays(30); + sutProvider.GetDependency().GetNextOccurrence(details.ScheduleCron, _now) + .Returns(nextOccurrence); + + await sutProvider.Sut.RecordAsync(details.OrganizationId, actingUserId, details.Id); + + // LastRotationAt = now; the obligation clears by moving NextRotationAt to the next scheduled occurrence. + await sutProvider.GetDependency().Received(1).ReplaceAsync(Arg.Is(c => + c.Id == details.Id && c.LastRotationAt == _now && c.NextRotationAt == nextOccurrence + && c.RevisionDate == _now)); + } + + [Theory, BitAutoData] + public async Task RecordAsync_NoSchedule_ClearsNextRotationAt(Guid actingUserId, PamRotationConfigDetails details) + { + var sutProvider = Setup(); + details.TargetSystemMethod = PamTargetSystemMethod.Manual; + details.ScheduleCron = null; + details.NextRotationAt = _now.AddMinutes(-10); + sutProvider.GetDependency().GetDetailsByIdAsync(details.Id).Returns(details); + sutProvider.GetDependency().GetNextOccurrence(null, _now).Returns((DateTime?)null); + + await sutProvider.Sut.RecordAsync(details.OrganizationId, actingUserId, details.Id); + + await sutProvider.GetDependency().Received(1).ReplaceAsync(Arg.Is(c => + c.Id == details.Id && c.LastRotationAt == _now && c.NextRotationAt == null)); + } + + [Theory, BitAutoData] + public async Task RecordAsync_HappyPath_EmitsAttemptThenOutcomeWithActor( + Guid actingUserId, PamRotationConfigDetails details) + { + var sutProvider = Setup(); + details.TargetSystemMethod = PamTargetSystemMethod.Manual; + sutProvider.GetDependency().GetDetailsByIdAsync(details.Id).Returns(details); + + await sutProvider.Sut.RecordAsync(details.OrganizationId, actingUserId, details.Id); + + var emitter = sutProvider.GetDependency(); + await emitter.Received(1).EmitAsync(Arg.Is(e => + e.Kind == AccessAuditEventKind.ManualRotationRecorded && e.Phase == AccessAuditEventPhase.Attempt + && e.RotationConfigId == details.Id && e.ActorId == actingUserId)); + await emitter.Received(1).EmitAsync(Arg.Is(e => + e.Kind == AccessAuditEventKind.ManualRotationRecorded && e.Phase == AccessAuditEventPhase.Outcome + && e.RotationConfigId == details.Id && e.ActorId == actingUserId)); + } + + 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/Rotation/Commands/RegisterDaemonCommandTests.cs b/bitwarden_license/test/Services/Pam.Test/Rotation/Commands/RegisterDaemonCommandTests.cs new file mode 100644 index 000000000000..bca43af4945e --- /dev/null +++ b/bitwarden_license/test/Services/Pam.Test/Rotation/Commands/RegisterDaemonCommandTests.cs @@ -0,0 +1,115 @@ +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.Pam.Services; +using Bit.Services.Pam.Rotation.Commands; +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.Rotation.Commands; + +[SutProviderCustomize] +public class RegisterDaemonCommandTests +{ + 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_CreatesApiKeyWithHashedSecretAndPamDaemon( + Guid organizationId, Guid actingUserId, string name, string encryptedPayload, string key) + { + var sutProvider = Setup(); + var apiKeyId = Guid.NewGuid(); + var daemonId = Guid.NewGuid(); + SetupCreates(sutProvider, apiKeyId, daemonId); + + var result = await sutProvider.Sut.RegisterAsync(organizationId, actingUserId, name, encryptedPayload, key); + + // The ApiKey row is the generic machine credential: ServiceAccountId stays null (this is not an SM key), + // the scope is the fixed rotation scope, and the stored value is a hash, never the plaintext secret. + 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 == PamDaemonStatus.Enrolled + && 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(daemonId, result.Daemon.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 daemonId = Guid.NewGuid(); + SetupCreates(sutProvider, apiKeyId, daemonId); + + await sutProvider.Sut.RegisterAsync(organizationId, actingUserId, name, encryptedPayload, key); + + var emitter = sutProvider.GetDependency(); + await emitter.Received(1).EmitAsync(Arg.Is(e => + e.Kind == AccessAuditEventKind.DaemonRegistered && e.Phase == AccessAuditEventPhase.Attempt + && e.OrganizationId == organizationId && e.ActorId == actingUserId && e.DaemonName == name)); + await emitter.Received(1).EmitAsync(Arg.Is(e => + e.Kind == AccessAuditEventKind.DaemonRegistered && e.Phase == AccessAuditEventPhase.Outcome + && e.DaemonId == daemonId)); + } + + 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 daemonId) + { + 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 daemon = call.Arg(); + daemon.Id = daemonId; + return Task.FromResult(daemon); + }); + } +} diff --git a/bitwarden_license/test/Services/Pam.Test/Rotation/Commands/RegisterTargetSystemCommandTests.cs b/bitwarden_license/test/Services/Pam.Test/Rotation/Commands/RegisterTargetSystemCommandTests.cs new file mode 100644 index 000000000000..fdcfef5ccf67 --- /dev/null +++ b/bitwarden_license/test/Services/Pam.Test/Rotation/Commands/RegisterTargetSystemCommandTests.cs @@ -0,0 +1,123 @@ +using Bit.Core.Exceptions; +using Bit.Pam.Entities; +using Bit.Pam.Enums; +using Bit.Pam.Models; +using Bit.Pam.Repositories; +using Bit.Pam.Services; +using Bit.Services.Pam.Rotation.Commands; +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.Rotation.Commands; + +[SutProviderCustomize] +public class RegisterTargetSystemCommandTests +{ + private static readonly DateTime _now = new(2026, 7, 6, 12, 0, 0, DateTimeKind.Utc); + + private static readonly PamPasswordPolicy _policy = new() { MinLength = 12, MaxLength = 64 }; + + [Theory, BitAutoData] + public async Task RegisterAsync_NameMissing_ThrowsBadRequest(Guid organizationId, Guid actingUserId) + { + var sutProvider = Setup(); + + await Assert.ThrowsAsync(() => sutProvider.Sut.RegisterAsync( + organizationId, actingUserId, " ", PamTargetSystemMethod.Manual, null, null, null)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().CreateAsync(default!); + } + + [Theory, BitAutoData] + public async Task RegisterAsync_AutomaticMissingKind_ThrowsBadRequest(Guid organizationId, Guid actingUserId, string name) + { + var sutProvider = Setup(); + + await Assert.ThrowsAsync(() => sutProvider.Sut.RegisterAsync( + organizationId, actingUserId, name, PamTargetSystemMethod.Automatic, null, _policy, true)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().CreateAsync(default!); + } + + [Theory, BitAutoData] + public async Task RegisterAsync_ManualWithKindSet_ThrowsBadRequest(Guid organizationId, Guid actingUserId, string name) + { + var sutProvider = Setup(); + + await Assert.ThrowsAsync(() => sutProvider.Sut.RegisterAsync( + organizationId, actingUserId, name, PamTargetSystemMethod.Manual, PamTargetSystemKind.Entra, null, null)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().CreateAsync(default!); + } + + [Theory, BitAutoData] + public async Task RegisterAsync_AutomaticHappyPath_CreatesTargetWithSerializedPolicy( + 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); + + Assert.Equal(PamTargetSystemMethod.Automatic, result.Method); + Assert.Equal(PamTargetSystemKind.Mssql, result.Kind); + Assert.True(result.SupportsSessionTermination); + Assert.Equal(PamTargetSystemStatus.Active, result.Status); + Assert.Equal(PamPasswordPolicy.Serialize(_policy), result.PasswordPolicy); + await sutProvider.GetDependency().Received(1).CreateAsync(Arg.Is(t => + t.OrganizationId == organizationId && t.Name == name && t.CreationDate == _now && t.RevisionDate == _now)); + } + + [Theory, BitAutoData] + public async Task RegisterAsync_ManualHappyPath_CreatesTargetWithNoPolicy( + 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.Manual, null, null, null); + + Assert.Equal(PamTargetSystemMethod.Manual, result.Method); + Assert.Null(result.Kind); + Assert.Null(result.SupportsSessionTermination); + Assert.Null(result.PasswordPolicy); + } + + [Theory, BitAutoData] + public async Task RegisterAsync_HappyPath_EmitsAttemptThenOutcome(Guid organizationId, Guid actingUserId, string name) + { + var sutProvider = Setup(); + var createdId = Guid.NewGuid(); + sutProvider.GetDependency().CreateAsync(Arg.Any()) + .Returns(call => + { + var target = call.Arg(); + target.Id = createdId; + return Task.FromResult(target); + }); + + await sutProvider.Sut.RegisterAsync(organizationId, actingUserId, name, PamTargetSystemMethod.Manual, null, null, null); + + var emitter = sutProvider.GetDependency(); + await emitter.Received(1).EmitAsync(Arg.Is(e => + e.Kind == AccessAuditEventKind.TargetSystemRegistered && e.Phase == AccessAuditEventPhase.Attempt + && e.TargetSystemName == name)); + await emitter.Received(1).EmitAsync(Arg.Is(e => + e.Kind == AccessAuditEventKind.TargetSystemRegistered && e.Phase == AccessAuditEventPhase.Outcome + && e.TargetSystemId == createdId)); + } + + 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/Rotation/Commands/RenameTargetSystemCommandTests.cs b/bitwarden_license/test/Services/Pam.Test/Rotation/Commands/RenameTargetSystemCommandTests.cs new file mode 100644 index 000000000000..a686a352dff5 --- /dev/null +++ b/bitwarden_license/test/Services/Pam.Test/Rotation/Commands/RenameTargetSystemCommandTests.cs @@ -0,0 +1,95 @@ +using Bit.Core.Exceptions; +using Bit.Pam.Entities; +using Bit.Pam.Enums; +using Bit.Pam.Models; +using Bit.Pam.Repositories; +using Bit.Pam.Services; +using Bit.Services.Pam.Rotation.Commands; +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.Rotation.Commands; + +[SutProviderCustomize] +public class RenameTargetSystemCommandTests +{ + private static readonly DateTime _now = new(2026, 7, 6, 12, 0, 0, DateTimeKind.Utc); + + [Theory, BitAutoData] + public async Task RenameAsync_NameMissing_ThrowsBadRequest(Guid organizationId, Guid actingUserId, Guid targetSystemId) + { + var sutProvider = Setup(); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.RenameAsync(organizationId, actingUserId, targetSystemId, " ")); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().ReplaceAsync(default!); + } + + [Theory, BitAutoData] + public async Task RenameAsync_TargetMissing_ThrowsNotFound(Guid organizationId, Guid actingUserId, Guid targetSystemId, string name) + { + var sutProvider = Setup(); + sutProvider.GetDependency().GetByIdAsync(targetSystemId).Returns((PamTargetSystem?)null); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.RenameAsync(organizationId, actingUserId, targetSystemId, name)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().ReplaceAsync(default!); + } + + [Theory, BitAutoData] + public async Task RenameAsync_WrongOrg_ThrowsNotFound(Guid actingUserId, PamTargetSystem target, string name) + { + var sutProvider = Setup(); + sutProvider.GetDependency().GetByIdAsync(target.Id).Returns(target); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.RenameAsync(Guid.NewGuid(), actingUserId, target.Id, name)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().ReplaceAsync(default!); + } + + [Theory, BitAutoData] + public async Task RenameAsync_HappyPath_UpdatesName(Guid actingUserId, PamTargetSystem target, string newName) + { + var sutProvider = Setup(); + var oldName = target.Name; + sutProvider.GetDependency().GetByIdAsync(target.Id).Returns(target); + + await sutProvider.Sut.RenameAsync(target.OrganizationId, actingUserId, target.Id, newName); + + await sutProvider.GetDependency().Received(1).ReplaceAsync(Arg.Is(t => + t.Id == target.Id && t.Name == newName && t.RevisionDate == _now)); + Assert.NotEqual(oldName, newName); + } + + [Theory, BitAutoData] + public async Task RenameAsync_HappyPath_EmitsAttemptThenOutcomeWithPriorNameInDetail( + Guid actingUserId, PamTargetSystem target, string newName) + { + var sutProvider = Setup(); + var oldName = target.Name; + sutProvider.GetDependency().GetByIdAsync(target.Id).Returns(target); + + await sutProvider.Sut.RenameAsync(target.OrganizationId, actingUserId, target.Id, newName); + + var emitter = sutProvider.GetDependency(); + await emitter.Received(1).EmitAsync(Arg.Is(e => + e.Kind == AccessAuditEventKind.TargetSystemRenamed && e.Phase == AccessAuditEventPhase.Attempt + && e.TargetSystemId == target.Id && e.TargetSystemName == newName && e.Detail!.Contains(oldName))); + await emitter.Received(1).EmitAsync(Arg.Is(e => + e.Kind == AccessAuditEventKind.TargetSystemRenamed && e.Phase == AccessAuditEventPhase.Outcome + && 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/Rotation/Commands/ReportRotationFailedCommandTests.cs b/bitwarden_license/test/Services/Pam.Test/Rotation/Commands/ReportRotationFailedCommandTests.cs new file mode 100644 index 000000000000..35d57ce07445 --- /dev/null +++ b/bitwarden_license/test/Services/Pam.Test/Rotation/Commands/ReportRotationFailedCommandTests.cs @@ -0,0 +1,164 @@ +using Bit.Core.Exceptions; +using Bit.Pam.Entities; +using Bit.Pam.Enums; +using Bit.Pam.Models; +using Bit.Pam.Repositories; +using Bit.Pam.Services; +using Bit.Services.Pam.Rotation; +using Bit.Services.Pam.Rotation.Commands; +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.Rotation.Commands; + +[SutProviderCustomize] +public class ReportRotationFailedCommandTests +{ + private static readonly DateTime _now = new(2026, 6, 5, 12, 0, 0, DateTimeKind.Utc); + private static readonly TimeSpan _failureRetryDelay = TimeSpan.FromHours(1); + + [Theory, BitAutoData] + public async Task ReportFailedAsync_UnknownAttempt_ThrowsNotFound_NoAudit( + Guid daemonId, Guid attemptId, string failureReason, PamRotationSyncState syncState) + { + var sutProvider = Setup(); + sutProvider.GetDependency().GetAttemptByIdAsync(attemptId) + .Returns((PamRotationAttempt?)null); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.ReportFailedAsync(daemonId, attemptId, failureReason, syncState)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .MarkAttemptErroredAsync(default, default, default, default, default, default, default); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().EmitAsync(default!); + } + + [Theory, BitAutoData] + public async Task ReportFailedAsync_ReasonExceeds500Chars_TruncatesBeforeRepositoryCall( + Guid daemonId, PamRotationAttempt attempt, PamRotationJob job, PamRotationConfig config, + PamDaemon daemon, PamRotationSyncState syncState) + { + var sutProvider = Setup(); + job.RotationConfigId = config.Id; + attempt.JobId = job.Id; + var longReason = new string('x', 600); + var expectedTruncated = longReason.Substring(0, 500); + SetupAttempt(sutProvider, attempt); + sutProvider.GetDependency() + .MarkAttemptErroredAsync(attempt.Id, daemonId, Arg.Any(), syncState, _now, Arg.Any(), Arg.Any()) + .Returns(new PamRotationFailureResult { Outcome = PamRotationAttemptResolveOutcome.Resolved, JobStatus = PamRotationJobStatus.Pending }); + sutProvider.GetDependency().GetByIdAsync(job.Id).Returns(job); + sutProvider.GetDependency().GetByIdAsync(config.Id).Returns(config); + sutProvider.GetDependency().GetByIdAsync(daemonId).Returns(daemon); + + await sutProvider.Sut.ReportFailedAsync(daemonId, attempt.Id, longReason, syncState); + + await sutProvider.GetDependency().Received(1).MarkAttemptErroredAsync( + attempt.Id, daemonId, Arg.Is(s => s.Length == 500 && s == expectedTruncated), syncState, _now, + Arg.Any(), Arg.Any()); + } + + [Theory, BitAutoData] + public async Task ReportFailedAsync_RetryBudgetRemains_EmitsAttemptFailedAuditAndConfigUntouched( + Guid daemonId, PamRotationAttempt attempt, PamRotationJob job, PamRotationConfig config, PamDaemon daemon, + string failureReason, PamRotationSyncState syncState) + { + var sutProvider = Setup(); + job.RotationConfigId = config.Id; + attempt.JobId = job.Id; + SetupAttempt(sutProvider, attempt); + sutProvider.GetDependency() + .MarkAttemptErroredAsync(attempt.Id, daemonId, Arg.Any(), syncState, _now, Arg.Any(), Arg.Any()) + .Returns(new PamRotationFailureResult { Outcome = PamRotationAttemptResolveOutcome.Resolved, JobStatus = PamRotationJobStatus.Pending }); + sutProvider.GetDependency().GetByIdAsync(job.Id).Returns(job); + sutProvider.GetDependency().GetByIdAsync(config.Id).Returns(config); + sutProvider.GetDependency().GetByIdAsync(daemonId).Returns(daemon); + + await sutProvider.Sut.ReportFailedAsync(daemonId, attempt.Id, failureReason, syncState); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .ReplaceAsync(default!); + await sutProvider.GetDependency().Received(1).EmitAsync( + Arg.Is(a => a.Kind == AccessAuditEventKind.RotationAttemptFailed + && a.OrganizationId == config.OrganizationId + && a.DaemonId == daemonId + && a.RotationJobId == job.Id + && a.RotationConfigId == config.Id + && a.SyncState == syncState + && a.Detail == failureReason)); + } + + [Theory, BitAutoData] + public async Task ReportFailedAsync_RetryBudgetExhausted_UpdatesConfigNextRotationAndEmitsFailedAudit( + Guid daemonId, PamRotationAttempt attempt, PamRotationJob job, PamRotationConfig config, PamDaemon daemon, + string failureReason, PamRotationSyncState syncState) + { + var sutProvider = Setup(); + job.RotationConfigId = config.Id; + attempt.JobId = job.Id; + SetupAttempt(sutProvider, attempt); + sutProvider.GetDependency() + .MarkAttemptErroredAsync(attempt.Id, daemonId, Arg.Any(), syncState, _now, Arg.Any(), Arg.Any()) + .Returns(new PamRotationFailureResult { Outcome = PamRotationAttemptResolveOutcome.Resolved, JobStatus = PamRotationJobStatus.Failed }); + sutProvider.GetDependency().GetByIdAsync(job.Id).Returns(job); + sutProvider.GetDependency().GetByIdAsync(config.Id).Returns(config); + sutProvider.GetDependency().GetByIdAsync(daemonId).Returns(daemon); + + await sutProvider.Sut.ReportFailedAsync(daemonId, attempt.Id, failureReason, syncState); + + await sutProvider.GetDependency().Received(1).ReplaceAsync( + Arg.Is(c => c.Id == config.Id && c.NextRotationAt == _now + _failureRetryDelay)); + await sutProvider.GetDependency().Received(1).EmitAsync( + Arg.Is(a => a.Kind == AccessAuditEventKind.RotationFailed + && a.OrganizationId == config.OrganizationId + && a.DaemonId == daemonId + && a.RotationJobId == job.Id + && a.RotationConfigId == config.Id + && a.SyncState == syncState + && a.Detail == failureReason)); + } + + [Theory, BitAutoData] + public async Task ReportFailedAsync_Rejected_EmitsReportRejectedAuditAndThrowsConflict_ConfigNotUpdated( + Guid daemonId, PamRotationAttempt attempt, PamRotationJob job, PamRotationConfig config, + string failureReason, PamRotationSyncState syncState) + { + var sutProvider = Setup(); + job.RotationConfigId = config.Id; + attempt.JobId = job.Id; + SetupAttempt(sutProvider, attempt); + sutProvider.GetDependency() + .MarkAttemptErroredAsync(attempt.Id, daemonId, Arg.Any(), syncState, _now, Arg.Any(), Arg.Any()) + .Returns(new PamRotationFailureResult { Outcome = PamRotationAttemptResolveOutcome.Rejected, JobStatus = null }); + sutProvider.GetDependency().GetByIdAsync(job.Id).Returns(job); + sutProvider.GetDependency().GetByIdAsync(config.Id).Returns(config); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.ReportFailedAsync(daemonId, attempt.Id, failureReason, syncState)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .ReplaceAsync(default!); + await sutProvider.GetDependency().Received(1).EmitAsync( + Arg.Is(a => a.Kind == AccessAuditEventKind.RotationReportRejected + && a.OrganizationId == config.OrganizationId + && a.DaemonId == daemonId + && a.RotationJobId == job.Id + && a.RotationConfigId == config.Id)); + } + + private static SutProvider Setup() + { + var sutProvider = new SutProvider().WithFakeTimeProvider().Create(); + sutProvider.GetDependency().SetUtcNow(_now); + sutProvider.GetDependency>().Value + .Returns(new PamRotationOptions { FailureRetryDelay = _failureRetryDelay }); + return sutProvider; + } + + private static void SetupAttempt(SutProvider sutProvider, PamRotationAttempt attempt) => + sutProvider.GetDependency().GetAttemptByIdAsync(attempt.Id).Returns(attempt); +} diff --git a/bitwarden_license/test/Services/Pam.Test/Rotation/Commands/ReportRotationSucceededCommandTests.cs b/bitwarden_license/test/Services/Pam.Test/Rotation/Commands/ReportRotationSucceededCommandTests.cs new file mode 100644 index 000000000000..7ff3fc32fb3a --- /dev/null +++ b/bitwarden_license/test/Services/Pam.Test/Rotation/Commands/ReportRotationSucceededCommandTests.cs @@ -0,0 +1,117 @@ +using Bit.Core.Exceptions; +using Bit.Pam.Entities; +using Bit.Pam.Enums; +using Bit.Pam.Models; +using Bit.Pam.Repositories; +using Bit.Pam.Services; +using Bit.Services.Pam.Rotation; +using Bit.Services.Pam.Rotation.Commands; +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.Rotation.Commands; + +[SutProviderCustomize] +public class ReportRotationSucceededCommandTests +{ + private static readonly DateTime _now = new(2026, 6, 5, 12, 0, 0, DateTimeKind.Utc); + private static readonly DateTime _nextOccurrence = new(2026, 6, 6, 12, 0, 0, DateTimeKind.Utc); + + [Theory, BitAutoData] + public async Task ReportSucceededAsync_UnknownAttempt_ThrowsNotFound_NoAudit( + Guid daemonId, Guid attemptId, PamSessionTerminationOutcome sessionTermination) + { + var sutProvider = Setup(); + sutProvider.GetDependency().GetAttemptByIdAsync(attemptId) + .Returns((PamRotationAttempt?)null); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.ReportSucceededAsync(daemonId, attemptId, sessionTermination)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .MarkAttemptRotatedAsync(default, default, default, default); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .ReplaceAsync(default!); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().EmitAsync(default!); + } + + [Theory, BitAutoData] + public async Task ReportSucceededAsync_Resolved_UpdatesConfigAndEmitsRotationSucceededAudit( + Guid daemonId, PamRotationAttempt attempt, PamRotationJob job, PamRotationConfig config, PamDaemon daemon, + PamSessionTerminationOutcome sessionTermination) + { + var sutProvider = Setup(); + job.RotationConfigId = config.Id; + attempt.JobId = job.Id; + daemon.Id = daemonId; + SetupAttempt(sutProvider, attempt); + sutProvider.GetDependency() + .MarkAttemptRotatedAsync(attempt.Id, daemonId, sessionTermination, _now) + .Returns(PamRotationAttemptResolveOutcome.Resolved); + sutProvider.GetDependency().GetByIdAsync(job.Id).Returns(job); + sutProvider.GetDependency().GetByIdAsync(config.Id).Returns(config); + sutProvider.GetDependency().GetByIdAsync(daemonId).Returns(daemon); + sutProvider.GetDependency().GetNextOccurrence(config.ScheduleCron, _now) + .Returns(_nextOccurrence); + + var returned = await sutProvider.Sut.ReportSucceededAsync(daemonId, attempt.Id, sessionTermination); + + Assert.Same(attempt, returned); + await sutProvider.GetDependency().Received(1).ReplaceAsync( + Arg.Is(c => c.Id == config.Id + && c.LastRotationAt == _now + && c.NextRotationAt == _nextOccurrence)); + await sutProvider.GetDependency().Received(1).EmitAsync( + Arg.Is(a => a.Kind == AccessAuditEventKind.RotationSucceeded + && a.OrganizationId == config.OrganizationId + && a.ActorId == null + && a.DaemonId == daemonId + && a.DaemonName == daemon.Name + && a.RotationJobId == job.Id + && a.RotationConfigId == config.Id + && a.CipherId == config.CipherId + && a.RotationSource == job.Source)); + } + + [Theory, BitAutoData] + public async Task ReportSucceededAsync_Rejected_EmitsReportRejectedAuditAndThrowsConflict_ConfigNotUpdated( + Guid daemonId, PamRotationAttempt attempt, PamRotationJob job, PamRotationConfig config, + PamSessionTerminationOutcome sessionTermination) + { + var sutProvider = Setup(); + job.RotationConfigId = config.Id; + attempt.JobId = job.Id; + SetupAttempt(sutProvider, attempt); + sutProvider.GetDependency() + .MarkAttemptRotatedAsync(attempt.Id, daemonId, sessionTermination, _now) + .Returns(PamRotationAttemptResolveOutcome.Rejected); + sutProvider.GetDependency().GetByIdAsync(job.Id).Returns(job); + sutProvider.GetDependency().GetByIdAsync(config.Id).Returns(config); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.ReportSucceededAsync(daemonId, attempt.Id, sessionTermination)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .ReplaceAsync(default!); + await sutProvider.GetDependency().Received(1).EmitAsync( + Arg.Is(a => a.Kind == AccessAuditEventKind.RotationReportRejected + && a.OrganizationId == config.OrganizationId + && a.DaemonId == daemonId + && a.RotationJobId == job.Id + && a.RotationConfigId == config.Id + && a.CipherId == config.CipherId)); + } + + private static SutProvider Setup() + { + var sutProvider = new SutProvider().WithFakeTimeProvider().Create(); + sutProvider.GetDependency().SetUtcNow(_now); + return sutProvider; + } + + private static void SetupAttempt(SutProvider sutProvider, PamRotationAttempt attempt) => + sutProvider.GetDependency().GetAttemptByIdAsync(attempt.Id).Returns(attempt); +} diff --git a/bitwarden_license/test/Services/Pam.Test/Rotation/Commands/ResumeRotationCommandTests.cs b/bitwarden_license/test/Services/Pam.Test/Rotation/Commands/ResumeRotationCommandTests.cs new file mode 100644 index 000000000000..8bddacce033c --- /dev/null +++ b/bitwarden_license/test/Services/Pam.Test/Rotation/Commands/ResumeRotationCommandTests.cs @@ -0,0 +1,140 @@ +using Bit.Core.Exceptions; +using Bit.Pam.Entities; +using Bit.Pam.Enums; +using Bit.Pam.Models; +using Bit.Pam.Repositories; +using Bit.Pam.Services; +using Bit.Services.Pam.Rotation; +using Bit.Services.Pam.Rotation.Commands; +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.Rotation.Commands; + +[SutProviderCustomize] +public class ResumeRotationCommandTests +{ + private static readonly DateTime _now = new(2026, 7, 6, 12, 0, 0, DateTimeKind.Utc); + + [Theory, BitAutoData] + public async Task ResumeAsync_ConfigMissing_ThrowsNotFound(Guid organizationId, Guid actingUserId, Guid configId) + { + var sutProvider = Setup(); + sutProvider.GetDependency().GetDetailsByIdAsync(configId) + .Returns((PamRotationConfigDetails?)null); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.ResumeAsync(organizationId, actingUserId, configId)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().ReplaceAsync(default!); + } + + [Theory, BitAutoData] + public async Task ResumeAsync_WrongOrg_ThrowsNotFound(Guid actingUserId, PamRotationConfigDetails details) + { + var sutProvider = Setup(); + sutProvider.GetDependency().GetDetailsByIdAsync(details.Id).Returns(details); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.ResumeAsync(Guid.NewGuid(), actingUserId, details.Id)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().ReplaceAsync(default!); + } + + [Theory, BitAutoData] + public async Task ResumeAsync_AlreadyEnabled_ThrowsBadRequest(Guid actingUserId, PamRotationConfigDetails details) + { + var sutProvider = Setup(); + details.Enabled = true; + sutProvider.GetDependency().GetDetailsByIdAsync(details.Id).Returns(details); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.ResumeAsync(details.OrganizationId, actingUserId, details.Id)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().ReplaceAsync(default!); + } + + [Theory, BitAutoData] + public async Task ResumeAsync_ManualWithDueObligation_SetsNextRotationAtToNowWithoutRecomputing( + Guid actingUserId, PamRotationConfigDetails details) + { + var sutProvider = Setup(); + details.Enabled = false; + details.TargetSystemMethod = PamTargetSystemMethod.Manual; + details.NextRotationAt = _now.AddMinutes(-5); // due while paused + sutProvider.GetDependency().GetDetailsByIdAsync(details.Id).Returns(details); + + await sutProvider.Sut.ResumeAsync(details.OrganizationId, actingUserId, details.Id); + + await sutProvider.GetDependency().Received(1).ReplaceAsync(Arg.Is(c => + c.Id == details.Id && c.Enabled && c.NextRotationAt == _now)); + // The due obligation is pulled to now, not recomputed from the schedule. + sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .GetNextOccurrence(default, default); + } + + [Theory, BitAutoData] + public async Task ResumeAsync_ManualWithoutDueObligation_Recomputes(Guid actingUserId, PamRotationConfigDetails details) + { + var sutProvider = Setup(); + details.Enabled = false; + details.TargetSystemMethod = PamTargetSystemMethod.Manual; + details.NextRotationAt = _now.AddDays(1); // not yet due + sutProvider.GetDependency().GetDetailsByIdAsync(details.Id).Returns(details); + var recomputed = _now.AddDays(2); + sutProvider.GetDependency().GetNextOccurrence(details.ScheduleCron, _now) + .Returns(recomputed); + + await sutProvider.Sut.ResumeAsync(details.OrganizationId, actingUserId, details.Id); + + await sutProvider.GetDependency().Received(1).ReplaceAsync(Arg.Is(c => + c.Id == details.Id && c.NextRotationAt == recomputed)); + } + + [Theory, BitAutoData] + public async Task ResumeAsync_Automatic_Recomputes(Guid actingUserId, PamRotationConfigDetails details) + { + var sutProvider = Setup(); + details.Enabled = false; + details.TargetSystemMethod = PamTargetSystemMethod.Automatic; + details.NextRotationAt = _now.AddMinutes(-5); // "due", but automatic never gets pulled to now here + sutProvider.GetDependency().GetDetailsByIdAsync(details.Id).Returns(details); + var recomputed = _now.AddHours(3); + sutProvider.GetDependency().GetNextOccurrence(details.ScheduleCron, _now) + .Returns(recomputed); + + await sutProvider.Sut.ResumeAsync(details.OrganizationId, actingUserId, details.Id); + + await sutProvider.GetDependency().Received(1).ReplaceAsync(Arg.Is(c => + c.Id == details.Id && c.NextRotationAt == recomputed)); + } + + [Theory, BitAutoData] + public async Task ResumeAsync_HappyPath_EmitsAttemptThenOutcome(Guid actingUserId, PamRotationConfigDetails details) + { + var sutProvider = Setup(); + details.Enabled = false; + details.TargetSystemMethod = PamTargetSystemMethod.Automatic; + sutProvider.GetDependency().GetDetailsByIdAsync(details.Id).Returns(details); + + await sutProvider.Sut.ResumeAsync(details.OrganizationId, actingUserId, details.Id); + + var emitter = sutProvider.GetDependency(); + await emitter.Received(1).EmitAsync(Arg.Is(e => + e.Kind == AccessAuditEventKind.RotationResumed && e.Phase == AccessAuditEventPhase.Attempt + && e.RotationConfigId == details.Id)); + await emitter.Received(1).EmitAsync(Arg.Is(e => + e.Kind == AccessAuditEventKind.RotationResumed && e.Phase == AccessAuditEventPhase.Outcome + && e.RotationConfigId == details.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/Rotation/Commands/RevokeDaemonCommandTests.cs b/bitwarden_license/test/Services/Pam.Test/Rotation/Commands/RevokeDaemonCommandTests.cs new file mode 100644 index 000000000000..a7b16d78db56 --- /dev/null +++ b/bitwarden_license/test/Services/Pam.Test/Rotation/Commands/RevokeDaemonCommandTests.cs @@ -0,0 +1,119 @@ +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.Pam.Services; +using Bit.Services.Pam.Rotation.Commands; +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.Rotation.Commands; + +[SutProviderCustomize] +public class RevokeDaemonCommandTests +{ + private static readonly DateTime _now = new(2026, 7, 6, 12, 0, 0, DateTimeKind.Utc); + + [Theory, BitAutoData] + public async Task RevokeAsync_DaemonMissing_ThrowsNotFound(Guid organizationId, Guid actingUserId, Guid daemonId) + { + var sutProvider = Setup(); + sutProvider.GetDependency().GetByIdAsync(daemonId).Returns((PamDaemon?)null); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.RevokeAsync(organizationId, actingUserId, daemonId)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().ReplaceAsync(default!); + } + + [Theory, BitAutoData] + public async Task RevokeAsync_WrongOrg_ThrowsNotFound(Guid actingUserId, PamDaemon daemon) + { + var sutProvider = Setup(); + daemon.Status = PamDaemonStatus.Enrolled; + sutProvider.GetDependency().GetByIdAsync(daemon.Id).Returns(daemon); + + // daemon.OrganizationId is an unrelated AutoFixture Guid -- a cross-org lookup must 404, never leak + // existence. + await Assert.ThrowsAsync( + () => sutProvider.Sut.RevokeAsync(Guid.NewGuid(), actingUserId, daemon.Id)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().ReplaceAsync(default!); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().DeleteAsync(default!); + } + + [Theory, BitAutoData] + public async Task RevokeAsync_AlreadyRevoked_ThrowsBadRequest(Guid actingUserId, PamDaemon daemon) + { + var sutProvider = Setup(); + daemon.Status = PamDaemonStatus.Revoked; + sutProvider.GetDependency().GetByIdAsync(daemon.Id).Returns(daemon); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.RevokeAsync(daemon.OrganizationId, actingUserId, daemon.Id)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().ReplaceAsync(default!); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().DeleteAsync(default!); + } + + [Theory, BitAutoData] + public async Task RevokeAsync_Enrolled_RevokesAndDeletesApiKey(Guid actingUserId, PamDaemon daemon, ApiKey apiKey) + { + var sutProvider = Setup(); + daemon.Status = PamDaemonStatus.Enrolled; + sutProvider.GetDependency().GetByIdAsync(daemon.Id).Returns(daemon); + sutProvider.GetDependency().GetByIdAsync(daemon.ApiKeyId).Returns(apiKey); + + await sutProvider.Sut.RevokeAsync(daemon.OrganizationId, actingUserId, daemon.Id); + + await sutProvider.GetDependency().Received(1).ReplaceAsync(Arg.Is(d => + d.Id == daemon.Id && d.Status == PamDaemonStatus.Revoked && d.RevisionDate == _now)); + await sutProvider.GetDependency().Received(1).DeleteAsync(apiKey); + } + + [Theory, BitAutoData] + public async Task RevokeAsync_ApiKeyAlreadyGone_StillRevokesWithoutThrowing(Guid actingUserId, PamDaemon daemon) + { + var sutProvider = Setup(); + daemon.Status = PamDaemonStatus.Enrolled; + sutProvider.GetDependency().GetByIdAsync(daemon.Id).Returns(daemon); + sutProvider.GetDependency().GetByIdAsync(daemon.ApiKeyId).Returns((ApiKey?)null); + + await sutProvider.Sut.RevokeAsync(daemon.OrganizationId, actingUserId, daemon.Id); + + await sutProvider.GetDependency().Received(1).ReplaceAsync(Arg.Any()); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().DeleteAsync(default!); + } + + [Theory, BitAutoData] + public async Task RevokeAsync_Enrolled_EmitsAttemptThenOutcome(Guid actingUserId, PamDaemon daemon, ApiKey apiKey) + { + var sutProvider = Setup(); + daemon.Status = PamDaemonStatus.Enrolled; + sutProvider.GetDependency().GetByIdAsync(daemon.Id).Returns(daemon); + sutProvider.GetDependency().GetByIdAsync(daemon.ApiKeyId).Returns(apiKey); + + await sutProvider.Sut.RevokeAsync(daemon.OrganizationId, actingUserId, daemon.Id); + + var emitter = sutProvider.GetDependency(); + await emitter.Received(1).EmitAsync(Arg.Is(e => + e.Kind == AccessAuditEventKind.DaemonRevoked && e.Phase == AccessAuditEventPhase.Attempt + && e.DaemonId == daemon.Id && e.DaemonName == daemon.Name && e.ActorId == actingUserId)); + await emitter.Received(1).EmitAsync(Arg.Is(e => + e.Kind == AccessAuditEventKind.DaemonRevoked && e.Phase == AccessAuditEventPhase.Outcome + && e.DaemonId == daemon.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/Rotation/Commands/SetTargetSystemStatusCommandTests.cs b/bitwarden_license/test/Services/Pam.Test/Rotation/Commands/SetTargetSystemStatusCommandTests.cs new file mode 100644 index 000000000000..e6cc5fba0f81 --- /dev/null +++ b/bitwarden_license/test/Services/Pam.Test/Rotation/Commands/SetTargetSystemStatusCommandTests.cs @@ -0,0 +1,113 @@ +using Bit.Core.Exceptions; +using Bit.Pam.Entities; +using Bit.Pam.Enums; +using Bit.Pam.Models; +using Bit.Pam.Repositories; +using Bit.Pam.Services; +using Bit.Services.Pam.Rotation.Commands; +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.Rotation.Commands; + +[SutProviderCustomize] +public class SetTargetSystemStatusCommandTests +{ + private static readonly DateTime _now = new(2026, 7, 6, 12, 0, 0, DateTimeKind.Utc); + + [Theory, BitAutoData] + public async Task SetStatusAsync_TargetMissing_ThrowsNotFound(Guid organizationId, Guid actingUserId, Guid targetSystemId) + { + var sutProvider = Setup(); + sutProvider.GetDependency().GetByIdAsync(targetSystemId).Returns((PamTargetSystem?)null); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.SetStatusAsync(organizationId, actingUserId, targetSystemId, true)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().ReplaceAsync(default!); + } + + [Theory, BitAutoData] + public async Task SetStatusAsync_WrongOrg_ThrowsNotFound(Guid actingUserId, PamTargetSystem target) + { + var sutProvider = Setup(); + sutProvider.GetDependency().GetByIdAsync(target.Id).Returns(target); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.SetStatusAsync(Guid.NewGuid(), actingUserId, target.Id, true)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().ReplaceAsync(default!); + } + + [Theory, BitAutoData] + public async Task SetStatusAsync_AlreadyEnabled_ThrowsBadRequest(Guid actingUserId, PamTargetSystem target) + { + var sutProvider = Setup(); + target.Status = PamTargetSystemStatus.Active; + sutProvider.GetDependency().GetByIdAsync(target.Id).Returns(target); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.SetStatusAsync(target.OrganizationId, actingUserId, target.Id, true)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().ReplaceAsync(default!); + } + + [Theory, BitAutoData] + public async Task SetStatusAsync_AlreadyDisabled_ThrowsBadRequest(Guid actingUserId, PamTargetSystem target) + { + var sutProvider = Setup(); + target.Status = PamTargetSystemStatus.Disabled; + sutProvider.GetDependency().GetByIdAsync(target.Id).Returns(target); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.SetStatusAsync(target.OrganizationId, actingUserId, target.Id, false)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().ReplaceAsync(default!); + } + + [Theory, BitAutoData] + public async Task SetStatusAsync_Disable_UpdatesStatusAndEmitsDisabledAudit(Guid actingUserId, PamTargetSystem target) + { + var sutProvider = Setup(); + target.Status = PamTargetSystemStatus.Active; + sutProvider.GetDependency().GetByIdAsync(target.Id).Returns(target); + + await sutProvider.Sut.SetStatusAsync(target.OrganizationId, actingUserId, target.Id, false); + + await sutProvider.GetDependency().Received(1).ReplaceAsync(Arg.Is(t => + t.Id == target.Id && t.Status == PamTargetSystemStatus.Disabled && t.RevisionDate == _now)); + var emitter = sutProvider.GetDependency(); + await emitter.Received(1).EmitAsync(Arg.Is(e => + e.Kind == AccessAuditEventKind.TargetSystemDisabled && e.Phase == AccessAuditEventPhase.Attempt)); + await emitter.Received(1).EmitAsync(Arg.Is(e => + e.Kind == AccessAuditEventKind.TargetSystemDisabled && e.Phase == AccessAuditEventPhase.Outcome)); + } + + [Theory, BitAutoData] + public async Task SetStatusAsync_Enable_UpdatesStatusAndEmitsEnabledAudit(Guid actingUserId, PamTargetSystem target) + { + var sutProvider = Setup(); + target.Status = PamTargetSystemStatus.Disabled; + sutProvider.GetDependency().GetByIdAsync(target.Id).Returns(target); + + await sutProvider.Sut.SetStatusAsync(target.OrganizationId, actingUserId, target.Id, true); + + await sutProvider.GetDependency().Received(1).ReplaceAsync(Arg.Is(t => + t.Id == target.Id && t.Status == PamTargetSystemStatus.Active)); + var emitter = sutProvider.GetDependency(); + await emitter.Received(1).EmitAsync(Arg.Is(e => + e.Kind == AccessAuditEventKind.TargetSystemEnabled && e.Phase == AccessAuditEventPhase.Attempt)); + await emitter.Received(1).EmitAsync(Arg.Is(e => + e.Kind == AccessAuditEventKind.TargetSystemEnabled && e.Phase == AccessAuditEventPhase.Outcome)); + } + + 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/Rotation/Commands/SubmitCipherUpdateCommandTests.cs b/bitwarden_license/test/Services/Pam.Test/Rotation/Commands/SubmitCipherUpdateCommandTests.cs new file mode 100644 index 000000000000..550510d076ab --- /dev/null +++ b/bitwarden_license/test/Services/Pam.Test/Rotation/Commands/SubmitCipherUpdateCommandTests.cs @@ -0,0 +1,131 @@ +using Bit.Core.Exceptions; +using Bit.Core.Vault.Entities; +using Bit.Core.Vault.Repositories; +using Bit.Core.Vault.Services; +using Bit.Pam.Entities; +using Bit.Pam.Enums; +using Bit.Pam.Models; +using Bit.Pam.Repositories; +using Bit.Pam.Services; +using Bit.Services.Pam.Rotation.Commands; +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.Rotation.Commands; + +[SutProviderCustomize] +public class SubmitCipherUpdateCommandTests +{ + private static readonly DateTime _now = new(2026, 6, 5, 12, 0, 0, DateTimeKind.Utc); + + [Theory, BitAutoData] + public async Task SubmitAsync_UnknownAttempt_ThrowsNotFound_NoAudit_NoPush( + Guid daemonId, Guid attemptId, string cipherDataJson, DateTime lastKnownRevisionDate) + { + var sutProvider = Setup(); + sutProvider.GetDependency().GetAttemptByIdAsync(attemptId) + .Returns((PamRotationAttempt?)null); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.SubmitAsync(daemonId, attemptId, cipherDataJson, lastKnownRevisionDate)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .AcceptCipherWriteAsync(default, default, default!, default, default); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().EmitAsync(default!); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .PushSyncCipherUpdateAsync(default!, default!); + } + + [Theory, BitAutoData] + public async Task SubmitAsync_Accepted_PushesCipherSyncUpdate( + Guid daemonId, PamRotationAttempt attempt, PamRotationJob job, PamRotationConfig config, Cipher cipher, + string cipherDataJson, DateTime lastKnownRevisionDate) + { + var sutProvider = Setup(); + job.RotationConfigId = config.Id; + attempt.JobId = job.Id; + cipher.Id = config.CipherId; + SetupAttempt(sutProvider, attempt); + sutProvider.GetDependency() + .AcceptCipherWriteAsync(attempt.Id, daemonId, cipherDataJson, lastKnownRevisionDate, _now) + .Returns(PamRotationCipherWriteOutcome.Accepted); + sutProvider.GetDependency().GetByIdAsync(job.Id).Returns(job); + sutProvider.GetDependency().GetByIdAsync(config.Id).Returns(config); + sutProvider.GetDependency().GetByIdAsync(config.CipherId).Returns(cipher); + + await sutProvider.Sut.SubmitAsync(daemonId, attempt.Id, cipherDataJson, lastKnownRevisionDate); + + await sutProvider.GetDependency().Received(1) + .PushSyncCipherUpdateAsync(cipher, Arg.Is>(c => !c.Any())); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().EmitAsync(default!); + } + + [Theory, BitAutoData] + public async Task SubmitAsync_Rejected_EmitsWriteRejectedAuditAndThrowsConflict_PushNotCalled( + Guid daemonId, PamRotationAttempt attempt, PamRotationJob job, PamRotationConfig config, PamDaemon daemon, + string cipherDataJson, DateTime lastKnownRevisionDate) + { + var sutProvider = Setup(); + job.RotationConfigId = config.Id; + attempt.JobId = job.Id; + SetupAttempt(sutProvider, attempt); + sutProvider.GetDependency() + .AcceptCipherWriteAsync(attempt.Id, daemonId, cipherDataJson, lastKnownRevisionDate, _now) + .Returns(PamRotationCipherWriteOutcome.Rejected); + sutProvider.GetDependency().GetByIdAsync(job.Id).Returns(job); + sutProvider.GetDependency().GetByIdAsync(config.Id).Returns(config); + sutProvider.GetDependency().GetByIdAsync(daemonId).Returns(daemon); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.SubmitAsync(daemonId, attempt.Id, cipherDataJson, lastKnownRevisionDate)); + + await sutProvider.GetDependency().Received(1).EmitAsync( + Arg.Is(a => a.Kind == AccessAuditEventKind.RotationCipherWriteRejected + && a.OrganizationId == config.OrganizationId + && a.DaemonId == daemonId + && a.RotationJobId == job.Id + && a.RotationConfigId == config.Id + && a.CipherId == config.CipherId)); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .PushSyncCipherUpdateAsync(default!, default!); + } + + [Theory, BitAutoData] + public async Task SubmitAsync_RevisionMismatch_EmitsWriteRejectedAuditAndThrowsConflict_PushNotCalled( + Guid daemonId, PamRotationAttempt attempt, PamRotationJob job, PamRotationConfig config, PamDaemon daemon, + string cipherDataJson, DateTime lastKnownRevisionDate) + { + var sutProvider = Setup(); + job.RotationConfigId = config.Id; + attempt.JobId = job.Id; + SetupAttempt(sutProvider, attempt); + sutProvider.GetDependency() + .AcceptCipherWriteAsync(attempt.Id, daemonId, cipherDataJson, lastKnownRevisionDate, _now) + .Returns(PamRotationCipherWriteOutcome.RevisionMismatch); + sutProvider.GetDependency().GetByIdAsync(job.Id).Returns(job); + sutProvider.GetDependency().GetByIdAsync(config.Id).Returns(config); + sutProvider.GetDependency().GetByIdAsync(daemonId).Returns(daemon); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.SubmitAsync(daemonId, attempt.Id, cipherDataJson, lastKnownRevisionDate)); + + await sutProvider.GetDependency().Received(1).EmitAsync( + Arg.Is(a => a.Kind == AccessAuditEventKind.RotationCipherWriteRejected + && a.RotationJobId == job.Id)); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .PushSyncCipherUpdateAsync(default!, default!); + } + + private static SutProvider Setup() + { + var sutProvider = new SutProvider().WithFakeTimeProvider().Create(); + sutProvider.GetDependency().SetUtcNow(_now); + return sutProvider; + } + + private static void SetupAttempt(SutProvider sutProvider, PamRotationAttempt attempt) => + sutProvider.GetDependency().GetAttemptByIdAsync(attempt.Id).Returns(attempt); +} diff --git a/bitwarden_license/test/Services/Pam.Test/Rotation/Commands/TriggerRotationCommandTests.cs b/bitwarden_license/test/Services/Pam.Test/Rotation/Commands/TriggerRotationCommandTests.cs new file mode 100644 index 000000000000..3eb3f079b182 --- /dev/null +++ b/bitwarden_license/test/Services/Pam.Test/Rotation/Commands/TriggerRotationCommandTests.cs @@ -0,0 +1,176 @@ +using Bit.Core.Exceptions; +using Bit.Pam.Entities; +using Bit.Pam.Enums; +using Bit.Pam.Models; +using Bit.Pam.Repositories; +using Bit.Services.Pam.Rotation; +using Bit.Services.Pam.Rotation.Commands; +using Bit.Services.Pam.Rotation.Commands.Interfaces; +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.Rotation.Commands; + +[SutProviderCustomize] +public class TriggerRotationCommandTests +{ + private static readonly DateTime _now = new(2026, 7, 6, 12, 0, 0, DateTimeKind.Utc); + + [Theory, BitAutoData] + public async Task TriggerAsync_ConfigMissing_ThrowsNotFound(Guid organizationId, Guid actingUserId, Guid configId) + { + var sutProvider = Setup(); + sutProvider.GetDependency().GetDetailsByIdAsync(configId) + .Returns((PamRotationConfigDetails?)null); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.TriggerAsync(organizationId, actingUserId, configId)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().OfferAsync(default, default); + } + + [Theory, BitAutoData] + public async Task TriggerAsync_WrongOrg_ThrowsNotFound(Guid actingUserId, PamRotationConfigDetails details) + { + var sutProvider = Setup(); + sutProvider.GetDependency().GetDetailsByIdAsync(details.Id).Returns(details); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.TriggerAsync(Guid.NewGuid(), actingUserId, details.Id)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().OfferAsync(default, default); + } + + [Theory, BitAutoData] + public async Task TriggerAsync_TargetMissing_ThrowsNotFound(Guid actingUserId, PamRotationConfigDetails details) + { + var sutProvider = Setup(); + sutProvider.GetDependency().GetDetailsByIdAsync(details.Id).Returns(details); + sutProvider.GetDependency().GetByIdAsync(details.TargetSystemId) + .Returns((PamTargetSystem?)null); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.TriggerAsync(details.OrganizationId, actingUserId, details.Id)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().OfferAsync(default, default); + } + + [Theory, BitAutoData] + public async Task TriggerAsync_Paused_ThrowsBadRequest(Guid actingUserId, PamRotationConfigDetails details, PamTargetSystem target) + { + var sutProvider = Setup(); + SetupOfferable(sutProvider, details, target); + details.Enabled = false; + + await Assert.ThrowsAsync( + () => sutProvider.Sut.TriggerAsync(details.OrganizationId, actingUserId, details.Id)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().OfferAsync(default, default); + } + + [Theory, BitAutoData] + public async Task TriggerAsync_ManualTarget_ThrowsBadRequest(Guid actingUserId, PamRotationConfigDetails details, PamTargetSystem target) + { + var sutProvider = Setup(); + SetupOfferable(sutProvider, details, target); + details.TargetSystemMethod = PamTargetSystemMethod.Manual; + + await Assert.ThrowsAsync( + () => sutProvider.Sut.TriggerAsync(details.OrganizationId, actingUserId, details.Id)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().OfferAsync(default, default); + } + + [Theory, BitAutoData] + public async Task TriggerAsync_TargetDisabled_ThrowsBadRequest(Guid actingUserId, PamRotationConfigDetails details, PamTargetSystem target) + { + var sutProvider = Setup(); + SetupOfferable(sutProvider, details, target); + target.Status = PamTargetSystemStatus.Disabled; + + await Assert.ThrowsAsync( + () => sutProvider.Sut.TriggerAsync(details.OrganizationId, actingUserId, details.Id)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().OfferAsync(default, default); + } + + [Theory, BitAutoData] + public async Task TriggerAsync_ActiveJob_ThrowsBadRequest(Guid actingUserId, PamRotationConfigDetails details, PamTargetSystem target) + { + var sutProvider = Setup(); + SetupOfferable(sutProvider, details, target); + details.HasActiveJob = true; + + await Assert.ThrowsAsync( + () => sutProvider.Sut.TriggerAsync(details.OrganizationId, actingUserId, details.Id)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().OfferAsync(default, default); + } + + [Theory, BitAutoData] + public async Task TriggerAsync_WithinCooldown_ThrowsBadRequest(Guid actingUserId, PamRotationConfigDetails details, PamTargetSystem target) + { + var sutProvider = Setup(); + SetupOfferable(sutProvider, details, target); + details.LastRotationAt = _now.AddSeconds(-30); // inside the 1-minute cooldown + + await Assert.ThrowsAsync( + () => sutProvider.Sut.TriggerAsync(details.OrganizationId, actingUserId, details.Id)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().OfferAsync(default, default); + } + + [Theory, BitAutoData] + public async Task TriggerAsync_CooldownElapsed_DelegatesToOfferWithOnDemand( + Guid actingUserId, PamRotationConfigDetails details, PamTargetSystem target) + { + var sutProvider = Setup(); + SetupOfferable(sutProvider, details, target); + details.LastRotationAt = _now.AddMinutes(-5); // past the 1-minute cooldown + + await sutProvider.Sut.TriggerAsync(details.OrganizationId, actingUserId, details.Id); + + await sutProvider.GetDependency().Received(1) + .OfferAsync(details.Id, PamRotationSource.OnDemand); + } + + [Theory, BitAutoData] + public async Task TriggerAsync_NeverRotated_DelegatesToOfferWithOnDemand( + Guid actingUserId, PamRotationConfigDetails details, PamTargetSystem target) + { + var sutProvider = Setup(); + SetupOfferable(sutProvider, details, target); + details.LastRotationAt = null; // no prior rotation, so no cooldown + + await sutProvider.Sut.TriggerAsync(details.OrganizationId, actingUserId, details.Id); + + await sutProvider.GetDependency().Received(1) + .OfferAsync(details.Id, PamRotationSource.OnDemand); + } + + /// An offerable baseline: enabled, automatic method, active target, no active job. + private static void SetupOfferable( + SutProvider sutProvider, PamRotationConfigDetails details, PamTargetSystem target) + { + details.Enabled = true; + details.TargetSystemMethod = PamTargetSystemMethod.Automatic; + details.HasActiveJob = false; + details.LastRotationAt = null; + target.Status = PamTargetSystemStatus.Active; + sutProvider.GetDependency().GetDetailsByIdAsync(details.Id).Returns(details); + sutProvider.GetDependency().GetByIdAsync(details.TargetSystemId).Returns(target); + } + + private static SutProvider Setup() + { + var sutProvider = new SutProvider().WithFakeTimeProvider().Create(); + sutProvider.GetDependency().SetUtcNow(_now); + sutProvider.GetDependency>().Value + .Returns(new PamRotationOptions { OnDemandCooldown = TimeSpan.FromMinutes(1) }); + return sutProvider; + } +} diff --git a/bitwarden_license/test/Services/Pam.Test/Rotation/Commands/UnassignDaemonFromTargetCommandTests.cs b/bitwarden_license/test/Services/Pam.Test/Rotation/Commands/UnassignDaemonFromTargetCommandTests.cs new file mode 100644 index 000000000000..58b3e8931d68 --- /dev/null +++ b/bitwarden_license/test/Services/Pam.Test/Rotation/Commands/UnassignDaemonFromTargetCommandTests.cs @@ -0,0 +1,133 @@ +using Bit.Core.Exceptions; +using Bit.Pam.Entities; +using Bit.Pam.Enums; +using Bit.Pam.Models; +using Bit.Pam.Repositories; +using Bit.Pam.Services; +using Bit.Services.Pam.Rotation.Commands; +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.Rotation.Commands; + +[SutProviderCustomize] +public class UnassignDaemonFromTargetCommandTests +{ + private static readonly DateTime _now = new(2026, 7, 6, 12, 0, 0, DateTimeKind.Utc); + + [Theory, BitAutoData] + public async Task UnassignAsync_DaemonMissing_ThrowsNotFound( + Guid organizationId, Guid actingUserId, Guid daemonId, Guid targetSystemId) + { + var sutProvider = Setup(); + sutProvider.GetDependency().GetByIdAsync(daemonId).Returns((PamDaemon?)null); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.UnassignAsync(organizationId, actingUserId, daemonId, targetSystemId)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .DeleteAssignmentAsync(default, default); + } + + [Theory, BitAutoData] + public async Task UnassignAsync_DaemonWrongOrg_ThrowsNotFound(Guid actingUserId, PamDaemon daemon, Guid targetSystemId) + { + var sutProvider = Setup(); + sutProvider.GetDependency().GetByIdAsync(daemon.Id).Returns(daemon); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.UnassignAsync(Guid.NewGuid(), actingUserId, daemon.Id, targetSystemId)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .DeleteAssignmentAsync(default, default); + } + + [Theory, BitAutoData] + public async Task UnassignAsync_TargetMissing_ThrowsNotFound(Guid actingUserId, PamDaemon daemon, Guid targetSystemId) + { + var sutProvider = Setup(); + sutProvider.GetDependency().GetByIdAsync(daemon.Id).Returns(daemon); + sutProvider.GetDependency().GetByIdAsync(targetSystemId) + .Returns((PamTargetSystem?)null); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.UnassignAsync(daemon.OrganizationId, actingUserId, daemon.Id, targetSystemId)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .DeleteAssignmentAsync(default, default); + } + + [Theory, BitAutoData] + public async Task UnassignAsync_TargetWrongOrg_ThrowsNotFound(Guid actingUserId, PamDaemon daemon, PamTargetSystem target) + { + var sutProvider = Setup(); + sutProvider.GetDependency().GetByIdAsync(daemon.Id).Returns(daemon); + sutProvider.GetDependency().GetByIdAsync(target.Id).Returns(target); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.UnassignAsync(daemon.OrganizationId, actingUserId, daemon.Id, target.Id)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .DeleteAssignmentAsync(default, default); + } + + [Theory, BitAutoData] + public async Task UnassignAsync_AssignmentMissing_ThrowsNotFound(Guid actingUserId, PamDaemon daemon, PamTargetSystem target) + { + var sutProvider = Setup(); + target.OrganizationId = daemon.OrganizationId; + sutProvider.GetDependency().GetByIdAsync(daemon.Id).Returns(daemon); + sutProvider.GetDependency().GetByIdAsync(target.Id).Returns(target); + sutProvider.GetDependency().AssignmentExistsAsync(daemon.Id, target.Id).Returns(false); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.UnassignAsync(daemon.OrganizationId, actingUserId, daemon.Id, target.Id)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .DeleteAssignmentAsync(default, default); + } + + [Theory, BitAutoData] + public async Task UnassignAsync_HappyPath_DeletesAssignment(Guid actingUserId, PamDaemon daemon, PamTargetSystem target) + { + var sutProvider = Setup(); + target.OrganizationId = daemon.OrganizationId; + sutProvider.GetDependency().GetByIdAsync(daemon.Id).Returns(daemon); + sutProvider.GetDependency().GetByIdAsync(target.Id).Returns(target); + sutProvider.GetDependency().AssignmentExistsAsync(daemon.Id, target.Id).Returns(true); + + await sutProvider.Sut.UnassignAsync(daemon.OrganizationId, actingUserId, daemon.Id, target.Id); + + await sutProvider.GetDependency().Received(1).DeleteAssignmentAsync(daemon.Id, target.Id); + } + + [Theory, BitAutoData] + public async Task UnassignAsync_HappyPath_EmitsAttemptThenOutcome(Guid actingUserId, PamDaemon daemon, PamTargetSystem target) + { + var sutProvider = Setup(); + target.OrganizationId = daemon.OrganizationId; + sutProvider.GetDependency().GetByIdAsync(daemon.Id).Returns(daemon); + sutProvider.GetDependency().GetByIdAsync(target.Id).Returns(target); + sutProvider.GetDependency().AssignmentExistsAsync(daemon.Id, target.Id).Returns(true); + + await sutProvider.Sut.UnassignAsync(daemon.OrganizationId, actingUserId, daemon.Id, target.Id); + + var emitter = sutProvider.GetDependency(); + await emitter.Received(1).EmitAsync(Arg.Is(e => + e.Kind == AccessAuditEventKind.DaemonUnassignedFromTarget && e.Phase == AccessAuditEventPhase.Attempt + && e.DaemonId == daemon.Id && e.TargetSystemId == target.Id)); + await emitter.Received(1).EmitAsync(Arg.Is(e => + e.Kind == AccessAuditEventKind.DaemonUnassignedFromTarget && e.Phase == AccessAuditEventPhase.Outcome + && e.DaemonId == daemon.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/Rotation/Commands/UpdateRotationAccountCommandTests.cs b/bitwarden_license/test/Services/Pam.Test/Rotation/Commands/UpdateRotationAccountCommandTests.cs new file mode 100644 index 000000000000..2abcc57b904b --- /dev/null +++ b/bitwarden_license/test/Services/Pam.Test/Rotation/Commands/UpdateRotationAccountCommandTests.cs @@ -0,0 +1,154 @@ +using Bit.Core.Exceptions; +using Bit.Pam.Entities; +using Bit.Pam.Enums; +using Bit.Pam.Models; +using Bit.Pam.Repositories; +using Bit.Pam.Services; +using Bit.Services.Pam.Rotation.Commands; +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.Rotation.Commands; + +[SutProviderCustomize] +public class UpdateRotationAccountCommandTests +{ + private static readonly DateTime _now = new(2026, 7, 6, 12, 0, 0, DateTimeKind.Utc); + + [Theory, BitAutoData] + public async Task UpdateAsync_AccountIdentityMissing_ThrowsBadRequest(Guid organizationId, Guid actingUserId, Guid configId) + { + var sutProvider = Setup(); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.UpdateAsync(organizationId, actingUserId, configId, " ", false)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().ReplaceAsync(default!); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_ConfigMissing_ThrowsNotFound( + Guid organizationId, Guid actingUserId, Guid configId, string accountIdentity) + { + var sutProvider = Setup(); + sutProvider.GetDependency().GetDetailsByIdAsync(configId) + .Returns((PamRotationConfigDetails?)null); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.UpdateAsync(organizationId, actingUserId, configId, accountIdentity, false)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().ReplaceAsync(default!); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_WrongOrg_ThrowsNotFound( + Guid actingUserId, PamRotationConfigDetails details, string accountIdentity) + { + var sutProvider = Setup(); + sutProvider.GetDependency().GetDetailsByIdAsync(details.Id).Returns(details); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.UpdateAsync(Guid.NewGuid(), actingUserId, details.Id, accountIdentity, false)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().ReplaceAsync(default!); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_ActiveJob_ThrowsBadRequest( + Guid actingUserId, PamRotationConfigDetails details, string accountIdentity) + { + var sutProvider = Setup(); + details.HasActiveJob = true; + sutProvider.GetDependency().GetDetailsByIdAsync(details.Id).Returns(details); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.UpdateAsync(details.OrganizationId, actingUserId, details.Id, accountIdentity, false)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().ReplaceAsync(default!); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_TerminateSessionsOnUnsupportingTarget_ThrowsBadRequest( + Guid actingUserId, PamRotationConfigDetails details, PamTargetSystem target, string accountIdentity) + { + var sutProvider = Setup(); + details.HasActiveJob = false; + details.TargetSystemMethod = PamTargetSystemMethod.Automatic; + target.SupportsSessionTermination = false; + sutProvider.GetDependency().GetDetailsByIdAsync(details.Id).Returns(details); + sutProvider.GetDependency().GetByIdAsync(details.TargetSystemId).Returns(target); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.UpdateAsync(details.OrganizationId, actingUserId, details.Id, accountIdentity, true)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().ReplaceAsync(default!); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_TerminateSessionsOnManualTarget_ThrowsBadRequest( + Guid actingUserId, PamRotationConfigDetails details, PamTargetSystem target, string accountIdentity) + { + var sutProvider = Setup(); + details.HasActiveJob = false; + details.TargetSystemMethod = PamTargetSystemMethod.Manual; + target.SupportsSessionTermination = true; + sutProvider.GetDependency().GetDetailsByIdAsync(details.Id).Returns(details); + sutProvider.GetDependency().GetByIdAsync(details.TargetSystemId).Returns(target); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.UpdateAsync(details.OrganizationId, actingUserId, details.Id, accountIdentity, true)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().ReplaceAsync(default!); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_HappyPath_ReplacesAccountAndTermination( + Guid actingUserId, PamRotationConfigDetails details, PamTargetSystem target, string accountIdentity) + { + var sutProvider = Setup(); + details.HasActiveJob = false; + details.TargetSystemMethod = PamTargetSystemMethod.Automatic; + target.SupportsSessionTermination = true; + sutProvider.GetDependency().GetDetailsByIdAsync(details.Id).Returns(details); + sutProvider.GetDependency().GetByIdAsync(details.TargetSystemId).Returns(target); + + var result = await sutProvider.Sut.UpdateAsync(details.OrganizationId, actingUserId, details.Id, accountIdentity, true); + + Assert.Equal(accountIdentity, result.AccountIdentity); + Assert.True(result.TerminateSessions); + Assert.Equal(_now, result.RevisionDate); + await sutProvider.GetDependency().Received(1).ReplaceAsync(Arg.Is(c => + c.Id == details.Id && c.AccountIdentity == accountIdentity && c.TerminateSessions + && c.RevisionDate == _now)); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_HappyPath_EmitsAttemptThenOutcome( + Guid actingUserId, PamRotationConfigDetails details, PamTargetSystem target, string accountIdentity) + { + var sutProvider = Setup(); + details.HasActiveJob = false; + sutProvider.GetDependency().GetDetailsByIdAsync(details.Id).Returns(details); + sutProvider.GetDependency().GetByIdAsync(details.TargetSystemId).Returns(target); + + await sutProvider.Sut.UpdateAsync(details.OrganizationId, actingUserId, details.Id, accountIdentity, false); + + var emitter = sutProvider.GetDependency(); + await emitter.Received(1).EmitAsync(Arg.Is(e => + e.Kind == AccessAuditEventKind.RotationAccountUpdated && e.Phase == AccessAuditEventPhase.Attempt + && e.RotationConfigId == details.Id)); + await emitter.Received(1).EmitAsync(Arg.Is(e => + e.Kind == AccessAuditEventKind.RotationAccountUpdated && e.Phase == AccessAuditEventPhase.Outcome + && e.RotationConfigId == details.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/Rotation/Commands/UpdateRotationSettingsCommandTests.cs b/bitwarden_license/test/Services/Pam.Test/Rotation/Commands/UpdateRotationSettingsCommandTests.cs new file mode 100644 index 000000000000..5ba6a0504606 --- /dev/null +++ b/bitwarden_license/test/Services/Pam.Test/Rotation/Commands/UpdateRotationSettingsCommandTests.cs @@ -0,0 +1,120 @@ +using Bit.Core.Exceptions; +using Bit.Pam.Entities; +using Bit.Pam.Enums; +using Bit.Pam.Models; +using Bit.Pam.Repositories; +using Bit.Pam.Services; +using Bit.Services.Pam.Rotation; +using Bit.Services.Pam.Rotation.Commands; +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.Rotation.Commands; + +[SutProviderCustomize] +public class UpdateRotationSettingsCommandTests +{ + private static readonly DateTime _now = new(2026, 7, 6, 12, 0, 0, DateTimeKind.Utc); + + [Theory, BitAutoData] + public async Task UpdateAsync_ConfigMissing_ThrowsNotFound(Guid organizationId, Guid actingUserId, Guid configId, string cron) + { + var sutProvider = Setup(); + sutProvider.GetDependency().GetByIdAsync(configId).Returns((PamRotationConfig?)null); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.UpdateAsync(organizationId, actingUserId, configId, cron, false)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().ReplaceAsync(default!); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_WrongOrg_ThrowsNotFound(Guid actingUserId, PamRotationConfig config, string cron) + { + var sutProvider = Setup(); + sutProvider.GetDependency().GetByIdAsync(config.Id).Returns(config); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.UpdateAsync(Guid.NewGuid(), actingUserId, config.Id, cron, false)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().ReplaceAsync(default!); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_InvalidCron_ThrowsBadRequest(Guid actingUserId, PamRotationConfig config, string cron) + { + var sutProvider = Setup(); + sutProvider.GetDependency().GetByIdAsync(config.Id).Returns(config); + sutProvider.GetDependency() + .When(c => c.ValidateSchedule(cron, Arg.Any())) + .Do(_ => throw new BadRequestException("The schedule is not a valid cron expression.")); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.UpdateAsync(config.OrganizationId, actingUserId, config.Id, cron, false)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().ReplaceAsync(default!); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_NewCron_RecomputesNextRotationAt(Guid actingUserId, PamRotationConfig config, string newCron) + { + var sutProvider = Setup(); + sutProvider.GetDependency().GetByIdAsync(config.Id).Returns(config); + var nextOccurrence = _now.AddHours(6); + sutProvider.GetDependency().GetNextOccurrence(newCron, _now).Returns(nextOccurrence); + + var result = await sutProvider.Sut.UpdateAsync(config.OrganizationId, actingUserId, config.Id, newCron, true); + + Assert.Equal(newCron, result.ScheduleCron); + Assert.Equal(nextOccurrence, result.NextRotationAt); + Assert.True(result.RotateOnAccessEnd); + await sutProvider.GetDependency().Received(1).ReplaceAsync(Arg.Is(c => + c.Id == config.Id && c.ScheduleCron == newCron && c.NextRotationAt == nextOccurrence)); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_NullCron_ClearsNextRotationAt(Guid actingUserId, PamRotationConfig config) + { + var sutProvider = Setup(); + config.NextRotationAt = _now.AddDays(1); + sutProvider.GetDependency().GetByIdAsync(config.Id).Returns(config); + // A null cron is always valid (no scheduled rotation) and GetNextOccurrence returns null for it. + sutProvider.GetDependency().GetNextOccurrence(null, _now).Returns((DateTime?)null); + + var result = await sutProvider.Sut.UpdateAsync(config.OrganizationId, actingUserId, config.Id, null, false); + + Assert.Null(result.ScheduleCron); + Assert.Null(result.NextRotationAt); + await sutProvider.GetDependency().Received(1).ReplaceAsync(Arg.Is(c => + c.Id == config.Id && c.ScheduleCron == null && c.NextRotationAt == null)); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_HappyPath_EmitsAttemptThenOutcome(Guid actingUserId, PamRotationConfig config, string cron) + { + var sutProvider = Setup(); + sutProvider.GetDependency().GetByIdAsync(config.Id).Returns(config); + + await sutProvider.Sut.UpdateAsync(config.OrganizationId, actingUserId, config.Id, cron, false); + + var emitter = sutProvider.GetDependency(); + await emitter.Received(1).EmitAsync(Arg.Is(e => + e.Kind == AccessAuditEventKind.RotationSettingsUpdated && e.Phase == AccessAuditEventPhase.Attempt + && e.RotationConfigId == config.Id)); + await emitter.Received(1).EmitAsync(Arg.Is(e => + e.Kind == AccessAuditEventKind.RotationSettingsUpdated && e.Phase == AccessAuditEventPhase.Outcome + && e.RotationConfigId == config.Id)); + } + + private static SutProvider Setup() + { + var sutProvider = new SutProvider().WithFakeTimeProvider().Create(); + sutProvider.GetDependency().SetUtcNow(_now); + sutProvider.GetDependency>().Value.Returns(new PamRotationOptions()); + return sutProvider; + } +} diff --git a/bitwarden_license/test/Services/Pam.Test/Rotation/Commands/UpdateTargetSystemPolicyCommandTests.cs b/bitwarden_license/test/Services/Pam.Test/Rotation/Commands/UpdateTargetSystemPolicyCommandTests.cs new file mode 100644 index 000000000000..e99e9ba7e638 --- /dev/null +++ b/bitwarden_license/test/Services/Pam.Test/Rotation/Commands/UpdateTargetSystemPolicyCommandTests.cs @@ -0,0 +1,137 @@ +using Bit.Core.Exceptions; +using Bit.Pam.Entities; +using Bit.Pam.Enums; +using Bit.Pam.Models; +using Bit.Pam.Repositories; +using Bit.Pam.Services; +using Bit.Services.Pam.Rotation.Commands; +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.Rotation.Commands; + +[SutProviderCustomize] +public class UpdateTargetSystemPolicyCommandTests +{ + private static readonly DateTime _now = new(2026, 7, 6, 12, 0, 0, DateTimeKind.Utc); + + private static readonly PamPasswordPolicy _policy = new() { MinLength = 12, MaxLength = 64, IncludeDigits = true }; + + [Theory, BitAutoData] + public async Task UpdateAsync_TargetMissing_ThrowsNotFound(Guid organizationId, Guid actingUserId, Guid targetSystemId) + { + var sutProvider = Setup(); + sutProvider.GetDependency().GetByIdAsync(targetSystemId).Returns((PamTargetSystem?)null); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.UpdateAsync(organizationId, actingUserId, targetSystemId, _policy, true)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().ReplaceAsync(default!); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_WrongOrg_ThrowsNotFound(Guid actingUserId, PamTargetSystem target) + { + var sutProvider = Setup(); + sutProvider.GetDependency().GetByIdAsync(target.Id).Returns(target); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.UpdateAsync(Guid.NewGuid(), actingUserId, target.Id, _policy, true)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().ReplaceAsync(default!); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_ManualTarget_ThrowsBadRequest(Guid actingUserId, PamTargetSystem target) + { + var sutProvider = Setup(); + target.Method = PamTargetSystemMethod.Manual; + sutProvider.GetDependency().GetByIdAsync(target.Id).Returns(target); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.UpdateAsync(target.OrganizationId, actingUserId, target.Id, _policy, true)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().ReplaceAsync(default!); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_WithdrawingTerminationWhileConfigRequiresIt_ThrowsBadRequest( + Guid actingUserId, PamTargetSystem target) + { + var sutProvider = Setup(); + target.Method = PamTargetSystemMethod.Automatic; + target.SupportsSessionTermination = true; + sutProvider.GetDependency().GetByIdAsync(target.Id).Returns(target); + sutProvider.GetDependency() + .AnyByTargetSystemWithTerminateSessionsAsync(target.Id).Returns(true); + + // Withdrawing (true -> false) while a config on this target still requires termination must be rejected. + await Assert.ThrowsAsync( + () => sutProvider.Sut.UpdateAsync(target.OrganizationId, actingUserId, target.Id, _policy, false)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().ReplaceAsync(default!); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_WithdrawingTerminationWhenNoConfigRequiresIt_Succeeds( + Guid actingUserId, PamTargetSystem target) + { + var sutProvider = Setup(); + target.Method = PamTargetSystemMethod.Automatic; + target.SupportsSessionTermination = true; + sutProvider.GetDependency().GetByIdAsync(target.Id).Returns(target); + sutProvider.GetDependency() + .AnyByTargetSystemWithTerminateSessionsAsync(target.Id).Returns(false); + + await sutProvider.Sut.UpdateAsync(target.OrganizationId, actingUserId, target.Id, _policy, false); + + await sutProvider.GetDependency().Received(1).ReplaceAsync(Arg.Is(t => + t.Id == target.Id && t.SupportsSessionTermination == false)); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_HappyPath_UpdatesPolicyAndTermination(Guid actingUserId, PamTargetSystem target) + { + var sutProvider = Setup(); + target.Method = PamTargetSystemMethod.Automatic; + target.SupportsSessionTermination = false; + sutProvider.GetDependency().GetByIdAsync(target.Id).Returns(target); + + await sutProvider.Sut.UpdateAsync(target.OrganizationId, actingUserId, target.Id, _policy, true); + + await sutProvider.GetDependency().Received(1).ReplaceAsync(Arg.Is(t => + t.Id == target.Id + && t.PasswordPolicy == PamPasswordPolicy.Serialize(_policy) + && t.SupportsSessionTermination == true + && t.RevisionDate == _now)); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_HappyPath_EmitsAttemptThenOutcome(Guid actingUserId, PamTargetSystem target) + { + var sutProvider = Setup(); + target.Method = PamTargetSystemMethod.Automatic; + target.SupportsSessionTermination = false; + sutProvider.GetDependency().GetByIdAsync(target.Id).Returns(target); + + await sutProvider.Sut.UpdateAsync(target.OrganizationId, actingUserId, target.Id, _policy, true); + + var emitter = sutProvider.GetDependency(); + await emitter.Received(1).EmitAsync(Arg.Is(e => + e.Kind == AccessAuditEventKind.TargetSystemPolicyUpdated && e.Phase == AccessAuditEventPhase.Attempt + && e.TargetSystemId == target.Id)); + await emitter.Received(1).EmitAsync(Arg.Is(e => + e.Kind == AccessAuditEventKind.TargetSystemPolicyUpdated && e.Phase == AccessAuditEventPhase.Outcome + && 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/Rotation/Jobs/PamLeaseExpirySweepServiceTests.cs b/bitwarden_license/test/Services/Pam.Test/Rotation/Jobs/PamLeaseExpirySweepServiceTests.cs new file mode 100644 index 000000000000..90420792e1c9 --- /dev/null +++ b/bitwarden_license/test/Services/Pam.Test/Rotation/Jobs/PamLeaseExpirySweepServiceTests.cs @@ -0,0 +1,85 @@ +using Bit.Pam.Enums; +using Bit.Pam.Models; +using Bit.Pam.Repositories; +using Bit.Pam.Services; +using Bit.Services.Pam.Rotation.Commands.Interfaces; +using Bit.Services.Pam.Rotation.Jobs; +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.Rotation.Jobs; + +[SutProviderCustomize] +public class PamLeaseExpirySweepServiceTests +{ + private static readonly DateTime _now = new(2026, 6, 5, 12, 0, 0, DateTimeKind.Utc); + + [Fact] + public async Task SweepAsync_PerExpiredLease_EmitsLeaseExpiredAuditAndCallsHandleAccessGrantEnded() + { + var sutProvider = Setup(); + var lease1 = MakeExpiredLease(); + var lease2 = MakeExpiredLease(); + sutProvider.GetDependency().ExpireDueAsync(_now) + .Returns(new List { lease1, lease2 }); + + await sutProvider.Sut.SweepAsync(); + + foreach (var lease in new[] { lease1, lease2 }) + { + await sutProvider.GetDependency().Received(1).EmitAsync( + Arg.Is(a => a.Kind == AccessAuditEventKind.LeaseExpired + && a.OrganizationId == lease.OrganizationId + && a.ActorId == null + && a.RequesterId == lease.RequesterId + && a.CollectionId == lease.CollectionId + && a.CipherId == lease.CipherId + && a.AccessLeaseId == lease.Id + && a.LeaseNotBefore == lease.NotBefore + && a.LeaseNotAfter == lease.NotAfter)); + await sutProvider.GetDependency().Received(1) + .HandleAsync(lease.CipherId); + } + } + + [Fact] + public async Task SweepAsync_OneLeaseThrows_ContinuesWithOthers() + { + var sutProvider = Setup(); + var lease1 = MakeExpiredLease(); + var lease2 = MakeExpiredLease(); + sutProvider.GetDependency().ExpireDueAsync(_now) + .Returns(new List { lease1, lease2 }); + sutProvider.GetDependency().HandleAsync(lease1.CipherId) + .Returns(Task.FromException(new InvalidOperationException("boom"))); + + // lease1's failure (raised from the HandleAsync call the sweep awaits after its own audit emit) must be + // logged and swallowed per-lease, never preventing lease2 from being processed. + await sutProvider.Sut.SweepAsync(); + + await sutProvider.GetDependency().Received(1).EmitAsync( + Arg.Is(a => a.AccessLeaseId == lease2.Id)); + await sutProvider.GetDependency().Received(1).HandleAsync(lease2.CipherId); + } + + private static SutProvider Setup() + { + var sutProvider = new SutProvider().WithFakeTimeProvider().Create(); + sutProvider.GetDependency().SetUtcNow(_now); + return sutProvider; + } + + private static PamExpiredLease MakeExpiredLease() => new() + { + Id = Guid.NewGuid(), + OrganizationId = Guid.NewGuid(), + CollectionId = Guid.NewGuid(), + CipherId = Guid.NewGuid(), + RequesterId = Guid.NewGuid(), + NotBefore = _now.AddHours(-2), + NotAfter = _now.AddMinutes(-1), + }; +} diff --git a/bitwarden_license/test/Services/Pam.Test/Rotation/Jobs/PamRotationSweepServiceTests.cs b/bitwarden_license/test/Services/Pam.Test/Rotation/Jobs/PamRotationSweepServiceTests.cs new file mode 100644 index 000000000000..aecc3c316dbe --- /dev/null +++ b/bitwarden_license/test/Services/Pam.Test/Rotation/Jobs/PamRotationSweepServiceTests.cs @@ -0,0 +1,164 @@ +using Bit.Pam.Entities; +using Bit.Pam.Enums; +using Bit.Pam.Models; +using Bit.Pam.Repositories; +using Bit.Pam.Services; +using Bit.Services.Pam.Rotation; +using Bit.Services.Pam.Rotation.Commands.Interfaces; +using Bit.Services.Pam.Rotation.Jobs; +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.Rotation.Jobs; + +[SutProviderCustomize] +public class PamRotationSweepServiceTests +{ + private static readonly DateTime _now = new(2026, 6, 5, 12, 0, 0, DateTimeKind.Utc); + private static readonly TimeSpan _failureRetryDelay = TimeSpan.FromHours(1); + + [Theory, BitAutoData] + public async Task SweepAsync_DuePhase_OffersEachDueConfig(PamRotationConfig config1, PamRotationConfig config2) + { + var sutProvider = Setup(); + sutProvider.GetDependency().GetManyDueAsync(_now) + .Returns(new List { config1, config2 }); + + await sutProvider.Sut.SweepAsync(); + + await sutProvider.GetDependency().Received(1) + .OfferAsync(config1.Id, PamRotationSource.Scheduled); + await sutProvider.GetDependency().Received(1) + .OfferAsync(config2.Id, PamRotationSource.Scheduled); + } + + [Theory, BitAutoData] + public async Task SweepAsync_DuePhase_OneConfigThrows_ContinuesWithOthers( + PamRotationConfig config1, PamRotationConfig config2) + { + var sutProvider = Setup(); + sutProvider.GetDependency().GetManyDueAsync(_now) + .Returns(new List { config1, config2 }); + sutProvider.GetDependency().OfferAsync(config1.Id, PamRotationSource.Scheduled) + .Returns(Task.FromException(new InvalidOperationException("boom"))); + + // The due phase's own try/catch (plus the outer RunPhaseAsync) must swallow config1's failure and still + // process config2 -- and never let the phase's exception escape SweepAsync itself. + await sutProvider.Sut.SweepAsync(); + + await sutProvider.GetDependency().Received(1) + .OfferAsync(config2.Id, PamRotationSource.Scheduled); + } + + [Theory, BitAutoData] + public async Task SweepAsync_TimeoutPhase_UnroutableJob_ReschedulesConfigAndEmitsAuditWithUnroutableDetail( + PamRotationConfig config) + { + var sutProvider = Setup(); + var timedOutJob = new PamTimedOutJob + { + JobId = Guid.NewGuid(), + RotationConfigId = config.Id, + OrganizationId = config.OrganizationId, + CipherId = config.CipherId, + Source = PamRotationSource.Scheduled, + ClaimedByDaemonId = null, + AttemptCount = 0, + }; + sutProvider.GetDependency().TimeoutDueAsync(_now) + .Returns(new List { timedOutJob }); + sutProvider.GetDependency().GetByIdAsync(config.Id).Returns(config); + + await sutProvider.Sut.SweepAsync(); + + await sutProvider.GetDependency().Received(1).ReplaceAsync( + Arg.Is(c => c.Id == config.Id && c.NextRotationAt == _now + _failureRetryDelay)); + await sutProvider.GetDependency().Received(1).EmitAsync( + Arg.Is(a => a.Kind == AccessAuditEventKind.RotationJobTimedOut + && a.RotationJobId == timedOutJob.JobId + && a.RotationConfigId == config.Id + && a.Detail!.Contains("unroutable"))); + } + + [Theory, BitAutoData] + public async Task SweepAsync_TimeoutPhase_StuckJob_EmitsAuditWithStuckDetail( + PamRotationConfig config, Guid claimedByDaemonId) + { + var sutProvider = Setup(); + var timedOutJob = new PamTimedOutJob + { + JobId = Guid.NewGuid(), + RotationConfigId = config.Id, + OrganizationId = config.OrganizationId, + CipherId = config.CipherId, + Source = PamRotationSource.Scheduled, + ClaimedByDaemonId = claimedByDaemonId, + AttemptCount = 3, + }; + sutProvider.GetDependency().TimeoutDueAsync(_now) + .Returns(new List { timedOutJob }); + sutProvider.GetDependency().GetByIdAsync(config.Id).Returns(config); + + await sutProvider.Sut.SweepAsync(); + + await sutProvider.GetDependency().Received(1).EmitAsync( + Arg.Is(a => a.Kind == AccessAuditEventKind.RotationJobTimedOut + && a.DaemonId == claimedByDaemonId + && a.Detail!.Contains("stuck"))); + } + + [Theory, BitAutoData] + public async Task SweepAsync_ReleasePhase_EmitsReleasedAuditPerRow(Guid daemonId1, Guid daemonId2) + { + var sutProvider = Setup(); + var released1 = new PamReleasedJob + { + JobId = Guid.NewGuid(), + RotationConfigId = Guid.NewGuid(), + OrganizationId = Guid.NewGuid(), + CipherId = Guid.NewGuid(), + Source = PamRotationSource.Scheduled, + ClaimedByDaemonId = daemonId1, + }; + var released2 = new PamReleasedJob + { + JobId = Guid.NewGuid(), + RotationConfigId = Guid.NewGuid(), + OrganizationId = Guid.NewGuid(), + CipherId = Guid.NewGuid(), + Source = PamRotationSource.OnDemand, + ClaimedByDaemonId = daemonId2, + }; + sutProvider.GetDependency() + .ReleaseExpiredLeasesAsync(_now, Arg.Any(), Arg.Any()) + .Returns(new List { released1, released2 }); + + await sutProvider.Sut.SweepAsync(); + + await sutProvider.GetDependency().Received(1).EmitAsync( + Arg.Is(a => a.Kind == AccessAuditEventKind.RotationJobReleased + && a.RotationJobId == released1.JobId + && a.RotationConfigId == released1.RotationConfigId + && a.OrganizationId == released1.OrganizationId + && a.CipherId == released1.CipherId + && a.RotationSource == released1.Source + && a.DaemonId == released1.ClaimedByDaemonId)); + await sutProvider.GetDependency().Received(1).EmitAsync( + Arg.Is(a => a.Kind == AccessAuditEventKind.RotationJobReleased + && a.RotationJobId == released2.JobId + && a.DaemonId == released2.ClaimedByDaemonId)); + } + + private static SutProvider Setup() + { + var sutProvider = new SutProvider().WithFakeTimeProvider().Create(); + sutProvider.GetDependency().SetUtcNow(_now); + sutProvider.GetDependency>().Value + .Returns(new PamRotationOptions { FailureRetryDelay = _failureRetryDelay }); + return sutProvider; + } +} diff --git a/bitwarden_license/test/Services/Pam.Test/Rotation/Services/RotationScheduleCalculatorTests.cs b/bitwarden_license/test/Services/Pam.Test/Rotation/Services/RotationScheduleCalculatorTests.cs new file mode 100644 index 000000000000..93f5f96fbf9f --- /dev/null +++ b/bitwarden_license/test/Services/Pam.Test/Rotation/Services/RotationScheduleCalculatorTests.cs @@ -0,0 +1,112 @@ +using Bit.Core.Exceptions; +using Bit.Services.Pam.Rotation; +using Xunit; + +namespace Bit.Services.Pam.Test.Rotation.Services; + +public class RotationScheduleCalculatorTests +{ + private static readonly DateTime _afterUtc = new(2026, 7, 6, 12, 0, 0, DateTimeKind.Utc); + + private readonly RotationScheduleCalculator _sut = new(); + + [Fact] + public void GetNextOccurrence_ValidSixFieldCron_ReturnsNextOccurrence() + { + // Every 15 minutes; after 12:07 the next run is 12:15. A quarter-hour cadence is time-zone-proof (every + // real-world UTC offset is a multiple of 15 minutes), unlike a day-anchored cron -- see the skipped test + // below. + var after = new DateTime(2026, 7, 6, 12, 7, 0, DateTimeKind.Utc); + + var next = _sut.GetNextOccurrence("0 0/15 * * * ?", after); + + Assert.Equal(new DateTime(2026, 7, 6, 12, 15, 0, DateTimeKind.Utc), next); + } + + [Fact] + public void GetNextOccurrence_AtExactOccurrence_ReturnsStrictlyAfter() + { + var atQuarterHour = new DateTime(2026, 7, 6, 12, 15, 0, DateTimeKind.Utc); + + var next = _sut.GetNextOccurrence("0 0/15 * * * ?", atQuarterHour); + + Assert.Equal(new DateTime(2026, 7, 6, 12, 30, 0, DateTimeKind.Utc), next); + } + + [Fact] + public void GetNextOccurrence_DayAnchoredCron_IsEvaluatedInUtc() + { + // Daily at 03:00 UTC; after 2026-07-06 12:00 UTC the next run should be 03:00 UTC the following day. + var next = _sut.GetNextOccurrence("0 0 3 * * ?", _afterUtc); + + Assert.Equal(new DateTime(2026, 7, 7, 3, 0, 0, DateTimeKind.Utc), next); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void GetNextOccurrence_NullOrWhitespaceCron_ReturnsNull(string? cron) + { + Assert.Null(_sut.GetNextOccurrence(cron, _afterUtc)); + } + + [Theory] + [InlineData("not a cron")] + [InlineData("* * * *")] + public void GetNextOccurrence_InvalidCron_ThrowsBadRequest(string cron) + { + Assert.Throws(() => _sut.GetNextOccurrence(cron, _afterUtc)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void ValidateSchedule_NullOrWhitespaceCron_IsValid(string? cron) + { + // Null means "no scheduled rotation" and is always accepted. + _sut.ValidateSchedule(cron, TimeSpan.FromMinutes(15)); + } + + [Theory] + [InlineData("not a cron")] + [InlineData("* * * *")] + public void ValidateSchedule_InvalidCron_ThrowsBadRequest(string cron) + { + Assert.Throws(() => _sut.ValidateSchedule(cron, TimeSpan.FromMinutes(15))); + } + + [Fact] + public void ValidateSchedule_SubFloorCadence_ThrowsBadRequest() + { + // Every minute is below the 15-minute floor. + var ex = Assert.Throws( + () => _sut.ValidateSchedule("0 * * * * ?", TimeSpan.FromMinutes(15))); + + Assert.Contains("15", ex.Message); + } + + [Fact] + public void ValidateSchedule_CadenceAtOrAboveFloor_IsValid() + { + // Daily at 03:00 clears a 15-minute floor easily. + _sut.ValidateSchedule("0 0 3 * * ?", TimeSpan.FromMinutes(15)); + } + + [Fact] + public void ValidateSchedule_CadenceExactlyAtFloor_IsValid() + { + // Every 15 minutes meets a 15-minute floor exactly (the guard is strictly-less-than). + _sut.ValidateSchedule("0 0/15 * * * ?", TimeSpan.FromMinutes(15)); + } + + [Fact] + public void ValidateSchedule_ScheduleThatNeverOccurs_ThrowsBadRequest() + { + // A 7-field Quartz cron pinned to a year in the past never fires again; the floor cannot be checked, so it + // is rejected the same way a too-frequent schedule is. + Assert.Throws( + () => _sut.ValidateSchedule("0 0 0 1 1 ? 2001", TimeSpan.FromMinutes(15))); + } +} diff --git a/bitwarden_license/test/Sso.IntegrationTest/packages.lock.json b/bitwarden_license/test/Sso.IntegrationTest/packages.lock.json index 47f721714bc8..28885e0777e0 100644 --- a/bitwarden_license/test/Sso.IntegrationTest/packages.lock.json +++ b/bitwarden_license/test/Sso.IntegrationTest/packages.lock.json @@ -1316,7 +1316,7 @@ "dependencies": { "AutoFixture.AutoNSubstitute": "[4.18.1, )", "AutoFixture.Xunit2": "[4.18.1, )", - "Core": "[2026.6.1, )", + "Core": "[2026.6.2, )", "Kralizek.AutoFixture.Extensions.MockHttp": "[2.2.1, 2.2.1]", "Microsoft.Extensions.TimeProvider.Testing": "[10.6.0, 10.6.0]", "Microsoft.NET.Test.Sdk": "[18.0.1, )", @@ -1340,7 +1340,7 @@ "BitPay.Light": "[1.0.1907, 1.0.1907]", "Braintree": "[5.36.0, 5.36.0]", "CsvHelper": "[33.1.0, 33.1.0]", - "Data": "[2026.6.1, )", + "Data": "[2026.6.2, )", "DnsClient": "[1.8.0, 1.8.0]", "Duende.IdentityServer": "[7.4.6, 7.4.6]", "DuoUniversal": "[1.3.1, 1.3.1]", @@ -1365,7 +1365,7 @@ "Newtonsoft.Json": "[13.0.3, 13.0.3]", "OneOf": "[3.0.271, 3.0.271]", "Otp.NET": "[1.4.0, 1.4.0]", - "Pam.Domain": "[2026.6.1, )", + "Pam.Domain": "[2026.6.2, )", "Quartz": "[3.15.1, 3.15.1]", "Quartz.Extensions.DependencyInjection": "[3.15.1, 3.15.1]", "Quartz.Extensions.Hosting": "[3.15.1, 3.15.1]", @@ -1385,7 +1385,7 @@ "identity": { "type": "Project", "dependencies": { - "Core": "[2026.6.1, )", + "Core": "[2026.6.2, )", "OpenTelemetry.Exporter.OpenTelemetryProtocol": "[1.15.3, )", "OpenTelemetry.Extensions.Hosting": "[1.15.3, )", "OpenTelemetry.Instrumentation.AspNetCore": "[1.15.0, )", @@ -1393,27 +1393,28 @@ "OpenTelemetry.Instrumentation.Http": "[1.15.0, )", "OpenTelemetry.Instrumentation.Runtime": "[1.15.0, )", "OpenTelemetry.Instrumentation.SqlClient": "[1.12.0-beta.3, )", - "SharedWeb": "[2026.6.1, )" + "Pam.Domain": "[2026.6.2, )", + "SharedWeb": "[2026.6.2, )" } }, "infrastructure.dapper": { "type": "Project", "dependencies": { - "Core": "[2026.6.1, )", + "Core": "[2026.6.2, )", "Dapper": "[2.1.66, 2.1.66]", - "Pam.Domain": "[2026.6.1, )" + "Pam.Domain": "[2026.6.2, )" } }, "infrastructure.entityframework": { "type": "Project", "dependencies": { "AutoMapper": "[14.0.0, 14.0.0]", - "Core": "[2026.6.1, )", + "Core": "[2026.6.2, )", "Microsoft.EntityFrameworkCore.Relational": "[8.0.8, 8.0.8]", "Microsoft.EntityFrameworkCore.SqlServer": "[8.0.8, 8.0.8]", "Microsoft.EntityFrameworkCore.Sqlite": "[8.0.8, 8.0.8]", "Npgsql.EntityFrameworkCore.PostgreSQL": "[8.0.4, 8.0.4]", - "Pam.Domain": "[2026.6.1, )", + "Pam.Domain": "[2026.6.2, )", "Pomelo.EntityFrameworkCore.MySql": "[8.0.2, 8.0.2]", "linq2db": "[5.4.1, 5.4.1]", "linq2db.EntityFrameworkCore": "[8.1.0, 8.1.0]" @@ -1422,17 +1423,17 @@ "integrationtestcommon": { "type": "Project", "dependencies": { - "Common": "[2026.6.1, )", - "Identity": "[2026.6.1, )", + "Common": "[2026.6.2, )", + "Identity": "[2026.6.2, )", "Microsoft.AspNetCore.Mvc.Testing": "[10.0.8, 10.0.8]", - "Migrator": "[2026.6.1, )", - "Seeder": "[2026.6.1, )" + "Migrator": "[2026.6.2, )", + "Seeder": "[2026.6.2, )" } }, "migrator": { "type": "Project", "dependencies": { - "Core": "[2026.6.1, )", + "Core": "[2026.6.2, )", "Microsoft.Extensions.Logging": "[10.0.8, 10.0.8]", "dbup-sqlserver": "[7.2.0, 7.2.0]" } @@ -1440,7 +1441,7 @@ "pam.domain": { "type": "Project", "dependencies": { - "Data": "[2026.6.1, )" + "Data": "[2026.6.2, )" } }, "rustsdk": { @@ -1450,18 +1451,18 @@ "type": "Project", "dependencies": { "Bogus": "[35.6.5, 35.6.5]", - "Core": "[2026.6.1, )", - "Infrastructure.EntityFramework": "[2026.6.1, )", - "RustSdk": "[2026.6.1, )", - "SharedWeb": "[2026.6.1, )" + "Core": "[2026.6.2, )", + "Infrastructure.EntityFramework": "[2026.6.2, )", + "RustSdk": "[2026.6.2, )", + "SharedWeb": "[2026.6.2, )" } }, "sharedweb": { "type": "Project", "dependencies": { - "Core": "[2026.6.1, )", - "Infrastructure.Dapper": "[2026.6.1, )", - "Infrastructure.EntityFramework": "[2026.6.1, )", + "Core": "[2026.6.2, )", + "Infrastructure.Dapper": "[2026.6.2, )", + "Infrastructure.EntityFramework": "[2026.6.2, )", "Microsoft.Bot.Builder.Integration.AspNet.Core": "[4.23.0, 4.23.0]", "Swashbuckle.AspNetCore.SwaggerGen": "[10.1.7, 10.1.7]" } @@ -1469,7 +1470,7 @@ "sso": { "type": "Project", "dependencies": { - "Core": "[2026.6.1, )", + "Core": "[2026.6.2, )", "OpenTelemetry.Exporter.OpenTelemetryProtocol": "[1.15.3, )", "OpenTelemetry.Extensions.Hosting": "[1.15.3, )", "OpenTelemetry.Instrumentation.AspNetCore": "[1.15.0, )", @@ -1477,7 +1478,7 @@ "OpenTelemetry.Instrumentation.Http": "[1.15.0, )", "OpenTelemetry.Instrumentation.Runtime": "[1.15.0, )", "OpenTelemetry.Instrumentation.SqlClient": "[1.12.0-beta.3, )", - "SharedWeb": "[2026.6.1, )", + "SharedWeb": "[2026.6.2, )", "Sustainsys.Saml2.AspNetCore2": "[2.11.0, 2.11.0]" } } diff --git a/perf/MicroBenchmarks/packages.lock.json b/perf/MicroBenchmarks/packages.lock.json index ef28435b5174..b40832211327 100644 --- a/perf/MicroBenchmarks/packages.lock.json +++ b/perf/MicroBenchmarks/packages.lock.json @@ -1555,7 +1555,7 @@ "BitPay.Light": "[1.0.1907, 1.0.1907]", "Braintree": "[5.36.0, 5.36.0]", "CsvHelper": "[33.1.0, 33.1.0]", - "Data": "[2026.6.1, )", + "Data": "[2026.6.2, )", "DnsClient": "[1.8.0, 1.8.0]", "Duende.IdentityServer": "[7.4.6, 7.4.6]", "DuoUniversal": "[1.3.1, 1.3.1]", @@ -1580,7 +1580,7 @@ "Newtonsoft.Json": "[13.0.3, 13.0.3]", "OneOf": "[3.0.271, 3.0.271]", "Otp.NET": "[1.4.0, 1.4.0]", - "Pam.Domain": "[2026.6.1, )", + "Pam.Domain": "[2026.6.2, )", "Quartz": "[3.15.1, 3.15.1]", "Quartz.Extensions.DependencyInjection": "[3.15.1, 3.15.1]", "Quartz.Extensions.Hosting": "[3.15.1, 3.15.1]", @@ -1600,7 +1600,7 @@ "identity": { "type": "Project", "dependencies": { - "Core": "[2026.6.1, )", + "Core": "[2026.6.2, )", "OpenTelemetry.Exporter.OpenTelemetryProtocol": "[1.15.3, )", "OpenTelemetry.Extensions.Hosting": "[1.15.3, )", "OpenTelemetry.Instrumentation.AspNetCore": "[1.15.0, )", @@ -1608,27 +1608,28 @@ "OpenTelemetry.Instrumentation.Http": "[1.15.0, )", "OpenTelemetry.Instrumentation.Runtime": "[1.15.0, )", "OpenTelemetry.Instrumentation.SqlClient": "[1.12.0-beta.3, )", - "SharedWeb": "[2026.6.1, )" + "Pam.Domain": "[2026.6.2, )", + "SharedWeb": "[2026.6.2, )" } }, "infrastructure.dapper": { "type": "Project", "dependencies": { - "Core": "[2026.6.1, )", + "Core": "[2026.6.2, )", "Dapper": "[2.1.66, 2.1.66]", - "Pam.Domain": "[2026.6.1, )" + "Pam.Domain": "[2026.6.2, )" } }, "infrastructure.entityframework": { "type": "Project", "dependencies": { "AutoMapper": "[14.0.0, 14.0.0]", - "Core": "[2026.6.1, )", + "Core": "[2026.6.2, )", "Microsoft.EntityFrameworkCore.Relational": "[8.0.8, 8.0.8]", "Microsoft.EntityFrameworkCore.SqlServer": "[8.0.8, 8.0.8]", "Microsoft.EntityFrameworkCore.Sqlite": "[8.0.8, 8.0.8]", "Npgsql.EntityFrameworkCore.PostgreSQL": "[8.0.4, 8.0.4]", - "Pam.Domain": "[2026.6.1, )", + "Pam.Domain": "[2026.6.2, )", "Pomelo.EntityFrameworkCore.MySql": "[8.0.2, 8.0.2]", "linq2db": "[5.4.1, 5.4.1]", "linq2db.EntityFrameworkCore": "[8.1.0, 8.1.0]" @@ -1637,15 +1638,15 @@ "pam.domain": { "type": "Project", "dependencies": { - "Data": "[2026.6.1, )" + "Data": "[2026.6.2, )" } }, "sharedweb": { "type": "Project", "dependencies": { - "Core": "[2026.6.1, )", - "Infrastructure.Dapper": "[2026.6.1, )", - "Infrastructure.EntityFramework": "[2026.6.1, )", + "Core": "[2026.6.2, )", + "Infrastructure.Dapper": "[2026.6.2, )", + "Infrastructure.EntityFramework": "[2026.6.2, )", "Microsoft.Bot.Builder.Integration.AspNet.Core": "[4.23.0, 4.23.0]", "Swashbuckle.AspNetCore.SwaggerGen": "[10.1.7, 10.1.7]" } diff --git a/test/Api.IntegrationTest/packages.lock.json b/test/Api.IntegrationTest/packages.lock.json index 3bf5bfa0f19d..b0dbc2074360 100644 --- a/test/Api.IntegrationTest/packages.lock.json +++ b/test/Api.IntegrationTest/packages.lock.json @@ -1466,6 +1466,7 @@ "OpenTelemetry.Instrumentation.Http": "[1.15.0, )", "OpenTelemetry.Instrumentation.Runtime": "[1.15.0, )", "OpenTelemetry.Instrumentation.SqlClient": "[1.12.0-beta.3, )", + "Pam.Domain": "[2026.6.2, )", "SharedWeb": "[2026.6.2, )" } }, diff --git a/test/Billing.IntegrationTest/packages.lock.json b/test/Billing.IntegrationTest/packages.lock.json index a6f10700abec..8bf0f9b917e4 100644 --- a/test/Billing.IntegrationTest/packages.lock.json +++ b/test/Billing.IntegrationTest/packages.lock.json @@ -1992,6 +1992,7 @@ "OpenTelemetry.Instrumentation.Http": "[1.15.0, )", "OpenTelemetry.Instrumentation.Runtime": "[1.15.0, )", "OpenTelemetry.Instrumentation.SqlClient": "[1.12.0-beta.3, )", + "Pam.Domain": "[2026.6.2, )", "SharedWeb": "[2026.6.2, )" } }, diff --git a/test/Events.IntegrationTest/packages.lock.json b/test/Events.IntegrationTest/packages.lock.json index bbdd5faca7d8..fc72c5560019 100644 --- a/test/Events.IntegrationTest/packages.lock.json +++ b/test/Events.IntegrationTest/packages.lock.json @@ -1713,7 +1713,7 @@ "dependencies": { "AutoFixture.AutoNSubstitute": "[4.18.1, )", "AutoFixture.Xunit2": "[4.18.1, )", - "Core": "[2026.6.1, )", + "Core": "[2026.6.2, )", "Kralizek.AutoFixture.Extensions.MockHttp": "[2.2.1, 2.2.1]", "Microsoft.Extensions.TimeProvider.Testing": "[10.6.0, 10.6.0]", "Microsoft.NET.Test.Sdk": "[18.0.1, )", @@ -1737,7 +1737,7 @@ "BitPay.Light": "[1.0.1907, 1.0.1907]", "Braintree": "[5.36.0, 5.36.0]", "CsvHelper": "[33.1.0, 33.1.0]", - "Data": "[2026.6.1, )", + "Data": "[2026.6.2, )", "DnsClient": "[1.8.0, 1.8.0]", "Duende.IdentityServer": "[7.4.6, 7.4.6]", "DuoUniversal": "[1.3.1, 1.3.1]", @@ -1762,7 +1762,7 @@ "Newtonsoft.Json": "[13.0.3, 13.0.3]", "OneOf": "[3.0.271, 3.0.271]", "Otp.NET": "[1.4.0, 1.4.0]", - "Pam.Domain": "[2026.6.1, )", + "Pam.Domain": "[2026.6.2, )", "Quartz": "[3.15.1, 3.15.1]", "Quartz.Extensions.DependencyInjection": "[3.15.1, 3.15.1]", "Quartz.Extensions.Hosting": "[3.15.1, 3.15.1]", @@ -1782,7 +1782,7 @@ "events": { "type": "Project", "dependencies": { - "Core": "[2026.6.1, )", + "Core": "[2026.6.2, )", "OpenTelemetry.Exporter.OpenTelemetryProtocol": "[1.15.3, )", "OpenTelemetry.Extensions.Hosting": "[1.15.3, )", "OpenTelemetry.Instrumentation.AspNetCore": "[1.15.0, )", @@ -1790,13 +1790,13 @@ "OpenTelemetry.Instrumentation.Http": "[1.15.0, )", "OpenTelemetry.Instrumentation.Runtime": "[1.15.0, )", "OpenTelemetry.Instrumentation.SqlClient": "[1.12.0-beta.3, )", - "SharedWeb": "[2026.6.1, )" + "SharedWeb": "[2026.6.2, )" } }, "identity": { "type": "Project", "dependencies": { - "Core": "[2026.6.1, )", + "Core": "[2026.6.2, )", "OpenTelemetry.Exporter.OpenTelemetryProtocol": "[1.15.3, )", "OpenTelemetry.Extensions.Hosting": "[1.15.3, )", "OpenTelemetry.Instrumentation.AspNetCore": "[1.15.0, )", @@ -1804,27 +1804,28 @@ "OpenTelemetry.Instrumentation.Http": "[1.15.0, )", "OpenTelemetry.Instrumentation.Runtime": "[1.15.0, )", "OpenTelemetry.Instrumentation.SqlClient": "[1.12.0-beta.3, )", - "SharedWeb": "[2026.6.1, )" + "Pam.Domain": "[2026.6.2, )", + "SharedWeb": "[2026.6.2, )" } }, "infrastructure.dapper": { "type": "Project", "dependencies": { - "Core": "[2026.6.1, )", + "Core": "[2026.6.2, )", "Dapper": "[2.1.66, 2.1.66]", - "Pam.Domain": "[2026.6.1, )" + "Pam.Domain": "[2026.6.2, )" } }, "infrastructure.entityframework": { "type": "Project", "dependencies": { "AutoMapper": "[14.0.0, 14.0.0]", - "Core": "[2026.6.1, )", + "Core": "[2026.6.2, )", "Microsoft.EntityFrameworkCore.Relational": "[8.0.8, 8.0.8]", "Microsoft.EntityFrameworkCore.SqlServer": "[8.0.8, 8.0.8]", "Microsoft.EntityFrameworkCore.Sqlite": "[8.0.8, 8.0.8]", "Npgsql.EntityFrameworkCore.PostgreSQL": "[8.0.4, 8.0.4]", - "Pam.Domain": "[2026.6.1, )", + "Pam.Domain": "[2026.6.2, )", "Pomelo.EntityFrameworkCore.MySql": "[8.0.2, 8.0.2]", "linq2db": "[5.4.1, 5.4.1]", "linq2db.EntityFrameworkCore": "[8.1.0, 8.1.0]" @@ -1833,17 +1834,17 @@ "integrationtestcommon": { "type": "Project", "dependencies": { - "Common": "[2026.6.1, )", - "Identity": "[2026.6.1, )", + "Common": "[2026.6.2, )", + "Identity": "[2026.6.2, )", "Microsoft.AspNetCore.Mvc.Testing": "[10.0.8, 10.0.8]", - "Migrator": "[2026.6.1, )", - "Seeder": "[2026.6.1, )" + "Migrator": "[2026.6.2, )", + "Seeder": "[2026.6.2, )" } }, "migrator": { "type": "Project", "dependencies": { - "Core": "[2026.6.1, )", + "Core": "[2026.6.2, )", "Microsoft.Extensions.Logging": "[10.0.8, 10.0.8]", "dbup-sqlserver": "[7.2.0, 7.2.0]" } @@ -1851,7 +1852,7 @@ "pam.domain": { "type": "Project", "dependencies": { - "Data": "[2026.6.1, )" + "Data": "[2026.6.2, )" } }, "rustsdk": { @@ -1861,18 +1862,18 @@ "type": "Project", "dependencies": { "Bogus": "[35.6.5, 35.6.5]", - "Core": "[2026.6.1, )", - "Infrastructure.EntityFramework": "[2026.6.1, )", - "RustSdk": "[2026.6.1, )", - "SharedWeb": "[2026.6.1, )" + "Core": "[2026.6.2, )", + "Infrastructure.EntityFramework": "[2026.6.2, )", + "RustSdk": "[2026.6.2, )", + "SharedWeb": "[2026.6.2, )" } }, "sharedweb": { "type": "Project", "dependencies": { - "Core": "[2026.6.1, )", - "Infrastructure.Dapper": "[2026.6.1, )", - "Infrastructure.EntityFramework": "[2026.6.1, )", + "Core": "[2026.6.2, )", + "Infrastructure.Dapper": "[2026.6.2, )", + "Infrastructure.EntityFramework": "[2026.6.2, )", "Microsoft.Bot.Builder.Integration.AspNet.Core": "[4.23.0, 4.23.0]", "Swashbuckle.AspNetCore.SwaggerGen": "[10.1.7, 10.1.7]" } diff --git a/test/Identity.IntegrationTest/packages.lock.json b/test/Identity.IntegrationTest/packages.lock.json index a4772c536e20..8eb97cbdb811 100644 --- a/test/Identity.IntegrationTest/packages.lock.json +++ b/test/Identity.IntegrationTest/packages.lock.json @@ -1299,7 +1299,7 @@ "dependencies": { "AutoFixture.AutoNSubstitute": "[4.18.1, )", "AutoFixture.Xunit2": "[4.18.1, )", - "Core": "[2026.6.1, )", + "Core": "[2026.6.2, )", "Kralizek.AutoFixture.Extensions.MockHttp": "[2.2.1, 2.2.1]", "Microsoft.Extensions.TimeProvider.Testing": "[10.6.0, 10.6.0]", "Microsoft.NET.Test.Sdk": "[18.0.1, )", @@ -1323,7 +1323,7 @@ "BitPay.Light": "[1.0.1907, 1.0.1907]", "Braintree": "[5.36.0, 5.36.0]", "CsvHelper": "[33.1.0, 33.1.0]", - "Data": "[2026.6.1, )", + "Data": "[2026.6.2, )", "DnsClient": "[1.8.0, 1.8.0]", "Duende.IdentityServer": "[7.4.6, 7.4.6]", "DuoUniversal": "[1.3.1, 1.3.1]", @@ -1348,7 +1348,7 @@ "Newtonsoft.Json": "[13.0.3, 13.0.3]", "OneOf": "[3.0.271, 3.0.271]", "Otp.NET": "[1.4.0, 1.4.0]", - "Pam.Domain": "[2026.6.1, )", + "Pam.Domain": "[2026.6.2, )", "Quartz": "[3.15.1, 3.15.1]", "Quartz.Extensions.DependencyInjection": "[3.15.1, 3.15.1]", "Quartz.Extensions.Hosting": "[3.15.1, 3.15.1]", @@ -1367,13 +1367,13 @@ "dependencies": { "AutoFixture.AutoNSubstitute": "[4.18.1, )", "AutoFixture.Xunit2": "[4.18.1, )", - "Common": "[2026.6.1, )", - "Core": "[2026.6.1, )", + "Common": "[2026.6.2, )", + "Core": "[2026.6.2, )", "Kralizek.AutoFixture.Extensions.MockHttp": "[2.2.1, 2.2.1]", "Microsoft.Extensions.Diagnostics.Testing": "[10.6.0, 10.6.0]", "Microsoft.NET.Test.Sdk": "[18.0.1, )", "NSubstitute": "[5.1.0, )", - "Pam.Domain": "[2026.6.1, )", + "Pam.Domain": "[2026.6.2, )", "xunit": "[2.6.6, )" } }, @@ -1383,7 +1383,7 @@ "identity": { "type": "Project", "dependencies": { - "Core": "[2026.6.1, )", + "Core": "[2026.6.2, )", "OpenTelemetry.Exporter.OpenTelemetryProtocol": "[1.15.3, )", "OpenTelemetry.Extensions.Hosting": "[1.15.3, )", "OpenTelemetry.Instrumentation.AspNetCore": "[1.15.0, )", @@ -1391,27 +1391,28 @@ "OpenTelemetry.Instrumentation.Http": "[1.15.0, )", "OpenTelemetry.Instrumentation.Runtime": "[1.15.0, )", "OpenTelemetry.Instrumentation.SqlClient": "[1.12.0-beta.3, )", - "SharedWeb": "[2026.6.1, )" + "Pam.Domain": "[2026.6.2, )", + "SharedWeb": "[2026.6.2, )" } }, "infrastructure.dapper": { "type": "Project", "dependencies": { - "Core": "[2026.6.1, )", + "Core": "[2026.6.2, )", "Dapper": "[2.1.66, 2.1.66]", - "Pam.Domain": "[2026.6.1, )" + "Pam.Domain": "[2026.6.2, )" } }, "infrastructure.entityframework": { "type": "Project", "dependencies": { "AutoMapper": "[14.0.0, 14.0.0]", - "Core": "[2026.6.1, )", + "Core": "[2026.6.2, )", "Microsoft.EntityFrameworkCore.Relational": "[8.0.8, 8.0.8]", "Microsoft.EntityFrameworkCore.SqlServer": "[8.0.8, 8.0.8]", "Microsoft.EntityFrameworkCore.Sqlite": "[8.0.8, 8.0.8]", "Npgsql.EntityFrameworkCore.PostgreSQL": "[8.0.4, 8.0.4]", - "Pam.Domain": "[2026.6.1, )", + "Pam.Domain": "[2026.6.2, )", "Pomelo.EntityFrameworkCore.MySql": "[8.0.2, 8.0.2]", "linq2db": "[5.4.1, 5.4.1]", "linq2db.EntityFrameworkCore": "[8.1.0, 8.1.0]" @@ -1420,17 +1421,17 @@ "integrationtestcommon": { "type": "Project", "dependencies": { - "Common": "[2026.6.1, )", - "Identity": "[2026.6.1, )", + "Common": "[2026.6.2, )", + "Identity": "[2026.6.2, )", "Microsoft.AspNetCore.Mvc.Testing": "[10.0.8, 10.0.8]", - "Migrator": "[2026.6.1, )", - "Seeder": "[2026.6.1, )" + "Migrator": "[2026.6.2, )", + "Seeder": "[2026.6.2, )" } }, "migrator": { "type": "Project", "dependencies": { - "Core": "[2026.6.1, )", + "Core": "[2026.6.2, )", "Microsoft.Extensions.Logging": "[10.0.8, 10.0.8]", "dbup-sqlserver": "[7.2.0, 7.2.0]" } @@ -1438,7 +1439,7 @@ "pam.domain": { "type": "Project", "dependencies": { - "Data": "[2026.6.1, )" + "Data": "[2026.6.2, )" } }, "rustsdk": { @@ -1448,18 +1449,18 @@ "type": "Project", "dependencies": { "Bogus": "[35.6.5, 35.6.5]", - "Core": "[2026.6.1, )", - "Infrastructure.EntityFramework": "[2026.6.1, )", - "RustSdk": "[2026.6.1, )", - "SharedWeb": "[2026.6.1, )" + "Core": "[2026.6.2, )", + "Infrastructure.EntityFramework": "[2026.6.2, )", + "RustSdk": "[2026.6.2, )", + "SharedWeb": "[2026.6.2, )" } }, "sharedweb": { "type": "Project", "dependencies": { - "Core": "[2026.6.1, )", - "Infrastructure.Dapper": "[2026.6.1, )", - "Infrastructure.EntityFramework": "[2026.6.1, )", + "Core": "[2026.6.2, )", + "Infrastructure.Dapper": "[2026.6.2, )", + "Infrastructure.EntityFramework": "[2026.6.2, )", "Microsoft.Bot.Builder.Integration.AspNet.Core": "[4.23.0, 4.23.0]", "Swashbuckle.AspNetCore.SwaggerGen": "[10.1.7, 10.1.7]" } diff --git a/test/Identity.Test/IdentityServer/ClientProviders/PamDaemonClientProviderTests.cs b/test/Identity.Test/IdentityServer/ClientProviders/PamDaemonClientProviderTests.cs new file mode 100644 index 000000000000..f5dfe53bd881 --- /dev/null +++ b/test/Identity.Test/IdentityServer/ClientProviders/PamDaemonClientProviderTests.cs @@ -0,0 +1,179 @@ +using Bit.Core.Auth.Identity; +using Bit.Core.Auth.IdentityServer; +using Bit.Core.SecretsManager.Entities; +using Bit.Core.SecretsManager.Repositories; +using Bit.Identity.IdentityServer.ClientProviders; +using Bit.Pam.Entities; +using Bit.Pam.Enums; +using Bit.Pam.Models; +using Bit.Pam.Repositories; +using Duende.IdentityModel; +using Duende.IdentityServer.Models; +using NSubstitute; +using Xunit; + +namespace Bit.Identity.Test.IdentityServer.ClientProviders; + +public class PamDaemonClientProviderTests +{ + private readonly IApiKeyRepository _apiKeyRepository; + private readonly IPamDaemonRepository _pamDaemonRepository; + private readonly PamDaemonClientProvider _sut; + + public PamDaemonClientProviderTests() + { + _apiKeyRepository = Substitute.For(); + _pamDaemonRepository = Substitute.For(); + + _sut = new PamDaemonClientProvider(_apiKeyRepository, _pamDaemonRepository); + } + + [Fact] + public async Task GetAsync_NonGuidIdentifier_ReturnsNull() + { + var client = await _sut.GetAsync("non-guid"); + + Assert.Null(client); + } + + [Fact] + public async Task GetAsync_ApiKeyMissing_ReturnsNull() + { + var client = await _sut.GetAsync(Guid.NewGuid().ToString()); + + Assert.Null(client); + } + + [Fact] + public async Task GetAsync_ApiKeyExpired_ReturnsNull() + { + var apiKeyId = Guid.NewGuid(); + _apiKeyRepository.GetByIdAsync(apiKeyId) + .Returns(CreateApiKey(apiKeyId, expireAt: DateTime.UtcNow.AddMinutes(-1))); + + var client = await _sut.GetAsync(apiKeyId.ToString()); + + Assert.Null(client); + } + + [Fact] + public async Task GetAsync_NoDaemonForApiKey_ReturnsNull() + { + var apiKeyId = Guid.NewGuid(); + _apiKeyRepository.GetByIdAsync(apiKeyId).Returns(CreateApiKey(apiKeyId)); + _pamDaemonRepository.GetDetailsByApiKeyIdAsync(apiKeyId).Returns((PamDaemonDetails?)null); + + var client = await _sut.GetAsync(apiKeyId.ToString()); + + Assert.Null(client); + } + + [Fact] + public async Task GetAsync_DaemonRevoked_ReturnsNull() + { + var apiKeyId = Guid.NewGuid(); + _apiKeyRepository.GetByIdAsync(apiKeyId).Returns(CreateApiKey(apiKeyId)); + _pamDaemonRepository.GetDetailsByApiKeyIdAsync(apiKeyId) + .Returns(CreateDaemonDetails(apiKeyId, status: PamDaemonStatus.Revoked)); + + var client = await _sut.GetAsync(apiKeyId.ToString()); + + Assert.Null(client); + } + + [Fact] + public async Task GetAsync_OrganizationDisabled_ReturnsNull() + { + var apiKeyId = Guid.NewGuid(); + _apiKeyRepository.GetByIdAsync(apiKeyId).Returns(CreateApiKey(apiKeyId)); + _pamDaemonRepository.GetDetailsByApiKeyIdAsync(apiKeyId) + .Returns(CreateDaemonDetails(apiKeyId, organizationEnabled: false)); + + var client = await _sut.GetAsync(apiKeyId.ToString()); + + Assert.Null(client); + } + + [Fact] + public async Task GetAsync_OrganizationUsePamFalse_ReturnsNull() + { + var apiKeyId = Guid.NewGuid(); + _apiKeyRepository.GetByIdAsync(apiKeyId).Returns(CreateApiKey(apiKeyId)); + _pamDaemonRepository.GetDetailsByApiKeyIdAsync(apiKeyId) + .Returns(CreateDaemonDetails(apiKeyId, organizationUsePam: false)); + + var client = await _sut.GetAsync(apiKeyId.ToString()); + + Assert.Null(client); + } + + [Fact] + public async Task GetAsync_EnrolledDaemonLicensedOrg_ReturnsClientCredentialsClient() + { + var apiKeyId = Guid.NewGuid(); + var apiKey = CreateApiKey(apiKeyId); + var daemonDetails = CreateDaemonDetails(apiKeyId); + _apiKeyRepository.GetByIdAsync(apiKeyId).Returns(apiKey); + _pamDaemonRepository.GetDetailsByApiKeyIdAsync(apiKeyId).Returns(daemonDetails); + + var client = await _sut.GetAsync(apiKeyId.ToString()); + + Assert.NotNull(client); + Assert.Equal($"daemon.{apiKeyId}", client.ClientId); + Assert.True(client.RequireClientSecret); + // The usage of this secret is tested in integration tests + Assert.Single(client.ClientSecrets); + var scope = Assert.Single(client.AllowedScopes); + Assert.Equal(ApiScopes.ApiPamRotation, scope); + Assert.Equal(GrantTypes.ClientCredentials, client.AllowedGrantTypes); + Assert.Null(client.ClientClaimsPrefix); + Assert.Equal("encrypted-payload", client.Properties["encryptedPayload"]); + Assert.Contains(client.Claims, c => + c.Type == JwtClaimTypes.Subject && c.Value == daemonDetails.Id.ToString()); + Assert.Contains(client.Claims, c => + c.Type == Claims.Type && c.Value == IdentityClientType.RotationDaemon.ToString()); + Assert.Contains(client.Claims, c => + c.Type == Claims.Organization && c.Value == daemonDetails.OrganizationId.ToString()); + } + + [Fact] + public async Task GetAsync_ApiKeyNeverExpires_NullExpireAt_ReturnsClient() + { + var apiKeyId = Guid.NewGuid(); + _apiKeyRepository.GetByIdAsync(apiKeyId).Returns(CreateApiKey(apiKeyId, expireAt: null)); + _pamDaemonRepository.GetDetailsByApiKeyIdAsync(apiKeyId).Returns(CreateDaemonDetails(apiKeyId)); + + var client = await _sut.GetAsync(apiKeyId.ToString()); + + // Daemon credentials are long-lived: a null ExpireAt must not be treated as expired. + Assert.NotNull(client); + } + + private static ApiKey CreateApiKey(Guid apiKeyId, DateTime? expireAt = null) => new() + { + Id = apiKeyId, + ServiceAccountId = null, + Name = "daemon-credential", + ClientSecretHash = "hashed-secret", + Scope = $"[\"{ApiScopes.ApiPamRotation}\"]", + EncryptedPayload = "encrypted-payload", + Key = "2.key|data|mac", + ExpireAt = expireAt, + }; + + private static PamDaemonDetails CreateDaemonDetails( + Guid apiKeyId, + PamDaemonStatus status = PamDaemonStatus.Enrolled, + bool organizationEnabled = true, + bool organizationUsePam = true) => PamDaemonDetails.From( + new PamDaemon + { + Id = Guid.NewGuid(), + OrganizationId = Guid.NewGuid(), + Name = "daemon-1", + ApiKeyId = apiKeyId, + Status = status, + }, + organizationEnabled, + organizationUsePam); +} diff --git a/test/Identity.Test/packages.lock.json b/test/Identity.Test/packages.lock.json index 733b1b1f4882..148f9c8d54dd 100644 --- a/test/Identity.Test/packages.lock.json +++ b/test/Identity.Test/packages.lock.json @@ -1630,7 +1630,7 @@ "dependencies": { "AutoFixture.AutoNSubstitute": "[4.18.1, )", "AutoFixture.Xunit2": "[4.18.1, )", - "Core": "[2026.6.1, )", + "Core": "[2026.6.2, )", "Kralizek.AutoFixture.Extensions.MockHttp": "[2.2.1, 2.2.1]", "Microsoft.Extensions.TimeProvider.Testing": "[10.6.0, 10.6.0]", "Microsoft.NET.Test.Sdk": "[18.0.1, )", @@ -1654,7 +1654,7 @@ "BitPay.Light": "[1.0.1907, 1.0.1907]", "Braintree": "[5.36.0, 5.36.0]", "CsvHelper": "[33.1.0, 33.1.0]", - "Data": "[2026.6.1, )", + "Data": "[2026.6.2, )", "DnsClient": "[1.8.0, 1.8.0]", "Duende.IdentityServer": "[7.4.6, 7.4.6]", "DuoUniversal": "[1.3.1, 1.3.1]", @@ -1679,7 +1679,7 @@ "Newtonsoft.Json": "[13.0.3, 13.0.3]", "OneOf": "[3.0.271, 3.0.271]", "Otp.NET": "[1.4.0, 1.4.0]", - "Pam.Domain": "[2026.6.1, )", + "Pam.Domain": "[2026.6.2, )", "Quartz": "[3.15.1, 3.15.1]", "Quartz.Extensions.DependencyInjection": "[3.15.1, 3.15.1]", "Quartz.Extensions.Hosting": "[3.15.1, 3.15.1]", @@ -1699,7 +1699,7 @@ "identity": { "type": "Project", "dependencies": { - "Core": "[2026.6.1, )", + "Core": "[2026.6.2, )", "OpenTelemetry.Exporter.OpenTelemetryProtocol": "[1.15.3, )", "OpenTelemetry.Extensions.Hosting": "[1.15.3, )", "OpenTelemetry.Instrumentation.AspNetCore": "[1.15.0, )", @@ -1707,27 +1707,28 @@ "OpenTelemetry.Instrumentation.Http": "[1.15.0, )", "OpenTelemetry.Instrumentation.Runtime": "[1.15.0, )", "OpenTelemetry.Instrumentation.SqlClient": "[1.12.0-beta.3, )", - "SharedWeb": "[2026.6.1, )" + "Pam.Domain": "[2026.6.2, )", + "SharedWeb": "[2026.6.2, )" } }, "infrastructure.dapper": { "type": "Project", "dependencies": { - "Core": "[2026.6.1, )", + "Core": "[2026.6.2, )", "Dapper": "[2.1.66, 2.1.66]", - "Pam.Domain": "[2026.6.1, )" + "Pam.Domain": "[2026.6.2, )" } }, "infrastructure.entityframework": { "type": "Project", "dependencies": { "AutoMapper": "[14.0.0, 14.0.0]", - "Core": "[2026.6.1, )", + "Core": "[2026.6.2, )", "Microsoft.EntityFrameworkCore.Relational": "[8.0.8, 8.0.8]", "Microsoft.EntityFrameworkCore.SqlServer": "[8.0.8, 8.0.8]", "Microsoft.EntityFrameworkCore.Sqlite": "[8.0.8, 8.0.8]", "Npgsql.EntityFrameworkCore.PostgreSQL": "[8.0.4, 8.0.4]", - "Pam.Domain": "[2026.6.1, )", + "Pam.Domain": "[2026.6.2, )", "Pomelo.EntityFrameworkCore.MySql": "[8.0.2, 8.0.2]", "linq2db": "[5.4.1, 5.4.1]", "linq2db.EntityFrameworkCore": "[8.1.0, 8.1.0]" @@ -1736,15 +1737,15 @@ "pam.domain": { "type": "Project", "dependencies": { - "Data": "[2026.6.1, )" + "Data": "[2026.6.2, )" } }, "sharedweb": { "type": "Project", "dependencies": { - "Core": "[2026.6.1, )", - "Infrastructure.Dapper": "[2026.6.1, )", - "Infrastructure.EntityFramework": "[2026.6.1, )", + "Core": "[2026.6.2, )", + "Infrastructure.Dapper": "[2026.6.2, )", + "Infrastructure.EntityFramework": "[2026.6.2, )", "Microsoft.Bot.Builder.Integration.AspNet.Core": "[4.23.0, 4.23.0]", "Swashbuckle.AspNetCore.SwaggerGen": "[10.1.7, 10.1.7]" } diff --git a/test/Infrastructure.IntegrationTest/Pam/Repositories/AccessLeaseExpiryTests.cs b/test/Infrastructure.IntegrationTest/Pam/Repositories/AccessLeaseExpiryTests.cs new file mode 100644 index 000000000000..a8cdd36cfdcb --- /dev/null +++ b/test/Infrastructure.IntegrationTest/Pam/Repositories/AccessLeaseExpiryTests.cs @@ -0,0 +1,155 @@ +using Bit.Core.Repositories; +using Bit.Core.Utilities; +using Bit.Infrastructure.IntegrationTest.AdminConsole; +using Bit.Pam.Entities; +using Bit.Pam.Enums; +using Bit.Pam.Repositories; +using Xunit; + +namespace Bit.Infrastructure.IntegrationTest.Pam.Repositories; + +/// +/// The lease natural-expiry sweep (AccessLease_ExpireDue via +/// ): Active leases whose window closed on its own flip to +/// Expired and are returned for the LeaseExpired audit emission / rotation access-end trigger. The sweep is +/// set-based across the whole table, so assertions scope to this test's lease ids rather than the full result. +/// +public class AccessLeaseExpiryTests +{ + [DatabaseTheory, DatabaseData] + public async Task ExpireDueAsync_ActiveLeasePastNotAfter_ExpiresAndReturnsIt( + IOrganizationRepository organizationRepository, + IAccessRequestRepository accessRequestRepository, + IAccessLeaseRepository accessLeaseRepository) + { + var organization = await organizationRepository.CreateTestOrganizationAsync(); + var now = DateTime.UtcNow; + + // Minted while the window was open, but the window has since elapsed on its own. + var lease = await SeedActiveLeaseAsync( + accessRequestRepository, accessLeaseRepository, organization.Id, now.AddHours(-2), now.AddHours(-1)); + + var expired = await accessLeaseRepository.ExpireDueAsync(now); + + // The returned row is self-contained: everything the caller audits/triggers on comes straight off the lease. + var row = Assert.Single(expired, r => r.Id == lease.Id); + Assert.Equal(lease.OrganizationId, row.OrganizationId); + Assert.Equal(lease.CollectionId, row.CollectionId); + Assert.Equal(lease.CipherId, row.CipherId); + Assert.Equal(lease.RequesterId, row.RequesterId); + Assert.Equal(lease.NotBefore, row.NotBefore); + Assert.Equal(lease.NotAfter, row.NotAfter); + + var persisted = await accessLeaseRepository.GetByIdAsync(lease.Id); + Assert.Equal(AccessLeaseStatus.Expired, persisted!.Status); + // Natural expiry involves no revoker: the revoke fields stay untouched. + Assert.Null(persisted.RevokedDate); + Assert.Null(persisted.RevokedBy); + } + + [DatabaseTheory, DatabaseData] + public async Task ExpireDueAsync_InWindowAndRevokedLeases_Untouched( + IOrganizationRepository organizationRepository, + IAccessRequestRepository accessRequestRepository, + IAccessLeaseRepository accessLeaseRepository) + { + var organization = await organizationRepository.CreateTestOrganizationAsync(); + var now = DateTime.UtcNow; + + // Still inside its window: not due. + var active = await SeedActiveLeaseAsync( + accessRequestRepository, accessLeaseRepository, organization.Id, now.AddMinutes(-5), now.AddHours(1)); + + // Past its window but already Revoked: the sweep only flips Active leases -- an operator-ended lease must + // never be rewritten to Expired. + var revoked = await SeedActiveLeaseAsync( + accessRequestRepository, accessLeaseRepository, organization.Id, now.AddHours(-2), now.AddHours(-1)); + await accessLeaseRepository.RevokeAsync(revoked, AccessLeaseStatus.Revoked, new AccessDecision + { + Id = CoreHelpers.GenerateComb(), + AccessRequestId = revoked.AccessRequestId, + DeciderKind = AccessDeciderKind.Human, + ApproverId = Guid.NewGuid(), + Verdict = AccessDecisionVerdict.Deny, + Comment = "ended for test", + CreationDate = now, + }, now); + + var expired = await accessLeaseRepository.ExpireDueAsync(now); + + Assert.DoesNotContain(expired, r => r.Id == active.Id); + Assert.DoesNotContain(expired, r => r.Id == revoked.Id); + Assert.Equal(AccessLeaseStatus.Active, (await accessLeaseRepository.GetByIdAsync(active.Id))!.Status); + Assert.Equal(AccessLeaseStatus.Revoked, (await accessLeaseRepository.GetByIdAsync(revoked.Id))!.Status); + } + + // The sweep is idempotent: a lease it already flipped is no longer Active, so a second run never returns it + // again -- the LeaseExpired audit event and the rotation access-end trigger fire exactly once per lease. + [DatabaseTheory, DatabaseData] + public async Task ExpireDueAsync_SecondRun_DoesNotReturnAlreadyExpiredLease( + IOrganizationRepository organizationRepository, + IAccessRequestRepository accessRequestRepository, + IAccessLeaseRepository accessLeaseRepository) + { + var organization = await organizationRepository.CreateTestOrganizationAsync(); + var now = DateTime.UtcNow; + var lease = await SeedActiveLeaseAsync( + accessRequestRepository, accessLeaseRepository, organization.Id, now.AddHours(-2), now.AddHours(-1)); + + var firstRun = await accessLeaseRepository.ExpireDueAsync(now); + Assert.Contains(firstRun, r => r.Id == lease.Id); + + var secondRun = await accessLeaseRepository.ExpireDueAsync(now.AddMinutes(1)); + Assert.DoesNotContain(secondRun, r => r.Id == lease.Id); + Assert.Equal(AccessLeaseStatus.Expired, (await accessLeaseRepository.GetByIdAsync(lease.Id))!.Status); + } + + // Seeds an active lease the way production does: record the auto-approved request, then mint the lease by + // activating it at a time inside the request's window (which can be in the past, so already-elapsed windows can + // still be seeded). + private static async Task SeedActiveLeaseAsync( + IAccessRequestRepository accessRequestRepository, + IAccessLeaseRepository accessLeaseRepository, + Guid organizationId, DateTime notBefore, DateTime notAfter) + { + var request = new AccessRequest + { + Id = CoreHelpers.GenerateComb(), + OrganizationId = organizationId, + CollectionId = Guid.NewGuid(), + CipherId = Guid.NewGuid(), + RequesterId = Guid.NewGuid(), + NotBefore = notBefore, + NotAfter = notAfter, + Status = AccessRequestStatus.Approved, + }; + var decision = new AccessDecision + { + Id = CoreHelpers.GenerateComb(), + AccessRequestId = request.Id, + DeciderKind = AccessDeciderKind.Automatic, + Verdict = AccessDecisionVerdict.Approve, + }; + await accessRequestRepository.CreateAutoApprovedAsync(request, decision); + + var lease = new AccessLease + { + Id = CoreHelpers.GenerateComb(), + AccessRequestId = request.Id, + OrganizationId = organizationId, + CollectionId = request.CollectionId, + CipherId = request.CipherId, + RequesterId = request.RequesterId, + Status = AccessLeaseStatus.Active, + NotBefore = notBefore, + NotAfter = notAfter, + CreationDate = notBefore, + }; + Assert.Equal(AccessLeaseMintOutcome.Minted, + await accessLeaseRepository.CreateFromApprovedRequestAsync(lease, notBefore, false)); + + // The mint copies the persisted request's window, whose datetime2 roundtrip may differ from the in-memory + // ticks -- read the lease back so the expiry assertions compare against what is actually stored. + return (await accessLeaseRepository.GetByIdAsync(lease.Id))!; + } +} diff --git a/test/Infrastructure.IntegrationTest/Pam/Repositories/PamDaemonRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Pam/Repositories/PamDaemonRepositoryTests.cs new file mode 100644 index 000000000000..feea5c652a32 --- /dev/null +++ b/test/Infrastructure.IntegrationTest/Pam/Repositories/PamDaemonRepositoryTests.cs @@ -0,0 +1,229 @@ +using Bit.Core.Repositories; +using Bit.Core.SecretsManager.Entities; +using Bit.Core.SecretsManager.Repositories; +using Bit.Core.Utilities; +using Bit.Infrastructure.IntegrationTest.AdminConsole; +using Bit.Pam.Entities; +using Bit.Pam.Enums; +using Bit.Pam.Repositories; +using Xunit; + +namespace Bit.Infrastructure.IntegrationTest.Pam.Repositories; + +public class PamDaemonRepositoryTests +{ + [DatabaseTheory, DatabaseData] + public async Task CreateAsync_ThenRead_RoundTripsFields( + IApiKeyRepository apiKeyRepository, + IOrganizationRepository organizationRepository, + IPamDaemonRepository pamDaemonRepository) + { + var organization = await organizationRepository.CreateTestOrganizationAsync(); + var apiKey = await apiKeyRepository.CreateAsync(BuildDaemonApiKey()); + + var daemon = await pamDaemonRepository.CreateAsync(new PamDaemon + { + OrganizationId = organization.Id, + Name = "prod-daemon", + ApiKeyId = apiKey.Id, + Status = PamDaemonStatus.Enrolled, + }); + + var persisted = await pamDaemonRepository.GetByIdAsync(daemon.Id); + + Assert.NotNull(persisted); + Assert.Equal(organization.Id, persisted!.OrganizationId); + Assert.Equal("prod-daemon", persisted.Name); + Assert.Equal(apiKey.Id, persisted.ApiKeyId); + Assert.Equal(PamDaemonStatus.Enrolled, persisted.Status); + Assert.Null(persisted.LastHeartbeatAt); + } + + // PamDaemonClientProvider's token-issuance lookup: the daemon plus its organization's licensing flags, keyed by + // the ApiKey credential rather than the daemon's own id. Enabled and UsePam are independent columns on + // Organization -- flip one without the other to prove the mapping does not swap or conflate them. + [DatabaseTheory, DatabaseData] + public async Task GetDetailsByApiKeyIdAsync_ReturnsDaemonWithOrganizationLicensingFlags( + IApiKeyRepository apiKeyRepository, + IOrganizationRepository organizationRepository, + IPamDaemonRepository pamDaemonRepository) + { + var organization = await organizationRepository.CreateTestOrganizationAsync(); + Assert.True(organization.Enabled); + Assert.True(organization.UsePam); + var apiKey = await apiKeyRepository.CreateAsync(BuildDaemonApiKey()); + var daemon = await pamDaemonRepository.CreateAsync(new PamDaemon + { + OrganizationId = organization.Id, + Name = "prod-daemon", + ApiKeyId = apiKey.Id, + Status = PamDaemonStatus.Enrolled, + }); + + var details = await pamDaemonRepository.GetDetailsByApiKeyIdAsync(apiKey.Id); + + Assert.NotNull(details); + Assert.Equal(daemon.Id, details!.Id); + Assert.Equal(PamDaemonStatus.Enrolled, details.Status); + Assert.True(details.OrganizationEnabled); + Assert.True(details.OrganizationUsePam); + + // Flip UsePam only: OrganizationEnabled must stay true while OrganizationUsePam flips, proving the two + // columns map independently rather than one driving both. + organization.UsePam = false; + await organizationRepository.ReplaceAsync(organization); + + var afterLicenseLapse = await pamDaemonRepository.GetDetailsByApiKeyIdAsync(apiKey.Id); + Assert.NotNull(afterLicenseLapse); + Assert.True(afterLicenseLapse!.OrganizationEnabled); + Assert.False(afterLicenseLapse.OrganizationUsePam); + } + + [DatabaseTheory, DatabaseData] + public async Task GetDetailsByApiKeyIdAsync_UnknownApiKeyId_ReturnsNull( + IPamDaemonRepository pamDaemonRepository) + { + Assert.Null(await pamDaemonRepository.GetDetailsByApiKeyIdAsync(Guid.NewGuid())); + } + + // The daemon-facing request filter calls this on every request; the WHERE guard on the sproc turns a poll that + // arrives before MinInterval has elapsed into a no-op, and only a poll arriving after it actually bumps the + // column. + [DatabaseTheory, DatabaseData] + public async Task UpdateHeartbeatAsync_ConditionalBump( + IApiKeyRepository apiKeyRepository, + IOrganizationRepository organizationRepository, + IPamDaemonRepository pamDaemonRepository) + { + var organization = await organizationRepository.CreateTestOrganizationAsync(); + var apiKey = await apiKeyRepository.CreateAsync(BuildDaemonApiKey()); + var daemon = await pamDaemonRepository.CreateAsync(new PamDaemon + { + OrganizationId = organization.Id, + Name = "prod-daemon", + ApiKeyId = apiKey.Id, + Status = PamDaemonStatus.Enrolled, + }); + var minInterval = TimeSpan.FromSeconds(15); + var firstHeartbeat = DateTime.UtcNow; + + // First heartbeat: LastHeartbeatAt was null, so it always bumps. + await pamDaemonRepository.UpdateHeartbeatAsync(daemon.Id, firstHeartbeat, minInterval); + var afterFirst = await pamDaemonRepository.GetByIdAsync(daemon.Id); + Assert.NotNull(afterFirst!.LastHeartbeatAt); + var recordedFirst = afterFirst.LastHeartbeatAt!.Value; + + // Second poll arrives well within MinInterval: the guard's WHERE clause keeps this a no-op. + await pamDaemonRepository.UpdateHeartbeatAsync(daemon.Id, firstHeartbeat.AddSeconds(5), minInterval); + var afterSecond = await pamDaemonRepository.GetByIdAsync(daemon.Id); + Assert.Equal(recordedFirst, afterSecond!.LastHeartbeatAt); + + // Third poll arrives after MinInterval has elapsed since the last recorded bump: it updates. + var thirdHeartbeat = firstHeartbeat.AddSeconds(20); + await pamDaemonRepository.UpdateHeartbeatAsync(daemon.Id, thirdHeartbeat, minInterval); + var afterThird = await pamDaemonRepository.GetByIdAsync(daemon.Id); + Assert.Equal(thirdHeartbeat, afterThird!.LastHeartbeatAt); + Assert.NotEqual(recordedFirst, afterThird.LastHeartbeatAt); + } + + [DatabaseTheory, DatabaseData] + public async Task Assignment_CreateExistsDeleteReadByOrganization_RoundTrips( + IApiKeyRepository apiKeyRepository, + IOrganizationRepository organizationRepository, + IPamTargetSystemRepository pamTargetSystemRepository, + IPamDaemonRepository pamDaemonRepository) + { + var organization = await organizationRepository.CreateTestOrganizationAsync(); + var now = DateTime.UtcNow; + var target = await pamTargetSystemRepository.CreateAsync(new PamTargetSystem + { + OrganizationId = organization.Id, + Name = "target", + Method = PamTargetSystemMethod.Automatic, + Kind = PamTargetSystemKind.Mssql, + Status = PamTargetSystemStatus.Active, + CreationDate = now, + RevisionDate = now, + }); + var apiKey = await apiKeyRepository.CreateAsync(BuildDaemonApiKey()); + var daemon = await pamDaemonRepository.CreateAsync(new PamDaemon + { + OrganizationId = organization.Id, + Name = "daemon", + ApiKeyId = apiKey.Id, + Status = PamDaemonStatus.Enrolled, + }); + + Assert.False(await pamDaemonRepository.AssignmentExistsAsync(daemon.Id, target.Id)); + Assert.Empty(await pamDaemonRepository.GetAssignmentsByOrganizationIdAsync(organization.Id)); + + var assignment = new PamDaemonTargetAssignment + { + Id = CoreHelpers.GenerateComb(), + DaemonId = daemon.Id, + TargetSystemId = target.Id, + OrganizationId = organization.Id, + CreationDate = now, + }; + await pamDaemonRepository.CreateAssignmentAsync(assignment); + + Assert.True(await pamDaemonRepository.AssignmentExistsAsync(daemon.Id, target.Id)); + var assignments = await pamDaemonRepository.GetAssignmentsByOrganizationIdAsync(organization.Id); + var persisted = Assert.Single(assignments); + Assert.Equal(assignment.Id, persisted.Id); + Assert.Equal(daemon.Id, persisted.DaemonId); + Assert.Equal(target.Id, persisted.TargetSystemId); + + await pamDaemonRepository.DeleteAssignmentAsync(daemon.Id, target.Id); + + Assert.False(await pamDaemonRepository.AssignmentExistsAsync(daemon.Id, target.Id)); + Assert.Empty(await pamDaemonRepository.GetAssignmentsByOrganizationIdAsync(organization.Id)); + } + + // PamDaemon_Update is narrow: only Name/Status/RevisionDate are declared sproc parameters. Mutate ApiKeyId and + // OrganizationId on the in-memory entity too before calling ReplaceAsync -- if the override were widened to a + // whole-entity write those garbage values would persist; instead they must be silently ignored. + [DatabaseTheory, DatabaseData] + public async Task ReplaceAsync_OnlyPersistsNameStatusRevisionDate( + IApiKeyRepository apiKeyRepository, + IOrganizationRepository organizationRepository, + IPamDaemonRepository pamDaemonRepository) + { + var organization = await organizationRepository.CreateTestOrganizationAsync(); + var apiKey = await apiKeyRepository.CreateAsync(BuildDaemonApiKey()); + var daemon = await pamDaemonRepository.CreateAsync(new PamDaemon + { + OrganizationId = organization.Id, + Name = "daemon", + ApiKeyId = apiKey.Id, + Status = PamDaemonStatus.Enrolled, + }); + var originalOrganizationId = daemon.OrganizationId; + var originalApiKeyId = daemon.ApiKeyId; + var newRevisionDate = DateTime.UtcNow.AddMinutes(10); + + daemon.Name = "renamed-daemon"; + daemon.Status = PamDaemonStatus.Revoked; + daemon.RevisionDate = newRevisionDate; + daemon.OrganizationId = Guid.NewGuid(); + daemon.ApiKeyId = Guid.NewGuid(); + await pamDaemonRepository.ReplaceAsync(daemon); + + var persisted = await pamDaemonRepository.GetByIdAsync(daemon.Id); + Assert.NotNull(persisted); + Assert.Equal("renamed-daemon", persisted!.Name); + Assert.Equal(PamDaemonStatus.Revoked, persisted.Status); + Assert.Equal(newRevisionDate, persisted.RevisionDate); + Assert.Equal(originalOrganizationId, persisted.OrganizationId); + Assert.Equal(originalApiKeyId, persisted.ApiKeyId); + } + + private static ApiKey BuildDaemonApiKey(string identifier = "daemon") => new() + { + ServiceAccountId = null, + Name = $"{identifier}-{Guid.NewGuid()}", + Scope = """["api.pam.rotation"]""", + EncryptedPayload = "encrypted-payload", + Key = "encrypted-key", + }; +} diff --git a/test/Infrastructure.IntegrationTest/Pam/Repositories/PamRotationConfigRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Pam/Repositories/PamRotationConfigRepositoryTests.cs new file mode 100644 index 000000000000..848412324592 --- /dev/null +++ b/test/Infrastructure.IntegrationTest/Pam/Repositories/PamRotationConfigRepositoryTests.cs @@ -0,0 +1,324 @@ +using Bit.Core.Repositories; +using Bit.Core.SecretsManager.Entities; +using Bit.Core.SecretsManager.Repositories; +using Bit.Core.Utilities; +using Bit.Core.Vault.Entities; +using Bit.Core.Vault.Enums; +using Bit.Core.Vault.Repositories; +using Bit.Infrastructure.IntegrationTest.AdminConsole; +using Bit.Pam.Entities; +using Bit.Pam.Enums; +using Bit.Pam.Repositories; +using Microsoft.Data.SqlClient; +using Xunit; + +namespace Bit.Infrastructure.IntegrationTest.Pam.Repositories; + +public class PamRotationConfigRepositoryTests +{ + [DatabaseTheory, DatabaseData] + public async Task CreateAsync_ThenRead_RoundTripsFields( + IOrganizationRepository organizationRepository, + IPamTargetSystemRepository pamTargetSystemRepository, + ICipherRepository cipherRepository, + IPamRotationConfigRepository pamRotationConfigRepository) + { + var organization = await organizationRepository.CreateTestOrganizationAsync(); + var now = DateTime.UtcNow; + var target = await CreateAutomaticTargetAsync(pamTargetSystemRepository, organization.Id, now); + var cipher = await CreateCipherAsync(cipherRepository, organization.Id); + + var config = await pamRotationConfigRepository.CreateAsync(new PamRotationConfig + { + OrganizationId = organization.Id, + CipherId = cipher.Id, + TargetSystemId = target.Id, + AccountIdentity = "svc-rotation-account", + TerminateSessions = true, + ScheduleCron = "0 0/15 * * * ?", + RotateOnAccessEnd = true, + NextRotationAt = now.AddMinutes(15), + Enabled = true, + CreationDate = now, + RevisionDate = now, + }); + + var persisted = await pamRotationConfigRepository.GetByIdAsync(config.Id); + Assert.NotNull(persisted); + Assert.Equal(organization.Id, persisted!.OrganizationId); + Assert.Equal(cipher.Id, persisted.CipherId); + Assert.Equal(target.Id, persisted.TargetSystemId); + Assert.Equal("svc-rotation-account", persisted.AccountIdentity); + Assert.True(persisted.TerminateSessions); + Assert.Equal("0 0/15 * * * ?", persisted.ScheduleCron); + Assert.True(persisted.RotateOnAccessEnd); + Assert.Equal(now.AddMinutes(15), persisted.NextRotationAt); + Assert.True(persisted.Enabled); + Assert.Null(persisted.LastRotationAt); + + var byCipher = await pamRotationConfigRepository.GetByCipherIdAsync(cipher.Id); + Assert.NotNull(byCipher); + Assert.Equal(config.Id, byCipher!.Id); + } + + [DatabaseTheory, DatabaseData] + public async Task GetByCipherIdAsync_NoConfig_ReturnsNull(IPamRotationConfigRepository pamRotationConfigRepository) + { + Assert.Null(await pamRotationConfigRepository.GetByCipherIdAsync(Guid.NewGuid())); + } + + // OneConfigPerCipher (IX_PamRotationConfig_CipherId): a second config for a cipher that already has one hits the + // unique index and throws -- PamRotationConfigRepository does not catch this the way AccessLeaseRepository does + // for its own unique-index backstop, so the caller (CreateRotationConfigCommand) is expected to have already + // guarded against it via GetByCipherIdAsync. + [DatabaseTheory, DatabaseData] + public async Task CreateAsync_SecondConfigForSameCipher_ThrowsSqlException( + IOrganizationRepository organizationRepository, + IPamTargetSystemRepository pamTargetSystemRepository, + ICipherRepository cipherRepository, + IPamRotationConfigRepository pamRotationConfigRepository) + { + var organization = await organizationRepository.CreateTestOrganizationAsync(); + var now = DateTime.UtcNow; + var target = await CreateAutomaticTargetAsync(pamTargetSystemRepository, organization.Id, now); + var cipher = await CreateCipherAsync(cipherRepository, organization.Id); + + await pamRotationConfigRepository.CreateAsync(BuildConfig(organization.Id, cipher.Id, target.Id, now)); + + await Assert.ThrowsAsync(() => + pamRotationConfigRepository.CreateAsync(BuildConfig(organization.Id, cipher.Id, target.Id, now))); + } + + // The sweep's due phase: enabled + automatic + active-target configs whose schedule has come due, with no active + // job already in flight. Paused, disabled-target, not-yet-due, and manual configs are all excluded. + [DatabaseTheory, DatabaseData] + public async Task GetManyDueAsync_ReturnsOnlyEnabledAutomaticActiveDueConfigs( + IOrganizationRepository organizationRepository, + IPamTargetSystemRepository pamTargetSystemRepository, + ICipherRepository cipherRepository, + IPamRotationConfigRepository pamRotationConfigRepository) + { + var organization = await organizationRepository.CreateTestOrganizationAsync(); + var now = DateTime.UtcNow; + var activeTarget = await CreateAutomaticTargetAsync(pamTargetSystemRepository, organization.Id, now); + var disabledTarget = await CreateAutomaticTargetAsync(pamTargetSystemRepository, organization.Id, now, + status: PamTargetSystemStatus.Disabled); + var manualTarget = await pamTargetSystemRepository.CreateAsync(new PamTargetSystem + { + OrganizationId = organization.Id, + Name = "manual", + Method = PamTargetSystemMethod.Manual, + Status = PamTargetSystemStatus.Active, + CreationDate = now, + RevisionDate = now, + }); + + var due = await pamRotationConfigRepository.CreateAsync( + BuildConfig(organization.Id, (await CreateCipherAsync(cipherRepository, organization.Id)).Id, activeTarget.Id, now, + nextRotationAt: now.AddMinutes(-1))); + await pamRotationConfigRepository.CreateAsync( + BuildConfig(organization.Id, (await CreateCipherAsync(cipherRepository, organization.Id)).Id, activeTarget.Id, now, + enabled: false, nextRotationAt: now.AddMinutes(-1))); + await pamRotationConfigRepository.CreateAsync( + BuildConfig(organization.Id, (await CreateCipherAsync(cipherRepository, organization.Id)).Id, disabledTarget.Id, now, + nextRotationAt: now.AddMinutes(-1))); + await pamRotationConfigRepository.CreateAsync( + BuildConfig(organization.Id, (await CreateCipherAsync(cipherRepository, organization.Id)).Id, manualTarget.Id, now, + nextRotationAt: now.AddMinutes(-1))); + await pamRotationConfigRepository.CreateAsync( + BuildConfig(organization.Id, (await CreateCipherAsync(cipherRepository, organization.Id)).Id, activeTarget.Id, now, + nextRotationAt: now.AddHours(1))); + await pamRotationConfigRepository.CreateAsync( + BuildConfig(organization.Id, (await CreateCipherAsync(cipherRepository, organization.Id)).Id, activeTarget.Id, now, + nextRotationAt: null)); + + var dueConfigs = await pamRotationConfigRepository.GetManyDueAsync(now); + + var row = Assert.Single(dueConfigs); + Assert.Equal(due.Id, row.Id); + } + + [DatabaseTheory, DatabaseData] + public async Task AnyByTargetSystemWithTerminateSessionsAsync_ReflectsConfigsOnTarget( + IOrganizationRepository organizationRepository, + IPamTargetSystemRepository pamTargetSystemRepository, + ICipherRepository cipherRepository, + IPamRotationConfigRepository pamRotationConfigRepository) + { + var organization = await organizationRepository.CreateTestOrganizationAsync(); + var now = DateTime.UtcNow; + var target = await CreateAutomaticTargetAsync(pamTargetSystemRepository, organization.Id, now, + supportsSessionTermination: true); + var otherTarget = await CreateAutomaticTargetAsync(pamTargetSystemRepository, organization.Id, now, + supportsSessionTermination: true); + + Assert.False(await pamRotationConfigRepository.AnyByTargetSystemWithTerminateSessionsAsync(target.Id)); + + var cipher = await CreateCipherAsync(cipherRepository, organization.Id); + await pamRotationConfigRepository.CreateAsync( + BuildConfig(organization.Id, cipher.Id, target.Id, now, terminateSessions: true)); + var otherCipher = await CreateCipherAsync(cipherRepository, organization.Id); + await pamRotationConfigRepository.CreateAsync( + BuildConfig(organization.Id, otherCipher.Id, otherTarget.Id, now, terminateSessions: false)); + + Assert.True(await pamRotationConfigRepository.AnyByTargetSystemWithTerminateSessionsAsync(target.Id)); + Assert.False(await pamRotationConfigRepository.AnyByTargetSystemWithTerminateSessionsAsync(otherTarget.Id)); + } + + [DatabaseTheory, DatabaseData] + public async Task DeleteWithJobsAsync_CascadesJobsAndAttempts( + IOrganizationRepository organizationRepository, + IPamTargetSystemRepository pamTargetSystemRepository, + IApiKeyRepository apiKeyRepository, + IPamDaemonRepository pamDaemonRepository, + ICipherRepository cipherRepository, + IPamRotationConfigRepository pamRotationConfigRepository, + IPamRotationJobRepository pamRotationJobRepository) + { + var organization = await organizationRepository.CreateTestOrganizationAsync(); + var now = DateTime.UtcNow; + var target = await CreateAutomaticTargetAsync(pamTargetSystemRepository, organization.Id, now); + var daemon = await CreateEnrolledDaemonAsync(apiKeyRepository, pamDaemonRepository, organization.Id); + await AssignAsync(pamDaemonRepository, daemon.Id, target.Id, organization.Id, now); + var cipher = await CreateCipherAsync(cipherRepository, organization.Id); + var config = await pamRotationConfigRepository.CreateAsync(BuildConfig(organization.Id, cipher.Id, target.Id, now)); + var job = BuildPendingJob(config.Id, now); + Assert.Equal(PamRotationJobCreateOutcome.Created, await pamRotationJobRepository.CreateGuardedAsync(job)); + var claim = await pamRotationJobRepository.ClaimAsync(job.Id, daemon.Id, now, TimeSpan.FromMinutes(15)); + Assert.Equal(PamRotationClaimOutcome.Claimed, claim.Outcome); + + await pamRotationConfigRepository.DeleteWithJobsAsync(config.Id); + + Assert.Null(await pamRotationConfigRepository.GetByIdAsync(config.Id)); + Assert.Null(await pamRotationJobRepository.GetByIdAsync(job.Id)); + Assert.Null(await pamRotationJobRepository.GetAttemptByIdAsync(claim.AttemptId!.Value)); + } + + // The config detail page's header projection: target display fields denormalized, plus a computed HasActiveJob + // that flips back to false once the job leaves Pending/Claimed (here, once it succeeds). + [DatabaseTheory, DatabaseData] + public async Task GetDetailsByIdAsync_ProjectsTargetFieldsAndHasActiveJob( + IOrganizationRepository organizationRepository, + IPamTargetSystemRepository pamTargetSystemRepository, + IApiKeyRepository apiKeyRepository, + IPamDaemonRepository pamDaemonRepository, + ICipherRepository cipherRepository, + IPamRotationConfigRepository pamRotationConfigRepository, + IPamRotationJobRepository pamRotationJobRepository) + { + var organization = await organizationRepository.CreateTestOrganizationAsync(); + var now = DateTime.UtcNow; + var target = await CreateAutomaticTargetAsync(pamTargetSystemRepository, organization.Id, now); + var daemon = await CreateEnrolledDaemonAsync(apiKeyRepository, pamDaemonRepository, organization.Id); + await AssignAsync(pamDaemonRepository, daemon.Id, target.Id, organization.Id, now); + var cipher = await CreateCipherAsync(cipherRepository, organization.Id); + var config = await pamRotationConfigRepository.CreateAsync(BuildConfig(organization.Id, cipher.Id, target.Id, now)); + + var beforeJob = await pamRotationConfigRepository.GetDetailsByIdAsync(config.Id); + Assert.NotNull(beforeJob); + Assert.Equal(target.Name, beforeJob!.TargetSystemName); + Assert.Equal(PamTargetSystemMethod.Automatic, beforeJob.TargetSystemMethod); + Assert.False(beforeJob.HasActiveJob); + + var job = BuildPendingJob(config.Id, now); + await pamRotationJobRepository.CreateGuardedAsync(job); + var withActiveJob = await pamRotationConfigRepository.GetDetailsByIdAsync(config.Id); + Assert.True(withActiveJob!.HasActiveJob); + + var claim = await pamRotationJobRepository.ClaimAsync(job.Id, daemon.Id, now, TimeSpan.FromMinutes(15)); + await pamRotationJobRepository.AcceptCipherWriteAsync( + claim.AttemptId!.Value, daemon.Id, "{\"rotated\":true}", cipher.RevisionDate, now); + await pamRotationJobRepository.MarkAttemptRotatedAsync( + claim.AttemptId!.Value, daemon.Id, PamSessionTerminationOutcome.NotRequested, now); + + var afterSuccess = await pamRotationConfigRepository.GetDetailsByIdAsync(config.Id); + Assert.NotNull(afterSuccess); + Assert.False(afterSuccess!.HasActiveJob); + } + + [DatabaseTheory, DatabaseData] + public async Task GetDetailsByIdAsync_UnknownId_ReturnsNull(IPamRotationConfigRepository pamRotationConfigRepository) + { + Assert.Null(await pamRotationConfigRepository.GetDetailsByIdAsync(Guid.NewGuid())); + } + + private static async Task CreateAutomaticTargetAsync( + IPamTargetSystemRepository pamTargetSystemRepository, Guid organizationId, DateTime now, + PamTargetSystemStatus status = PamTargetSystemStatus.Active, bool? supportsSessionTermination = null) + => await pamTargetSystemRepository.CreateAsync(new PamTargetSystem + { + OrganizationId = organizationId, + Name = $"target-{Guid.NewGuid()}", + Method = PamTargetSystemMethod.Automatic, + Kind = PamTargetSystemKind.Mssql, + SupportsSessionTermination = supportsSessionTermination, + Status = status, + CreationDate = now, + RevisionDate = now, + }); + + private static async Task CreateEnrolledDaemonAsync( + IApiKeyRepository apiKeyRepository, IPamDaemonRepository pamDaemonRepository, Guid organizationId) + { + var apiKey = await apiKeyRepository.CreateAsync(new ApiKey + { + ServiceAccountId = null, + Name = $"daemon-{Guid.NewGuid()}", + Scope = """["api.pam.rotation"]""", + EncryptedPayload = "encrypted-payload", + Key = "encrypted-key", + }); + return await pamDaemonRepository.CreateAsync(new PamDaemon + { + OrganizationId = organizationId, + Name = $"daemon-{Guid.NewGuid()}", + ApiKeyId = apiKey.Id, + Status = PamDaemonStatus.Enrolled, + }); + } + + private static async Task AssignAsync( + IPamDaemonRepository pamDaemonRepository, Guid daemonId, Guid targetSystemId, Guid organizationId, DateTime now) + => await pamDaemonRepository.CreateAssignmentAsync(new PamDaemonTargetAssignment + { + Id = CoreHelpers.GenerateComb(), + DaemonId = daemonId, + TargetSystemId = targetSystemId, + OrganizationId = organizationId, + CreationDate = now, + }); + + private static async Task CreateCipherAsync(ICipherRepository cipherRepository, Guid organizationId) + => await cipherRepository.CreateAsync(new Cipher + { + OrganizationId = organizationId, + Type = CipherType.Login, + Data = "{\"originalSecret\":true}", + }); + + private static PamRotationConfig BuildConfig(Guid organizationId, Guid cipherId, Guid targetSystemId, DateTime now, + bool enabled = true, bool terminateSessions = false, DateTime? nextRotationAt = null) => new() + { + OrganizationId = organizationId, + CipherId = cipherId, + TargetSystemId = targetSystemId, + AccountIdentity = "svc-account", + TerminateSessions = terminateSessions, + RotateOnAccessEnd = false, + NextRotationAt = nextRotationAt, + Enabled = enabled, + CreationDate = now, + RevisionDate = now, + }; + + private static PamRotationJob BuildPendingJob(Guid configId, DateTime now) => new() + { + Id = CoreHelpers.GenerateComb(), + RotationConfigId = configId, + Source = PamRotationSource.Scheduled, + Status = PamRotationJobStatus.Pending, + CreationDate = now, + NextClaimableAt = now, + ExpiresAt = now.AddHours(1), + }; +} diff --git a/test/Infrastructure.IntegrationTest/Pam/Repositories/PamRotationJobRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Pam/Repositories/PamRotationJobRepositoryTests.cs new file mode 100644 index 000000000000..14d89711ba40 --- /dev/null +++ b/test/Infrastructure.IntegrationTest/Pam/Repositories/PamRotationJobRepositoryTests.cs @@ -0,0 +1,901 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Repositories; +using Bit.Core.SecretsManager.Entities; +using Bit.Core.SecretsManager.Repositories; +using Bit.Core.Utilities; +using Bit.Core.Vault.Entities; +using Bit.Core.Vault.Enums; +using Bit.Core.Vault.Repositories; +using Bit.Infrastructure.IntegrationTest.AdminConsole; +using Bit.Pam.Entities; +using Bit.Pam.Enums; +using Bit.Pam.Repositories; +using Xunit; + +namespace Bit.Infrastructure.IntegrationTest.Pam.Repositories; + +public class PamRotationJobRepositoryTests +{ + private static readonly TimeSpan _releaseDelay = TimeSpan.FromMinutes(15); + private static readonly TimeSpan _offlineAfter = TimeSpan.FromMinutes(2); + + [DatabaseTheory, DatabaseData] + public async Task CreateGuardedAsync_EnforcesAtMostOneActiveJobPerConfig( + IOrganizationRepository organizationRepository, + IPamTargetSystemRepository pamTargetSystemRepository, + IApiKeyRepository apiKeyRepository, + IPamDaemonRepository pamDaemonRepository, + ICipherRepository cipherRepository, + IPamRotationConfigRepository pamRotationConfigRepository, + IPamRotationJobRepository pamRotationJobRepository) + { + var fixture = await SeedClaimableJobAsync(organizationRepository, pamTargetSystemRepository, apiKeyRepository, + pamDaemonRepository, cipherRepository, pamRotationConfigRepository, pamRotationJobRepository); + + // The seed already created a Pending job for the config -- a second offer must be refused as ActiveJobExists, + // and only the first job may exist. + var second = BuildPendingJob(fixture.Config.Id, fixture.Now); + Assert.Equal(PamRotationJobCreateOutcome.ActiveJobExists, + await pamRotationJobRepository.CreateGuardedAsync(second)); + Assert.Null(await pamRotationJobRepository.GetByIdAsync(second.Id)); + Assert.NotNull(await pamRotationJobRepository.GetByIdAsync(fixture.Job.Id)); + } + + [DatabaseTheory, DatabaseData] + public async Task CreateGuardedAsync_PausedConfig_ConfigNotOfferable( + IOrganizationRepository organizationRepository, + IPamTargetSystemRepository pamTargetSystemRepository, + ICipherRepository cipherRepository, + IPamRotationConfigRepository pamRotationConfigRepository, + IPamRotationJobRepository pamRotationJobRepository) + { + var organization = await organizationRepository.CreateTestOrganizationAsync(); + var now = DateTime.UtcNow; + var target = await CreateAutomaticTargetAsync(pamTargetSystemRepository, organization.Id, now); + var cipher = await CreateCipherAsync(cipherRepository, organization.Id); + var config = await pamRotationConfigRepository.CreateAsync( + BuildConfig(organization.Id, cipher.Id, target.Id, now, enabled: false)); + + var job = BuildPendingJob(config.Id, now); + Assert.Equal(PamRotationJobCreateOutcome.ConfigNotOfferable, + await pamRotationJobRepository.CreateGuardedAsync(job)); + Assert.Null(await pamRotationJobRepository.GetByIdAsync(job.Id)); + } + + // First-claim-wins under real contention: two daemons race the same Pending job on concurrent connections. + // Exactly one wins with the full work snapshot; the loser sees NotClaimable (the job left Pending), and only the + // winner's Executing attempt exists. + [DatabaseTheory, DatabaseData] + public async Task ClaimAsync_ConcurrentDoubleClaim_ExactlyOneWinner( + IOrganizationRepository organizationRepository, + IPamTargetSystemRepository pamTargetSystemRepository, + IApiKeyRepository apiKeyRepository, + IPamDaemonRepository pamDaemonRepository, + ICipherRepository cipherRepository, + IPamRotationConfigRepository pamRotationConfigRepository, + IPamRotationJobRepository pamRotationJobRepository) + { + var fixture = await SeedClaimableJobAsync(organizationRepository, pamTargetSystemRepository, apiKeyRepository, + pamDaemonRepository, cipherRepository, pamRotationConfigRepository, pamRotationJobRepository); + var rival = await CreateEnrolledDaemonAsync(apiKeyRepository, pamDaemonRepository, fixture.Organization.Id); + await AssignAsync(pamDaemonRepository, rival.Id, fixture.Target.Id, fixture.Organization.Id, fixture.Now); + var claimNow = fixture.Now; + + var results = await Task.WhenAll( + pamRotationJobRepository.ClaimAsync(fixture.Job.Id, fixture.Daemon.Id, claimNow, _releaseDelay), + pamRotationJobRepository.ClaimAsync(fixture.Job.Id, rival.Id, claimNow, _releaseDelay)); + + var winner = Assert.Single(results, r => r.Outcome == PamRotationClaimOutcome.Claimed); + var loser = Assert.Single(results, r => r.Outcome != PamRotationClaimOutcome.Claimed); + Assert.Equal(PamRotationClaimOutcome.NotClaimable, loser.Outcome); + Assert.Null(loser.AttemptId); + + // The winner's snapshot is fully populated, including the claim lease's deadline. + Assert.NotNull(winner.AttemptId); + Assert.Equal(fixture.Job.Id, winner.JobId); + Assert.Equal(PamRotationSource.Scheduled, winner.Source); + Assert.Equal(fixture.Target.Id, winner.TargetSystemId); + Assert.Equal(fixture.Target.Name, winner.TargetSystemName); + Assert.Equal(PamTargetSystemKind.Mssql, winner.Kind); + Assert.Equal(fixture.Target.PasswordPolicy, winner.PasswordPolicy); + Assert.Equal(fixture.Cipher.Id, winner.CipherId); + Assert.Equal(fixture.Config.AccountIdentity, winner.AccountIdentity); + Assert.Equal(fixture.Config.TerminateSessions, winner.TerminateSessions); + Assert.Equal(claimNow.Add(_releaseDelay), winner.ExecuteBy); + + // The job records the winning claim, and ExecuteBy is exactly ClaimedAt + releaseDelay. + var job = await pamRotationJobRepository.GetByIdAsync(fixture.Job.Id); + Assert.Equal(PamRotationJobStatus.Claimed, job!.Status); + Assert.NotNull(job.ClaimedByDaemonId); + Assert.Equal(claimNow, job.ClaimedAt); + Assert.Equal(job.ClaimedAt!.Value.Add(_releaseDelay), winner.ExecuteBy); + + // AtMostOneInFlightAttemptPerJob: only the winner's attempt row was inserted. + var details = Assert.Single(await pamRotationJobRepository.GetManyByConfigIdAsync(fixture.Config.Id)); + var attempt = Assert.Single(details.Attempts); + Assert.Equal(winner.AttemptId, attempt.Id); + Assert.Equal(job.ClaimedByDaemonId, attempt.ClaimedByDaemonId); + Assert.Equal(PamRotationAttemptStatus.Executing, attempt.Status); + } + + // The claim sproc's Daemon.OrganizationId = Config.OrganizationId join is defense in depth: even a forged + // assignment row linking an org-B daemon to an org-A target (the assignment FKs do not enforce same-org) must not + // let the foreign daemon claim -- and the poll must not surface the job to it either. + [DatabaseTheory, DatabaseData] + public async Task ClaimAsync_CrossOrganizationDaemonWithForgedAssignment_NotEligibleAndZeroEffect( + IOrganizationRepository organizationRepository, + IPamTargetSystemRepository pamTargetSystemRepository, + IApiKeyRepository apiKeyRepository, + IPamDaemonRepository pamDaemonRepository, + ICipherRepository cipherRepository, + IPamRotationConfigRepository pamRotationConfigRepository, + IPamRotationJobRepository pamRotationJobRepository) + { + var fixture = await SeedClaimableJobAsync(organizationRepository, pamTargetSystemRepository, apiKeyRepository, + pamDaemonRepository, cipherRepository, pamRotationConfigRepository, pamRotationJobRepository); + var foreignOrganization = await organizationRepository.CreateTestOrganizationAsync(); + var foreignDaemon = await CreateEnrolledDaemonAsync( + apiKeyRepository, pamDaemonRepository, foreignOrganization.Id); + // Forge the cross-org assignment directly at the repository layer. + await AssignAsync( + pamDaemonRepository, foreignDaemon.Id, fixture.Target.Id, foreignOrganization.Id, fixture.Now); + + var result = await pamRotationJobRepository.ClaimAsync( + fixture.Job.Id, foreignDaemon.Id, fixture.Now, _releaseDelay); + + Assert.Equal(PamRotationClaimOutcome.NotEligible, result.Outcome); + Assert.Null(result.AttemptId); + + // Zero effect: the job is untouched and no attempt row exists. + var job = await pamRotationJobRepository.GetByIdAsync(fixture.Job.Id); + Assert.Equal(PamRotationJobStatus.Pending, job!.Status); + Assert.Null(job.ClaimedByDaemonId); + Assert.Null(job.ClaimedAt); + var details = Assert.Single(await pamRotationJobRepository.GetManyByConfigIdAsync(fixture.Config.Id)); + Assert.Empty(details.Attempts); + + // The poll re-derives the same org join, so the foreign daemon never even sees the job. + Assert.Empty(await pamRotationJobRepository.GetManyClaimableByDaemonIdAsync(foreignDaemon.Id, fixture.Now)); + } + + [DatabaseTheory, DatabaseData] + public async Task ClaimAsync_UnassignedDaemon_NotEligible( + IOrganizationRepository organizationRepository, + IPamTargetSystemRepository pamTargetSystemRepository, + IApiKeyRepository apiKeyRepository, + IPamDaemonRepository pamDaemonRepository, + ICipherRepository cipherRepository, + IPamRotationConfigRepository pamRotationConfigRepository, + IPamRotationJobRepository pamRotationJobRepository) + { + var fixture = await SeedClaimableJobAsync(organizationRepository, pamTargetSystemRepository, apiKeyRepository, + pamDaemonRepository, cipherRepository, pamRotationConfigRepository, pamRotationJobRepository); + // Same org, enrolled, but never assigned to the target. + var unassigned = await CreateEnrolledDaemonAsync( + apiKeyRepository, pamDaemonRepository, fixture.Organization.Id); + + var result = await pamRotationJobRepository.ClaimAsync( + fixture.Job.Id, unassigned.Id, fixture.Now, _releaseDelay); + + Assert.Equal(PamRotationClaimOutcome.NotEligible, result.Outcome); + var job = await pamRotationJobRepository.GetByIdAsync(fixture.Job.Id); + Assert.Equal(PamRotationJobStatus.Pending, job!.Status); + Assert.Null(job.ClaimedByDaemonId); + } + + [DatabaseTheory, DatabaseData] + public async Task ClaimAsync_BeforeNextClaimableAt_NotClaimable( + IOrganizationRepository organizationRepository, + IPamTargetSystemRepository pamTargetSystemRepository, + IApiKeyRepository apiKeyRepository, + IPamDaemonRepository pamDaemonRepository, + ICipherRepository cipherRepository, + IPamRotationConfigRepository pamRotationConfigRepository, + IPamRotationJobRepository pamRotationJobRepository) + { + var fixture = await SeedClaimableJobAsync(organizationRepository, pamTargetSystemRepository, apiKeyRepository, + pamDaemonRepository, cipherRepository, pamRotationConfigRepository, pamRotationJobRepository, + nextClaimableAt: DateTime.UtcNow.AddHours(1)); + + var result = await pamRotationJobRepository.ClaimAsync( + fixture.Job.Id, fixture.Daemon.Id, fixture.Now, _releaseDelay); + + // The daemon is fully eligible; the job itself is just not claimable yet (still in backoff). + Assert.Equal(PamRotationClaimOutcome.NotClaimable, result.Outcome); + var job = await pamRotationJobRepository.GetByIdAsync(fixture.Job.Id); + Assert.Equal(PamRotationJobStatus.Pending, job!.Status); + Assert.Null(job.ClaimedByDaemonId); + } + + [DatabaseTheory, DatabaseData] + public async Task ClaimAsync_DisabledTargetOrPausedConfig_NotEligible( + IOrganizationRepository organizationRepository, + IPamTargetSystemRepository pamTargetSystemRepository, + IApiKeyRepository apiKeyRepository, + IPamDaemonRepository pamDaemonRepository, + ICipherRepository cipherRepository, + IPamRotationConfigRepository pamRotationConfigRepository, + IPamRotationJobRepository pamRotationJobRepository) + { + var fixture = await SeedClaimableJobAsync(organizationRepository, pamTargetSystemRepository, apiKeyRepository, + pamDaemonRepository, cipherRepository, pamRotationConfigRepository, pamRotationJobRepository); + + // Pause the config after the job was offered: EligibleClaimsOnly's capability half fails. + fixture.Config.Enabled = false; + await pamRotationConfigRepository.ReplaceAsync(fixture.Config); + Assert.Equal(PamRotationClaimOutcome.NotEligible, + (await pamRotationJobRepository.ClaimAsync(fixture.Job.Id, fixture.Daemon.Id, fixture.Now, _releaseDelay)) + .Outcome); + + // Re-enable the config but disable the target: still not eligible. + fixture.Config.Enabled = true; + await pamRotationConfigRepository.ReplaceAsync(fixture.Config); + fixture.Target.Status = PamTargetSystemStatus.Disabled; + await pamTargetSystemRepository.ReplaceAsync(fixture.Target); + Assert.Equal(PamRotationClaimOutcome.NotEligible, + (await pamRotationJobRepository.ClaimAsync(fixture.Job.Id, fixture.Daemon.Id, fixture.Now, _releaseDelay)) + .Outcome); + + // Neither refusal touched the job or created an attempt. + var job = await pamRotationJobRepository.GetByIdAsync(fixture.Job.Id); + Assert.Equal(PamRotationJobStatus.Pending, job!.Status); + Assert.Null(job.ClaimedByDaemonId); + var details = Assert.Single(await pamRotationJobRepository.GetManyByConfigIdAsync(fixture.Config.Id)); + Assert.Empty(details.Attempts); + } + + [DatabaseTheory, DatabaseData] + public async Task AcceptCipherWriteAsync_HappyPath_ReplacesCipherDataAndMarksAttempt( + IOrganizationRepository organizationRepository, + IPamTargetSystemRepository pamTargetSystemRepository, + IApiKeyRepository apiKeyRepository, + IPamDaemonRepository pamDaemonRepository, + ICipherRepository cipherRepository, + IPamRotationConfigRepository pamRotationConfigRepository, + IPamRotationJobRepository pamRotationJobRepository) + { + var fixture = await SeedClaimableJobAsync(organizationRepository, pamTargetSystemRepository, apiKeyRepository, + pamDaemonRepository, cipherRepository, pamRotationConfigRepository, pamRotationJobRepository); + var claim = await pamRotationJobRepository.ClaimAsync( + fixture.Job.Id, fixture.Daemon.Id, fixture.Now, _releaseDelay); + Assert.Equal(PamRotationClaimOutcome.Claimed, claim.Outcome); + var writeNow = fixture.Now.AddMinutes(1); + const string rotatedData = "{\"rotatedSecret\":true}"; + + var outcome = await pamRotationJobRepository.AcceptCipherWriteAsync( + claim.AttemptId!.Value, fixture.Daemon.Id, rotatedData, fixture.Cipher.RevisionDate, writeNow); + + Assert.Equal(PamRotationCipherWriteOutcome.Accepted, outcome); + + // The cipher's Data was replaced and its RevisionDate bumped to the write time. + var cipher = await cipherRepository.GetByIdAsync(fixture.Cipher.Id); + Assert.NotNull(cipher); + Assert.Equal(rotatedData, cipher!.Data); + Assert.Equal(writeNow, cipher.RevisionDate); + + // The attempt records the accepted write; it stays Executing until the success report. + var attempt = await pamRotationJobRepository.GetAttemptByIdAsync(claim.AttemptId.Value); + Assert.True(attempt!.CipherUpdated); + Assert.Equal(PamRotationAttemptStatus.Executing, attempt.Status); + } + + [DatabaseTheory, DatabaseData] + public async Task AcceptCipherWriteAsync_WrongDaemon_RejectedAndNothingPersisted( + IOrganizationRepository organizationRepository, + IPamTargetSystemRepository pamTargetSystemRepository, + IApiKeyRepository apiKeyRepository, + IPamDaemonRepository pamDaemonRepository, + ICipherRepository cipherRepository, + IPamRotationConfigRepository pamRotationConfigRepository, + IPamRotationJobRepository pamRotationJobRepository) + { + var fixture = await SeedClaimableJobAsync(organizationRepository, pamTargetSystemRepository, apiKeyRepository, + pamDaemonRepository, cipherRepository, pamRotationConfigRepository, pamRotationJobRepository); + var claim = await pamRotationJobRepository.ClaimAsync( + fixture.Job.Id, fixture.Daemon.Id, fixture.Now, _releaseDelay); + var impostor = await CreateEnrolledDaemonAsync( + apiKeyRepository, pamDaemonRepository, fixture.Organization.Id); + + var outcome = await pamRotationJobRepository.AcceptCipherWriteAsync( + claim.AttemptId!.Value, impostor.Id, "{\"stolen\":true}", fixture.Cipher.RevisionDate, fixture.Now); + + Assert.Equal(PamRotationCipherWriteOutcome.Rejected, outcome); + var cipher = await cipherRepository.GetByIdAsync(fixture.Cipher.Id); + Assert.Equal(fixture.Cipher.Data, cipher!.Data); + var attempt = await pamRotationJobRepository.GetAttemptByIdAsync(claim.AttemptId.Value); + Assert.False(attempt!.CipherUpdated); + } + + [DatabaseTheory, DatabaseData] + public async Task AcceptCipherWriteAsync_StaleLastKnownRevisionDate_RevisionMismatch( + IOrganizationRepository organizationRepository, + IPamTargetSystemRepository pamTargetSystemRepository, + IApiKeyRepository apiKeyRepository, + IPamDaemonRepository pamDaemonRepository, + ICipherRepository cipherRepository, + IPamRotationConfigRepository pamRotationConfigRepository, + IPamRotationJobRepository pamRotationJobRepository) + { + var fixture = await SeedClaimableJobAsync(organizationRepository, pamTargetSystemRepository, apiKeyRepository, + pamDaemonRepository, cipherRepository, pamRotationConfigRepository, pamRotationJobRepository); + var claim = await pamRotationJobRepository.ClaimAsync( + fixture.Job.Id, fixture.Daemon.Id, fixture.Now, _releaseDelay); + + // Drift beyond the 1-second tolerance: a concurrent user edit would have bumped the revision date since the + // daemon last read the cipher. + var outcome = await pamRotationJobRepository.AcceptCipherWriteAsync( + claim.AttemptId!.Value, fixture.Daemon.Id, "{\"rotated\":true}", + fixture.Cipher.RevisionDate.AddSeconds(-5), fixture.Now); + + Assert.Equal(PamRotationCipherWriteOutcome.RevisionMismatch, outcome); + var cipher = await cipherRepository.GetByIdAsync(fixture.Cipher.Id); + Assert.Equal(fixture.Cipher.Data, cipher!.Data); + var attempt = await pamRotationJobRepository.GetAttemptByIdAsync(claim.AttemptId.Value); + Assert.False(attempt!.CipherUpdated); + } + + // The release-sweep vs cipher-write interleaving: once the sweep has released the job (status back to Pending, + // attempt Abandoned), the daemon's late write must be refused -- the atomic accept sproc re-verifies the claim + // under the same job-row lock the sweep takes. + [DatabaseTheory, DatabaseData] + public async Task AcceptCipherWriteAsync_AfterJobReleased_Rejected( + IOrganizationRepository organizationRepository, + IPamTargetSystemRepository pamTargetSystemRepository, + IApiKeyRepository apiKeyRepository, + IPamDaemonRepository pamDaemonRepository, + ICipherRepository cipherRepository, + IPamRotationConfigRepository pamRotationConfigRepository, + IPamRotationJobRepository pamRotationJobRepository) + { + var now = DateTime.UtcNow; + var claimTime = now.Add(-_releaseDelay).AddMinutes(-5); // Lease already expired by `now`. + var fixture = await SeedClaimableJobAsync(organizationRepository, pamTargetSystemRepository, apiKeyRepository, + pamDaemonRepository, cipherRepository, pamRotationConfigRepository, pamRotationJobRepository, + now: claimTime); + var claim = await pamRotationJobRepository.ClaimAsync( + fixture.Job.Id, fixture.Daemon.Id, claimTime, _releaseDelay); + Assert.Equal(PamRotationClaimOutcome.Claimed, claim.Outcome); + + // The daemon never heartbeats, so by `now` it is stale and its expired lease is released. + var released = await pamRotationJobRepository.ReleaseExpiredLeasesAsync(now, _offlineAfter, _releaseDelay); + Assert.Contains(released, r => r.JobId == fixture.Job.Id); + + var outcome = await pamRotationJobRepository.AcceptCipherWriteAsync( + claim.AttemptId!.Value, fixture.Daemon.Id, "{\"late\":true}", fixture.Cipher.RevisionDate, now); + + Assert.Equal(PamRotationCipherWriteOutcome.Rejected, outcome); + var cipher = await cipherRepository.GetByIdAsync(fixture.Cipher.Id); + Assert.Equal(fixture.Cipher.Data, cipher!.Data); + } + + [DatabaseTheory, DatabaseData] + public async Task MarkAttemptRotatedAsync_WithoutCipherUpdate_Rejected( + IOrganizationRepository organizationRepository, + IPamTargetSystemRepository pamTargetSystemRepository, + IApiKeyRepository apiKeyRepository, + IPamDaemonRepository pamDaemonRepository, + ICipherRepository cipherRepository, + IPamRotationConfigRepository pamRotationConfigRepository, + IPamRotationJobRepository pamRotationJobRepository) + { + var fixture = await SeedClaimableJobAsync(organizationRepository, pamTargetSystemRepository, apiKeyRepository, + pamDaemonRepository, cipherRepository, pamRotationConfigRepository, pamRotationJobRepository); + var claim = await pamRotationJobRepository.ClaimAsync( + fixture.Job.Id, fixture.Daemon.Id, fixture.Now, _releaseDelay); + + // VerifiedBeforeSuccess: a success report with no accepted cipher write cannot resolve the attempt. + var outcome = await pamRotationJobRepository.MarkAttemptRotatedAsync( + claim.AttemptId!.Value, fixture.Daemon.Id, PamSessionTerminationOutcome.NotRequested, fixture.Now); + + Assert.Equal(PamRotationAttemptResolveOutcome.Rejected, outcome); + var attempt = await pamRotationJobRepository.GetAttemptByIdAsync(claim.AttemptId.Value); + Assert.Equal(PamRotationAttemptStatus.Executing, attempt!.Status); + var job = await pamRotationJobRepository.GetByIdAsync(fixture.Job.Id); + Assert.Equal(PamRotationJobStatus.Claimed, job!.Status); + } + + [DatabaseTheory, DatabaseData] + public async Task MarkAttemptRotatedAsync_AfterAcceptedWrite_ResolvesAttemptAndSucceedsJob( + IOrganizationRepository organizationRepository, + IPamTargetSystemRepository pamTargetSystemRepository, + IApiKeyRepository apiKeyRepository, + IPamDaemonRepository pamDaemonRepository, + ICipherRepository cipherRepository, + IPamRotationConfigRepository pamRotationConfigRepository, + IPamRotationJobRepository pamRotationJobRepository) + { + var fixture = await SeedClaimableJobAsync(organizationRepository, pamTargetSystemRepository, apiKeyRepository, + pamDaemonRepository, cipherRepository, pamRotationConfigRepository, pamRotationJobRepository); + var claim = await pamRotationJobRepository.ClaimAsync( + fixture.Job.Id, fixture.Daemon.Id, fixture.Now, _releaseDelay); + Assert.Equal(PamRotationCipherWriteOutcome.Accepted, await pamRotationJobRepository.AcceptCipherWriteAsync( + claim.AttemptId!.Value, fixture.Daemon.Id, "{\"rotated\":true}", fixture.Cipher.RevisionDate, fixture.Now)); + var resolveNow = fixture.Now.AddMinutes(2); + + var outcome = await pamRotationJobRepository.MarkAttemptRotatedAsync( + claim.AttemptId.Value, fixture.Daemon.Id, PamSessionTerminationOutcome.Terminated, resolveNow); + + Assert.Equal(PamRotationAttemptResolveOutcome.Resolved, outcome); + var attempt = await pamRotationJobRepository.GetAttemptByIdAsync(claim.AttemptId.Value); + Assert.Equal(PamRotationAttemptStatus.Rotated, attempt!.Status); + Assert.Equal(PamSessionTerminationOutcome.Terminated, attempt.SessionTermination); + Assert.Equal(resolveNow, attempt.ResolvedDate); + // The attempt keeps the executing daemon's identity permanently... + Assert.Equal(fixture.Daemon.Id, attempt.ClaimedByDaemonId); + + // ...while the job leaves Claimed with its claim fields nulled. + var job = await pamRotationJobRepository.GetByIdAsync(fixture.Job.Id); + Assert.Equal(PamRotationJobStatus.Succeeded, job!.Status); + Assert.Null(job.ClaimedByDaemonId); + Assert.Null(job.ClaimedAt); + } + + [DatabaseTheory, DatabaseData] + public async Task MarkAttemptErroredAsync_WithRetryBudget_RetriesJobWithBackoff( + IOrganizationRepository organizationRepository, + IPamTargetSystemRepository pamTargetSystemRepository, + IApiKeyRepository apiKeyRepository, + IPamDaemonRepository pamDaemonRepository, + ICipherRepository cipherRepository, + IPamRotationConfigRepository pamRotationConfigRepository, + IPamRotationJobRepository pamRotationJobRepository) + { + var fixture = await SeedClaimableJobAsync(organizationRepository, pamTargetSystemRepository, apiKeyRepository, + pamDaemonRepository, cipherRepository, pamRotationConfigRepository, pamRotationJobRepository); + var claim = await pamRotationJobRepository.ClaimAsync( + fixture.Job.Id, fixture.Daemon.Id, fixture.Now, _releaseDelay); + var errorNow = fixture.Now.AddMinutes(1); + var retryBaseDelay = TimeSpan.FromSeconds(60); + + var result = await pamRotationJobRepository.MarkAttemptErroredAsync( + claim.AttemptId!.Value, fixture.Daemon.Id, "target unreachable", PamRotationSyncState.TargetUnchanged, + errorNow, maxAttempts: 5, retryBaseDelay); + + Assert.Equal(PamRotationAttemptResolveOutcome.Resolved, result.Outcome); + Assert.Equal(PamRotationJobStatus.Pending, result.JobStatus); + Assert.Equal(1, result.ErroredAttemptCount); + + var attempt = await pamRotationJobRepository.GetAttemptByIdAsync(claim.AttemptId.Value); + Assert.Equal(PamRotationAttemptStatus.Errored, attempt!.Status); + Assert.Equal("target unreachable", attempt.FailureReason); + Assert.Equal(PamRotationSyncState.TargetUnchanged, attempt.SyncState); + Assert.Equal(errorNow, attempt.ResolvedDate); + + // The job goes back to Pending with the claim fields cleared and the first backoff step applied: + // NextClaimableAt = now + retryBaseDelay * 2^(1-1). + var job = await pamRotationJobRepository.GetByIdAsync(fixture.Job.Id); + Assert.Equal(PamRotationJobStatus.Pending, job!.Status); + Assert.Null(job.ClaimedByDaemonId); + Assert.Null(job.ClaimedAt); + Assert.Equal(errorNow.Add(retryBaseDelay), job.NextClaimableAt); + } + + [DatabaseTheory, DatabaseData] + public async Task MarkAttemptErroredAsync_BudgetExhausted_FailsJob( + IOrganizationRepository organizationRepository, + IPamTargetSystemRepository pamTargetSystemRepository, + IApiKeyRepository apiKeyRepository, + IPamDaemonRepository pamDaemonRepository, + ICipherRepository cipherRepository, + IPamRotationConfigRepository pamRotationConfigRepository, + IPamRotationJobRepository pamRotationJobRepository) + { + var fixture = await SeedClaimableJobAsync(organizationRepository, pamTargetSystemRepository, apiKeyRepository, + pamDaemonRepository, cipherRepository, pamRotationConfigRepository, pamRotationJobRepository); + var claim = await pamRotationJobRepository.ClaimAsync( + fixture.Job.Id, fixture.Daemon.Id, fixture.Now, _releaseDelay); + + // maxAttempts = 1: this first errored attempt already exhausts the budget. + var result = await pamRotationJobRepository.MarkAttemptErroredAsync( + claim.AttemptId!.Value, fixture.Daemon.Id, "still unreachable", PamRotationSyncState.Indeterminate, + fixture.Now, maxAttempts: 1, TimeSpan.FromSeconds(60)); + + Assert.Equal(PamRotationAttemptResolveOutcome.Resolved, result.Outcome); + Assert.Equal(PamRotationJobStatus.Failed, result.JobStatus); + Assert.Equal(1, result.ErroredAttemptCount); + + var job = await pamRotationJobRepository.GetByIdAsync(fixture.Job.Id); + Assert.Equal(PamRotationJobStatus.Failed, job!.Status); + Assert.Null(job.ClaimedByDaemonId); + Assert.Null(job.ClaimedAt); + } + + // Abandoned attempts are never charged against the retry budget: after a release (attempt Abandoned) and a + // re-claim, the first *errored* attempt with maxAttempts = 2 still takes the retry branch -- if the abandoned + // attempt were counted the budget would already be exhausted and the job would fail. + [DatabaseTheory, DatabaseData] + public async Task MarkAttemptErroredAsync_AbandonedAttemptsNotCounted( + IOrganizationRepository organizationRepository, + IPamTargetSystemRepository pamTargetSystemRepository, + IApiKeyRepository apiKeyRepository, + IPamDaemonRepository pamDaemonRepository, + ICipherRepository cipherRepository, + IPamRotationConfigRepository pamRotationConfigRepository, + IPamRotationJobRepository pamRotationJobRepository) + { + var now = DateTime.UtcNow; + var firstClaimTime = now.Add(-_releaseDelay).AddMinutes(-5); + var fixture = await SeedClaimableJobAsync(organizationRepository, pamTargetSystemRepository, apiKeyRepository, + pamDaemonRepository, cipherRepository, pamRotationConfigRepository, pamRotationJobRepository, + now: firstClaimTime); + + // First claim goes stale and is released -> its attempt is Abandoned. + var firstClaim = await pamRotationJobRepository.ClaimAsync( + fixture.Job.Id, fixture.Daemon.Id, firstClaimTime, _releaseDelay); + var released = await pamRotationJobRepository.ReleaseExpiredLeasesAsync(now, _offlineAfter, _releaseDelay); + Assert.Contains(released, r => r.JobId == fixture.Job.Id); + var abandoned = await pamRotationJobRepository.GetAttemptByIdAsync(firstClaim.AttemptId!.Value); + Assert.Equal(PamRotationAttemptStatus.Abandoned, abandoned!.Status); + + // Second claim errors with maxAttempts = 2: errored count is 1 (the abandoned attempt is not charged), so + // the retry branch is taken instead of failing the job. + var secondClaim = await pamRotationJobRepository.ClaimAsync( + fixture.Job.Id, fixture.Daemon.Id, now, _releaseDelay); + Assert.Equal(PamRotationClaimOutcome.Claimed, secondClaim.Outcome); + var result = await pamRotationJobRepository.MarkAttemptErroredAsync( + secondClaim.AttemptId!.Value, fixture.Daemon.Id, "flaky target", PamRotationSyncState.TargetUnchanged, + now, maxAttempts: 2, TimeSpan.FromSeconds(60)); + + Assert.Equal(PamRotationAttemptResolveOutcome.Resolved, result.Outcome); + Assert.Equal(PamRotationJobStatus.Pending, result.JobStatus); + Assert.Equal(1, result.ErroredAttemptCount); + } + + [DatabaseTheory, DatabaseData] + public async Task TimeoutDueAsync_PendingJobPastExpiry_TimedOutAsUnroutable( + IOrganizationRepository organizationRepository, + IPamTargetSystemRepository pamTargetSystemRepository, + IApiKeyRepository apiKeyRepository, + IPamDaemonRepository pamDaemonRepository, + ICipherRepository cipherRepository, + IPamRotationConfigRepository pamRotationConfigRepository, + IPamRotationJobRepository pamRotationJobRepository) + { + var now = DateTime.UtcNow; + var fixture = await SeedClaimableJobAsync(organizationRepository, pamTargetSystemRepository, apiKeyRepository, + pamDaemonRepository, cipherRepository, pamRotationConfigRepository, pamRotationJobRepository, + now: now.AddHours(-2), expiresAt: now.AddMinutes(-1)); + + var timedOut = await pamRotationJobRepository.TimeoutDueAsync(now); + + // The sweep is set-based across the whole table, so scope the assertion to this test's job. + var row = Assert.Single(timedOut, r => r.JobId == fixture.Job.Id); + Assert.Equal(fixture.Config.Id, row.RotationConfigId); + Assert.Equal(fixture.Organization.Id, row.OrganizationId); + Assert.Equal(fixture.Cipher.Id, row.CipherId); + Assert.Equal(PamRotationSource.Scheduled, row.Source); + // Never claimed: unroutable, not stuck. + Assert.Null(row.ClaimedByDaemonId); + Assert.Equal(0, row.AttemptCount); + + var job = await pamRotationJobRepository.GetByIdAsync(fixture.Job.Id); + Assert.Equal(PamRotationJobStatus.TimedOut, job!.Status); + } + + [DatabaseTheory, DatabaseData] + public async Task TimeoutDueAsync_ClaimedJobPastExpiry_TimedOutAndAttemptAbandoned( + IOrganizationRepository organizationRepository, + IPamTargetSystemRepository pamTargetSystemRepository, + IApiKeyRepository apiKeyRepository, + IPamDaemonRepository pamDaemonRepository, + ICipherRepository cipherRepository, + IPamRotationConfigRepository pamRotationConfigRepository, + IPamRotationJobRepository pamRotationJobRepository) + { + var now = DateTime.UtcNow; + var claimTime = now.AddHours(-2); + var fixture = await SeedClaimableJobAsync(organizationRepository, pamTargetSystemRepository, apiKeyRepository, + pamDaemonRepository, cipherRepository, pamRotationConfigRepository, pamRotationJobRepository, + now: claimTime, expiresAt: now.AddMinutes(-1)); + var claim = await pamRotationJobRepository.ClaimAsync( + fixture.Job.Id, fixture.Daemon.Id, claimTime, _releaseDelay); + Assert.Equal(PamRotationClaimOutcome.Claimed, claim.Outcome); + + var timedOut = await pamRotationJobRepository.TimeoutDueAsync(now); + + var row = Assert.Single(timedOut, r => r.JobId == fixture.Job.Id); + // Claimed at timeout: stuck, not unroutable. + Assert.Equal(fixture.Daemon.Id, row.ClaimedByDaemonId); + Assert.Equal(1, row.AttemptCount); + + var job = await pamRotationJobRepository.GetByIdAsync(fixture.Job.Id); + Assert.Equal(PamRotationJobStatus.TimedOut, job!.Status); + Assert.Null(job.ClaimedByDaemonId); + Assert.Null(job.ClaimedAt); + var attempt = await pamRotationJobRepository.GetAttemptByIdAsync(claim.AttemptId!.Value); + Assert.Equal(PamRotationAttemptStatus.Abandoned, attempt!.Status); + Assert.Equal(now, attempt.ResolvedDate); + } + + // Success wins: a job whose attempt reached Rotated is never timed out, no matter how far past ExpiresAt it is. + // (A Rotated attempt only ever exists on a job MarkAttemptRotatedAsync atomically moved to Succeeded, so the + // observable surface is a Succeeded job the sweep must leave alone; the sproc's NOT-EXISTS-Rotated guard is + // defense in depth for the same rule.) + [DatabaseTheory, DatabaseData] + public async Task TimeoutDueAsync_JobWithRotatedAttempt_NotTimedOut( + IOrganizationRepository organizationRepository, + IPamTargetSystemRepository pamTargetSystemRepository, + IApiKeyRepository apiKeyRepository, + IPamDaemonRepository pamDaemonRepository, + ICipherRepository cipherRepository, + IPamRotationConfigRepository pamRotationConfigRepository, + IPamRotationJobRepository pamRotationJobRepository) + { + var now = DateTime.UtcNow; + var claimTime = now.AddHours(-2); + var fixture = await SeedClaimableJobAsync(organizationRepository, pamTargetSystemRepository, apiKeyRepository, + pamDaemonRepository, cipherRepository, pamRotationConfigRepository, pamRotationJobRepository, + now: claimTime, expiresAt: now.AddMinutes(-1)); + var claim = await pamRotationJobRepository.ClaimAsync( + fixture.Job.Id, fixture.Daemon.Id, claimTime, _releaseDelay); + Assert.Equal(PamRotationCipherWriteOutcome.Accepted, await pamRotationJobRepository.AcceptCipherWriteAsync( + claim.AttemptId!.Value, fixture.Daemon.Id, "{\"rotated\":true}", fixture.Cipher.RevisionDate, claimTime)); + Assert.Equal(PamRotationAttemptResolveOutcome.Resolved, await pamRotationJobRepository.MarkAttemptRotatedAsync( + claim.AttemptId.Value, fixture.Daemon.Id, PamSessionTerminationOutcome.NotRequested, claimTime)); + + var timedOut = await pamRotationJobRepository.TimeoutDueAsync(now); + + Assert.DoesNotContain(timedOut, r => r.JobId == fixture.Job.Id); + var job = await pamRotationJobRepository.GetByIdAsync(fixture.Job.Id); + Assert.Equal(PamRotationJobStatus.Succeeded, job!.Status); + var attempt = await pamRotationJobRepository.GetAttemptByIdAsync(claim.AttemptId.Value); + Assert.Equal(PamRotationAttemptStatus.Rotated, attempt!.Status); + } + + [DatabaseTheory, DatabaseData] + public async Task ReleaseExpiredLeasesAsync_StaleDaemonPastExecuteBy_ReleasesJobAndAbandonsAttempt( + IOrganizationRepository organizationRepository, + IPamTargetSystemRepository pamTargetSystemRepository, + IApiKeyRepository apiKeyRepository, + IPamDaemonRepository pamDaemonRepository, + ICipherRepository cipherRepository, + IPamRotationConfigRepository pamRotationConfigRepository, + IPamRotationJobRepository pamRotationJobRepository) + { + var now = DateTime.UtcNow; + var claimTime = now.Add(-_releaseDelay).AddMinutes(-5); // ExecuteBy = claimTime + releaseDelay < now. + var fixture = await SeedClaimableJobAsync(organizationRepository, pamTargetSystemRepository, apiKeyRepository, + pamDaemonRepository, cipherRepository, pamRotationConfigRepository, pamRotationJobRepository, + now: claimTime); + var claim = await pamRotationJobRepository.ClaimAsync( + fixture.Job.Id, fixture.Daemon.Id, claimTime, _releaseDelay); + Assert.Equal(PamRotationClaimOutcome.Claimed, claim.Outcome); + // The daemon never heartbeats, so it is stale (LastHeartbeatAt null). + + var released = await pamRotationJobRepository.ReleaseExpiredLeasesAsync(now, _offlineAfter, _releaseDelay); + + var row = Assert.Single(released, r => r.JobId == fixture.Job.Id); + Assert.Equal(fixture.Config.Id, row.RotationConfigId); + Assert.Equal(fixture.Organization.Id, row.OrganizationId); + Assert.Equal(fixture.Cipher.Id, row.CipherId); + // The pre-clear claimant survives on the audit row even though the job's own field is nulled. + Assert.Equal(fixture.Daemon.Id, row.ClaimedByDaemonId); + + var job = await pamRotationJobRepository.GetByIdAsync(fixture.Job.Id); + Assert.Equal(PamRotationJobStatus.Pending, job!.Status); + Assert.Null(job.ClaimedByDaemonId); + Assert.Null(job.ClaimedAt); + // Re-claimable exactly at the lease's end (pre-clear ClaimedAt + releaseDelay), not at the sweep's run time. + Assert.Equal(claimTime.Add(_releaseDelay), job.NextClaimableAt); + + var attempt = await pamRotationJobRepository.GetAttemptByIdAsync(claim.AttemptId!.Value); + Assert.Equal(PamRotationAttemptStatus.Abandoned, attempt!.Status); + Assert.Equal(now, attempt.ResolvedDate); + } + + [DatabaseTheory, DatabaseData] + public async Task ReleaseExpiredLeasesAsync_FreshHeartbeat_NotReleased( + IOrganizationRepository organizationRepository, + IPamTargetSystemRepository pamTargetSystemRepository, + IApiKeyRepository apiKeyRepository, + IPamDaemonRepository pamDaemonRepository, + ICipherRepository cipherRepository, + IPamRotationConfigRepository pamRotationConfigRepository, + IPamRotationJobRepository pamRotationJobRepository) + { + var now = DateTime.UtcNow; + var claimTime = now.Add(-_releaseDelay).AddMinutes(-5); // Lease expired... + var fixture = await SeedClaimableJobAsync(organizationRepository, pamTargetSystemRepository, apiKeyRepository, + pamDaemonRepository, cipherRepository, pamRotationConfigRepository, pamRotationJobRepository, + now: claimTime); + var claim = await pamRotationJobRepository.ClaimAsync( + fixture.Job.Id, fixture.Daemon.Id, claimTime, _releaseDelay); + // ...but the daemon is slow, not gone: a fresh heartbeat keeps the claim alive (success wins for a + // slow-but-live daemon whose report may still land). + await pamDaemonRepository.UpdateHeartbeatAsync(fixture.Daemon.Id, now, TimeSpan.FromSeconds(15)); + + var released = await pamRotationJobRepository.ReleaseExpiredLeasesAsync(now, _offlineAfter, _releaseDelay); + + Assert.DoesNotContain(released, r => r.JobId == fixture.Job.Id); + var job = await pamRotationJobRepository.GetByIdAsync(fixture.Job.Id); + Assert.Equal(PamRotationJobStatus.Claimed, job!.Status); + Assert.Equal(fixture.Daemon.Id, job.ClaimedByDaemonId); + var attempt = await pamRotationJobRepository.GetAttemptByIdAsync(claim.AttemptId!.Value); + Assert.Equal(PamRotationAttemptStatus.Executing, attempt!.Status); + } + + [DatabaseTheory, DatabaseData] + public async Task ReleaseExpiredLeasesAsync_WithinLease_NotReleased( + IOrganizationRepository organizationRepository, + IPamTargetSystemRepository pamTargetSystemRepository, + IApiKeyRepository apiKeyRepository, + IPamDaemonRepository pamDaemonRepository, + ICipherRepository cipherRepository, + IPamRotationConfigRepository pamRotationConfigRepository, + IPamRotationJobRepository pamRotationJobRepository) + { + var now = DateTime.UtcNow; + var fixture = await SeedClaimableJobAsync(organizationRepository, pamTargetSystemRepository, apiKeyRepository, + pamDaemonRepository, cipherRepository, pamRotationConfigRepository, pamRotationJobRepository, now: now); + // Claimed just now: the daemon is heartbeat-stale (it never beat), but ExecuteBy is still releaseDelay away -- + // release fires at lease expiry, never at stale detection. + var claim = await pamRotationJobRepository.ClaimAsync( + fixture.Job.Id, fixture.Daemon.Id, now, _releaseDelay); + + var released = await pamRotationJobRepository.ReleaseExpiredLeasesAsync(now, _offlineAfter, _releaseDelay); + + Assert.DoesNotContain(released, r => r.JobId == fixture.Job.Id); + var job = await pamRotationJobRepository.GetByIdAsync(fixture.Job.Id); + Assert.Equal(PamRotationJobStatus.Claimed, job!.Status); + var attempt = await pamRotationJobRepository.GetAttemptByIdAsync(claim.AttemptId!.Value); + Assert.Equal(PamRotationAttemptStatus.Executing, attempt!.Status); + } + + // Success wins on the release path too: a job whose attempt reached Rotated is never released. As with the + // timeout sweep, the reachable surface is the Succeeded job the atomic success report produced. + [DatabaseTheory, DatabaseData] + public async Task ReleaseExpiredLeasesAsync_JobWithRotatedAttempt_NotReleased( + IOrganizationRepository organizationRepository, + IPamTargetSystemRepository pamTargetSystemRepository, + IApiKeyRepository apiKeyRepository, + IPamDaemonRepository pamDaemonRepository, + ICipherRepository cipherRepository, + IPamRotationConfigRepository pamRotationConfigRepository, + IPamRotationJobRepository pamRotationJobRepository) + { + var now = DateTime.UtcNow; + var claimTime = now.Add(-_releaseDelay).AddMinutes(-5); + var fixture = await SeedClaimableJobAsync(organizationRepository, pamTargetSystemRepository, apiKeyRepository, + pamDaemonRepository, cipherRepository, pamRotationConfigRepository, pamRotationJobRepository, + now: claimTime); + var claim = await pamRotationJobRepository.ClaimAsync( + fixture.Job.Id, fixture.Daemon.Id, claimTime, _releaseDelay); + Assert.Equal(PamRotationCipherWriteOutcome.Accepted, await pamRotationJobRepository.AcceptCipherWriteAsync( + claim.AttemptId!.Value, fixture.Daemon.Id, "{\"rotated\":true}", fixture.Cipher.RevisionDate, claimTime)); + Assert.Equal(PamRotationAttemptResolveOutcome.Resolved, await pamRotationJobRepository.MarkAttemptRotatedAsync( + claim.AttemptId.Value, fixture.Daemon.Id, PamSessionTerminationOutcome.NotRequested, claimTime)); + + var released = await pamRotationJobRepository.ReleaseExpiredLeasesAsync(now, _offlineAfter, _releaseDelay); + + Assert.DoesNotContain(released, r => r.JobId == fixture.Job.Id); + var job = await pamRotationJobRepository.GetByIdAsync(fixture.Job.Id); + Assert.Equal(PamRotationJobStatus.Succeeded, job!.Status); + var attempt = await pamRotationJobRepository.GetAttemptByIdAsync(claim.AttemptId.Value); + Assert.Equal(PamRotationAttemptStatus.Rotated, attempt!.Status); + } + + private sealed record ClaimableJobFixture( + Organization Organization, + PamTargetSystem Target, + PamDaemon Daemon, + Cipher Cipher, + PamRotationConfig Config, + PamRotationJob Job, + DateTime Now); + + /// + /// Seeds the full eligibility graph for a claimable job: org, active automatic target, enrolled+assigned daemon, + /// org cipher, enabled config, and a Pending job created through the guarded sproc. lets + /// sweep tests place the whole graph in the past; / + /// override the job's window (defaults keep it claimable now and far from any concurrently-running sweep test's + /// cutoff). + /// + private static async Task SeedClaimableJobAsync( + IOrganizationRepository organizationRepository, + IPamTargetSystemRepository pamTargetSystemRepository, + IApiKeyRepository apiKeyRepository, + IPamDaemonRepository pamDaemonRepository, + ICipherRepository cipherRepository, + IPamRotationConfigRepository pamRotationConfigRepository, + IPamRotationJobRepository pamRotationJobRepository, + DateTime? now = null, + DateTime? expiresAt = null, + DateTime? nextClaimableAt = null) + { + var seedNow = now ?? DateTime.UtcNow; + var organization = await organizationRepository.CreateTestOrganizationAsync(); + var target = await CreateAutomaticTargetAsync(pamTargetSystemRepository, organization.Id, seedNow); + var daemon = await CreateEnrolledDaemonAsync(apiKeyRepository, pamDaemonRepository, organization.Id); + await AssignAsync(pamDaemonRepository, daemon.Id, target.Id, organization.Id, seedNow); + var cipher = await CreateCipherAsync(cipherRepository, organization.Id); + var config = await pamRotationConfigRepository.CreateAsync( + BuildConfig(organization.Id, cipher.Id, target.Id, seedNow)); + + var job = BuildPendingJob(config.Id, seedNow, expiresAt, nextClaimableAt); + Assert.Equal(PamRotationJobCreateOutcome.Created, await pamRotationJobRepository.CreateGuardedAsync(job)); + + return new ClaimableJobFixture(organization, target, daemon, cipher, config, job, seedNow); + } + + private static async Task CreateAutomaticTargetAsync( + IPamTargetSystemRepository pamTargetSystemRepository, Guid organizationId, DateTime now) + => await pamTargetSystemRepository.CreateAsync(new PamTargetSystem + { + OrganizationId = organizationId, + Name = $"target-{Guid.NewGuid()}", + Method = PamTargetSystemMethod.Automatic, + Kind = PamTargetSystemKind.Mssql, + PasswordPolicy = """{"minLength":16,"maxLength":32}""", + SupportsSessionTermination = true, + Status = PamTargetSystemStatus.Active, + CreationDate = now, + RevisionDate = now, + }); + + private static async Task CreateEnrolledDaemonAsync( + IApiKeyRepository apiKeyRepository, IPamDaemonRepository pamDaemonRepository, Guid organizationId) + { + var apiKey = await apiKeyRepository.CreateAsync(new ApiKey + { + ServiceAccountId = null, + Name = $"daemon-{Guid.NewGuid()}", + Scope = """["api.pam.rotation"]""", + EncryptedPayload = "encrypted-payload", + Key = "encrypted-key", + }); + return await pamDaemonRepository.CreateAsync(new PamDaemon + { + OrganizationId = organizationId, + Name = $"daemon-{Guid.NewGuid()}", + ApiKeyId = apiKey.Id, + Status = PamDaemonStatus.Enrolled, + }); + } + + private static async Task AssignAsync( + IPamDaemonRepository pamDaemonRepository, Guid daemonId, Guid targetSystemId, Guid organizationId, DateTime now) + => await pamDaemonRepository.CreateAssignmentAsync(new PamDaemonTargetAssignment + { + Id = CoreHelpers.GenerateComb(), + DaemonId = daemonId, + TargetSystemId = targetSystemId, + OrganizationId = organizationId, + CreationDate = now, + }); + + private static async Task CreateCipherAsync(ICipherRepository cipherRepository, Guid organizationId) + => await cipherRepository.CreateAsync(new Cipher + { + OrganizationId = organizationId, + Type = CipherType.Login, + Data = "{\"originalSecret\":true}", + }); + + private static PamRotationConfig BuildConfig( + Guid organizationId, Guid cipherId, Guid targetSystemId, DateTime now, bool enabled = true) => new() + { + OrganizationId = organizationId, + CipherId = cipherId, + TargetSystemId = targetSystemId, + AccountIdentity = "svc-account", + TerminateSessions = true, + RotateOnAccessEnd = false, + Enabled = enabled, + CreationDate = now, + RevisionDate = now, + }; + + // ExpiresAt defaults far into the future so a concurrently-running timeout-sweep test (the sweeps are set-based + // across the whole table) never times this job out from under its own test. + private static PamRotationJob BuildPendingJob( + Guid configId, DateTime now, DateTime? expiresAt = null, DateTime? nextClaimableAt = null) => new() + { + Id = CoreHelpers.GenerateComb(), + RotationConfigId = configId, + Source = PamRotationSource.Scheduled, + Status = PamRotationJobStatus.Pending, + CreationDate = now, + NextClaimableAt = nextClaimableAt ?? now, + ExpiresAt = expiresAt ?? DateTime.UtcNow.AddDays(1), + }; +} diff --git a/test/Infrastructure.IntegrationTest/Pam/Repositories/PamTargetSystemRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Pam/Repositories/PamTargetSystemRepositoryTests.cs new file mode 100644 index 000000000000..e0940e0b8d3b --- /dev/null +++ b/test/Infrastructure.IntegrationTest/Pam/Repositories/PamTargetSystemRepositoryTests.cs @@ -0,0 +1,109 @@ +using Bit.Core.Repositories; +using Bit.Infrastructure.IntegrationTest.AdminConsole; +using Bit.Pam.Entities; +using Bit.Pam.Enums; +using Bit.Pam.Repositories; +using Xunit; + +namespace Bit.Infrastructure.IntegrationTest.Pam.Repositories; + +public class PamTargetSystemRepositoryTests +{ + [DatabaseTheory, DatabaseData] + public async Task CreateAsync_ThenRead_RoundTripsFields( + IOrganizationRepository organizationRepository, + IPamTargetSystemRepository pamTargetSystemRepository) + { + var organization = await organizationRepository.CreateTestOrganizationAsync(); + var now = DateTime.UtcNow; + + var target = await pamTargetSystemRepository.CreateAsync(new PamTargetSystem + { + OrganizationId = organization.Id, + Name = "prod-mssql", + Method = PamTargetSystemMethod.Automatic, + Kind = PamTargetSystemKind.Mssql, + PasswordPolicy = """{"minLength":16,"maxLength":32,"includeUppercase":true,"includeDigits":true}""", + SupportsSessionTermination = true, + Status = PamTargetSystemStatus.Active, + CreationDate = now, + RevisionDate = now, + }); + + var persisted = await pamTargetSystemRepository.GetByIdAsync(target.Id); + + Assert.NotNull(persisted); + Assert.Equal(organization.Id, persisted!.OrganizationId); + Assert.Equal("prod-mssql", persisted.Name); + Assert.Equal(PamTargetSystemMethod.Automatic, persisted.Method); + Assert.Equal(PamTargetSystemKind.Mssql, persisted.Kind); + Assert.Contains("minLength", persisted.PasswordPolicy); + Assert.True(persisted.SupportsSessionTermination); + Assert.Equal(PamTargetSystemStatus.Active, persisted.Status); + } + + // A manual target carries no connector: Kind/PasswordPolicy stay null through the round trip, and the narrow + // fields a rename/status-change touches (Name, Status, RevisionDate) persist via the generic ReplaceAsync. + [DatabaseTheory, DatabaseData] + public async Task ReplaceAsync_UpdatesFields( + IOrganizationRepository organizationRepository, + IPamTargetSystemRepository pamTargetSystemRepository) + { + var organization = await organizationRepository.CreateTestOrganizationAsync(); + var now = DateTime.UtcNow; + + var target = await pamTargetSystemRepository.CreateAsync(new PamTargetSystem + { + OrganizationId = organization.Id, + Name = "manual-vault", + Method = PamTargetSystemMethod.Manual, + Status = PamTargetSystemStatus.Active, + CreationDate = now, + RevisionDate = now, + }); + Assert.Null(target.Kind); + Assert.Null(target.PasswordPolicy); + + target.Name = "manual-vault-renamed"; + target.Status = PamTargetSystemStatus.Disabled; + target.RevisionDate = now.AddMinutes(5); + await pamTargetSystemRepository.ReplaceAsync(target); + + var persisted = await pamTargetSystemRepository.GetByIdAsync(target.Id); + Assert.NotNull(persisted); + Assert.Equal("manual-vault-renamed", persisted!.Name); + Assert.Equal(PamTargetSystemStatus.Disabled, persisted.Status); + Assert.Equal(now.AddMinutes(5), persisted.RevisionDate); + Assert.Null(persisted.Kind); + Assert.Null(persisted.PasswordPolicy); + } + + [DatabaseTheory, DatabaseData] + public async Task GetManyByOrganizationIdAsync_ScopesToOrganization( + IOrganizationRepository organizationRepository, + IPamTargetSystemRepository pamTargetSystemRepository) + { + var organization = await organizationRepository.CreateTestOrganizationAsync(); + var otherOrganization = await organizationRepository.CreateTestOrganizationAsync(); + var now = DateTime.UtcNow; + + var mine = await pamTargetSystemRepository.CreateAsync(BuildTarget(organization.Id, "mine", now)); + await pamTargetSystemRepository.CreateAsync(BuildTarget(otherOrganization.Id, "not-mine", now)); + + var results = await pamTargetSystemRepository.GetManyByOrganizationIdAsync(organization.Id); + + var row = Assert.Single(results); + Assert.Equal(mine.Id, row.Id); + } + + 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, + }; +} diff --git a/test/IntegrationTestCommon/packages.lock.json b/test/IntegrationTestCommon/packages.lock.json index d5af571cc619..e1dc98a6dc57 100644 --- a/test/IntegrationTestCommon/packages.lock.json +++ b/test/IntegrationTestCommon/packages.lock.json @@ -1700,7 +1700,7 @@ "dependencies": { "AutoFixture.AutoNSubstitute": "[4.18.1, )", "AutoFixture.Xunit2": "[4.18.1, )", - "Core": "[2026.6.1, )", + "Core": "[2026.6.2, )", "Kralizek.AutoFixture.Extensions.MockHttp": "[2.2.1, 2.2.1]", "Microsoft.Extensions.TimeProvider.Testing": "[10.6.0, 10.6.0]", "Microsoft.NET.Test.Sdk": "[18.0.1, )", @@ -1724,7 +1724,7 @@ "BitPay.Light": "[1.0.1907, 1.0.1907]", "Braintree": "[5.36.0, 5.36.0]", "CsvHelper": "[33.1.0, 33.1.0]", - "Data": "[2026.6.1, )", + "Data": "[2026.6.2, )", "DnsClient": "[1.8.0, 1.8.0]", "Duende.IdentityServer": "[7.4.6, 7.4.6]", "DuoUniversal": "[1.3.1, 1.3.1]", @@ -1749,7 +1749,7 @@ "Newtonsoft.Json": "[13.0.3, 13.0.3]", "OneOf": "[3.0.271, 3.0.271]", "Otp.NET": "[1.4.0, 1.4.0]", - "Pam.Domain": "[2026.6.1, )", + "Pam.Domain": "[2026.6.2, )", "Quartz": "[3.15.1, 3.15.1]", "Quartz.Extensions.DependencyInjection": "[3.15.1, 3.15.1]", "Quartz.Extensions.Hosting": "[3.15.1, 3.15.1]", @@ -1769,7 +1769,7 @@ "identity": { "type": "Project", "dependencies": { - "Core": "[2026.6.1, )", + "Core": "[2026.6.2, )", "OpenTelemetry.Exporter.OpenTelemetryProtocol": "[1.15.3, )", "OpenTelemetry.Extensions.Hosting": "[1.15.3, )", "OpenTelemetry.Instrumentation.AspNetCore": "[1.15.0, )", @@ -1777,27 +1777,28 @@ "OpenTelemetry.Instrumentation.Http": "[1.15.0, )", "OpenTelemetry.Instrumentation.Runtime": "[1.15.0, )", "OpenTelemetry.Instrumentation.SqlClient": "[1.12.0-beta.3, )", - "SharedWeb": "[2026.6.1, )" + "Pam.Domain": "[2026.6.2, )", + "SharedWeb": "[2026.6.2, )" } }, "infrastructure.dapper": { "type": "Project", "dependencies": { - "Core": "[2026.6.1, )", + "Core": "[2026.6.2, )", "Dapper": "[2.1.66, 2.1.66]", - "Pam.Domain": "[2026.6.1, )" + "Pam.Domain": "[2026.6.2, )" } }, "infrastructure.entityframework": { "type": "Project", "dependencies": { "AutoMapper": "[14.0.0, 14.0.0]", - "Core": "[2026.6.1, )", + "Core": "[2026.6.2, )", "Microsoft.EntityFrameworkCore.Relational": "[8.0.8, 8.0.8]", "Microsoft.EntityFrameworkCore.SqlServer": "[8.0.8, 8.0.8]", "Microsoft.EntityFrameworkCore.Sqlite": "[8.0.8, 8.0.8]", "Npgsql.EntityFrameworkCore.PostgreSQL": "[8.0.4, 8.0.4]", - "Pam.Domain": "[2026.6.1, )", + "Pam.Domain": "[2026.6.2, )", "Pomelo.EntityFrameworkCore.MySql": "[8.0.2, 8.0.2]", "linq2db": "[5.4.1, 5.4.1]", "linq2db.EntityFrameworkCore": "[8.1.0, 8.1.0]" @@ -1806,7 +1807,7 @@ "migrator": { "type": "Project", "dependencies": { - "Core": "[2026.6.1, )", + "Core": "[2026.6.2, )", "Microsoft.Extensions.Logging": "[10.0.8, 10.0.8]", "dbup-sqlserver": "[7.2.0, 7.2.0]" } @@ -1814,7 +1815,7 @@ "pam.domain": { "type": "Project", "dependencies": { - "Data": "[2026.6.1, )" + "Data": "[2026.6.2, )" } }, "rustsdk": { @@ -1824,18 +1825,18 @@ "type": "Project", "dependencies": { "Bogus": "[35.6.5, 35.6.5]", - "Core": "[2026.6.1, )", - "Infrastructure.EntityFramework": "[2026.6.1, )", - "RustSdk": "[2026.6.1, )", - "SharedWeb": "[2026.6.1, )" + "Core": "[2026.6.2, )", + "Infrastructure.EntityFramework": "[2026.6.2, )", + "RustSdk": "[2026.6.2, )", + "SharedWeb": "[2026.6.2, )" } }, "sharedweb": { "type": "Project", "dependencies": { - "Core": "[2026.6.1, )", - "Infrastructure.Dapper": "[2026.6.1, )", - "Infrastructure.EntityFramework": "[2026.6.1, )", + "Core": "[2026.6.2, )", + "Infrastructure.Dapper": "[2026.6.2, )", + "Infrastructure.EntityFramework": "[2026.6.2, )", "Microsoft.Bot.Builder.Integration.AspNet.Core": "[4.23.0, 4.23.0]", "Swashbuckle.AspNetCore.SwaggerGen": "[10.1.7, 10.1.7]" } diff --git a/test/SeederApi.IntegrationTest/packages.lock.json b/test/SeederApi.IntegrationTest/packages.lock.json index c58530df22c6..5a1c5f7bb2d7 100644 --- a/test/SeederApi.IntegrationTest/packages.lock.json +++ b/test/SeederApi.IntegrationTest/packages.lock.json @@ -1293,7 +1293,7 @@ "dependencies": { "AutoFixture.AutoNSubstitute": "[4.18.1, )", "AutoFixture.Xunit2": "[4.18.1, )", - "Core": "[2026.6.1, )", + "Core": "[2026.6.2, )", "Kralizek.AutoFixture.Extensions.MockHttp": "[2.2.1, 2.2.1]", "Microsoft.Extensions.TimeProvider.Testing": "[10.6.0, 10.6.0]", "Microsoft.NET.Test.Sdk": "[18.0.1, )", @@ -1317,7 +1317,7 @@ "BitPay.Light": "[1.0.1907, 1.0.1907]", "Braintree": "[5.36.0, 5.36.0]", "CsvHelper": "[33.1.0, 33.1.0]", - "Data": "[2026.6.1, )", + "Data": "[2026.6.2, )", "DnsClient": "[1.8.0, 1.8.0]", "Duende.IdentityServer": "[7.4.6, 7.4.6]", "DuoUniversal": "[1.3.1, 1.3.1]", @@ -1342,7 +1342,7 @@ "Newtonsoft.Json": "[13.0.3, 13.0.3]", "OneOf": "[3.0.271, 3.0.271]", "Otp.NET": "[1.4.0, 1.4.0]", - "Pam.Domain": "[2026.6.1, )", + "Pam.Domain": "[2026.6.2, )", "Quartz": "[3.15.1, 3.15.1]", "Quartz.Extensions.DependencyInjection": "[3.15.1, 3.15.1]", "Quartz.Extensions.Hosting": "[3.15.1, 3.15.1]", @@ -1362,7 +1362,7 @@ "identity": { "type": "Project", "dependencies": { - "Core": "[2026.6.1, )", + "Core": "[2026.6.2, )", "OpenTelemetry.Exporter.OpenTelemetryProtocol": "[1.15.3, )", "OpenTelemetry.Extensions.Hosting": "[1.15.3, )", "OpenTelemetry.Instrumentation.AspNetCore": "[1.15.0, )", @@ -1370,27 +1370,28 @@ "OpenTelemetry.Instrumentation.Http": "[1.15.0, )", "OpenTelemetry.Instrumentation.Runtime": "[1.15.0, )", "OpenTelemetry.Instrumentation.SqlClient": "[1.12.0-beta.3, )", - "SharedWeb": "[2026.6.1, )" + "Pam.Domain": "[2026.6.2, )", + "SharedWeb": "[2026.6.2, )" } }, "infrastructure.dapper": { "type": "Project", "dependencies": { - "Core": "[2026.6.1, )", + "Core": "[2026.6.2, )", "Dapper": "[2.1.66, 2.1.66]", - "Pam.Domain": "[2026.6.1, )" + "Pam.Domain": "[2026.6.2, )" } }, "infrastructure.entityframework": { "type": "Project", "dependencies": { "AutoMapper": "[14.0.0, 14.0.0]", - "Core": "[2026.6.1, )", + "Core": "[2026.6.2, )", "Microsoft.EntityFrameworkCore.Relational": "[8.0.8, 8.0.8]", "Microsoft.EntityFrameworkCore.SqlServer": "[8.0.8, 8.0.8]", "Microsoft.EntityFrameworkCore.Sqlite": "[8.0.8, 8.0.8]", "Npgsql.EntityFrameworkCore.PostgreSQL": "[8.0.4, 8.0.4]", - "Pam.Domain": "[2026.6.1, )", + "Pam.Domain": "[2026.6.2, )", "Pomelo.EntityFrameworkCore.MySql": "[8.0.2, 8.0.2]", "linq2db": "[5.4.1, 5.4.1]", "linq2db.EntityFrameworkCore": "[8.1.0, 8.1.0]" @@ -1399,17 +1400,17 @@ "integrationtestcommon": { "type": "Project", "dependencies": { - "Common": "[2026.6.1, )", - "Identity": "[2026.6.1, )", + "Common": "[2026.6.2, )", + "Identity": "[2026.6.2, )", "Microsoft.AspNetCore.Mvc.Testing": "[10.0.8, 10.0.8]", - "Migrator": "[2026.6.1, )", - "Seeder": "[2026.6.1, )" + "Migrator": "[2026.6.2, )", + "Seeder": "[2026.6.2, )" } }, "migrator": { "type": "Project", "dependencies": { - "Core": "[2026.6.1, )", + "Core": "[2026.6.2, )", "Microsoft.Extensions.Logging": "[10.0.8, 10.0.8]", "dbup-sqlserver": "[7.2.0, 7.2.0]" } @@ -1417,7 +1418,7 @@ "pam.domain": { "type": "Project", "dependencies": { - "Data": "[2026.6.1, )" + "Data": "[2026.6.2, )" } }, "rustsdk": { @@ -1427,34 +1428,34 @@ "type": "Project", "dependencies": { "Bogus": "[35.6.5, 35.6.5]", - "Core": "[2026.6.1, )", - "Infrastructure.EntityFramework": "[2026.6.1, )", - "RustSdk": "[2026.6.1, )", - "SharedWeb": "[2026.6.1, )" + "Core": "[2026.6.2, )", + "Infrastructure.EntityFramework": "[2026.6.2, )", + "RustSdk": "[2026.6.2, )", + "SharedWeb": "[2026.6.2, )" } }, "seederapi": { "type": "Project", "dependencies": { - "Core": "[2026.6.1, )", - "Seeder": "[2026.6.1, )", - "SharedWeb": "[2026.6.1, )" + "Core": "[2026.6.2, )", + "Seeder": "[2026.6.2, )", + "SharedWeb": "[2026.6.2, )" } }, "seederutility": { "type": "Project", "dependencies": { "CommandDotNet": "[7.0.5, 7.0.5]", - "Seeder": "[2026.6.1, )", + "Seeder": "[2026.6.2, )", "Spectre.Console": "[0.55.2, 0.55.2]" } }, "sharedweb": { "type": "Project", "dependencies": { - "Core": "[2026.6.1, )", - "Infrastructure.Dapper": "[2026.6.1, )", - "Infrastructure.EntityFramework": "[2026.6.1, )", + "Core": "[2026.6.2, )", + "Infrastructure.Dapper": "[2026.6.2, )", + "Infrastructure.EntityFramework": "[2026.6.2, )", "Microsoft.Bot.Builder.Integration.AspNet.Core": "[4.23.0, 4.23.0]", "Swashbuckle.AspNetCore.SwaggerGen": "[10.1.7, 10.1.7]" } From 69d020730a96e1481e3efff62260d747002315de Mon Sep 17 00:00:00 2001 From: Hinton Date: Tue, 7 Jul 2026 17:34:15 +0200 Subject: [PATCH 10/10] Pam dev scripts --- dev/pam-daemon-key.py | 225 ++++++++++++++++++++++++++++++++++++++++ dev/pam-rotation-sim.py | 184 ++++++++++++++++++++++++++++++++ 2 files changed, 409 insertions(+) create mode 100644 dev/pam-daemon-key.py create mode 100644 dev/pam-rotation-sim.py diff --git a/dev/pam-daemon-key.py b/dev/pam-daemon-key.py new file mode 100644 index 000000000000..585b0cde00ce --- /dev/null +++ b/dev/pam-daemon-key.py @@ -0,0 +1,225 @@ +#!/usr/bin/env python3 +"""Build a PAM rotation-daemon credential with a *valid* organization key for local dev. + +LOCAL DEV ONLY. Operates on seeded synthetic data in `vault_dev`: + * Seeded users draw their RSA keypair from the fixed pool in + util/RustSdk/rust/src/rsa_keys.rs (selected by poolIndex). The private key is + therefore a known constant in the repo, so we can RSA-OAEP-SHA1 decrypt the + org key stored (RSA-wrapped) in OrganizationUser.Key -- exactly what a real + client does at daemon registration. + * We mirror the Secrets Manager access-token layout (CONTRACT C1): a random + 16-byte seed is generated; the 64-byte symmetric key is *derived* from that seed + via `bitwarden_crypto::derive_shareable_key(seed, "accesstoken", + Some("sm-access-token"))`; the payload is encrypted under that derived key; and + the daemon token embeds the base64-encoded seed (not the key) after the ':'. + +This deliberately reconstructs an org key from test data. It is NOT a break of the +zero-knowledge design: the RSA keys are test-only constants committed to the repo, +the master password is the public seeder default, and there is no real vault data. + +Usage: + python3 dev/pam-daemon-key.py \ + --email enterprise.owner@redwood.example \ + --org-id 34C5C52C-AC9A-4D53-878B-B46600CA936C \ + --name local-dev-daemon [--register] +""" +import argparse +import base64 +import hashlib +import hmac +import json +import os +import re +import subprocess +import sys +import urllib.request + +REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +RSA_KEYS_RS = os.path.join(REPO, "util/RustSdk/rust/src/rsa_keys.rs") +SECRETS_JSON = os.path.join(REPO, "dev/secrets.json") + +from cryptography.hazmat.primitives.asymmetric import padding +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.serialization import ( + load_pem_private_key, Encoding, PublicFormat, +) +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes +from cryptography.hazmat.primitives.kdf.hkdf import HKDFExpand + + +def sql_password(): + """Read the SqlServer SA password from dev/secrets.json. + + Parsing the whole JSONC file is brittle (comments, `http://` in strings), so + pull the first `Database=vault_dev` connection string's Password directly. + """ + raw = open(SECRETS_JSON).read() + for conn in re.findall(r'"connectionString"\s*:\s*"([^"]+)"', raw): + if "Database=vault_dev;" in conn: + return re.search(r"Password=([^;]+);", conn).group(1) + raise SystemExit("Could not find the vault_dev connection string in dev/secrets.json.") + + +def query(container, password, sql): + out = subprocess.run( + ["docker", "exec", container, + "/opt/mssql-tools18/bin/sqlcmd", "-C", "-S", "localhost", "-U", "SA", + "-P", password, "-d", "vault_dev", "-h", "-1", "-y", "1000", "-Q", + "SET NOCOUNT ON;\n" + sql], + capture_output=True, text=True, check=True).stdout + return [l.rstrip() for l in out.splitlines() if l.strip()] + + +def pool_keys(): + """Parse every PEM literal from the seeder RSA pool, in declaration order.""" + src = open(RSA_KEYS_RS).read() + return re.findall(r'TEST_FAKE_RSA_KEY_\d+: &str = "(.*?)";', src, re.S) + + +def find_private_key(user_pub_b64): + """Return (poolIndex, loaded_private_key) whose SPKI matches the user's public key.""" + for idx, pem in enumerate(pool_keys()): + priv = load_pem_private_key((pem + "\n").encode(), password=None) + spki = base64.b64encode( + priv.public_key().public_bytes(Encoding.DER, PublicFormat.SubjectPublicKeyInfo) + ).decode() + if spki == user_pub_b64: + return idx, priv + raise SystemExit("No pool key matched the user's public key (was this user seeded?).") + + +def encstring_type2(plaintext: bytes, key64: bytes) -> str: + """Bitwarden EncString type 2: AES-256-CBC + HMAC-SHA256. key64 = 32B enc || 32B mac.""" + enc_key, mac_key = key64[:32], key64[32:] + iv = os.urandom(16) + pad = 16 - (len(plaintext) % 16) + padded = plaintext + bytes([pad]) * pad + enc = Cipher(algorithms.AES(enc_key), modes.CBC(iv)).encryptor() + ct = enc.update(padded) + enc.finalize() + mac = hmac.new(mac_key, iv + ct, hashlib.sha256).digest() + return f"2.{base64.b64encode(iv).decode()}|{base64.b64encode(ct).decode()}|{base64.b64encode(mac).decode()}" + + +def derive_daemon_key(seed16: bytes) -> bytes: + """Derive a 64-byte symmetric key from a 16-byte seed. + + Mirrors `bitwarden_crypto::derive_shareable_key(seed, "accesstoken", + Some("sm-access-token"))` — CONTRACT C1. + + Step 1: HMAC-SHA256 extract + prk = HMAC-SHA256(key=b"bitwarden-accesstoken", msg=seed16) + Step 2: HKDF-Expand (SHA-256, no extract step) + key64 = HKDFExpand(prk, info=b"sm-access-token", length=64) + + The returned 64 bytes are split enc_key=[:32] / mac_key=[32:] by encstring_type2, + matching the Aes256CbcHmacKey layout used by the daemon. + """ + prk = hmac.new(b"bitwarden-accesstoken", seed16, hashlib.sha256).digest() + hkdf = HKDFExpand(algorithm=hashes.SHA256(), length=64, info=b"sm-access-token") + return hkdf.derive(prk) + + +def main(): + # Known-answer self-check (CONTRACT C1 test vector, from token.rs). + # seed = base64-decode("X8vbvA0bduihIDe/qrzIQQ==") + # derived key must equal "H9/oIRLtL9nGCQOVDjSMoEbJsjWXSOCb3qeyDt6ckzS3FhyboEDWyTP/CQfbIszNmAVg2ExFganG1FVFGXO/Jg==" + _kac_seed = base64.b64decode("X8vbvA0bduihIDe/qrzIQQ==") + _kac_expected = "H9/oIRLtL9nGCQOVDjSMoEbJsjWXSOCb3qeyDt6ckzS3FhyboEDWyTP/CQfbIszNmAVg2ExFganG1FVFGXO/Jg==" + _kac_actual = base64.b64encode(derive_daemon_key(_kac_seed)).decode() + if _kac_actual != _kac_expected: + raise SystemExit( + f"FATAL: derive_daemon_key known-answer check failed!\n" + f" expected: {_kac_expected}\n" + f" got: {_kac_actual}\n" + "The key-derivation implementation does not match the daemon's CONTRACT C1." + ) + + ap = argparse.ArgumentParser() + ap.add_argument("--email", required=True) + ap.add_argument("--org-id", required=True) + ap.add_argument("--name", default="local-dev-daemon") + ap.add_argument("--container", default="bitwardenserver-mssql-1") + ap.add_argument("--register", action="store_true", + help="POST the daemon registration to the local API and assemble the token") + ap.add_argument("--api-base", default="http://localhost:4000") + ap.add_argument("--identity-base", default="http://localhost:33656") + ap.add_argument("--client-version", default="2026.5.0") + args = ap.parse_args() + + pw = sql_password() + + rows = query(args.container, pw, f""" + SELECT 'PUB='+U.PublicKey FROM [User] U WHERE U.Email='{args.email}'; + SELECT 'OUK='+OU.[Key] FROM OrganizationUser OU + JOIN [User] U ON U.Id=OU.UserId + WHERE U.Email='{args.email}' AND OU.OrganizationId='{args.org_id}';""") + vals = dict(r.split("=", 1) for r in rows if "=" in r) + user_pub, org_user_key = vals["PUB"].strip(), vals["OUK"].strip() + + idx, priv = find_private_key(user_pub) + print(f"matched seeder RSA pool index: {idx}") + + assert org_user_key.startswith("4."), f"expected RSA (type 4) org key, got {org_user_key[:2]}" + org_key = priv.decrypt( + base64.b64decode(org_user_key[2:]), + padding.OAEP(mgf=padding.MGF1(hashes.SHA1()), algorithm=hashes.SHA1(), label=None)) + assert len(org_key) == 64, f"unexpected org key length {len(org_key)}" + org_key_b64 = base64.b64encode(org_key).decode() + + # Mirror the SM access-token layout (CONTRACT C1): generate a 16-byte random + # seed; derive the 64-byte symmetric key from it; encrypt the payload under + # that derived key; and store the base64-encoded seed (not the key itself) in + # the Key field and after the ':' in the final token. + seed = os.urandom(16) + seed_b64 = base64.b64encode(seed).decode() + k = derive_daemon_key(seed) + payload = json.dumps({"encryptionKey": org_key_b64}).encode() + encrypted_payload = encstring_type2(payload, k) + key_field = encstring_type2(seed_b64.encode(), org_key) + + body = {"name": args.name, "encryptedPayload": encrypted_payload, "key": key_field} + + print("\n=== org key (base64, 64 bytes) ===") + print(org_key_b64) + print("\n=== registration request body ===") + print(json.dumps(body, indent=2)) + + if not args.register: + print("\n(dry run -- pass --register to POST and assemble the daemon token)") + return + + # Owner user's ApiKey drives a client_credentials login to satisfy the admin policy. + ukrows = query(args.container, pw, + f"SELECT 'UK='+CAST(U.Id AS varchar(64))+'|'+U.ApiKey FROM [User] U WHERE U.Email='{args.email}';") + uid, ukey = dict(r.split("=", 1) for r in ukrows if "=" in r)["UK"].strip().split("|") + + tok = urllib.request.urlopen(urllib.request.Request( + f"{args.identity_base}/connect/token", + data=urllib.parse.urlencode({ + "grant_type": "client_credentials", "scope": "api", + "client_id": f"user.{uid}", "client_secret": ukey, + "deviceType": "21", "deviceIdentifier": "pam-daemon-key-script", + "deviceName": "pam-daemon-key-script"}).encode(), + headers={"Content-Type": "application/x-www-form-urlencoded", + "Bitwarden-Client-Version": args.client_version})).read() + access_token = json.loads(tok)["access_token"] + + reg = urllib.request.urlopen(urllib.request.Request( + f"{args.api_base}/organizations/{args.org_id}/rotation/daemons", + data=json.dumps(body).encode(), + headers={"Content-Type": "application/json", + "Authorization": f"Bearer {access_token}"})).read() + result = json.loads(reg) + api_key_id = result["apiKeyId"] + + print("\n=== registered daemon ===") + print(json.dumps(result, indent=2)) + print("\n=== daemon access token (client_id : client_secret : encryption_key) ===") + print(f"client_id = daemon.{api_key_id}") + print(f"client_secret = {result['clientSecret']}") + print(f"full token = 0.daemon.{api_key_id}.{result['clientSecret']}:{seed_b64}") + + +if __name__ == "__main__": + import urllib.parse # noqa: E402 (kept local so --dry-run has no import cost surprises) + main() diff --git a/dev/pam-rotation-sim.py b/dev/pam-rotation-sim.py new file mode 100644 index 000000000000..7268de97fa17 --- /dev/null +++ b/dev/pam-rotation-sim.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +"""Set up and trigger a PAM credential-rotation job from the admin side, on the local dev stack. + +LOCAL DEV ONLY. Drives the admin HTTP surface exactly as the admin console would, then +leaves a claimable job for a *real* rotation daemon to pick up, execute, and report on: + + ADMIN (owner bearer, client_credentials on the seeded user's ApiKey) + 1. register an automatic target system POST organizations/{org}/rotation/target-systems + (or reuse one via --target-id) + 2. assign the daemon to that target POST organizations/{org}/rotation/daemons/{id}/assignments + 3. create a rotation config for a cipher POST organizations/{org}/rotation/configs + 4. trigger an on-demand rotation POST organizations/{org}/rotation/configs/{id}/rotate + +The daemon side (poll rotation/daemon/jobs -> claim -> read/write cipher -> report) is NOT +done here -- an actual daemon handles that. By default this creates a fresh target + config +on a fresh cipher, which sidesteps the on-demand cooldown and the "config already has an +active job" guard so repeated runs don't 400. + +This is all seeded synthetic data in vault_dev. + +Usage: + python3 dev/pam-rotation-sim.py \ + --org-id 34C5C52C-AC9A-4D53-878B-B46600CA936C \ + --admin-email enterprise.owner@redwood.example \ + --daemon-id [--target-id ] [--cipher-id ] [--cleanup] +""" +import argparse +import json +import os +import re +import subprocess +import urllib.error +import urllib.parse +import urllib.request + +REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +SECRETS_JSON = os.path.join(REPO, "dev/secrets.json") + + +def sql_password(): + raw = open(SECRETS_JSON).read() + for conn in re.findall(r'"connectionString"\s*:\s*"([^"]+)"', raw): + if "Database=vault_dev;" in conn: + return re.search(r"Password=([^;]+);", conn).group(1) + raise SystemExit("Could not find the vault_dev connection string in dev/secrets.json.") + + +def query1(container, password, sql): + """Run a scalar query and return the single trimmed value (or None).""" + out = subprocess.run( + ["docker", "exec", container, + "/opt/mssql-tools18/bin/sqlcmd", "-C", "-S", "localhost", "-U", "SA", + "-P", password, "-d", "vault_dev", "-h", "-1", "-W", "-Q", + "SET NOCOUNT ON;\n" + sql], + capture_output=True, text=True, check=True).stdout + rows = [l.strip() for l in out.splitlines() if l.strip()] + return rows[0] if rows else None + + +def http(method, url, bearer, body=None, allow=()): + """Issue a request; raise on unexpected error, but return (status, None) for statuses in `allow`.""" + data = json.dumps(body).encode() if body is not None else None + req = urllib.request.Request(url, data=data, method=method, headers={ + "Authorization": f"Bearer {bearer}", + **({"Content-Type": "application/json"} if data is not None else {})}) + try: + with urllib.request.urlopen(req) as r: + raw = r.read() + return r.status, (json.loads(raw) if raw else None) + except urllib.error.HTTPError as e: + if e.code in allow: + return e.code, None + raise SystemExit(f"{method} {url} -> {e.code}\n{e.read().decode(errors='replace')}") + + +def admin_token(identity_base, client_version, uid, ukey): + req = urllib.request.Request( + f"{identity_base}/connect/token", + data=urllib.parse.urlencode({ + "grant_type": "client_credentials", "scope": "api", + "client_id": f"user.{uid}", "client_secret": ukey, + "deviceType": "21", "deviceIdentifier": "pam-rotation-sim", + "deviceName": "pam-rotation-sim"}).encode(), + headers={"Content-Type": "application/x-www-form-urlencoded", + "Bitwarden-Client-Version": client_version}) + with urllib.request.urlopen(req) as r: + return json.loads(r.read())["access_token"] + + +def step(n, msg): + print(f"\n[{n}] {msg}") + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--org-id", required=True) + ap.add_argument("--admin-email", required=True) + ap.add_argument("--daemon-id", required=True, help="PamDaemon.Id to assign to the target") + ap.add_argument("--kind", default="entra", choices=["entra", "mssql", "customscript"], + help="automatic connector kind for a newly registered target (default: entra)") + ap.add_argument("--target-id", help="reuse an existing target system instead of registering one") + ap.add_argument("--cipher-id", help="org cipher to rotate; auto-selected if omitted") + ap.add_argument("--account-identity", default="svc-rotation@redwood.example") + ap.add_argument("--cleanup", action="store_true", + help="delete the created rotation config at the end (cascades the job)") + ap.add_argument("--container", default="bitwardenserver-mssql-1") + ap.add_argument("--api-base", default="http://localhost:4000") + ap.add_argument("--identity-base", default="http://localhost:33656") + ap.add_argument("--client-version", default="2026.5.0") + args = ap.parse_args() + + org = args.org_id + pw = sql_password() + api = args.api_base.rstrip("/") + + # --- admin bearer (seeded user's ApiKey via client_credentials) --- + uk = query1(args.container, pw, + f"SELECT CAST(U.Id AS varchar(64))+'|'+U.ApiKey FROM [User] U WHERE U.Email='{args.admin_email}';") + if not uk: + raise SystemExit(f"No user found for {args.admin_email}") + uid, ukey = uk.split("|") + admin = admin_token(args.identity_base, args.client_version, uid, ukey) + + # --- pick a cipher with no existing rotation config --- + cipher_id = args.cipher_id or query1(args.container, pw, + f"""SELECT TOP 1 CAST(Id AS varchar(64)) FROM Cipher + WHERE OrganizationId='{org}' AND Type=1 + AND Id NOT IN (SELECT CipherId FROM PamRotationConfig);""") + if not cipher_id: + raise SystemExit("No org login cipher available without an existing rotation config.") + print(f"cipher to rotate = {cipher_id}") + + # === ADMIN SETUP === + if args.target_id: + target_id = args.target_id + print(f"\n[1] reusing target system {target_id}") + else: + kind_val = {"entra": 0, "mssql": 1, "customscript": 2}[args.kind] + step(1, f"register automatic target system (kind={args.kind})") + _, target = http("POST", f"{api}/organizations/{org}/rotation/target-systems", admin, { + "name": f"sim-{args.kind}-{cipher_id[:8]}", + "method": 0, # Automatic + "kind": kind_val, + "passwordPolicy": {"minLength": 16, "maxLength": 32, + "includeUppercase": True, "includeLowercase": True, + "includeDigits": True, "includeSymbols": True}, + "supportsSessionTermination": False}) + target_id = target["id"] + print(f" targetSystemId = {target_id}") + + step(2, "assign daemon to target") + status, _ = http("POST", f"{api}/organizations/{org}/rotation/daemons/{args.daemon_id}/assignments", + admin, {"targetSystemId": target_id}, allow=(409,)) + print(" already assigned (409)" if status == 409 else " assigned (204)") + + step(3, "create rotation config") + _, config = http("POST", f"{api}/organizations/{org}/rotation/configs", admin, { + "cipherId": cipher_id, "targetSystemId": target_id, + "accountIdentity": args.account_identity, "terminateSessions": False, + "scheduleCron": None, "rotateOnAccessEnd": False}) + config_id = config["config"]["id"] # POST returns the detail view: { "config": {...}, "jobs": [...] } + print(f" configId = {config_id}") + + step(4, "trigger on-demand rotation (creates a claimable job)") + http("POST", f"{api}/organizations/{org}/rotation/configs/{config_id}/rotate", admin) + print(" triggered (204)") + + # --- show the pending job the real daemon will claim --- + job = query1(args.container, pw, + f"""SELECT TOP 1 CAST(Id AS varchar(64))+' status='+CAST(Status AS varchar) + FROM PamRotationJob WHERE RotationConfigId='{config_id}' ORDER BY CreationDate DESC;""") + print(f"\n=== ready for the daemon ===") + print(f"pending job: {job} (PamRotationJobStatus 0=Pending)") + print(f"the assigned daemon ({args.daemon_id}) will now see this job on its next poll of rotation/daemon/jobs") + + if args.cleanup: + http("DELETE", f"{api}/organizations/{org}/rotation/configs/{config_id}", admin) + print(f"\ncleaned up: deleted config {config_id} (job cascaded)") + else: + print(f"\nartifacts kept: target={target_id} config={config_id}") + + +if __name__ == "__main__": + main()