From d7183747511a251cfde27d82c71afb547b8fa504 Mon Sep 17 00:00:00 2001 From: Hinton Date: Thu, 21 May 2026 16:57:53 +0200 Subject: [PATCH 01/54] [PM-37751] Add Collection leasing config (enable + policy) Adds LeasingEnabled (bool) and LeasingPolicy (JSON) columns to Collection, gated by the FeatureFlagKeys.Pam feature flag. Includes: - Schema: SSDT table + sprocs update, dated MSSQL migration with COL_LENGTH guards and CREATE OR ALTER for the affected Collection_* sprocs, and EF migrations for Postgres, MySQL, and SQLite. - Domain: new src/Core/PrivilegedAccessManagement/ namespace with policy DTOs (human_approval, ip_allowlist, time_of_day, all_of) and the LeasingPolicyValidator service. - Commands: CreateCollectionCommand and UpdateCollectionCommand validate the policy when the flag is on and clear the leasing fields when it is off. - API: request/response models surface the two new fields; leasing flows through the existing collection create/update endpoints (the dedicated /leasing endpoint from the design doc is intentionally not introduced). - Tests: LeasingPolicyValidatorTests cover the four policy kinds and failure modes; command tests cover the flag on/off and validation paths. --- .../Models/Request/CollectionRequestModel.cs | 15 + .../Response/CollectionResponseModel.cs | 21 + src/Core/AdminConsole/Entities/Collection.cs | 11 + .../Collections/CreateCollectionCommand.cs | 25 +- .../Collections/UpdateCollectionCommand.cs | 25 +- ...OrganizationServiceCollectionExtensions.cs | 2 + .../Models/Policies/AllOfPolicy.cs | 9 + .../Models/Policies/HumanApprovalPolicy.cs | 6 + .../Models/Policies/IpAllowlistPolicy.cs | 9 + .../Models/Policies/LeasingPolicy.cs | 14 + .../Models/Policies/TimeOfDayPolicy.cs | 18 + .../Services/ILeasingPolicyValidator.cs | 16 + .../Services/LeasingPolicyValidator.cs | 167 + .../Repositories/CollectionRepository.cs | 6 + .../Stored Procedures/Collection_Create.sql | 12 +- .../Collection_CreateWithGroupsAndUsers.sql | 6 +- .../Collection_ReadByIdWithPermissions.sql | 4 +- .../Collection_ReadByUserId.sql | 8 +- ...ectionsByOrganizationIdWithPermissions.sql | 4 +- .../Stored Procedures/Collection_Update.sql | 8 +- .../Collection_UpdateWithGroups.sql | 6 +- .../Collection_UpdateWithGroupsAndUsers.sql | 6 +- .../Collection_UpdateWithUsers.sql | 6 +- src/Sql/dbo/Tables/Collection.sql | 2 + .../CreateCollectionCommandTests.cs | 77 + .../UpdateCollectionCommandTests.cs | 74 + .../Services/LeasingPolicyValidatorTests.cs | 181 + .../2026-05-21_00_AddCollectionLeasing.sql | 744 ++++ ...521121534_AddCollectionLeasing.Designer.cs | 3767 ++++++++++++++++ .../20260521121534_AddCollectionLeasing.cs | 39 + .../DatabaseContextModelSnapshot.cs | 6 + ...521121303_AddCollectionLeasing.Designer.cs | 3773 +++++++++++++++++ .../20260521121303_AddCollectionLeasing.cs | 38 + .../DatabaseContextModelSnapshot.cs | 6 + ...521121259_AddCollectionLeasing.Designer.cs | 3756 ++++++++++++++++ .../20260521121259_AddCollectionLeasing.cs | 38 + .../DatabaseContextModelSnapshot.cs | 6 + 37 files changed, 12892 insertions(+), 19 deletions(-) create mode 100644 src/Core/PrivilegedAccessManagement/Models/Policies/AllOfPolicy.cs create mode 100644 src/Core/PrivilegedAccessManagement/Models/Policies/HumanApprovalPolicy.cs create mode 100644 src/Core/PrivilegedAccessManagement/Models/Policies/IpAllowlistPolicy.cs create mode 100644 src/Core/PrivilegedAccessManagement/Models/Policies/LeasingPolicy.cs create mode 100644 src/Core/PrivilegedAccessManagement/Models/Policies/TimeOfDayPolicy.cs create mode 100644 src/Core/PrivilegedAccessManagement/Services/ILeasingPolicyValidator.cs create mode 100644 src/Core/PrivilegedAccessManagement/Services/LeasingPolicyValidator.cs create mode 100644 test/Core.Test/PrivilegedAccessManagement/Services/LeasingPolicyValidatorTests.cs create mode 100644 util/Migrator/DbScripts/2026-05-21_00_AddCollectionLeasing.sql create mode 100644 util/MySqlMigrations/Migrations/20260521121534_AddCollectionLeasing.Designer.cs create mode 100644 util/MySqlMigrations/Migrations/20260521121534_AddCollectionLeasing.cs create mode 100644 util/PostgresMigrations/Migrations/20260521121303_AddCollectionLeasing.Designer.cs create mode 100644 util/PostgresMigrations/Migrations/20260521121303_AddCollectionLeasing.cs create mode 100644 util/SqliteMigrations/Migrations/20260521121259_AddCollectionLeasing.Designer.cs create mode 100644 util/SqliteMigrations/Migrations/20260521121259_AddCollectionLeasing.cs diff --git a/src/Api/AdminConsole/Models/Request/CollectionRequestModel.cs b/src/Api/AdminConsole/Models/Request/CollectionRequestModel.cs index b981cff2b80b..b8ef4550b2a5 100644 --- a/src/Api/AdminConsole/Models/Request/CollectionRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/CollectionRequestModel.cs @@ -2,6 +2,7 @@ #nullable disable using System.ComponentModel.DataAnnotations; +using System.Text.Json; using Bit.Api.Models.Request; using Bit.Core.Entities; using Bit.Core.Utilities; @@ -18,6 +19,8 @@ public class CreateCollectionRequestModel public string ExternalId { get; set; } public IEnumerable Groups { get; set; } public IEnumerable Users { get; set; } + public bool LeasingEnabled { get; set; } + public object LeasingPolicy { get; set; } public Collection ToCollection(Guid orgId) { @@ -31,8 +34,18 @@ public virtual Collection ToCollection(Collection existingCollection) { existingCollection.Name = Name; existingCollection.ExternalId = ExternalId; + existingCollection.LeasingEnabled = LeasingEnabled; + existingCollection.LeasingPolicy = SerializeLeasingPolicy(LeasingPolicy); return existingCollection; } + + protected static string SerializeLeasingPolicy(object policy) => policy switch + { + null => null, + JsonElement je when je.ValueKind == JsonValueKind.Null => null, + JsonElement je => je.GetRawText(), + _ => JsonSerializer.Serialize(policy), + }; } public class CollectionBulkDeleteRequestModel @@ -65,6 +78,8 @@ public override Collection ToCollection(Collection existingCollection) existingCollection.Name = Name; } existingCollection.ExternalId = ExternalId; + existingCollection.LeasingEnabled = LeasingEnabled; + existingCollection.LeasingPolicy = SerializeLeasingPolicy(LeasingPolicy); return existingCollection; } diff --git a/src/Api/AdminConsole/Models/Response/CollectionResponseModel.cs b/src/Api/AdminConsole/Models/Response/CollectionResponseModel.cs index 44ead9c5a233..c37bcb2265dc 100644 --- a/src/Api/AdminConsole/Models/Response/CollectionResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/CollectionResponseModel.cs @@ -1,6 +1,7 @@ // FIXME: Update this file to be null safe and then delete the line below #nullable disable +using System.Text.Json; using Bit.Api.Models.Response; using Bit.Core.Entities; using Bit.Core.Enums; @@ -25,6 +26,24 @@ public CollectionResponseModel(Collection collection, string obj = "collection") ExternalId = collection.ExternalId; Type = collection.Type; DefaultUserCollectionEmail = collection.DefaultUserCollectionEmail; + LeasingEnabled = collection.LeasingEnabled; + LeasingPolicy = TryParsePolicy(collection.LeasingPolicy); + } + + private static JsonElement? TryParsePolicy(string policyJson) + { + if (string.IsNullOrEmpty(policyJson)) + { + return null; + } + try + { + return JsonDocument.Parse(policyJson).RootElement; + } + catch (JsonException) + { + return null; + } } public Guid Id { get; set; } @@ -33,6 +52,8 @@ public CollectionResponseModel(Collection collection, string obj = "collection") public string ExternalId { get; set; } public CollectionType Type { get; set; } public string DefaultUserCollectionEmail { get; set; } + public bool LeasingEnabled { get; set; } + public JsonElement? LeasingPolicy { get; set; } } /// diff --git a/src/Core/AdminConsole/Entities/Collection.cs b/src/Core/AdminConsole/Entities/Collection.cs index 15e8f9cfa0fe..aeb631c32fde 100644 --- a/src/Core/AdminConsole/Entities/Collection.cs +++ b/src/Core/AdminConsole/Entities/Collection.cs @@ -46,6 +46,17 @@ public class Collection : ITableObject /// unknown user). Unencrypted. /// public string? DefaultUserCollectionEmail { get; set; } + /// + /// Master switch for PAM credential leasing on this collection. When false, leasing endpoints + /// behave as if leasing did not exist for this collection. + /// + public bool LeasingEnabled { get; set; } + /// + /// Raw JSON policy document evaluated when leasing is enabled. Validated by + /// LeasingPolicyValidator. Null means no policy is configured; behavior defaults to + /// human_approval at evaluation time. + /// + public string? LeasingPolicy { get; set; } /// /// Initializes to a new COMB GUID. diff --git a/src/Core/AdminConsole/OrganizationFeatures/Collections/CreateCollectionCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Collections/CreateCollectionCommand.cs index f09735530c91..7251bc34843d 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Collections/CreateCollectionCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Collections/CreateCollectionCommand.cs @@ -6,6 +6,7 @@ using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Data; +using Bit.Core.PrivilegedAccessManagement.Services; using Bit.Core.Repositories; using Bit.Core.Services; @@ -16,15 +17,21 @@ public class CreateCollectionCommand : ICreateCollectionCommand private readonly IEventService _eventService; private readonly IOrganizationRepository _organizationRepository; private readonly ICollectionRepository _collectionRepository; + private readonly IFeatureService _featureService; + private readonly ILeasingPolicyValidator _leasingPolicyValidator; public CreateCollectionCommand( IEventService eventService, IOrganizationRepository organizationRepository, - ICollectionRepository collectionRepository) + ICollectionRepository collectionRepository, + IFeatureService featureService, + ILeasingPolicyValidator leasingPolicyValidator) { _eventService = eventService; _organizationRepository = organizationRepository; _collectionRepository = collectionRepository; + _featureService = featureService; + _leasingPolicyValidator = leasingPolicyValidator; } public async Task CreateAsync(Collection collection, IEnumerable groups = null, @@ -35,6 +42,8 @@ public async Task CreateAsync(Collection collection, IEnumerable CreateAsync(Collection collection, IEnumerable UpdateAsync(Collection collection, IEnumerable groups = null, @@ -38,6 +45,8 @@ public async Task UpdateAsync(Collection collection, IEnumerable UpdateAsync(Collection collection, IEnumerable(); services.AddScoped(); services.AddScoped(); + services.AddSingleton(); } private static void AddOrganizationGroupCommands(this IServiceCollection services) diff --git a/src/Core/PrivilegedAccessManagement/Models/Policies/AllOfPolicy.cs b/src/Core/PrivilegedAccessManagement/Models/Policies/AllOfPolicy.cs new file mode 100644 index 000000000000..3de212084acd --- /dev/null +++ b/src/Core/PrivilegedAccessManagement/Models/Policies/AllOfPolicy.cs @@ -0,0 +1,9 @@ +namespace Bit.Core.PrivilegedAccessManagement.Models.Policies; + +/// +/// Composite policy that approves only when every child policy approves. +/// +public sealed class AllOfPolicy : LeasingPolicy +{ + public IReadOnlyList Policies { get; init; } = []; +} diff --git a/src/Core/PrivilegedAccessManagement/Models/Policies/HumanApprovalPolicy.cs b/src/Core/PrivilegedAccessManagement/Models/Policies/HumanApprovalPolicy.cs new file mode 100644 index 000000000000..de64103c466a --- /dev/null +++ b/src/Core/PrivilegedAccessManagement/Models/Policies/HumanApprovalPolicy.cs @@ -0,0 +1,6 @@ +namespace Bit.Core.PrivilegedAccessManagement.Models.Policies; + +/// +/// Always requires a human decision before a lease can be issued. +/// +public sealed class HumanApprovalPolicy : LeasingPolicy; diff --git a/src/Core/PrivilegedAccessManagement/Models/Policies/IpAllowlistPolicy.cs b/src/Core/PrivilegedAccessManagement/Models/Policies/IpAllowlistPolicy.cs new file mode 100644 index 000000000000..75045d5a5bfe --- /dev/null +++ b/src/Core/PrivilegedAccessManagement/Models/Policies/IpAllowlistPolicy.cs @@ -0,0 +1,9 @@ +namespace Bit.Core.PrivilegedAccessManagement.Models.Policies; + +/// +/// Auto-approves a lease when the requester's IP matches a listed CIDR; otherwise denies. +/// +public sealed class IpAllowlistPolicy : LeasingPolicy +{ + public IReadOnlyList Cidrs { get; init; } = []; +} diff --git a/src/Core/PrivilegedAccessManagement/Models/Policies/LeasingPolicy.cs b/src/Core/PrivilegedAccessManagement/Models/Policies/LeasingPolicy.cs new file mode 100644 index 000000000000..3e6af1aaac2a --- /dev/null +++ b/src/Core/PrivilegedAccessManagement/Models/Policies/LeasingPolicy.cs @@ -0,0 +1,14 @@ +using System.Text.Json.Serialization; + +namespace Bit.Core.PrivilegedAccessManagement.Models.Policies; + +/// +/// Base type for the structured leasing policy stored on Collection.LeasingPolicy. +/// Polymorphic deserialization is keyed by the JSON kind property. +/// +[JsonPolymorphic(TypeDiscriminatorPropertyName = "kind", UnknownDerivedTypeHandling = JsonUnknownDerivedTypeHandling.FailSerialization)] +[JsonDerivedType(typeof(HumanApprovalPolicy), "human_approval")] +[JsonDerivedType(typeof(IpAllowlistPolicy), "ip_allowlist")] +[JsonDerivedType(typeof(TimeOfDayPolicy), "time_of_day")] +[JsonDerivedType(typeof(AllOfPolicy), "all_of")] +public abstract class LeasingPolicy; diff --git a/src/Core/PrivilegedAccessManagement/Models/Policies/TimeOfDayPolicy.cs b/src/Core/PrivilegedAccessManagement/Models/Policies/TimeOfDayPolicy.cs new file mode 100644 index 000000000000..9220169f1ea1 --- /dev/null +++ b/src/Core/PrivilegedAccessManagement/Models/Policies/TimeOfDayPolicy.cs @@ -0,0 +1,18 @@ +namespace Bit.Core.PrivilegedAccessManagement.Models.Policies; + +/// +/// Auto-approves a lease when the request falls inside one of the configured windows, evaluated in +/// the named IANA timezone; otherwise denies. +/// +public sealed class TimeOfDayPolicy : LeasingPolicy +{ + public string Tz { get; init; } = string.Empty; + public IReadOnlyList Windows { get; init; } = []; +} + +public sealed class TimeWindow +{ + public IReadOnlyList Days { get; init; } = []; + public string From { get; init; } = string.Empty; + public string To { get; init; } = string.Empty; +} diff --git a/src/Core/PrivilegedAccessManagement/Services/ILeasingPolicyValidator.cs b/src/Core/PrivilegedAccessManagement/Services/ILeasingPolicyValidator.cs new file mode 100644 index 000000000000..1a7800558f6b --- /dev/null +++ b/src/Core/PrivilegedAccessManagement/Services/ILeasingPolicyValidator.cs @@ -0,0 +1,16 @@ +namespace Bit.Core.PrivilegedAccessManagement.Services; + +public interface ILeasingPolicyValidator +{ + /// + /// Validates a raw JSON leasing policy. A null or empty policy is treated as "no policy + /// configured" and considered valid; callers decide how to treat that semantically. + /// + LeasingPolicyValidationResult Validate(string? policyJson); +} + +public sealed record LeasingPolicyValidationResult(bool IsValid, string? Error) +{ + public static LeasingPolicyValidationResult Valid { get; } = new(true, null); + public static LeasingPolicyValidationResult Invalid(string error) => new(false, error); +} diff --git a/src/Core/PrivilegedAccessManagement/Services/LeasingPolicyValidator.cs b/src/Core/PrivilegedAccessManagement/Services/LeasingPolicyValidator.cs new file mode 100644 index 000000000000..af26e5b5bb99 --- /dev/null +++ b/src/Core/PrivilegedAccessManagement/Services/LeasingPolicyValidator.cs @@ -0,0 +1,167 @@ +using System.Net; +using System.Text.Json; +using System.Text.RegularExpressions; +using Bit.Core.PrivilegedAccessManagement.Models.Policies; + +namespace Bit.Core.PrivilegedAccessManagement.Services; + +public sealed partial class LeasingPolicyValidator : ILeasingPolicyValidator +{ + private const int MaxCompositeDepth = 3; + private const int MaxCompositeChildren = 10; + + private static readonly HashSet AllowedDays = + new(StringComparer.OrdinalIgnoreCase) { "mon", "tue", "wed", "thu", "fri", "sat", "sun" }; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + }; + + [GeneratedRegex(@"^([01][0-9]|2[0-3]):[0-5][0-9]$")] + private static partial Regex TimeOfDayRegex(); + + public LeasingPolicyValidationResult Validate(string? policyJson) + { + if (policyJson is null) + { + return LeasingPolicyValidationResult.Valid; + } + + if (string.IsNullOrWhiteSpace(policyJson)) + { + return LeasingPolicyValidationResult.Invalid("Policy JSON cannot be empty."); + } + + LeasingPolicy? policy; + try + { + policy = JsonSerializer.Deserialize(policyJson, JsonOptions); + } + catch (JsonException ex) + { + return LeasingPolicyValidationResult.Invalid($"Policy JSON is malformed: {ex.Message}"); + } + + if (policy is null) + { + return LeasingPolicyValidationResult.Invalid("Policy must be an object."); + } + + return ValidatePolicy(policy, depth: 0); + } + + private static LeasingPolicyValidationResult ValidatePolicy(LeasingPolicy policy, int depth) + { + return policy switch + { + HumanApprovalPolicy => LeasingPolicyValidationResult.Valid, + IpAllowlistPolicy ip => ValidateIpAllowlist(ip), + TimeOfDayPolicy tod => ValidateTimeOfDay(tod), + AllOfPolicy all => ValidateAllOf(all, depth), + _ => LeasingPolicyValidationResult.Invalid($"Unsupported policy kind: {policy.GetType().Name}."), + }; + } + + private static LeasingPolicyValidationResult ValidateIpAllowlist(IpAllowlistPolicy policy) + { + if (policy.Cidrs.Count == 0) + { + return LeasingPolicyValidationResult.Invalid("ip_allowlist requires at least one CIDR."); + } + + foreach (var cidr in policy.Cidrs) + { + if (string.IsNullOrWhiteSpace(cidr) || !IPNetwork.TryParse(cidr, out _)) + { + return LeasingPolicyValidationResult.Invalid($"Invalid CIDR: '{cidr}'."); + } + } + + return LeasingPolicyValidationResult.Valid; + } + + private static LeasingPolicyValidationResult ValidateTimeOfDay(TimeOfDayPolicy policy) + { + if (string.IsNullOrWhiteSpace(policy.Tz)) + { + return LeasingPolicyValidationResult.Invalid("time_of_day requires a tz."); + } + + try + { + TimeZoneInfo.FindSystemTimeZoneById(policy.Tz); + } + catch (TimeZoneNotFoundException) + { + return LeasingPolicyValidationResult.Invalid($"Unknown timezone: '{policy.Tz}'."); + } + catch (InvalidTimeZoneException) + { + return LeasingPolicyValidationResult.Invalid($"Invalid timezone: '{policy.Tz}'."); + } + + if (policy.Windows.Count == 0) + { + return LeasingPolicyValidationResult.Invalid("time_of_day requires at least one window."); + } + + foreach (var window in policy.Windows) + { + if (window.Days.Count == 0) + { + return LeasingPolicyValidationResult.Invalid("time_of_day window requires at least one day."); + } + + foreach (var day in window.Days) + { + if (!AllowedDays.Contains(day)) + { + return LeasingPolicyValidationResult.Invalid($"Invalid day: '{day}'."); + } + } + + if (!TimeOfDayRegex().IsMatch(window.From)) + { + return LeasingPolicyValidationResult.Invalid($"Invalid 'from' time: '{window.From}'. Expected HH:mm."); + } + + if (!TimeOfDayRegex().IsMatch(window.To)) + { + return LeasingPolicyValidationResult.Invalid($"Invalid 'to' time: '{window.To}'. Expected HH:mm."); + } + } + + return LeasingPolicyValidationResult.Valid; + } + + private static LeasingPolicyValidationResult ValidateAllOf(AllOfPolicy policy, int depth) + { + if (depth >= MaxCompositeDepth) + { + return LeasingPolicyValidationResult.Invalid($"all_of nesting exceeds maximum depth of {MaxCompositeDepth}."); + } + + if (policy.Policies.Count == 0) + { + return LeasingPolicyValidationResult.Invalid("all_of requires at least one child policy."); + } + + if (policy.Policies.Count > MaxCompositeChildren) + { + return LeasingPolicyValidationResult.Invalid($"all_of cannot contain more than {MaxCompositeChildren} child policies."); + } + + foreach (var child in policy.Policies) + { + var childResult = ValidatePolicy(child, depth + 1); + if (!childResult.IsValid) + { + return childResult; + } + } + + return LeasingPolicyValidationResult.Valid; + } +} diff --git a/src/Infrastructure.Dapper/AdminConsole/Repositories/CollectionRepository.cs b/src/Infrastructure.Dapper/AdminConsole/Repositories/CollectionRepository.cs index 4eb8fc12e525..ef807fde445c 100644 --- a/src/Infrastructure.Dapper/AdminConsole/Repositories/CollectionRepository.cs +++ b/src/Infrastructure.Dapper/AdminConsole/Repositories/CollectionRepository.cs @@ -486,6 +486,8 @@ public CollectionWithGroupsAndUsers(Collection collection, Type = collection.Type; ExternalId = collection.ExternalId; DefaultUserCollectionEmail = collection.DefaultUserCollectionEmail; + LeasingEnabled = collection.LeasingEnabled; + LeasingPolicy = collection.LeasingPolicy; Groups = groups.ToArrayTVP(); Users = users.ToArrayTVP(); } @@ -510,6 +512,8 @@ public CollectionWithGroups(Collection collection, IEnumerable() .DidNotReceiveWithAnyArgs() .LogCollectionEventAsync(default, default); } + + [Theory, BitAutoData] + public async Task CreateAsync_PamFlagOff_ClearsLeasingFields( + Organization organization, Collection collection, + [CollectionAccessSelectionCustomize(true)] IEnumerable users, + SutProvider sutProvider) + { + collection.Id = default; + collection.LeasingEnabled = true; + collection.LeasingPolicy = """{"kind":"human_approval"}"""; + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.Pam) + .Returns(false); + sutProvider.GetDependency() + .GetByIdAsync(organization.Id) + .Returns(organization); + + await sutProvider.Sut.CreateAsync(collection, null, users); + + Assert.False(collection.LeasingEnabled); + Assert.Null(collection.LeasingPolicy); + sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .Validate(default); + } + + [Theory, BitAutoData] + public async Task CreateAsync_PamFlagOn_InvalidPolicy_ThrowsBadRequest( + Organization organization, Collection collection, + [CollectionAccessSelectionCustomize(true)] IEnumerable users, + SutProvider sutProvider) + { + collection.Id = default; + collection.LeasingEnabled = true; + collection.LeasingPolicy = """{"kind":"bogus"}"""; + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.Pam) + .Returns(true); + sutProvider.GetDependency() + .Validate(collection.LeasingPolicy) + .Returns(LeasingPolicyValidationResult.Invalid("Unsupported policy kind")); + sutProvider.GetDependency() + .GetByIdAsync(organization.Id) + .Returns(organization); + + var ex = await Assert.ThrowsAsync(() => sutProvider.Sut.CreateAsync(collection, null, users)); + Assert.Equal("Unsupported policy kind", ex.Message); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .CreateAsync(default, default, default); + } + + [Theory, BitAutoData] + public async Task CreateAsync_PamFlagOn_ValidPolicy_RetainsLeasingFields( + Organization organization, Collection collection, + [CollectionAccessSelectionCustomize(true)] IEnumerable users, + SutProvider sutProvider) + { + collection.Id = default; + collection.LeasingEnabled = true; + collection.LeasingPolicy = """{"kind":"human_approval"}"""; + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.Pam) + .Returns(true); + sutProvider.GetDependency() + .Validate(collection.LeasingPolicy) + .Returns(LeasingPolicyValidationResult.Valid); + sutProvider.GetDependency() + .GetByIdAsync(organization.Id) + .Returns(organization); + + await sutProvider.Sut.CreateAsync(collection, null, users); + + Assert.True(collection.LeasingEnabled); + Assert.Equal("""{"kind":"human_approval"}""", collection.LeasingPolicy); + } } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Collections/UpdateCollectionCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Collections/UpdateCollectionCommandTests.cs index acdd61583d56..7bc8119aea52 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/Collections/UpdateCollectionCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Collections/UpdateCollectionCommandTests.cs @@ -4,6 +4,7 @@ using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Data; +using Bit.Core.PrivilegedAccessManagement.Services; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Test.AutoFixture; @@ -323,6 +324,79 @@ await sutProvider.GetDependency() .ReplaceAsync(collection, null, null); } + [Theory, BitAutoData] + public async Task UpdateAsync_PamFlagOff_ClearsLeasingFields( + Organization organization, Collection collection) + { + var sutProvider = SetupSutProvider(); + organization.AllowAdminAccessToAllCollectionItems = true; + collection.LeasingEnabled = true; + collection.LeasingPolicy = """{"kind":"human_approval"}"""; + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.Pam) + .Returns(false); + sutProvider.GetDependency() + .GetByIdAsync(organization.Id) + .Returns(organization); + + await sutProvider.Sut.UpdateAsync(collection, null, null); + + Assert.False(collection.LeasingEnabled); + Assert.Null(collection.LeasingPolicy); + sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .Validate(default); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_PamFlagOn_InvalidPolicy_ThrowsBadRequest( + Organization organization, Collection collection) + { + var sutProvider = SetupSutProvider(); + organization.AllowAdminAccessToAllCollectionItems = true; + collection.LeasingEnabled = true; + collection.LeasingPolicy = """{"kind":"bogus"}"""; + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.Pam) + .Returns(true); + sutProvider.GetDependency() + .Validate(collection.LeasingPolicy) + .Returns(LeasingPolicyValidationResult.Invalid("Unsupported policy kind")); + sutProvider.GetDependency() + .GetByIdAsync(organization.Id) + .Returns(organization); + + var ex = await Assert.ThrowsAsync(() => sutProvider.Sut.UpdateAsync(collection, null, null)); + Assert.Equal("Unsupported policy kind", ex.Message); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .ReplaceAsync(default, default, default); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_PamFlagOn_ValidPolicy_RetainsLeasingFields( + Organization organization, Collection collection) + { + var sutProvider = SetupSutProvider(); + organization.AllowAdminAccessToAllCollectionItems = true; + collection.LeasingEnabled = true; + collection.LeasingPolicy = """{"kind":"human_approval"}"""; + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.Pam) + .Returns(true); + sutProvider.GetDependency() + .Validate(collection.LeasingPolicy) + .Returns(LeasingPolicyValidationResult.Valid); + sutProvider.GetDependency() + .GetByIdAsync(organization.Id) + .Returns(organization); + + await sutProvider.Sut.UpdateAsync(collection, null, null); + + Assert.True(collection.LeasingEnabled); + Assert.Equal("""{"kind":"human_approval"}""", collection.LeasingPolicy); + } + private static SutProvider SetupSutProvider() { var sutProvider = new SutProvider() diff --git a/test/Core.Test/PrivilegedAccessManagement/Services/LeasingPolicyValidatorTests.cs b/test/Core.Test/PrivilegedAccessManagement/Services/LeasingPolicyValidatorTests.cs new file mode 100644 index 000000000000..7b018a2af488 --- /dev/null +++ b/test/Core.Test/PrivilegedAccessManagement/Services/LeasingPolicyValidatorTests.cs @@ -0,0 +1,181 @@ +using Bit.Core.PrivilegedAccessManagement.Services; +using Xunit; + +namespace Bit.Core.Test.PrivilegedAccessManagement.Services; + +public class LeasingPolicyValidatorTests +{ + private readonly LeasingPolicyValidator _sut = new(); + + [Fact] + public void Validate_NullPolicy_IsValid() + { + var result = _sut.Validate(null); + + Assert.True(result.IsValid); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void Validate_EmptyOrWhitespacePolicy_IsInvalid(string policyJson) + { + var result = _sut.Validate(policyJson); + + Assert.False(result.IsValid); + } + + [Fact] + public void Validate_MalformedJson_IsInvalid() + { + var result = _sut.Validate("{not json"); + + Assert.False(result.IsValid); + Assert.Contains("malformed", result.Error); + } + + [Fact] + public void Validate_UnknownKind_IsInvalid() + { + var result = _sut.Validate("""{"kind":"bogus"}"""); + + Assert.False(result.IsValid); + } + + [Fact] + public void Validate_HumanApproval_IsValid() + { + var result = _sut.Validate("""{"kind":"human_approval"}"""); + + Assert.True(result.IsValid); + } + + [Theory] + [InlineData("""{"kind":"ip_allowlist","cidrs":["10.0.0.0/8"]}""")] + [InlineData("""{"kind":"ip_allowlist","cidrs":["10.0.0.0/8","192.168.0.0/16","2001:db8::/32"]}""")] + public void Validate_IpAllowlist_ValidCidrs_IsValid(string policyJson) + { + var result = _sut.Validate(policyJson); + + Assert.True(result.IsValid); + } + + [Theory] + [InlineData("""{"kind":"ip_allowlist","cidrs":[]}""", "at least one CIDR")] + [InlineData("""{"kind":"ip_allowlist","cidrs":["not-a-cidr"]}""", "Invalid CIDR")] + [InlineData("""{"kind":"ip_allowlist","cidrs":["10.0.0.0/99"]}""", "Invalid CIDR")] + public void Validate_IpAllowlist_InvalidCidrs_IsInvalid(string policyJson, string expectedMessageFragment) + { + var result = _sut.Validate(policyJson); + + Assert.False(result.IsValid); + Assert.Contains(expectedMessageFragment, result.Error); + } + + [Fact] + public void Validate_TimeOfDay_Valid_IsValid() + { + var result = _sut.Validate(""" + { + "kind": "time_of_day", + "tz": "UTC", + "windows": [ + { "days": ["mon","tue","wed","thu","fri"], "from": "09:00", "to": "18:00" } + ] + } + """); + + Assert.True(result.IsValid); + } + + [Theory] + [InlineData("""{"kind":"time_of_day","tz":"Invalid/Zone","windows":[{"days":["mon"],"from":"09:00","to":"17:00"}]}""", "timezone")] + [InlineData("""{"kind":"time_of_day","tz":"UTC","windows":[]}""", "at least one window")] + [InlineData("""{"kind":"time_of_day","tz":"UTC","windows":[{"days":[],"from":"09:00","to":"17:00"}]}""", "at least one day")] + [InlineData("""{"kind":"time_of_day","tz":"UTC","windows":[{"days":["funday"],"from":"09:00","to":"17:00"}]}""", "day")] + [InlineData("""{"kind":"time_of_day","tz":"UTC","windows":[{"days":["mon"],"from":"9am","to":"5pm"}]}""", "Expected HH:mm")] + [InlineData("""{"kind":"time_of_day","tz":"UTC","windows":[{"days":["mon"],"from":"25:00","to":"26:00"}]}""", "Expected HH:mm")] + public void Validate_TimeOfDay_Invalid_IsInvalid(string policyJson, string expectedMessageFragment) + { + var result = _sut.Validate(policyJson); + + Assert.False(result.IsValid); + Assert.Contains(expectedMessageFragment, result.Error); + } + + [Fact] + public void Validate_AllOf_NestedHumanAndIpAllowlist_IsValid() + { + var result = _sut.Validate(""" + { + "kind": "all_of", + "policies": [ + { "kind": "human_approval" }, + { "kind": "ip_allowlist", "cidrs": ["10.0.0.0/8"] } + ] + } + """); + + Assert.True(result.IsValid); + } + + [Fact] + public void Validate_AllOf_EmptyChildren_IsInvalid() + { + var result = _sut.Validate("""{"kind":"all_of","policies":[]}"""); + + Assert.False(result.IsValid); + Assert.Contains("at least one child", result.Error); + } + + [Fact] + public void Validate_AllOf_ExceedsMaxNestingDepth_IsInvalid() + { + // Depth 4: all_of > all_of > all_of > all_of > human_approval; limit is 3 + var result = _sut.Validate(""" + { + "kind": "all_of", + "policies": [{ + "kind": "all_of", + "policies": [{ + "kind": "all_of", + "policies": [{ + "kind": "all_of", + "policies": [{ "kind": "human_approval" }] + }] + }] + }] + } + """); + + Assert.False(result.IsValid); + Assert.Contains("nesting", result.Error); + } + + [Fact] + public void Validate_AllOf_ExceedsMaxChildren_IsInvalid() + { + var policies = string.Join(",", Enumerable.Repeat("""{"kind":"human_approval"}""", 11)); + var result = _sut.Validate($$"""{"kind":"all_of","policies":[{{policies}}]}"""); + + Assert.False(result.IsValid); + Assert.Contains("more than", result.Error); + } + + [Fact] + public void Validate_AllOf_InvalidChild_IsInvalid() + { + var result = _sut.Validate(""" + { + "kind": "all_of", + "policies": [ + { "kind": "human_approval" }, + { "kind": "ip_allowlist", "cidrs": ["bogus"] } + ] + } + """); + + Assert.False(result.IsValid); + Assert.Contains("CIDR", result.Error); + } +} diff --git a/util/Migrator/DbScripts/2026-05-21_00_AddCollectionLeasing.sql b/util/Migrator/DbScripts/2026-05-21_00_AddCollectionLeasing.sql new file mode 100644 index 000000000000..aaef2ef1fb5e --- /dev/null +++ b/util/Migrator/DbScripts/2026-05-21_00_AddCollectionLeasing.sql @@ -0,0 +1,744 @@ +-- Add LeasingEnabled column to Collection table +IF COL_LENGTH('[dbo].[Collection]', 'LeasingEnabled') IS NULL +BEGIN + ALTER TABLE [dbo].[Collection] + ADD [LeasingEnabled] BIT NOT NULL CONSTRAINT [DF_Collection_LeasingEnabled] DEFAULT (0); +END +GO + +-- Add LeasingPolicy column to Collection table +IF COL_LENGTH('[dbo].[Collection]', 'LeasingPolicy') IS NULL +BEGIN + ALTER TABLE [dbo].[Collection] + ADD [LeasingPolicy] NVARCHAR(MAX) NULL; +END +GO + +-- Refresh modules that depend on the Collection schema +IF OBJECT_ID('[dbo].[CollectionView]') IS NOT NULL +BEGIN + EXECUTE sp_refreshsqlmodule N'[dbo].[CollectionView]'; +END +GO + +IF OBJECT_ID('[dbo].[Collection_ReadById]') IS NOT NULL +BEGIN + EXECUTE sp_refreshsqlmodule N'[dbo].[Collection_ReadById]'; +END +GO + +IF OBJECT_ID('[dbo].[Collection_ReadByIds]') IS NOT NULL +BEGIN + EXECUTE sp_refreshsqlmodule N'[dbo].[Collection_ReadByIds]'; +END +GO + +IF OBJECT_ID('[dbo].[Collection_ReadByOrganizationId]') IS NOT NULL +BEGIN + EXECUTE sp_refreshsqlmodule N'[dbo].[Collection_ReadByOrganizationId]'; +END +GO + +IF OBJECT_ID('[dbo].[UserCollectionDetails]') IS NOT NULL +BEGIN + EXECUTE sp_refreshsqlmodule N'[dbo].[UserCollectionDetails]'; +END +GO + +-- Update Collection_Create stored procedure to include leasing columns +CREATE OR ALTER PROCEDURE [dbo].[Collection_Create] + @Id UNIQUEIDENTIFIER OUTPUT, + @OrganizationId UNIQUEIDENTIFIER, + @Name VARCHAR(MAX), + @ExternalId NVARCHAR(300), + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7), + @DefaultUserCollectionEmail NVARCHAR(256) = NULL, + @Type TINYINT = 0, + @LeasingEnabled BIT = 0, + @LeasingPolicy NVARCHAR(MAX) = NULL +AS +BEGIN + SET NOCOUNT ON + + INSERT INTO [dbo].[Collection] + ( + [Id], + [OrganizationId], + [Name], + [ExternalId], + [CreationDate], + [RevisionDate], + [DefaultUserCollectionEmail], + [Type], + [LeasingEnabled], + [LeasingPolicy] + ) + VALUES + ( + @Id, + @OrganizationId, + @Name, + @ExternalId, + @CreationDate, + @RevisionDate, + @DefaultUserCollectionEmail, + @Type, + @LeasingEnabled, + @LeasingPolicy + ) + + EXEC [dbo].[User_BumpAccountRevisionDateByCollectionId] @Id, @OrganizationId +END +GO + +-- Update Collection_Update stored procedure to include leasing columns +CREATE OR ALTER PROCEDURE [dbo].[Collection_Update] + @Id UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @Name VARCHAR(MAX), + @ExternalId NVARCHAR(300), + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7), + @DefaultUserCollectionEmail NVARCHAR(256) = NULL, + @Type TINYINT = 0, + @LeasingEnabled BIT = 0, + @LeasingPolicy NVARCHAR(MAX) = NULL +AS +BEGIN + SET NOCOUNT ON + + UPDATE + [dbo].[Collection] + SET + [OrganizationId] = @OrganizationId, + [Name] = @Name, + [ExternalId] = @ExternalId, + [CreationDate] = @CreationDate, + [RevisionDate] = @RevisionDate, + [DefaultUserCollectionEmail] = @DefaultUserCollectionEmail, + [Type] = @Type, + [LeasingEnabled] = @LeasingEnabled, + [LeasingPolicy] = @LeasingPolicy + WHERE + [Id] = @Id + + EXEC [dbo].[User_BumpAccountRevisionDateByCollectionId] @Id, @OrganizationId +END +GO + +-- Update Collection_CreateWithGroupsAndUsers stored procedure to forward leasing columns +CREATE OR ALTER PROCEDURE [dbo].[Collection_CreateWithGroupsAndUsers] + @Id UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @Name VARCHAR(MAX), + @ExternalId NVARCHAR(300), + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7), + @Groups AS [dbo].[CollectionAccessSelectionType] READONLY, + @Users AS [dbo].[CollectionAccessSelectionType] READONLY, + @DefaultUserCollectionEmail NVARCHAR(256) = NULL, + @Type TINYINT = 0, + @LeasingEnabled BIT = 0, + @LeasingPolicy NVARCHAR(MAX) = NULL +AS +BEGIN + SET NOCOUNT ON + + EXEC [dbo].[Collection_Create] @Id, @OrganizationId, @Name, @ExternalId, @CreationDate, @RevisionDate, @DefaultUserCollectionEmail, @Type, @LeasingEnabled, @LeasingPolicy + + -- Groups + ;WITH [AvailableGroupsCTE] AS( + SELECT + [Id] + FROM + [dbo].[Group] + WHERE + [OrganizationId] = @OrganizationId + ) + INSERT INTO [dbo].[CollectionGroup] + ( + [CollectionId], + [GroupId], + [ReadOnly], + [HidePasswords], + [Manage] + ) + SELECT + @Id, + [Id], + [ReadOnly], + [HidePasswords], + [Manage] + FROM + @Groups + WHERE + [Id] IN (SELECT [Id] FROM [AvailableGroupsCTE]) + + -- Users + ;WITH [AvailableUsersCTE] AS( + SELECT + [Id] + FROM + [dbo].[OrganizationUser] + WHERE + [OrganizationId] = @OrganizationId + ) + INSERT INTO [dbo].[CollectionUser] + ( + [CollectionId], + [OrganizationUserId], + [ReadOnly], + [HidePasswords], + [Manage] + ) + SELECT + @Id, + [Id], + [ReadOnly], + [HidePasswords], + [Manage] + FROM + @Users + WHERE + [Id] IN (SELECT [Id] FROM [AvailableUsersCTE]) + + EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationId] @OrganizationId +END +GO + +-- Update Collection_UpdateWithGroupsAndUsers stored procedure to forward leasing columns +CREATE OR ALTER PROCEDURE [dbo].[Collection_UpdateWithGroupsAndUsers] + @Id UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @Name VARCHAR(MAX), + @ExternalId NVARCHAR(300), + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7), + @Groups AS [dbo].[CollectionAccessSelectionType] READONLY, + @Users AS [dbo].[CollectionAccessSelectionType] READONLY, + @DefaultUserCollectionEmail NVARCHAR(256) = NULL, + @Type TINYINT = 0, + @LeasingEnabled BIT = 0, + @LeasingPolicy NVARCHAR(MAX) = NULL +AS +BEGIN + SET NOCOUNT ON + + EXEC [dbo].[Collection_Update] @Id, @OrganizationId, @Name, @ExternalId, @CreationDate, @RevisionDate, @DefaultUserCollectionEmail, @Type, @LeasingEnabled, @LeasingPolicy + + -- Bump RevisionDate on all affected groups (old + new) before modifying CollectionGroup + ;WITH [AffectedGroupsCTE] AS ( + SELECT + g.[Id] + FROM + @Groups g + + UNION + + SELECT + CG.[GroupId] + FROM + [dbo].[CollectionGroup] CG + WHERE + CG.[CollectionId] = @Id + ) + UPDATE + G + SET + G.[RevisionDate] = @RevisionDate + FROM + [dbo].[Group] G + WHERE + G.[OrganizationId] = @OrganizationId + AND G.[Id] IN (SELECT [Id] FROM [AffectedGroupsCTE]) + + -- Groups + -- Delete groups that are no longer in source + DELETE cg + FROM [dbo].[CollectionGroup] cg + LEFT JOIN @Groups g ON cg.GroupId = g.Id + WHERE cg.CollectionId = @Id + AND g.Id IS NULL; + + -- Update existing groups + UPDATE cg + SET cg.ReadOnly = g.ReadOnly, + cg.HidePasswords = g.HidePasswords, + cg.Manage = g.Manage + FROM [dbo].[CollectionGroup] cg + INNER JOIN @Groups g ON cg.GroupId = g.Id + WHERE cg.CollectionId = @Id + AND (cg.ReadOnly != g.ReadOnly + OR cg.HidePasswords != g.HidePasswords + OR cg.Manage != g.Manage); + + -- Insert new groups + INSERT INTO [dbo].[CollectionGroup] + ( + [CollectionId], + [GroupId], + [ReadOnly], + [HidePasswords], + [Manage] + ) + SELECT + @Id, + g.Id, + g.ReadOnly, + g.HidePasswords, + g.Manage + FROM @Groups g + INNER JOIN [dbo].[Group] grp ON grp.Id = g.Id + LEFT JOIN [dbo].[CollectionGroup] cg + ON cg.CollectionId = @Id AND cg.GroupId = g.Id + WHERE grp.OrganizationId = @OrganizationId + AND cg.CollectionId IS NULL; + + -- Users + -- Delete users that are no longer in source + DELETE cu + FROM [dbo].[CollectionUser] cu + LEFT JOIN @Users u ON cu.OrganizationUserId = u.Id + WHERE cu.CollectionId = @Id + AND u.Id IS NULL; + + -- Update existing users + UPDATE cu + SET cu.ReadOnly = u.ReadOnly, + cu.HidePasswords = u.HidePasswords, + cu.Manage = u.Manage + FROM [dbo].[CollectionUser] cu + INNER JOIN @Users u ON cu.OrganizationUserId = u.Id + WHERE cu.CollectionId = @Id + AND (cu.ReadOnly != u.ReadOnly + OR cu.HidePasswords != u.HidePasswords + OR cu.Manage != u.Manage); + + -- Insert new users + INSERT INTO [dbo].[CollectionUser] + ( + [CollectionId], + [OrganizationUserId], + [ReadOnly], + [HidePasswords], + [Manage] + ) + SELECT + @Id, + u.Id, + u.ReadOnly, + u.HidePasswords, + u.Manage + FROM @Users u + INNER JOIN [dbo].[OrganizationUser] ou ON ou.Id = u.Id + LEFT JOIN [dbo].[CollectionUser] cu + ON cu.CollectionId = @Id AND cu.OrganizationUserId = u.Id + WHERE ou.OrganizationId = @OrganizationId + AND cu.CollectionId IS NULL; + + EXEC [dbo].[User_BumpAccountRevisionDateByCollectionId] @Id, @OrganizationId +END +GO + +-- Update Collection_UpdateWithGroups stored procedure to forward leasing columns +CREATE OR ALTER PROCEDURE [dbo].[Collection_UpdateWithGroups] + @Id UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @Name VARCHAR(MAX), + @ExternalId NVARCHAR(300), + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7), + @Groups AS [dbo].[CollectionAccessSelectionType] READONLY, + @DefaultUserCollectionEmail NVARCHAR(256) = NULL, + @Type TINYINT = 0, + @LeasingEnabled BIT = 0, + @LeasingPolicy NVARCHAR(MAX) = NULL +AS +BEGIN + SET NOCOUNT ON + + EXEC [dbo].[Collection_Update] @Id, @OrganizationId, @Name, @ExternalId, @CreationDate, @RevisionDate, @DefaultUserCollectionEmail, @Type, @LeasingEnabled, @LeasingPolicy + + -- Bump RevisionDate on all affected groups (old + new) before modifying CollectionGroup + ;WITH [AffectedGroupsCTE] AS ( + SELECT + g.[Id] + FROM + @Groups g + + UNION + + SELECT + CG.[GroupId] + FROM + [dbo].[CollectionGroup] CG + WHERE + CG.[CollectionId] = @Id + ) + UPDATE + G + SET + G.[RevisionDate] = @RevisionDate + FROM + [dbo].[Group] G + WHERE + G.[OrganizationId] = @OrganizationId + AND G.[Id] IN (SELECT [Id] FROM [AffectedGroupsCTE]) + + -- Groups + -- Delete groups that are no longer in source + DELETE + cg + FROM + [dbo].[CollectionGroup] cg + LEFT JOIN + @Groups g ON cg.GroupId = g.Id + WHERE + cg.CollectionId = @Id + AND g.Id IS NULL; + + -- Update existing groups + UPDATE + cg + SET + cg.ReadOnly = g.ReadOnly, + cg.HidePasswords = g.HidePasswords, + cg.Manage = g.Manage + FROM + [dbo].[CollectionGroup] cg + INNER JOIN + @Groups g ON cg.GroupId = g.Id + WHERE + cg.CollectionId = @Id + AND ( + cg.ReadOnly != g.ReadOnly + OR cg.HidePasswords != g.HidePasswords + OR cg.Manage != g.Manage + ); + + -- Insert new groups + INSERT INTO [dbo].[CollectionGroup] + ( + [CollectionId], + [GroupId], + [ReadOnly], + [HidePasswords], + [Manage] + ) + SELECT + @Id, + g.Id, + g.ReadOnly, + g.HidePasswords, + g.Manage + FROM + @Groups g + INNER JOIN + [dbo].[Group] grp ON grp.Id = g.Id + LEFT JOIN + [dbo].[CollectionGroup] cg ON cg.CollectionId = @Id AND cg.GroupId = g.Id + WHERE + grp.OrganizationId = @OrganizationId + AND cg.CollectionId IS NULL; + + EXEC [dbo].[User_BumpAccountRevisionDateByCollectionId] @Id, @OrganizationId +END +GO + +-- Update Collection_UpdateWithUsers stored procedure to forward leasing columns +CREATE OR ALTER PROCEDURE [dbo].[Collection_UpdateWithUsers] + @Id UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @Name VARCHAR(MAX), + @ExternalId NVARCHAR(300), + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7), + @Users AS [dbo].[CollectionAccessSelectionType] READONLY, + @DefaultUserCollectionEmail NVARCHAR(256) = NULL, + @Type TINYINT = 0, + @LeasingEnabled BIT = 0, + @LeasingPolicy NVARCHAR(MAX) = NULL +AS +BEGIN + SET NOCOUNT ON + + EXEC [dbo].[Collection_Update] @Id, @OrganizationId, @Name, @ExternalId, @CreationDate, @RevisionDate, @DefaultUserCollectionEmail, @Type, @LeasingEnabled, @LeasingPolicy + + -- Users + -- Delete users that are no longer in source + DELETE + cu + FROM + [dbo].[CollectionUser] cu + LEFT JOIN + @Users u ON cu.OrganizationUserId = u.Id + WHERE + cu.CollectionId = @Id + AND u.Id IS NULL; + + -- Update existing users + UPDATE + cu + SET + cu.ReadOnly = u.ReadOnly, + cu.HidePasswords = u.HidePasswords, + cu.Manage = u.Manage + FROM + [dbo].[CollectionUser] cu + INNER JOIN + @Users u ON cu.OrganizationUserId = u.Id + WHERE + cu.CollectionId = @Id + AND ( + cu.ReadOnly != u.ReadOnly + OR cu.HidePasswords != u.HidePasswords + OR cu.Manage != u.Manage + ); + + -- Insert new users + INSERT INTO [dbo].[CollectionUser] + ( + [CollectionId], + [OrganizationUserId], + [ReadOnly], + [HidePasswords], + [Manage] + ) + SELECT + @Id, + u.Id, + u.ReadOnly, + u.HidePasswords, + u.Manage + FROM + @Users u + INNER JOIN + [dbo].[OrganizationUser] ou ON ou.Id = u.Id + LEFT JOIN + [dbo].[CollectionUser] cu ON cu.CollectionId = @Id AND cu.OrganizationUserId = u.Id + WHERE + ou.OrganizationId = @OrganizationId + AND cu.CollectionId IS NULL; + + EXEC [dbo].[User_BumpAccountRevisionDateByCollectionId] @Id, @OrganizationId +END +GO + +-- Update Collection_ReadByUserId stored procedure to project leasing columns +CREATE OR ALTER PROCEDURE [dbo].[Collection_ReadByUserId] + @UserId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT + Id, + OrganizationId, + [Name], + CreationDate, + RevisionDate, + ExternalId, + MIN([ReadOnly]) AS [ReadOnly], + MIN([HidePasswords]) AS [HidePasswords], + MAX([Manage]) AS [Manage], + [DefaultUserCollectionEmail], + [Type], + [LeasingEnabled], + [LeasingPolicy] + FROM + [dbo].[UserCollectionDetails](@UserId) + GROUP BY + Id, + OrganizationId, + [Name], + CreationDate, + RevisionDate, + ExternalId, + [DefaultUserCollectionEmail], + [Type], + [LeasingEnabled], + [LeasingPolicy] +END +GO + +-- Update Collection_ReadByIdWithPermissions stored procedure to GROUP BY leasing columns +CREATE OR ALTER PROCEDURE [dbo].[Collection_ReadByIdWithPermissions] + @CollectionId UNIQUEIDENTIFIER, + @UserId UNIQUEIDENTIFIER, + @IncludeAccessRelationships BIT +AS +BEGIN + SET NOCOUNT ON + + SELECT + C.*, + MIN(CASE + WHEN + COALESCE(CU.[ReadOnly], CG.[ReadOnly], 0) = 0 + THEN 0 + ELSE 1 + END) AS [ReadOnly], + MIN (CASE + WHEN + COALESCE(CU.[HidePasswords], CG.[HidePasswords], 0) = 0 + THEN 0 + ELSE 1 + END) AS [HidePasswords], + MAX(CASE + WHEN + COALESCE(CU.[Manage], CG.[Manage], 0) = 0 + THEN 0 + ELSE 1 + END) AS [Manage], + MAX(CASE + WHEN + CU.[CollectionId] IS NULL AND CG.[CollectionId] IS NULL + THEN 0 + ELSE 1 + END) AS [Assigned], + CASE + WHEN + -- No user or group has manage rights + NOT EXISTS( + SELECT 1 + FROM [dbo].[CollectionUser] CU2 + JOIN [dbo].[OrganizationUser] OU2 ON CU2.[OrganizationUserId] = OU2.[Id] + WHERE + CU2.[CollectionId] = C.[Id] AND + CU2.[Manage] = 1 + ) + AND NOT EXISTS ( + SELECT 1 + FROM [dbo].[CollectionGroup] CG2 + WHERE + CG2.[CollectionId] = C.[Id] AND + CG2.[Manage] = 1 + ) + THEN 1 + ELSE 0 + END AS [Unmanaged] + FROM + [dbo].[CollectionView] C + LEFT JOIN + [dbo].[OrganizationUser] OU ON C.[OrganizationId] = OU.[OrganizationId] AND OU.[UserId] = @UserId + LEFT JOIN + [dbo].[CollectionUser] CU ON CU.[CollectionId] = C.[Id] AND CU.[OrganizationUserId] = [OU].[Id] + LEFT JOIN + [dbo].[GroupUser] GU ON CU.[CollectionId] IS NULL AND GU.[OrganizationUserId] = OU.[Id] + LEFT JOIN + [dbo].[Group] G ON G.[Id] = GU.[GroupId] + LEFT JOIN + [dbo].[CollectionGroup] CG ON CG.[CollectionId] = C.[Id] AND CG.[GroupId] = GU.[GroupId] + WHERE + C.[Id] = @CollectionId + GROUP BY + C.[Id], + C.[OrganizationId], + C.[Name], + C.[CreationDate], + C.[RevisionDate], + C.[ExternalId], + C.[DefaultUserCollectionEmail], + C.[Type], + C.[LeasingEnabled], + C.[LeasingPolicy] + + IF (@IncludeAccessRelationships = 1) + BEGIN + EXEC [dbo].[CollectionGroup_ReadByCollectionId] @CollectionId + EXEC [dbo].[CollectionUser_ReadByCollectionId] @CollectionId + END +END +GO + +-- Update Collection_ReadSharedCollectionsByOrganizationIdWithPermissions stored procedure to GROUP BY leasing columns +CREATE OR ALTER PROCEDURE [dbo].[Collection_ReadSharedCollectionsByOrganizationIdWithPermissions] + @OrganizationId UNIQUEIDENTIFIER, + @UserId UNIQUEIDENTIFIER, + @IncludeAccessRelationships BIT +AS +BEGIN + SET NOCOUNT ON + + SELECT + C.*, + MIN(CASE + WHEN + COALESCE(CU.[ReadOnly], CG.[ReadOnly], 0) = 0 + THEN 0 + ELSE 1 + END) AS [ReadOnly], + MIN(CASE + WHEN + COALESCE(CU.[HidePasswords], CG.[HidePasswords], 0) = 0 + THEN 0 + ELSE 1 + END) AS [HidePasswords], + MAX(CASE + WHEN + COALESCE(CU.[Manage], CG.[Manage], 0) = 0 + THEN 0 + ELSE 1 + END) AS [Manage], + MAX(CASE + WHEN + CU.[CollectionId] IS NULL AND CG.[CollectionId] IS NULL + THEN 0 + ELSE 1 + END) AS [Assigned], + CASE + WHEN + -- No user or group has manage rights + NOT EXISTS( + SELECT 1 + FROM [dbo].[CollectionUser] CU2 + JOIN [dbo].[OrganizationUser] OU2 ON CU2.[OrganizationUserId] = OU2.[Id] + WHERE + CU2.[CollectionId] = C.[Id] AND + CU2.[Manage] = 1 + ) + AND NOT EXISTS ( + SELECT 1 + FROM [dbo].[CollectionGroup] CG2 + WHERE + CG2.[CollectionId] = C.[Id] AND + CG2.[Manage] = 1 + ) + THEN 1 + ELSE 0 + END AS [Unmanaged] + FROM + [dbo].[CollectionView] C + LEFT JOIN + [dbo].[OrganizationUser] OU ON C.[OrganizationId] = OU.[OrganizationId] AND OU.[UserId] = @UserId + LEFT JOIN + [dbo].[CollectionUser] CU ON CU.[CollectionId] = C.[Id] AND CU.[OrganizationUserId] = [OU].[Id] + LEFT JOIN + [dbo].[GroupUser] GU ON CU.[CollectionId] IS NULL AND GU.[OrganizationUserId] = OU.[Id] + LEFT JOIN + [dbo].[Group] G ON G.[Id] = GU.[GroupId] + LEFT JOIN + [dbo].[CollectionGroup] CG ON CG.[CollectionId] = C.[Id] AND CG.[GroupId] = GU.[GroupId] + WHERE + C.[OrganizationId] = @OrganizationId AND + C.[Type] = 0 -- Only SharedCollection + GROUP BY + C.[Id], + C.[OrganizationId], + C.[Name], + C.[CreationDate], + C.[RevisionDate], + C.[ExternalId], + C.[DefaultUserCollectionEmail], + C.[Type], + C.[LeasingEnabled], + C.[LeasingPolicy] + + IF (@IncludeAccessRelationships = 1) + BEGIN + EXEC [dbo].[CollectionGroup_ReadByOrganizationId] @OrganizationId + EXEC [dbo].[CollectionUser_ReadByOrganizationId] @OrganizationId + END +END +GO diff --git a/util/MySqlMigrations/Migrations/20260521121534_AddCollectionLeasing.Designer.cs b/util/MySqlMigrations/Migrations/20260521121534_AddCollectionLeasing.Designer.cs new file mode 100644 index 000000000000..dbc37bdbf127 --- /dev/null +++ b/util/MySqlMigrations/Migrations/20260521121534_AddCollectionLeasing.Designer.cs @@ -0,0 +1,3767 @@ +// +using System; +using Bit.Infrastructure.EntityFramework.Repositories; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Bit.MySqlMigrations.Migrations +{ + [DbContext(typeof(DatabaseContext))] + [Migration("20260521121534_AddCollectionLeasing")] + partial class AddCollectionLeasing + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); + + modelBuilder.Entity("Bit.Core.Dirt.Reports.Models.Data.OrganizationMemberBaseDetail", b => + { + b.Property("CipherId") + .HasColumnType("char(36)"); + + b.Property("CollectionId") + .HasColumnType("char(36)"); + + b.Property("CollectionName") + .HasColumnType("longtext"); + + b.Property("Email") + .HasColumnType("longtext"); + + b.Property("GroupId") + .HasColumnType("char(36)"); + + b.Property("GroupName") + .HasColumnType("longtext"); + + b.Property("HidePasswords") + .HasColumnType("tinyint(1)"); + + b.Property("Manage") + .HasColumnType("tinyint(1)"); + + b.Property("ReadOnly") + .HasColumnType("tinyint(1)"); + + b.Property("ResetPasswordKey") + .HasColumnType("longtext"); + + b.Property("TwoFactorProviders") + .HasColumnType("longtext"); + + b.Property("UserGuid") + .HasColumnType("char(36)"); + + b.Property("UserName") + .HasColumnType("longtext"); + + b.Property("UsesKeyConnector") + .HasColumnType("tinyint(1)"); + + b.ToTable("OrganizationMemberBaseDetails"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Collection", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("DefaultUserCollectionEmail") + .HasColumnType("longtext"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("LeasingEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("LeasingPolicy") + .HasColumnType("longtext"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Collection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.CollectionGroup", b => + { + b.Property("CollectionId") + .HasColumnType("char(36)"); + + b.Property("GroupId") + .HasColumnType("char(36)"); + + b.Property("HidePasswords") + .HasColumnType("tinyint(1)"); + + b.Property("Manage") + .HasColumnType("tinyint(1)"); + + b.Property("ReadOnly") + .HasColumnType("tinyint(1)"); + + b.HasKey("CollectionId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("CollectionGroups"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.CollectionUser", b => + { + b.Property("CollectionId") + .HasColumnType("char(36)"); + + b.Property("OrganizationUserId") + .HasColumnType("char(36)"); + + b.Property("HidePasswords") + .HasColumnType("tinyint(1)"); + + b.Property("Manage") + .HasColumnType("tinyint(1)"); + + b.Property("ReadOnly") + .HasColumnType("tinyint(1)"); + + b.HasKey("CollectionId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AllowAdminAccessToAllCollectionItems") + .HasColumnType("tinyint(1)") + .HasDefaultValue(true); + + b.Property("BillingEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("BusinessAddress1") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("BusinessAddress2") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("BusinessAddress3") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("BusinessCountry") + .HasMaxLength(2) + .HasColumnType("varchar(2)"); + + b.Property("BusinessName") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("BusinessTaxNumber") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property("ExemptFromBillingAutomation") + .HasColumnType("tinyint(1)"); + + b.Property("ExpirationDate") + .HasColumnType("datetime(6)"); + + b.Property("Gateway") + .HasColumnType("tinyint unsigned"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Identifier") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("LimitCollectionCreation") + .HasColumnType("tinyint(1)"); + + b.Property("LimitCollectionDeletion") + .HasColumnType("tinyint(1)"); + + b.Property("LimitItemDeletion") + .HasColumnType("tinyint(1)"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("int"); + + b.Property("MaxAutoscaleSmSeats") + .HasColumnType("int"); + + b.Property("MaxAutoscaleSmServiceAccounts") + .HasColumnType("int"); + + b.Property("MaxCollections") + .HasColumnType("smallint"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("OwnersNotifiedOfAutoscaling") + .HasColumnType("datetime(6)"); + + b.Property("Plan") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("PlanType") + .HasColumnType("tinyint unsigned"); + + b.Property("PrivateKey") + .HasColumnType("longtext"); + + b.Property("PublicKey") + .HasColumnType("longtext"); + + b.Property("ReferenceData") + .HasColumnType("longtext"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Seats") + .HasColumnType("int"); + + b.Property("SelfHost") + .HasColumnType("tinyint(1)"); + + b.Property("SmSeats") + .HasColumnType("int"); + + b.Property("SmServiceAccounts") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("tinyint unsigned"); + + b.Property("Storage") + .HasColumnType("bigint"); + + b.Property("SyncSeats") + .HasColumnType("tinyint(1)"); + + b.Property("TwoFactorProviders") + .HasColumnType("longtext"); + + b.Property("Use2fa") + .HasColumnType("tinyint(1)"); + + b.Property("UseAdminSponsoredFamilies") + .HasColumnType("tinyint(1)"); + + b.Property("UseApi") + .HasColumnType("tinyint(1)"); + + b.Property("UseAutomaticUserConfirmation") + .HasColumnType("tinyint(1)"); + + b.Property("UseCustomPermissions") + .HasColumnType("tinyint(1)"); + + b.Property("UseDirectory") + .HasColumnType("tinyint(1)"); + + b.Property("UseDisableSmAdsForUsers") + .HasColumnType("tinyint(1)"); + + b.Property("UseEvents") + .HasColumnType("tinyint(1)"); + + b.Property("UseGroups") + .HasColumnType("tinyint(1)"); + + b.Property("UseInviteLinks") + .HasColumnType("tinyint(1)"); + + b.Property("UseKeyConnector") + .HasColumnType("tinyint(1)"); + + b.Property("UseMyItems") + .HasColumnType("tinyint(1)"); + + b.Property("UseOrganizationDomains") + .HasColumnType("tinyint(1)"); + + b.Property("UsePasswordManager") + .HasColumnType("tinyint(1)"); + + b.Property("UsePhishingBlocker") + .HasColumnType("tinyint(1)"); + + b.Property("UsePolicies") + .HasColumnType("tinyint(1)"); + + b.Property("UseResetPassword") + .HasColumnType("tinyint(1)"); + + b.Property("UseRiskInsights") + .HasColumnType("tinyint(1)"); + + b.Property("UseScim") + .HasColumnType("tinyint(1)"); + + b.Property("UseSecretsManager") + .HasColumnType("tinyint(1)"); + + b.Property("UseSso") + .HasColumnType("tinyint(1)"); + + b.Property("UseTotp") + .HasColumnType("tinyint(1)"); + + b.Property("UsersGetPremium") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + b.HasIndex("GatewayCustomerId"); + + b.HasIndex("GatewaySubscriptionId"); + + b.HasIndex("Id", "Enabled") + .HasAnnotation("Npgsql:IndexInclude", new[] { "UseTotp", "UsersGetPremium" }); + + b.ToTable("Organization", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationInviteLink", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AllowedDomains") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Code") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("EncryptedInviteKey") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("EncryptedOrgKey") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationInviteLink", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Data") + .HasColumnType("longtext"); + + b.Property("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "Type") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Policy", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("BillingEmail") + .HasColumnType("longtext"); + + b.Property("BillingPhone") + .HasColumnType("longtext"); + + b.Property("BusinessAddress1") + .HasColumnType("longtext"); + + b.Property("BusinessAddress2") + .HasColumnType("longtext"); + + b.Property("BusinessAddress3") + .HasColumnType("longtext"); + + b.Property("BusinessCountry") + .HasColumnType("longtext"); + + b.Property("BusinessName") + .HasColumnType("longtext"); + + b.Property("BusinessTaxNumber") + .HasColumnType("longtext"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("DiscountId") + .HasColumnType("longtext"); + + b.Property("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property("Gateway") + .HasColumnType("tinyint unsigned"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Name") + .HasColumnType("longtext"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("tinyint unsigned"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UseEvents") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + b.HasIndex("GatewayCustomerId"); + + b.HasIndex("GatewaySubscriptionId"); + + b.ToTable("Provider", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Settings") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ProviderId"); + + b.ToTable("ProviderOrganization", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderUser", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Email") + .HasColumnType("longtext"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("Permissions") + .HasColumnType("longtext"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("tinyint unsigned"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.HasIndex("UserId"); + + b.ToTable("ProviderUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.AuthRequest", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AccessCode") + .HasMaxLength(25) + .HasColumnType("varchar(25)"); + + b.Property("Approved") + .HasColumnType("tinyint(1)"); + + b.Property("AuthenticationDate") + .HasColumnType("datetime(6)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("MasterPasswordHash") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("PublicKey") + .HasColumnType("longtext"); + + b.Property("RequestCountryName") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("RequestDeviceIdentifier") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("RequestDeviceType") + .HasColumnType("tinyint unsigned"); + + b.Property("RequestIpAddress") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("ResponseDate") + .HasColumnType("datetime(6)"); + + b.Property("ResponseDeviceId") + .HasColumnType("char(36)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ResponseDeviceId"); + + b.HasIndex("UserId"); + + b.ToTable("AuthRequest", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("GranteeId") + .HasColumnType("char(36)"); + + b.Property("GrantorId") + .HasColumnType("char(36)"); + + b.Property("KeyEncrypted") + .HasColumnType("longtext"); + + b.Property("LastNotificationDate") + .HasColumnType("datetime(6)"); + + b.Property("RecoveryInitiatedDate") + .HasColumnType("datetime(6)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("tinyint unsigned"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("WaitTimeDays") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("GranteeId"); + + b.HasIndex("GrantorId"); + + b.ToTable("EmergencyAccess", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.Grant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ClientId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("ConsumedDate") + .HasColumnType("datetime(6)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Data") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Description") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("ExpirationDate") + .HasColumnType("datetime(6)"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("SessionId") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("SubjectId") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.HasKey("Id") + .HasName("PK_Grant") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpirationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("Grant", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Data") + .HasColumnType("longtext"); + + b.Property("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("SsoConfig", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId"); + + b.HasIndex("OrganizationId", "ExternalId") + .IsUnique() + .HasAnnotation("Npgsql:IndexInclude", new[] { "UserId" }) + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "UserId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SsoUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AaGuid") + .HasColumnType("char(36)"); + + b.Property("Counter") + .HasColumnType("int"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("CredentialId") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("EncryptedPrivateKey") + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("EncryptedPublicKey") + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("EncryptedUserKey") + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("PublicKey") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("SupportsPrf") + .HasColumnType("tinyint(1)"); + + b.Property("Type") + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("WebAuthnCredential", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ClientOrganizationMigrationRecord", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ExpirationDate") + .HasColumnType("datetime(6)"); + + b.Property("GatewayCustomerId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("GatewaySubscriptionId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("int"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("PlanType") + .HasColumnType("tinyint unsigned"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("Seats") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("tinyint unsigned"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId", "OrganizationId") + .IsUnique(); + + b.ToTable("ClientOrganizationMigrationRecord", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("InstallationId") + .HasColumnType("char(36)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("InstallationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationInstallation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationPlanMigrationCohort", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ChurnDiscountCouponCode") + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("MigrationPathId") + .HasColumnType("tinyint unsigned"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("ProactiveDiscountCouponCode") + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("Name") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationPlanMigrationCohort", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationPlanMigrationCohortAssignment", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ChurnDiscountAppliedDate") + .HasColumnType("datetime(6)"); + + b.Property("CohortId") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("MigratedDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("ScheduledDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("CohortId", "ScheduledDate", "MigratedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationPlanMigrationCohortAssignment", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AssignedSeats") + .HasColumnType("int"); + + b.Property("ClientId") + .HasColumnType("char(36)"); + + b.Property("ClientName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Created") + .HasColumnType("datetime(6)"); + + b.Property("InvoiceId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("InvoiceNumber") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("PlanName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("Total") + .HasColumnType("decimal(65,30)"); + + b.Property("UsedSeats") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.ToTable("ProviderInvoiceItem", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AllocatedSeats") + .HasColumnType("int"); + + b.Property("PlanType") + .HasColumnType("tinyint unsigned"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("PurchasedSeats") + .HasColumnType("int"); + + b.Property("SeatMinimum") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.HasIndex("Id", "PlanType") + .IsUnique(); + + b.ToTable("ProviderPlan", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.SubscriptionDiscount", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AmountOff") + .HasColumnType("bigint"); + + b.Property("AudienceType") + .HasColumnType("int"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Currency") + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("Duration") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("DurationInMonths") + .HasColumnType("int"); + + b.Property("EndDate") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("PercentOff") + .HasPrecision(5, 2) + .HasColumnType("decimal(5,2)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("StartDate") + .HasColumnType("datetime(6)"); + + b.Property("StripeCouponId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("StripeProductIds") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("StripeCouponId") + .IsUnique(); + + b.HasIndex("StartDate", "EndDate") + .HasDatabaseName("IX_SubscriptionDiscount_DateRange") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SubscriptionDiscount", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationApplication", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Applications") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("ContentEncryptionKey") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationApplication", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegration", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Configuration") + .HasColumnType("longtext"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "Type") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationIntegration", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegrationConfiguration", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Configuration") + .HasColumnType("longtext"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("EventType") + .HasColumnType("int"); + + b.Property("Filters") + .HasColumnType("longtext"); + + b.Property("OrganizationIntegrationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Template") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationIntegrationId"); + + b.ToTable("OrganizationIntegrationConfiguration", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ApplicationAtRiskCount") + .HasColumnType("int"); + + b.Property("ApplicationCount") + .HasColumnType("int"); + + b.Property("ApplicationData") + .HasColumnType("longtext"); + + b.Property("ContentEncryptionKey") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("CriticalApplicationAtRiskCount") + .HasColumnType("int"); + + b.Property("CriticalApplicationCount") + .HasColumnType("int"); + + b.Property("CriticalMemberAtRiskCount") + .HasColumnType("int"); + + b.Property("CriticalMemberCount") + .HasColumnType("int"); + + b.Property("CriticalPasswordAtRiskCount") + .HasColumnType("int"); + + b.Property("CriticalPasswordCount") + .HasColumnType("int"); + + b.Property("MemberAtRiskCount") + .HasColumnType("int"); + + b.Property("MemberCount") + .HasColumnType("int"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("PasswordAtRiskCount") + .HasColumnType("int"); + + b.Property("PasswordCount") + .HasColumnType("int"); + + b.Property("ReportData") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("ReportFile") + .HasColumnType("longtext"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("SummaryData") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationReport", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.PasswordHealthReportApplication", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Uri") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("PasswordHealthReportApplication", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Cache", b => + { + b.Property("Id") + .HasMaxLength(449) + .HasColumnType("varchar(449)"); + + b.Property("AbsoluteExpiration") + .HasColumnType("datetime(6)"); + + b.Property("ExpiresAtTime") + .HasColumnType("datetime(6)"); + + b.Property("SlidingExpirationInSeconds") + .HasColumnType("bigint"); + + b.Property("Value") + .IsRequired() + .HasColumnType("longblob"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpiresAtTime") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Cache", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.Property("CollectionId") + .HasColumnType("char(36)"); + + b.Property("CipherId") + .HasColumnType("char(36)"); + + b.HasKey("CollectionId", "CipherId"); + + b.HasIndex("CipherId"); + + b.ToTable("CollectionCipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Active") + .HasColumnType("tinyint(1)") + .HasDefaultValue(true); + + b.Property("ClientVersion") + .HasMaxLength(43) + .HasColumnType("varchar(43)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("EncryptedPrivateKey") + .HasColumnType("longtext"); + + b.Property("EncryptedPublicKey") + .HasColumnType("longtext"); + + b.Property("EncryptedUserKey") + .HasColumnType("longtext"); + + b.Property("Identifier") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("LastActivityDate") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("PushToken") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("Identifier") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "Identifier") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Device", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Event", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ActingUserId") + .HasColumnType("char(36)"); + + b.Property("CipherId") + .HasColumnType("char(36)"); + + b.Property("CollectionId") + .HasColumnType("char(36)"); + + b.Property("Date") + .HasColumnType("datetime(6)"); + + b.Property("DeviceType") + .HasColumnType("tinyint unsigned"); + + b.Property("DomainName") + .HasColumnType("longtext"); + + b.Property("GrantedServiceAccountId") + .HasColumnType("char(36)"); + + b.Property("GroupId") + .HasColumnType("char(36)"); + + b.Property("InstallationId") + .HasColumnType("char(36)"); + + b.Property("IpAddress") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("OrganizationUserId") + .HasColumnType("char(36)"); + + b.Property("PolicyId") + .HasColumnType("char(36)"); + + b.Property("ProjectId") + .HasColumnType("char(36)"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("ProviderOrganizationId") + .HasColumnType("char(36)"); + + b.Property("ProviderUserId") + .HasColumnType("char(36)"); + + b.Property("SecretId") + .HasColumnType("char(36)"); + + b.Property("ServiceAccountId") + .HasColumnType("char(36)"); + + b.Property("SystemUser") + .HasColumnType("tinyint unsigned"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("Date", "OrganizationId", "ActingUserId", "CipherId") + .HasDatabaseName("IX_Event_DateOrganizationIdUserId") + .HasAnnotation("SqlServer:Clustered", false) + .HasAnnotation("SqlServer:Include", new[] { "ServiceAccountId", "GrantedServiceAccountId" }); + + b.ToTable("Event", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Group", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.Property("GroupId") + .HasColumnType("char(36)"); + + b.Property("OrganizationUserId") + .HasColumnType("char(36)"); + + b.HasKey("GroupId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("GroupUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Config") + .HasColumnType("longtext"); + + b.Property("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationConnection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("DomainName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("JobRunCount") + .HasColumnType("int"); + + b.Property("LastCheckedDate") + .HasColumnType("datetime(6)"); + + b.Property("NextRunDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Txt") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("VerifiedDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationDomain", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("FriendlyName") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("IsAdminInitiated") + .HasColumnType("tinyint(1)"); + + b.Property("LastSyncDate") + .HasColumnType("datetime(6)"); + + b.Property("Notes") + .HasColumnType("longtext"); + + b.Property("OfferedToEmail") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("PlanSponsorshipType") + .HasColumnType("tinyint unsigned"); + + b.Property("SponsoredOrganizationId") + .HasColumnType("char(36)"); + + b.Property("SponsoringOrganizationId") + .HasColumnType("char(36)"); + + b.Property("SponsoringOrganizationUserId") + .HasColumnType("char(36)"); + + b.Property("ToDelete") + .HasColumnType("tinyint(1)"); + + b.Property("ValidUntil") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("SponsoredOrganizationId"); + + b.HasIndex("SponsoringOrganizationId"); + + b.HasIndex("SponsoringOrganizationUserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationSponsorship", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AccessSecretsManager") + .HasColumnType("tinyint(1)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Permissions") + .HasColumnType("longtext"); + + b.Property("ResetPasswordKey") + .HasColumnType("longtext"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("RevocationReason") + .HasColumnType("tinyint unsigned"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.PlayItem", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("PlayId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("PlayId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("PlayItem", null, t => + { + t.HasCheckConstraint("CK_PlayItem_UserOrOrganization", "(\"UserId\" IS NOT NULL AND \"OrganizationId\" IS NULL) OR (\"UserId\" IS NULL AND \"OrganizationId\" IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AccessCount") + .HasColumnType("int"); + + b.Property("AuthType") + .HasColumnType("tinyint unsigned"); + + b.Property("CipherId") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Data") + .HasColumnType("longtext"); + + b.Property("DeletionDate") + .HasColumnType("datetime(6)"); + + b.Property("Disabled") + .HasColumnType("tinyint(1)"); + + b.Property("Emails") + .HasMaxLength(4000) + .HasColumnType("varchar(4000)"); + + b.Property("ExpirationDate") + .HasColumnType("datetime(6)"); + + b.Property("HideEmail") + .HasColumnType("tinyint(1)"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("MaxAccessCount") + .HasColumnType("int"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Password") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("DeletionDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Send", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.TaxRate", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("Active") + .HasColumnType("tinyint(1)"); + + b.Property("Country") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("PostalCode") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("Rate") + .HasColumnType("decimal(65,30)"); + + b.Property("State") + .HasMaxLength(2) + .HasColumnType("varchar(2)"); + + b.HasKey("Id"); + + b.ToTable("TaxRate", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Amount") + .HasColumnType("decimal(65,30)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Details") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Gateway") + .HasColumnType("tinyint unsigned"); + + b.Property("GatewayId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("PaymentMethodType") + .HasColumnType("tinyint unsigned"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("Refunded") + .HasColumnType("tinyint(1)"); + + b.Property("RefundedAmount") + .HasColumnType("decimal(65,30)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ProviderId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId", "CreationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Transaction", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AccountRevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("AvatarColor") + .HasMaxLength(7) + .HasColumnType("varchar(7)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Culture") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("EmailVerified") + .HasColumnType("tinyint(1)"); + + b.Property("EquivalentDomains") + .HasColumnType("longtext"); + + b.Property("ExcludedGlobalEquivalentDomains") + .HasColumnType("longtext"); + + b.Property("FailedLoginCount") + .HasColumnType("int"); + + b.Property("ForcePasswordReset") + .HasColumnType("tinyint(1)"); + + b.Property("Gateway") + .HasColumnType("tinyint unsigned"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Kdf") + .HasColumnType("tinyint unsigned"); + + b.Property("KdfIterations") + .HasColumnType("int"); + + b.Property("KdfMemory") + .HasColumnType("int"); + + b.Property("KdfParallelism") + .HasColumnType("int"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("LastApiKeyRotationDate") + .HasColumnType("datetime(6)"); + + b.Property("LastEmailChangeDate") + .HasColumnType("datetime(6)"); + + b.Property("LastFailedLoginDate") + .HasColumnType("datetime(6)"); + + b.Property("LastKdfChangeDate") + .HasColumnType("datetime(6)"); + + b.Property("LastKeyRotationDate") + .HasColumnType("datetime(6)"); + + b.Property("LastPasswordChangeDate") + .HasColumnType("datetime(6)"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("MasterPassword") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("MasterPasswordHint") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("MasterPasswordSalt") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Premium") + .HasColumnType("tinyint(1)"); + + b.Property("PremiumExpirationDate") + .HasColumnType("datetime(6)"); + + b.Property("PrivateKey") + .HasColumnType("longtext"); + + b.Property("PublicKey") + .HasColumnType("longtext"); + + b.Property("ReferenceData") + .HasColumnType("longtext"); + + b.Property("RenewalReminderDate") + .HasColumnType("datetime(6)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("SecurityStamp") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("SecurityState") + .HasColumnType("longtext"); + + b.Property("SecurityVersion") + .HasColumnType("int"); + + b.Property("SignedPublicKey") + .HasColumnType("longtext"); + + b.Property("Storage") + .HasColumnType("bigint"); + + b.Property("TwoFactorProviders") + .HasColumnType("longtext"); + + b.Property("TwoFactorRecoveryCode") + .HasMaxLength(32) + .HasColumnType("varchar(32)"); + + b.Property("UsesKeyConnector") + .HasColumnType("tinyint(1)"); + + b.Property("V2UpgradeToken") + .HasColumnType("longtext"); + + b.Property("VerifyDevices") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("GatewayCustomerId"); + + b.HasIndex("GatewaySubscriptionId"); + + b.HasIndex("Premium", "PremiumExpirationDate", "RenewalReminderDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("User", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.UserSignatureKeyPair", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("SignatureAlgorithm") + .HasColumnType("tinyint unsigned"); + + b.Property("SigningKey") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.Property("VerifyingKey") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("UserSignatureKeyPair", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Body") + .HasMaxLength(3000) + .HasColumnType("varchar(3000)"); + + b.Property("ClientType") + .HasColumnType("tinyint unsigned"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Global") + .HasColumnType("tinyint(1)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Priority") + .HasColumnType("tinyint unsigned"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("TaskId") + .HasColumnType("char(36)"); + + b.Property("Title") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("TaskId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("ClientType", "Global", "UserId", "OrganizationId", "Priority", "CreationDate") + .IsDescending(false, false, false, false, true, true) + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Notification", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.Property("UserId") + .HasColumnType("char(36)"); + + b.Property("NotificationId") + .HasColumnType("char(36)"); + + b.Property("DeletedDate") + .HasColumnType("datetime(6)"); + + b.Property("ReadDate") + .HasColumnType("datetime(6)"); + + b.HasKey("UserId", "NotificationId") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("NotificationId"); + + b.ToTable("NotificationStatus", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Platform.Installation", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("varchar(150)"); + + b.Property("LastActivityDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.ToTable("Installation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Discriminator") + .IsRequired() + .HasMaxLength(34) + .HasColumnType("varchar(34)"); + + b.Property("Read") + .HasColumnType("tinyint(1)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Write") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.ToTable("AccessPolicy", (string)null); + + b.HasDiscriminator().HasValue("AccessPolicy"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ClientSecretHash") + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("EncryptedPayload") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("varchar(4000)"); + + b.Property("ExpireAt") + .HasColumnType("datetime(6)"); + + b.Property("Key") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("varchar(4000)"); + + b.Property("ServiceAccountId") + .HasColumnType("char(36)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ServiceAccountId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("DeletedDate") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Project", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("DeletedDate") + .HasColumnType("datetime(6)"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("Note") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Value") + .HasColumnType("longtext"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Secret", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.SecretVersion", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("EditorOrganizationUserId") + .HasColumnType("char(36)"); + + b.Property("EditorServiceAccountId") + .HasColumnType("char(36)"); + + b.Property("SecretId") + .HasColumnType("char(36)"); + + b.Property("Value") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("VersionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("EditorOrganizationUserId") + .HasDatabaseName("IX_SecretVersion_EditorOrganizationUserId"); + + b.HasIndex("EditorServiceAccountId") + .HasDatabaseName("IX_SecretVersion_EditorServiceAccountId"); + + b.HasIndex("SecretId") + .HasDatabaseName("IX_SecretVersion_SecretId"); + + b.ToTable("SecretVersion"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ServiceAccount", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Archives") + .HasColumnType("longtext"); + + b.Property("Attachments") + .HasColumnType("longtext"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Data") + .HasColumnType("longtext"); + + b.Property("DeletedDate") + .HasColumnType("datetime(6)"); + + b.Property("Favorites") + .HasColumnType("longtext"); + + b.Property("Folders") + .HasColumnType("longtext"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Reprompt") + .HasColumnType("tinyint unsigned"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId"); + + b.ToTable("Cipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .HasColumnType("longtext"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Folder", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CipherId") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("tinyint unsigned"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("CipherId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SecurityTask", (string)null); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.Property("ProjectsId") + .HasColumnType("char(36)"); + + b.Property("SecretsId") + .HasColumnType("char(36)"); + + b.HasKey("ProjectsId", "SecretsId"); + + b.HasIndex("SecretsId"); + + b.ToTable("ProjectSecret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedProjectId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedSecretId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedProjectId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedSecretId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedProjectId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedSecretId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Collection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Collections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.CollectionGroup", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Collection", "Collection") + .WithMany("CollectionGroups") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.CollectionUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Collection", "Collection") + .WithMany("CollectionUsers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("CollectionUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationInviteLink", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Policies") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.AuthRequest", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Device", "ResponseDevice") + .WithMany() + .HasForeignKey("ResponseDeviceId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("ResponseDevice"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantee") + .WithMany() + .HasForeignKey("GranteeId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantor") + .WithMany() + .HasForeignKey("GrantorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Grantee"); + + b.Navigation("Grantor"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoConfigs") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoUsers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("SsoUsers") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Platform.Installation", "Installation") + .WithMany() + .HasForeignKey("InstallationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Installation"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationPlanMigrationCohortAssignment", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationPlanMigrationCohort", "Cohort") + .WithMany() + .HasForeignKey("CohortId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cohort"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationApplication", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegrationConfiguration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegration", "OrganizationIntegration") + .WithMany() + .HasForeignKey("OrganizationIntegrationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("OrganizationIntegration"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.PasswordHealthReportApplication", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany("CollectionCiphers") + .HasForeignKey("CipherId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Collection", "Collection") + .WithMany("CollectionCiphers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Collection"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Groups") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany("GroupUsers") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("GroupUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("ApiKeys") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Connections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Domains") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoredOrganization") + .WithMany() + .HasForeignKey("SponsoredOrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoringOrganization") + .WithMany() + .HasForeignKey("SponsoringOrganizationId"); + + b.Navigation("SponsoredOrganization"); + + b.Navigation("SponsoringOrganization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("OrganizationUsers") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("OrganizationUsers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.PlayItem", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Transactions") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Transactions") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.UserSignatureKeyPair", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", "Task") + .WithMany() + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Task"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", "Notification") + .WithMany() + .HasForeignKey("NotificationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Notification"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ApiKeys") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.SecretVersion", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "EditorOrganizationUser") + .WithMany() + .HasForeignKey("EditorOrganizationUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "EditorServiceAccount") + .WithMany() + .HasForeignKey("EditorServiceAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "Secret") + .WithMany("SecretVersions") + .HasForeignKey("SecretId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("EditorOrganizationUser"); + + b.Navigation("EditorServiceAccount"); + + b.Navigation("Secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Ciphers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Ciphers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Folders") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany() + .HasForeignKey("CipherId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", null) + .WithMany() + .HasForeignKey("ProjectsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", null) + .WithMany() + .HasForeignKey("SecretsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedProject"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedSecret"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ProjectAccessPolicies") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedProject"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany() + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedProject"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Collection", b => + { + b.Navigation("CollectionCiphers"); + + b.Navigation("CollectionGroups"); + + b.Navigation("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("Ciphers"); + + b.Navigation("Collections"); + + b.Navigation("Connections"); + + b.Navigation("Domains"); + + b.Navigation("Groups"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("Policies"); + + b.Navigation("SsoConfigs"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Navigation("CollectionUsers"); + + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Navigation("Ciphers"); + + b.Navigation("Folders"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("SecretVersions"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ProjectAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Navigation("CollectionCiphers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/util/MySqlMigrations/Migrations/20260521121534_AddCollectionLeasing.cs b/util/MySqlMigrations/Migrations/20260521121534_AddCollectionLeasing.cs new file mode 100644 index 000000000000..11054b69d261 --- /dev/null +++ b/util/MySqlMigrations/Migrations/20260521121534_AddCollectionLeasing.cs @@ -0,0 +1,39 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bit.MySqlMigrations.Migrations; + +/// +public partial class AddCollectionLeasing : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "LeasingEnabled", + table: "Collection", + type: "tinyint(1)", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "LeasingPolicy", + table: "Collection", + type: "longtext", + nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "LeasingEnabled", + table: "Collection"); + + migrationBuilder.DropColumn( + name: "LeasingPolicy", + table: "Collection"); + } +} diff --git a/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs b/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs index 249f974316c9..cb758830fb53 100644 --- a/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs +++ b/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs @@ -84,6 +84,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(300) .HasColumnType("varchar(300)"); + b.Property("LeasingEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("LeasingPolicy") + .HasColumnType("longtext"); + b.Property("Name") .IsRequired() .HasColumnType("longtext"); diff --git a/util/PostgresMigrations/Migrations/20260521121303_AddCollectionLeasing.Designer.cs b/util/PostgresMigrations/Migrations/20260521121303_AddCollectionLeasing.Designer.cs new file mode 100644 index 000000000000..231116119a8e --- /dev/null +++ b/util/PostgresMigrations/Migrations/20260521121303_AddCollectionLeasing.Designer.cs @@ -0,0 +1,3773 @@ +// +using System; +using Bit.Infrastructure.EntityFramework.Repositories; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Bit.PostgresMigrations.Migrations +{ + [DbContext(typeof(DatabaseContext))] + [Migration("20260521121303_AddCollectionLeasing")] + partial class AddCollectionLeasing + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("Npgsql:CollationDefinition:postgresIndetermanisticCollation", "en-u-ks-primary,en-u-ks-primary,icu,False") + .HasAnnotation("ProductVersion", "8.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Bit.Core.Dirt.Reports.Models.Data.OrganizationMemberBaseDetail", b => + { + b.Property("CipherId") + .HasColumnType("uuid"); + + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("CollectionName") + .HasColumnType("text"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("GroupName") + .HasColumnType("text"); + + b.Property("HidePasswords") + .HasColumnType("boolean"); + + b.Property("Manage") + .HasColumnType("boolean"); + + b.Property("ReadOnly") + .HasColumnType("boolean"); + + b.Property("ResetPasswordKey") + .HasColumnType("text"); + + b.Property("TwoFactorProviders") + .HasColumnType("text"); + + b.Property("UserGuid") + .HasColumnType("uuid"); + + b.Property("UserName") + .HasColumnType("text"); + + b.Property("UsesKeyConnector") + .HasColumnType("boolean"); + + b.ToTable("OrganizationMemberBaseDetails"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Collection", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DefaultUserCollectionEmail") + .HasColumnType("text"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("LeasingEnabled") + .HasColumnType("boolean"); + + b.Property("LeasingPolicy") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Collection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.CollectionGroup", b => + { + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("HidePasswords") + .HasColumnType("boolean"); + + b.Property("Manage") + .HasColumnType("boolean"); + + b.Property("ReadOnly") + .HasColumnType("boolean"); + + b.HasKey("CollectionId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("CollectionGroups"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.CollectionUser", b => + { + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("OrganizationUserId") + .HasColumnType("uuid"); + + b.Property("HidePasswords") + .HasColumnType("boolean"); + + b.Property("Manage") + .HasColumnType("boolean"); + + b.Property("ReadOnly") + .HasColumnType("boolean"); + + b.HasKey("CollectionId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AllowAdminAccessToAllCollectionItems") + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("BillingEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("BusinessAddress1") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("BusinessAddress2") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("BusinessAddress3") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("BusinessCountry") + .HasMaxLength(2) + .HasColumnType("character varying(2)"); + + b.Property("BusinessName") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("BusinessTaxNumber") + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("ExemptFromBillingAutomation") + .HasColumnType("boolean"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Gateway") + .HasColumnType("smallint"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Identifier") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .UseCollation("postgresIndetermanisticCollation"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("LimitCollectionCreation") + .HasColumnType("boolean"); + + b.Property("LimitCollectionDeletion") + .HasColumnType("boolean"); + + b.Property("LimitItemDeletion") + .HasColumnType("boolean"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("integer"); + + b.Property("MaxAutoscaleSmSeats") + .HasColumnType("integer"); + + b.Property("MaxAutoscaleSmServiceAccounts") + .HasColumnType("integer"); + + b.Property("MaxCollections") + .HasColumnType("smallint"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OwnersNotifiedOfAutoscaling") + .HasColumnType("timestamp with time zone"); + + b.Property("Plan") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PlanType") + .HasColumnType("smallint"); + + b.Property("PrivateKey") + .HasColumnType("text"); + + b.Property("PublicKey") + .HasColumnType("text"); + + b.Property("ReferenceData") + .HasColumnType("text"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Seats") + .HasColumnType("integer"); + + b.Property("SelfHost") + .HasColumnType("boolean"); + + b.Property("SmSeats") + .HasColumnType("integer"); + + b.Property("SmServiceAccounts") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Storage") + .HasColumnType("bigint"); + + b.Property("SyncSeats") + .HasColumnType("boolean"); + + b.Property("TwoFactorProviders") + .HasColumnType("text"); + + b.Property("Use2fa") + .HasColumnType("boolean"); + + b.Property("UseAdminSponsoredFamilies") + .HasColumnType("boolean"); + + b.Property("UseApi") + .HasColumnType("boolean"); + + b.Property("UseAutomaticUserConfirmation") + .HasColumnType("boolean"); + + b.Property("UseCustomPermissions") + .HasColumnType("boolean"); + + b.Property("UseDirectory") + .HasColumnType("boolean"); + + b.Property("UseDisableSmAdsForUsers") + .HasColumnType("boolean"); + + b.Property("UseEvents") + .HasColumnType("boolean"); + + b.Property("UseGroups") + .HasColumnType("boolean"); + + b.Property("UseInviteLinks") + .HasColumnType("boolean"); + + b.Property("UseKeyConnector") + .HasColumnType("boolean"); + + b.Property("UseMyItems") + .HasColumnType("boolean"); + + b.Property("UseOrganizationDomains") + .HasColumnType("boolean"); + + b.Property("UsePasswordManager") + .HasColumnType("boolean"); + + b.Property("UsePhishingBlocker") + .HasColumnType("boolean"); + + b.Property("UsePolicies") + .HasColumnType("boolean"); + + b.Property("UseResetPassword") + .HasColumnType("boolean"); + + b.Property("UseRiskInsights") + .HasColumnType("boolean"); + + b.Property("UseScim") + .HasColumnType("boolean"); + + b.Property("UseSecretsManager") + .HasColumnType("boolean"); + + b.Property("UseSso") + .HasColumnType("boolean"); + + b.Property("UseTotp") + .HasColumnType("boolean"); + + b.Property("UsersGetPremium") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("GatewayCustomerId"); + + b.HasIndex("GatewaySubscriptionId"); + + b.HasIndex("Id", "Enabled"); + + NpgsqlIndexBuilderExtensions.IncludeProperties(b.HasIndex("Id", "Enabled"), new[] { "UseTotp", "UsersGetPremium" }); + + b.ToTable("Organization", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationInviteLink", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AllowedDomains") + .IsRequired() + .HasColumnType("text"); + + b.Property("Code") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedInviteKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("EncryptedOrgKey") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationInviteLink", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "Type") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Policy", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BillingEmail") + .HasColumnType("text"); + + b.Property("BillingPhone") + .HasColumnType("text"); + + b.Property("BusinessAddress1") + .HasColumnType("text"); + + b.Property("BusinessAddress2") + .HasColumnType("text"); + + b.Property("BusinessAddress3") + .HasColumnType("text"); + + b.Property("BusinessCountry") + .HasColumnType("text"); + + b.Property("BusinessName") + .HasColumnType("text"); + + b.Property("BusinessTaxNumber") + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DiscountId") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("Gateway") + .HasColumnType("smallint"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UseEvents") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("GatewayCustomerId"); + + b.HasIndex("GatewaySubscriptionId"); + + b.ToTable("Provider", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Settings") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ProviderId"); + + b.ToTable("ProviderOrganization", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderUser", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("Permissions") + .HasColumnType("text"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.HasIndex("UserId"); + + b.ToTable("ProviderUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.AuthRequest", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccessCode") + .HasMaxLength(25) + .HasColumnType("character varying(25)"); + + b.Property("Approved") + .HasColumnType("boolean"); + + b.Property("AuthenticationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("MasterPasswordHash") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("PublicKey") + .HasColumnType("text"); + + b.Property("RequestCountryName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RequestDeviceIdentifier") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("RequestDeviceType") + .HasColumnType("smallint"); + + b.Property("RequestIpAddress") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ResponseDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ResponseDeviceId") + .HasColumnType("uuid"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ResponseDeviceId"); + + b.HasIndex("UserId"); + + b.ToTable("AuthRequest", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("GranteeId") + .HasColumnType("uuid"); + + b.Property("GrantorId") + .HasColumnType("uuid"); + + b.Property("KeyEncrypted") + .HasColumnType("text"); + + b.Property("LastNotificationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("RecoveryInitiatedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("WaitTimeDays") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("GranteeId"); + + b.HasIndex("GrantorId"); + + b.ToTable("EmergencyAccess", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.Grant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClientId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ConsumedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .IsRequired() + .HasColumnType("text"); + + b.Property("Description") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SessionId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SubjectId") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id") + .HasName("PK_Grant") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpirationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("Grant", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("SsoConfig", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .UseCollation("postgresIndetermanisticCollation"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId"); + + b.HasIndex("OrganizationId", "ExternalId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + NpgsqlIndexBuilderExtensions.IncludeProperties(b.HasIndex("OrganizationId", "ExternalId"), new[] { "UserId" }); + + b.HasIndex("OrganizationId", "UserId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SsoUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AaGuid") + .HasColumnType("uuid"); + + b.Property("Counter") + .HasColumnType("integer"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CredentialId") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EncryptedPrivateKey") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("EncryptedPublicKey") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("EncryptedUserKey") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PublicKey") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("SupportsPrf") + .HasColumnType("boolean"); + + b.Property("Type") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("WebAuthnCredential", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ClientOrganizationMigrationRecord", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("GatewayCustomerId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("GatewaySubscriptionId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("integer"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("PlanType") + .HasColumnType("smallint"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("Seats") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId", "OrganizationId") + .IsUnique(); + + b.ToTable("ClientOrganizationMigrationRecord", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("InstallationId") + .HasColumnType("uuid"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("InstallationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationInstallation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationPlanMigrationCohort", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChurnDiscountCouponCode") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("MigrationPathId") + .HasColumnType("smallint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ProactiveDiscountCouponCode") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("Name") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationPlanMigrationCohort", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationPlanMigrationCohortAssignment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChurnDiscountAppliedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CohortId") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("MigratedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ScheduledDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("CohortId", "ScheduledDate", "MigratedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationPlanMigrationCohortAssignment", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AssignedSeats") + .HasColumnType("integer"); + + b.Property("ClientId") + .HasColumnType("uuid"); + + b.Property("ClientName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("InvoiceId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("InvoiceNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PlanName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("Total") + .HasColumnType("numeric"); + + b.Property("UsedSeats") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.ToTable("ProviderInvoiceItem", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AllocatedSeats") + .HasColumnType("integer"); + + b.Property("PlanType") + .HasColumnType("smallint"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("PurchasedSeats") + .HasColumnType("integer"); + + b.Property("SeatMinimum") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.HasIndex("Id", "PlanType") + .IsUnique(); + + b.ToTable("ProviderPlan", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.SubscriptionDiscount", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AmountOff") + .HasColumnType("bigint"); + + b.Property("AudienceType") + .HasColumnType("integer"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Currency") + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("Duration") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("DurationInMonths") + .HasColumnType("integer"); + + b.Property("EndDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PercentOff") + .HasPrecision(5, 2) + .HasColumnType("numeric(5,2)"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("StartDate") + .HasColumnType("timestamp with time zone"); + + b.Property("StripeCouponId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("StripeProductIds") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("StripeCouponId") + .IsUnique(); + + b.HasIndex("StartDate", "EndDate") + .HasDatabaseName("IX_SubscriptionDiscount_DateRange") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SubscriptionDiscount", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationApplication", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Applications") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentEncryptionKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationApplication", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegration", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Configuration") + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "Type") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationIntegration", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegrationConfiguration", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Configuration") + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("EventType") + .HasColumnType("integer"); + + b.Property("Filters") + .HasColumnType("text"); + + b.Property("OrganizationIntegrationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Template") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationIntegrationId"); + + b.ToTable("OrganizationIntegrationConfiguration", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationAtRiskCount") + .HasColumnType("integer"); + + b.Property("ApplicationCount") + .HasColumnType("integer"); + + b.Property("ApplicationData") + .HasColumnType("text"); + + b.Property("ContentEncryptionKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CriticalApplicationAtRiskCount") + .HasColumnType("integer"); + + b.Property("CriticalApplicationCount") + .HasColumnType("integer"); + + b.Property("CriticalMemberAtRiskCount") + .HasColumnType("integer"); + + b.Property("CriticalMemberCount") + .HasColumnType("integer"); + + b.Property("CriticalPasswordAtRiskCount") + .HasColumnType("integer"); + + b.Property("CriticalPasswordCount") + .HasColumnType("integer"); + + b.Property("MemberAtRiskCount") + .HasColumnType("integer"); + + b.Property("MemberCount") + .HasColumnType("integer"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("PasswordAtRiskCount") + .HasColumnType("integer"); + + b.Property("PasswordCount") + .HasColumnType("integer"); + + b.Property("ReportData") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReportFile") + .HasColumnType("text"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("SummaryData") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationReport", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.PasswordHealthReportApplication", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Uri") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("PasswordHealthReportApplication", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Cache", b => + { + b.Property("Id") + .HasMaxLength(449) + .HasColumnType("character varying(449)"); + + b.Property("AbsoluteExpiration") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SlidingExpirationInSeconds") + .HasColumnType("bigint"); + + b.Property("Value") + .IsRequired() + .HasColumnType("bytea"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpiresAtTime") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Cache", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("CipherId") + .HasColumnType("uuid"); + + b.HasKey("CollectionId", "CipherId"); + + b.HasIndex("CipherId"); + + b.ToTable("CollectionCipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Active") + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("ClientVersion") + .HasMaxLength(43) + .HasColumnType("character varying(43)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedPrivateKey") + .HasColumnType("text"); + + b.Property("EncryptedPublicKey") + .HasColumnType("text"); + + b.Property("EncryptedUserKey") + .HasColumnType("text"); + + b.Property("Identifier") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("LastActivityDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PushToken") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Identifier") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "Identifier") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Device", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Event", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ActingUserId") + .HasColumnType("uuid"); + + b.Property("CipherId") + .HasColumnType("uuid"); + + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("Date") + .HasColumnType("timestamp with time zone"); + + b.Property("DeviceType") + .HasColumnType("smallint"); + + b.Property("DomainName") + .HasColumnType("text"); + + b.Property("GrantedServiceAccountId") + .HasColumnType("uuid"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("InstallationId") + .HasColumnType("uuid"); + + b.Property("IpAddress") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("OrganizationUserId") + .HasColumnType("uuid"); + + b.Property("PolicyId") + .HasColumnType("uuid"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("ProviderOrganizationId") + .HasColumnType("uuid"); + + b.Property("ProviderUserId") + .HasColumnType("uuid"); + + b.Property("SecretId") + .HasColumnType("uuid"); + + b.Property("ServiceAccountId") + .HasColumnType("uuid"); + + b.Property("SystemUser") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("Date", "OrganizationId", "ActingUserId", "CipherId") + .HasDatabaseName("IX_Event_DateOrganizationIdUserId") + .HasAnnotation("SqlServer:Clustered", false) + .HasAnnotation("SqlServer:Include", new[] { "ServiceAccountId", "GrantedServiceAccountId" }); + + b.ToTable("Event", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Group", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("OrganizationUserId") + .HasColumnType("uuid"); + + b.HasKey("GroupId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("GroupUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Config") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationConnection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DomainName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("JobRunCount") + .HasColumnType("integer"); + + b.Property("LastCheckedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("NextRunDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Txt") + .IsRequired() + .HasColumnType("text"); + + b.Property("VerifiedDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationDomain", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("FriendlyName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("IsAdminInitiated") + .HasColumnType("boolean"); + + b.Property("LastSyncDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("OfferedToEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PlanSponsorshipType") + .HasColumnType("smallint"); + + b.Property("SponsoredOrganizationId") + .HasColumnType("uuid"); + + b.Property("SponsoringOrganizationId") + .HasColumnType("uuid"); + + b.Property("SponsoringOrganizationUserId") + .HasColumnType("uuid"); + + b.Property("ToDelete") + .HasColumnType("boolean"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("SponsoredOrganizationId"); + + b.HasIndex("SponsoringOrganizationId"); + + b.HasIndex("SponsoringOrganizationUserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationSponsorship", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccessSecretsManager") + .HasColumnType("boolean"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Permissions") + .HasColumnType("text"); + + b.Property("ResetPasswordKey") + .HasColumnType("text"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("RevocationReason") + .HasColumnType("smallint"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.PlayItem", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("PlayId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("PlayId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("PlayItem", null, t => + { + t.HasCheckConstraint("CK_PlayItem_UserOrOrganization", "(\"UserId\" IS NOT NULL AND \"OrganizationId\" IS NULL) OR (\"UserId\" IS NULL AND \"OrganizationId\" IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccessCount") + .HasColumnType("integer"); + + b.Property("AuthType") + .HasColumnType("smallint"); + + b.Property("CipherId") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .HasColumnType("text"); + + b.Property("DeletionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Disabled") + .HasColumnType("boolean"); + + b.Property("Emails") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("HideEmail") + .HasColumnType("boolean"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("MaxAccessCount") + .HasColumnType("integer"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Password") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DeletionDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Send", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.TaxRate", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Active") + .HasColumnType("boolean"); + + b.Property("Country") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PostalCode") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("Rate") + .HasColumnType("numeric"); + + b.Property("State") + .HasMaxLength(2) + .HasColumnType("character varying(2)"); + + b.HasKey("Id"); + + b.ToTable("TaxRate", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Amount") + .HasColumnType("numeric"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Details") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Gateway") + .HasColumnType("smallint"); + + b.Property("GatewayId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("PaymentMethodType") + .HasColumnType("smallint"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("Refunded") + .HasColumnType("boolean"); + + b.Property("RefundedAmount") + .HasColumnType("numeric"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ProviderId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId", "CreationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Transaction", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccountRevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("AvatarColor") + .HasMaxLength(7) + .HasColumnType("character varying(7)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Culture") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .UseCollation("postgresIndetermanisticCollation"); + + b.Property("EmailVerified") + .HasColumnType("boolean"); + + b.Property("EquivalentDomains") + .HasColumnType("text"); + + b.Property("ExcludedGlobalEquivalentDomains") + .HasColumnType("text"); + + b.Property("FailedLoginCount") + .HasColumnType("integer"); + + b.Property("ForcePasswordReset") + .HasColumnType("boolean"); + + b.Property("Gateway") + .HasColumnType("smallint"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Kdf") + .HasColumnType("smallint"); + + b.Property("KdfIterations") + .HasColumnType("integer"); + + b.Property("KdfMemory") + .HasColumnType("integer"); + + b.Property("KdfParallelism") + .HasColumnType("integer"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("LastApiKeyRotationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastEmailChangeDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastFailedLoginDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastKdfChangeDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastKeyRotationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastPasswordChangeDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("MasterPassword") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("MasterPasswordHint") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("MasterPasswordSalt") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Premium") + .HasColumnType("boolean"); + + b.Property("PremiumExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("PrivateKey") + .HasColumnType("text"); + + b.Property("PublicKey") + .HasColumnType("text"); + + b.Property("ReferenceData") + .HasColumnType("text"); + + b.Property("RenewalReminderDate") + .HasColumnType("timestamp with time zone"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("SecurityStamp") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("SecurityState") + .HasColumnType("text"); + + b.Property("SecurityVersion") + .HasColumnType("integer"); + + b.Property("SignedPublicKey") + .HasColumnType("text"); + + b.Property("Storage") + .HasColumnType("bigint"); + + b.Property("TwoFactorProviders") + .HasColumnType("text"); + + b.Property("TwoFactorRecoveryCode") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UsesKeyConnector") + .HasColumnType("boolean"); + + b.Property("V2UpgradeToken") + .HasColumnType("text"); + + b.Property("VerifyDevices") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("GatewayCustomerId"); + + b.HasIndex("GatewaySubscriptionId"); + + b.HasIndex("Premium", "PremiumExpirationDate", "RenewalReminderDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("User", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.UserSignatureKeyPair", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("SignatureAlgorithm") + .HasColumnType("smallint"); + + b.Property("SigningKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("VerifyingKey") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("UserSignatureKeyPair", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Body") + .HasMaxLength(3000) + .HasColumnType("character varying(3000)"); + + b.Property("ClientType") + .HasColumnType("smallint"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Global") + .HasColumnType("boolean"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Priority") + .HasColumnType("smallint"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("TaskId") + .HasColumnType("uuid"); + + b.Property("Title") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("TaskId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("ClientType", "Global", "UserId", "OrganizationId", "Priority", "CreationDate") + .IsDescending(false, false, false, false, true, true) + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Notification", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("NotificationId") + .HasColumnType("uuid"); + + b.Property("DeletedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ReadDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("UserId", "NotificationId") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("NotificationId"); + + b.ToTable("NotificationStatus", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Platform.Installation", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("LastActivityDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Installation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Discriminator") + .IsRequired() + .HasMaxLength(34) + .HasColumnType("character varying(34)"); + + b.Property("Read") + .HasColumnType("boolean"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Write") + .HasColumnType("boolean"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.ToTable("AccessPolicy", (string)null); + + b.HasDiscriminator().HasValue("AccessPolicy"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ClientSecretHash") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedPayload") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ExpireAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ServiceAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ServiceAccountId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Project", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Secret", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.SecretVersion", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("EditorOrganizationUserId") + .HasColumnType("uuid"); + + b.Property("EditorServiceAccountId") + .HasColumnType("uuid"); + + b.Property("SecretId") + .HasColumnType("uuid"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b.Property("VersionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("EditorOrganizationUserId") + .HasDatabaseName("IX_SecretVersion_EditorOrganizationUserId"); + + b.HasIndex("EditorServiceAccountId") + .HasDatabaseName("IX_SecretVersion_EditorServiceAccountId"); + + b.HasIndex("SecretId") + .HasDatabaseName("IX_SecretVersion_SecretId"); + + b.ToTable("SecretVersion"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ServiceAccount", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Archives") + .HasColumnType("text"); + + b.Property("Attachments") + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .HasColumnType("text"); + + b.Property("DeletedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Favorites") + .HasColumnType("text"); + + b.Property("Folders") + .HasColumnType("text"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Reprompt") + .HasColumnType("smallint"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId"); + + b.ToTable("Cipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Folder", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CipherId") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("CipherId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SecurityTask", (string)null); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.Property("ProjectsId") + .HasColumnType("uuid"); + + b.Property("SecretsId") + .HasColumnType("uuid"); + + b.HasKey("ProjectsId", "SecretsId"); + + b.HasIndex("SecretsId"); + + b.ToTable("ProjectSecret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedProjectId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedSecretId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedProjectId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedSecretId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedProjectId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedSecretId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Collection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Collections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.CollectionGroup", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Collection", "Collection") + .WithMany("CollectionGroups") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.CollectionUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Collection", "Collection") + .WithMany("CollectionUsers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("CollectionUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationInviteLink", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Policies") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.AuthRequest", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Device", "ResponseDevice") + .WithMany() + .HasForeignKey("ResponseDeviceId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("ResponseDevice"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantee") + .WithMany() + .HasForeignKey("GranteeId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantor") + .WithMany() + .HasForeignKey("GrantorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Grantee"); + + b.Navigation("Grantor"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoConfigs") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoUsers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("SsoUsers") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Platform.Installation", "Installation") + .WithMany() + .HasForeignKey("InstallationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Installation"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationPlanMigrationCohortAssignment", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationPlanMigrationCohort", "Cohort") + .WithMany() + .HasForeignKey("CohortId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cohort"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationApplication", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegrationConfiguration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegration", "OrganizationIntegration") + .WithMany() + .HasForeignKey("OrganizationIntegrationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("OrganizationIntegration"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.PasswordHealthReportApplication", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany("CollectionCiphers") + .HasForeignKey("CipherId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Collection", "Collection") + .WithMany("CollectionCiphers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Collection"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Groups") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany("GroupUsers") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("GroupUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("ApiKeys") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Connections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Domains") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoredOrganization") + .WithMany() + .HasForeignKey("SponsoredOrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoringOrganization") + .WithMany() + .HasForeignKey("SponsoringOrganizationId"); + + b.Navigation("SponsoredOrganization"); + + b.Navigation("SponsoringOrganization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("OrganizationUsers") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("OrganizationUsers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.PlayItem", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Transactions") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Transactions") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.UserSignatureKeyPair", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", "Task") + .WithMany() + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Task"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", "Notification") + .WithMany() + .HasForeignKey("NotificationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Notification"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ApiKeys") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.SecretVersion", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "EditorOrganizationUser") + .WithMany() + .HasForeignKey("EditorOrganizationUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "EditorServiceAccount") + .WithMany() + .HasForeignKey("EditorServiceAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "Secret") + .WithMany("SecretVersions") + .HasForeignKey("SecretId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("EditorOrganizationUser"); + + b.Navigation("EditorServiceAccount"); + + b.Navigation("Secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Ciphers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Ciphers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Folders") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany() + .HasForeignKey("CipherId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", null) + .WithMany() + .HasForeignKey("ProjectsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", null) + .WithMany() + .HasForeignKey("SecretsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedProject"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedSecret"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ProjectAccessPolicies") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedProject"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany() + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedProject"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Collection", b => + { + b.Navigation("CollectionCiphers"); + + b.Navigation("CollectionGroups"); + + b.Navigation("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("Ciphers"); + + b.Navigation("Collections"); + + b.Navigation("Connections"); + + b.Navigation("Domains"); + + b.Navigation("Groups"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("Policies"); + + b.Navigation("SsoConfigs"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Navigation("CollectionUsers"); + + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Navigation("Ciphers"); + + b.Navigation("Folders"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("SecretVersions"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ProjectAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Navigation("CollectionCiphers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/util/PostgresMigrations/Migrations/20260521121303_AddCollectionLeasing.cs b/util/PostgresMigrations/Migrations/20260521121303_AddCollectionLeasing.cs new file mode 100644 index 000000000000..843c8bd91fcb --- /dev/null +++ b/util/PostgresMigrations/Migrations/20260521121303_AddCollectionLeasing.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bit.PostgresMigrations.Migrations; + +/// +public partial class AddCollectionLeasing : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "LeasingEnabled", + table: "Collection", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "LeasingPolicy", + table: "Collection", + type: "text", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "LeasingEnabled", + table: "Collection"); + + migrationBuilder.DropColumn( + name: "LeasingPolicy", + table: "Collection"); + } +} diff --git a/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs b/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs index 9e0babe788ab..a09573c68cda 100644 --- a/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs +++ b/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs @@ -85,6 +85,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(300) .HasColumnType("character varying(300)"); + b.Property("LeasingEnabled") + .HasColumnType("boolean"); + + b.Property("LeasingPolicy") + .HasColumnType("text"); + b.Property("Name") .IsRequired() .HasColumnType("text"); diff --git a/util/SqliteMigrations/Migrations/20260521121259_AddCollectionLeasing.Designer.cs b/util/SqliteMigrations/Migrations/20260521121259_AddCollectionLeasing.Designer.cs new file mode 100644 index 000000000000..22835758b09b --- /dev/null +++ b/util/SqliteMigrations/Migrations/20260521121259_AddCollectionLeasing.Designer.cs @@ -0,0 +1,3756 @@ +// +using System; +using Bit.Infrastructure.EntityFramework.Repositories; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Bit.SqliteMigrations.Migrations +{ + [DbContext(typeof(DatabaseContext))] + [Migration("20260521121259_AddCollectionLeasing")] + partial class AddCollectionLeasing + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.8"); + + modelBuilder.Entity("Bit.Core.Dirt.Reports.Models.Data.OrganizationMemberBaseDetail", b => + { + b.Property("CipherId") + .HasColumnType("TEXT"); + + b.Property("CollectionId") + .HasColumnType("TEXT"); + + b.Property("CollectionName") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("GroupName") + .HasColumnType("TEXT"); + + b.Property("HidePasswords") + .HasColumnType("INTEGER"); + + b.Property("Manage") + .HasColumnType("INTEGER"); + + b.Property("ReadOnly") + .HasColumnType("INTEGER"); + + b.Property("ResetPasswordKey") + .HasColumnType("TEXT"); + + b.Property("TwoFactorProviders") + .HasColumnType("TEXT"); + + b.Property("UserGuid") + .HasColumnType("TEXT"); + + b.Property("UserName") + .HasColumnType("TEXT"); + + b.Property("UsesKeyConnector") + .HasColumnType("INTEGER"); + + b.ToTable("OrganizationMemberBaseDetails"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Collection", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("DefaultUserCollectionEmail") + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("LeasingEnabled") + .HasColumnType("INTEGER"); + + b.Property("LeasingPolicy") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Collection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.CollectionGroup", b => + { + b.Property("CollectionId") + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("HidePasswords") + .HasColumnType("INTEGER"); + + b.Property("Manage") + .HasColumnType("INTEGER"); + + b.Property("ReadOnly") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("CollectionGroups"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.CollectionUser", b => + { + b.Property("CollectionId") + .HasColumnType("TEXT"); + + b.Property("OrganizationUserId") + .HasColumnType("TEXT"); + + b.Property("HidePasswords") + .HasColumnType("INTEGER"); + + b.Property("Manage") + .HasColumnType("INTEGER"); + + b.Property("ReadOnly") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AllowAdminAccessToAllCollectionItems") + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("BillingEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("BusinessAddress1") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("BusinessAddress2") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("BusinessAddress3") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("BusinessCountry") + .HasMaxLength(2) + .HasColumnType("TEXT"); + + b.Property("BusinessName") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("BusinessTaxNumber") + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("ExemptFromBillingAutomation") + .HasColumnType("INTEGER"); + + b.Property("ExpirationDate") + .HasColumnType("TEXT"); + + b.Property("Gateway") + .HasColumnType("INTEGER"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Identifier") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LimitCollectionCreation") + .HasColumnType("INTEGER"); + + b.Property("LimitCollectionDeletion") + .HasColumnType("INTEGER"); + + b.Property("LimitItemDeletion") + .HasColumnType("INTEGER"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("INTEGER"); + + b.Property("MaxAutoscaleSmSeats") + .HasColumnType("INTEGER"); + + b.Property("MaxAutoscaleSmServiceAccounts") + .HasColumnType("INTEGER"); + + b.Property("MaxCollections") + .HasColumnType("INTEGER"); + + b.Property("MaxStorageGb") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("OwnersNotifiedOfAutoscaling") + .HasColumnType("TEXT"); + + b.Property("Plan") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PlanType") + .HasColumnType("INTEGER"); + + b.Property("PrivateKey") + .HasColumnType("TEXT"); + + b.Property("PublicKey") + .HasColumnType("TEXT"); + + b.Property("ReferenceData") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Seats") + .HasColumnType("INTEGER"); + + b.Property("SelfHost") + .HasColumnType("INTEGER"); + + b.Property("SmSeats") + .HasColumnType("INTEGER"); + + b.Property("SmServiceAccounts") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Storage") + .HasColumnType("INTEGER"); + + b.Property("SyncSeats") + .HasColumnType("INTEGER"); + + b.Property("TwoFactorProviders") + .HasColumnType("TEXT"); + + b.Property("Use2fa") + .HasColumnType("INTEGER"); + + b.Property("UseAdminSponsoredFamilies") + .HasColumnType("INTEGER"); + + b.Property("UseApi") + .HasColumnType("INTEGER"); + + b.Property("UseAutomaticUserConfirmation") + .HasColumnType("INTEGER"); + + b.Property("UseCustomPermissions") + .HasColumnType("INTEGER"); + + b.Property("UseDirectory") + .HasColumnType("INTEGER"); + + b.Property("UseDisableSmAdsForUsers") + .HasColumnType("INTEGER"); + + b.Property("UseEvents") + .HasColumnType("INTEGER"); + + b.Property("UseGroups") + .HasColumnType("INTEGER"); + + b.Property("UseInviteLinks") + .HasColumnType("INTEGER"); + + b.Property("UseKeyConnector") + .HasColumnType("INTEGER"); + + b.Property("UseMyItems") + .HasColumnType("INTEGER"); + + b.Property("UseOrganizationDomains") + .HasColumnType("INTEGER"); + + b.Property("UsePasswordManager") + .HasColumnType("INTEGER"); + + b.Property("UsePhishingBlocker") + .HasColumnType("INTEGER"); + + b.Property("UsePolicies") + .HasColumnType("INTEGER"); + + b.Property("UseResetPassword") + .HasColumnType("INTEGER"); + + b.Property("UseRiskInsights") + .HasColumnType("INTEGER"); + + b.Property("UseScim") + .HasColumnType("INTEGER"); + + b.Property("UseSecretsManager") + .HasColumnType("INTEGER"); + + b.Property("UseSso") + .HasColumnType("INTEGER"); + + b.Property("UseTotp") + .HasColumnType("INTEGER"); + + b.Property("UsersGetPremium") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GatewayCustomerId"); + + b.HasIndex("GatewaySubscriptionId"); + + b.HasIndex("Id", "Enabled") + .HasAnnotation("Npgsql:IndexInclude", new[] { "UseTotp", "UsersGetPremium" }); + + b.ToTable("Organization", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationInviteLink", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AllowedDomains") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Code") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("EncryptedInviteKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("EncryptedOrgKey") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationInviteLink", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "Type") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Policy", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("BillingEmail") + .HasColumnType("TEXT"); + + b.Property("BillingPhone") + .HasColumnType("TEXT"); + + b.Property("BusinessAddress1") + .HasColumnType("TEXT"); + + b.Property("BusinessAddress2") + .HasColumnType("TEXT"); + + b.Property("BusinessAddress3") + .HasColumnType("TEXT"); + + b.Property("BusinessCountry") + .HasColumnType("TEXT"); + + b.Property("BusinessName") + .HasColumnType("TEXT"); + + b.Property("BusinessTaxNumber") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("DiscountId") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Gateway") + .HasColumnType("INTEGER"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UseEvents") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GatewayCustomerId"); + + b.HasIndex("GatewaySubscriptionId"); + + b.ToTable("Provider", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Settings") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ProviderId"); + + b.ToTable("ProviderOrganization", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("Permissions") + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.HasIndex("UserId"); + + b.ToTable("ProviderUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.AuthRequest", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessCode") + .HasMaxLength(25) + .HasColumnType("TEXT"); + + b.Property("Approved") + .HasColumnType("INTEGER"); + + b.Property("AuthenticationDate") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("MasterPasswordHash") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("PublicKey") + .HasColumnType("TEXT"); + + b.Property("RequestCountryName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("RequestDeviceIdentifier") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("RequestDeviceType") + .HasColumnType("INTEGER"); + + b.Property("RequestIpAddress") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("ResponseDate") + .HasColumnType("TEXT"); + + b.Property("ResponseDeviceId") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ResponseDeviceId"); + + b.HasIndex("UserId"); + + b.ToTable("AuthRequest", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("GranteeId") + .HasColumnType("TEXT"); + + b.Property("GrantorId") + .HasColumnType("TEXT"); + + b.Property("KeyEncrypted") + .HasColumnType("TEXT"); + + b.Property("LastNotificationDate") + .HasColumnType("TEXT"); + + b.Property("RecoveryInitiatedDate") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("WaitTimeDays") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GranteeId"); + + b.HasIndex("GrantorId"); + + b.ToTable("EmergencyAccess", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.Grant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClientId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ConsumedDate") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Data") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ExpirationDate") + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SessionId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SubjectId") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasName("PK_Grant") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpirationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("Grant", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("SsoConfig", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId"); + + b.HasIndex("OrganizationId", "ExternalId") + .IsUnique() + .HasAnnotation("Npgsql:IndexInclude", new[] { "UserId" }) + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "UserId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SsoUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AaGuid") + .HasColumnType("TEXT"); + + b.Property("Counter") + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("CredentialId") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EncryptedPrivateKey") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("EncryptedPublicKey") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("EncryptedUserKey") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PublicKey") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("SupportsPrf") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("WebAuthnCredential", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ClientOrganizationMigrationRecord", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ExpirationDate") + .HasColumnType("TEXT"); + + b.Property("GatewayCustomerId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("GatewaySubscriptionId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("INTEGER"); + + b.Property("MaxStorageGb") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("PlanType") + .HasColumnType("INTEGER"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("Seats") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId", "OrganizationId") + .IsUnique(); + + b.ToTable("ClientOrganizationMigrationRecord", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("InstallationId") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("InstallationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationInstallation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationPlanMigrationCohort", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ChurnDiscountCouponCode") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("MigrationPathId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("ProactiveDiscountCouponCode") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("Name") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationPlanMigrationCohort", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationPlanMigrationCohortAssignment", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ChurnDiscountAppliedDate") + .HasColumnType("TEXT"); + + b.Property("CohortId") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("MigratedDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("ScheduledDate") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("CohortId", "ScheduledDate", "MigratedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationPlanMigrationCohortAssignment", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AssignedSeats") + .HasColumnType("INTEGER"); + + b.Property("ClientId") + .HasColumnType("TEXT"); + + b.Property("ClientName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("InvoiceId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("InvoiceNumber") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PlanName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("Total") + .HasColumnType("TEXT"); + + b.Property("UsedSeats") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.ToTable("ProviderInvoiceItem", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AllocatedSeats") + .HasColumnType("INTEGER"); + + b.Property("PlanType") + .HasColumnType("INTEGER"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("PurchasedSeats") + .HasColumnType("INTEGER"); + + b.Property("SeatMinimum") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.HasIndex("Id", "PlanType") + .IsUnique(); + + b.ToTable("ProviderPlan", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.SubscriptionDiscount", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AmountOff") + .HasColumnType("INTEGER"); + + b.Property("AudienceType") + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Currency") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("Duration") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("DurationInMonths") + .HasColumnType("INTEGER"); + + b.Property("EndDate") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("PercentOff") + .HasPrecision(5, 2) + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("StartDate") + .HasColumnType("TEXT"); + + b.Property("StripeCouponId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("StripeProductIds") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("StripeCouponId") + .IsUnique(); + + b.HasIndex("StartDate", "EndDate") + .HasDatabaseName("IX_SubscriptionDiscount_DateRange") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SubscriptionDiscount", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationApplication", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Applications") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ContentEncryptionKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationApplication", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegration", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Configuration") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "Type") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationIntegration", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegrationConfiguration", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Configuration") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("EventType") + .HasColumnType("INTEGER"); + + b.Property("Filters") + .HasColumnType("TEXT"); + + b.Property("OrganizationIntegrationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Template") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationIntegrationId"); + + b.ToTable("OrganizationIntegrationConfiguration", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ApplicationAtRiskCount") + .HasColumnType("INTEGER"); + + b.Property("ApplicationCount") + .HasColumnType("INTEGER"); + + b.Property("ApplicationData") + .HasColumnType("TEXT"); + + b.Property("ContentEncryptionKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("CriticalApplicationAtRiskCount") + .HasColumnType("INTEGER"); + + b.Property("CriticalApplicationCount") + .HasColumnType("INTEGER"); + + b.Property("CriticalMemberAtRiskCount") + .HasColumnType("INTEGER"); + + b.Property("CriticalMemberCount") + .HasColumnType("INTEGER"); + + b.Property("CriticalPasswordAtRiskCount") + .HasColumnType("INTEGER"); + + b.Property("CriticalPasswordCount") + .HasColumnType("INTEGER"); + + b.Property("MemberAtRiskCount") + .HasColumnType("INTEGER"); + + b.Property("MemberCount") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("PasswordAtRiskCount") + .HasColumnType("INTEGER"); + + b.Property("PasswordCount") + .HasColumnType("INTEGER"); + + b.Property("ReportData") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ReportFile") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("SummaryData") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationReport", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.PasswordHealthReportApplication", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Uri") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("PasswordHealthReportApplication", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Cache", b => + { + b.Property("Id") + .HasMaxLength(449) + .HasColumnType("TEXT"); + + b.Property("AbsoluteExpiration") + .HasColumnType("TEXT"); + + b.Property("ExpiresAtTime") + .HasColumnType("TEXT"); + + b.Property("SlidingExpirationInSeconds") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasColumnType("BLOB"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpiresAtTime") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Cache", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.Property("CollectionId") + .HasColumnType("TEXT"); + + b.Property("CipherId") + .HasColumnType("TEXT"); + + b.HasKey("CollectionId", "CipherId"); + + b.HasIndex("CipherId"); + + b.ToTable("CollectionCipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Active") + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("ClientVersion") + .HasMaxLength(43) + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("EncryptedPrivateKey") + .HasColumnType("TEXT"); + + b.Property("EncryptedPublicKey") + .HasColumnType("TEXT"); + + b.Property("EncryptedUserKey") + .HasColumnType("TEXT"); + + b.Property("Identifier") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("LastActivityDate") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PushToken") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Identifier") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "Identifier") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Device", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Event", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ActingUserId") + .HasColumnType("TEXT"); + + b.Property("CipherId") + .HasColumnType("TEXT"); + + b.Property("CollectionId") + .HasColumnType("TEXT"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("DeviceType") + .HasColumnType("INTEGER"); + + b.Property("DomainName") + .HasColumnType("TEXT"); + + b.Property("GrantedServiceAccountId") + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("InstallationId") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("OrganizationUserId") + .HasColumnType("TEXT"); + + b.Property("PolicyId") + .HasColumnType("TEXT"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("ProviderOrganizationId") + .HasColumnType("TEXT"); + + b.Property("ProviderUserId") + .HasColumnType("TEXT"); + + b.Property("SecretId") + .HasColumnType("TEXT"); + + b.Property("ServiceAccountId") + .HasColumnType("TEXT"); + + b.Property("SystemUser") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("Date", "OrganizationId", "ActingUserId", "CipherId") + .HasDatabaseName("IX_Event_DateOrganizationIdUserId") + .HasAnnotation("SqlServer:Clustered", false) + .HasAnnotation("SqlServer:Include", new[] { "ServiceAccountId", "GrantedServiceAccountId" }); + + b.ToTable("Event", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Group", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("OrganizationUserId") + .HasColumnType("TEXT"); + + b.HasKey("GroupId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("GroupUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Config") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationConnection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("DomainName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("JobRunCount") + .HasColumnType("INTEGER"); + + b.Property("LastCheckedDate") + .HasColumnType("TEXT"); + + b.Property("NextRunDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Txt") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("VerifiedDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationDomain", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("FriendlyName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAdminInitiated") + .HasColumnType("INTEGER"); + + b.Property("LastSyncDate") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("OfferedToEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PlanSponsorshipType") + .HasColumnType("INTEGER"); + + b.Property("SponsoredOrganizationId") + .HasColumnType("TEXT"); + + b.Property("SponsoringOrganizationId") + .HasColumnType("TEXT"); + + b.Property("SponsoringOrganizationUserId") + .HasColumnType("TEXT"); + + b.Property("ToDelete") + .HasColumnType("INTEGER"); + + b.Property("ValidUntil") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SponsoredOrganizationId"); + + b.HasIndex("SponsoringOrganizationId"); + + b.HasIndex("SponsoringOrganizationUserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationSponsorship", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessSecretsManager") + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Permissions") + .HasColumnType("TEXT"); + + b.Property("ResetPasswordKey") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("RevocationReason") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.PlayItem", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("PlayId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("PlayId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("PlayItem", null, t => + { + t.HasCheckConstraint("CK_PlayItem_UserOrOrganization", "(\"UserId\" IS NOT NULL AND \"OrganizationId\" IS NULL) OR (\"UserId\" IS NULL AND \"OrganizationId\" IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessCount") + .HasColumnType("INTEGER"); + + b.Property("AuthType") + .HasColumnType("INTEGER"); + + b.Property("CipherId") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("DeletionDate") + .HasColumnType("TEXT"); + + b.Property("Disabled") + .HasColumnType("INTEGER"); + + b.Property("Emails") + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("ExpirationDate") + .HasColumnType("TEXT"); + + b.Property("HideEmail") + .HasColumnType("INTEGER"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("MaxAccessCount") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeletionDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Send", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.TaxRate", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("TEXT"); + + b.Property("Active") + .HasColumnType("INTEGER"); + + b.Property("Country") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PostalCode") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("Rate") + .HasColumnType("TEXT"); + + b.Property("State") + .HasMaxLength(2) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("TaxRate", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Amount") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Gateway") + .HasColumnType("INTEGER"); + + b.Property("GatewayId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("PaymentMethodType") + .HasColumnType("INTEGER"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("Refunded") + .HasColumnType("INTEGER"); + + b.Property("RefundedAmount") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ProviderId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId", "CreationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Transaction", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccountRevisionDate") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.Property("AvatarColor") + .HasMaxLength(7) + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Culture") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailVerified") + .HasColumnType("INTEGER"); + + b.Property("EquivalentDomains") + .HasColumnType("TEXT"); + + b.Property("ExcludedGlobalEquivalentDomains") + .HasColumnType("TEXT"); + + b.Property("FailedLoginCount") + .HasColumnType("INTEGER"); + + b.Property("ForcePasswordReset") + .HasColumnType("INTEGER"); + + b.Property("Gateway") + .HasColumnType("INTEGER"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Kdf") + .HasColumnType("INTEGER"); + + b.Property("KdfIterations") + .HasColumnType("INTEGER"); + + b.Property("KdfMemory") + .HasColumnType("INTEGER"); + + b.Property("KdfParallelism") + .HasColumnType("INTEGER"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("LastApiKeyRotationDate") + .HasColumnType("TEXT"); + + b.Property("LastEmailChangeDate") + .HasColumnType("TEXT"); + + b.Property("LastFailedLoginDate") + .HasColumnType("TEXT"); + + b.Property("LastKdfChangeDate") + .HasColumnType("TEXT"); + + b.Property("LastKeyRotationDate") + .HasColumnType("TEXT"); + + b.Property("LastPasswordChangeDate") + .HasColumnType("TEXT"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("MasterPassword") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("MasterPasswordHint") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("MasterPasswordSalt") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("MaxStorageGb") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Premium") + .HasColumnType("INTEGER"); + + b.Property("PremiumExpirationDate") + .HasColumnType("TEXT"); + + b.Property("PrivateKey") + .HasColumnType("TEXT"); + + b.Property("PublicKey") + .HasColumnType("TEXT"); + + b.Property("ReferenceData") + .HasColumnType("TEXT"); + + b.Property("RenewalReminderDate") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("SecurityStamp") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("SecurityState") + .HasColumnType("TEXT"); + + b.Property("SecurityVersion") + .HasColumnType("INTEGER"); + + b.Property("SignedPublicKey") + .HasColumnType("TEXT"); + + b.Property("Storage") + .HasColumnType("INTEGER"); + + b.Property("TwoFactorProviders") + .HasColumnType("TEXT"); + + b.Property("TwoFactorRecoveryCode") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("UsesKeyConnector") + .HasColumnType("INTEGER"); + + b.Property("V2UpgradeToken") + .HasColumnType("TEXT"); + + b.Property("VerifyDevices") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("GatewayCustomerId"); + + b.HasIndex("GatewaySubscriptionId"); + + b.HasIndex("Premium", "PremiumExpirationDate", "RenewalReminderDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("User", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.UserSignatureKeyPair", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("SignatureAlgorithm") + .HasColumnType("INTEGER"); + + b.Property("SigningKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("VerifyingKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("UserSignatureKeyPair", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Body") + .HasMaxLength(3000) + .HasColumnType("TEXT"); + + b.Property("ClientType") + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Global") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Priority") + .HasColumnType("INTEGER"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("TaskId") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("TaskId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("ClientType", "Global", "UserId", "OrganizationId", "Priority", "CreationDate") + .IsDescending(false, false, false, false, true, true) + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Notification", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("NotificationId") + .HasColumnType("TEXT"); + + b.Property("DeletedDate") + .HasColumnType("TEXT"); + + b.Property("ReadDate") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "NotificationId") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("NotificationId"); + + b.ToTable("NotificationStatus", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Platform.Installation", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("TEXT"); + + b.Property("LastActivityDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Installation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Discriminator") + .IsRequired() + .HasMaxLength(34) + .HasColumnType("TEXT"); + + b.Property("Read") + .HasColumnType("INTEGER"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Write") + .HasColumnType("INTEGER"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.ToTable("AccessPolicy", (string)null); + + b.HasDiscriminator().HasValue("AccessPolicy"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ClientSecretHash") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("EncryptedPayload") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("ExpireAt") + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("ServiceAccountId") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ServiceAccountId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("DeletedDate") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Project", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("DeletedDate") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("Note") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Secret", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.SecretVersion", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("EditorOrganizationUserId") + .HasColumnType("TEXT"); + + b.Property("EditorServiceAccountId") + .HasColumnType("TEXT"); + + b.Property("SecretId") + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("VersionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("EditorOrganizationUserId") + .HasDatabaseName("IX_SecretVersion_EditorOrganizationUserId"); + + b.HasIndex("EditorServiceAccountId") + .HasDatabaseName("IX_SecretVersion_EditorServiceAccountId"); + + b.HasIndex("SecretId") + .HasDatabaseName("IX_SecretVersion_SecretId"); + + b.ToTable("SecretVersion"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ServiceAccount", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Archives") + .HasColumnType("TEXT"); + + b.Property("Attachments") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("DeletedDate") + .HasColumnType("TEXT"); + + b.Property("Favorites") + .HasColumnType("TEXT"); + + b.Property("Folders") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Reprompt") + .HasColumnType("INTEGER"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId"); + + b.ToTable("Cipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Folder", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CipherId") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("CipherId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SecurityTask", (string)null); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.Property("ProjectsId") + .HasColumnType("TEXT"); + + b.Property("SecretsId") + .HasColumnType("TEXT"); + + b.HasKey("ProjectsId", "SecretsId"); + + b.HasIndex("SecretsId"); + + b.ToTable("ProjectSecret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedProjectId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedSecretId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedProjectId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedSecretId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedProjectId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedSecretId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Collection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Collections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.CollectionGroup", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Collection", "Collection") + .WithMany("CollectionGroups") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.CollectionUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Collection", "Collection") + .WithMany("CollectionUsers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("CollectionUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationInviteLink", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Policies") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.AuthRequest", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Device", "ResponseDevice") + .WithMany() + .HasForeignKey("ResponseDeviceId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("ResponseDevice"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantee") + .WithMany() + .HasForeignKey("GranteeId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantor") + .WithMany() + .HasForeignKey("GrantorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Grantee"); + + b.Navigation("Grantor"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoConfigs") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoUsers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("SsoUsers") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Platform.Installation", "Installation") + .WithMany() + .HasForeignKey("InstallationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Installation"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationPlanMigrationCohortAssignment", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationPlanMigrationCohort", "Cohort") + .WithMany() + .HasForeignKey("CohortId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cohort"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationApplication", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegrationConfiguration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegration", "OrganizationIntegration") + .WithMany() + .HasForeignKey("OrganizationIntegrationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("OrganizationIntegration"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.PasswordHealthReportApplication", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany("CollectionCiphers") + .HasForeignKey("CipherId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Collection", "Collection") + .WithMany("CollectionCiphers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Collection"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Groups") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany("GroupUsers") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("GroupUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("ApiKeys") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Connections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Domains") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoredOrganization") + .WithMany() + .HasForeignKey("SponsoredOrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoringOrganization") + .WithMany() + .HasForeignKey("SponsoringOrganizationId"); + + b.Navigation("SponsoredOrganization"); + + b.Navigation("SponsoringOrganization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("OrganizationUsers") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("OrganizationUsers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.PlayItem", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Transactions") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Transactions") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.UserSignatureKeyPair", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", "Task") + .WithMany() + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Task"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", "Notification") + .WithMany() + .HasForeignKey("NotificationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Notification"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ApiKeys") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.SecretVersion", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "EditorOrganizationUser") + .WithMany() + .HasForeignKey("EditorOrganizationUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "EditorServiceAccount") + .WithMany() + .HasForeignKey("EditorServiceAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "Secret") + .WithMany("SecretVersions") + .HasForeignKey("SecretId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("EditorOrganizationUser"); + + b.Navigation("EditorServiceAccount"); + + b.Navigation("Secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Ciphers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Ciphers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Folders") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany() + .HasForeignKey("CipherId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", null) + .WithMany() + .HasForeignKey("ProjectsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", null) + .WithMany() + .HasForeignKey("SecretsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedProject"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedSecret"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ProjectAccessPolicies") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedProject"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany() + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedProject"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Collection", b => + { + b.Navigation("CollectionCiphers"); + + b.Navigation("CollectionGroups"); + + b.Navigation("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("Ciphers"); + + b.Navigation("Collections"); + + b.Navigation("Connections"); + + b.Navigation("Domains"); + + b.Navigation("Groups"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("Policies"); + + b.Navigation("SsoConfigs"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Navigation("CollectionUsers"); + + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Navigation("Ciphers"); + + b.Navigation("Folders"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("SecretVersions"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ProjectAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Navigation("CollectionCiphers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/util/SqliteMigrations/Migrations/20260521121259_AddCollectionLeasing.cs b/util/SqliteMigrations/Migrations/20260521121259_AddCollectionLeasing.cs new file mode 100644 index 000000000000..08787dbb6060 --- /dev/null +++ b/util/SqliteMigrations/Migrations/20260521121259_AddCollectionLeasing.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bit.SqliteMigrations.Migrations; + +/// +public partial class AddCollectionLeasing : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "LeasingEnabled", + table: "Collection", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "LeasingPolicy", + table: "Collection", + type: "TEXT", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "LeasingEnabled", + table: "Collection"); + + migrationBuilder.DropColumn( + name: "LeasingPolicy", + table: "Collection"); + } +} diff --git a/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs b/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs index 025519c10a7e..d09c89d05272 100644 --- a/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs +++ b/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs @@ -79,6 +79,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(300) .HasColumnType("TEXT"); + b.Property("LeasingEnabled") + .HasColumnType("INTEGER"); + + b.Property("LeasingPolicy") + .HasColumnType("TEXT"); + b.Property("Name") .IsRequired() .HasColumnType("TEXT"); From 2fa46976826ac7c980c3b1b25919e1c0ef8dbf90 Mon Sep 17 00:00:00 2001 From: Hinton Date: Mon, 25 May 2026 13:36:39 +0200 Subject: [PATCH 02/54] Replace inline leasing config with reusable LeasingPolicy entity Promotes leasing policy from inline Collection columns to a first-class, org-scoped, reusable entity that collections (and eventually Secrets Manager entities) reference by FK. - Schema: drop Collection.LeasingEnabled and Collection.LeasingPolicy; add Collection.LeasingPolicyId UNIQUEIDENTIFIER NULL with FK to the new LeasingPolicy(Id) ON DELETE SET NULL. - New LeasingPolicy table (org-scoped, unique Name per org) with CRUD stored procedures under src/Sql/dbo/PrivilegedAccessManagement/. - EF: new LeasingPolicy entity + DbSet, explicit OnModelCreating configuration for SET NULL behavior and the (OrganizationId, Name) unique index. Generated migrations for Postgres, MySQL, and SQLite. - MSSQL migration 2026-05-21_00_AddLeasingPolicy.sql (renamed from the previous AddCollectionLeasing) creates the table + sprocs, drops the prior inline columns, adds the FK column, and CREATE OR ALTERs the affected Collection_* sprocs to forward @LeasingPolicyId. - Collection request/response models now expose Guid? LeasingPolicyId. - Create/UpdateCollectionCommand reverted to pre-leasing shape; policy validation moves to upcoming LeasingPolicy CRUD commands. The existing LeasingPolicy DTOs and LeasingPolicyValidator survive for the new policy CRUD pipeline (commands, controller, DI wiring to follow). --- .../Models/Request/CollectionRequestModel.cs | 19 +- .../Response/CollectionResponseModel.cs | 23 +- src/Core/AdminConsole/Entities/Collection.cs | 12 +- .../Collections/CreateCollectionCommand.cs | 25 +- .../Collections/UpdateCollectionCommand.cs | 25 +- ...OrganizationServiceCollectionExtensions.cs | 2 - .../Entities/LeasingPolicy.cs | 33 +++ .../Repositories/CollectionRepository.cs | 9 +- .../Models/LeasingPolicy.cs | 20 ++ .../Repositories/DatabaseContext.cs | 12 + .../Stored Procedures/Collection_Create.sql | 9 +- .../Collection_CreateWithGroupsAndUsers.sql | 5 +- .../Collection_ReadByIdWithPermissions.sql | 3 +- .../Collection_ReadByUserId.sql | 6 +- ...ectionsByOrganizationIdWithPermissions.sql | 3 +- .../Stored Procedures/Collection_Update.sql | 6 +- .../Collection_UpdateWithGroups.sql | 5 +- .../Collection_UpdateWithGroupsAndUsers.sql | 5 +- .../Collection_UpdateWithUsers.sql | 5 +- .../LeasingPolicy_Create.sql | 33 +++ .../LeasingPolicy_DeleteById.sql | 8 + .../LeasingPolicy_ReadById.sql | 10 + .../LeasingPolicy_ReadByOrganizationId.sql | 10 + .../LeasingPolicy_Update.sql | 24 ++ .../Tables/LeasingPolicy.sql | 17 ++ src/Sql/dbo/Tables/Collection.sql | 10 +- .../CreateCollectionCommandTests.cs | 77 ------ .../UpdateCollectionCommandTests.cs | 74 ------ ...sql => 2026-05-21_00_AddLeasingPolicy.sql} | 219 ++++++++++++++---- .../20260521121534_AddCollectionLeasing.cs | 39 ---- ...260525112822_AddLeasingPolicy.Designer.cs} | 63 ++++- .../20260525112822_AddLeasingPolicy.cs | 107 +++++++++ .../DatabaseContextModelSnapshot.cs | 59 ++++- .../20260521121303_AddCollectionLeasing.cs | 38 --- ...260525092251_AddLeasingPolicy.Designer.cs} | 63 ++++- .../20260525092251_AddLeasingPolicy.cs | 101 ++++++++ .../DatabaseContextModelSnapshot.cs | 59 ++++- .../20260521121259_AddCollectionLeasing.cs | 38 --- ...260525092256_AddLeasingPolicy.Designer.cs} | 61 ++++- .../20260525092256_AddLeasingPolicy.cs | 91 ++++++++ .../DatabaseContextModelSnapshot.cs | 57 ++++- 41 files changed, 1004 insertions(+), 481 deletions(-) create mode 100644 src/Core/PrivilegedAccessManagement/Entities/LeasingPolicy.cs create mode 100644 src/Infrastructure.EntityFramework/PrivilegedAccessManagement/Models/LeasingPolicy.cs create mode 100644 src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/LeasingPolicy_Create.sql create mode 100644 src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/LeasingPolicy_DeleteById.sql create mode 100644 src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/LeasingPolicy_ReadById.sql create mode 100644 src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/LeasingPolicy_ReadByOrganizationId.sql create mode 100644 src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/LeasingPolicy_Update.sql create mode 100644 src/Sql/dbo/PrivilegedAccessManagement/Tables/LeasingPolicy.sql rename util/Migrator/DbScripts/{2026-05-21_00_AddCollectionLeasing.sql => 2026-05-21_00_AddLeasingPolicy.sql} (78%) delete mode 100644 util/MySqlMigrations/Migrations/20260521121534_AddCollectionLeasing.cs rename util/MySqlMigrations/Migrations/{20260521121534_AddCollectionLeasing.Designer.cs => 20260525112822_AddLeasingPolicy.Designer.cs} (98%) create mode 100644 util/MySqlMigrations/Migrations/20260525112822_AddLeasingPolicy.cs delete mode 100644 util/PostgresMigrations/Migrations/20260521121303_AddCollectionLeasing.cs rename util/PostgresMigrations/Migrations/{20260521121303_AddCollectionLeasing.Designer.cs => 20260525092251_AddLeasingPolicy.Designer.cs} (98%) create mode 100644 util/PostgresMigrations/Migrations/20260525092251_AddLeasingPolicy.cs delete mode 100644 util/SqliteMigrations/Migrations/20260521121259_AddCollectionLeasing.cs rename util/SqliteMigrations/Migrations/{20260521121259_AddCollectionLeasing.Designer.cs => 20260525092256_AddLeasingPolicy.Designer.cs} (98%) create mode 100644 util/SqliteMigrations/Migrations/20260525092256_AddLeasingPolicy.cs diff --git a/src/Api/AdminConsole/Models/Request/CollectionRequestModel.cs b/src/Api/AdminConsole/Models/Request/CollectionRequestModel.cs index b8ef4550b2a5..f394510c3b60 100644 --- a/src/Api/AdminConsole/Models/Request/CollectionRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/CollectionRequestModel.cs @@ -2,7 +2,6 @@ #nullable disable using System.ComponentModel.DataAnnotations; -using System.Text.Json; using Bit.Api.Models.Request; using Bit.Core.Entities; using Bit.Core.Utilities; @@ -19,8 +18,7 @@ public class CreateCollectionRequestModel public string ExternalId { get; set; } public IEnumerable Groups { get; set; } public IEnumerable Users { get; set; } - public bool LeasingEnabled { get; set; } - public object LeasingPolicy { get; set; } + public Guid? LeasingPolicyId { get; set; } public Collection ToCollection(Guid orgId) { @@ -34,18 +32,9 @@ public virtual Collection ToCollection(Collection existingCollection) { existingCollection.Name = Name; existingCollection.ExternalId = ExternalId; - existingCollection.LeasingEnabled = LeasingEnabled; - existingCollection.LeasingPolicy = SerializeLeasingPolicy(LeasingPolicy); + existingCollection.LeasingPolicyId = LeasingPolicyId; return existingCollection; } - - protected static string SerializeLeasingPolicy(object policy) => policy switch - { - null => null, - JsonElement je when je.ValueKind == JsonValueKind.Null => null, - JsonElement je => je.GetRawText(), - _ => JsonSerializer.Serialize(policy), - }; } public class CollectionBulkDeleteRequestModel @@ -78,9 +67,7 @@ public override Collection ToCollection(Collection existingCollection) existingCollection.Name = Name; } existingCollection.ExternalId = ExternalId; - existingCollection.LeasingEnabled = LeasingEnabled; - existingCollection.LeasingPolicy = SerializeLeasingPolicy(LeasingPolicy); + existingCollection.LeasingPolicyId = LeasingPolicyId; return existingCollection; } - } diff --git a/src/Api/AdminConsole/Models/Response/CollectionResponseModel.cs b/src/Api/AdminConsole/Models/Response/CollectionResponseModel.cs index c37bcb2265dc..2b505f60393e 100644 --- a/src/Api/AdminConsole/Models/Response/CollectionResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/CollectionResponseModel.cs @@ -1,7 +1,6 @@ // FIXME: Update this file to be null safe and then delete the line below #nullable disable -using System.Text.Json; using Bit.Api.Models.Response; using Bit.Core.Entities; using Bit.Core.Enums; @@ -26,24 +25,7 @@ public CollectionResponseModel(Collection collection, string obj = "collection") ExternalId = collection.ExternalId; Type = collection.Type; DefaultUserCollectionEmail = collection.DefaultUserCollectionEmail; - LeasingEnabled = collection.LeasingEnabled; - LeasingPolicy = TryParsePolicy(collection.LeasingPolicy); - } - - private static JsonElement? TryParsePolicy(string policyJson) - { - if (string.IsNullOrEmpty(policyJson)) - { - return null; - } - try - { - return JsonDocument.Parse(policyJson).RootElement; - } - catch (JsonException) - { - return null; - } + LeasingPolicyId = collection.LeasingPolicyId; } public Guid Id { get; set; } @@ -52,8 +34,7 @@ public CollectionResponseModel(Collection collection, string obj = "collection") public string ExternalId { get; set; } public CollectionType Type { get; set; } public string DefaultUserCollectionEmail { get; set; } - public bool LeasingEnabled { get; set; } - public JsonElement? LeasingPolicy { get; set; } + public Guid? LeasingPolicyId { get; set; } } /// diff --git a/src/Core/AdminConsole/Entities/Collection.cs b/src/Core/AdminConsole/Entities/Collection.cs index aeb631c32fde..2aabe5296ba7 100644 --- a/src/Core/AdminConsole/Entities/Collection.cs +++ b/src/Core/AdminConsole/Entities/Collection.cs @@ -47,16 +47,10 @@ public class Collection : ITableObject /// public string? DefaultUserCollectionEmail { get; set; } /// - /// Master switch for PAM credential leasing on this collection. When false, leasing endpoints - /// behave as if leasing did not exist for this collection. + /// Reference to a that gates + /// PAM credential leasing for this collection. Null means leasing is disabled for the collection. /// - public bool LeasingEnabled { get; set; } - /// - /// Raw JSON policy document evaluated when leasing is enabled. Validated by - /// LeasingPolicyValidator. Null means no policy is configured; behavior defaults to - /// human_approval at evaluation time. - /// - public string? LeasingPolicy { get; set; } + public Guid? LeasingPolicyId { get; set; } /// /// Initializes to a new COMB GUID. diff --git a/src/Core/AdminConsole/OrganizationFeatures/Collections/CreateCollectionCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Collections/CreateCollectionCommand.cs index 7251bc34843d..f09735530c91 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Collections/CreateCollectionCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Collections/CreateCollectionCommand.cs @@ -6,7 +6,6 @@ using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Data; -using Bit.Core.PrivilegedAccessManagement.Services; using Bit.Core.Repositories; using Bit.Core.Services; @@ -17,21 +16,15 @@ public class CreateCollectionCommand : ICreateCollectionCommand private readonly IEventService _eventService; private readonly IOrganizationRepository _organizationRepository; private readonly ICollectionRepository _collectionRepository; - private readonly IFeatureService _featureService; - private readonly ILeasingPolicyValidator _leasingPolicyValidator; public CreateCollectionCommand( IEventService eventService, IOrganizationRepository organizationRepository, - ICollectionRepository collectionRepository, - IFeatureService featureService, - ILeasingPolicyValidator leasingPolicyValidator) + ICollectionRepository collectionRepository) { _eventService = eventService; _organizationRepository = organizationRepository; _collectionRepository = collectionRepository; - _featureService = featureService; - _leasingPolicyValidator = leasingPolicyValidator; } public async Task CreateAsync(Collection collection, IEnumerable groups = null, @@ -42,8 +35,6 @@ public async Task CreateAsync(Collection collection, IEnumerable CreateAsync(Collection collection, IEnumerable UpdateAsync(Collection collection, IEnumerable groups = null, @@ -45,8 +38,6 @@ public async Task UpdateAsync(Collection collection, IEnumerable UpdateAsync(Collection collection, IEnumerable(); services.AddScoped(); services.AddScoped(); - services.AddSingleton(); } private static void AddOrganizationGroupCommands(this IServiceCollection services) diff --git a/src/Core/PrivilegedAccessManagement/Entities/LeasingPolicy.cs b/src/Core/PrivilegedAccessManagement/Entities/LeasingPolicy.cs new file mode 100644 index 000000000000..fe115252862c --- /dev/null +++ b/src/Core/PrivilegedAccessManagement/Entities/LeasingPolicy.cs @@ -0,0 +1,33 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Core.Entities; +using Bit.Core.Utilities; + +namespace Bit.Core.PrivilegedAccessManagement.Entities; + +/// +/// A reusable, org-scoped PAM leasing policy. Referenced by collections (and eventually Secrets Manager +/// entities) via FK to govern credential lease decisions. +/// +public class LeasingPolicy : ITableObject +{ + public Guid Id { get; set; } + public Guid OrganizationId { get; set; } + + [MaxLength(256)] + public string Name { get; set; } = null!; + + public string? Description { get; set; } + + /// + /// JSON policy document. Validated by LeasingPolicyValidator before being persisted. + /// + public string Policy { get; set; } = null!; + + public DateTime CreationDate { get; set; } = DateTime.UtcNow; + public DateTime RevisionDate { get; set; } = DateTime.UtcNow; + + public void SetNewId() + { + Id = CoreHelpers.GenerateComb(); + } +} diff --git a/src/Infrastructure.Dapper/AdminConsole/Repositories/CollectionRepository.cs b/src/Infrastructure.Dapper/AdminConsole/Repositories/CollectionRepository.cs index ef807fde445c..088ff66cdef7 100644 --- a/src/Infrastructure.Dapper/AdminConsole/Repositories/CollectionRepository.cs +++ b/src/Infrastructure.Dapper/AdminConsole/Repositories/CollectionRepository.cs @@ -486,8 +486,7 @@ public CollectionWithGroupsAndUsers(Collection collection, Type = collection.Type; ExternalId = collection.ExternalId; DefaultUserCollectionEmail = collection.DefaultUserCollectionEmail; - LeasingEnabled = collection.LeasingEnabled; - LeasingPolicy = collection.LeasingPolicy; + LeasingPolicyId = collection.LeasingPolicyId; Groups = groups.ToArrayTVP(); Users = users.ToArrayTVP(); } @@ -512,8 +511,7 @@ public CollectionWithGroups(Collection collection, IEnumerable().ReverseMap(); + } +} diff --git a/src/Infrastructure.EntityFramework/Repositories/DatabaseContext.cs b/src/Infrastructure.EntityFramework/Repositories/DatabaseContext.cs index 4265e9d7ea84..e9ae207da2fa 100644 --- a/src/Infrastructure.EntityFramework/Repositories/DatabaseContext.cs +++ b/src/Infrastructure.EntityFramework/Repositories/DatabaseContext.cs @@ -9,6 +9,7 @@ using Bit.Infrastructure.EntityFramework.Models; using Bit.Infrastructure.EntityFramework.NotificationCenter.Models; using Bit.Infrastructure.EntityFramework.Platform; +using Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Models; using Bit.Infrastructure.EntityFramework.SecretsManager.Models; using Bit.Infrastructure.EntityFramework.Vault.Models; using Microsoft.EntityFrameworkCore; @@ -43,6 +44,7 @@ public DatabaseContext(DbContextOptions options) public DbSet CollectionCiphers { get; set; } public DbSet CollectionGroups { get; set; } public DbSet CollectionUsers { get; set; } + public DbSet LeasingPolicies { get; set; } public DbSet Devices { get; set; } public DbSet EmergencyAccesses { get; set; } public DbSet Events { get; set; } @@ -107,6 +109,7 @@ protected override void OnModelCreating(ModelBuilder builder) var eCollectionCipher = builder.Entity(); var eCollectionUser = builder.Entity(); var eCollectionGroup = builder.Entity(); + var eLeasingPolicy = builder.Entity(); var eEmergencyAccess = builder.Entity(); var eFolder = builder.Entity(); var eGroup = builder.Entity(); @@ -146,6 +149,14 @@ protected override void OnModelCreating(ModelBuilder builder) eCollectionGroup.HasKey(cg => new { cg.CollectionId, cg.GroupId }); eGroupUser.HasKey(gu => new { gu.GroupId, gu.OrganizationUserId }); + eLeasingPolicy.Property(p => p.Id).ValueGeneratedNever(); + eLeasingPolicy.HasIndex(p => new { p.OrganizationId, p.Name }).IsUnique(); + eCollection + .HasOne() + .WithMany() + .HasForeignKey(c => c.LeasingPolicyId) + .OnDelete(DeleteBehavior.SetNull); + eOrganizationMemberBaseDetail.HasNoKey(); var dataProtector = this.GetService().CreateProtector( @@ -169,6 +180,7 @@ protected override void OnModelCreating(ModelBuilder builder) eCipher.ToTable(nameof(Cipher)); eCollection.ToTable(nameof(Collection)); eCollectionCipher.ToTable(nameof(CollectionCipher)); + eLeasingPolicy.ToTable(nameof(LeasingPolicy)); eEmergencyAccess.ToTable(nameof(EmergencyAccess)); eFolder.ToTable(nameof(Folder)); eGroup.ToTable(nameof(Group)); diff --git a/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_Create.sql b/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_Create.sql index 2c42c75e9ee7..851348ab994c 100644 --- a/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_Create.sql +++ b/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_Create.sql @@ -7,8 +7,7 @@ @RevisionDate DATETIME2(7), @DefaultUserCollectionEmail NVARCHAR(256) = NULL, @Type TINYINT = 0, - @LeasingEnabled BIT = 0, - @LeasingPolicy NVARCHAR(MAX) = NULL + @LeasingPolicyId UNIQUEIDENTIFIER = NULL AS BEGIN SET NOCOUNT ON @@ -23,8 +22,7 @@ BEGIN [RevisionDate], [DefaultUserCollectionEmail], [Type], - [LeasingEnabled], - [LeasingPolicy] + [LeasingPolicyId] ) VALUES ( @@ -36,8 +34,7 @@ BEGIN @RevisionDate, @DefaultUserCollectionEmail, @Type, - @LeasingEnabled, - @LeasingPolicy + @LeasingPolicyId ) EXEC [dbo].[User_BumpAccountRevisionDateByCollectionId] @Id, @OrganizationId diff --git a/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_CreateWithGroupsAndUsers.sql b/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_CreateWithGroupsAndUsers.sql index 994cfef9832f..b44364bcf955 100644 --- a/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_CreateWithGroupsAndUsers.sql +++ b/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_CreateWithGroupsAndUsers.sql @@ -9,13 +9,12 @@ CREATE PROCEDURE [dbo].[Collection_CreateWithGroupsAndUsers] @Users AS [dbo].[CollectionAccessSelectionType] READONLY, @DefaultUserCollectionEmail NVARCHAR(256) = NULL, @Type TINYINT = 0, - @LeasingEnabled BIT = 0, - @LeasingPolicy NVARCHAR(MAX) = NULL + @LeasingPolicyId UNIQUEIDENTIFIER = NULL AS BEGIN SET NOCOUNT ON - EXEC [dbo].[Collection_Create] @Id, @OrganizationId, @Name, @ExternalId, @CreationDate, @RevisionDate, @DefaultUserCollectionEmail, @Type, @LeasingEnabled, @LeasingPolicy + EXEC [dbo].[Collection_Create] @Id, @OrganizationId, @Name, @ExternalId, @CreationDate, @RevisionDate, @DefaultUserCollectionEmail, @Type, @LeasingPolicyId -- Groups ;WITH [AvailableGroupsCTE] AS( diff --git a/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_ReadByIdWithPermissions.sql b/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_ReadByIdWithPermissions.sql index 99136c1fccb3..6bc8de5914e3 100644 --- a/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_ReadByIdWithPermissions.sql +++ b/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_ReadByIdWithPermissions.sql @@ -76,8 +76,7 @@ BEGIN C.[ExternalId], C.[DefaultUserCollectionEmail], C.[Type], - C.[LeasingEnabled], - C.[LeasingPolicy] + C.[LeasingPolicyId] IF (@IncludeAccessRelationships = 1) BEGIN diff --git a/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_ReadByUserId.sql b/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_ReadByUserId.sql index c4962dff5ecb..2ef93c446c81 100644 --- a/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_ReadByUserId.sql +++ b/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_ReadByUserId.sql @@ -16,8 +16,7 @@ BEGIN MAX([Manage]) AS [Manage], [DefaultUserCollectionEmail], [Type], - [LeasingEnabled], - [LeasingPolicy] + [LeasingPolicyId] FROM [dbo].[UserCollectionDetails](@UserId) GROUP BY @@ -29,6 +28,5 @@ BEGIN ExternalId, [DefaultUserCollectionEmail], [Type], - [LeasingEnabled], - [LeasingPolicy] + [LeasingPolicyId] END diff --git a/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_ReadSharedCollectionsByOrganizationIdWithPermissions.sql b/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_ReadSharedCollectionsByOrganizationIdWithPermissions.sql index 93b44f8137bb..088150ef84fb 100644 --- a/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_ReadSharedCollectionsByOrganizationIdWithPermissions.sql +++ b/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_ReadSharedCollectionsByOrganizationIdWithPermissions.sql @@ -77,8 +77,7 @@ BEGIN C.[ExternalId], C.[DefaultUserCollectionEmail], C.[Type], - C.[LeasingEnabled], - C.[LeasingPolicy] + C.[LeasingPolicyId] IF (@IncludeAccessRelationships = 1) BEGIN diff --git a/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_Update.sql b/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_Update.sql index 0c480552a7ff..f8d83d293855 100644 --- a/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_Update.sql +++ b/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_Update.sql @@ -7,8 +7,7 @@ @RevisionDate DATETIME2(7), @DefaultUserCollectionEmail NVARCHAR(256) = NULL, @Type TINYINT = 0, - @LeasingEnabled BIT = 0, - @LeasingPolicy NVARCHAR(MAX) = NULL + @LeasingPolicyId UNIQUEIDENTIFIER = NULL AS BEGIN SET NOCOUNT ON @@ -23,8 +22,7 @@ BEGIN [RevisionDate] = @RevisionDate, [DefaultUserCollectionEmail] = @DefaultUserCollectionEmail, [Type] = @Type, - [LeasingEnabled] = @LeasingEnabled, - [LeasingPolicy] = @LeasingPolicy + [LeasingPolicyId] = @LeasingPolicyId WHERE [Id] = @Id diff --git a/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_UpdateWithGroups.sql b/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_UpdateWithGroups.sql index 06a21f47b3cb..03d65ad73d2b 100644 --- a/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_UpdateWithGroups.sql +++ b/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_UpdateWithGroups.sql @@ -8,13 +8,12 @@ CREATE PROCEDURE [dbo].[Collection_UpdateWithGroups] @Groups AS [dbo].[CollectionAccessSelectionType] READONLY, @DefaultUserCollectionEmail NVARCHAR(256) = NULL, @Type TINYINT = 0, - @LeasingEnabled BIT = 0, - @LeasingPolicy NVARCHAR(MAX) = NULL + @LeasingPolicyId UNIQUEIDENTIFIER = NULL AS BEGIN SET NOCOUNT ON - EXEC [dbo].[Collection_Update] @Id, @OrganizationId, @Name, @ExternalId, @CreationDate, @RevisionDate, @DefaultUserCollectionEmail, @Type, @LeasingEnabled, @LeasingPolicy + EXEC [dbo].[Collection_Update] @Id, @OrganizationId, @Name, @ExternalId, @CreationDate, @RevisionDate, @DefaultUserCollectionEmail, @Type, @LeasingPolicyId -- Bump RevisionDate on all affected groups (old + new) before modifying CollectionGroup ;WITH [AffectedGroupsCTE] AS ( diff --git a/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_UpdateWithGroupsAndUsers.sql b/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_UpdateWithGroupsAndUsers.sql index aed09ab748d3..832db577e53a 100644 --- a/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_UpdateWithGroupsAndUsers.sql +++ b/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_UpdateWithGroupsAndUsers.sql @@ -9,13 +9,12 @@ @Users AS [dbo].[CollectionAccessSelectionType] READONLY, @DefaultUserCollectionEmail NVARCHAR(256) = NULL, @Type TINYINT = 0, - @LeasingEnabled BIT = 0, - @LeasingPolicy NVARCHAR(MAX) = NULL + @LeasingPolicyId UNIQUEIDENTIFIER = NULL AS BEGIN SET NOCOUNT ON - EXEC [dbo].[Collection_Update] @Id, @OrganizationId, @Name, @ExternalId, @CreationDate, @RevisionDate, @DefaultUserCollectionEmail, @Type, @LeasingEnabled, @LeasingPolicy + EXEC [dbo].[Collection_Update] @Id, @OrganizationId, @Name, @ExternalId, @CreationDate, @RevisionDate, @DefaultUserCollectionEmail, @Type, @LeasingPolicyId -- Bump RevisionDate on all affected groups (old + new) before modifying CollectionGroup ;WITH [AffectedGroupsCTE] AS ( diff --git a/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_UpdateWithUsers.sql b/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_UpdateWithUsers.sql index f19c147162bb..a56a4763ff98 100644 --- a/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_UpdateWithUsers.sql +++ b/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_UpdateWithUsers.sql @@ -8,13 +8,12 @@ CREATE PROCEDURE [dbo].[Collection_UpdateWithUsers] @Users AS [dbo].[CollectionAccessSelectionType] READONLY, @DefaultUserCollectionEmail NVARCHAR(256) = NULL, @Type TINYINT = 0, - @LeasingEnabled BIT = 0, - @LeasingPolicy NVARCHAR(MAX) = NULL + @LeasingPolicyId UNIQUEIDENTIFIER = NULL AS BEGIN SET NOCOUNT ON - EXEC [dbo].[Collection_Update] @Id, @OrganizationId, @Name, @ExternalId, @CreationDate, @RevisionDate, @DefaultUserCollectionEmail, @Type, @LeasingEnabled, @LeasingPolicy + EXEC [dbo].[Collection_Update] @Id, @OrganizationId, @Name, @ExternalId, @CreationDate, @RevisionDate, @DefaultUserCollectionEmail, @Type, @LeasingPolicyId -- Users -- Delete users that are no longer in source diff --git a/src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/LeasingPolicy_Create.sql b/src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/LeasingPolicy_Create.sql new file mode 100644 index 000000000000..e969c7b49d33 --- /dev/null +++ b/src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/LeasingPolicy_Create.sql @@ -0,0 +1,33 @@ +CREATE PROCEDURE [dbo].[LeasingPolicy_Create] + @Id UNIQUEIDENTIFIER OUTPUT, + @OrganizationId UNIQUEIDENTIFIER, + @Name NVARCHAR(256), + @Description NVARCHAR(MAX) = NULL, + @Policy NVARCHAR(MAX), + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + INSERT INTO [dbo].[LeasingPolicy] + ( + [Id], + [OrganizationId], + [Name], + [Description], + [Policy], + [CreationDate], + [RevisionDate] + ) + VALUES + ( + @Id, + @OrganizationId, + @Name, + @Description, + @Policy, + @CreationDate, + @RevisionDate + ) +END diff --git a/src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/LeasingPolicy_DeleteById.sql b/src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/LeasingPolicy_DeleteById.sql new file mode 100644 index 000000000000..dbad954f6099 --- /dev/null +++ b/src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/LeasingPolicy_DeleteById.sql @@ -0,0 +1,8 @@ +CREATE PROCEDURE [dbo].[LeasingPolicy_DeleteById] + @Id UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + DELETE FROM [dbo].[LeasingPolicy] WHERE [Id] = @Id +END diff --git a/src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/LeasingPolicy_ReadById.sql b/src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/LeasingPolicy_ReadById.sql new file mode 100644 index 000000000000..191ad05ff594 --- /dev/null +++ b/src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/LeasingPolicy_ReadById.sql @@ -0,0 +1,10 @@ +CREATE PROCEDURE [dbo].[LeasingPolicy_ReadById] + @Id UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT * + FROM [dbo].[LeasingPolicy] + WHERE [Id] = @Id +END diff --git a/src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/LeasingPolicy_ReadByOrganizationId.sql b/src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/LeasingPolicy_ReadByOrganizationId.sql new file mode 100644 index 000000000000..b23f8e72851d --- /dev/null +++ b/src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/LeasingPolicy_ReadByOrganizationId.sql @@ -0,0 +1,10 @@ +CREATE PROCEDURE [dbo].[LeasingPolicy_ReadByOrganizationId] + @OrganizationId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT * + FROM [dbo].[LeasingPolicy] + WHERE [OrganizationId] = @OrganizationId +END diff --git a/src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/LeasingPolicy_Update.sql b/src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/LeasingPolicy_Update.sql new file mode 100644 index 000000000000..4e04ceb7860a --- /dev/null +++ b/src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/LeasingPolicy_Update.sql @@ -0,0 +1,24 @@ +CREATE PROCEDURE [dbo].[LeasingPolicy_Update] + @Id UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @Name NVARCHAR(256), + @Description NVARCHAR(MAX) = NULL, + @Policy NVARCHAR(MAX), + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + UPDATE + [dbo].[LeasingPolicy] + SET + [OrganizationId] = @OrganizationId, + [Name] = @Name, + [Description] = @Description, + [Policy] = @Policy, + [CreationDate] = @CreationDate, + [RevisionDate] = @RevisionDate + WHERE + [Id] = @Id +END diff --git a/src/Sql/dbo/PrivilegedAccessManagement/Tables/LeasingPolicy.sql b/src/Sql/dbo/PrivilegedAccessManagement/Tables/LeasingPolicy.sql new file mode 100644 index 000000000000..e078bcd50b6e --- /dev/null +++ b/src/Sql/dbo/PrivilegedAccessManagement/Tables/LeasingPolicy.sql @@ -0,0 +1,17 @@ +CREATE TABLE [dbo].[LeasingPolicy] ( + [Id] UNIQUEIDENTIFIER NOT NULL, + [OrganizationId] UNIQUEIDENTIFIER NOT NULL, + [Name] NVARCHAR(256) NOT NULL, + [Description] NVARCHAR(MAX) NULL, + [Policy] NVARCHAR(MAX) NOT NULL, + [CreationDate] DATETIME2(7) NOT NULL, + [RevisionDate] DATETIME2(7) NOT NULL, + CONSTRAINT [PK_LeasingPolicy] PRIMARY KEY CLUSTERED ([Id] ASC), + CONSTRAINT [FK_LeasingPolicy_Organization] FOREIGN KEY ([OrganizationId]) + REFERENCES [dbo].[Organization] ([Id]) ON DELETE CASCADE +); +GO + +CREATE UNIQUE NONCLUSTERED INDEX [IX_LeasingPolicy_OrganizationId_Name] + ON [dbo].[LeasingPolicy] ([OrganizationId] ASC, [Name] ASC); +GO diff --git a/src/Sql/dbo/Tables/Collection.sql b/src/Sql/dbo/Tables/Collection.sql index 435492cf8eae..45cc9050df12 100644 --- a/src/Sql/dbo/Tables/Collection.sql +++ b/src/Sql/dbo/Tables/Collection.sql @@ -7,10 +7,10 @@ [RevisionDate] DATETIME2 (7) NOT NULL, [DefaultUserCollectionEmail] NVARCHAR(256) NULL, [Type] TINYINT NOT NULL DEFAULT(0), - [LeasingEnabled] BIT NOT NULL DEFAULT(0), - [LeasingPolicy] NVARCHAR(MAX) NULL, + [LeasingPolicyId] UNIQUEIDENTIFIER NULL, CONSTRAINT [PK_Collection] PRIMARY KEY CLUSTERED ([Id] ASC), - CONSTRAINT [FK_Collection_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]) ON DELETE CASCADE + CONSTRAINT [FK_Collection_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]) ON DELETE CASCADE, + CONSTRAINT [FK_Collection_LeasingPolicy] FOREIGN KEY ([LeasingPolicyId]) REFERENCES [dbo].[LeasingPolicy] ([Id]) ON DELETE SET NULL ); GO @@ -19,3 +19,7 @@ CREATE NONCLUSTERED INDEX [IX_Collection_OrganizationId_IncludeAll] INCLUDE([CreationDate], [Name], [RevisionDate], [Type]); GO +CREATE NONCLUSTERED INDEX [IX_Collection_LeasingPolicyId] + ON [dbo].[Collection]([LeasingPolicyId] ASC); +GO + diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Collections/CreateCollectionCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Collections/CreateCollectionCommandTests.cs index 6c967f8b89d2..3feac5c37206 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/Collections/CreateCollectionCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Collections/CreateCollectionCommandTests.cs @@ -4,7 +4,6 @@ using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Data; -using Bit.Core.PrivilegedAccessManagement.Services; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Test.AutoFixture; @@ -223,80 +222,4 @@ await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() .LogCollectionEventAsync(default, default); } - - [Theory, BitAutoData] - public async Task CreateAsync_PamFlagOff_ClearsLeasingFields( - Organization organization, Collection collection, - [CollectionAccessSelectionCustomize(true)] IEnumerable users, - SutProvider sutProvider) - { - collection.Id = default; - collection.LeasingEnabled = true; - collection.LeasingPolicy = """{"kind":"human_approval"}"""; - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.Pam) - .Returns(false); - sutProvider.GetDependency() - .GetByIdAsync(organization.Id) - .Returns(organization); - - await sutProvider.Sut.CreateAsync(collection, null, users); - - Assert.False(collection.LeasingEnabled); - Assert.Null(collection.LeasingPolicy); - sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .Validate(default); - } - - [Theory, BitAutoData] - public async Task CreateAsync_PamFlagOn_InvalidPolicy_ThrowsBadRequest( - Organization organization, Collection collection, - [CollectionAccessSelectionCustomize(true)] IEnumerable users, - SutProvider sutProvider) - { - collection.Id = default; - collection.LeasingEnabled = true; - collection.LeasingPolicy = """{"kind":"bogus"}"""; - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.Pam) - .Returns(true); - sutProvider.GetDependency() - .Validate(collection.LeasingPolicy) - .Returns(LeasingPolicyValidationResult.Invalid("Unsupported policy kind")); - sutProvider.GetDependency() - .GetByIdAsync(organization.Id) - .Returns(organization); - - var ex = await Assert.ThrowsAsync(() => sutProvider.Sut.CreateAsync(collection, null, users)); - Assert.Equal("Unsupported policy kind", ex.Message); - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .CreateAsync(default, default, default); - } - - [Theory, BitAutoData] - public async Task CreateAsync_PamFlagOn_ValidPolicy_RetainsLeasingFields( - Organization organization, Collection collection, - [CollectionAccessSelectionCustomize(true)] IEnumerable users, - SutProvider sutProvider) - { - collection.Id = default; - collection.LeasingEnabled = true; - collection.LeasingPolicy = """{"kind":"human_approval"}"""; - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.Pam) - .Returns(true); - sutProvider.GetDependency() - .Validate(collection.LeasingPolicy) - .Returns(LeasingPolicyValidationResult.Valid); - sutProvider.GetDependency() - .GetByIdAsync(organization.Id) - .Returns(organization); - - await sutProvider.Sut.CreateAsync(collection, null, users); - - Assert.True(collection.LeasingEnabled); - Assert.Equal("""{"kind":"human_approval"}""", collection.LeasingPolicy); - } } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Collections/UpdateCollectionCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Collections/UpdateCollectionCommandTests.cs index 7bc8119aea52..acdd61583d56 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/Collections/UpdateCollectionCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Collections/UpdateCollectionCommandTests.cs @@ -4,7 +4,6 @@ using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Data; -using Bit.Core.PrivilegedAccessManagement.Services; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Test.AutoFixture; @@ -324,79 +323,6 @@ await sutProvider.GetDependency() .ReplaceAsync(collection, null, null); } - [Theory, BitAutoData] - public async Task UpdateAsync_PamFlagOff_ClearsLeasingFields( - Organization organization, Collection collection) - { - var sutProvider = SetupSutProvider(); - organization.AllowAdminAccessToAllCollectionItems = true; - collection.LeasingEnabled = true; - collection.LeasingPolicy = """{"kind":"human_approval"}"""; - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.Pam) - .Returns(false); - sutProvider.GetDependency() - .GetByIdAsync(organization.Id) - .Returns(organization); - - await sutProvider.Sut.UpdateAsync(collection, null, null); - - Assert.False(collection.LeasingEnabled); - Assert.Null(collection.LeasingPolicy); - sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .Validate(default); - } - - [Theory, BitAutoData] - public async Task UpdateAsync_PamFlagOn_InvalidPolicy_ThrowsBadRequest( - Organization organization, Collection collection) - { - var sutProvider = SetupSutProvider(); - organization.AllowAdminAccessToAllCollectionItems = true; - collection.LeasingEnabled = true; - collection.LeasingPolicy = """{"kind":"bogus"}"""; - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.Pam) - .Returns(true); - sutProvider.GetDependency() - .Validate(collection.LeasingPolicy) - .Returns(LeasingPolicyValidationResult.Invalid("Unsupported policy kind")); - sutProvider.GetDependency() - .GetByIdAsync(organization.Id) - .Returns(organization); - - var ex = await Assert.ThrowsAsync(() => sutProvider.Sut.UpdateAsync(collection, null, null)); - Assert.Equal("Unsupported policy kind", ex.Message); - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .ReplaceAsync(default, default, default); - } - - [Theory, BitAutoData] - public async Task UpdateAsync_PamFlagOn_ValidPolicy_RetainsLeasingFields( - Organization organization, Collection collection) - { - var sutProvider = SetupSutProvider(); - organization.AllowAdminAccessToAllCollectionItems = true; - collection.LeasingEnabled = true; - collection.LeasingPolicy = """{"kind":"human_approval"}"""; - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.Pam) - .Returns(true); - sutProvider.GetDependency() - .Validate(collection.LeasingPolicy) - .Returns(LeasingPolicyValidationResult.Valid); - sutProvider.GetDependency() - .GetByIdAsync(organization.Id) - .Returns(organization); - - await sutProvider.Sut.UpdateAsync(collection, null, null); - - Assert.True(collection.LeasingEnabled); - Assert.Equal("""{"kind":"human_approval"}""", collection.LeasingPolicy); - } - private static SutProvider SetupSutProvider() { var sutProvider = new SutProvider() diff --git a/util/Migrator/DbScripts/2026-05-21_00_AddCollectionLeasing.sql b/util/Migrator/DbScripts/2026-05-21_00_AddLeasingPolicy.sql similarity index 78% rename from util/Migrator/DbScripts/2026-05-21_00_AddCollectionLeasing.sql rename to util/Migrator/DbScripts/2026-05-21_00_AddLeasingPolicy.sql index aaef2ef1fb5e..3cd67bde62aa 100644 --- a/util/Migrator/DbScripts/2026-05-21_00_AddCollectionLeasing.sql +++ b/util/Migrator/DbScripts/2026-05-21_00_AddLeasingPolicy.sql @@ -1,16 +1,60 @@ --- Add LeasingEnabled column to Collection table -IF COL_LENGTH('[dbo].[Collection]', 'LeasingEnabled') IS NULL +-- Create the LeasingPolicy table +IF OBJECT_ID('[dbo].[LeasingPolicy]') IS NULL BEGIN - ALTER TABLE [dbo].[Collection] - ADD [LeasingEnabled] BIT NOT NULL CONSTRAINT [DF_Collection_LeasingEnabled] DEFAULT (0); + CREATE TABLE [dbo].[LeasingPolicy] ( + [Id] UNIQUEIDENTIFIER NOT NULL, + [OrganizationId] UNIQUEIDENTIFIER NOT NULL, + [Name] NVARCHAR(256) NOT NULL, + [Description] NVARCHAR(MAX) NULL, + [Policy] NVARCHAR(MAX) NOT NULL, + [CreationDate] DATETIME2(7) NOT NULL, + [RevisionDate] DATETIME2(7) NOT NULL, + CONSTRAINT [PK_LeasingPolicy] PRIMARY KEY CLUSTERED ([Id] ASC), + CONSTRAINT [FK_LeasingPolicy_Organization] FOREIGN KEY ([OrganizationId]) + REFERENCES [dbo].[Organization] ([Id]) ON DELETE CASCADE + ); + + CREATE UNIQUE NONCLUSTERED INDEX [IX_LeasingPolicy_OrganizationId_Name] + ON [dbo].[LeasingPolicy] ([OrganizationId] ASC, [Name] ASC); +END +GO + +-- Drop the previous iteration's inline leasing columns from Collection (if present) +IF EXISTS ( + SELECT 1 FROM sys.default_constraints WHERE name = 'DF_Collection_LeasingEnabled' +) +BEGIN + ALTER TABLE [dbo].[Collection] DROP CONSTRAINT [DF_Collection_LeasingEnabled]; +END +GO + +IF COL_LENGTH('[dbo].[Collection]', 'LeasingEnabled') IS NOT NULL +BEGIN + ALTER TABLE [dbo].[Collection] DROP COLUMN [LeasingEnabled]; +END +GO + +IF COL_LENGTH('[dbo].[Collection]', 'LeasingPolicy') IS NOT NULL +BEGIN + ALTER TABLE [dbo].[Collection] DROP COLUMN [LeasingPolicy]; END GO --- Add LeasingPolicy column to Collection table -IF COL_LENGTH('[dbo].[Collection]', 'LeasingPolicy') IS NULL +-- Add LeasingPolicyId FK column to Collection +IF COL_LENGTH('[dbo].[Collection]', 'LeasingPolicyId') IS NULL BEGIN ALTER TABLE [dbo].[Collection] - ADD [LeasingPolicy] NVARCHAR(MAX) NULL; + ADD [LeasingPolicyId] UNIQUEIDENTIFIER NULL + CONSTRAINT [FK_Collection_LeasingPolicy] REFERENCES [dbo].[LeasingPolicy] ([Id]) ON DELETE SET NULL; +END +GO + +IF NOT EXISTS ( + SELECT 1 FROM sys.indexes WHERE name = 'IX_Collection_LeasingPolicyId' AND object_id = OBJECT_ID('[dbo].[Collection]') +) +BEGIN + CREATE NONCLUSTERED INDEX [IX_Collection_LeasingPolicyId] + ON [dbo].[Collection] ([LeasingPolicyId] ASC); END GO @@ -45,7 +89,103 @@ BEGIN END GO --- Update Collection_Create stored procedure to include leasing columns +-- LeasingPolicy CRUD stored procedures +CREATE OR ALTER PROCEDURE [dbo].[LeasingPolicy_Create] + @Id UNIQUEIDENTIFIER OUTPUT, + @OrganizationId UNIQUEIDENTIFIER, + @Name NVARCHAR(256), + @Description NVARCHAR(MAX) = NULL, + @Policy NVARCHAR(MAX), + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + INSERT INTO [dbo].[LeasingPolicy] + ( + [Id], + [OrganizationId], + [Name], + [Description], + [Policy], + [CreationDate], + [RevisionDate] + ) + VALUES + ( + @Id, + @OrganizationId, + @Name, + @Description, + @Policy, + @CreationDate, + @RevisionDate + ) +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[LeasingPolicy_Update] + @Id UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @Name NVARCHAR(256), + @Description NVARCHAR(MAX) = NULL, + @Policy NVARCHAR(MAX), + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + UPDATE + [dbo].[LeasingPolicy] + SET + [OrganizationId] = @OrganizationId, + [Name] = @Name, + [Description] = @Description, + [Policy] = @Policy, + [CreationDate] = @CreationDate, + [RevisionDate] = @RevisionDate + WHERE + [Id] = @Id +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[LeasingPolicy_DeleteById] + @Id UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + DELETE FROM [dbo].[LeasingPolicy] WHERE [Id] = @Id +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[LeasingPolicy_ReadById] + @Id UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT * + FROM [dbo].[LeasingPolicy] + WHERE [Id] = @Id +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[LeasingPolicy_ReadByOrganizationId] + @OrganizationId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT * + FROM [dbo].[LeasingPolicy] + WHERE [OrganizationId] = @OrganizationId +END +GO + +-- Update Collection_Create to accept LeasingPolicyId CREATE OR ALTER PROCEDURE [dbo].[Collection_Create] @Id UNIQUEIDENTIFIER OUTPUT, @OrganizationId UNIQUEIDENTIFIER, @@ -55,8 +195,7 @@ CREATE OR ALTER PROCEDURE [dbo].[Collection_Create] @RevisionDate DATETIME2(7), @DefaultUserCollectionEmail NVARCHAR(256) = NULL, @Type TINYINT = 0, - @LeasingEnabled BIT = 0, - @LeasingPolicy NVARCHAR(MAX) = NULL + @LeasingPolicyId UNIQUEIDENTIFIER = NULL AS BEGIN SET NOCOUNT ON @@ -71,8 +210,7 @@ BEGIN [RevisionDate], [DefaultUserCollectionEmail], [Type], - [LeasingEnabled], - [LeasingPolicy] + [LeasingPolicyId] ) VALUES ( @@ -84,15 +222,14 @@ BEGIN @RevisionDate, @DefaultUserCollectionEmail, @Type, - @LeasingEnabled, - @LeasingPolicy + @LeasingPolicyId ) EXEC [dbo].[User_BumpAccountRevisionDateByCollectionId] @Id, @OrganizationId END GO --- Update Collection_Update stored procedure to include leasing columns +-- Update Collection_Update to accept LeasingPolicyId CREATE OR ALTER PROCEDURE [dbo].[Collection_Update] @Id UNIQUEIDENTIFIER, @OrganizationId UNIQUEIDENTIFIER, @@ -102,8 +239,7 @@ CREATE OR ALTER PROCEDURE [dbo].[Collection_Update] @RevisionDate DATETIME2(7), @DefaultUserCollectionEmail NVARCHAR(256) = NULL, @Type TINYINT = 0, - @LeasingEnabled BIT = 0, - @LeasingPolicy NVARCHAR(MAX) = NULL + @LeasingPolicyId UNIQUEIDENTIFIER = NULL AS BEGIN SET NOCOUNT ON @@ -118,8 +254,7 @@ BEGIN [RevisionDate] = @RevisionDate, [DefaultUserCollectionEmail] = @DefaultUserCollectionEmail, [Type] = @Type, - [LeasingEnabled] = @LeasingEnabled, - [LeasingPolicy] = @LeasingPolicy + [LeasingPolicyId] = @LeasingPolicyId WHERE [Id] = @Id @@ -127,7 +262,7 @@ BEGIN END GO --- Update Collection_CreateWithGroupsAndUsers stored procedure to forward leasing columns +-- Update Collection_CreateWithGroupsAndUsers to forward LeasingPolicyId CREATE OR ALTER PROCEDURE [dbo].[Collection_CreateWithGroupsAndUsers] @Id UNIQUEIDENTIFIER, @OrganizationId UNIQUEIDENTIFIER, @@ -139,13 +274,12 @@ CREATE OR ALTER PROCEDURE [dbo].[Collection_CreateWithGroupsAndUsers] @Users AS [dbo].[CollectionAccessSelectionType] READONLY, @DefaultUserCollectionEmail NVARCHAR(256) = NULL, @Type TINYINT = 0, - @LeasingEnabled BIT = 0, - @LeasingPolicy NVARCHAR(MAX) = NULL + @LeasingPolicyId UNIQUEIDENTIFIER = NULL AS BEGIN SET NOCOUNT ON - EXEC [dbo].[Collection_Create] @Id, @OrganizationId, @Name, @ExternalId, @CreationDate, @RevisionDate, @DefaultUserCollectionEmail, @Type, @LeasingEnabled, @LeasingPolicy + EXEC [dbo].[Collection_Create] @Id, @OrganizationId, @Name, @ExternalId, @CreationDate, @RevisionDate, @DefaultUserCollectionEmail, @Type, @LeasingPolicyId -- Groups ;WITH [AvailableGroupsCTE] AS( @@ -207,7 +341,7 @@ BEGIN END GO --- Update Collection_UpdateWithGroupsAndUsers stored procedure to forward leasing columns +-- Update Collection_UpdateWithGroupsAndUsers to forward LeasingPolicyId CREATE OR ALTER PROCEDURE [dbo].[Collection_UpdateWithGroupsAndUsers] @Id UNIQUEIDENTIFIER, @OrganizationId UNIQUEIDENTIFIER, @@ -219,13 +353,12 @@ CREATE OR ALTER PROCEDURE [dbo].[Collection_UpdateWithGroupsAndUsers] @Users AS [dbo].[CollectionAccessSelectionType] READONLY, @DefaultUserCollectionEmail NVARCHAR(256) = NULL, @Type TINYINT = 0, - @LeasingEnabled BIT = 0, - @LeasingPolicy NVARCHAR(MAX) = NULL + @LeasingPolicyId UNIQUEIDENTIFIER = NULL AS BEGIN SET NOCOUNT ON - EXEC [dbo].[Collection_Update] @Id, @OrganizationId, @Name, @ExternalId, @CreationDate, @RevisionDate, @DefaultUserCollectionEmail, @Type, @LeasingEnabled, @LeasingPolicy + EXEC [dbo].[Collection_Update] @Id, @OrganizationId, @Name, @ExternalId, @CreationDate, @RevisionDate, @DefaultUserCollectionEmail, @Type, @LeasingPolicyId -- Bump RevisionDate on all affected groups (old + new) before modifying CollectionGroup ;WITH [AffectedGroupsCTE] AS ( @@ -341,7 +474,7 @@ BEGIN END GO --- Update Collection_UpdateWithGroups stored procedure to forward leasing columns +-- Update Collection_UpdateWithGroups to forward LeasingPolicyId CREATE OR ALTER PROCEDURE [dbo].[Collection_UpdateWithGroups] @Id UNIQUEIDENTIFIER, @OrganizationId UNIQUEIDENTIFIER, @@ -352,13 +485,12 @@ CREATE OR ALTER PROCEDURE [dbo].[Collection_UpdateWithGroups] @Groups AS [dbo].[CollectionAccessSelectionType] READONLY, @DefaultUserCollectionEmail NVARCHAR(256) = NULL, @Type TINYINT = 0, - @LeasingEnabled BIT = 0, - @LeasingPolicy NVARCHAR(MAX) = NULL + @LeasingPolicyId UNIQUEIDENTIFIER = NULL AS BEGIN SET NOCOUNT ON - EXEC [dbo].[Collection_Update] @Id, @OrganizationId, @Name, @ExternalId, @CreationDate, @RevisionDate, @DefaultUserCollectionEmail, @Type, @LeasingEnabled, @LeasingPolicy + EXEC [dbo].[Collection_Update] @Id, @OrganizationId, @Name, @ExternalId, @CreationDate, @RevisionDate, @DefaultUserCollectionEmail, @Type, @LeasingPolicyId -- Bump RevisionDate on all affected groups (old + new) before modifying CollectionGroup ;WITH [AffectedGroupsCTE] AS ( @@ -446,7 +578,7 @@ BEGIN END GO --- Update Collection_UpdateWithUsers stored procedure to forward leasing columns +-- Update Collection_UpdateWithUsers to forward LeasingPolicyId CREATE OR ALTER PROCEDURE [dbo].[Collection_UpdateWithUsers] @Id UNIQUEIDENTIFIER, @OrganizationId UNIQUEIDENTIFIER, @@ -457,13 +589,12 @@ CREATE OR ALTER PROCEDURE [dbo].[Collection_UpdateWithUsers] @Users AS [dbo].[CollectionAccessSelectionType] READONLY, @DefaultUserCollectionEmail NVARCHAR(256) = NULL, @Type TINYINT = 0, - @LeasingEnabled BIT = 0, - @LeasingPolicy NVARCHAR(MAX) = NULL + @LeasingPolicyId UNIQUEIDENTIFIER = NULL AS BEGIN SET NOCOUNT ON - EXEC [dbo].[Collection_Update] @Id, @OrganizationId, @Name, @ExternalId, @CreationDate, @RevisionDate, @DefaultUserCollectionEmail, @Type, @LeasingEnabled, @LeasingPolicy + EXEC [dbo].[Collection_Update] @Id, @OrganizationId, @Name, @ExternalId, @CreationDate, @RevisionDate, @DefaultUserCollectionEmail, @Type, @LeasingPolicyId -- Users -- Delete users that are no longer in source @@ -525,7 +656,7 @@ BEGIN END GO --- Update Collection_ReadByUserId stored procedure to project leasing columns +-- Update Collection_ReadByUserId to project LeasingPolicyId CREATE OR ALTER PROCEDURE [dbo].[Collection_ReadByUserId] @UserId UNIQUEIDENTIFIER AS @@ -544,8 +675,7 @@ BEGIN MAX([Manage]) AS [Manage], [DefaultUserCollectionEmail], [Type], - [LeasingEnabled], - [LeasingPolicy] + [LeasingPolicyId] FROM [dbo].[UserCollectionDetails](@UserId) GROUP BY @@ -557,12 +687,11 @@ BEGIN ExternalId, [DefaultUserCollectionEmail], [Type], - [LeasingEnabled], - [LeasingPolicy] + [LeasingPolicyId] END GO --- Update Collection_ReadByIdWithPermissions stored procedure to GROUP BY leasing columns +-- Update Collection_ReadByIdWithPermissions to GROUP BY LeasingPolicyId CREATE OR ALTER PROCEDURE [dbo].[Collection_ReadByIdWithPermissions] @CollectionId UNIQUEIDENTIFIER, @UserId UNIQUEIDENTIFIER, @@ -641,8 +770,7 @@ BEGIN C.[ExternalId], C.[DefaultUserCollectionEmail], C.[Type], - C.[LeasingEnabled], - C.[LeasingPolicy] + C.[LeasingPolicyId] IF (@IncludeAccessRelationships = 1) BEGIN @@ -652,7 +780,7 @@ BEGIN END GO --- Update Collection_ReadSharedCollectionsByOrganizationIdWithPermissions stored procedure to GROUP BY leasing columns +-- Update Collection_ReadSharedCollectionsByOrganizationIdWithPermissions to GROUP BY LeasingPolicyId CREATE OR ALTER PROCEDURE [dbo].[Collection_ReadSharedCollectionsByOrganizationIdWithPermissions] @OrganizationId UNIQUEIDENTIFIER, @UserId UNIQUEIDENTIFIER, @@ -732,8 +860,7 @@ BEGIN C.[ExternalId], C.[DefaultUserCollectionEmail], C.[Type], - C.[LeasingEnabled], - C.[LeasingPolicy] + C.[LeasingPolicyId] IF (@IncludeAccessRelationships = 1) BEGIN diff --git a/util/MySqlMigrations/Migrations/20260521121534_AddCollectionLeasing.cs b/util/MySqlMigrations/Migrations/20260521121534_AddCollectionLeasing.cs deleted file mode 100644 index 11054b69d261..000000000000 --- a/util/MySqlMigrations/Migrations/20260521121534_AddCollectionLeasing.cs +++ /dev/null @@ -1,39 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Bit.MySqlMigrations.Migrations; - -/// -public partial class AddCollectionLeasing : Migration -{ - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "LeasingEnabled", - table: "Collection", - type: "tinyint(1)", - nullable: false, - defaultValue: false); - - migrationBuilder.AddColumn( - name: "LeasingPolicy", - table: "Collection", - type: "longtext", - nullable: true) - .Annotation("MySql:CharSet", "utf8mb4"); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropColumn( - name: "LeasingEnabled", - table: "Collection"); - - migrationBuilder.DropColumn( - name: "LeasingPolicy", - table: "Collection"); - } -} diff --git a/util/MySqlMigrations/Migrations/20260521121534_AddCollectionLeasing.Designer.cs b/util/MySqlMigrations/Migrations/20260525112822_AddLeasingPolicy.Designer.cs similarity index 98% rename from util/MySqlMigrations/Migrations/20260521121534_AddCollectionLeasing.Designer.cs rename to util/MySqlMigrations/Migrations/20260525112822_AddLeasingPolicy.Designer.cs index dbc37bdbf127..9a88fb0c1c54 100644 --- a/util/MySqlMigrations/Migrations/20260521121534_AddCollectionLeasing.Designer.cs +++ b/util/MySqlMigrations/Migrations/20260525112822_AddLeasingPolicy.Designer.cs @@ -12,8 +12,8 @@ namespace Bit.MySqlMigrations.Migrations { [DbContext(typeof(DatabaseContext))] - [Migration("20260521121534_AddCollectionLeasing")] - partial class AddCollectionLeasing + [Migration("20260525112822_AddLeasingPolicy")] + partial class AddLeasingPolicy { /// protected override void BuildTargetModel(ModelBuilder modelBuilder) @@ -87,11 +87,8 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasMaxLength(300) .HasColumnType("varchar(300)"); - b.Property("LeasingEnabled") - .HasColumnType("tinyint(1)"); - - b.Property("LeasingPolicy") - .HasColumnType("longtext"); + b.Property("LeasingPolicyId") + .HasColumnType("char(36)"); b.Property("Name") .IsRequired() @@ -108,6 +105,8 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.HasKey("Id"); + b.HasIndex("LeasingPolicyId"); + b.HasIndex("OrganizationId"); b.ToTable("Collection", (string)null); @@ -2373,6 +2372,40 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("Installation", (string)null); }); + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Models.LeasingPolicy", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Description") + .HasColumnType("longtext"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Policy") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId", "Name") + .IsUnique(); + + b.ToTable("LeasingPolicy", (string)null); + }); + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy", b => { b.Property("Id") @@ -2883,6 +2916,11 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Collection", b => { + b.HasOne("Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Models.LeasingPolicy", null) + .WithMany() + .HasForeignKey("LeasingPolicyId") + .OnDelete(DeleteBehavior.SetNull); + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") .WithMany("Collections") .HasForeignKey("OrganizationId") @@ -3412,6 +3450,17 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Navigation("User"); }); + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Models.LeasingPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => { b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") diff --git a/util/MySqlMigrations/Migrations/20260525112822_AddLeasingPolicy.cs b/util/MySqlMigrations/Migrations/20260525112822_AddLeasingPolicy.cs new file mode 100644 index 000000000000..7d821f2d2077 --- /dev/null +++ b/util/MySqlMigrations/Migrations/20260525112822_AddLeasingPolicy.cs @@ -0,0 +1,107 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bit.MySqlMigrations.Migrations; + +/// +public partial class AddLeasingPolicy : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "LeasingEnabled", + table: "Collection"); + + migrationBuilder.DropColumn( + name: "LeasingPolicy", + table: "Collection"); + + migrationBuilder.AddColumn( + name: "LeasingPolicyId", + table: "Collection", + type: "char(36)", + nullable: true, + collation: "ascii_general_ci"); + + migrationBuilder.CreateTable( + name: "LeasingPolicy", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + OrganizationId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + Name = table.Column(type: "varchar(256)", maxLength: 256, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Description = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + Policy = table.Column(type: "longtext", nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + CreationDate = table.Column(type: "datetime(6)", nullable: false), + RevisionDate = table.Column(type: "datetime(6)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_LeasingPolicy", x => x.Id); + table.ForeignKey( + name: "FK_LeasingPolicy_Organization_OrganizationId", + column: x => x.OrganizationId, + principalTable: "Organization", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateIndex( + name: "IX_Collection_LeasingPolicyId", + table: "Collection", + column: "LeasingPolicyId"); + + migrationBuilder.CreateIndex( + name: "IX_LeasingPolicy_OrganizationId_Name", + table: "LeasingPolicy", + columns: new[] { "OrganizationId", "Name" }, + unique: true); + + migrationBuilder.AddForeignKey( + name: "FK_Collection_LeasingPolicy_LeasingPolicyId", + table: "Collection", + column: "LeasingPolicyId", + principalTable: "LeasingPolicy", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Collection_LeasingPolicy_LeasingPolicyId", + table: "Collection"); + + migrationBuilder.DropTable( + name: "LeasingPolicy"); + + migrationBuilder.DropIndex( + name: "IX_Collection_LeasingPolicyId", + table: "Collection"); + + migrationBuilder.DropColumn( + name: "LeasingPolicyId", + table: "Collection"); + + migrationBuilder.AddColumn( + name: "LeasingEnabled", + table: "Collection", + type: "tinyint(1)", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "LeasingPolicy", + table: "Collection", + type: "longtext", + nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"); + } +} diff --git a/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs b/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs index cb758830fb53..7d18ec3aabc5 100644 --- a/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs +++ b/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs @@ -84,11 +84,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(300) .HasColumnType("varchar(300)"); - b.Property("LeasingEnabled") - .HasColumnType("tinyint(1)"); - - b.Property("LeasingPolicy") - .HasColumnType("longtext"); + b.Property("LeasingPolicyId") + .HasColumnType("char(36)"); b.Property("Name") .IsRequired() @@ -105,6 +102,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); + b.HasIndex("LeasingPolicyId"); + b.HasIndex("OrganizationId"); b.ToTable("Collection", (string)null); @@ -2370,6 +2369,40 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Installation", (string)null); }); + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Models.LeasingPolicy", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Description") + .HasColumnType("longtext"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Policy") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId", "Name") + .IsUnique(); + + b.ToTable("LeasingPolicy", (string)null); + }); + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy", b => { b.Property("Id") @@ -2880,6 +2913,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Collection", b => { + b.HasOne("Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Models.LeasingPolicy", null) + .WithMany() + .HasForeignKey("LeasingPolicyId") + .OnDelete(DeleteBehavior.SetNull); + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") .WithMany("Collections") .HasForeignKey("OrganizationId") @@ -3409,6 +3447,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("User"); }); + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Models.LeasingPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => { b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") diff --git a/util/PostgresMigrations/Migrations/20260521121303_AddCollectionLeasing.cs b/util/PostgresMigrations/Migrations/20260521121303_AddCollectionLeasing.cs deleted file mode 100644 index 843c8bd91fcb..000000000000 --- a/util/PostgresMigrations/Migrations/20260521121303_AddCollectionLeasing.cs +++ /dev/null @@ -1,38 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Bit.PostgresMigrations.Migrations; - -/// -public partial class AddCollectionLeasing : Migration -{ - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "LeasingEnabled", - table: "Collection", - type: "boolean", - nullable: false, - defaultValue: false); - - migrationBuilder.AddColumn( - name: "LeasingPolicy", - table: "Collection", - type: "text", - nullable: true); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropColumn( - name: "LeasingEnabled", - table: "Collection"); - - migrationBuilder.DropColumn( - name: "LeasingPolicy", - table: "Collection"); - } -} diff --git a/util/PostgresMigrations/Migrations/20260521121303_AddCollectionLeasing.Designer.cs b/util/PostgresMigrations/Migrations/20260525092251_AddLeasingPolicy.Designer.cs similarity index 98% rename from util/PostgresMigrations/Migrations/20260521121303_AddCollectionLeasing.Designer.cs rename to util/PostgresMigrations/Migrations/20260525092251_AddLeasingPolicy.Designer.cs index 231116119a8e..78ad9e32e11a 100644 --- a/util/PostgresMigrations/Migrations/20260521121303_AddCollectionLeasing.Designer.cs +++ b/util/PostgresMigrations/Migrations/20260525092251_AddLeasingPolicy.Designer.cs @@ -12,8 +12,8 @@ namespace Bit.PostgresMigrations.Migrations { [DbContext(typeof(DatabaseContext))] - [Migration("20260521121303_AddCollectionLeasing")] - partial class AddCollectionLeasing + [Migration("20260525092251_AddLeasingPolicy")] + partial class AddLeasingPolicy { /// protected override void BuildTargetModel(ModelBuilder modelBuilder) @@ -88,11 +88,8 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasMaxLength(300) .HasColumnType("character varying(300)"); - b.Property("LeasingEnabled") - .HasColumnType("boolean"); - - b.Property("LeasingPolicy") - .HasColumnType("text"); + b.Property("LeasingPolicyId") + .HasColumnType("uuid"); b.Property("Name") .IsRequired() @@ -109,6 +106,8 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.HasKey("Id"); + b.HasIndex("LeasingPolicyId"); + b.HasIndex("OrganizationId"); b.ToTable("Collection", (string)null); @@ -2379,6 +2378,40 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("Installation", (string)null); }); + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Models.LeasingPolicy", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Policy") + .IsRequired() + .HasColumnType("text"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId", "Name") + .IsUnique(); + + b.ToTable("LeasingPolicy", (string)null); + }); + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy", b => { b.Property("Id") @@ -2889,6 +2922,11 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Collection", b => { + b.HasOne("Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Models.LeasingPolicy", null) + .WithMany() + .HasForeignKey("LeasingPolicyId") + .OnDelete(DeleteBehavior.SetNull); + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") .WithMany("Collections") .HasForeignKey("OrganizationId") @@ -3418,6 +3456,17 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Navigation("User"); }); + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Models.LeasingPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => { b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") diff --git a/util/PostgresMigrations/Migrations/20260525092251_AddLeasingPolicy.cs b/util/PostgresMigrations/Migrations/20260525092251_AddLeasingPolicy.cs new file mode 100644 index 000000000000..3a668d81c0ab --- /dev/null +++ b/util/PostgresMigrations/Migrations/20260525092251_AddLeasingPolicy.cs @@ -0,0 +1,101 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bit.PostgresMigrations.Migrations; + +/// +public partial class AddLeasingPolicy : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "LeasingEnabled", + table: "Collection"); + + migrationBuilder.DropColumn( + name: "LeasingPolicy", + table: "Collection"); + + migrationBuilder.AddColumn( + name: "LeasingPolicyId", + table: "Collection", + type: "uuid", + nullable: true); + + migrationBuilder.CreateTable( + name: "LeasingPolicy", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + OrganizationId = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + Description = table.Column(type: "text", nullable: true), + Policy = table.Column(type: "text", nullable: false), + CreationDate = table.Column(type: "timestamp with time zone", nullable: false), + RevisionDate = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_LeasingPolicy", x => x.Id); + table.ForeignKey( + name: "FK_LeasingPolicy_Organization_OrganizationId", + column: x => x.OrganizationId, + principalTable: "Organization", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Collection_LeasingPolicyId", + table: "Collection", + column: "LeasingPolicyId"); + + migrationBuilder.CreateIndex( + name: "IX_LeasingPolicy_OrganizationId_Name", + table: "LeasingPolicy", + columns: new[] { "OrganizationId", "Name" }, + unique: true); + + migrationBuilder.AddForeignKey( + name: "FK_Collection_LeasingPolicy_LeasingPolicyId", + table: "Collection", + column: "LeasingPolicyId", + principalTable: "LeasingPolicy", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Collection_LeasingPolicy_LeasingPolicyId", + table: "Collection"); + + migrationBuilder.DropTable( + name: "LeasingPolicy"); + + migrationBuilder.DropIndex( + name: "IX_Collection_LeasingPolicyId", + table: "Collection"); + + migrationBuilder.DropColumn( + name: "LeasingPolicyId", + table: "Collection"); + + migrationBuilder.AddColumn( + name: "LeasingEnabled", + table: "Collection", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "LeasingPolicy", + table: "Collection", + type: "text", + nullable: true); + } +} diff --git a/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs b/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs index a09573c68cda..6500a2d9bdb6 100644 --- a/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs +++ b/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs @@ -85,11 +85,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(300) .HasColumnType("character varying(300)"); - b.Property("LeasingEnabled") - .HasColumnType("boolean"); - - b.Property("LeasingPolicy") - .HasColumnType("text"); + b.Property("LeasingPolicyId") + .HasColumnType("uuid"); b.Property("Name") .IsRequired() @@ -106,6 +103,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); + b.HasIndex("LeasingPolicyId"); + b.HasIndex("OrganizationId"); b.ToTable("Collection", (string)null); @@ -2376,6 +2375,40 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Installation", (string)null); }); + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Models.LeasingPolicy", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Policy") + .IsRequired() + .HasColumnType("text"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId", "Name") + .IsUnique(); + + b.ToTable("LeasingPolicy", (string)null); + }); + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy", b => { b.Property("Id") @@ -2886,6 +2919,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Collection", b => { + b.HasOne("Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Models.LeasingPolicy", null) + .WithMany() + .HasForeignKey("LeasingPolicyId") + .OnDelete(DeleteBehavior.SetNull); + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") .WithMany("Collections") .HasForeignKey("OrganizationId") @@ -3415,6 +3453,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("User"); }); + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Models.LeasingPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => { b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") diff --git a/util/SqliteMigrations/Migrations/20260521121259_AddCollectionLeasing.cs b/util/SqliteMigrations/Migrations/20260521121259_AddCollectionLeasing.cs deleted file mode 100644 index 08787dbb6060..000000000000 --- a/util/SqliteMigrations/Migrations/20260521121259_AddCollectionLeasing.cs +++ /dev/null @@ -1,38 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Bit.SqliteMigrations.Migrations; - -/// -public partial class AddCollectionLeasing : Migration -{ - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "LeasingEnabled", - table: "Collection", - type: "INTEGER", - nullable: false, - defaultValue: false); - - migrationBuilder.AddColumn( - name: "LeasingPolicy", - table: "Collection", - type: "TEXT", - nullable: true); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropColumn( - name: "LeasingEnabled", - table: "Collection"); - - migrationBuilder.DropColumn( - name: "LeasingPolicy", - table: "Collection"); - } -} diff --git a/util/SqliteMigrations/Migrations/20260521121259_AddCollectionLeasing.Designer.cs b/util/SqliteMigrations/Migrations/20260525092256_AddLeasingPolicy.Designer.cs similarity index 98% rename from util/SqliteMigrations/Migrations/20260521121259_AddCollectionLeasing.Designer.cs rename to util/SqliteMigrations/Migrations/20260525092256_AddLeasingPolicy.Designer.cs index 22835758b09b..95596cf2f1f3 100644 --- a/util/SqliteMigrations/Migrations/20260521121259_AddCollectionLeasing.Designer.cs +++ b/util/SqliteMigrations/Migrations/20260525092256_AddLeasingPolicy.Designer.cs @@ -11,8 +11,8 @@ namespace Bit.SqliteMigrations.Migrations { [DbContext(typeof(DatabaseContext))] - [Migration("20260521121259_AddCollectionLeasing")] - partial class AddCollectionLeasing + [Migration("20260525092256_AddLeasingPolicy")] + partial class AddLeasingPolicy { /// protected override void BuildTargetModel(ModelBuilder modelBuilder) @@ -82,10 +82,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasMaxLength(300) .HasColumnType("TEXT"); - b.Property("LeasingEnabled") - .HasColumnType("INTEGER"); - - b.Property("LeasingPolicy") + b.Property("LeasingPolicyId") .HasColumnType("TEXT"); b.Property("Name") @@ -103,6 +100,8 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.HasKey("Id"); + b.HasIndex("LeasingPolicyId"); + b.HasIndex("OrganizationId"); b.ToTable("Collection", (string)null); @@ -2362,6 +2361,40 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("Installation", (string)null); }); + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Models.LeasingPolicy", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Policy") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId", "Name") + .IsUnique(); + + b.ToTable("LeasingPolicy", (string)null); + }); + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy", b => { b.Property("Id") @@ -2872,6 +2905,11 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Collection", b => { + b.HasOne("Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Models.LeasingPolicy", null) + .WithMany() + .HasForeignKey("LeasingPolicyId") + .OnDelete(DeleteBehavior.SetNull); + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") .WithMany("Collections") .HasForeignKey("OrganizationId") @@ -3401,6 +3439,17 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Navigation("User"); }); + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Models.LeasingPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => { b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") diff --git a/util/SqliteMigrations/Migrations/20260525092256_AddLeasingPolicy.cs b/util/SqliteMigrations/Migrations/20260525092256_AddLeasingPolicy.cs new file mode 100644 index 000000000000..beb31baefc50 --- /dev/null +++ b/util/SqliteMigrations/Migrations/20260525092256_AddLeasingPolicy.cs @@ -0,0 +1,91 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bit.SqliteMigrations.Migrations; + +/// +public partial class AddLeasingPolicy : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "LeasingEnabled", + table: "Collection"); + + migrationBuilder.RenameColumn( + name: "LeasingPolicy", + table: "Collection", + newName: "LeasingPolicyId"); + + migrationBuilder.CreateTable( + name: "LeasingPolicy", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + OrganizationId = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", maxLength: 256, nullable: false), + Description = table.Column(type: "TEXT", nullable: true), + Policy = table.Column(type: "TEXT", nullable: false), + CreationDate = table.Column(type: "TEXT", nullable: false), + RevisionDate = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_LeasingPolicy", x => x.Id); + table.ForeignKey( + name: "FK_LeasingPolicy_Organization_OrganizationId", + column: x => x.OrganizationId, + principalTable: "Organization", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Collection_LeasingPolicyId", + table: "Collection", + column: "LeasingPolicyId"); + + migrationBuilder.CreateIndex( + name: "IX_LeasingPolicy_OrganizationId_Name", + table: "LeasingPolicy", + columns: new[] { "OrganizationId", "Name" }, + unique: true); + + migrationBuilder.AddForeignKey( + name: "FK_Collection_LeasingPolicy_LeasingPolicyId", + table: "Collection", + column: "LeasingPolicyId", + principalTable: "LeasingPolicy", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Collection_LeasingPolicy_LeasingPolicyId", + table: "Collection"); + + migrationBuilder.DropTable( + name: "LeasingPolicy"); + + migrationBuilder.DropIndex( + name: "IX_Collection_LeasingPolicyId", + table: "Collection"); + + migrationBuilder.RenameColumn( + name: "LeasingPolicyId", + table: "Collection", + newName: "LeasingPolicy"); + + migrationBuilder.AddColumn( + name: "LeasingEnabled", + table: "Collection", + type: "INTEGER", + nullable: false, + defaultValue: false); + } +} diff --git a/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs b/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs index d09c89d05272..fd6e284d34b5 100644 --- a/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs +++ b/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs @@ -79,10 +79,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(300) .HasColumnType("TEXT"); - b.Property("LeasingEnabled") - .HasColumnType("INTEGER"); - - b.Property("LeasingPolicy") + b.Property("LeasingPolicyId") .HasColumnType("TEXT"); b.Property("Name") @@ -100,6 +97,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); + b.HasIndex("LeasingPolicyId"); + b.HasIndex("OrganizationId"); b.ToTable("Collection", (string)null); @@ -2359,6 +2358,40 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Installation", (string)null); }); + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Models.LeasingPolicy", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Policy") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId", "Name") + .IsUnique(); + + b.ToTable("LeasingPolicy", (string)null); + }); + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy", b => { b.Property("Id") @@ -2869,6 +2902,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Collection", b => { + b.HasOne("Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Models.LeasingPolicy", null) + .WithMany() + .HasForeignKey("LeasingPolicyId") + .OnDelete(DeleteBehavior.SetNull); + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") .WithMany("Collections") .HasForeignKey("OrganizationId") @@ -3398,6 +3436,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("User"); }); + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Models.LeasingPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => { b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") From b71f8d6838028301eca4e4d53ace87f31e4e32e7 Mon Sep 17 00:00:00 2001 From: Hinton Date: Mon, 25 May 2026 16:53:08 +0200 Subject: [PATCH 03/54] Add LeasingPolicy CRUD endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the HTTP surface for managing reusable PAM leasing policies — the backing schema, entities, and migrations from the prior commit are now reachable via: - GET /organizations/{orgId}/leasing-policies (org member) - GET /organizations/{orgId}/leasing-policies/{id} (org member) - POST /organizations/{orgId}/leasing-policies (Owner/Admin) - PUT /organizations/{orgId}/leasing-policies/{id} (Owner/Admin) - DELETE /organizations/{orgId}/leasing-policies/{id} (Owner/Admin) All endpoints are gated by [RequireFeature(FeatureFlagKeys.Pam)] and return 404 on auth failure (matches PoliciesController convention so resource existence isn't leaked). - Repository: ILeasingPolicyRepository extends IRepository; base picks up the LeasingPolicy_Create/_Update/_ReadById/_DeleteById sprocs from the migration, custom GetManyByOrganizationIdAsync added for both Dapper and EF. - Commands: CreateLeasingPolicyCommand and UpdateLeasingPolicyCommand validate the JSON via ILeasingPolicyValidator, set/refresh CreationDate/RevisionDate, and check name uniqueness within the org. DeleteLeasingPolicyCommand performs the cross-org check then calls DeleteAsync (FK ON DELETE SET NULL handles detaching referencing collections at the DB level). - API: LeasingPoliciesController plus request/response models under src/Api/PrivilegedAccessManagement/. Request accepts `policy` as structured JSON; response surfaces it as a parsed JsonElement. - DI: AddLeasingPolicyCommands() in OrganizationServiceCollectionExtensions registers the validator (singleton) and three commands (scoped). The repository is registered alongside other singletons in the Dapper and EF service-collection extensions. - Tests: 11 new command tests (4 Create / 4 Update / 3 Delete) covering happy paths, cross-org NotFound, validator failures, and duplicate names. --- .../Controllers/LeasingPoliciesController.cs | 106 ++++++++++++++++++ .../Request/LeasingPolicyRequestModel.cs | 32 ++++++ .../Response/LeasingPolicyResponseModel.cs | 46 ++++++++ ...OrganizationServiceCollectionExtensions.cs | 12 ++ .../Commands/CreateLeasingPolicyCommand.cs | 50 +++++++++ .../Commands/DeleteLeasingPolicyCommand.cs | 26 +++++ .../Interfaces/ICreateLeasingPolicyCommand.cs | 8 ++ .../Interfaces/IDeleteLeasingPolicyCommand.cs | 6 + .../Interfaces/IUpdateLeasingPolicyCommand.cs | 8 ++ .../Commands/UpdateLeasingPolicyCommand.cs | 58 ++++++++++ .../Repositories/ILeasingPolicyRepository.cs | 9 ++ .../DapperServiceCollectionExtensions.cs | 2 + .../Repositories/LeasingPolicyRepository.cs | 33 ++++++ ...ityFrameworkServiceCollectionExtensions.cs | 2 + .../Repositories/LeasingPolicyRepository.cs | 29 +++++ .../CreateLeasingPolicyCommandTests.cs | 96 ++++++++++++++++ .../DeleteLeasingPolicyCommandTests.cs | 53 +++++++++ .../UpdateLeasingPolicyCommandTests.cs | 99 ++++++++++++++++ 18 files changed, 675 insertions(+) create mode 100644 src/Api/PrivilegedAccessManagement/Controllers/LeasingPoliciesController.cs create mode 100644 src/Api/PrivilegedAccessManagement/Models/Request/LeasingPolicyRequestModel.cs create mode 100644 src/Api/PrivilegedAccessManagement/Models/Response/LeasingPolicyResponseModel.cs create mode 100644 src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/CreateLeasingPolicyCommand.cs create mode 100644 src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/DeleteLeasingPolicyCommand.cs create mode 100644 src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/Interfaces/ICreateLeasingPolicyCommand.cs create mode 100644 src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/Interfaces/IDeleteLeasingPolicyCommand.cs create mode 100644 src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/Interfaces/IUpdateLeasingPolicyCommand.cs create mode 100644 src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/UpdateLeasingPolicyCommand.cs create mode 100644 src/Core/PrivilegedAccessManagement/Repositories/ILeasingPolicyRepository.cs create mode 100644 src/Infrastructure.Dapper/PrivilegedAccessManagement/Repositories/LeasingPolicyRepository.cs create mode 100644 src/Infrastructure.EntityFramework/PrivilegedAccessManagement/Repositories/LeasingPolicyRepository.cs create mode 100644 test/Core.Test/PrivilegedAccessManagement/Commands/CreateLeasingPolicyCommandTests.cs create mode 100644 test/Core.Test/PrivilegedAccessManagement/Commands/DeleteLeasingPolicyCommandTests.cs create mode 100644 test/Core.Test/PrivilegedAccessManagement/Commands/UpdateLeasingPolicyCommandTests.cs diff --git a/src/Api/PrivilegedAccessManagement/Controllers/LeasingPoliciesController.cs b/src/Api/PrivilegedAccessManagement/Controllers/LeasingPoliciesController.cs new file mode 100644 index 000000000000..e1db2c860f78 --- /dev/null +++ b/src/Api/PrivilegedAccessManagement/Controllers/LeasingPoliciesController.cs @@ -0,0 +1,106 @@ +using Bit.Api.Models.Response; +using Bit.Api.PrivilegedAccessManagement.Models.Request; +using Bit.Api.PrivilegedAccessManagement.Models.Response; +using Bit.Core; +using Bit.Core.Context; +using Bit.Core.Exceptions; +using Bit.Core.PrivilegedAccessManagement.OrganizationFeatures.Commands.Interfaces; +using Bit.Core.PrivilegedAccessManagement.Repositories; +using Bit.Core.Utilities; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Bit.Api.PrivilegedAccessManagement.Controllers; + +[Route("organizations/{orgId:guid}/leasing-policies")] +[Authorize("Application")] +[RequireFeature(FeatureFlagKeys.Pam)] +public class LeasingPoliciesController : Controller +{ + private readonly ICurrentContext _currentContext; + private readonly ILeasingPolicyRepository _repository; + private readonly ICreateLeasingPolicyCommand _createCommand; + private readonly IUpdateLeasingPolicyCommand _updateCommand; + private readonly IDeleteLeasingPolicyCommand _deleteCommand; + + public LeasingPoliciesController( + ICurrentContext currentContext, + ILeasingPolicyRepository repository, + ICreateLeasingPolicyCommand createCommand, + IUpdateLeasingPolicyCommand updateCommand, + IDeleteLeasingPolicyCommand deleteCommand) + { + _currentContext = currentContext; + _repository = repository; + _createCommand = createCommand; + _updateCommand = updateCommand; + _deleteCommand = deleteCommand; + } + + [HttpGet("")] + public async Task> GetAll(Guid orgId) + { + await EnsureMemberAsync(orgId); + + var policies = await _repository.GetManyByOrganizationIdAsync(orgId); + return new ListResponseModel( + policies.Select(p => new LeasingPolicyResponseModel(p))); + } + + [HttpGet("{id:guid}")] + public async Task Get(Guid orgId, Guid id) + { + await EnsureMemberAsync(orgId); + + var policy = await _repository.GetByIdAsync(id); + if (policy is null || policy.OrganizationId != orgId) + { + throw new NotFoundException(); + } + + return new LeasingPolicyResponseModel(policy); + } + + [HttpPost("")] + public async Task Post(Guid orgId, [FromBody] LeasingPolicyRequestModel model) + { + await EnsureAdminAsync(orgId); + + var policy = await _createCommand.CreateAsync(model.ToLeasingPolicy(orgId)); + return new LeasingPolicyResponseModel(policy); + } + + [HttpPut("{id:guid}")] + public async Task Put(Guid orgId, Guid id, [FromBody] LeasingPolicyRequestModel model) + { + await EnsureAdminAsync(orgId); + + var policy = await _updateCommand.UpdateAsync(orgId, id, model.ToLeasingPolicy(orgId)); + return new LeasingPolicyResponseModel(policy); + } + + [HttpDelete("{id:guid}")] + public async Task Delete(Guid orgId, Guid id) + { + await EnsureAdminAsync(orgId); + + await _deleteCommand.DeleteAsync(orgId, id); + return NoContent(); + } + + private async Task EnsureMemberAsync(Guid orgId) + { + if (!await _currentContext.OrganizationUser(orgId)) + { + throw new NotFoundException(); + } + } + + private async Task EnsureAdminAsync(Guid orgId) + { + if (!await _currentContext.OrganizationAdmin(orgId) && !await _currentContext.OrganizationOwner(orgId)) + { + throw new NotFoundException(); + } + } +} diff --git a/src/Api/PrivilegedAccessManagement/Models/Request/LeasingPolicyRequestModel.cs b/src/Api/PrivilegedAccessManagement/Models/Request/LeasingPolicyRequestModel.cs new file mode 100644 index 000000000000..1eedac0e0ddc --- /dev/null +++ b/src/Api/PrivilegedAccessManagement/Models/Request/LeasingPolicyRequestModel.cs @@ -0,0 +1,32 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json; +using Bit.Core.PrivilegedAccessManagement.Entities; + +namespace Bit.Api.PrivilegedAccessManagement.Models.Request; + +public class LeasingPolicyRequestModel +{ + [Required] + [StringLength(256)] + public string Name { get; set; } = null!; + + public string? Description { get; set; } + + [Required] + public object Policy { get; set; } = null!; + + public LeasingPolicy ToLeasingPolicy(Guid organizationId) => new() + { + OrganizationId = organizationId, + Name = Name, + Description = Description, + Policy = SerializePolicy(Policy), + }; + + private static string SerializePolicy(object policy) => policy switch + { + JsonElement je when je.ValueKind == JsonValueKind.Null => string.Empty, + JsonElement je => je.GetRawText(), + _ => JsonSerializer.Serialize(policy), + }; +} diff --git a/src/Api/PrivilegedAccessManagement/Models/Response/LeasingPolicyResponseModel.cs b/src/Api/PrivilegedAccessManagement/Models/Response/LeasingPolicyResponseModel.cs new file mode 100644 index 000000000000..cb22431cc7f3 --- /dev/null +++ b/src/Api/PrivilegedAccessManagement/Models/Response/LeasingPolicyResponseModel.cs @@ -0,0 +1,46 @@ +using System.Text.Json; +using Bit.Core.Models.Api; +using Bit.Core.PrivilegedAccessManagement.Entities; + +namespace Bit.Api.PrivilegedAccessManagement.Models.Response; + +public class LeasingPolicyResponseModel : ResponseModel +{ + public LeasingPolicyResponseModel(LeasingPolicy policy) + : base("leasingPolicy") + { + ArgumentNullException.ThrowIfNull(policy); + + Id = policy.Id; + OrganizationId = policy.OrganizationId; + Name = policy.Name; + Description = policy.Description; + Policy = TryParsePolicy(policy.Policy); + CreationDate = policy.CreationDate; + RevisionDate = policy.RevisionDate; + } + + public Guid Id { get; } + public Guid OrganizationId { get; } + public string Name { get; } + public string? Description { get; } + public JsonElement? Policy { get; } + public DateTime CreationDate { get; } + public DateTime RevisionDate { get; } + + private static JsonElement? TryParsePolicy(string? policyJson) + { + if (string.IsNullOrEmpty(policyJson)) + { + return null; + } + try + { + return JsonDocument.Parse(policyJson).RootElement; + } + catch (JsonException) + { + return null; + } + } +} diff --git a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs index b2ccbadb1236..273e9b17c72e 100644 --- a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs +++ b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs @@ -40,6 +40,9 @@ using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; using Bit.Core.OrganizationFeatures.OrganizationUsers; using Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.PrivilegedAccessManagement.OrganizationFeatures.Commands; +using Bit.Core.PrivilegedAccessManagement.OrganizationFeatures.Commands.Interfaces; +using Bit.Core.PrivilegedAccessManagement.Services; using Bit.Core.Services; using Bit.Core.Settings; using Bit.Core.Tokens; @@ -67,6 +70,7 @@ public static void AddOrganizationServices(this IServiceCollection services, IGl services.AddOrganizationSponsorshipCommands(globalSettings); services.AddOrganizationApiKeyCommandsQueries(); services.AddOrganizationCollectionCommands(); + services.AddLeasingPolicyCommands(); services.AddOrganizationGroupCommands(); services.AddOrganizationInviteLinkCommandsQueries(); services.AddOrganizationDomainCommandsQueries(); @@ -186,6 +190,14 @@ public static void AddOrganizationCollectionCommands(this IServiceCollection ser services.AddScoped(); } + public static void AddLeasingPolicyCommands(this IServiceCollection services) + { + services.AddSingleton(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + } + private static void AddOrganizationGroupCommands(this IServiceCollection services) { services.AddScoped(); diff --git a/src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/CreateLeasingPolicyCommand.cs b/src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/CreateLeasingPolicyCommand.cs new file mode 100644 index 000000000000..5e133f29641f --- /dev/null +++ b/src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/CreateLeasingPolicyCommand.cs @@ -0,0 +1,50 @@ +using Bit.Core.Exceptions; +using Bit.Core.PrivilegedAccessManagement.Entities; +using Bit.Core.PrivilegedAccessManagement.OrganizationFeatures.Commands.Interfaces; +using Bit.Core.PrivilegedAccessManagement.Repositories; +using Bit.Core.PrivilegedAccessManagement.Services; + +namespace Bit.Core.PrivilegedAccessManagement.OrganizationFeatures.Commands; + +public class CreateLeasingPolicyCommand : ICreateLeasingPolicyCommand +{ + private readonly ILeasingPolicyRepository _repository; + private readonly ILeasingPolicyValidator _validator; + private readonly TimeProvider _timeProvider; + + public CreateLeasingPolicyCommand( + ILeasingPolicyRepository repository, + ILeasingPolicyValidator validator, + TimeProvider timeProvider) + { + _repository = repository; + _validator = validator; + _timeProvider = timeProvider; + } + + public async Task CreateAsync(LeasingPolicy policy) + { + if (string.IsNullOrWhiteSpace(policy.Name)) + { + throw new BadRequestException("Name is required."); + } + + var validation = _validator.Validate(policy.Policy); + if (!validation.IsValid) + { + throw new BadRequestException(validation.Error!); + } + + var existing = await _repository.GetManyByOrganizationIdAsync(policy.OrganizationId); + if (existing.Any(p => string.Equals(p.Name, policy.Name, StringComparison.OrdinalIgnoreCase))) + { + throw new BadRequestException("A policy with that name already exists."); + } + + var now = _timeProvider.GetUtcNow().UtcDateTime; + policy.CreationDate = now; + policy.RevisionDate = now; + + return await _repository.CreateAsync(policy); + } +} diff --git a/src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/DeleteLeasingPolicyCommand.cs b/src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/DeleteLeasingPolicyCommand.cs new file mode 100644 index 000000000000..98cc72130221 --- /dev/null +++ b/src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/DeleteLeasingPolicyCommand.cs @@ -0,0 +1,26 @@ +using Bit.Core.Exceptions; +using Bit.Core.PrivilegedAccessManagement.OrganizationFeatures.Commands.Interfaces; +using Bit.Core.PrivilegedAccessManagement.Repositories; + +namespace Bit.Core.PrivilegedAccessManagement.OrganizationFeatures.Commands; + +public class DeleteLeasingPolicyCommand : IDeleteLeasingPolicyCommand +{ + private readonly ILeasingPolicyRepository _repository; + + public DeleteLeasingPolicyCommand(ILeasingPolicyRepository repository) + { + _repository = repository; + } + + public async Task DeleteAsync(Guid organizationId, Guid id) + { + var existing = await _repository.GetByIdAsync(id); + if (existing is null || existing.OrganizationId != organizationId) + { + throw new NotFoundException(); + } + + await _repository.DeleteAsync(existing); + } +} diff --git a/src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/Interfaces/ICreateLeasingPolicyCommand.cs b/src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/Interfaces/ICreateLeasingPolicyCommand.cs new file mode 100644 index 000000000000..52d8b9ffe8cf --- /dev/null +++ b/src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/Interfaces/ICreateLeasingPolicyCommand.cs @@ -0,0 +1,8 @@ +using Bit.Core.PrivilegedAccessManagement.Entities; + +namespace Bit.Core.PrivilegedAccessManagement.OrganizationFeatures.Commands.Interfaces; + +public interface ICreateLeasingPolicyCommand +{ + Task CreateAsync(LeasingPolicy policy); +} diff --git a/src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/Interfaces/IDeleteLeasingPolicyCommand.cs b/src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/Interfaces/IDeleteLeasingPolicyCommand.cs new file mode 100644 index 000000000000..01fdb37769a8 --- /dev/null +++ b/src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/Interfaces/IDeleteLeasingPolicyCommand.cs @@ -0,0 +1,6 @@ +namespace Bit.Core.PrivilegedAccessManagement.OrganizationFeatures.Commands.Interfaces; + +public interface IDeleteLeasingPolicyCommand +{ + Task DeleteAsync(Guid organizationId, Guid id); +} diff --git a/src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/Interfaces/IUpdateLeasingPolicyCommand.cs b/src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/Interfaces/IUpdateLeasingPolicyCommand.cs new file mode 100644 index 000000000000..5df4d28daca1 --- /dev/null +++ b/src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/Interfaces/IUpdateLeasingPolicyCommand.cs @@ -0,0 +1,8 @@ +using Bit.Core.PrivilegedAccessManagement.Entities; + +namespace Bit.Core.PrivilegedAccessManagement.OrganizationFeatures.Commands.Interfaces; + +public interface IUpdateLeasingPolicyCommand +{ + Task UpdateAsync(Guid organizationId, Guid id, LeasingPolicy update); +} diff --git a/src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/UpdateLeasingPolicyCommand.cs b/src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/UpdateLeasingPolicyCommand.cs new file mode 100644 index 000000000000..440511c32b3e --- /dev/null +++ b/src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/UpdateLeasingPolicyCommand.cs @@ -0,0 +1,58 @@ +using Bit.Core.Exceptions; +using Bit.Core.PrivilegedAccessManagement.Entities; +using Bit.Core.PrivilegedAccessManagement.OrganizationFeatures.Commands.Interfaces; +using Bit.Core.PrivilegedAccessManagement.Repositories; +using Bit.Core.PrivilegedAccessManagement.Services; + +namespace Bit.Core.PrivilegedAccessManagement.OrganizationFeatures.Commands; + +public class UpdateLeasingPolicyCommand : IUpdateLeasingPolicyCommand +{ + private readonly ILeasingPolicyRepository _repository; + private readonly ILeasingPolicyValidator _validator; + private readonly TimeProvider _timeProvider; + + public UpdateLeasingPolicyCommand( + ILeasingPolicyRepository repository, + ILeasingPolicyValidator validator, + TimeProvider timeProvider) + { + _repository = repository; + _validator = validator; + _timeProvider = timeProvider; + } + + public async Task UpdateAsync(Guid organizationId, Guid id, LeasingPolicy update) + { + if (string.IsNullOrWhiteSpace(update.Name)) + { + throw new BadRequestException("Name is required."); + } + + var existing = await _repository.GetByIdAsync(id); + if (existing is null || existing.OrganizationId != organizationId) + { + throw new NotFoundException(); + } + + var validation = _validator.Validate(update.Policy); + if (!validation.IsValid) + { + throw new BadRequestException(validation.Error!); + } + + var siblings = await _repository.GetManyByOrganizationIdAsync(organizationId); + if (siblings.Any(p => p.Id != id && string.Equals(p.Name, update.Name, StringComparison.OrdinalIgnoreCase))) + { + throw new BadRequestException("A policy with that name already exists."); + } + + existing.Name = update.Name; + existing.Description = update.Description; + existing.Policy = update.Policy; + existing.RevisionDate = _timeProvider.GetUtcNow().UtcDateTime; + + await _repository.ReplaceAsync(existing); + return existing; + } +} diff --git a/src/Core/PrivilegedAccessManagement/Repositories/ILeasingPolicyRepository.cs b/src/Core/PrivilegedAccessManagement/Repositories/ILeasingPolicyRepository.cs new file mode 100644 index 000000000000..83c55a945dcf --- /dev/null +++ b/src/Core/PrivilegedAccessManagement/Repositories/ILeasingPolicyRepository.cs @@ -0,0 +1,9 @@ +using Bit.Core.PrivilegedAccessManagement.Entities; +using Bit.Core.Repositories; + +namespace Bit.Core.PrivilegedAccessManagement.Repositories; + +public interface ILeasingPolicyRepository : IRepository +{ + Task> GetManyByOrganizationIdAsync(Guid organizationId); +} diff --git a/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs b/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs index d31970869163..744fb39f8f1b 100644 --- a/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs +++ b/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs @@ -9,6 +9,7 @@ using Bit.Core.KeyManagement.Repositories; using Bit.Core.NotificationCenter.Repositories; using Bit.Core.Platform.Installations; +using Bit.Core.PrivilegedAccessManagement.Repositories; using Bit.Core.Repositories; using Bit.Core.SecretsManager.Repositories; using Bit.Core.Tools.Repositories; @@ -54,6 +55,7 @@ public static void AddDapperRepositories(this IServiceCollection services, bool services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Infrastructure.Dapper/PrivilegedAccessManagement/Repositories/LeasingPolicyRepository.cs b/src/Infrastructure.Dapper/PrivilegedAccessManagement/Repositories/LeasingPolicyRepository.cs new file mode 100644 index 000000000000..7b671a963284 --- /dev/null +++ b/src/Infrastructure.Dapper/PrivilegedAccessManagement/Repositories/LeasingPolicyRepository.cs @@ -0,0 +1,33 @@ +using System.Data; +using Bit.Core.PrivilegedAccessManagement.Entities; +using Bit.Core.PrivilegedAccessManagement.Repositories; +using Bit.Core.Settings; +using Bit.Infrastructure.Dapper.Repositories; +using Dapper; +using Microsoft.Data.SqlClient; + +#nullable enable + +namespace Bit.Infrastructure.Dapper.PrivilegedAccessManagement.Repositories; + +public class LeasingPolicyRepository : Repository, ILeasingPolicyRepository +{ + public LeasingPolicyRepository(GlobalSettings globalSettings) + : this(globalSettings.SqlServer.ConnectionString, globalSettings.SqlServer.ReadOnlyConnectionString) + { } + + public LeasingPolicyRepository(string connectionString, string readOnlyConnectionString) + : base(connectionString, readOnlyConnectionString) + { } + + public async Task> GetManyByOrganizationIdAsync(Guid organizationId) + { + using var connection = new SqlConnection(ConnectionString); + var results = await connection.QueryAsync( + $"[{Schema}].[LeasingPolicy_ReadByOrganizationId]", + new { OrganizationId = organizationId }, + commandType: CommandType.StoredProcedure); + + return results.ToList(); + } +} diff --git a/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs b/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs index cd0d1feb4dfd..4dcd0a2c1c3b 100644 --- a/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs +++ b/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs @@ -10,6 +10,7 @@ using Bit.Core.KeyManagement.Repositories; using Bit.Core.NotificationCenter.Repositories; using Bit.Core.Platform.Installations; +using Bit.Core.PrivilegedAccessManagement.Repositories; using Bit.Core.Repositories; using Bit.Core.SecretsManager.Repositories; using Bit.Core.Tools.Repositories; @@ -102,6 +103,7 @@ public static void AddPasswordManagerEFRepositories(this IServiceCollection serv services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Infrastructure.EntityFramework/PrivilegedAccessManagement/Repositories/LeasingPolicyRepository.cs b/src/Infrastructure.EntityFramework/PrivilegedAccessManagement/Repositories/LeasingPolicyRepository.cs new file mode 100644 index 000000000000..00ff2d29c844 --- /dev/null +++ b/src/Infrastructure.EntityFramework/PrivilegedAccessManagement/Repositories/LeasingPolicyRepository.cs @@ -0,0 +1,29 @@ +using AutoMapper; +using Bit.Core.PrivilegedAccessManagement.Repositories; +using Bit.Infrastructure.EntityFramework.Repositories; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using CoreEntity = Bit.Core.PrivilegedAccessManagement.Entities.LeasingPolicy; +using EfModel = Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Models.LeasingPolicy; + +#nullable enable + +namespace Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Repositories; + +public class LeasingPolicyRepository : Repository, ILeasingPolicyRepository +{ + public LeasingPolicyRepository(IServiceScopeFactory serviceScopeFactory, IMapper mapper) + : base(serviceScopeFactory, mapper, context => context.LeasingPolicies) + { } + + public async Task> GetManyByOrganizationIdAsync(Guid organizationId) + { + using var scope = ServiceScopeFactory.CreateScope(); + var dbContext = GetDatabaseContext(scope); + var policies = await dbContext.LeasingPolicies + .Where(p => p.OrganizationId == organizationId) + .AsNoTracking() + .ToListAsync(); + return Mapper.Map>(policies); + } +} diff --git a/test/Core.Test/PrivilegedAccessManagement/Commands/CreateLeasingPolicyCommandTests.cs b/test/Core.Test/PrivilegedAccessManagement/Commands/CreateLeasingPolicyCommandTests.cs new file mode 100644 index 000000000000..1ae5b5d2a331 --- /dev/null +++ b/test/Core.Test/PrivilegedAccessManagement/Commands/CreateLeasingPolicyCommandTests.cs @@ -0,0 +1,96 @@ +using Bit.Core.Exceptions; +using Bit.Core.PrivilegedAccessManagement.Entities; +using Bit.Core.PrivilegedAccessManagement.OrganizationFeatures.Commands; +using Bit.Core.PrivilegedAccessManagement.Repositories; +using Bit.Core.PrivilegedAccessManagement.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.Extensions.Time.Testing; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.PrivilegedAccessManagement.Commands; + +[SutProviderCustomize] +public class CreateLeasingPolicyCommandTests +{ + private static readonly DateTime _now = new(2026, 5, 21, 12, 0, 0, DateTimeKind.Utc); + + [Theory, BitAutoData] + public async Task CreateAsync_HappyPath_PersistsWithTimestampsAndValidates(LeasingPolicy policy) + { + var sutProvider = SetupSutProvider(); + policy.Name = "VPN + business hours"; + policy.Policy = """{"kind":"human_approval"}"""; + sutProvider.GetDependency() + .Validate(policy.Policy) + .Returns(LeasingPolicyValidationResult.Valid); + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(policy.OrganizationId) + .Returns(new List()); + sutProvider.GetDependency() + .CreateAsync(policy) + .Returns(policy); + + var result = await sutProvider.Sut.CreateAsync(policy); + + Assert.Equal(_now, result.CreationDate); + Assert.Equal(_now, result.RevisionDate); + await sutProvider.GetDependency().Received(1).CreateAsync(policy); + } + + [Theory, BitAutoData] + public async Task CreateAsync_EmptyName_ThrowsBadRequest(LeasingPolicy policy) + { + var sutProvider = SetupSutProvider(); + policy.Name = " "; + + var ex = await Assert.ThrowsAsync(() => sutProvider.Sut.CreateAsync(policy)); + Assert.Contains("Name is required", ex.Message); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().CreateAsync(default!); + } + + [Theory, BitAutoData] + public async Task CreateAsync_InvalidPolicy_ThrowsBadRequest(LeasingPolicy policy) + { + var sutProvider = SetupSutProvider(); + policy.Name = "test"; + policy.Policy = """{"kind":"bogus"}"""; + sutProvider.GetDependency() + .Validate(policy.Policy) + .Returns(LeasingPolicyValidationResult.Invalid("Unsupported policy kind")); + + var ex = await Assert.ThrowsAsync(() => sutProvider.Sut.CreateAsync(policy)); + Assert.Equal("Unsupported policy kind", ex.Message); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().CreateAsync(default!); + } + + [Theory, BitAutoData] + public async Task CreateAsync_DuplicateName_ThrowsBadRequest(LeasingPolicy policy, LeasingPolicy existing) + { + var sutProvider = SetupSutProvider(); + policy.Name = "duplicate"; + policy.Policy = """{"kind":"human_approval"}"""; + existing.OrganizationId = policy.OrganizationId; + existing.Name = "Duplicate"; // case-insensitive collision + sutProvider.GetDependency() + .Validate(policy.Policy) + .Returns(LeasingPolicyValidationResult.Valid); + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(policy.OrganizationId) + .Returns(new List { existing }); + + var ex = await Assert.ThrowsAsync(() => sutProvider.Sut.CreateAsync(policy)); + Assert.Contains("already exists", ex.Message); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().CreateAsync(default!); + } + + private static SutProvider SetupSutProvider() + { + var sutProvider = new SutProvider() + .WithFakeTimeProvider() + .Create(); + sutProvider.GetDependency().SetUtcNow(_now); + return sutProvider; + } +} diff --git a/test/Core.Test/PrivilegedAccessManagement/Commands/DeleteLeasingPolicyCommandTests.cs b/test/Core.Test/PrivilegedAccessManagement/Commands/DeleteLeasingPolicyCommandTests.cs new file mode 100644 index 000000000000..341c3120f6b9 --- /dev/null +++ b/test/Core.Test/PrivilegedAccessManagement/Commands/DeleteLeasingPolicyCommandTests.cs @@ -0,0 +1,53 @@ +using Bit.Core.Exceptions; +using Bit.Core.PrivilegedAccessManagement.Entities; +using Bit.Core.PrivilegedAccessManagement.OrganizationFeatures.Commands; +using Bit.Core.PrivilegedAccessManagement.Repositories; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.PrivilegedAccessManagement.Commands; + +[SutProviderCustomize] +public class DeleteLeasingPolicyCommandTests +{ + [Theory, BitAutoData] + public async Task DeleteAsync_HappyPath_Deletes( + LeasingPolicy existing, SutProvider sutProvider) + { + sutProvider.GetDependency() + .GetByIdAsync(existing.Id) + .Returns(existing); + + await sutProvider.Sut.DeleteAsync(existing.OrganizationId, existing.Id); + + await sutProvider.GetDependency().Received(1).DeleteAsync(existing); + } + + [Theory, BitAutoData] + public async Task DeleteAsync_MissingExisting_ThrowsNotFound( + SutProvider sutProvider) + { + sutProvider.GetDependency() + .GetByIdAsync(Arg.Any()) + .Returns((LeasingPolicy?)null); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.DeleteAsync(Guid.NewGuid(), Guid.NewGuid())); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().DeleteAsync(default!); + } + + [Theory, BitAutoData] + public async Task DeleteAsync_WrongOrg_ThrowsNotFound( + LeasingPolicy existing, SutProvider sutProvider) + { + sutProvider.GetDependency() + .GetByIdAsync(existing.Id) + .Returns(existing); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.DeleteAsync(Guid.NewGuid(), existing.Id)); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().DeleteAsync(default!); + } +} diff --git a/test/Core.Test/PrivilegedAccessManagement/Commands/UpdateLeasingPolicyCommandTests.cs b/test/Core.Test/PrivilegedAccessManagement/Commands/UpdateLeasingPolicyCommandTests.cs new file mode 100644 index 000000000000..34e56ea9adfb --- /dev/null +++ b/test/Core.Test/PrivilegedAccessManagement/Commands/UpdateLeasingPolicyCommandTests.cs @@ -0,0 +1,99 @@ +using Bit.Core.Exceptions; +using Bit.Core.PrivilegedAccessManagement.Entities; +using Bit.Core.PrivilegedAccessManagement.OrganizationFeatures.Commands; +using Bit.Core.PrivilegedAccessManagement.Repositories; +using Bit.Core.PrivilegedAccessManagement.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.Extensions.Time.Testing; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.PrivilegedAccessManagement.Commands; + +[SutProviderCustomize] +public class UpdateLeasingPolicyCommandTests +{ + private static readonly DateTime _now = new(2026, 5, 21, 12, 0, 0, DateTimeKind.Utc); + + [Theory, BitAutoData] + public async Task UpdateAsync_HappyPath_UpdatesFieldsAndBumpsRevision(LeasingPolicy existing, LeasingPolicy update) + { + var sutProvider = SetupSutProvider(); + var orgId = existing.OrganizationId; + update.Name = "renamed"; + update.Description = "new description"; + update.Policy = """{"kind":"human_approval"}"""; + sutProvider.GetDependency() + .GetByIdAsync(existing.Id) + .Returns(existing); + sutProvider.GetDependency() + .Validate(update.Policy) + .Returns(LeasingPolicyValidationResult.Valid); + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(orgId) + .Returns(new List { existing }); + + var result = await sutProvider.Sut.UpdateAsync(orgId, existing.Id, update); + + Assert.Equal("renamed", result.Name); + Assert.Equal("new description", result.Description); + Assert.Equal(update.Policy, result.Policy); + Assert.Equal(_now, result.RevisionDate); + await sutProvider.GetDependency().Received(1).ReplaceAsync(existing); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_MissingExisting_ThrowsNotFound(LeasingPolicy update) + { + var sutProvider = SetupSutProvider(); + sutProvider.GetDependency() + .GetByIdAsync(Arg.Any()) + .Returns((LeasingPolicy?)null); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.UpdateAsync(Guid.NewGuid(), Guid.NewGuid(), update)); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_WrongOrg_ThrowsNotFound(LeasingPolicy existing, LeasingPolicy update) + { + var sutProvider = SetupSutProvider(); + var differentOrg = Guid.NewGuid(); + sutProvider.GetDependency() + .GetByIdAsync(existing.Id) + .Returns(existing); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.UpdateAsync(differentOrg, existing.Id, update)); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_InvalidPolicy_ThrowsBadRequest(LeasingPolicy existing, LeasingPolicy update) + { + var sutProvider = SetupSutProvider(); + var orgId = existing.OrganizationId; + update.Name = "ok"; + update.Policy = """{"kind":"bogus"}"""; + sutProvider.GetDependency() + .GetByIdAsync(existing.Id) + .Returns(existing); + sutProvider.GetDependency() + .Validate(update.Policy) + .Returns(LeasingPolicyValidationResult.Invalid("nope")); + + var ex = await Assert.ThrowsAsync( + () => sutProvider.Sut.UpdateAsync(orgId, existing.Id, update)); + Assert.Equal("nope", ex.Message); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().ReplaceAsync(default!); + } + + private static SutProvider SetupSutProvider() + { + var sutProvider = new SutProvider() + .WithFakeTimeProvider() + .Create(); + sutProvider.GetDependency().SetUtcNow(_now); + return sutProvider; + } +} From 1c79bbdde5fd37c215ce6a15f87af8c4a8aa56ad Mon Sep 17 00:00:00 2001 From: Hinton Date: Tue, 26 May 2026 11:36:33 +0200 Subject: [PATCH 04/54] Fix migrations --- .../Repositories/DatabaseContext.cs | 2 +- .../DbScripts/2026-05-21_00_AddLeasingPolicy.sql | 2 +- ....cs => 20260526092815_AddLeasingPolicy.Designer.cs} | 4 ++-- ...ingPolicy.cs => 20260526092815_AddLeasingPolicy.cs} | 10 +++++----- .../Migrations/DatabaseContextModelSnapshot.cs | 2 +- ....cs => 20260526092811_AddLeasingPolicy.Designer.cs} | 4 ++-- ...ingPolicy.cs => 20260526092811_AddLeasingPolicy.cs} | 2 +- .../Migrations/DatabaseContextModelSnapshot.cs | 2 +- ....cs => 20260526092819_AddLeasingPolicy.Designer.cs} | 4 ++-- ...ingPolicy.cs => 20260526092819_AddLeasingPolicy.cs} | 2 +- .../Migrations/DatabaseContextModelSnapshot.cs | 2 +- 11 files changed, 18 insertions(+), 18 deletions(-) rename util/MySqlMigrations/Migrations/{20260525112822_AddLeasingPolicy.Designer.cs => 20260526092815_AddLeasingPolicy.Designer.cs} (99%) rename util/MySqlMigrations/Migrations/{20260525112822_AddLeasingPolicy.cs => 20260526092815_AddLeasingPolicy.cs} (95%) rename util/PostgresMigrations/Migrations/{20260525092251_AddLeasingPolicy.Designer.cs => 20260526092811_AddLeasingPolicy.Designer.cs} (99%) rename util/PostgresMigrations/Migrations/{20260525092251_AddLeasingPolicy.cs => 20260526092811_AddLeasingPolicy.cs} (98%) rename util/SqliteMigrations/Migrations/{20260525092256_AddLeasingPolicy.Designer.cs => 20260526092819_AddLeasingPolicy.Designer.cs} (99%) rename util/SqliteMigrations/Migrations/{20260525092256_AddLeasingPolicy.cs => 20260526092819_AddLeasingPolicy.cs} (98%) diff --git a/src/Infrastructure.EntityFramework/Repositories/DatabaseContext.cs b/src/Infrastructure.EntityFramework/Repositories/DatabaseContext.cs index e9ae207da2fa..a20a6447a1c8 100644 --- a/src/Infrastructure.EntityFramework/Repositories/DatabaseContext.cs +++ b/src/Infrastructure.EntityFramework/Repositories/DatabaseContext.cs @@ -155,7 +155,7 @@ protected override void OnModelCreating(ModelBuilder builder) .HasOne() .WithMany() .HasForeignKey(c => c.LeasingPolicyId) - .OnDelete(DeleteBehavior.SetNull); + .OnDelete(DeleteBehavior.Restrict); eOrganizationMemberBaseDetail.HasNoKey(); diff --git a/util/Migrator/DbScripts/2026-05-21_00_AddLeasingPolicy.sql b/util/Migrator/DbScripts/2026-05-21_00_AddLeasingPolicy.sql index 3cd67bde62aa..6f8de3fecd82 100644 --- a/util/Migrator/DbScripts/2026-05-21_00_AddLeasingPolicy.sql +++ b/util/Migrator/DbScripts/2026-05-21_00_AddLeasingPolicy.sql @@ -45,7 +45,7 @@ IF COL_LENGTH('[dbo].[Collection]', 'LeasingPolicyId') IS NULL BEGIN ALTER TABLE [dbo].[Collection] ADD [LeasingPolicyId] UNIQUEIDENTIFIER NULL - CONSTRAINT [FK_Collection_LeasingPolicy] REFERENCES [dbo].[LeasingPolicy] ([Id]) ON DELETE SET NULL; + CONSTRAINT [FK_Collection_LeasingPolicy] REFERENCES [dbo].[LeasingPolicy] ([Id]) ON DELETE NO ACTION; END GO diff --git a/util/MySqlMigrations/Migrations/20260525112822_AddLeasingPolicy.Designer.cs b/util/MySqlMigrations/Migrations/20260526092815_AddLeasingPolicy.Designer.cs similarity index 99% rename from util/MySqlMigrations/Migrations/20260525112822_AddLeasingPolicy.Designer.cs rename to util/MySqlMigrations/Migrations/20260526092815_AddLeasingPolicy.Designer.cs index 9a88fb0c1c54..5db56b688b9e 100644 --- a/util/MySqlMigrations/Migrations/20260525112822_AddLeasingPolicy.Designer.cs +++ b/util/MySqlMigrations/Migrations/20260526092815_AddLeasingPolicy.Designer.cs @@ -12,7 +12,7 @@ namespace Bit.MySqlMigrations.Migrations { [DbContext(typeof(DatabaseContext))] - [Migration("20260525112822_AddLeasingPolicy")] + [Migration("20260526092815_AddLeasingPolicy")] partial class AddLeasingPolicy { /// @@ -2919,7 +2919,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.HasOne("Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Models.LeasingPolicy", null) .WithMany() .HasForeignKey("LeasingPolicyId") - .OnDelete(DeleteBehavior.SetNull); + .OnDelete(DeleteBehavior.Restrict); b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") .WithMany("Collections") diff --git a/util/MySqlMigrations/Migrations/20260525112822_AddLeasingPolicy.cs b/util/MySqlMigrations/Migrations/20260526092815_AddLeasingPolicy.cs similarity index 95% rename from util/MySqlMigrations/Migrations/20260525112822_AddLeasingPolicy.cs rename to util/MySqlMigrations/Migrations/20260526092815_AddLeasingPolicy.cs index 7d821f2d2077..4769ba12dbca 100644 --- a/util/MySqlMigrations/Migrations/20260525112822_AddLeasingPolicy.cs +++ b/util/MySqlMigrations/Migrations/20260526092815_AddLeasingPolicy.cs @@ -69,7 +69,7 @@ protected override void Up(MigrationBuilder migrationBuilder) column: "LeasingPolicyId", principalTable: "LeasingPolicy", principalColumn: "Id", - onDelete: ReferentialAction.SetNull); + onDelete: ReferentialAction.Restrict); } /// @@ -98,10 +98,10 @@ protected override void Down(MigrationBuilder migrationBuilder) defaultValue: false); migrationBuilder.AddColumn( - name: "LeasingPolicy", - table: "Collection", - type: "longtext", - nullable: true) + name: "LeasingPolicy", + table: "Collection", + type: "longtext", + nullable: true) .Annotation("MySql:CharSet", "utf8mb4"); } } diff --git a/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs b/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs index 7d18ec3aabc5..4ae4f087092b 100644 --- a/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs +++ b/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs @@ -2916,7 +2916,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasOne("Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Models.LeasingPolicy", null) .WithMany() .HasForeignKey("LeasingPolicyId") - .OnDelete(DeleteBehavior.SetNull); + .OnDelete(DeleteBehavior.Restrict); b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") .WithMany("Collections") diff --git a/util/PostgresMigrations/Migrations/20260525092251_AddLeasingPolicy.Designer.cs b/util/PostgresMigrations/Migrations/20260526092811_AddLeasingPolicy.Designer.cs similarity index 99% rename from util/PostgresMigrations/Migrations/20260525092251_AddLeasingPolicy.Designer.cs rename to util/PostgresMigrations/Migrations/20260526092811_AddLeasingPolicy.Designer.cs index 78ad9e32e11a..efe4727d5b45 100644 --- a/util/PostgresMigrations/Migrations/20260525092251_AddLeasingPolicy.Designer.cs +++ b/util/PostgresMigrations/Migrations/20260526092811_AddLeasingPolicy.Designer.cs @@ -12,7 +12,7 @@ namespace Bit.PostgresMigrations.Migrations { [DbContext(typeof(DatabaseContext))] - [Migration("20260525092251_AddLeasingPolicy")] + [Migration("20260526092811_AddLeasingPolicy")] partial class AddLeasingPolicy { /// @@ -2925,7 +2925,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.HasOne("Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Models.LeasingPolicy", null) .WithMany() .HasForeignKey("LeasingPolicyId") - .OnDelete(DeleteBehavior.SetNull); + .OnDelete(DeleteBehavior.Restrict); b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") .WithMany("Collections") diff --git a/util/PostgresMigrations/Migrations/20260525092251_AddLeasingPolicy.cs b/util/PostgresMigrations/Migrations/20260526092811_AddLeasingPolicy.cs similarity index 98% rename from util/PostgresMigrations/Migrations/20260525092251_AddLeasingPolicy.cs rename to util/PostgresMigrations/Migrations/20260526092811_AddLeasingPolicy.cs index 3a668d81c0ab..5103add32de4 100644 --- a/util/PostgresMigrations/Migrations/20260525092251_AddLeasingPolicy.cs +++ b/util/PostgresMigrations/Migrations/20260526092811_AddLeasingPolicy.cs @@ -64,7 +64,7 @@ protected override void Up(MigrationBuilder migrationBuilder) column: "LeasingPolicyId", principalTable: "LeasingPolicy", principalColumn: "Id", - onDelete: ReferentialAction.SetNull); + onDelete: ReferentialAction.Restrict); } /// diff --git a/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs b/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs index 6500a2d9bdb6..c7c0dab1479f 100644 --- a/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs +++ b/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs @@ -2922,7 +2922,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasOne("Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Models.LeasingPolicy", null) .WithMany() .HasForeignKey("LeasingPolicyId") - .OnDelete(DeleteBehavior.SetNull); + .OnDelete(DeleteBehavior.Restrict); b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") .WithMany("Collections") diff --git a/util/SqliteMigrations/Migrations/20260525092256_AddLeasingPolicy.Designer.cs b/util/SqliteMigrations/Migrations/20260526092819_AddLeasingPolicy.Designer.cs similarity index 99% rename from util/SqliteMigrations/Migrations/20260525092256_AddLeasingPolicy.Designer.cs rename to util/SqliteMigrations/Migrations/20260526092819_AddLeasingPolicy.Designer.cs index 95596cf2f1f3..68745ba26d7e 100644 --- a/util/SqliteMigrations/Migrations/20260525092256_AddLeasingPolicy.Designer.cs +++ b/util/SqliteMigrations/Migrations/20260526092819_AddLeasingPolicy.Designer.cs @@ -11,7 +11,7 @@ namespace Bit.SqliteMigrations.Migrations { [DbContext(typeof(DatabaseContext))] - [Migration("20260525092256_AddLeasingPolicy")] + [Migration("20260526092819_AddLeasingPolicy")] partial class AddLeasingPolicy { /// @@ -2908,7 +2908,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.HasOne("Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Models.LeasingPolicy", null) .WithMany() .HasForeignKey("LeasingPolicyId") - .OnDelete(DeleteBehavior.SetNull); + .OnDelete(DeleteBehavior.Restrict); b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") .WithMany("Collections") diff --git a/util/SqliteMigrations/Migrations/20260525092256_AddLeasingPolicy.cs b/util/SqliteMigrations/Migrations/20260526092819_AddLeasingPolicy.cs similarity index 98% rename from util/SqliteMigrations/Migrations/20260525092256_AddLeasingPolicy.cs rename to util/SqliteMigrations/Migrations/20260526092819_AddLeasingPolicy.cs index beb31baefc50..1ef923d498fb 100644 --- a/util/SqliteMigrations/Migrations/20260525092256_AddLeasingPolicy.cs +++ b/util/SqliteMigrations/Migrations/20260526092819_AddLeasingPolicy.cs @@ -59,7 +59,7 @@ protected override void Up(MigrationBuilder migrationBuilder) column: "LeasingPolicyId", principalTable: "LeasingPolicy", principalColumn: "Id", - onDelete: ReferentialAction.SetNull); + onDelete: ReferentialAction.Restrict); } /// diff --git a/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs b/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs index fd6e284d34b5..31a6568e4129 100644 --- a/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs +++ b/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs @@ -2905,7 +2905,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasOne("Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Models.LeasingPolicy", null) .WithMany() .HasForeignKey("LeasingPolicyId") - .OnDelete(DeleteBehavior.SetNull); + .OnDelete(DeleteBehavior.Restrict); b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") .WithMany("Collections") From 68f7f1fea063312614eeaf3f4eed1811be952554 Mon Sep 17 00:00:00 2001 From: Hinton Date: Tue, 26 May 2026 14:31:05 +0200 Subject: [PATCH 05/54] Rename LeasingPolicy to AccessRule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renames the PAM leasing-policy concept to access rule across the codebase so the persisted entity and the JSON DSL have distinct names: - Entity LeasingPolicy → AccessRule (table, Collection.AccessRuleId FK, repos, commands, controller, request/response models). Controller route is now /organizations/{orgId}/access-rules. - JSON DSL base LeasingPolicy → Rule, subclasses *Policy → *Rule, namespace Models/Policies → Models/Rules. - Validator LeasingPolicyValidator → AccessRuleValidator and validates the AccessRule.Rule column (renamed from Policy). - DB column [Policy] → [Rule] on AccessRule; sproc params @Policy → @Rule. - MSSQL migration renamed to 2026-05-21_00_AddAccessRule.sql; EF migrations regenerated as AddAccessRule. The legacy LeasingEnabled/LeasingPolicy inline-column cleanup is preserved for dev DBs that ran the earlier iteration. --- .../Models/Request/CollectionRequestModel.cs | 6 +- .../Response/CollectionResponseModel.cs | 4 +- .../Controllers/AccessRulesController.cs | 92 ++++++++++ .../Controllers/LeasingPoliciesController.cs | 106 ----------- ...uestModel.cs => AccessRuleRequestModel.cs} | 12 +- .../Response/AccessRuleResponseModel.cs | 46 +++++ .../Response/LeasingPolicyResponseModel.cs | 46 ----- src/Core/AdminConsole/Entities/Collection.cs | 4 +- ...OrganizationServiceCollectionExtensions.cs | 12 +- .../{LeasingPolicy.cs => AccessRule.cs} | 8 +- .../Models/Policies/AllOfPolicy.cs | 9 - .../Models/Policies/HumanApprovalPolicy.cs | 6 - .../Models/Policies/LeasingPolicy.cs | 14 -- .../Models/Rules/AllOfRule.cs | 9 + .../Models/Rules/HumanApprovalRule.cs | 6 + .../IpAllowlistRule.cs} | 4 +- .../Models/Rules/Rule.cs | 14 ++ .../TimeOfDayRule.cs} | 4 +- ...yCommand.cs => CreateAccessRuleCommand.cs} | 30 ++-- ...yCommand.cs => DeleteAccessRuleCommand.cs} | 6 +- ...Command.cs => ICreateAccessRuleCommand.cs} | 4 +- ...Command.cs => IDeleteAccessRuleCommand.cs} | 2 +- ...Command.cs => IUpdateAccessRuleCommand.cs} | 4 +- ...yCommand.cs => UpdateAccessRuleCommand.cs} | 20 +-- .../Repositories/IAccessRuleRepository.cs | 9 + .../Repositories/ILeasingPolicyRepository.cs | 9 - .../Services/AccessRuleValidator.cs | 167 ++++++++++++++++++ .../Services/IAccessRuleValidator.cs | 16 ++ .../Services/ILeasingPolicyValidator.cs | 16 -- .../Services/LeasingPolicyValidator.cs | 167 ------------------ .../Repositories/CollectionRepository.cs | 6 +- .../DapperServiceCollectionExtensions.cs | 2 +- ...yRepository.cs => AccessRuleRepository.cs} | 12 +- ...ityFrameworkServiceCollectionExtensions.cs | 2 +- .../{LeasingPolicy.cs => AccessRule.cs} | 8 +- ...yRepository.cs => AccessRuleRepository.cs} | 14 +- .../Repositories/DatabaseContext.cs | 14 +- .../Stored Procedures/Collection_Create.sql | 6 +- .../Collection_CreateWithGroupsAndUsers.sql | 4 +- .../Collection_ReadByIdWithPermissions.sql | 2 +- .../Collection_ReadByUserId.sql | 4 +- ...ectionsByOrganizationIdWithPermissions.sql | 2 +- .../Stored Procedures/Collection_Update.sql | 4 +- .../Collection_UpdateWithGroups.sql | 4 +- .../Collection_UpdateWithGroupsAndUsers.sql | 4 +- .../Collection_UpdateWithUsers.sql | 4 +- ...olicy_Create.sql => AccessRule_Create.sql} | 10 +- .../AccessRule_DeleteById.sql | 8 + ...y_ReadById.sql => AccessRule_ReadById.sql} | 4 +- ...ql => AccessRule_ReadByOrganizationId.sql} | 4 +- ...olicy_Update.sql => AccessRule_Update.sql} | 8 +- .../LeasingPolicy_DeleteById.sql | 8 - .../{LeasingPolicy.sql => AccessRule.sql} | 12 +- src/Sql/dbo/Tables/Collection.sql | 8 +- .../Commands/CreateAccessRuleCommandTests.cs | 96 ++++++++++ .../CreateLeasingPolicyCommandTests.cs | 96 ---------- ...sts.cs => DeleteAccessRuleCommandTests.cs} | 22 +-- ...sts.cs => UpdateAccessRuleCommandTests.cs} | 50 +++--- ...orTests.cs => AccessRuleValidatorTests.cs} | 40 ++--- ...cy.sql => 2026-05-21_00_AddAccessRule.sql} | 114 ++++++------ ... 20260526122321_AddAccessRule.Designer.cs} | 30 ++-- ...icy.cs => 20260526122321_AddAccessRule.cs} | 56 ++---- .../DatabaseContextModelSnapshot.cs | 26 +-- ... 20260526122317_AddAccessRule.Designer.cs} | 30 ++-- ...icy.cs => 20260526122317_AddAccessRule.cs} | 55 ++---- .../DatabaseContextModelSnapshot.cs | 26 +-- ... 20260526122325_AddAccessRule.Designer.cs} | 28 +-- ...icy.cs => 20260526122325_AddAccessRule.cs} | 55 +++--- .../DatabaseContextModelSnapshot.cs | 24 +-- 69 files changed, 843 insertions(+), 911 deletions(-) create mode 100644 src/Api/PrivilegedAccessManagement/Controllers/AccessRulesController.cs delete mode 100644 src/Api/PrivilegedAccessManagement/Controllers/LeasingPoliciesController.cs rename src/Api/PrivilegedAccessManagement/Models/Request/{LeasingPolicyRequestModel.cs => AccessRuleRequestModel.cs} (64%) create mode 100644 src/Api/PrivilegedAccessManagement/Models/Response/AccessRuleResponseModel.cs delete mode 100644 src/Api/PrivilegedAccessManagement/Models/Response/LeasingPolicyResponseModel.cs rename src/Core/PrivilegedAccessManagement/Entities/{LeasingPolicy.cs => AccessRule.cs} (69%) delete mode 100644 src/Core/PrivilegedAccessManagement/Models/Policies/AllOfPolicy.cs delete mode 100644 src/Core/PrivilegedAccessManagement/Models/Policies/HumanApprovalPolicy.cs delete mode 100644 src/Core/PrivilegedAccessManagement/Models/Policies/LeasingPolicy.cs create mode 100644 src/Core/PrivilegedAccessManagement/Models/Rules/AllOfRule.cs create mode 100644 src/Core/PrivilegedAccessManagement/Models/Rules/HumanApprovalRule.cs rename src/Core/PrivilegedAccessManagement/Models/{Policies/IpAllowlistPolicy.cs => Rules/IpAllowlistRule.cs} (60%) create mode 100644 src/Core/PrivilegedAccessManagement/Models/Rules/Rule.cs rename src/Core/PrivilegedAccessManagement/Models/{Policies/TimeOfDayPolicy.cs => Rules/TimeOfDayRule.cs} (80%) rename src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/{CreateLeasingPolicyCommand.cs => CreateAccessRuleCommand.cs} (52%) rename src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/{DeleteLeasingPolicyCommand.cs => DeleteAccessRuleCommand.cs} (75%) rename src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/Interfaces/{ICreateLeasingPolicyCommand.cs => ICreateAccessRuleCommand.cs} (58%) rename src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/Interfaces/{IDeleteLeasingPolicyCommand.cs => IDeleteAccessRuleCommand.cs} (76%) rename src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/Interfaces/{IUpdateLeasingPolicyCommand.cs => IUpdateAccessRuleCommand.cs} (52%) rename src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/{UpdateLeasingPolicyCommand.cs => UpdateAccessRuleCommand.cs} (71%) create mode 100644 src/Core/PrivilegedAccessManagement/Repositories/IAccessRuleRepository.cs delete mode 100644 src/Core/PrivilegedAccessManagement/Repositories/ILeasingPolicyRepository.cs create mode 100644 src/Core/PrivilegedAccessManagement/Services/AccessRuleValidator.cs create mode 100644 src/Core/PrivilegedAccessManagement/Services/IAccessRuleValidator.cs delete mode 100644 src/Core/PrivilegedAccessManagement/Services/ILeasingPolicyValidator.cs delete mode 100644 src/Core/PrivilegedAccessManagement/Services/LeasingPolicyValidator.cs rename src/Infrastructure.Dapper/PrivilegedAccessManagement/Repositories/{LeasingPolicyRepository.cs => AccessRuleRepository.cs} (60%) rename src/Infrastructure.EntityFramework/PrivilegedAccessManagement/Models/{LeasingPolicy.cs => AccessRule.cs} (54%) rename src/Infrastructure.EntityFramework/PrivilegedAccessManagement/Repositories/{LeasingPolicyRepository.cs => AccessRuleRepository.cs} (64%) rename src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/{LeasingPolicy_Create.sql => AccessRule_Create.sql} (77%) create mode 100644 src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/AccessRule_DeleteById.sql rename src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/{LeasingPolicy_ReadById.sql => AccessRule_ReadById.sql} (53%) rename src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/{LeasingPolicy_ReadByOrganizationId.sql => AccessRule_ReadByOrganizationId.sql} (58%) rename src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/{LeasingPolicy_Update.sql => AccessRule_Update.sql} (77%) delete mode 100644 src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/LeasingPolicy_DeleteById.sql rename src/Sql/dbo/PrivilegedAccessManagement/Tables/{LeasingPolicy.sql => AccessRule.sql} (51%) create mode 100644 test/Core.Test/PrivilegedAccessManagement/Commands/CreateAccessRuleCommandTests.cs delete mode 100644 test/Core.Test/PrivilegedAccessManagement/Commands/CreateLeasingPolicyCommandTests.cs rename test/Core.Test/PrivilegedAccessManagement/Commands/{DeleteLeasingPolicyCommandTests.cs => DeleteAccessRuleCommandTests.cs} (59%) rename test/Core.Test/PrivilegedAccessManagement/Commands/{UpdateLeasingPolicyCommandTests.cs => UpdateAccessRuleCommandTests.cs} (60%) rename test/Core.Test/PrivilegedAccessManagement/Services/{LeasingPolicyValidatorTests.cs => AccessRuleValidatorTests.cs} (80%) rename util/Migrator/DbScripts/{2026-05-21_00_AddLeasingPolicy.sql => 2026-05-21_00_AddAccessRule.sql} (88%) rename util/MySqlMigrations/Migrations/{20260526092815_AddLeasingPolicy.Designer.cs => 20260526122321_AddAccessRule.Designer.cs} (99%) rename util/MySqlMigrations/Migrations/{20260526092815_AddLeasingPolicy.cs => 20260526122321_AddAccessRule.cs} (60%) rename util/PostgresMigrations/Migrations/{20260526092811_AddLeasingPolicy.Designer.cs => 20260526122317_AddAccessRule.Designer.cs} (99%) rename util/PostgresMigrations/Migrations/{20260526092811_AddLeasingPolicy.cs => 20260526122317_AddAccessRule.cs} (58%) rename util/SqliteMigrations/Migrations/{20260526092819_AddLeasingPolicy.Designer.cs => 20260526122325_AddAccessRule.Designer.cs} (99%) rename util/SqliteMigrations/Migrations/{20260526092819_AddLeasingPolicy.cs => 20260526122325_AddAccessRule.cs} (59%) diff --git a/src/Api/AdminConsole/Models/Request/CollectionRequestModel.cs b/src/Api/AdminConsole/Models/Request/CollectionRequestModel.cs index f394510c3b60..fcd3a1494d54 100644 --- a/src/Api/AdminConsole/Models/Request/CollectionRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/CollectionRequestModel.cs @@ -18,7 +18,7 @@ public class CreateCollectionRequestModel public string ExternalId { get; set; } public IEnumerable Groups { get; set; } public IEnumerable Users { get; set; } - public Guid? LeasingPolicyId { get; set; } + public Guid? AccessRuleId { get; set; } public Collection ToCollection(Guid orgId) { @@ -32,7 +32,7 @@ public virtual Collection ToCollection(Collection existingCollection) { existingCollection.Name = Name; existingCollection.ExternalId = ExternalId; - existingCollection.LeasingPolicyId = LeasingPolicyId; + existingCollection.AccessRuleId = AccessRuleId; return existingCollection; } } @@ -67,7 +67,7 @@ public override Collection ToCollection(Collection existingCollection) existingCollection.Name = Name; } existingCollection.ExternalId = ExternalId; - existingCollection.LeasingPolicyId = LeasingPolicyId; + existingCollection.AccessRuleId = AccessRuleId; return existingCollection; } } diff --git a/src/Api/AdminConsole/Models/Response/CollectionResponseModel.cs b/src/Api/AdminConsole/Models/Response/CollectionResponseModel.cs index 2b505f60393e..569aa9906881 100644 --- a/src/Api/AdminConsole/Models/Response/CollectionResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/CollectionResponseModel.cs @@ -25,7 +25,7 @@ public CollectionResponseModel(Collection collection, string obj = "collection") ExternalId = collection.ExternalId; Type = collection.Type; DefaultUserCollectionEmail = collection.DefaultUserCollectionEmail; - LeasingPolicyId = collection.LeasingPolicyId; + AccessRuleId = collection.AccessRuleId; } public Guid Id { get; set; } @@ -34,7 +34,7 @@ public CollectionResponseModel(Collection collection, string obj = "collection") public string ExternalId { get; set; } public CollectionType Type { get; set; } public string DefaultUserCollectionEmail { get; set; } - public Guid? LeasingPolicyId { get; set; } + public Guid? AccessRuleId { get; set; } } /// diff --git a/src/Api/PrivilegedAccessManagement/Controllers/AccessRulesController.cs b/src/Api/PrivilegedAccessManagement/Controllers/AccessRulesController.cs new file mode 100644 index 000000000000..8337f5f1f4d2 --- /dev/null +++ b/src/Api/PrivilegedAccessManagement/Controllers/AccessRulesController.cs @@ -0,0 +1,92 @@ +using Bit.Api.Models.Response; +using Bit.Api.PrivilegedAccessManagement.Models.Request; +using Bit.Api.PrivilegedAccessManagement.Models.Response; +using Bit.Core; +using Bit.Core.Context; +using Bit.Core.Exceptions; +using Bit.Core.PrivilegedAccessManagement.OrganizationFeatures.Commands.Interfaces; +using Bit.Core.PrivilegedAccessManagement.Repositories; +using Bit.Core.Utilities; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Bit.Api.PrivilegedAccessManagement.Controllers; + +[Route("organizations/{orgId:guid}/access-rules")] +[Authorize("Application")] +[RequireFeature(FeatureFlagKeys.Pam)] +public class AccessRulesController( + ICurrentContext currentContext, + IAccessRuleRepository repository, + ICreateAccessRuleCommand createCommand, + IUpdateAccessRuleCommand updateCommand, + IDeleteAccessRuleCommand deleteCommand) + : Controller +{ + [HttpGet("")] + public async Task> GetAll(Guid orgId) + { + await EnsureMemberAsync(orgId); + + var rules = await repository.GetManyByOrganizationIdAsync(orgId); + return new ListResponseModel( + rules.Select(p => new AccessRuleResponseModel(p))); + } + + [HttpGet("{id:guid}")] + public async Task Get(Guid orgId, Guid id) + { + await EnsureMemberAsync(orgId); + + var rule = await repository.GetByIdAsync(id); + if (rule is null || rule.OrganizationId != orgId) + { + throw new NotFoundException(); + } + + return new AccessRuleResponseModel(rule); + } + + [HttpPost("")] + public async Task Post(Guid orgId, [FromBody] AccessRuleRequestModel model) + { + await EnsureAdminAsync(orgId); + + var rule = await createCommand.CreateAsync(model.ToAccessRule(orgId)); + return new AccessRuleResponseModel(rule); + } + + [HttpPut("{id:guid}")] + public async Task Put(Guid orgId, Guid id, [FromBody] AccessRuleRequestModel model) + { + await EnsureAdminAsync(orgId); + + var rule = await updateCommand.UpdateAsync(orgId, id, model.ToAccessRule(orgId)); + return new AccessRuleResponseModel(rule); + } + + [HttpDelete("{id:guid}")] + public async Task Delete(Guid orgId, Guid id) + { + await EnsureAdminAsync(orgId); + + await deleteCommand.DeleteAsync(orgId, id); + return NoContent(); + } + + private async Task EnsureMemberAsync(Guid orgId) + { + if (!await currentContext.OrganizationUser(orgId)) + { + throw new NotFoundException(); + } + } + + private async Task EnsureAdminAsync(Guid orgId) + { + if (!await currentContext.OrganizationAdmin(orgId) && !await currentContext.OrganizationOwner(orgId)) + { + throw new NotFoundException(); + } + } +} diff --git a/src/Api/PrivilegedAccessManagement/Controllers/LeasingPoliciesController.cs b/src/Api/PrivilegedAccessManagement/Controllers/LeasingPoliciesController.cs deleted file mode 100644 index e1db2c860f78..000000000000 --- a/src/Api/PrivilegedAccessManagement/Controllers/LeasingPoliciesController.cs +++ /dev/null @@ -1,106 +0,0 @@ -using Bit.Api.Models.Response; -using Bit.Api.PrivilegedAccessManagement.Models.Request; -using Bit.Api.PrivilegedAccessManagement.Models.Response; -using Bit.Core; -using Bit.Core.Context; -using Bit.Core.Exceptions; -using Bit.Core.PrivilegedAccessManagement.OrganizationFeatures.Commands.Interfaces; -using Bit.Core.PrivilegedAccessManagement.Repositories; -using Bit.Core.Utilities; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; - -namespace Bit.Api.PrivilegedAccessManagement.Controllers; - -[Route("organizations/{orgId:guid}/leasing-policies")] -[Authorize("Application")] -[RequireFeature(FeatureFlagKeys.Pam)] -public class LeasingPoliciesController : Controller -{ - private readonly ICurrentContext _currentContext; - private readonly ILeasingPolicyRepository _repository; - private readonly ICreateLeasingPolicyCommand _createCommand; - private readonly IUpdateLeasingPolicyCommand _updateCommand; - private readonly IDeleteLeasingPolicyCommand _deleteCommand; - - public LeasingPoliciesController( - ICurrentContext currentContext, - ILeasingPolicyRepository repository, - ICreateLeasingPolicyCommand createCommand, - IUpdateLeasingPolicyCommand updateCommand, - IDeleteLeasingPolicyCommand deleteCommand) - { - _currentContext = currentContext; - _repository = repository; - _createCommand = createCommand; - _updateCommand = updateCommand; - _deleteCommand = deleteCommand; - } - - [HttpGet("")] - public async Task> GetAll(Guid orgId) - { - await EnsureMemberAsync(orgId); - - var policies = await _repository.GetManyByOrganizationIdAsync(orgId); - return new ListResponseModel( - policies.Select(p => new LeasingPolicyResponseModel(p))); - } - - [HttpGet("{id:guid}")] - public async Task Get(Guid orgId, Guid id) - { - await EnsureMemberAsync(orgId); - - var policy = await _repository.GetByIdAsync(id); - if (policy is null || policy.OrganizationId != orgId) - { - throw new NotFoundException(); - } - - return new LeasingPolicyResponseModel(policy); - } - - [HttpPost("")] - public async Task Post(Guid orgId, [FromBody] LeasingPolicyRequestModel model) - { - await EnsureAdminAsync(orgId); - - var policy = await _createCommand.CreateAsync(model.ToLeasingPolicy(orgId)); - return new LeasingPolicyResponseModel(policy); - } - - [HttpPut("{id:guid}")] - public async Task Put(Guid orgId, Guid id, [FromBody] LeasingPolicyRequestModel model) - { - await EnsureAdminAsync(orgId); - - var policy = await _updateCommand.UpdateAsync(orgId, id, model.ToLeasingPolicy(orgId)); - return new LeasingPolicyResponseModel(policy); - } - - [HttpDelete("{id:guid}")] - public async Task Delete(Guid orgId, Guid id) - { - await EnsureAdminAsync(orgId); - - await _deleteCommand.DeleteAsync(orgId, id); - return NoContent(); - } - - private async Task EnsureMemberAsync(Guid orgId) - { - if (!await _currentContext.OrganizationUser(orgId)) - { - throw new NotFoundException(); - } - } - - private async Task EnsureAdminAsync(Guid orgId) - { - if (!await _currentContext.OrganizationAdmin(orgId) && !await _currentContext.OrganizationOwner(orgId)) - { - throw new NotFoundException(); - } - } -} diff --git a/src/Api/PrivilegedAccessManagement/Models/Request/LeasingPolicyRequestModel.cs b/src/Api/PrivilegedAccessManagement/Models/Request/AccessRuleRequestModel.cs similarity index 64% rename from src/Api/PrivilegedAccessManagement/Models/Request/LeasingPolicyRequestModel.cs rename to src/Api/PrivilegedAccessManagement/Models/Request/AccessRuleRequestModel.cs index 1eedac0e0ddc..b47c85e4a16e 100644 --- a/src/Api/PrivilegedAccessManagement/Models/Request/LeasingPolicyRequestModel.cs +++ b/src/Api/PrivilegedAccessManagement/Models/Request/AccessRuleRequestModel.cs @@ -4,7 +4,7 @@ namespace Bit.Api.PrivilegedAccessManagement.Models.Request; -public class LeasingPolicyRequestModel +public class AccessRuleRequestModel { [Required] [StringLength(256)] @@ -13,20 +13,20 @@ public class LeasingPolicyRequestModel public string? Description { get; set; } [Required] - public object Policy { get; set; } = null!; + public object Rule { get; set; } = null!; - public LeasingPolicy ToLeasingPolicy(Guid organizationId) => new() + public AccessRule ToAccessRule(Guid organizationId) => new() { OrganizationId = organizationId, Name = Name, Description = Description, - Policy = SerializePolicy(Policy), + Rule = SerializeRule(Rule), }; - private static string SerializePolicy(object policy) => policy switch + private static string SerializeRule(object rule) => rule switch { JsonElement je when je.ValueKind == JsonValueKind.Null => string.Empty, JsonElement je => je.GetRawText(), - _ => JsonSerializer.Serialize(policy), + _ => JsonSerializer.Serialize(rule), }; } diff --git a/src/Api/PrivilegedAccessManagement/Models/Response/AccessRuleResponseModel.cs b/src/Api/PrivilegedAccessManagement/Models/Response/AccessRuleResponseModel.cs new file mode 100644 index 000000000000..465f338079f8 --- /dev/null +++ b/src/Api/PrivilegedAccessManagement/Models/Response/AccessRuleResponseModel.cs @@ -0,0 +1,46 @@ +using System.Text.Json; +using Bit.Core.Models.Api; +using Bit.Core.PrivilegedAccessManagement.Entities; + +namespace Bit.Api.PrivilegedAccessManagement.Models.Response; + +public class AccessRuleResponseModel : ResponseModel +{ + public AccessRuleResponseModel(AccessRule rule) + : base("accessRule") + { + ArgumentNullException.ThrowIfNull(rule); + + Id = rule.Id; + OrganizationId = rule.OrganizationId; + Name = rule.Name; + Description = rule.Description; + Rule = TryParseRule(rule.Rule); + CreationDate = rule.CreationDate; + RevisionDate = rule.RevisionDate; + } + + public Guid Id { get; } + public Guid OrganizationId { get; } + public string Name { get; } + public string? Description { get; } + public JsonElement? Rule { get; } + public DateTime CreationDate { get; } + public DateTime RevisionDate { get; } + + private static JsonElement? TryParseRule(string? ruleJson) + { + if (string.IsNullOrEmpty(ruleJson)) + { + return null; + } + try + { + return JsonDocument.Parse(ruleJson).RootElement; + } + catch (JsonException) + { + return null; + } + } +} diff --git a/src/Api/PrivilegedAccessManagement/Models/Response/LeasingPolicyResponseModel.cs b/src/Api/PrivilegedAccessManagement/Models/Response/LeasingPolicyResponseModel.cs deleted file mode 100644 index cb22431cc7f3..000000000000 --- a/src/Api/PrivilegedAccessManagement/Models/Response/LeasingPolicyResponseModel.cs +++ /dev/null @@ -1,46 +0,0 @@ -using System.Text.Json; -using Bit.Core.Models.Api; -using Bit.Core.PrivilegedAccessManagement.Entities; - -namespace Bit.Api.PrivilegedAccessManagement.Models.Response; - -public class LeasingPolicyResponseModel : ResponseModel -{ - public LeasingPolicyResponseModel(LeasingPolicy policy) - : base("leasingPolicy") - { - ArgumentNullException.ThrowIfNull(policy); - - Id = policy.Id; - OrganizationId = policy.OrganizationId; - Name = policy.Name; - Description = policy.Description; - Policy = TryParsePolicy(policy.Policy); - CreationDate = policy.CreationDate; - RevisionDate = policy.RevisionDate; - } - - public Guid Id { get; } - public Guid OrganizationId { get; } - public string Name { get; } - public string? Description { get; } - public JsonElement? Policy { get; } - public DateTime CreationDate { get; } - public DateTime RevisionDate { get; } - - private static JsonElement? TryParsePolicy(string? policyJson) - { - if (string.IsNullOrEmpty(policyJson)) - { - return null; - } - try - { - return JsonDocument.Parse(policyJson).RootElement; - } - catch (JsonException) - { - return null; - } - } -} diff --git a/src/Core/AdminConsole/Entities/Collection.cs b/src/Core/AdminConsole/Entities/Collection.cs index 2aabe5296ba7..273a37de6c4e 100644 --- a/src/Core/AdminConsole/Entities/Collection.cs +++ b/src/Core/AdminConsole/Entities/Collection.cs @@ -47,10 +47,10 @@ public class Collection : ITableObject /// public string? DefaultUserCollectionEmail { get; set; } /// - /// Reference to a that gates + /// Reference to a that gates /// PAM credential leasing for this collection. Null means leasing is disabled for the collection. /// - public Guid? LeasingPolicyId { get; set; } + public Guid? AccessRuleId { get; set; } /// /// Initializes to a new COMB GUID. diff --git a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs index 273e9b17c72e..7c6732f612b9 100644 --- a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs +++ b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs @@ -70,7 +70,7 @@ public static void AddOrganizationServices(this IServiceCollection services, IGl services.AddOrganizationSponsorshipCommands(globalSettings); services.AddOrganizationApiKeyCommandsQueries(); services.AddOrganizationCollectionCommands(); - services.AddLeasingPolicyCommands(); + services.AddAccessRuleCommands(); services.AddOrganizationGroupCommands(); services.AddOrganizationInviteLinkCommandsQueries(); services.AddOrganizationDomainCommandsQueries(); @@ -190,12 +190,12 @@ public static void AddOrganizationCollectionCommands(this IServiceCollection ser services.AddScoped(); } - public static void AddLeasingPolicyCommands(this IServiceCollection services) + public static void AddAccessRuleCommands(this IServiceCollection services) { - services.AddSingleton(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); + services.AddSingleton(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); } private static void AddOrganizationGroupCommands(this IServiceCollection services) diff --git a/src/Core/PrivilegedAccessManagement/Entities/LeasingPolicy.cs b/src/Core/PrivilegedAccessManagement/Entities/AccessRule.cs similarity index 69% rename from src/Core/PrivilegedAccessManagement/Entities/LeasingPolicy.cs rename to src/Core/PrivilegedAccessManagement/Entities/AccessRule.cs index fe115252862c..1790f6a55321 100644 --- a/src/Core/PrivilegedAccessManagement/Entities/LeasingPolicy.cs +++ b/src/Core/PrivilegedAccessManagement/Entities/AccessRule.cs @@ -5,10 +5,10 @@ namespace Bit.Core.PrivilegedAccessManagement.Entities; /// -/// A reusable, org-scoped PAM leasing policy. Referenced by collections (and eventually Secrets Manager +/// A reusable, org-scoped PAM access rule. Referenced by collections (and eventually Secrets Manager /// entities) via FK to govern credential lease decisions. /// -public class LeasingPolicy : ITableObject +public class AccessRule : ITableObject { public Guid Id { get; set; } public Guid OrganizationId { get; set; } @@ -19,9 +19,9 @@ public class LeasingPolicy : ITableObject public string? Description { get; set; } /// - /// JSON policy document. Validated by LeasingPolicyValidator before being persisted. + /// JSON rule document. Validated by AccessRuleValidator before being persisted. /// - public string Policy { get; set; } = null!; + public string Rule { get; set; } = null!; public DateTime CreationDate { get; set; } = DateTime.UtcNow; public DateTime RevisionDate { get; set; } = DateTime.UtcNow; diff --git a/src/Core/PrivilegedAccessManagement/Models/Policies/AllOfPolicy.cs b/src/Core/PrivilegedAccessManagement/Models/Policies/AllOfPolicy.cs deleted file mode 100644 index 3de212084acd..000000000000 --- a/src/Core/PrivilegedAccessManagement/Models/Policies/AllOfPolicy.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Bit.Core.PrivilegedAccessManagement.Models.Policies; - -/// -/// Composite policy that approves only when every child policy approves. -/// -public sealed class AllOfPolicy : LeasingPolicy -{ - public IReadOnlyList Policies { get; init; } = []; -} diff --git a/src/Core/PrivilegedAccessManagement/Models/Policies/HumanApprovalPolicy.cs b/src/Core/PrivilegedAccessManagement/Models/Policies/HumanApprovalPolicy.cs deleted file mode 100644 index de64103c466a..000000000000 --- a/src/Core/PrivilegedAccessManagement/Models/Policies/HumanApprovalPolicy.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Bit.Core.PrivilegedAccessManagement.Models.Policies; - -/// -/// Always requires a human decision before a lease can be issued. -/// -public sealed class HumanApprovalPolicy : LeasingPolicy; diff --git a/src/Core/PrivilegedAccessManagement/Models/Policies/LeasingPolicy.cs b/src/Core/PrivilegedAccessManagement/Models/Policies/LeasingPolicy.cs deleted file mode 100644 index 3e6af1aaac2a..000000000000 --- a/src/Core/PrivilegedAccessManagement/Models/Policies/LeasingPolicy.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Bit.Core.PrivilegedAccessManagement.Models.Policies; - -/// -/// Base type for the structured leasing policy stored on Collection.LeasingPolicy. -/// Polymorphic deserialization is keyed by the JSON kind property. -/// -[JsonPolymorphic(TypeDiscriminatorPropertyName = "kind", UnknownDerivedTypeHandling = JsonUnknownDerivedTypeHandling.FailSerialization)] -[JsonDerivedType(typeof(HumanApprovalPolicy), "human_approval")] -[JsonDerivedType(typeof(IpAllowlistPolicy), "ip_allowlist")] -[JsonDerivedType(typeof(TimeOfDayPolicy), "time_of_day")] -[JsonDerivedType(typeof(AllOfPolicy), "all_of")] -public abstract class LeasingPolicy; diff --git a/src/Core/PrivilegedAccessManagement/Models/Rules/AllOfRule.cs b/src/Core/PrivilegedAccessManagement/Models/Rules/AllOfRule.cs new file mode 100644 index 000000000000..99f6eb68dbdd --- /dev/null +++ b/src/Core/PrivilegedAccessManagement/Models/Rules/AllOfRule.cs @@ -0,0 +1,9 @@ +namespace Bit.Core.PrivilegedAccessManagement.Models.Rules; + +/// +/// Composite rule that approves only when every child rule approves. +/// +public sealed class AllOfRule : Rule +{ + public IReadOnlyList Rules { get; init; } = []; +} diff --git a/src/Core/PrivilegedAccessManagement/Models/Rules/HumanApprovalRule.cs b/src/Core/PrivilegedAccessManagement/Models/Rules/HumanApprovalRule.cs new file mode 100644 index 000000000000..d2aa6c5f5b2c --- /dev/null +++ b/src/Core/PrivilegedAccessManagement/Models/Rules/HumanApprovalRule.cs @@ -0,0 +1,6 @@ +namespace Bit.Core.PrivilegedAccessManagement.Models.Rules; + +/// +/// Always requires a human decision before a lease can be issued. +/// +public sealed class HumanApprovalRule : Rule; diff --git a/src/Core/PrivilegedAccessManagement/Models/Policies/IpAllowlistPolicy.cs b/src/Core/PrivilegedAccessManagement/Models/Rules/IpAllowlistRule.cs similarity index 60% rename from src/Core/PrivilegedAccessManagement/Models/Policies/IpAllowlistPolicy.cs rename to src/Core/PrivilegedAccessManagement/Models/Rules/IpAllowlistRule.cs index 75045d5a5bfe..5aa85e5e13c0 100644 --- a/src/Core/PrivilegedAccessManagement/Models/Policies/IpAllowlistPolicy.cs +++ b/src/Core/PrivilegedAccessManagement/Models/Rules/IpAllowlistRule.cs @@ -1,9 +1,9 @@ -namespace Bit.Core.PrivilegedAccessManagement.Models.Policies; +namespace Bit.Core.PrivilegedAccessManagement.Models.Rules; /// /// Auto-approves a lease when the requester's IP matches a listed CIDR; otherwise denies. /// -public sealed class IpAllowlistPolicy : LeasingPolicy +public sealed class IpAllowlistRule : Rule { public IReadOnlyList Cidrs { get; init; } = []; } diff --git a/src/Core/PrivilegedAccessManagement/Models/Rules/Rule.cs b/src/Core/PrivilegedAccessManagement/Models/Rules/Rule.cs new file mode 100644 index 000000000000..5ebc2e3cd5d8 --- /dev/null +++ b/src/Core/PrivilegedAccessManagement/Models/Rules/Rule.cs @@ -0,0 +1,14 @@ +using System.Text.Json.Serialization; + +namespace Bit.Core.PrivilegedAccessManagement.Models.Rules; + +/// +/// Base type for the structured rule stored on AccessRule.Rule. +/// Polymorphic deserialization is keyed by the JSON kind property. +/// +[JsonPolymorphic(TypeDiscriminatorPropertyName = "kind", UnknownDerivedTypeHandling = JsonUnknownDerivedTypeHandling.FailSerialization)] +[JsonDerivedType(typeof(HumanApprovalRule), "human_approval")] +[JsonDerivedType(typeof(IpAllowlistRule), "ip_allowlist")] +[JsonDerivedType(typeof(TimeOfDayRule), "time_of_day")] +[JsonDerivedType(typeof(AllOfRule), "all_of")] +public abstract class Rule; diff --git a/src/Core/PrivilegedAccessManagement/Models/Policies/TimeOfDayPolicy.cs b/src/Core/PrivilegedAccessManagement/Models/Rules/TimeOfDayRule.cs similarity index 80% rename from src/Core/PrivilegedAccessManagement/Models/Policies/TimeOfDayPolicy.cs rename to src/Core/PrivilegedAccessManagement/Models/Rules/TimeOfDayRule.cs index 9220169f1ea1..3733b8ac46ac 100644 --- a/src/Core/PrivilegedAccessManagement/Models/Policies/TimeOfDayPolicy.cs +++ b/src/Core/PrivilegedAccessManagement/Models/Rules/TimeOfDayRule.cs @@ -1,10 +1,10 @@ -namespace Bit.Core.PrivilegedAccessManagement.Models.Policies; +namespace Bit.Core.PrivilegedAccessManagement.Models.Rules; /// /// Auto-approves a lease when the request falls inside one of the configured windows, evaluated in /// the named IANA timezone; otherwise denies. /// -public sealed class TimeOfDayPolicy : LeasingPolicy +public sealed class TimeOfDayRule : Rule { public string Tz { get; init; } = string.Empty; public IReadOnlyList Windows { get; init; } = []; diff --git a/src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/CreateLeasingPolicyCommand.cs b/src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/CreateAccessRuleCommand.cs similarity index 52% rename from src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/CreateLeasingPolicyCommand.cs rename to src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/CreateAccessRuleCommand.cs index 5e133f29641f..5a991a9282da 100644 --- a/src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/CreateLeasingPolicyCommand.cs +++ b/src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/CreateAccessRuleCommand.cs @@ -6,15 +6,15 @@ namespace Bit.Core.PrivilegedAccessManagement.OrganizationFeatures.Commands; -public class CreateLeasingPolicyCommand : ICreateLeasingPolicyCommand +public class CreateAccessRuleCommand : ICreateAccessRuleCommand { - private readonly ILeasingPolicyRepository _repository; - private readonly ILeasingPolicyValidator _validator; + private readonly IAccessRuleRepository _repository; + private readonly IAccessRuleValidator _validator; private readonly TimeProvider _timeProvider; - public CreateLeasingPolicyCommand( - ILeasingPolicyRepository repository, - ILeasingPolicyValidator validator, + public CreateAccessRuleCommand( + IAccessRuleRepository repository, + IAccessRuleValidator validator, TimeProvider timeProvider) { _repository = repository; @@ -22,29 +22,29 @@ public CreateLeasingPolicyCommand( _timeProvider = timeProvider; } - public async Task CreateAsync(LeasingPolicy policy) + public async Task CreateAsync(AccessRule rule) { - if (string.IsNullOrWhiteSpace(policy.Name)) + if (string.IsNullOrWhiteSpace(rule.Name)) { throw new BadRequestException("Name is required."); } - var validation = _validator.Validate(policy.Policy); + var validation = _validator.Validate(rule.Rule); if (!validation.IsValid) { throw new BadRequestException(validation.Error!); } - var existing = await _repository.GetManyByOrganizationIdAsync(policy.OrganizationId); - if (existing.Any(p => string.Equals(p.Name, policy.Name, StringComparison.OrdinalIgnoreCase))) + var existing = await _repository.GetManyByOrganizationIdAsync(rule.OrganizationId); + if (existing.Any(p => string.Equals(p.Name, rule.Name, StringComparison.OrdinalIgnoreCase))) { - throw new BadRequestException("A policy with that name already exists."); + throw new BadRequestException("A rule with that name already exists."); } var now = _timeProvider.GetUtcNow().UtcDateTime; - policy.CreationDate = now; - policy.RevisionDate = now; + rule.CreationDate = now; + rule.RevisionDate = now; - return await _repository.CreateAsync(policy); + return await _repository.CreateAsync(rule); } } diff --git a/src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/DeleteLeasingPolicyCommand.cs b/src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/DeleteAccessRuleCommand.cs similarity index 75% rename from src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/DeleteLeasingPolicyCommand.cs rename to src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/DeleteAccessRuleCommand.cs index 98cc72130221..64f06317f0cc 100644 --- a/src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/DeleteLeasingPolicyCommand.cs +++ b/src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/DeleteAccessRuleCommand.cs @@ -4,11 +4,11 @@ namespace Bit.Core.PrivilegedAccessManagement.OrganizationFeatures.Commands; -public class DeleteLeasingPolicyCommand : IDeleteLeasingPolicyCommand +public class DeleteAccessRuleCommand : IDeleteAccessRuleCommand { - private readonly ILeasingPolicyRepository _repository; + private readonly IAccessRuleRepository _repository; - public DeleteLeasingPolicyCommand(ILeasingPolicyRepository repository) + public DeleteAccessRuleCommand(IAccessRuleRepository repository) { _repository = repository; } diff --git a/src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/Interfaces/ICreateLeasingPolicyCommand.cs b/src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/Interfaces/ICreateAccessRuleCommand.cs similarity index 58% rename from src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/Interfaces/ICreateLeasingPolicyCommand.cs rename to src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/Interfaces/ICreateAccessRuleCommand.cs index 52d8b9ffe8cf..5606441e1b9f 100644 --- a/src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/Interfaces/ICreateLeasingPolicyCommand.cs +++ b/src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/Interfaces/ICreateAccessRuleCommand.cs @@ -2,7 +2,7 @@ namespace Bit.Core.PrivilegedAccessManagement.OrganizationFeatures.Commands.Interfaces; -public interface ICreateLeasingPolicyCommand +public interface ICreateAccessRuleCommand { - Task CreateAsync(LeasingPolicy policy); + Task CreateAsync(AccessRule rule); } diff --git a/src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/Interfaces/IDeleteLeasingPolicyCommand.cs b/src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/Interfaces/IDeleteAccessRuleCommand.cs similarity index 76% rename from src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/Interfaces/IDeleteLeasingPolicyCommand.cs rename to src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/Interfaces/IDeleteAccessRuleCommand.cs index 01fdb37769a8..0b0efbdf1ce6 100644 --- a/src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/Interfaces/IDeleteLeasingPolicyCommand.cs +++ b/src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/Interfaces/IDeleteAccessRuleCommand.cs @@ -1,6 +1,6 @@ namespace Bit.Core.PrivilegedAccessManagement.OrganizationFeatures.Commands.Interfaces; -public interface IDeleteLeasingPolicyCommand +public interface IDeleteAccessRuleCommand { Task DeleteAsync(Guid organizationId, Guid id); } diff --git a/src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/Interfaces/IUpdateLeasingPolicyCommand.cs b/src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/Interfaces/IUpdateAccessRuleCommand.cs similarity index 52% rename from src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/Interfaces/IUpdateLeasingPolicyCommand.cs rename to src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/Interfaces/IUpdateAccessRuleCommand.cs index 5df4d28daca1..c8b19b1ead11 100644 --- a/src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/Interfaces/IUpdateLeasingPolicyCommand.cs +++ b/src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/Interfaces/IUpdateAccessRuleCommand.cs @@ -2,7 +2,7 @@ namespace Bit.Core.PrivilegedAccessManagement.OrganizationFeatures.Commands.Interfaces; -public interface IUpdateLeasingPolicyCommand +public interface IUpdateAccessRuleCommand { - Task UpdateAsync(Guid organizationId, Guid id, LeasingPolicy update); + Task UpdateAsync(Guid organizationId, Guid id, AccessRule update); } diff --git a/src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/UpdateLeasingPolicyCommand.cs b/src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/UpdateAccessRuleCommand.cs similarity index 71% rename from src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/UpdateLeasingPolicyCommand.cs rename to src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/UpdateAccessRuleCommand.cs index 440511c32b3e..642d7c226de4 100644 --- a/src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/UpdateLeasingPolicyCommand.cs +++ b/src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/UpdateAccessRuleCommand.cs @@ -6,15 +6,15 @@ namespace Bit.Core.PrivilegedAccessManagement.OrganizationFeatures.Commands; -public class UpdateLeasingPolicyCommand : IUpdateLeasingPolicyCommand +public class UpdateAccessRuleCommand : IUpdateAccessRuleCommand { - private readonly ILeasingPolicyRepository _repository; - private readonly ILeasingPolicyValidator _validator; + private readonly IAccessRuleRepository _repository; + private readonly IAccessRuleValidator _validator; private readonly TimeProvider _timeProvider; - public UpdateLeasingPolicyCommand( - ILeasingPolicyRepository repository, - ILeasingPolicyValidator validator, + public UpdateAccessRuleCommand( + IAccessRuleRepository repository, + IAccessRuleValidator validator, TimeProvider timeProvider) { _repository = repository; @@ -22,7 +22,7 @@ public UpdateLeasingPolicyCommand( _timeProvider = timeProvider; } - public async Task UpdateAsync(Guid organizationId, Guid id, LeasingPolicy update) + public async Task UpdateAsync(Guid organizationId, Guid id, AccessRule update) { if (string.IsNullOrWhiteSpace(update.Name)) { @@ -35,7 +35,7 @@ public async Task UpdateAsync(Guid organizationId, Guid id, Leasi throw new NotFoundException(); } - var validation = _validator.Validate(update.Policy); + var validation = _validator.Validate(update.Rule); if (!validation.IsValid) { throw new BadRequestException(validation.Error!); @@ -44,12 +44,12 @@ public async Task UpdateAsync(Guid organizationId, Guid id, Leasi var siblings = await _repository.GetManyByOrganizationIdAsync(organizationId); if (siblings.Any(p => p.Id != id && string.Equals(p.Name, update.Name, StringComparison.OrdinalIgnoreCase))) { - throw new BadRequestException("A policy with that name already exists."); + throw new BadRequestException("A rule with that name already exists."); } existing.Name = update.Name; existing.Description = update.Description; - existing.Policy = update.Policy; + existing.Rule = update.Rule; existing.RevisionDate = _timeProvider.GetUtcNow().UtcDateTime; await _repository.ReplaceAsync(existing); diff --git a/src/Core/PrivilegedAccessManagement/Repositories/IAccessRuleRepository.cs b/src/Core/PrivilegedAccessManagement/Repositories/IAccessRuleRepository.cs new file mode 100644 index 000000000000..8bbc7a1d54c9 --- /dev/null +++ b/src/Core/PrivilegedAccessManagement/Repositories/IAccessRuleRepository.cs @@ -0,0 +1,9 @@ +using Bit.Core.PrivilegedAccessManagement.Entities; +using Bit.Core.Repositories; + +namespace Bit.Core.PrivilegedAccessManagement.Repositories; + +public interface IAccessRuleRepository : IRepository +{ + Task> GetManyByOrganizationIdAsync(Guid organizationId); +} diff --git a/src/Core/PrivilegedAccessManagement/Repositories/ILeasingPolicyRepository.cs b/src/Core/PrivilegedAccessManagement/Repositories/ILeasingPolicyRepository.cs deleted file mode 100644 index 83c55a945dcf..000000000000 --- a/src/Core/PrivilegedAccessManagement/Repositories/ILeasingPolicyRepository.cs +++ /dev/null @@ -1,9 +0,0 @@ -using Bit.Core.PrivilegedAccessManagement.Entities; -using Bit.Core.Repositories; - -namespace Bit.Core.PrivilegedAccessManagement.Repositories; - -public interface ILeasingPolicyRepository : IRepository -{ - Task> GetManyByOrganizationIdAsync(Guid organizationId); -} diff --git a/src/Core/PrivilegedAccessManagement/Services/AccessRuleValidator.cs b/src/Core/PrivilegedAccessManagement/Services/AccessRuleValidator.cs new file mode 100644 index 000000000000..3d0163f0441f --- /dev/null +++ b/src/Core/PrivilegedAccessManagement/Services/AccessRuleValidator.cs @@ -0,0 +1,167 @@ +using System.Net; +using System.Text.Json; +using System.Text.RegularExpressions; +using Bit.Core.PrivilegedAccessManagement.Models.Rules; + +namespace Bit.Core.PrivilegedAccessManagement.Services; + +public sealed partial class AccessRuleValidator : IAccessRuleValidator +{ + private const int MaxCompositeDepth = 3; + private const int MaxCompositeChildren = 10; + + private static readonly HashSet AllowedDays = + new(StringComparer.OrdinalIgnoreCase) { "mon", "tue", "wed", "thu", "fri", "sat", "sun" }; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + }; + + [GeneratedRegex(@"^([01][0-9]|2[0-3]):[0-5][0-9]$")] + private static partial Regex TimeOfDayRegex(); + + public AccessRuleValidationResult Validate(string? ruleJson) + { + if (ruleJson is null) + { + return AccessRuleValidationResult.Valid; + } + + if (string.IsNullOrWhiteSpace(ruleJson)) + { + return AccessRuleValidationResult.Invalid("Rule JSON cannot be empty."); + } + + Rule? rule; + try + { + rule = JsonSerializer.Deserialize(ruleJson, JsonOptions); + } + catch (JsonException ex) + { + return AccessRuleValidationResult.Invalid($"Rule JSON is malformed: {ex.Message}"); + } + + if (rule is null) + { + return AccessRuleValidationResult.Invalid("Rule must be an object."); + } + + return ValidateRule(rule, depth: 0); + } + + private static AccessRuleValidationResult ValidateRule(Rule rule, int depth) + { + return rule switch + { + HumanApprovalRule => AccessRuleValidationResult.Valid, + IpAllowlistRule ip => ValidateIpAllowlist(ip), + TimeOfDayRule tod => ValidateTimeOfDay(tod), + AllOfRule all => ValidateAllOf(all, depth), + _ => AccessRuleValidationResult.Invalid($"Unsupported rule kind: {rule.GetType().Name}."), + }; + } + + private static AccessRuleValidationResult ValidateIpAllowlist(IpAllowlistRule rule) + { + if (rule.Cidrs.Count == 0) + { + return AccessRuleValidationResult.Invalid("ip_allowlist requires at least one CIDR."); + } + + foreach (var cidr in rule.Cidrs) + { + if (string.IsNullOrWhiteSpace(cidr) || !IPNetwork.TryParse(cidr, out _)) + { + return AccessRuleValidationResult.Invalid($"Invalid CIDR: '{cidr}'."); + } + } + + return AccessRuleValidationResult.Valid; + } + + private static AccessRuleValidationResult ValidateTimeOfDay(TimeOfDayRule rule) + { + if (string.IsNullOrWhiteSpace(rule.Tz)) + { + return AccessRuleValidationResult.Invalid("time_of_day requires a tz."); + } + + try + { + TimeZoneInfo.FindSystemTimeZoneById(rule.Tz); + } + catch (TimeZoneNotFoundException) + { + return AccessRuleValidationResult.Invalid($"Unknown timezone: '{rule.Tz}'."); + } + catch (InvalidTimeZoneException) + { + return AccessRuleValidationResult.Invalid($"Invalid timezone: '{rule.Tz}'."); + } + + if (rule.Windows.Count == 0) + { + return AccessRuleValidationResult.Invalid("time_of_day requires at least one window."); + } + + foreach (var window in rule.Windows) + { + if (window.Days.Count == 0) + { + return AccessRuleValidationResult.Invalid("time_of_day window requires at least one day."); + } + + foreach (var day in window.Days) + { + if (!AllowedDays.Contains(day)) + { + return AccessRuleValidationResult.Invalid($"Invalid day: '{day}'."); + } + } + + if (!TimeOfDayRegex().IsMatch(window.From)) + { + return AccessRuleValidationResult.Invalid($"Invalid 'from' time: '{window.From}'. Expected HH:mm."); + } + + if (!TimeOfDayRegex().IsMatch(window.To)) + { + return AccessRuleValidationResult.Invalid($"Invalid 'to' time: '{window.To}'. Expected HH:mm."); + } + } + + return AccessRuleValidationResult.Valid; + } + + private static AccessRuleValidationResult ValidateAllOf(AllOfRule rule, int depth) + { + if (depth >= MaxCompositeDepth) + { + return AccessRuleValidationResult.Invalid($"all_of nesting exceeds maximum depth of {MaxCompositeDepth}."); + } + + if (rule.Rules.Count == 0) + { + return AccessRuleValidationResult.Invalid("all_of requires at least one child rule."); + } + + if (rule.Rules.Count > MaxCompositeChildren) + { + return AccessRuleValidationResult.Invalid($"all_of cannot contain more than {MaxCompositeChildren} child rules."); + } + + foreach (var child in rule.Rules) + { + var childResult = ValidateRule(child, depth + 1); + if (!childResult.IsValid) + { + return childResult; + } + } + + return AccessRuleValidationResult.Valid; + } +} diff --git a/src/Core/PrivilegedAccessManagement/Services/IAccessRuleValidator.cs b/src/Core/PrivilegedAccessManagement/Services/IAccessRuleValidator.cs new file mode 100644 index 000000000000..a8b779281270 --- /dev/null +++ b/src/Core/PrivilegedAccessManagement/Services/IAccessRuleValidator.cs @@ -0,0 +1,16 @@ +namespace Bit.Core.PrivilegedAccessManagement.Services; + +public interface IAccessRuleValidator +{ + /// + /// Validates a raw JSON rule. A null or empty rule is treated as "no rule + /// configured" and considered valid; callers decide how to treat that semantically. + /// + AccessRuleValidationResult Validate(string? ruleJson); +} + +public sealed record AccessRuleValidationResult(bool IsValid, string? Error) +{ + public static AccessRuleValidationResult Valid { get; } = new(true, null); + public static AccessRuleValidationResult Invalid(string error) => new(false, error); +} diff --git a/src/Core/PrivilegedAccessManagement/Services/ILeasingPolicyValidator.cs b/src/Core/PrivilegedAccessManagement/Services/ILeasingPolicyValidator.cs deleted file mode 100644 index 1a7800558f6b..000000000000 --- a/src/Core/PrivilegedAccessManagement/Services/ILeasingPolicyValidator.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace Bit.Core.PrivilegedAccessManagement.Services; - -public interface ILeasingPolicyValidator -{ - /// - /// Validates a raw JSON leasing policy. A null or empty policy is treated as "no policy - /// configured" and considered valid; callers decide how to treat that semantically. - /// - LeasingPolicyValidationResult Validate(string? policyJson); -} - -public sealed record LeasingPolicyValidationResult(bool IsValid, string? Error) -{ - public static LeasingPolicyValidationResult Valid { get; } = new(true, null); - public static LeasingPolicyValidationResult Invalid(string error) => new(false, error); -} diff --git a/src/Core/PrivilegedAccessManagement/Services/LeasingPolicyValidator.cs b/src/Core/PrivilegedAccessManagement/Services/LeasingPolicyValidator.cs deleted file mode 100644 index af26e5b5bb99..000000000000 --- a/src/Core/PrivilegedAccessManagement/Services/LeasingPolicyValidator.cs +++ /dev/null @@ -1,167 +0,0 @@ -using System.Net; -using System.Text.Json; -using System.Text.RegularExpressions; -using Bit.Core.PrivilegedAccessManagement.Models.Policies; - -namespace Bit.Core.PrivilegedAccessManagement.Services; - -public sealed partial class LeasingPolicyValidator : ILeasingPolicyValidator -{ - private const int MaxCompositeDepth = 3; - private const int MaxCompositeChildren = 10; - - private static readonly HashSet AllowedDays = - new(StringComparer.OrdinalIgnoreCase) { "mon", "tue", "wed", "thu", "fri", "sat", "sun" }; - - private static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - PropertyNameCaseInsensitive = true, - }; - - [GeneratedRegex(@"^([01][0-9]|2[0-3]):[0-5][0-9]$")] - private static partial Regex TimeOfDayRegex(); - - public LeasingPolicyValidationResult Validate(string? policyJson) - { - if (policyJson is null) - { - return LeasingPolicyValidationResult.Valid; - } - - if (string.IsNullOrWhiteSpace(policyJson)) - { - return LeasingPolicyValidationResult.Invalid("Policy JSON cannot be empty."); - } - - LeasingPolicy? policy; - try - { - policy = JsonSerializer.Deserialize(policyJson, JsonOptions); - } - catch (JsonException ex) - { - return LeasingPolicyValidationResult.Invalid($"Policy JSON is malformed: {ex.Message}"); - } - - if (policy is null) - { - return LeasingPolicyValidationResult.Invalid("Policy must be an object."); - } - - return ValidatePolicy(policy, depth: 0); - } - - private static LeasingPolicyValidationResult ValidatePolicy(LeasingPolicy policy, int depth) - { - return policy switch - { - HumanApprovalPolicy => LeasingPolicyValidationResult.Valid, - IpAllowlistPolicy ip => ValidateIpAllowlist(ip), - TimeOfDayPolicy tod => ValidateTimeOfDay(tod), - AllOfPolicy all => ValidateAllOf(all, depth), - _ => LeasingPolicyValidationResult.Invalid($"Unsupported policy kind: {policy.GetType().Name}."), - }; - } - - private static LeasingPolicyValidationResult ValidateIpAllowlist(IpAllowlistPolicy policy) - { - if (policy.Cidrs.Count == 0) - { - return LeasingPolicyValidationResult.Invalid("ip_allowlist requires at least one CIDR."); - } - - foreach (var cidr in policy.Cidrs) - { - if (string.IsNullOrWhiteSpace(cidr) || !IPNetwork.TryParse(cidr, out _)) - { - return LeasingPolicyValidationResult.Invalid($"Invalid CIDR: '{cidr}'."); - } - } - - return LeasingPolicyValidationResult.Valid; - } - - private static LeasingPolicyValidationResult ValidateTimeOfDay(TimeOfDayPolicy policy) - { - if (string.IsNullOrWhiteSpace(policy.Tz)) - { - return LeasingPolicyValidationResult.Invalid("time_of_day requires a tz."); - } - - try - { - TimeZoneInfo.FindSystemTimeZoneById(policy.Tz); - } - catch (TimeZoneNotFoundException) - { - return LeasingPolicyValidationResult.Invalid($"Unknown timezone: '{policy.Tz}'."); - } - catch (InvalidTimeZoneException) - { - return LeasingPolicyValidationResult.Invalid($"Invalid timezone: '{policy.Tz}'."); - } - - if (policy.Windows.Count == 0) - { - return LeasingPolicyValidationResult.Invalid("time_of_day requires at least one window."); - } - - foreach (var window in policy.Windows) - { - if (window.Days.Count == 0) - { - return LeasingPolicyValidationResult.Invalid("time_of_day window requires at least one day."); - } - - foreach (var day in window.Days) - { - if (!AllowedDays.Contains(day)) - { - return LeasingPolicyValidationResult.Invalid($"Invalid day: '{day}'."); - } - } - - if (!TimeOfDayRegex().IsMatch(window.From)) - { - return LeasingPolicyValidationResult.Invalid($"Invalid 'from' time: '{window.From}'. Expected HH:mm."); - } - - if (!TimeOfDayRegex().IsMatch(window.To)) - { - return LeasingPolicyValidationResult.Invalid($"Invalid 'to' time: '{window.To}'. Expected HH:mm."); - } - } - - return LeasingPolicyValidationResult.Valid; - } - - private static LeasingPolicyValidationResult ValidateAllOf(AllOfPolicy policy, int depth) - { - if (depth >= MaxCompositeDepth) - { - return LeasingPolicyValidationResult.Invalid($"all_of nesting exceeds maximum depth of {MaxCompositeDepth}."); - } - - if (policy.Policies.Count == 0) - { - return LeasingPolicyValidationResult.Invalid("all_of requires at least one child policy."); - } - - if (policy.Policies.Count > MaxCompositeChildren) - { - return LeasingPolicyValidationResult.Invalid($"all_of cannot contain more than {MaxCompositeChildren} child policies."); - } - - foreach (var child in policy.Policies) - { - var childResult = ValidatePolicy(child, depth + 1); - if (!childResult.IsValid) - { - return childResult; - } - } - - return LeasingPolicyValidationResult.Valid; - } -} diff --git a/src/Infrastructure.Dapper/AdminConsole/Repositories/CollectionRepository.cs b/src/Infrastructure.Dapper/AdminConsole/Repositories/CollectionRepository.cs index 088ff66cdef7..85316ad63dea 100644 --- a/src/Infrastructure.Dapper/AdminConsole/Repositories/CollectionRepository.cs +++ b/src/Infrastructure.Dapper/AdminConsole/Repositories/CollectionRepository.cs @@ -486,7 +486,7 @@ public CollectionWithGroupsAndUsers(Collection collection, Type = collection.Type; ExternalId = collection.ExternalId; DefaultUserCollectionEmail = collection.DefaultUserCollectionEmail; - LeasingPolicyId = collection.LeasingPolicyId; + AccessRuleId = collection.AccessRuleId; Groups = groups.ToArrayTVP(); Users = users.ToArrayTVP(); } @@ -511,7 +511,7 @@ public CollectionWithGroups(Collection collection, IEnumerable(); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Infrastructure.Dapper/PrivilegedAccessManagement/Repositories/LeasingPolicyRepository.cs b/src/Infrastructure.Dapper/PrivilegedAccessManagement/Repositories/AccessRuleRepository.cs similarity index 60% rename from src/Infrastructure.Dapper/PrivilegedAccessManagement/Repositories/LeasingPolicyRepository.cs rename to src/Infrastructure.Dapper/PrivilegedAccessManagement/Repositories/AccessRuleRepository.cs index 7b671a963284..b930db58e001 100644 --- a/src/Infrastructure.Dapper/PrivilegedAccessManagement/Repositories/LeasingPolicyRepository.cs +++ b/src/Infrastructure.Dapper/PrivilegedAccessManagement/Repositories/AccessRuleRepository.cs @@ -10,21 +10,21 @@ namespace Bit.Infrastructure.Dapper.PrivilegedAccessManagement.Repositories; -public class LeasingPolicyRepository : Repository, ILeasingPolicyRepository +public class AccessRuleRepository : Repository, IAccessRuleRepository { - public LeasingPolicyRepository(GlobalSettings globalSettings) + public AccessRuleRepository(GlobalSettings globalSettings) : this(globalSettings.SqlServer.ConnectionString, globalSettings.SqlServer.ReadOnlyConnectionString) { } - public LeasingPolicyRepository(string connectionString, string readOnlyConnectionString) + public AccessRuleRepository(string connectionString, string readOnlyConnectionString) : base(connectionString, readOnlyConnectionString) { } - public async Task> GetManyByOrganizationIdAsync(Guid organizationId) + public async Task> GetManyByOrganizationIdAsync(Guid organizationId) { using var connection = new SqlConnection(ConnectionString); - var results = await connection.QueryAsync( - $"[{Schema}].[LeasingPolicy_ReadByOrganizationId]", + var results = await connection.QueryAsync( + $"[{Schema}].[AccessRule_ReadByOrganizationId]", new { OrganizationId = organizationId }, commandType: CommandType.StoredProcedure); diff --git a/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs b/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs index 4dcd0a2c1c3b..e0544176d16d 100644 --- a/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs +++ b/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs @@ -103,7 +103,7 @@ public static void AddPasswordManagerEFRepositories(this IServiceCollection serv services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Infrastructure.EntityFramework/PrivilegedAccessManagement/Models/LeasingPolicy.cs b/src/Infrastructure.EntityFramework/PrivilegedAccessManagement/Models/AccessRule.cs similarity index 54% rename from src/Infrastructure.EntityFramework/PrivilegedAccessManagement/Models/LeasingPolicy.cs rename to src/Infrastructure.EntityFramework/PrivilegedAccessManagement/Models/AccessRule.cs index 14152118eeef..530ac5a40a99 100644 --- a/src/Infrastructure.EntityFramework/PrivilegedAccessManagement/Models/LeasingPolicy.cs +++ b/src/Infrastructure.EntityFramework/PrivilegedAccessManagement/Models/AccessRule.cs @@ -6,15 +6,15 @@ namespace Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Models; -public class LeasingPolicy : Core.PrivilegedAccessManagement.Entities.LeasingPolicy +public class AccessRule : Core.PrivilegedAccessManagement.Entities.AccessRule { public virtual Organization Organization { get; set; } } -public class LeasingPolicyMapperProfile : Profile +public class AccessRuleMapperProfile : Profile { - public LeasingPolicyMapperProfile() + public AccessRuleMapperProfile() { - CreateMap().ReverseMap(); + CreateMap().ReverseMap(); } } diff --git a/src/Infrastructure.EntityFramework/PrivilegedAccessManagement/Repositories/LeasingPolicyRepository.cs b/src/Infrastructure.EntityFramework/PrivilegedAccessManagement/Repositories/AccessRuleRepository.cs similarity index 64% rename from src/Infrastructure.EntityFramework/PrivilegedAccessManagement/Repositories/LeasingPolicyRepository.cs rename to src/Infrastructure.EntityFramework/PrivilegedAccessManagement/Repositories/AccessRuleRepository.cs index 00ff2d29c844..bcd205b8073e 100644 --- a/src/Infrastructure.EntityFramework/PrivilegedAccessManagement/Repositories/LeasingPolicyRepository.cs +++ b/src/Infrastructure.EntityFramework/PrivilegedAccessManagement/Repositories/AccessRuleRepository.cs @@ -3,27 +3,27 @@ using Bit.Infrastructure.EntityFramework.Repositories; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; -using CoreEntity = Bit.Core.PrivilegedAccessManagement.Entities.LeasingPolicy; -using EfModel = Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Models.LeasingPolicy; +using CoreEntity = Bit.Core.PrivilegedAccessManagement.Entities.AccessRule; +using EfModel = Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Models.AccessRule; #nullable enable namespace Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Repositories; -public class LeasingPolicyRepository : Repository, ILeasingPolicyRepository +public class AccessRuleRepository : Repository, IAccessRuleRepository { - public LeasingPolicyRepository(IServiceScopeFactory serviceScopeFactory, IMapper mapper) - : base(serviceScopeFactory, mapper, context => context.LeasingPolicies) + public AccessRuleRepository(IServiceScopeFactory serviceScopeFactory, IMapper mapper) + : base(serviceScopeFactory, mapper, context => context.AccessRules) { } public async Task> GetManyByOrganizationIdAsync(Guid organizationId) { using var scope = ServiceScopeFactory.CreateScope(); var dbContext = GetDatabaseContext(scope); - var policies = await dbContext.LeasingPolicies + var rules = await dbContext.AccessRules .Where(p => p.OrganizationId == organizationId) .AsNoTracking() .ToListAsync(); - return Mapper.Map>(policies); + return Mapper.Map>(rules); } } diff --git a/src/Infrastructure.EntityFramework/Repositories/DatabaseContext.cs b/src/Infrastructure.EntityFramework/Repositories/DatabaseContext.cs index a20a6447a1c8..ea213d4d6271 100644 --- a/src/Infrastructure.EntityFramework/Repositories/DatabaseContext.cs +++ b/src/Infrastructure.EntityFramework/Repositories/DatabaseContext.cs @@ -44,7 +44,7 @@ public DatabaseContext(DbContextOptions options) public DbSet CollectionCiphers { get; set; } public DbSet CollectionGroups { get; set; } public DbSet CollectionUsers { get; set; } - public DbSet LeasingPolicies { get; set; } + public DbSet AccessRules { get; set; } public DbSet Devices { get; set; } public DbSet EmergencyAccesses { get; set; } public DbSet Events { get; set; } @@ -109,7 +109,7 @@ protected override void OnModelCreating(ModelBuilder builder) var eCollectionCipher = builder.Entity(); var eCollectionUser = builder.Entity(); var eCollectionGroup = builder.Entity(); - var eLeasingPolicy = builder.Entity(); + var eAccessRule = builder.Entity(); var eEmergencyAccess = builder.Entity(); var eFolder = builder.Entity(); var eGroup = builder.Entity(); @@ -149,12 +149,12 @@ protected override void OnModelCreating(ModelBuilder builder) eCollectionGroup.HasKey(cg => new { cg.CollectionId, cg.GroupId }); eGroupUser.HasKey(gu => new { gu.GroupId, gu.OrganizationUserId }); - eLeasingPolicy.Property(p => p.Id).ValueGeneratedNever(); - eLeasingPolicy.HasIndex(p => new { p.OrganizationId, p.Name }).IsUnique(); + eAccessRule.Property(p => p.Id).ValueGeneratedNever(); + eAccessRule.HasIndex(p => new { p.OrganizationId, p.Name }).IsUnique(); eCollection - .HasOne() + .HasOne() .WithMany() - .HasForeignKey(c => c.LeasingPolicyId) + .HasForeignKey(c => c.AccessRuleId) .OnDelete(DeleteBehavior.Restrict); eOrganizationMemberBaseDetail.HasNoKey(); @@ -180,7 +180,7 @@ protected override void OnModelCreating(ModelBuilder builder) eCipher.ToTable(nameof(Cipher)); eCollection.ToTable(nameof(Collection)); eCollectionCipher.ToTable(nameof(CollectionCipher)); - eLeasingPolicy.ToTable(nameof(LeasingPolicy)); + eAccessRule.ToTable(nameof(AccessRule)); eEmergencyAccess.ToTable(nameof(EmergencyAccess)); eFolder.ToTable(nameof(Folder)); eGroup.ToTable(nameof(Group)); diff --git a/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_Create.sql b/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_Create.sql index 851348ab994c..a5cdefd64201 100644 --- a/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_Create.sql +++ b/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_Create.sql @@ -7,7 +7,7 @@ @RevisionDate DATETIME2(7), @DefaultUserCollectionEmail NVARCHAR(256) = NULL, @Type TINYINT = 0, - @LeasingPolicyId UNIQUEIDENTIFIER = NULL + @AccessRuleId UNIQUEIDENTIFIER = NULL AS BEGIN SET NOCOUNT ON @@ -22,7 +22,7 @@ BEGIN [RevisionDate], [DefaultUserCollectionEmail], [Type], - [LeasingPolicyId] + [AccessRuleId] ) VALUES ( @@ -34,7 +34,7 @@ BEGIN @RevisionDate, @DefaultUserCollectionEmail, @Type, - @LeasingPolicyId + @AccessRuleId ) EXEC [dbo].[User_BumpAccountRevisionDateByCollectionId] @Id, @OrganizationId diff --git a/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_CreateWithGroupsAndUsers.sql b/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_CreateWithGroupsAndUsers.sql index b44364bcf955..cfd80ae9136e 100644 --- a/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_CreateWithGroupsAndUsers.sql +++ b/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_CreateWithGroupsAndUsers.sql @@ -9,12 +9,12 @@ CREATE PROCEDURE [dbo].[Collection_CreateWithGroupsAndUsers] @Users AS [dbo].[CollectionAccessSelectionType] READONLY, @DefaultUserCollectionEmail NVARCHAR(256) = NULL, @Type TINYINT = 0, - @LeasingPolicyId UNIQUEIDENTIFIER = NULL + @AccessRuleId UNIQUEIDENTIFIER = NULL AS BEGIN SET NOCOUNT ON - EXEC [dbo].[Collection_Create] @Id, @OrganizationId, @Name, @ExternalId, @CreationDate, @RevisionDate, @DefaultUserCollectionEmail, @Type, @LeasingPolicyId + EXEC [dbo].[Collection_Create] @Id, @OrganizationId, @Name, @ExternalId, @CreationDate, @RevisionDate, @DefaultUserCollectionEmail, @Type, @AccessRuleId -- Groups ;WITH [AvailableGroupsCTE] AS( diff --git a/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_ReadByIdWithPermissions.sql b/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_ReadByIdWithPermissions.sql index 6bc8de5914e3..cb23b2cb4d9e 100644 --- a/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_ReadByIdWithPermissions.sql +++ b/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_ReadByIdWithPermissions.sql @@ -76,7 +76,7 @@ BEGIN C.[ExternalId], C.[DefaultUserCollectionEmail], C.[Type], - C.[LeasingPolicyId] + C.[AccessRuleId] IF (@IncludeAccessRelationships = 1) BEGIN diff --git a/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_ReadByUserId.sql b/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_ReadByUserId.sql index 2ef93c446c81..c9bd7e96bee1 100644 --- a/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_ReadByUserId.sql +++ b/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_ReadByUserId.sql @@ -16,7 +16,7 @@ BEGIN MAX([Manage]) AS [Manage], [DefaultUserCollectionEmail], [Type], - [LeasingPolicyId] + [AccessRuleId] FROM [dbo].[UserCollectionDetails](@UserId) GROUP BY @@ -28,5 +28,5 @@ BEGIN ExternalId, [DefaultUserCollectionEmail], [Type], - [LeasingPolicyId] + [AccessRuleId] END diff --git a/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_ReadSharedCollectionsByOrganizationIdWithPermissions.sql b/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_ReadSharedCollectionsByOrganizationIdWithPermissions.sql index 088150ef84fb..9310c102e023 100644 --- a/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_ReadSharedCollectionsByOrganizationIdWithPermissions.sql +++ b/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_ReadSharedCollectionsByOrganizationIdWithPermissions.sql @@ -77,7 +77,7 @@ BEGIN C.[ExternalId], C.[DefaultUserCollectionEmail], C.[Type], - C.[LeasingPolicyId] + C.[AccessRuleId] IF (@IncludeAccessRelationships = 1) BEGIN diff --git a/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_Update.sql b/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_Update.sql index f8d83d293855..5bf3566a51af 100644 --- a/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_Update.sql +++ b/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_Update.sql @@ -7,7 +7,7 @@ @RevisionDate DATETIME2(7), @DefaultUserCollectionEmail NVARCHAR(256) = NULL, @Type TINYINT = 0, - @LeasingPolicyId UNIQUEIDENTIFIER = NULL + @AccessRuleId UNIQUEIDENTIFIER = NULL AS BEGIN SET NOCOUNT ON @@ -22,7 +22,7 @@ BEGIN [RevisionDate] = @RevisionDate, [DefaultUserCollectionEmail] = @DefaultUserCollectionEmail, [Type] = @Type, - [LeasingPolicyId] = @LeasingPolicyId + [AccessRuleId] = @AccessRuleId WHERE [Id] = @Id diff --git a/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_UpdateWithGroups.sql b/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_UpdateWithGroups.sql index 03d65ad73d2b..d0380f899fc6 100644 --- a/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_UpdateWithGroups.sql +++ b/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_UpdateWithGroups.sql @@ -8,12 +8,12 @@ CREATE PROCEDURE [dbo].[Collection_UpdateWithGroups] @Groups AS [dbo].[CollectionAccessSelectionType] READONLY, @DefaultUserCollectionEmail NVARCHAR(256) = NULL, @Type TINYINT = 0, - @LeasingPolicyId UNIQUEIDENTIFIER = NULL + @AccessRuleId UNIQUEIDENTIFIER = NULL AS BEGIN SET NOCOUNT ON - EXEC [dbo].[Collection_Update] @Id, @OrganizationId, @Name, @ExternalId, @CreationDate, @RevisionDate, @DefaultUserCollectionEmail, @Type, @LeasingPolicyId + EXEC [dbo].[Collection_Update] @Id, @OrganizationId, @Name, @ExternalId, @CreationDate, @RevisionDate, @DefaultUserCollectionEmail, @Type, @AccessRuleId -- Bump RevisionDate on all affected groups (old + new) before modifying CollectionGroup ;WITH [AffectedGroupsCTE] AS ( diff --git a/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_UpdateWithGroupsAndUsers.sql b/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_UpdateWithGroupsAndUsers.sql index 832db577e53a..3be2d74dc61b 100644 --- a/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_UpdateWithGroupsAndUsers.sql +++ b/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_UpdateWithGroupsAndUsers.sql @@ -9,12 +9,12 @@ @Users AS [dbo].[CollectionAccessSelectionType] READONLY, @DefaultUserCollectionEmail NVARCHAR(256) = NULL, @Type TINYINT = 0, - @LeasingPolicyId UNIQUEIDENTIFIER = NULL + @AccessRuleId UNIQUEIDENTIFIER = NULL AS BEGIN SET NOCOUNT ON - EXEC [dbo].[Collection_Update] @Id, @OrganizationId, @Name, @ExternalId, @CreationDate, @RevisionDate, @DefaultUserCollectionEmail, @Type, @LeasingPolicyId + EXEC [dbo].[Collection_Update] @Id, @OrganizationId, @Name, @ExternalId, @CreationDate, @RevisionDate, @DefaultUserCollectionEmail, @Type, @AccessRuleId -- Bump RevisionDate on all affected groups (old + new) before modifying CollectionGroup ;WITH [AffectedGroupsCTE] AS ( diff --git a/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_UpdateWithUsers.sql b/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_UpdateWithUsers.sql index a56a4763ff98..ae51a43fabb0 100644 --- a/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_UpdateWithUsers.sql +++ b/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_UpdateWithUsers.sql @@ -8,12 +8,12 @@ CREATE PROCEDURE [dbo].[Collection_UpdateWithUsers] @Users AS [dbo].[CollectionAccessSelectionType] READONLY, @DefaultUserCollectionEmail NVARCHAR(256) = NULL, @Type TINYINT = 0, - @LeasingPolicyId UNIQUEIDENTIFIER = NULL + @AccessRuleId UNIQUEIDENTIFIER = NULL AS BEGIN SET NOCOUNT ON - EXEC [dbo].[Collection_Update] @Id, @OrganizationId, @Name, @ExternalId, @CreationDate, @RevisionDate, @DefaultUserCollectionEmail, @Type, @LeasingPolicyId + EXEC [dbo].[Collection_Update] @Id, @OrganizationId, @Name, @ExternalId, @CreationDate, @RevisionDate, @DefaultUserCollectionEmail, @Type, @AccessRuleId -- Users -- Delete users that are no longer in source diff --git a/src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/LeasingPolicy_Create.sql b/src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/AccessRule_Create.sql similarity index 77% rename from src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/LeasingPolicy_Create.sql rename to src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/AccessRule_Create.sql index e969c7b49d33..0a2bf8a4c3f3 100644 --- a/src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/LeasingPolicy_Create.sql +++ b/src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/AccessRule_Create.sql @@ -1,22 +1,22 @@ -CREATE PROCEDURE [dbo].[LeasingPolicy_Create] +CREATE PROCEDURE [dbo].[AccessRule_Create] @Id UNIQUEIDENTIFIER OUTPUT, @OrganizationId UNIQUEIDENTIFIER, @Name NVARCHAR(256), @Description NVARCHAR(MAX) = NULL, - @Policy NVARCHAR(MAX), + @Rule NVARCHAR(MAX), @CreationDate DATETIME2(7), @RevisionDate DATETIME2(7) AS BEGIN SET NOCOUNT ON - INSERT INTO [dbo].[LeasingPolicy] + INSERT INTO [dbo].[AccessRule] ( [Id], [OrganizationId], [Name], [Description], - [Policy], + [Rule], [CreationDate], [RevisionDate] ) @@ -26,7 +26,7 @@ BEGIN @OrganizationId, @Name, @Description, - @Policy, + @Rule, @CreationDate, @RevisionDate ) diff --git a/src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/AccessRule_DeleteById.sql b/src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/AccessRule_DeleteById.sql new file mode 100644 index 000000000000..238724033a67 --- /dev/null +++ b/src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/AccessRule_DeleteById.sql @@ -0,0 +1,8 @@ +CREATE PROCEDURE [dbo].[AccessRule_DeleteById] + @Id UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + DELETE FROM [dbo].[AccessRule] WHERE [Id] = @Id +END diff --git a/src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/LeasingPolicy_ReadById.sql b/src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/AccessRule_ReadById.sql similarity index 53% rename from src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/LeasingPolicy_ReadById.sql rename to src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/AccessRule_ReadById.sql index 191ad05ff594..1851baead921 100644 --- a/src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/LeasingPolicy_ReadById.sql +++ b/src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/AccessRule_ReadById.sql @@ -1,10 +1,10 @@ -CREATE PROCEDURE [dbo].[LeasingPolicy_ReadById] +CREATE PROCEDURE [dbo].[AccessRule_ReadById] @Id UNIQUEIDENTIFIER AS BEGIN SET NOCOUNT ON SELECT * - FROM [dbo].[LeasingPolicy] + FROM [dbo].[AccessRule] WHERE [Id] = @Id END diff --git a/src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/LeasingPolicy_ReadByOrganizationId.sql b/src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/AccessRule_ReadByOrganizationId.sql similarity index 58% rename from src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/LeasingPolicy_ReadByOrganizationId.sql rename to src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/AccessRule_ReadByOrganizationId.sql index b23f8e72851d..60c001940650 100644 --- a/src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/LeasingPolicy_ReadByOrganizationId.sql +++ b/src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/AccessRule_ReadByOrganizationId.sql @@ -1,10 +1,10 @@ -CREATE PROCEDURE [dbo].[LeasingPolicy_ReadByOrganizationId] +CREATE PROCEDURE [dbo].[AccessRule_ReadByOrganizationId] @OrganizationId UNIQUEIDENTIFIER AS BEGIN SET NOCOUNT ON SELECT * - FROM [dbo].[LeasingPolicy] + FROM [dbo].[AccessRule] WHERE [OrganizationId] = @OrganizationId END diff --git a/src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/LeasingPolicy_Update.sql b/src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/AccessRule_Update.sql similarity index 77% rename from src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/LeasingPolicy_Update.sql rename to src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/AccessRule_Update.sql index 4e04ceb7860a..65fb49128a11 100644 --- a/src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/LeasingPolicy_Update.sql +++ b/src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/AccessRule_Update.sql @@ -1,9 +1,9 @@ -CREATE PROCEDURE [dbo].[LeasingPolicy_Update] +CREATE PROCEDURE [dbo].[AccessRule_Update] @Id UNIQUEIDENTIFIER, @OrganizationId UNIQUEIDENTIFIER, @Name NVARCHAR(256), @Description NVARCHAR(MAX) = NULL, - @Policy NVARCHAR(MAX), + @Rule NVARCHAR(MAX), @CreationDate DATETIME2(7), @RevisionDate DATETIME2(7) AS @@ -11,12 +11,12 @@ BEGIN SET NOCOUNT ON UPDATE - [dbo].[LeasingPolicy] + [dbo].[AccessRule] SET [OrganizationId] = @OrganizationId, [Name] = @Name, [Description] = @Description, - [Policy] = @Policy, + [Rule] = @Rule, [CreationDate] = @CreationDate, [RevisionDate] = @RevisionDate WHERE diff --git a/src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/LeasingPolicy_DeleteById.sql b/src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/LeasingPolicy_DeleteById.sql deleted file mode 100644 index dbad954f6099..000000000000 --- a/src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/LeasingPolicy_DeleteById.sql +++ /dev/null @@ -1,8 +0,0 @@ -CREATE PROCEDURE [dbo].[LeasingPolicy_DeleteById] - @Id UNIQUEIDENTIFIER -AS -BEGIN - SET NOCOUNT ON - - DELETE FROM [dbo].[LeasingPolicy] WHERE [Id] = @Id -END diff --git a/src/Sql/dbo/PrivilegedAccessManagement/Tables/LeasingPolicy.sql b/src/Sql/dbo/PrivilegedAccessManagement/Tables/AccessRule.sql similarity index 51% rename from src/Sql/dbo/PrivilegedAccessManagement/Tables/LeasingPolicy.sql rename to src/Sql/dbo/PrivilegedAccessManagement/Tables/AccessRule.sql index e078bcd50b6e..e9f9c3cb34dc 100644 --- a/src/Sql/dbo/PrivilegedAccessManagement/Tables/LeasingPolicy.sql +++ b/src/Sql/dbo/PrivilegedAccessManagement/Tables/AccessRule.sql @@ -1,17 +1,17 @@ -CREATE TABLE [dbo].[LeasingPolicy] ( +CREATE TABLE [dbo].[AccessRule] ( [Id] UNIQUEIDENTIFIER NOT NULL, [OrganizationId] UNIQUEIDENTIFIER NOT NULL, [Name] NVARCHAR(256) NOT NULL, [Description] NVARCHAR(MAX) NULL, - [Policy] NVARCHAR(MAX) NOT NULL, + [Rule] NVARCHAR(MAX) NOT NULL, [CreationDate] DATETIME2(7) NOT NULL, [RevisionDate] DATETIME2(7) NOT NULL, - CONSTRAINT [PK_LeasingPolicy] PRIMARY KEY CLUSTERED ([Id] ASC), - CONSTRAINT [FK_LeasingPolicy_Organization] FOREIGN KEY ([OrganizationId]) + CONSTRAINT [PK_AccessRule] PRIMARY KEY CLUSTERED ([Id] ASC), + CONSTRAINT [FK_AccessRule_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]) ON DELETE CASCADE ); GO -CREATE UNIQUE NONCLUSTERED INDEX [IX_LeasingPolicy_OrganizationId_Name] - ON [dbo].[LeasingPolicy] ([OrganizationId] ASC, [Name] ASC); +CREATE UNIQUE NONCLUSTERED INDEX [IX_AccessRule_OrganizationId_Name] + ON [dbo].[AccessRule] ([OrganizationId] ASC, [Name] ASC); GO diff --git a/src/Sql/dbo/Tables/Collection.sql b/src/Sql/dbo/Tables/Collection.sql index 45cc9050df12..b5bef98062a0 100644 --- a/src/Sql/dbo/Tables/Collection.sql +++ b/src/Sql/dbo/Tables/Collection.sql @@ -7,10 +7,10 @@ [RevisionDate] DATETIME2 (7) NOT NULL, [DefaultUserCollectionEmail] NVARCHAR(256) NULL, [Type] TINYINT NOT NULL DEFAULT(0), - [LeasingPolicyId] UNIQUEIDENTIFIER NULL, + [AccessRuleId] UNIQUEIDENTIFIER NULL, CONSTRAINT [PK_Collection] PRIMARY KEY CLUSTERED ([Id] ASC), CONSTRAINT [FK_Collection_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]) ON DELETE CASCADE, - CONSTRAINT [FK_Collection_LeasingPolicy] FOREIGN KEY ([LeasingPolicyId]) REFERENCES [dbo].[LeasingPolicy] ([Id]) ON DELETE SET NULL + CONSTRAINT [FK_Collection_AccessRule] FOREIGN KEY ([AccessRuleId]) REFERENCES [dbo].[AccessRule] ([Id]) ON DELETE NO ACTION ); GO @@ -19,7 +19,7 @@ CREATE NONCLUSTERED INDEX [IX_Collection_OrganizationId_IncludeAll] INCLUDE([CreationDate], [Name], [RevisionDate], [Type]); GO -CREATE NONCLUSTERED INDEX [IX_Collection_LeasingPolicyId] - ON [dbo].[Collection]([LeasingPolicyId] ASC); +CREATE NONCLUSTERED INDEX [IX_Collection_AccessRuleId] + ON [dbo].[Collection]([AccessRuleId] ASC); GO diff --git a/test/Core.Test/PrivilegedAccessManagement/Commands/CreateAccessRuleCommandTests.cs b/test/Core.Test/PrivilegedAccessManagement/Commands/CreateAccessRuleCommandTests.cs new file mode 100644 index 000000000000..6717d8f0c911 --- /dev/null +++ b/test/Core.Test/PrivilegedAccessManagement/Commands/CreateAccessRuleCommandTests.cs @@ -0,0 +1,96 @@ +using Bit.Core.Exceptions; +using Bit.Core.PrivilegedAccessManagement.Entities; +using Bit.Core.PrivilegedAccessManagement.OrganizationFeatures.Commands; +using Bit.Core.PrivilegedAccessManagement.Repositories; +using Bit.Core.PrivilegedAccessManagement.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.Extensions.Time.Testing; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.PrivilegedAccessManagement.Commands; + +[SutProviderCustomize] +public class CreateAccessRuleCommandTests +{ + private static readonly DateTime _now = new(2026, 5, 21, 12, 0, 0, DateTimeKind.Utc); + + [Theory, BitAutoData] + public async Task CreateAsync_HappyPath_PersistsWithTimestampsAndValidates(AccessRule rule) + { + var sutProvider = SetupSutProvider(); + rule.Name = "VPN + business hours"; + rule.Rule = """{"kind":"human_approval"}"""; + sutProvider.GetDependency() + .Validate(rule.Rule) + .Returns(AccessRuleValidationResult.Valid); + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(rule.OrganizationId) + .Returns(new List()); + sutProvider.GetDependency() + .CreateAsync(rule) + .Returns(rule); + + var result = await sutProvider.Sut.CreateAsync(rule); + + Assert.Equal(_now, result.CreationDate); + Assert.Equal(_now, result.RevisionDate); + await sutProvider.GetDependency().Received(1).CreateAsync(rule); + } + + [Theory, BitAutoData] + public async Task CreateAsync_EmptyName_ThrowsBadRequest(AccessRule rule) + { + var sutProvider = SetupSutProvider(); + rule.Name = " "; + + var ex = await Assert.ThrowsAsync(() => sutProvider.Sut.CreateAsync(rule)); + Assert.Contains("Name is required", ex.Message); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().CreateAsync(default!); + } + + [Theory, BitAutoData] + public async Task CreateAsync_InvalidRule_ThrowsBadRequest(AccessRule rule) + { + var sutProvider = SetupSutProvider(); + rule.Name = "test"; + rule.Rule = """{"kind":"bogus"}"""; + sutProvider.GetDependency() + .Validate(rule.Rule) + .Returns(AccessRuleValidationResult.Invalid("Unsupported rule kind")); + + var ex = await Assert.ThrowsAsync(() => sutProvider.Sut.CreateAsync(rule)); + Assert.Equal("Unsupported rule kind", ex.Message); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().CreateAsync(default!); + } + + [Theory, BitAutoData] + public async Task CreateAsync_DuplicateName_ThrowsBadRequest(AccessRule rule, AccessRule existing) + { + var sutProvider = SetupSutProvider(); + rule.Name = "duplicate"; + rule.Rule = """{"kind":"human_approval"}"""; + existing.OrganizationId = rule.OrganizationId; + existing.Name = "Duplicate"; // case-insensitive collision + sutProvider.GetDependency() + .Validate(rule.Rule) + .Returns(AccessRuleValidationResult.Valid); + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(rule.OrganizationId) + .Returns(new List { existing }); + + var ex = await Assert.ThrowsAsync(() => sutProvider.Sut.CreateAsync(rule)); + Assert.Contains("already exists", ex.Message); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().CreateAsync(default!); + } + + private static SutProvider SetupSutProvider() + { + var sutProvider = new SutProvider() + .WithFakeTimeProvider() + .Create(); + sutProvider.GetDependency().SetUtcNow(_now); + return sutProvider; + } +} diff --git a/test/Core.Test/PrivilegedAccessManagement/Commands/CreateLeasingPolicyCommandTests.cs b/test/Core.Test/PrivilegedAccessManagement/Commands/CreateLeasingPolicyCommandTests.cs deleted file mode 100644 index 1ae5b5d2a331..000000000000 --- a/test/Core.Test/PrivilegedAccessManagement/Commands/CreateLeasingPolicyCommandTests.cs +++ /dev/null @@ -1,96 +0,0 @@ -using Bit.Core.Exceptions; -using Bit.Core.PrivilegedAccessManagement.Entities; -using Bit.Core.PrivilegedAccessManagement.OrganizationFeatures.Commands; -using Bit.Core.PrivilegedAccessManagement.Repositories; -using Bit.Core.PrivilegedAccessManagement.Services; -using Bit.Test.Common.AutoFixture; -using Bit.Test.Common.AutoFixture.Attributes; -using Microsoft.Extensions.Time.Testing; -using NSubstitute; -using Xunit; - -namespace Bit.Core.Test.PrivilegedAccessManagement.Commands; - -[SutProviderCustomize] -public class CreateLeasingPolicyCommandTests -{ - private static readonly DateTime _now = new(2026, 5, 21, 12, 0, 0, DateTimeKind.Utc); - - [Theory, BitAutoData] - public async Task CreateAsync_HappyPath_PersistsWithTimestampsAndValidates(LeasingPolicy policy) - { - var sutProvider = SetupSutProvider(); - policy.Name = "VPN + business hours"; - policy.Policy = """{"kind":"human_approval"}"""; - sutProvider.GetDependency() - .Validate(policy.Policy) - .Returns(LeasingPolicyValidationResult.Valid); - sutProvider.GetDependency() - .GetManyByOrganizationIdAsync(policy.OrganizationId) - .Returns(new List()); - sutProvider.GetDependency() - .CreateAsync(policy) - .Returns(policy); - - var result = await sutProvider.Sut.CreateAsync(policy); - - Assert.Equal(_now, result.CreationDate); - Assert.Equal(_now, result.RevisionDate); - await sutProvider.GetDependency().Received(1).CreateAsync(policy); - } - - [Theory, BitAutoData] - public async Task CreateAsync_EmptyName_ThrowsBadRequest(LeasingPolicy policy) - { - var sutProvider = SetupSutProvider(); - policy.Name = " "; - - var ex = await Assert.ThrowsAsync(() => sutProvider.Sut.CreateAsync(policy)); - Assert.Contains("Name is required", ex.Message); - await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().CreateAsync(default!); - } - - [Theory, BitAutoData] - public async Task CreateAsync_InvalidPolicy_ThrowsBadRequest(LeasingPolicy policy) - { - var sutProvider = SetupSutProvider(); - policy.Name = "test"; - policy.Policy = """{"kind":"bogus"}"""; - sutProvider.GetDependency() - .Validate(policy.Policy) - .Returns(LeasingPolicyValidationResult.Invalid("Unsupported policy kind")); - - var ex = await Assert.ThrowsAsync(() => sutProvider.Sut.CreateAsync(policy)); - Assert.Equal("Unsupported policy kind", ex.Message); - await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().CreateAsync(default!); - } - - [Theory, BitAutoData] - public async Task CreateAsync_DuplicateName_ThrowsBadRequest(LeasingPolicy policy, LeasingPolicy existing) - { - var sutProvider = SetupSutProvider(); - policy.Name = "duplicate"; - policy.Policy = """{"kind":"human_approval"}"""; - existing.OrganizationId = policy.OrganizationId; - existing.Name = "Duplicate"; // case-insensitive collision - sutProvider.GetDependency() - .Validate(policy.Policy) - .Returns(LeasingPolicyValidationResult.Valid); - sutProvider.GetDependency() - .GetManyByOrganizationIdAsync(policy.OrganizationId) - .Returns(new List { existing }); - - var ex = await Assert.ThrowsAsync(() => sutProvider.Sut.CreateAsync(policy)); - Assert.Contains("already exists", ex.Message); - await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().CreateAsync(default!); - } - - private static SutProvider SetupSutProvider() - { - var sutProvider = new SutProvider() - .WithFakeTimeProvider() - .Create(); - sutProvider.GetDependency().SetUtcNow(_now); - return sutProvider; - } -} diff --git a/test/Core.Test/PrivilegedAccessManagement/Commands/DeleteLeasingPolicyCommandTests.cs b/test/Core.Test/PrivilegedAccessManagement/Commands/DeleteAccessRuleCommandTests.cs similarity index 59% rename from test/Core.Test/PrivilegedAccessManagement/Commands/DeleteLeasingPolicyCommandTests.cs rename to test/Core.Test/PrivilegedAccessManagement/Commands/DeleteAccessRuleCommandTests.cs index 341c3120f6b9..6455ee34ec71 100644 --- a/test/Core.Test/PrivilegedAccessManagement/Commands/DeleteLeasingPolicyCommandTests.cs +++ b/test/Core.Test/PrivilegedAccessManagement/Commands/DeleteAccessRuleCommandTests.cs @@ -10,44 +10,44 @@ namespace Bit.Core.Test.PrivilegedAccessManagement.Commands; [SutProviderCustomize] -public class DeleteLeasingPolicyCommandTests +public class DeleteAccessRuleCommandTests { [Theory, BitAutoData] public async Task DeleteAsync_HappyPath_Deletes( - LeasingPolicy existing, SutProvider sutProvider) + AccessRule existing, SutProvider sutProvider) { - sutProvider.GetDependency() + sutProvider.GetDependency() .GetByIdAsync(existing.Id) .Returns(existing); await sutProvider.Sut.DeleteAsync(existing.OrganizationId, existing.Id); - await sutProvider.GetDependency().Received(1).DeleteAsync(existing); + await sutProvider.GetDependency().Received(1).DeleteAsync(existing); } [Theory, BitAutoData] public async Task DeleteAsync_MissingExisting_ThrowsNotFound( - SutProvider sutProvider) + SutProvider sutProvider) { - sutProvider.GetDependency() + sutProvider.GetDependency() .GetByIdAsync(Arg.Any()) - .Returns((LeasingPolicy?)null); + .Returns((AccessRule?)null); await Assert.ThrowsAsync( () => sutProvider.Sut.DeleteAsync(Guid.NewGuid(), Guid.NewGuid())); - await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().DeleteAsync(default!); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().DeleteAsync(default!); } [Theory, BitAutoData] public async Task DeleteAsync_WrongOrg_ThrowsNotFound( - LeasingPolicy existing, SutProvider sutProvider) + AccessRule existing, SutProvider sutProvider) { - sutProvider.GetDependency() + sutProvider.GetDependency() .GetByIdAsync(existing.Id) .Returns(existing); await Assert.ThrowsAsync( () => sutProvider.Sut.DeleteAsync(Guid.NewGuid(), existing.Id)); - await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().DeleteAsync(default!); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().DeleteAsync(default!); } } diff --git a/test/Core.Test/PrivilegedAccessManagement/Commands/UpdateLeasingPolicyCommandTests.cs b/test/Core.Test/PrivilegedAccessManagement/Commands/UpdateAccessRuleCommandTests.cs similarity index 60% rename from test/Core.Test/PrivilegedAccessManagement/Commands/UpdateLeasingPolicyCommandTests.cs rename to test/Core.Test/PrivilegedAccessManagement/Commands/UpdateAccessRuleCommandTests.cs index 34e56ea9adfb..472d596b8d81 100644 --- a/test/Core.Test/PrivilegedAccessManagement/Commands/UpdateLeasingPolicyCommandTests.cs +++ b/test/Core.Test/PrivilegedAccessManagement/Commands/UpdateAccessRuleCommandTests.cs @@ -12,55 +12,55 @@ namespace Bit.Core.Test.PrivilegedAccessManagement.Commands; [SutProviderCustomize] -public class UpdateLeasingPolicyCommandTests +public class UpdateAccessRuleCommandTests { private static readonly DateTime _now = new(2026, 5, 21, 12, 0, 0, DateTimeKind.Utc); [Theory, BitAutoData] - public async Task UpdateAsync_HappyPath_UpdatesFieldsAndBumpsRevision(LeasingPolicy existing, LeasingPolicy update) + public async Task UpdateAsync_HappyPath_UpdatesFieldsAndBumpsRevision(AccessRule existing, AccessRule update) { var sutProvider = SetupSutProvider(); var orgId = existing.OrganizationId; update.Name = "renamed"; update.Description = "new description"; - update.Policy = """{"kind":"human_approval"}"""; - sutProvider.GetDependency() + update.Rule = """{"kind":"human_approval"}"""; + sutProvider.GetDependency() .GetByIdAsync(existing.Id) .Returns(existing); - sutProvider.GetDependency() - .Validate(update.Policy) - .Returns(LeasingPolicyValidationResult.Valid); - sutProvider.GetDependency() + sutProvider.GetDependency() + .Validate(update.Rule) + .Returns(AccessRuleValidationResult.Valid); + sutProvider.GetDependency() .GetManyByOrganizationIdAsync(orgId) - .Returns(new List { existing }); + .Returns(new List { existing }); var result = await sutProvider.Sut.UpdateAsync(orgId, existing.Id, update); Assert.Equal("renamed", result.Name); Assert.Equal("new description", result.Description); - Assert.Equal(update.Policy, result.Policy); + Assert.Equal(update.Rule, result.Rule); Assert.Equal(_now, result.RevisionDate); - await sutProvider.GetDependency().Received(1).ReplaceAsync(existing); + await sutProvider.GetDependency().Received(1).ReplaceAsync(existing); } [Theory, BitAutoData] - public async Task UpdateAsync_MissingExisting_ThrowsNotFound(LeasingPolicy update) + public async Task UpdateAsync_MissingExisting_ThrowsNotFound(AccessRule update) { var sutProvider = SetupSutProvider(); - sutProvider.GetDependency() + sutProvider.GetDependency() .GetByIdAsync(Arg.Any()) - .Returns((LeasingPolicy?)null); + .Returns((AccessRule?)null); await Assert.ThrowsAsync( () => sutProvider.Sut.UpdateAsync(Guid.NewGuid(), Guid.NewGuid(), update)); } [Theory, BitAutoData] - public async Task UpdateAsync_WrongOrg_ThrowsNotFound(LeasingPolicy existing, LeasingPolicy update) + public async Task UpdateAsync_WrongOrg_ThrowsNotFound(AccessRule existing, AccessRule update) { var sutProvider = SetupSutProvider(); var differentOrg = Guid.NewGuid(); - sutProvider.GetDependency() + sutProvider.GetDependency() .GetByIdAsync(existing.Id) .Returns(existing); @@ -69,28 +69,28 @@ await Assert.ThrowsAsync( } [Theory, BitAutoData] - public async Task UpdateAsync_InvalidPolicy_ThrowsBadRequest(LeasingPolicy existing, LeasingPolicy update) + public async Task UpdateAsync_InvalidRule_ThrowsBadRequest(AccessRule existing, AccessRule update) { var sutProvider = SetupSutProvider(); var orgId = existing.OrganizationId; update.Name = "ok"; - update.Policy = """{"kind":"bogus"}"""; - sutProvider.GetDependency() + update.Rule = """{"kind":"bogus"}"""; + sutProvider.GetDependency() .GetByIdAsync(existing.Id) .Returns(existing); - sutProvider.GetDependency() - .Validate(update.Policy) - .Returns(LeasingPolicyValidationResult.Invalid("nope")); + sutProvider.GetDependency() + .Validate(update.Rule) + .Returns(AccessRuleValidationResult.Invalid("nope")); var ex = await Assert.ThrowsAsync( () => sutProvider.Sut.UpdateAsync(orgId, existing.Id, update)); Assert.Equal("nope", ex.Message); - await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().ReplaceAsync(default!); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().ReplaceAsync(default!); } - private static SutProvider SetupSutProvider() + private static SutProvider SetupSutProvider() { - var sutProvider = new SutProvider() + var sutProvider = new SutProvider() .WithFakeTimeProvider() .Create(); sutProvider.GetDependency().SetUtcNow(_now); diff --git a/test/Core.Test/PrivilegedAccessManagement/Services/LeasingPolicyValidatorTests.cs b/test/Core.Test/PrivilegedAccessManagement/Services/AccessRuleValidatorTests.cs similarity index 80% rename from test/Core.Test/PrivilegedAccessManagement/Services/LeasingPolicyValidatorTests.cs rename to test/Core.Test/PrivilegedAccessManagement/Services/AccessRuleValidatorTests.cs index 7b018a2af488..b4c8c01791e6 100644 --- a/test/Core.Test/PrivilegedAccessManagement/Services/LeasingPolicyValidatorTests.cs +++ b/test/Core.Test/PrivilegedAccessManagement/Services/AccessRuleValidatorTests.cs @@ -3,12 +3,12 @@ namespace Bit.Core.Test.PrivilegedAccessManagement.Services; -public class LeasingPolicyValidatorTests +public class AccessRuleValidatorTests { - private readonly LeasingPolicyValidator _sut = new(); + private readonly AccessRuleValidator _sut = new(); [Fact] - public void Validate_NullPolicy_IsValid() + public void Validate_NullRule_IsValid() { var result = _sut.Validate(null); @@ -18,9 +18,9 @@ public void Validate_NullPolicy_IsValid() [Theory] [InlineData("")] [InlineData(" ")] - public void Validate_EmptyOrWhitespacePolicy_IsInvalid(string policyJson) + public void Validate_EmptyOrWhitespaceRule_IsInvalid(string ruleJson) { - var result = _sut.Validate(policyJson); + var result = _sut.Validate(ruleJson); Assert.False(result.IsValid); } @@ -53,9 +53,9 @@ public void Validate_HumanApproval_IsValid() [Theory] [InlineData("""{"kind":"ip_allowlist","cidrs":["10.0.0.0/8"]}""")] [InlineData("""{"kind":"ip_allowlist","cidrs":["10.0.0.0/8","192.168.0.0/16","2001:db8::/32"]}""")] - public void Validate_IpAllowlist_ValidCidrs_IsValid(string policyJson) + public void Validate_IpAllowlist_ValidCidrs_IsValid(string ruleJson) { - var result = _sut.Validate(policyJson); + var result = _sut.Validate(ruleJson); Assert.True(result.IsValid); } @@ -64,9 +64,9 @@ public void Validate_IpAllowlist_ValidCidrs_IsValid(string policyJson) [InlineData("""{"kind":"ip_allowlist","cidrs":[]}""", "at least one CIDR")] [InlineData("""{"kind":"ip_allowlist","cidrs":["not-a-cidr"]}""", "Invalid CIDR")] [InlineData("""{"kind":"ip_allowlist","cidrs":["10.0.0.0/99"]}""", "Invalid CIDR")] - public void Validate_IpAllowlist_InvalidCidrs_IsInvalid(string policyJson, string expectedMessageFragment) + public void Validate_IpAllowlist_InvalidCidrs_IsInvalid(string ruleJson, string expectedMessageFragment) { - var result = _sut.Validate(policyJson); + var result = _sut.Validate(ruleJson); Assert.False(result.IsValid); Assert.Contains(expectedMessageFragment, result.Error); @@ -95,9 +95,9 @@ public void Validate_TimeOfDay_Valid_IsValid() [InlineData("""{"kind":"time_of_day","tz":"UTC","windows":[{"days":["funday"],"from":"09:00","to":"17:00"}]}""", "day")] [InlineData("""{"kind":"time_of_day","tz":"UTC","windows":[{"days":["mon"],"from":"9am","to":"5pm"}]}""", "Expected HH:mm")] [InlineData("""{"kind":"time_of_day","tz":"UTC","windows":[{"days":["mon"],"from":"25:00","to":"26:00"}]}""", "Expected HH:mm")] - public void Validate_TimeOfDay_Invalid_IsInvalid(string policyJson, string expectedMessageFragment) + public void Validate_TimeOfDay_Invalid_IsInvalid(string ruleJson, string expectedMessageFragment) { - var result = _sut.Validate(policyJson); + var result = _sut.Validate(ruleJson); Assert.False(result.IsValid); Assert.Contains(expectedMessageFragment, result.Error); @@ -109,7 +109,7 @@ public void Validate_AllOf_NestedHumanAndIpAllowlist_IsValid() var result = _sut.Validate(""" { "kind": "all_of", - "policies": [ + "rules": [ { "kind": "human_approval" }, { "kind": "ip_allowlist", "cidrs": ["10.0.0.0/8"] } ] @@ -122,7 +122,7 @@ public void Validate_AllOf_NestedHumanAndIpAllowlist_IsValid() [Fact] public void Validate_AllOf_EmptyChildren_IsInvalid() { - var result = _sut.Validate("""{"kind":"all_of","policies":[]}"""); + var result = _sut.Validate("""{"kind":"all_of","rules":[]}"""); Assert.False(result.IsValid); Assert.Contains("at least one child", result.Error); @@ -135,13 +135,13 @@ public void Validate_AllOf_ExceedsMaxNestingDepth_IsInvalid() var result = _sut.Validate(""" { "kind": "all_of", - "policies": [{ + "rules": [{ "kind": "all_of", - "policies": [{ + "rules": [{ "kind": "all_of", - "policies": [{ + "rules": [{ "kind": "all_of", - "policies": [{ "kind": "human_approval" }] + "rules": [{ "kind": "human_approval" }] }] }] }] @@ -155,8 +155,8 @@ public void Validate_AllOf_ExceedsMaxNestingDepth_IsInvalid() [Fact] public void Validate_AllOf_ExceedsMaxChildren_IsInvalid() { - var policies = string.Join(",", Enumerable.Repeat("""{"kind":"human_approval"}""", 11)); - var result = _sut.Validate($$"""{"kind":"all_of","policies":[{{policies}}]}"""); + var rules = string.Join(",", Enumerable.Repeat("""{"kind":"human_approval"}""", 11)); + var result = _sut.Validate($$"""{"kind":"all_of","rules":[{{rules}}]}"""); Assert.False(result.IsValid); Assert.Contains("more than", result.Error); @@ -168,7 +168,7 @@ public void Validate_AllOf_InvalidChild_IsInvalid() var result = _sut.Validate(""" { "kind": "all_of", - "policies": [ + "rules": [ { "kind": "human_approval" }, { "kind": "ip_allowlist", "cidrs": ["bogus"] } ] diff --git a/util/Migrator/DbScripts/2026-05-21_00_AddLeasingPolicy.sql b/util/Migrator/DbScripts/2026-05-21_00_AddAccessRule.sql similarity index 88% rename from util/Migrator/DbScripts/2026-05-21_00_AddLeasingPolicy.sql rename to util/Migrator/DbScripts/2026-05-21_00_AddAccessRule.sql index 6f8de3fecd82..34e4e003632a 100644 --- a/util/Migrator/DbScripts/2026-05-21_00_AddLeasingPolicy.sql +++ b/util/Migrator/DbScripts/2026-05-21_00_AddAccessRule.sql @@ -1,21 +1,21 @@ --- Create the LeasingPolicy table -IF OBJECT_ID('[dbo].[LeasingPolicy]') IS NULL +-- Create the AccessRule table +IF OBJECT_ID('[dbo].[AccessRule]') IS NULL BEGIN - CREATE TABLE [dbo].[LeasingPolicy] ( + CREATE TABLE [dbo].[AccessRule] ( [Id] UNIQUEIDENTIFIER NOT NULL, [OrganizationId] UNIQUEIDENTIFIER NOT NULL, [Name] NVARCHAR(256) NOT NULL, [Description] NVARCHAR(MAX) NULL, - [Policy] NVARCHAR(MAX) NOT NULL, + [Rule] NVARCHAR(MAX) NOT NULL, [CreationDate] DATETIME2(7) NOT NULL, [RevisionDate] DATETIME2(7) NOT NULL, - CONSTRAINT [PK_LeasingPolicy] PRIMARY KEY CLUSTERED ([Id] ASC), - CONSTRAINT [FK_LeasingPolicy_Organization] FOREIGN KEY ([OrganizationId]) + CONSTRAINT [PK_AccessRule] PRIMARY KEY CLUSTERED ([Id] ASC), + CONSTRAINT [FK_AccessRule_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]) ON DELETE CASCADE ); - CREATE UNIQUE NONCLUSTERED INDEX [IX_LeasingPolicy_OrganizationId_Name] - ON [dbo].[LeasingPolicy] ([OrganizationId] ASC, [Name] ASC); + CREATE UNIQUE NONCLUSTERED INDEX [IX_AccessRule_OrganizationId_Name] + ON [dbo].[AccessRule] ([OrganizationId] ASC, [Name] ASC); END GO @@ -40,21 +40,21 @@ BEGIN END GO --- Add LeasingPolicyId FK column to Collection -IF COL_LENGTH('[dbo].[Collection]', 'LeasingPolicyId') IS NULL +-- Add AccessRuleId FK column to Collection +IF COL_LENGTH('[dbo].[Collection]', 'AccessRuleId') IS NULL BEGIN ALTER TABLE [dbo].[Collection] - ADD [LeasingPolicyId] UNIQUEIDENTIFIER NULL - CONSTRAINT [FK_Collection_LeasingPolicy] REFERENCES [dbo].[LeasingPolicy] ([Id]) ON DELETE NO ACTION; + ADD [AccessRuleId] UNIQUEIDENTIFIER NULL + CONSTRAINT [FK_Collection_AccessRule] REFERENCES [dbo].[AccessRule] ([Id]) ON DELETE NO ACTION; END GO IF NOT EXISTS ( - SELECT 1 FROM sys.indexes WHERE name = 'IX_Collection_LeasingPolicyId' AND object_id = OBJECT_ID('[dbo].[Collection]') + SELECT 1 FROM sys.indexes WHERE name = 'IX_Collection_AccessRuleId' AND object_id = OBJECT_ID('[dbo].[Collection]') ) BEGIN - CREATE NONCLUSTERED INDEX [IX_Collection_LeasingPolicyId] - ON [dbo].[Collection] ([LeasingPolicyId] ASC); + CREATE NONCLUSTERED INDEX [IX_Collection_AccessRuleId] + ON [dbo].[Collection] ([AccessRuleId] ASC); END GO @@ -89,26 +89,26 @@ BEGIN END GO --- LeasingPolicy CRUD stored procedures -CREATE OR ALTER PROCEDURE [dbo].[LeasingPolicy_Create] +-- AccessRule CRUD stored procedures +CREATE OR ALTER PROCEDURE [dbo].[AccessRule_Create] @Id UNIQUEIDENTIFIER OUTPUT, @OrganizationId UNIQUEIDENTIFIER, @Name NVARCHAR(256), @Description NVARCHAR(MAX) = NULL, - @Policy NVARCHAR(MAX), + @Rule NVARCHAR(MAX), @CreationDate DATETIME2(7), @RevisionDate DATETIME2(7) AS BEGIN SET NOCOUNT ON - INSERT INTO [dbo].[LeasingPolicy] + INSERT INTO [dbo].[AccessRule] ( [Id], [OrganizationId], [Name], [Description], - [Policy], + [Rule], [CreationDate], [RevisionDate] ) @@ -118,19 +118,19 @@ BEGIN @OrganizationId, @Name, @Description, - @Policy, + @Rule, @CreationDate, @RevisionDate ) END GO -CREATE OR ALTER PROCEDURE [dbo].[LeasingPolicy_Update] +CREATE OR ALTER PROCEDURE [dbo].[AccessRule_Update] @Id UNIQUEIDENTIFIER, @OrganizationId UNIQUEIDENTIFIER, @Name NVARCHAR(256), @Description NVARCHAR(MAX) = NULL, - @Policy NVARCHAR(MAX), + @Rule NVARCHAR(MAX), @CreationDate DATETIME2(7), @RevisionDate DATETIME2(7) AS @@ -138,12 +138,12 @@ BEGIN SET NOCOUNT ON UPDATE - [dbo].[LeasingPolicy] + [dbo].[AccessRule] SET [OrganizationId] = @OrganizationId, [Name] = @Name, [Description] = @Description, - [Policy] = @Policy, + [Rule] = @Rule, [CreationDate] = @CreationDate, [RevisionDate] = @RevisionDate WHERE @@ -151,41 +151,41 @@ BEGIN END GO -CREATE OR ALTER PROCEDURE [dbo].[LeasingPolicy_DeleteById] +CREATE OR ALTER PROCEDURE [dbo].[AccessRule_DeleteById] @Id UNIQUEIDENTIFIER AS BEGIN SET NOCOUNT ON - DELETE FROM [dbo].[LeasingPolicy] WHERE [Id] = @Id + DELETE FROM [dbo].[AccessRule] WHERE [Id] = @Id END GO -CREATE OR ALTER PROCEDURE [dbo].[LeasingPolicy_ReadById] +CREATE OR ALTER PROCEDURE [dbo].[AccessRule_ReadById] @Id UNIQUEIDENTIFIER AS BEGIN SET NOCOUNT ON SELECT * - FROM [dbo].[LeasingPolicy] + FROM [dbo].[AccessRule] WHERE [Id] = @Id END GO -CREATE OR ALTER PROCEDURE [dbo].[LeasingPolicy_ReadByOrganizationId] +CREATE OR ALTER PROCEDURE [dbo].[AccessRule_ReadByOrganizationId] @OrganizationId UNIQUEIDENTIFIER AS BEGIN SET NOCOUNT ON SELECT * - FROM [dbo].[LeasingPolicy] + FROM [dbo].[AccessRule] WHERE [OrganizationId] = @OrganizationId END GO --- Update Collection_Create to accept LeasingPolicyId +-- Update Collection_Create to accept AccessRuleId CREATE OR ALTER PROCEDURE [dbo].[Collection_Create] @Id UNIQUEIDENTIFIER OUTPUT, @OrganizationId UNIQUEIDENTIFIER, @@ -195,7 +195,7 @@ CREATE OR ALTER PROCEDURE [dbo].[Collection_Create] @RevisionDate DATETIME2(7), @DefaultUserCollectionEmail NVARCHAR(256) = NULL, @Type TINYINT = 0, - @LeasingPolicyId UNIQUEIDENTIFIER = NULL + @AccessRuleId UNIQUEIDENTIFIER = NULL AS BEGIN SET NOCOUNT ON @@ -210,7 +210,7 @@ BEGIN [RevisionDate], [DefaultUserCollectionEmail], [Type], - [LeasingPolicyId] + [AccessRuleId] ) VALUES ( @@ -222,14 +222,14 @@ BEGIN @RevisionDate, @DefaultUserCollectionEmail, @Type, - @LeasingPolicyId + @AccessRuleId ) EXEC [dbo].[User_BumpAccountRevisionDateByCollectionId] @Id, @OrganizationId END GO --- Update Collection_Update to accept LeasingPolicyId +-- Update Collection_Update to accept AccessRuleId CREATE OR ALTER PROCEDURE [dbo].[Collection_Update] @Id UNIQUEIDENTIFIER, @OrganizationId UNIQUEIDENTIFIER, @@ -239,7 +239,7 @@ CREATE OR ALTER PROCEDURE [dbo].[Collection_Update] @RevisionDate DATETIME2(7), @DefaultUserCollectionEmail NVARCHAR(256) = NULL, @Type TINYINT = 0, - @LeasingPolicyId UNIQUEIDENTIFIER = NULL + @AccessRuleId UNIQUEIDENTIFIER = NULL AS BEGIN SET NOCOUNT ON @@ -254,7 +254,7 @@ BEGIN [RevisionDate] = @RevisionDate, [DefaultUserCollectionEmail] = @DefaultUserCollectionEmail, [Type] = @Type, - [LeasingPolicyId] = @LeasingPolicyId + [AccessRuleId] = @AccessRuleId WHERE [Id] = @Id @@ -262,7 +262,7 @@ BEGIN END GO --- Update Collection_CreateWithGroupsAndUsers to forward LeasingPolicyId +-- Update Collection_CreateWithGroupsAndUsers to forward AccessRuleId CREATE OR ALTER PROCEDURE [dbo].[Collection_CreateWithGroupsAndUsers] @Id UNIQUEIDENTIFIER, @OrganizationId UNIQUEIDENTIFIER, @@ -274,12 +274,12 @@ CREATE OR ALTER PROCEDURE [dbo].[Collection_CreateWithGroupsAndUsers] @Users AS [dbo].[CollectionAccessSelectionType] READONLY, @DefaultUserCollectionEmail NVARCHAR(256) = NULL, @Type TINYINT = 0, - @LeasingPolicyId UNIQUEIDENTIFIER = NULL + @AccessRuleId UNIQUEIDENTIFIER = NULL AS BEGIN SET NOCOUNT ON - EXEC [dbo].[Collection_Create] @Id, @OrganizationId, @Name, @ExternalId, @CreationDate, @RevisionDate, @DefaultUserCollectionEmail, @Type, @LeasingPolicyId + EXEC [dbo].[Collection_Create] @Id, @OrganizationId, @Name, @ExternalId, @CreationDate, @RevisionDate, @DefaultUserCollectionEmail, @Type, @AccessRuleId -- Groups ;WITH [AvailableGroupsCTE] AS( @@ -341,7 +341,7 @@ BEGIN END GO --- Update Collection_UpdateWithGroupsAndUsers to forward LeasingPolicyId +-- Update Collection_UpdateWithGroupsAndUsers to forward AccessRuleId CREATE OR ALTER PROCEDURE [dbo].[Collection_UpdateWithGroupsAndUsers] @Id UNIQUEIDENTIFIER, @OrganizationId UNIQUEIDENTIFIER, @@ -353,12 +353,12 @@ CREATE OR ALTER PROCEDURE [dbo].[Collection_UpdateWithGroupsAndUsers] @Users AS [dbo].[CollectionAccessSelectionType] READONLY, @DefaultUserCollectionEmail NVARCHAR(256) = NULL, @Type TINYINT = 0, - @LeasingPolicyId UNIQUEIDENTIFIER = NULL + @AccessRuleId UNIQUEIDENTIFIER = NULL AS BEGIN SET NOCOUNT ON - EXEC [dbo].[Collection_Update] @Id, @OrganizationId, @Name, @ExternalId, @CreationDate, @RevisionDate, @DefaultUserCollectionEmail, @Type, @LeasingPolicyId + EXEC [dbo].[Collection_Update] @Id, @OrganizationId, @Name, @ExternalId, @CreationDate, @RevisionDate, @DefaultUserCollectionEmail, @Type, @AccessRuleId -- Bump RevisionDate on all affected groups (old + new) before modifying CollectionGroup ;WITH [AffectedGroupsCTE] AS ( @@ -474,7 +474,7 @@ BEGIN END GO --- Update Collection_UpdateWithGroups to forward LeasingPolicyId +-- Update Collection_UpdateWithGroups to forward AccessRuleId CREATE OR ALTER PROCEDURE [dbo].[Collection_UpdateWithGroups] @Id UNIQUEIDENTIFIER, @OrganizationId UNIQUEIDENTIFIER, @@ -485,12 +485,12 @@ CREATE OR ALTER PROCEDURE [dbo].[Collection_UpdateWithGroups] @Groups AS [dbo].[CollectionAccessSelectionType] READONLY, @DefaultUserCollectionEmail NVARCHAR(256) = NULL, @Type TINYINT = 0, - @LeasingPolicyId UNIQUEIDENTIFIER = NULL + @AccessRuleId UNIQUEIDENTIFIER = NULL AS BEGIN SET NOCOUNT ON - EXEC [dbo].[Collection_Update] @Id, @OrganizationId, @Name, @ExternalId, @CreationDate, @RevisionDate, @DefaultUserCollectionEmail, @Type, @LeasingPolicyId + EXEC [dbo].[Collection_Update] @Id, @OrganizationId, @Name, @ExternalId, @CreationDate, @RevisionDate, @DefaultUserCollectionEmail, @Type, @AccessRuleId -- Bump RevisionDate on all affected groups (old + new) before modifying CollectionGroup ;WITH [AffectedGroupsCTE] AS ( @@ -578,7 +578,7 @@ BEGIN END GO --- Update Collection_UpdateWithUsers to forward LeasingPolicyId +-- Update Collection_UpdateWithUsers to forward AccessRuleId CREATE OR ALTER PROCEDURE [dbo].[Collection_UpdateWithUsers] @Id UNIQUEIDENTIFIER, @OrganizationId UNIQUEIDENTIFIER, @@ -589,12 +589,12 @@ CREATE OR ALTER PROCEDURE [dbo].[Collection_UpdateWithUsers] @Users AS [dbo].[CollectionAccessSelectionType] READONLY, @DefaultUserCollectionEmail NVARCHAR(256) = NULL, @Type TINYINT = 0, - @LeasingPolicyId UNIQUEIDENTIFIER = NULL + @AccessRuleId UNIQUEIDENTIFIER = NULL AS BEGIN SET NOCOUNT ON - EXEC [dbo].[Collection_Update] @Id, @OrganizationId, @Name, @ExternalId, @CreationDate, @RevisionDate, @DefaultUserCollectionEmail, @Type, @LeasingPolicyId + EXEC [dbo].[Collection_Update] @Id, @OrganizationId, @Name, @ExternalId, @CreationDate, @RevisionDate, @DefaultUserCollectionEmail, @Type, @AccessRuleId -- Users -- Delete users that are no longer in source @@ -656,7 +656,7 @@ BEGIN END GO --- Update Collection_ReadByUserId to project LeasingPolicyId +-- Update Collection_ReadByUserId to project AccessRuleId CREATE OR ALTER PROCEDURE [dbo].[Collection_ReadByUserId] @UserId UNIQUEIDENTIFIER AS @@ -675,7 +675,7 @@ BEGIN MAX([Manage]) AS [Manage], [DefaultUserCollectionEmail], [Type], - [LeasingPolicyId] + [AccessRuleId] FROM [dbo].[UserCollectionDetails](@UserId) GROUP BY @@ -687,11 +687,11 @@ BEGIN ExternalId, [DefaultUserCollectionEmail], [Type], - [LeasingPolicyId] + [AccessRuleId] END GO --- Update Collection_ReadByIdWithPermissions to GROUP BY LeasingPolicyId +-- Update Collection_ReadByIdWithPermissions to GROUP BY AccessRuleId CREATE OR ALTER PROCEDURE [dbo].[Collection_ReadByIdWithPermissions] @CollectionId UNIQUEIDENTIFIER, @UserId UNIQUEIDENTIFIER, @@ -770,7 +770,7 @@ BEGIN C.[ExternalId], C.[DefaultUserCollectionEmail], C.[Type], - C.[LeasingPolicyId] + C.[AccessRuleId] IF (@IncludeAccessRelationships = 1) BEGIN @@ -780,7 +780,7 @@ BEGIN END GO --- Update Collection_ReadSharedCollectionsByOrganizationIdWithPermissions to GROUP BY LeasingPolicyId +-- Update Collection_ReadSharedCollectionsByOrganizationIdWithPermissions to GROUP BY AccessRuleId CREATE OR ALTER PROCEDURE [dbo].[Collection_ReadSharedCollectionsByOrganizationIdWithPermissions] @OrganizationId UNIQUEIDENTIFIER, @UserId UNIQUEIDENTIFIER, @@ -860,7 +860,7 @@ BEGIN C.[ExternalId], C.[DefaultUserCollectionEmail], C.[Type], - C.[LeasingPolicyId] + C.[AccessRuleId] IF (@IncludeAccessRelationships = 1) BEGIN diff --git a/util/MySqlMigrations/Migrations/20260526092815_AddLeasingPolicy.Designer.cs b/util/MySqlMigrations/Migrations/20260526122321_AddAccessRule.Designer.cs similarity index 99% rename from util/MySqlMigrations/Migrations/20260526092815_AddLeasingPolicy.Designer.cs rename to util/MySqlMigrations/Migrations/20260526122321_AddAccessRule.Designer.cs index 5db56b688b9e..1d1ae65841c4 100644 --- a/util/MySqlMigrations/Migrations/20260526092815_AddLeasingPolicy.Designer.cs +++ b/util/MySqlMigrations/Migrations/20260526122321_AddAccessRule.Designer.cs @@ -12,8 +12,8 @@ namespace Bit.MySqlMigrations.Migrations { [DbContext(typeof(DatabaseContext))] - [Migration("20260526092815_AddLeasingPolicy")] - partial class AddLeasingPolicy + [Migration("20260526122321_AddAccessRule")] + partial class AddAccessRule { /// protected override void BuildTargetModel(ModelBuilder modelBuilder) @@ -77,6 +77,9 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("Id") .HasColumnType("char(36)"); + b.Property("AccessRuleId") + .HasColumnType("char(36)"); + b.Property("CreationDate") .HasColumnType("datetime(6)"); @@ -87,9 +90,6 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasMaxLength(300) .HasColumnType("varchar(300)"); - b.Property("LeasingPolicyId") - .HasColumnType("char(36)"); - b.Property("Name") .IsRequired() .HasColumnType("longtext"); @@ -105,7 +105,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.HasKey("Id"); - b.HasIndex("LeasingPolicyId"); + b.HasIndex("AccessRuleId"); b.HasIndex("OrganizationId"); @@ -2372,7 +2372,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("Installation", (string)null); }); - modelBuilder.Entity("Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Models.LeasingPolicy", b => + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Models.AccessRule", b => { b.Property("Id") .HasColumnType("char(36)"); @@ -2391,19 +2391,19 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("OrganizationId") .HasColumnType("char(36)"); - b.Property("Policy") - .IsRequired() - .HasColumnType("longtext"); - b.Property("RevisionDate") .HasColumnType("datetime(6)"); + b.Property("Rule") + .IsRequired() + .HasColumnType("longtext"); + b.HasKey("Id"); b.HasIndex("OrganizationId", "Name") .IsUnique(); - b.ToTable("LeasingPolicy", (string)null); + b.ToTable("AccessRule", (string)null); }); modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy", b => @@ -2916,9 +2916,9 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Collection", b => { - b.HasOne("Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Models.LeasingPolicy", null) + b.HasOne("Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Models.AccessRule", null) .WithMany() - .HasForeignKey("LeasingPolicyId") + .HasForeignKey("AccessRuleId") .OnDelete(DeleteBehavior.Restrict); b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") @@ -3450,7 +3450,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Navigation("User"); }); - modelBuilder.Entity("Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Models.LeasingPolicy", b => + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Models.AccessRule", b => { b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") .WithMany() diff --git a/util/MySqlMigrations/Migrations/20260526092815_AddLeasingPolicy.cs b/util/MySqlMigrations/Migrations/20260526122321_AddAccessRule.cs similarity index 60% rename from util/MySqlMigrations/Migrations/20260526092815_AddLeasingPolicy.cs rename to util/MySqlMigrations/Migrations/20260526122321_AddAccessRule.cs index 4769ba12dbca..b2742411071d 100644 --- a/util/MySqlMigrations/Migrations/20260526092815_AddLeasingPolicy.cs +++ b/util/MySqlMigrations/Migrations/20260526122321_AddAccessRule.cs @@ -5,28 +5,20 @@ namespace Bit.MySqlMigrations.Migrations; /// -public partial class AddLeasingPolicy : Migration +public partial class AddAccessRule : Migration { /// protected override void Up(MigrationBuilder migrationBuilder) { - migrationBuilder.DropColumn( - name: "LeasingEnabled", - table: "Collection"); - - migrationBuilder.DropColumn( - name: "LeasingPolicy", - table: "Collection"); - migrationBuilder.AddColumn( - name: "LeasingPolicyId", + name: "AccessRuleId", table: "Collection", type: "char(36)", nullable: true, collation: "ascii_general_ci"); migrationBuilder.CreateTable( - name: "LeasingPolicy", + name: "AccessRule", columns: table => new { Id = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), @@ -35,16 +27,16 @@ protected override void Up(MigrationBuilder migrationBuilder) .Annotation("MySql:CharSet", "utf8mb4"), Description = table.Column(type: "longtext", nullable: true) .Annotation("MySql:CharSet", "utf8mb4"), - Policy = table.Column(type: "longtext", nullable: false) + Rule = table.Column(type: "longtext", nullable: false) .Annotation("MySql:CharSet", "utf8mb4"), CreationDate = table.Column(type: "datetime(6)", nullable: false), RevisionDate = table.Column(type: "datetime(6)", nullable: false) }, constraints: table => { - table.PrimaryKey("PK_LeasingPolicy", x => x.Id); + table.PrimaryKey("PK_AccessRule", x => x.Id); table.ForeignKey( - name: "FK_LeasingPolicy_Organization_OrganizationId", + name: "FK_AccessRule_Organization_OrganizationId", column: x => x.OrganizationId, principalTable: "Organization", principalColumn: "Id", @@ -53,21 +45,21 @@ protected override void Up(MigrationBuilder migrationBuilder) .Annotation("MySql:CharSet", "utf8mb4"); migrationBuilder.CreateIndex( - name: "IX_Collection_LeasingPolicyId", + name: "IX_Collection_AccessRuleId", table: "Collection", - column: "LeasingPolicyId"); + column: "AccessRuleId"); migrationBuilder.CreateIndex( - name: "IX_LeasingPolicy_OrganizationId_Name", - table: "LeasingPolicy", + name: "IX_AccessRule_OrganizationId_Name", + table: "AccessRule", columns: new[] { "OrganizationId", "Name" }, unique: true); migrationBuilder.AddForeignKey( - name: "FK_Collection_LeasingPolicy_LeasingPolicyId", + name: "FK_Collection_AccessRule_AccessRuleId", table: "Collection", - column: "LeasingPolicyId", - principalTable: "LeasingPolicy", + column: "AccessRuleId", + principalTable: "AccessRule", principalColumn: "Id", onDelete: ReferentialAction.Restrict); } @@ -76,32 +68,18 @@ protected override void Up(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder) { migrationBuilder.DropForeignKey( - name: "FK_Collection_LeasingPolicy_LeasingPolicyId", + name: "FK_Collection_AccessRule_AccessRuleId", table: "Collection"); migrationBuilder.DropTable( - name: "LeasingPolicy"); + name: "AccessRule"); migrationBuilder.DropIndex( - name: "IX_Collection_LeasingPolicyId", + name: "IX_Collection_AccessRuleId", table: "Collection"); migrationBuilder.DropColumn( - name: "LeasingPolicyId", + name: "AccessRuleId", table: "Collection"); - - migrationBuilder.AddColumn( - name: "LeasingEnabled", - table: "Collection", - type: "tinyint(1)", - nullable: false, - defaultValue: false); - - migrationBuilder.AddColumn( - name: "LeasingPolicy", - table: "Collection", - type: "longtext", - nullable: true) - .Annotation("MySql:CharSet", "utf8mb4"); } } diff --git a/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs b/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs index 4ae4f087092b..905149c3950f 100644 --- a/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs +++ b/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs @@ -74,6 +74,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Id") .HasColumnType("char(36)"); + b.Property("AccessRuleId") + .HasColumnType("char(36)"); + b.Property("CreationDate") .HasColumnType("datetime(6)"); @@ -84,9 +87,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(300) .HasColumnType("varchar(300)"); - b.Property("LeasingPolicyId") - .HasColumnType("char(36)"); - b.Property("Name") .IsRequired() .HasColumnType("longtext"); @@ -102,7 +102,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); - b.HasIndex("LeasingPolicyId"); + b.HasIndex("AccessRuleId"); b.HasIndex("OrganizationId"); @@ -2369,7 +2369,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Installation", (string)null); }); - modelBuilder.Entity("Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Models.LeasingPolicy", b => + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Models.AccessRule", b => { b.Property("Id") .HasColumnType("char(36)"); @@ -2388,19 +2388,19 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("OrganizationId") .HasColumnType("char(36)"); - b.Property("Policy") - .IsRequired() - .HasColumnType("longtext"); - b.Property("RevisionDate") .HasColumnType("datetime(6)"); + b.Property("Rule") + .IsRequired() + .HasColumnType("longtext"); + b.HasKey("Id"); b.HasIndex("OrganizationId", "Name") .IsUnique(); - b.ToTable("LeasingPolicy", (string)null); + b.ToTable("AccessRule", (string)null); }); modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy", b => @@ -2913,9 +2913,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Collection", b => { - b.HasOne("Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Models.LeasingPolicy", null) + b.HasOne("Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Models.AccessRule", null) .WithMany() - .HasForeignKey("LeasingPolicyId") + .HasForeignKey("AccessRuleId") .OnDelete(DeleteBehavior.Restrict); b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") @@ -3447,7 +3447,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("User"); }); - modelBuilder.Entity("Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Models.LeasingPolicy", b => + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Models.AccessRule", b => { b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") .WithMany() diff --git a/util/PostgresMigrations/Migrations/20260526092811_AddLeasingPolicy.Designer.cs b/util/PostgresMigrations/Migrations/20260526122317_AddAccessRule.Designer.cs similarity index 99% rename from util/PostgresMigrations/Migrations/20260526092811_AddLeasingPolicy.Designer.cs rename to util/PostgresMigrations/Migrations/20260526122317_AddAccessRule.Designer.cs index efe4727d5b45..2e94ab439b82 100644 --- a/util/PostgresMigrations/Migrations/20260526092811_AddLeasingPolicy.Designer.cs +++ b/util/PostgresMigrations/Migrations/20260526122317_AddAccessRule.Designer.cs @@ -12,8 +12,8 @@ namespace Bit.PostgresMigrations.Migrations { [DbContext(typeof(DatabaseContext))] - [Migration("20260526092811_AddLeasingPolicy")] - partial class AddLeasingPolicy + [Migration("20260526122317_AddAccessRule")] + partial class AddAccessRule { /// protected override void BuildTargetModel(ModelBuilder modelBuilder) @@ -78,6 +78,9 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("Id") .HasColumnType("uuid"); + b.Property("AccessRuleId") + .HasColumnType("uuid"); + b.Property("CreationDate") .HasColumnType("timestamp with time zone"); @@ -88,9 +91,6 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasMaxLength(300) .HasColumnType("character varying(300)"); - b.Property("LeasingPolicyId") - .HasColumnType("uuid"); - b.Property("Name") .IsRequired() .HasColumnType("text"); @@ -106,7 +106,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.HasKey("Id"); - b.HasIndex("LeasingPolicyId"); + b.HasIndex("AccessRuleId"); b.HasIndex("OrganizationId"); @@ -2378,7 +2378,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("Installation", (string)null); }); - modelBuilder.Entity("Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Models.LeasingPolicy", b => + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Models.AccessRule", b => { b.Property("Id") .HasColumnType("uuid"); @@ -2397,19 +2397,19 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("OrganizationId") .HasColumnType("uuid"); - b.Property("Policy") - .IsRequired() - .HasColumnType("text"); - b.Property("RevisionDate") .HasColumnType("timestamp with time zone"); + b.Property("Rule") + .IsRequired() + .HasColumnType("text"); + b.HasKey("Id"); b.HasIndex("OrganizationId", "Name") .IsUnique(); - b.ToTable("LeasingPolicy", (string)null); + b.ToTable("AccessRule", (string)null); }); modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy", b => @@ -2922,9 +2922,9 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Collection", b => { - b.HasOne("Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Models.LeasingPolicy", null) + b.HasOne("Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Models.AccessRule", null) .WithMany() - .HasForeignKey("LeasingPolicyId") + .HasForeignKey("AccessRuleId") .OnDelete(DeleteBehavior.Restrict); b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") @@ -3456,7 +3456,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Navigation("User"); }); - modelBuilder.Entity("Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Models.LeasingPolicy", b => + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Models.AccessRule", b => { b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") .WithMany() diff --git a/util/PostgresMigrations/Migrations/20260526092811_AddLeasingPolicy.cs b/util/PostgresMigrations/Migrations/20260526122317_AddAccessRule.cs similarity index 58% rename from util/PostgresMigrations/Migrations/20260526092811_AddLeasingPolicy.cs rename to util/PostgresMigrations/Migrations/20260526122317_AddAccessRule.cs index 5103add32de4..711d81f996f3 100644 --- a/util/PostgresMigrations/Migrations/20260526092811_AddLeasingPolicy.cs +++ b/util/PostgresMigrations/Migrations/20260526122317_AddAccessRule.cs @@ -5,42 +5,34 @@ namespace Bit.PostgresMigrations.Migrations; /// -public partial class AddLeasingPolicy : Migration +public partial class AddAccessRule : Migration { /// protected override void Up(MigrationBuilder migrationBuilder) { - migrationBuilder.DropColumn( - name: "LeasingEnabled", - table: "Collection"); - - migrationBuilder.DropColumn( - name: "LeasingPolicy", - table: "Collection"); - migrationBuilder.AddColumn( - name: "LeasingPolicyId", + name: "AccessRuleId", table: "Collection", type: "uuid", nullable: true); migrationBuilder.CreateTable( - name: "LeasingPolicy", + name: "AccessRule", columns: table => new { Id = table.Column(type: "uuid", nullable: false), OrganizationId = table.Column(type: "uuid", nullable: false), Name = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), Description = table.Column(type: "text", nullable: true), - Policy = table.Column(type: "text", nullable: false), + Rule = table.Column(type: "text", nullable: false), CreationDate = table.Column(type: "timestamp with time zone", nullable: false), RevisionDate = table.Column(type: "timestamp with time zone", nullable: false) }, constraints: table => { - table.PrimaryKey("PK_LeasingPolicy", x => x.Id); + table.PrimaryKey("PK_AccessRule", x => x.Id); table.ForeignKey( - name: "FK_LeasingPolicy_Organization_OrganizationId", + name: "FK_AccessRule_Organization_OrganizationId", column: x => x.OrganizationId, principalTable: "Organization", principalColumn: "Id", @@ -48,21 +40,21 @@ protected override void Up(MigrationBuilder migrationBuilder) }); migrationBuilder.CreateIndex( - name: "IX_Collection_LeasingPolicyId", + name: "IX_Collection_AccessRuleId", table: "Collection", - column: "LeasingPolicyId"); + column: "AccessRuleId"); migrationBuilder.CreateIndex( - name: "IX_LeasingPolicy_OrganizationId_Name", - table: "LeasingPolicy", + name: "IX_AccessRule_OrganizationId_Name", + table: "AccessRule", columns: new[] { "OrganizationId", "Name" }, unique: true); migrationBuilder.AddForeignKey( - name: "FK_Collection_LeasingPolicy_LeasingPolicyId", + name: "FK_Collection_AccessRule_AccessRuleId", table: "Collection", - column: "LeasingPolicyId", - principalTable: "LeasingPolicy", + column: "AccessRuleId", + principalTable: "AccessRule", principalColumn: "Id", onDelete: ReferentialAction.Restrict); } @@ -71,31 +63,18 @@ protected override void Up(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder) { migrationBuilder.DropForeignKey( - name: "FK_Collection_LeasingPolicy_LeasingPolicyId", + name: "FK_Collection_AccessRule_AccessRuleId", table: "Collection"); migrationBuilder.DropTable( - name: "LeasingPolicy"); + name: "AccessRule"); migrationBuilder.DropIndex( - name: "IX_Collection_LeasingPolicyId", + name: "IX_Collection_AccessRuleId", table: "Collection"); migrationBuilder.DropColumn( - name: "LeasingPolicyId", + name: "AccessRuleId", table: "Collection"); - - migrationBuilder.AddColumn( - name: "LeasingEnabled", - table: "Collection", - type: "boolean", - nullable: false, - defaultValue: false); - - migrationBuilder.AddColumn( - name: "LeasingPolicy", - table: "Collection", - type: "text", - nullable: true); } } diff --git a/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs b/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs index c7c0dab1479f..297de06d92de 100644 --- a/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs +++ b/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs @@ -75,6 +75,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Id") .HasColumnType("uuid"); + b.Property("AccessRuleId") + .HasColumnType("uuid"); + b.Property("CreationDate") .HasColumnType("timestamp with time zone"); @@ -85,9 +88,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(300) .HasColumnType("character varying(300)"); - b.Property("LeasingPolicyId") - .HasColumnType("uuid"); - b.Property("Name") .IsRequired() .HasColumnType("text"); @@ -103,7 +103,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); - b.HasIndex("LeasingPolicyId"); + b.HasIndex("AccessRuleId"); b.HasIndex("OrganizationId"); @@ -2375,7 +2375,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Installation", (string)null); }); - modelBuilder.Entity("Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Models.LeasingPolicy", b => + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Models.AccessRule", b => { b.Property("Id") .HasColumnType("uuid"); @@ -2394,19 +2394,19 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("OrganizationId") .HasColumnType("uuid"); - b.Property("Policy") - .IsRequired() - .HasColumnType("text"); - b.Property("RevisionDate") .HasColumnType("timestamp with time zone"); + b.Property("Rule") + .IsRequired() + .HasColumnType("text"); + b.HasKey("Id"); b.HasIndex("OrganizationId", "Name") .IsUnique(); - b.ToTable("LeasingPolicy", (string)null); + b.ToTable("AccessRule", (string)null); }); modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy", b => @@ -2919,9 +2919,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Collection", b => { - b.HasOne("Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Models.LeasingPolicy", null) + b.HasOne("Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Models.AccessRule", null) .WithMany() - .HasForeignKey("LeasingPolicyId") + .HasForeignKey("AccessRuleId") .OnDelete(DeleteBehavior.Restrict); b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") @@ -3453,7 +3453,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("User"); }); - modelBuilder.Entity("Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Models.LeasingPolicy", b => + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Models.AccessRule", b => { b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") .WithMany() diff --git a/util/SqliteMigrations/Migrations/20260526092819_AddLeasingPolicy.Designer.cs b/util/SqliteMigrations/Migrations/20260526122325_AddAccessRule.Designer.cs similarity index 99% rename from util/SqliteMigrations/Migrations/20260526092819_AddLeasingPolicy.Designer.cs rename to util/SqliteMigrations/Migrations/20260526122325_AddAccessRule.Designer.cs index 68745ba26d7e..50b82702dc8f 100644 --- a/util/SqliteMigrations/Migrations/20260526092819_AddLeasingPolicy.Designer.cs +++ b/util/SqliteMigrations/Migrations/20260526122325_AddAccessRule.Designer.cs @@ -11,8 +11,8 @@ namespace Bit.SqliteMigrations.Migrations { [DbContext(typeof(DatabaseContext))] - [Migration("20260526092819_AddLeasingPolicy")] - partial class AddLeasingPolicy + [Migration("20260526122325_AddAccessRule")] + partial class AddAccessRule { /// protected override void BuildTargetModel(ModelBuilder modelBuilder) @@ -72,6 +72,9 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("Id") .HasColumnType("TEXT"); + b.Property("AccessRuleId") + .HasColumnType("TEXT"); + b.Property("CreationDate") .HasColumnType("TEXT"); @@ -82,9 +85,6 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasMaxLength(300) .HasColumnType("TEXT"); - b.Property("LeasingPolicyId") - .HasColumnType("TEXT"); - b.Property("Name") .IsRequired() .HasColumnType("TEXT"); @@ -100,7 +100,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.HasKey("Id"); - b.HasIndex("LeasingPolicyId"); + b.HasIndex("AccessRuleId"); b.HasIndex("OrganizationId"); @@ -2361,7 +2361,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("Installation", (string)null); }); - modelBuilder.Entity("Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Models.LeasingPolicy", b => + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Models.AccessRule", b => { b.Property("Id") .HasColumnType("TEXT"); @@ -2380,11 +2380,11 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("OrganizationId") .HasColumnType("TEXT"); - b.Property("Policy") - .IsRequired() + b.Property("RevisionDate") .HasColumnType("TEXT"); - b.Property("RevisionDate") + b.Property("Rule") + .IsRequired() .HasColumnType("TEXT"); b.HasKey("Id"); @@ -2392,7 +2392,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.HasIndex("OrganizationId", "Name") .IsUnique(); - b.ToTable("LeasingPolicy", (string)null); + b.ToTable("AccessRule", (string)null); }); modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy", b => @@ -2905,9 +2905,9 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Collection", b => { - b.HasOne("Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Models.LeasingPolicy", null) + b.HasOne("Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Models.AccessRule", null) .WithMany() - .HasForeignKey("LeasingPolicyId") + .HasForeignKey("AccessRuleId") .OnDelete(DeleteBehavior.Restrict); b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") @@ -3439,7 +3439,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Navigation("User"); }); - modelBuilder.Entity("Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Models.LeasingPolicy", b => + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Models.AccessRule", b => { b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") .WithMany() diff --git a/util/SqliteMigrations/Migrations/20260526092819_AddLeasingPolicy.cs b/util/SqliteMigrations/Migrations/20260526122325_AddAccessRule.cs similarity index 59% rename from util/SqliteMigrations/Migrations/20260526092819_AddLeasingPolicy.cs rename to util/SqliteMigrations/Migrations/20260526122325_AddAccessRule.cs index 1ef923d498fb..a0bda5678df6 100644 --- a/util/SqliteMigrations/Migrations/20260526092819_AddLeasingPolicy.cs +++ b/util/SqliteMigrations/Migrations/20260526122325_AddAccessRule.cs @@ -5,37 +5,34 @@ namespace Bit.SqliteMigrations.Migrations; /// -public partial class AddLeasingPolicy : Migration +public partial class AddAccessRule : Migration { /// protected override void Up(MigrationBuilder migrationBuilder) { - migrationBuilder.DropColumn( - name: "LeasingEnabled", - table: "Collection"); - - migrationBuilder.RenameColumn( - name: "LeasingPolicy", + migrationBuilder.AddColumn( + name: "AccessRuleId", table: "Collection", - newName: "LeasingPolicyId"); + type: "TEXT", + nullable: true); migrationBuilder.CreateTable( - name: "LeasingPolicy", + name: "AccessRule", columns: table => new { Id = table.Column(type: "TEXT", nullable: false), OrganizationId = table.Column(type: "TEXT", nullable: false), Name = table.Column(type: "TEXT", maxLength: 256, nullable: false), Description = table.Column(type: "TEXT", nullable: true), - Policy = table.Column(type: "TEXT", nullable: false), + Rule = table.Column(type: "TEXT", nullable: false), CreationDate = table.Column(type: "TEXT", nullable: false), RevisionDate = table.Column(type: "TEXT", nullable: false) }, constraints: table => { - table.PrimaryKey("PK_LeasingPolicy", x => x.Id); + table.PrimaryKey("PK_AccessRule", x => x.Id); table.ForeignKey( - name: "FK_LeasingPolicy_Organization_OrganizationId", + name: "FK_AccessRule_Organization_OrganizationId", column: x => x.OrganizationId, principalTable: "Organization", principalColumn: "Id", @@ -43,21 +40,21 @@ protected override void Up(MigrationBuilder migrationBuilder) }); migrationBuilder.CreateIndex( - name: "IX_Collection_LeasingPolicyId", + name: "IX_Collection_AccessRuleId", table: "Collection", - column: "LeasingPolicyId"); + column: "AccessRuleId"); migrationBuilder.CreateIndex( - name: "IX_LeasingPolicy_OrganizationId_Name", - table: "LeasingPolicy", + name: "IX_AccessRule_OrganizationId_Name", + table: "AccessRule", columns: new[] { "OrganizationId", "Name" }, unique: true); migrationBuilder.AddForeignKey( - name: "FK_Collection_LeasingPolicy_LeasingPolicyId", + name: "FK_Collection_AccessRule_AccessRuleId", table: "Collection", - column: "LeasingPolicyId", - principalTable: "LeasingPolicy", + column: "AccessRuleId", + principalTable: "AccessRule", principalColumn: "Id", onDelete: ReferentialAction.Restrict); } @@ -66,26 +63,18 @@ protected override void Up(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder) { migrationBuilder.DropForeignKey( - name: "FK_Collection_LeasingPolicy_LeasingPolicyId", + name: "FK_Collection_AccessRule_AccessRuleId", table: "Collection"); migrationBuilder.DropTable( - name: "LeasingPolicy"); + name: "AccessRule"); migrationBuilder.DropIndex( - name: "IX_Collection_LeasingPolicyId", + name: "IX_Collection_AccessRuleId", table: "Collection"); - migrationBuilder.RenameColumn( - name: "LeasingPolicyId", - table: "Collection", - newName: "LeasingPolicy"); - - migrationBuilder.AddColumn( - name: "LeasingEnabled", - table: "Collection", - type: "INTEGER", - nullable: false, - defaultValue: false); + migrationBuilder.DropColumn( + name: "AccessRuleId", + table: "Collection"); } } diff --git a/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs b/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs index 31a6568e4129..6e29c78564e8 100644 --- a/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs +++ b/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs @@ -69,6 +69,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Id") .HasColumnType("TEXT"); + b.Property("AccessRuleId") + .HasColumnType("TEXT"); + b.Property("CreationDate") .HasColumnType("TEXT"); @@ -79,9 +82,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(300) .HasColumnType("TEXT"); - b.Property("LeasingPolicyId") - .HasColumnType("TEXT"); - b.Property("Name") .IsRequired() .HasColumnType("TEXT"); @@ -97,7 +97,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); - b.HasIndex("LeasingPolicyId"); + b.HasIndex("AccessRuleId"); b.HasIndex("OrganizationId"); @@ -2358,7 +2358,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Installation", (string)null); }); - modelBuilder.Entity("Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Models.LeasingPolicy", b => + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Models.AccessRule", b => { b.Property("Id") .HasColumnType("TEXT"); @@ -2377,11 +2377,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("OrganizationId") .HasColumnType("TEXT"); - b.Property("Policy") - .IsRequired() + b.Property("RevisionDate") .HasColumnType("TEXT"); - b.Property("RevisionDate") + b.Property("Rule") + .IsRequired() .HasColumnType("TEXT"); b.HasKey("Id"); @@ -2389,7 +2389,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("OrganizationId", "Name") .IsUnique(); - b.ToTable("LeasingPolicy", (string)null); + b.ToTable("AccessRule", (string)null); }); modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy", b => @@ -2902,9 +2902,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Collection", b => { - b.HasOne("Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Models.LeasingPolicy", null) + b.HasOne("Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Models.AccessRule", null) .WithMany() - .HasForeignKey("LeasingPolicyId") + .HasForeignKey("AccessRuleId") .OnDelete(DeleteBehavior.Restrict); b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") @@ -3436,7 +3436,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("User"); }); - modelBuilder.Entity("Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Models.LeasingPolicy", b => + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Models.AccessRule", b => { b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") .WithMany() From 6c2a4be6608266e8fe1a5f704a50d9f3b95a08db Mon Sep 17 00:00:00 2001 From: Hinton Date: Tue, 2 Jun 2026 09:48:14 +0200 Subject: [PATCH 06/54] Add collection support --- .../Controllers/AccessRulesController.cs | 10 +- .../Models/Request/AccessRuleRequestModel.cs | 7 + .../Response/AccessRuleResponseModel.cs | 6 +- .../Models/AccessRuleDetails.cs | 23 ++++ .../Commands/CreateAccessRuleCommand.cs | 49 ++++++- .../Interfaces/ICreateAccessRuleCommand.cs | 6 +- .../Interfaces/IUpdateAccessRuleCommand.cs | 6 +- .../Commands/UpdateAccessRuleCommand.cs | 63 +++++++-- .../Repositories/IAccessRuleRepository.cs | 22 +++ .../Repositories/AccessRuleRepository.cs | 65 +++++++++ .../Models/AccessRule.cs | 1 + .../Repositories/AccessRuleRepository.cs | 112 +++++++++++++++ .../AccessRule_DeleteById.sql | 24 ++++ .../AccessRule_ReadDetailsById.sql | 14 ++ ...AccessRule_ReadDetailsByOrganizationId.sql | 17 +++ .../Collection_SetAccessRuleAssociations.sql | 42 ++++++ .../Commands/CreateAccessRuleCommandTests.cs | 114 +++++++++++++++- .../Commands/UpdateAccessRuleCommandTests.cs | 128 ++++++++++++++++-- .../Repositories/AccessRuleRepositoryTests.cs | 53 ++++++++ ...00_AddAccessRuleCollectionAssociations.sql | 117 ++++++++++++++++ 20 files changed, 840 insertions(+), 39 deletions(-) create mode 100644 src/Core/PrivilegedAccessManagement/Models/AccessRuleDetails.cs create mode 100644 src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/AccessRule_ReadDetailsById.sql create mode 100644 src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/AccessRule_ReadDetailsByOrganizationId.sql create mode 100644 src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/Collection_SetAccessRuleAssociations.sql create mode 100644 test/Infrastructure.IntegrationTest/PrivilegedAccessManagement/Repositories/AccessRuleRepositoryTests.cs create mode 100644 util/Migrator/DbScripts/2026-06-01_00_AddAccessRuleCollectionAssociations.sql diff --git a/src/Api/PrivilegedAccessManagement/Controllers/AccessRulesController.cs b/src/Api/PrivilegedAccessManagement/Controllers/AccessRulesController.cs index 8337f5f1f4d2..60bd43c19ab4 100644 --- a/src/Api/PrivilegedAccessManagement/Controllers/AccessRulesController.cs +++ b/src/Api/PrivilegedAccessManagement/Controllers/AccessRulesController.cs @@ -28,9 +28,9 @@ public async Task> GetAll(Guid orgId) { await EnsureMemberAsync(orgId); - var rules = await repository.GetManyByOrganizationIdAsync(orgId); + var rules = await repository.GetManyDetailsByOrganizationIdAsync(orgId); return new ListResponseModel( - rules.Select(p => new AccessRuleResponseModel(p))); + rules.Select(rule => new AccessRuleResponseModel(rule))); } [HttpGet("{id:guid}")] @@ -38,7 +38,7 @@ public async Task Get(Guid orgId, Guid id) { await EnsureMemberAsync(orgId); - var rule = await repository.GetByIdAsync(id); + var rule = await repository.GetDetailsByIdAsync(id); if (rule is null || rule.OrganizationId != orgId) { throw new NotFoundException(); @@ -52,7 +52,7 @@ public async Task Post(Guid orgId, [FromBody] AccessRul { await EnsureAdminAsync(orgId); - var rule = await createCommand.CreateAsync(model.ToAccessRule(orgId)); + var rule = await createCommand.CreateAsync(model.ToAccessRule(orgId), model.Collections); return new AccessRuleResponseModel(rule); } @@ -61,7 +61,7 @@ public async Task Put(Guid orgId, Guid id, [FromBody] A { await EnsureAdminAsync(orgId); - var rule = await updateCommand.UpdateAsync(orgId, id, model.ToAccessRule(orgId)); + var rule = await updateCommand.UpdateAsync(orgId, id, model.ToAccessRule(orgId), model.Collections); return new AccessRuleResponseModel(rule); } diff --git a/src/Api/PrivilegedAccessManagement/Models/Request/AccessRuleRequestModel.cs b/src/Api/PrivilegedAccessManagement/Models/Request/AccessRuleRequestModel.cs index b47c85e4a16e..c49f2e0e3589 100644 --- a/src/Api/PrivilegedAccessManagement/Models/Request/AccessRuleRequestModel.cs +++ b/src/Api/PrivilegedAccessManagement/Models/Request/AccessRuleRequestModel.cs @@ -15,6 +15,13 @@ public class AccessRuleRequestModel [Required] public object Rule { get; set; } = null!; + /// + /// The complete set of collections this rule governs. The rule's associations are replaced to match + /// exactly this set; an empty array clears all associations. + /// + [Required] + public IEnumerable Collections { get; set; } = null!; + public AccessRule ToAccessRule(Guid organizationId) => new() { OrganizationId = organizationId, diff --git a/src/Api/PrivilegedAccessManagement/Models/Response/AccessRuleResponseModel.cs b/src/Api/PrivilegedAccessManagement/Models/Response/AccessRuleResponseModel.cs index 465f338079f8..9c4ef8e636a9 100644 --- a/src/Api/PrivilegedAccessManagement/Models/Response/AccessRuleResponseModel.cs +++ b/src/Api/PrivilegedAccessManagement/Models/Response/AccessRuleResponseModel.cs @@ -1,12 +1,12 @@ using System.Text.Json; using Bit.Core.Models.Api; -using Bit.Core.PrivilegedAccessManagement.Entities; +using Bit.Core.PrivilegedAccessManagement.Models; namespace Bit.Api.PrivilegedAccessManagement.Models.Response; public class AccessRuleResponseModel : ResponseModel { - public AccessRuleResponseModel(AccessRule rule) + public AccessRuleResponseModel(AccessRuleDetails rule) : base("accessRule") { ArgumentNullException.ThrowIfNull(rule); @@ -18,6 +18,7 @@ public AccessRuleResponseModel(AccessRule rule) Rule = TryParseRule(rule.Rule); CreationDate = rule.CreationDate; RevisionDate = rule.RevisionDate; + Collections = rule.CollectionIds.ToList(); } public Guid Id { get; } @@ -27,6 +28,7 @@ public AccessRuleResponseModel(AccessRule rule) public JsonElement? Rule { get; } public DateTime CreationDate { get; } public DateTime RevisionDate { get; } + public IEnumerable Collections { get; } private static JsonElement? TryParseRule(string? ruleJson) { diff --git a/src/Core/PrivilegedAccessManagement/Models/AccessRuleDetails.cs b/src/Core/PrivilegedAccessManagement/Models/AccessRuleDetails.cs new file mode 100644 index 000000000000..50ea70a74dfd --- /dev/null +++ b/src/Core/PrivilegedAccessManagement/Models/AccessRuleDetails.cs @@ -0,0 +1,23 @@ +using Bit.Core.PrivilegedAccessManagement.Entities; + +namespace Bit.Core.PrivilegedAccessManagement.Models; + +/// +/// An together with the IDs of the collections it governs. +/// +public class AccessRuleDetails : AccessRule +{ + public IEnumerable CollectionIds { get; set; } = []; + + public static AccessRuleDetails From(AccessRule rule, IEnumerable collectionIds) => new() + { + Id = rule.Id, + OrganizationId = rule.OrganizationId, + Name = rule.Name, + Description = rule.Description, + Rule = rule.Rule, + CreationDate = rule.CreationDate, + RevisionDate = rule.RevisionDate, + CollectionIds = collectionIds, + }; +} diff --git a/src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/CreateAccessRuleCommand.cs b/src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/CreateAccessRuleCommand.cs index 5a991a9282da..78a6ab2abb7c 100644 --- a/src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/CreateAccessRuleCommand.cs +++ b/src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/CreateAccessRuleCommand.cs @@ -1,28 +1,34 @@ -using Bit.Core.Exceptions; +using Bit.Core.Entities; +using Bit.Core.Exceptions; using Bit.Core.PrivilegedAccessManagement.Entities; +using Bit.Core.PrivilegedAccessManagement.Models; using Bit.Core.PrivilegedAccessManagement.OrganizationFeatures.Commands.Interfaces; using Bit.Core.PrivilegedAccessManagement.Repositories; using Bit.Core.PrivilegedAccessManagement.Services; +using Bit.Core.Repositories; namespace Bit.Core.PrivilegedAccessManagement.OrganizationFeatures.Commands; public class CreateAccessRuleCommand : ICreateAccessRuleCommand { private readonly IAccessRuleRepository _repository; + private readonly ICollectionRepository _collectionRepository; private readonly IAccessRuleValidator _validator; private readonly TimeProvider _timeProvider; public CreateAccessRuleCommand( IAccessRuleRepository repository, + ICollectionRepository collectionRepository, IAccessRuleValidator validator, TimeProvider timeProvider) { _repository = repository; + _collectionRepository = collectionRepository; _validator = validator; _timeProvider = timeProvider; } - public async Task CreateAsync(AccessRule rule) + public async Task CreateAsync(AccessRule rule, IEnumerable collectionIds) { if (string.IsNullOrWhiteSpace(rule.Name)) { @@ -41,10 +47,47 @@ public async Task CreateAsync(AccessRule rule) throw new BadRequestException("A rule with that name already exists."); } + var desiredCollectionIds = await ValidateCollectionsAsync(rule.OrganizationId, collectionIds); + var now = _timeProvider.GetUtcNow().UtcDateTime; rule.CreationDate = now; rule.RevisionDate = now; - return await _repository.CreateAsync(rule); + var created = await _repository.CreateAsync(rule); + + await _repository.SetCollectionAssociationsAsync( + created.OrganizationId, created.Id, desiredCollectionIds, []); + + return AccessRuleDetails.From(created, desiredCollectionIds); } + + private async Task> ValidateCollectionsAsync(Guid organizationId, IEnumerable collectionIds) + { + var distinctIds = collectionIds.Distinct().ToList(); + if (distinctIds.Count == 0) + { + return distinctIds; + } + + var collections = await _collectionRepository.GetManyByManyIdsAsync(distinctIds); + if (collections.Count != distinctIds.Count) + { + throw new BadRequestException("One or more collections could not be found."); + } + + if (collections.Any(c => c.OrganizationId != organizationId)) + { + throw new BadRequestException("One or more collections do not belong to this organization."); + } + + if (collections.Any(IsGovernedByAnotherRule)) + { + throw new BadRequestException("One or more collections are already governed by another access rule."); + } + + return distinctIds; + } + + // A new rule has no Id yet, so any existing association is a conflict. + private static bool IsGovernedByAnotherRule(Collection collection) => collection.AccessRuleId.HasValue; } diff --git a/src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/Interfaces/ICreateAccessRuleCommand.cs b/src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/Interfaces/ICreateAccessRuleCommand.cs index 5606441e1b9f..b11661e81e71 100644 --- a/src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/Interfaces/ICreateAccessRuleCommand.cs +++ b/src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/Interfaces/ICreateAccessRuleCommand.cs @@ -1,8 +1,12 @@ using Bit.Core.PrivilegedAccessManagement.Entities; +using Bit.Core.PrivilegedAccessManagement.Models; namespace Bit.Core.PrivilegedAccessManagement.OrganizationFeatures.Commands.Interfaces; public interface ICreateAccessRuleCommand { - Task CreateAsync(AccessRule rule); + /// + /// Creates an access rule and associates exactly the given collections with it. + /// + Task CreateAsync(AccessRule rule, IEnumerable collectionIds); } diff --git a/src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/Interfaces/IUpdateAccessRuleCommand.cs b/src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/Interfaces/IUpdateAccessRuleCommand.cs index c8b19b1ead11..e75f0d4bfcb4 100644 --- a/src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/Interfaces/IUpdateAccessRuleCommand.cs +++ b/src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/Interfaces/IUpdateAccessRuleCommand.cs @@ -1,8 +1,12 @@ using Bit.Core.PrivilegedAccessManagement.Entities; +using Bit.Core.PrivilegedAccessManagement.Models; namespace Bit.Core.PrivilegedAccessManagement.OrganizationFeatures.Commands.Interfaces; public interface IUpdateAccessRuleCommand { - Task UpdateAsync(Guid organizationId, Guid id, AccessRule update); + /// + /// Updates an access rule and replaces its collection associations with exactly the given collections. + /// + Task UpdateAsync(Guid organizationId, Guid id, AccessRule update, IEnumerable collectionIds); } diff --git a/src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/UpdateAccessRuleCommand.cs b/src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/UpdateAccessRuleCommand.cs index 642d7c226de4..d66e69c6994e 100644 --- a/src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/UpdateAccessRuleCommand.cs +++ b/src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/UpdateAccessRuleCommand.cs @@ -1,35 +1,41 @@ using Bit.Core.Exceptions; using Bit.Core.PrivilegedAccessManagement.Entities; +using Bit.Core.PrivilegedAccessManagement.Models; using Bit.Core.PrivilegedAccessManagement.OrganizationFeatures.Commands.Interfaces; using Bit.Core.PrivilegedAccessManagement.Repositories; using Bit.Core.PrivilegedAccessManagement.Services; +using Bit.Core.Repositories; namespace Bit.Core.PrivilegedAccessManagement.OrganizationFeatures.Commands; public class UpdateAccessRuleCommand : IUpdateAccessRuleCommand { private readonly IAccessRuleRepository _repository; + private readonly ICollectionRepository _collectionRepository; private readonly IAccessRuleValidator _validator; private readonly TimeProvider _timeProvider; public UpdateAccessRuleCommand( IAccessRuleRepository repository, + ICollectionRepository collectionRepository, IAccessRuleValidator validator, TimeProvider timeProvider) { _repository = repository; + _collectionRepository = collectionRepository; _validator = validator; _timeProvider = timeProvider; } - public async Task UpdateAsync(Guid organizationId, Guid id, AccessRule update) + public async Task UpdateAsync(Guid organizationId, Guid id, AccessRule update, + IEnumerable collectionIds) { if (string.IsNullOrWhiteSpace(update.Name)) { throw new BadRequestException("Name is required."); } - var existing = await _repository.GetByIdAsync(id); + var existing = await _repository.GetDetailsByIdAsync(id); if (existing is null || existing.OrganizationId != organizationId) { throw new NotFoundException(); @@ -47,12 +53,53 @@ public async Task UpdateAsync(Guid organizationId, Guid id, AccessRu throw new BadRequestException("A rule with that name already exists."); } - existing.Name = update.Name; - existing.Description = update.Description; - existing.Rule = update.Rule; - existing.RevisionDate = _timeProvider.GetUtcNow().UtcDateTime; + var desiredCollectionIds = await ValidateCollectionsAsync(organizationId, id, collectionIds); - await _repository.ReplaceAsync(existing); - return existing; + // Persist a plain AccessRule: the AccessRuleDetails returned by GetDetailsByIdAsync carries an extra + // CollectionIds property that the base ReplaceAsync would otherwise forward to AccessRule_Update. + var toPersist = new AccessRule + { + Id = existing.Id, + OrganizationId = existing.OrganizationId, + Name = update.Name, + Description = update.Description, + Rule = update.Rule, + CreationDate = existing.CreationDate, + RevisionDate = _timeProvider.GetUtcNow().UtcDateTime, + }; + await _repository.ReplaceAsync(toPersist); + + var toClear = existing.CollectionIds.Except(desiredCollectionIds).ToList(); + await _repository.SetCollectionAssociationsAsync(organizationId, id, desiredCollectionIds, toClear); + + return AccessRuleDetails.From(toPersist, desiredCollectionIds); + } + + private async Task> ValidateCollectionsAsync(Guid organizationId, Guid accessRuleId, + IEnumerable collectionIds) + { + var distinctIds = collectionIds.Distinct().ToList(); + if (distinctIds.Count == 0) + { + return distinctIds; + } + + var collections = await _collectionRepository.GetManyByManyIdsAsync(distinctIds); + if (collections.Count != distinctIds.Count) + { + throw new BadRequestException("One or more collections could not be found."); + } + + if (collections.Any(c => c.OrganizationId != organizationId)) + { + throw new BadRequestException("One or more collections do not belong to this organization."); + } + + if (collections.Any(c => c.AccessRuleId.HasValue && c.AccessRuleId != accessRuleId)) + { + throw new BadRequestException("One or more collections are already governed by another access rule."); + } + + return distinctIds; } } diff --git a/src/Core/PrivilegedAccessManagement/Repositories/IAccessRuleRepository.cs b/src/Core/PrivilegedAccessManagement/Repositories/IAccessRuleRepository.cs index 8bbc7a1d54c9..f8891483fdb5 100644 --- a/src/Core/PrivilegedAccessManagement/Repositories/IAccessRuleRepository.cs +++ b/src/Core/PrivilegedAccessManagement/Repositories/IAccessRuleRepository.cs @@ -1,4 +1,5 @@ using Bit.Core.PrivilegedAccessManagement.Entities; +using Bit.Core.PrivilegedAccessManagement.Models; using Bit.Core.Repositories; namespace Bit.Core.PrivilegedAccessManagement.Repositories; @@ -6,4 +7,25 @@ namespace Bit.Core.PrivilegedAccessManagement.Repositories; public interface IAccessRuleRepository : IRepository { Task> GetManyByOrganizationIdAsync(Guid organizationId); + + /// + /// Returns the access rule along with the IDs of the collections it governs, or null if it does not exist. + /// + Task GetDetailsByIdAsync(Guid id); + + /// + /// Returns all access rules in the organization, each along with the IDs of the collections it governs. + /// + Task> GetManyDetailsByOrganizationIdAsync(Guid organizationId); + + /// + /// Points the given collections at the access rule and clears the rule from any collections that should no + /// longer reference it. Both sets are scoped to the organization. + /// + /// The organization that owns the access rule and collections. + /// The access rule to associate. + /// Collections that should reference the access rule. + /// Collections whose reference to the access rule should be removed. + Task SetCollectionAssociationsAsync(Guid organizationId, Guid accessRuleId, + IEnumerable collectionIdsToAssign, IEnumerable collectionIdsToClear); } diff --git a/src/Infrastructure.Dapper/PrivilegedAccessManagement/Repositories/AccessRuleRepository.cs b/src/Infrastructure.Dapper/PrivilegedAccessManagement/Repositories/AccessRuleRepository.cs index b930db58e001..4032d99dcc8a 100644 --- a/src/Infrastructure.Dapper/PrivilegedAccessManagement/Repositories/AccessRuleRepository.cs +++ b/src/Infrastructure.Dapper/PrivilegedAccessManagement/Repositories/AccessRuleRepository.cs @@ -1,5 +1,6 @@ using System.Data; using Bit.Core.PrivilegedAccessManagement.Entities; +using Bit.Core.PrivilegedAccessManagement.Models; using Bit.Core.PrivilegedAccessManagement.Repositories; using Bit.Core.Settings; using Bit.Infrastructure.Dapper.Repositories; @@ -30,4 +31,68 @@ public async Task> GetManyByOrganizationIdAsync(Guid org return results.ToList(); } + + public async Task GetDetailsByIdAsync(Guid id) + { + using var connection = new SqlConnection(ConnectionString); + using var results = await connection.QueryMultipleAsync( + $"[{Schema}].[AccessRule_ReadDetailsById]", + new { Id = id }, + commandType: CommandType.StoredProcedure); + + var rule = (await results.ReadAsync()).SingleOrDefault(); + if (rule is null) + { + return null; + } + + rule.CollectionIds = (await results.ReadAsync()).ToList(); + return rule; + } + + public async Task> GetManyDetailsByOrganizationIdAsync(Guid organizationId) + { + using var connection = new SqlConnection(ConnectionString); + using var results = await connection.QueryMultipleAsync( + $"[{Schema}].[AccessRule_ReadDetailsByOrganizationId]", + new { OrganizationId = organizationId }, + commandType: CommandType.StoredProcedure); + + var rules = (await results.ReadAsync()).ToList(); + var collectionIdsByRule = (await results.ReadAsync()) + .GroupBy(m => m.AccessRuleId) + .ToDictionary(g => g.Key, g => g.Select(m => m.CollectionId).ToList()); + + foreach (var rule in rules) + { + if (collectionIdsByRule.TryGetValue(rule.Id, out var collectionIds)) + { + rule.CollectionIds = collectionIds; + } + } + + return rules; + } + + public async Task SetCollectionAssociationsAsync(Guid organizationId, Guid accessRuleId, + IEnumerable collectionIdsToAssign, IEnumerable collectionIdsToClear) + { + using var connection = new SqlConnection(ConnectionString); + await connection.ExecuteAsync( + $"[{Schema}].[Collection_SetAccessRuleAssociations]", + new + { + AccessRuleId = accessRuleId, + OrganizationId = organizationId, + ToAssign = collectionIdsToAssign.ToGuidIdArrayTVP(), + ToClear = collectionIdsToClear.ToGuidIdArrayTVP(), + }, + commandType: CommandType.StoredProcedure); + } + + private sealed class CollectionAccessRuleMapping + { + public Guid AccessRuleId { get; init; } + public Guid CollectionId { get; init; } + } } diff --git a/src/Infrastructure.EntityFramework/PrivilegedAccessManagement/Models/AccessRule.cs b/src/Infrastructure.EntityFramework/PrivilegedAccessManagement/Models/AccessRule.cs index 530ac5a40a99..d0ff7f758e07 100644 --- a/src/Infrastructure.EntityFramework/PrivilegedAccessManagement/Models/AccessRule.cs +++ b/src/Infrastructure.EntityFramework/PrivilegedAccessManagement/Models/AccessRule.cs @@ -16,5 +16,6 @@ public class AccessRuleMapperProfile : Profile public AccessRuleMapperProfile() { CreateMap().ReverseMap(); + CreateMap(); } } diff --git a/src/Infrastructure.EntityFramework/PrivilegedAccessManagement/Repositories/AccessRuleRepository.cs b/src/Infrastructure.EntityFramework/PrivilegedAccessManagement/Repositories/AccessRuleRepository.cs index bcd205b8073e..9ef4de0a3ff9 100644 --- a/src/Infrastructure.EntityFramework/PrivilegedAccessManagement/Repositories/AccessRuleRepository.cs +++ b/src/Infrastructure.EntityFramework/PrivilegedAccessManagement/Repositories/AccessRuleRepository.cs @@ -1,4 +1,5 @@ using AutoMapper; +using Bit.Core.PrivilegedAccessManagement.Models; using Bit.Core.PrivilegedAccessManagement.Repositories; using Bit.Infrastructure.EntityFramework.Repositories; using Microsoft.EntityFrameworkCore; @@ -26,4 +27,115 @@ public async Task> GetManyByOrganizationIdAsync(Guid org .ToListAsync(); return Mapper.Map>(rules); } + + public async Task GetDetailsByIdAsync(Guid id) + { + using var scope = ServiceScopeFactory.CreateScope(); + var dbContext = GetDatabaseContext(scope); + var rule = await dbContext.AccessRules + .Where(p => p.Id == id) + .AsNoTracking() + .FirstOrDefaultAsync(); + if (rule is null) + { + return null; + } + + var details = Mapper.Map(rule); + details.CollectionIds = await dbContext.Collections + .Where(c => c.AccessRuleId == id) + .Select(c => c.Id) + .ToListAsync(); + return details; + } + + public async Task> GetManyDetailsByOrganizationIdAsync(Guid organizationId) + { + using var scope = ServiceScopeFactory.CreateScope(); + var dbContext = GetDatabaseContext(scope); + var rules = await dbContext.AccessRules + .Where(p => p.OrganizationId == organizationId) + .AsNoTracking() + .ToListAsync(); + + var collectionIdsByRule = (await dbContext.Collections + .Where(c => c.OrganizationId == organizationId && c.AccessRuleId != null) + .Select(c => new { AccessRuleId = c.AccessRuleId!.Value, CollectionId = c.Id }) + .ToListAsync()) + .GroupBy(r => r.AccessRuleId) + .ToDictionary(g => g.Key, g => g.Select(r => r.CollectionId).ToList()); + + return rules + .Select(rule => + { + var details = Mapper.Map(rule); + if (collectionIdsByRule.TryGetValue(rule.Id, out var collectionIds)) + { + details.CollectionIds = collectionIds; + } + return details; + }) + .ToList(); + } + + public override async Task DeleteAsync(CoreEntity accessRule) + { + using var scope = ServiceScopeFactory.CreateScope(); + var dbContext = GetDatabaseContext(scope); + var now = DateTime.UtcNow; + + await using var transaction = await dbContext.Database.BeginTransactionAsync(); + + // The Collection -> AccessRule FK is Restrict, so clear it from governed collections before deleting. + await dbContext.Collections + .Where(c => c.AccessRuleId == accessRule.Id) + .ExecuteUpdateAsync(s => s + .SetProperty(c => c.AccessRuleId, (Guid?)null) + .SetProperty(c => c.RevisionDate, now)); + + await dbContext.AccessRules + .Where(r => r.Id == accessRule.Id) + .ExecuteDeleteAsync(); + + await dbContext.UserBumpAccountRevisionDateByOrganizationIdAsync(accessRule.OrganizationId); + await dbContext.SaveChangesAsync(); + await transaction.CommitAsync(); + } + + public async Task SetCollectionAssociationsAsync(Guid organizationId, Guid accessRuleId, + IEnumerable collectionIdsToAssign, IEnumerable collectionIdsToClear) + { + var assignIds = collectionIdsToAssign.ToList(); + var clearIds = collectionIdsToClear.ToList(); + + using var scope = ServiceScopeFactory.CreateScope(); + var dbContext = GetDatabaseContext(scope); + var now = DateTime.UtcNow; + + await using var transaction = await dbContext.Database.BeginTransactionAsync(); + + if (clearIds.Count > 0) + { + await dbContext.Collections + .Where(c => c.OrganizationId == organizationId + && c.AccessRuleId == accessRuleId + && clearIds.Contains(c.Id)) + .ExecuteUpdateAsync(s => s + .SetProperty(c => c.AccessRuleId, (Guid?)null) + .SetProperty(c => c.RevisionDate, now)); + } + + if (assignIds.Count > 0) + { + await dbContext.Collections + .Where(c => c.OrganizationId == organizationId && assignIds.Contains(c.Id)) + .ExecuteUpdateAsync(s => s + .SetProperty(c => c.AccessRuleId, accessRuleId) + .SetProperty(c => c.RevisionDate, now)); + } + + await dbContext.UserBumpAccountRevisionDateByOrganizationIdAsync(organizationId); + await dbContext.SaveChangesAsync(); + await transaction.CommitAsync(); + } } diff --git a/src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/AccessRule_DeleteById.sql b/src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/AccessRule_DeleteById.sql index 238724033a67..50ba4eb8db4f 100644 --- a/src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/AccessRule_DeleteById.sql +++ b/src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/AccessRule_DeleteById.sql @@ -4,5 +4,29 @@ AS BEGIN SET NOCOUNT ON + DECLARE @RevisionDate DATETIME2(7) = SYSUTCDATETIME() + DECLARE @OrganizationId UNIQUEIDENTIFIER + + SELECT @OrganizationId = [OrganizationId] + FROM [dbo].[AccessRule] + WHERE [Id] = @Id + + BEGIN TRANSACTION + + UPDATE + [dbo].[Collection] + SET + [AccessRuleId] = NULL, + [RevisionDate] = @RevisionDate + WHERE + [AccessRuleId] = @Id + DELETE FROM [dbo].[AccessRule] WHERE [Id] = @Id + + COMMIT TRANSACTION + + IF @OrganizationId IS NOT NULL + BEGIN + EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationId] @OrganizationId + END END diff --git a/src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/AccessRule_ReadDetailsById.sql b/src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/AccessRule_ReadDetailsById.sql new file mode 100644 index 000000000000..f9ee5eec5992 --- /dev/null +++ b/src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/AccessRule_ReadDetailsById.sql @@ -0,0 +1,14 @@ +CREATE PROCEDURE [dbo].[AccessRule_ReadDetailsById] + @Id UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT * + FROM [dbo].[AccessRule] + WHERE [Id] = @Id + + SELECT [Id] + FROM [dbo].[Collection] + WHERE [AccessRuleId] = @Id +END diff --git a/src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/AccessRule_ReadDetailsByOrganizationId.sql b/src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/AccessRule_ReadDetailsByOrganizationId.sql new file mode 100644 index 000000000000..6a4cb6d4bd40 --- /dev/null +++ b/src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/AccessRule_ReadDetailsByOrganizationId.sql @@ -0,0 +1,17 @@ +CREATE PROCEDURE [dbo].[AccessRule_ReadDetailsByOrganizationId] + @OrganizationId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT * + FROM [dbo].[AccessRule] + WHERE [OrganizationId] = @OrganizationId + + SELECT + [AccessRuleId], + [Id] AS [CollectionId] + FROM [dbo].[Collection] + WHERE [OrganizationId] = @OrganizationId + AND [AccessRuleId] IS NOT NULL +END diff --git a/src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/Collection_SetAccessRuleAssociations.sql b/src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/Collection_SetAccessRuleAssociations.sql new file mode 100644 index 000000000000..9a52130f076b --- /dev/null +++ b/src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/Collection_SetAccessRuleAssociations.sql @@ -0,0 +1,42 @@ +CREATE PROCEDURE [dbo].[Collection_SetAccessRuleAssociations] + @AccessRuleId UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @ToAssign AS [dbo].[GuidIdArray] READONLY, + @ToClear AS [dbo].[GuidIdArray] READONLY +AS +BEGIN + SET NOCOUNT ON + + DECLARE @RevisionDate DATETIME2(7) = SYSUTCDATETIME() + + BEGIN TRANSACTION + + UPDATE + C + SET + C.[AccessRuleId] = NULL, + C.[RevisionDate] = @RevisionDate + FROM + [dbo].[Collection] C + INNER JOIN + @ToClear T ON T.[Id] = C.[Id] + WHERE + C.[OrganizationId] = @OrganizationId + AND C.[AccessRuleId] = @AccessRuleId + + UPDATE + C + SET + C.[AccessRuleId] = @AccessRuleId, + C.[RevisionDate] = @RevisionDate + FROM + [dbo].[Collection] C + INNER JOIN + @ToAssign T ON T.[Id] = C.[Id] + WHERE + C.[OrganizationId] = @OrganizationId + + COMMIT TRANSACTION + + EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationId] @OrganizationId +END diff --git a/test/Core.Test/PrivilegedAccessManagement/Commands/CreateAccessRuleCommandTests.cs b/test/Core.Test/PrivilegedAccessManagement/Commands/CreateAccessRuleCommandTests.cs index 6717d8f0c911..e4012dfdd62f 100644 --- a/test/Core.Test/PrivilegedAccessManagement/Commands/CreateAccessRuleCommandTests.cs +++ b/test/Core.Test/PrivilegedAccessManagement/Commands/CreateAccessRuleCommandTests.cs @@ -1,8 +1,10 @@ -using Bit.Core.Exceptions; +using Bit.Core.Entities; +using Bit.Core.Exceptions; using Bit.Core.PrivilegedAccessManagement.Entities; using Bit.Core.PrivilegedAccessManagement.OrganizationFeatures.Commands; using Bit.Core.PrivilegedAccessManagement.Repositories; using Bit.Core.PrivilegedAccessManagement.Services; +using Bit.Core.Repositories; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using Microsoft.Extensions.Time.Testing; @@ -32,20 +34,122 @@ public async Task CreateAsync_HappyPath_PersistsWithTimestampsAndValidates(Acces .CreateAsync(rule) .Returns(rule); - var result = await sutProvider.Sut.CreateAsync(rule); + var result = await sutProvider.Sut.CreateAsync(rule, []); Assert.Equal(_now, result.CreationDate); Assert.Equal(_now, result.RevisionDate); await sutProvider.GetDependency().Received(1).CreateAsync(rule); } + [Theory, BitAutoData] + public async Task CreateAsync_WithCollections_AssociatesAndReturnsThem(AccessRule rule, Collection collectionA, + Collection collectionB) + { + var sutProvider = SetupSutProvider(); + rule.Name = "VPN + business hours"; + rule.Rule = """{"kind":"human_approval"}"""; + collectionA.OrganizationId = rule.OrganizationId; + collectionA.AccessRuleId = null; + collectionB.OrganizationId = rule.OrganizationId; + collectionB.AccessRuleId = null; + var collectionIds = new[] { collectionA.Id, collectionB.Id }; + sutProvider.GetDependency() + .Validate(rule.Rule) + .Returns(AccessRuleValidationResult.Valid); + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(rule.OrganizationId) + .Returns(new List()); + sutProvider.GetDependency() + .CreateAsync(rule) + .Returns(rule); + sutProvider.GetDependency() + .GetManyByManyIdsAsync(Arg.Is>(ids => ids.OrderBy(x => x).SequenceEqual(collectionIds.OrderBy(x => x)))) + .Returns(new List { collectionA, collectionB }); + + await sutProvider.Sut.CreateAsync(rule, collectionIds); + + await sutProvider.GetDependency().Received(1) + .SetCollectionAssociationsAsync(rule.OrganizationId, rule.Id, + Arg.Is>(ids => ids.OrderBy(x => x).SequenceEqual(collectionIds.OrderBy(x => x))), + Arg.Is>(ids => !ids.Any())); + } + + [Theory, BitAutoData] + public async Task CreateAsync_CollectionInDifferentOrg_ThrowsBadRequest(AccessRule rule, Collection collection) + { + var sutProvider = SetupSutProvider(); + rule.Name = "test"; + rule.Rule = """{"kind":"human_approval"}"""; + collection.OrganizationId = Guid.NewGuid(); + sutProvider.GetDependency() + .Validate(rule.Rule) + .Returns(AccessRuleValidationResult.Valid); + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(rule.OrganizationId) + .Returns(new List()); + sutProvider.GetDependency() + .GetManyByManyIdsAsync(Arg.Any>()) + .Returns(new List { collection }); + + var ex = await Assert.ThrowsAsync( + () => sutProvider.Sut.CreateAsync(rule, new[] { collection.Id })); + Assert.Contains("do not belong to this organization", ex.Message); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().CreateAsync(default!); + } + + [Theory, BitAutoData] + public async Task CreateAsync_CollectionGovernedByAnotherRule_ThrowsBadRequest(AccessRule rule, Collection collection) + { + var sutProvider = SetupSutProvider(); + rule.Name = "test"; + rule.Rule = """{"kind":"human_approval"}"""; + collection.OrganizationId = rule.OrganizationId; + collection.AccessRuleId = Guid.NewGuid(); + sutProvider.GetDependency() + .Validate(rule.Rule) + .Returns(AccessRuleValidationResult.Valid); + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(rule.OrganizationId) + .Returns(new List()); + sutProvider.GetDependency() + .GetManyByManyIdsAsync(Arg.Any>()) + .Returns(new List { collection }); + + var ex = await Assert.ThrowsAsync( + () => sutProvider.Sut.CreateAsync(rule, new[] { collection.Id })); + Assert.Contains("already governed by another access rule", ex.Message); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().CreateAsync(default!); + } + + [Theory, BitAutoData] + public async Task CreateAsync_CollectionNotFound_ThrowsBadRequest(AccessRule rule, Guid missingCollectionId) + { + var sutProvider = SetupSutProvider(); + rule.Name = "test"; + rule.Rule = """{"kind":"human_approval"}"""; + sutProvider.GetDependency() + .Validate(rule.Rule) + .Returns(AccessRuleValidationResult.Valid); + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(rule.OrganizationId) + .Returns(new List()); + sutProvider.GetDependency() + .GetManyByManyIdsAsync(Arg.Any>()) + .Returns(new List()); + + var ex = await Assert.ThrowsAsync( + () => sutProvider.Sut.CreateAsync(rule, new[] { missingCollectionId })); + Assert.Contains("could not be found", ex.Message); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().CreateAsync(default!); + } + [Theory, BitAutoData] public async Task CreateAsync_EmptyName_ThrowsBadRequest(AccessRule rule) { var sutProvider = SetupSutProvider(); rule.Name = " "; - var ex = await Assert.ThrowsAsync(() => sutProvider.Sut.CreateAsync(rule)); + var ex = await Assert.ThrowsAsync(() => sutProvider.Sut.CreateAsync(rule, [])); Assert.Contains("Name is required", ex.Message); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().CreateAsync(default!); } @@ -60,7 +164,7 @@ public async Task CreateAsync_InvalidRule_ThrowsBadRequest(AccessRule rule) .Validate(rule.Rule) .Returns(AccessRuleValidationResult.Invalid("Unsupported rule kind")); - var ex = await Assert.ThrowsAsync(() => sutProvider.Sut.CreateAsync(rule)); + var ex = await Assert.ThrowsAsync(() => sutProvider.Sut.CreateAsync(rule, [])); Assert.Equal("Unsupported rule kind", ex.Message); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().CreateAsync(default!); } @@ -80,7 +184,7 @@ public async Task CreateAsync_DuplicateName_ThrowsBadRequest(AccessRule rule, Ac .GetManyByOrganizationIdAsync(rule.OrganizationId) .Returns(new List { existing }); - var ex = await Assert.ThrowsAsync(() => sutProvider.Sut.CreateAsync(rule)); + var ex = await Assert.ThrowsAsync(() => sutProvider.Sut.CreateAsync(rule, [])); Assert.Contains("already exists", ex.Message); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().CreateAsync(default!); } diff --git a/test/Core.Test/PrivilegedAccessManagement/Commands/UpdateAccessRuleCommandTests.cs b/test/Core.Test/PrivilegedAccessManagement/Commands/UpdateAccessRuleCommandTests.cs index 472d596b8d81..384bd4bc5d97 100644 --- a/test/Core.Test/PrivilegedAccessManagement/Commands/UpdateAccessRuleCommandTests.cs +++ b/test/Core.Test/PrivilegedAccessManagement/Commands/UpdateAccessRuleCommandTests.cs @@ -1,8 +1,11 @@ -using Bit.Core.Exceptions; +using Bit.Core.Entities; +using Bit.Core.Exceptions; using Bit.Core.PrivilegedAccessManagement.Entities; +using Bit.Core.PrivilegedAccessManagement.Models; using Bit.Core.PrivilegedAccessManagement.OrganizationFeatures.Commands; using Bit.Core.PrivilegedAccessManagement.Repositories; using Bit.Core.PrivilegedAccessManagement.Services; +using Bit.Core.Repositories; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using Microsoft.Extensions.Time.Testing; @@ -17,15 +20,16 @@ public class UpdateAccessRuleCommandTests private static readonly DateTime _now = new(2026, 5, 21, 12, 0, 0, DateTimeKind.Utc); [Theory, BitAutoData] - public async Task UpdateAsync_HappyPath_UpdatesFieldsAndBumpsRevision(AccessRule existing, AccessRule update) + public async Task UpdateAsync_HappyPath_UpdatesFieldsAndBumpsRevision(AccessRuleDetails existing, AccessRule update) { var sutProvider = SetupSutProvider(); var orgId = existing.OrganizationId; + existing.CollectionIds = []; update.Name = "renamed"; update.Description = "new description"; update.Rule = """{"kind":"human_approval"}"""; sutProvider.GetDependency() - .GetByIdAsync(existing.Id) + .GetDetailsByIdAsync(existing.Id) .Returns(existing); sutProvider.GetDependency() .Validate(update.Rule) @@ -34,13 +38,109 @@ public async Task UpdateAsync_HappyPath_UpdatesFieldsAndBumpsRevision(AccessRule .GetManyByOrganizationIdAsync(orgId) .Returns(new List { existing }); - var result = await sutProvider.Sut.UpdateAsync(orgId, existing.Id, update); + var result = await sutProvider.Sut.UpdateAsync(orgId, existing.Id, update, []); Assert.Equal("renamed", result.Name); Assert.Equal("new description", result.Description); Assert.Equal(update.Rule, result.Rule); Assert.Equal(_now, result.RevisionDate); - await sutProvider.GetDependency().Received(1).ReplaceAsync(existing); + await sutProvider.GetDependency().Received(1) + .ReplaceAsync(Arg.Is(r => + r.Id == existing.Id && r.Name == "renamed" && r.Description == "new description")); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_ReplacesCollections_AssignsNewAndClearsRemoved(AccessRuleDetails existing, + AccessRule update, Collection keep, Collection add) + { + var sutProvider = SetupSutProvider(); + var orgId = existing.OrganizationId; + update.Name = "renamed"; + update.Rule = """{"kind":"human_approval"}"""; + keep.OrganizationId = orgId; + keep.AccessRuleId = existing.Id; // already governed by this rule + add.OrganizationId = orgId; + add.AccessRuleId = null; + var desired = new[] { keep.Id, add.Id }; + var removedId = Guid.NewGuid(); + existing.CollectionIds = [keep.Id, removedId]; + sutProvider.GetDependency() + .GetDetailsByIdAsync(existing.Id) + .Returns(existing); + sutProvider.GetDependency() + .Validate(update.Rule) + .Returns(AccessRuleValidationResult.Valid); + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(orgId) + .Returns(new List { existing }); + sutProvider.GetDependency() + .GetManyByManyIdsAsync(Arg.Any>()) + .Returns(new List { keep, add }); + + var result = await sutProvider.Sut.UpdateAsync(orgId, existing.Id, update, desired); + + Assert.Equal(desired, result.CollectionIds); + await sutProvider.GetDependency().Received(1) + .SetCollectionAssociationsAsync(orgId, existing.Id, + Arg.Is>(ids => ids.OrderBy(x => x).SequenceEqual(desired.OrderBy(x => x))), + Arg.Is>(ids => ids.SequenceEqual(new[] { removedId }))); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_EmptyCollections_ClearsAll(AccessRuleDetails existing, AccessRule update) + { + var sutProvider = SetupSutProvider(); + var orgId = existing.OrganizationId; + update.Name = "renamed"; + update.Rule = """{"kind":"human_approval"}"""; + var currentId = Guid.NewGuid(); + existing.CollectionIds = [currentId]; + sutProvider.GetDependency() + .GetDetailsByIdAsync(existing.Id) + .Returns(existing); + sutProvider.GetDependency() + .Validate(update.Rule) + .Returns(AccessRuleValidationResult.Valid); + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(orgId) + .Returns(new List { existing }); + + var result = await sutProvider.Sut.UpdateAsync(orgId, existing.Id, update, []); + + Assert.Empty(result.CollectionIds); + await sutProvider.GetDependency().Received(1) + .SetCollectionAssociationsAsync(orgId, existing.Id, + Arg.Is>(ids => !ids.Any()), + Arg.Is>(ids => ids.SequenceEqual(new[] { currentId }))); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_CollectionGovernedByAnotherRule_ThrowsBadRequest(AccessRuleDetails existing, + AccessRule update, Collection collection) + { + var sutProvider = SetupSutProvider(); + var orgId = existing.OrganizationId; + update.Name = "renamed"; + update.Rule = """{"kind":"human_approval"}"""; + collection.OrganizationId = orgId; + collection.AccessRuleId = Guid.NewGuid(); // a different rule + sutProvider.GetDependency() + .GetDetailsByIdAsync(existing.Id) + .Returns(existing); + sutProvider.GetDependency() + .Validate(update.Rule) + .Returns(AccessRuleValidationResult.Valid); + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(orgId) + .Returns(new List { existing }); + sutProvider.GetDependency() + .GetManyByManyIdsAsync(Arg.Any>()) + .Returns(new List { collection }); + + var ex = await Assert.ThrowsAsync( + () => sutProvider.Sut.UpdateAsync(orgId, existing.Id, update, new[] { collection.Id })); + Assert.Contains("already governed by another access rule", ex.Message); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().ReplaceAsync(default!); } [Theory, BitAutoData] @@ -48,42 +148,42 @@ public async Task UpdateAsync_MissingExisting_ThrowsNotFound(AccessRule update) { var sutProvider = SetupSutProvider(); sutProvider.GetDependency() - .GetByIdAsync(Arg.Any()) - .Returns((AccessRule?)null); + .GetDetailsByIdAsync(Arg.Any()) + .Returns((AccessRuleDetails?)null); await Assert.ThrowsAsync( - () => sutProvider.Sut.UpdateAsync(Guid.NewGuid(), Guid.NewGuid(), update)); + () => sutProvider.Sut.UpdateAsync(Guid.NewGuid(), Guid.NewGuid(), update, [])); } [Theory, BitAutoData] - public async Task UpdateAsync_WrongOrg_ThrowsNotFound(AccessRule existing, AccessRule update) + public async Task UpdateAsync_WrongOrg_ThrowsNotFound(AccessRuleDetails existing, AccessRule update) { var sutProvider = SetupSutProvider(); var differentOrg = Guid.NewGuid(); sutProvider.GetDependency() - .GetByIdAsync(existing.Id) + .GetDetailsByIdAsync(existing.Id) .Returns(existing); await Assert.ThrowsAsync( - () => sutProvider.Sut.UpdateAsync(differentOrg, existing.Id, update)); + () => sutProvider.Sut.UpdateAsync(differentOrg, existing.Id, update, [])); } [Theory, BitAutoData] - public async Task UpdateAsync_InvalidRule_ThrowsBadRequest(AccessRule existing, AccessRule update) + public async Task UpdateAsync_InvalidRule_ThrowsBadRequest(AccessRuleDetails existing, AccessRule update) { var sutProvider = SetupSutProvider(); var orgId = existing.OrganizationId; update.Name = "ok"; update.Rule = """{"kind":"bogus"}"""; sutProvider.GetDependency() - .GetByIdAsync(existing.Id) + .GetDetailsByIdAsync(existing.Id) .Returns(existing); sutProvider.GetDependency() .Validate(update.Rule) .Returns(AccessRuleValidationResult.Invalid("nope")); var ex = await Assert.ThrowsAsync( - () => sutProvider.Sut.UpdateAsync(orgId, existing.Id, update)); + () => sutProvider.Sut.UpdateAsync(orgId, existing.Id, update, [])); Assert.Equal("nope", ex.Message); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().ReplaceAsync(default!); } diff --git a/test/Infrastructure.IntegrationTest/PrivilegedAccessManagement/Repositories/AccessRuleRepositoryTests.cs b/test/Infrastructure.IntegrationTest/PrivilegedAccessManagement/Repositories/AccessRuleRepositoryTests.cs new file mode 100644 index 000000000000..920e3ea52148 --- /dev/null +++ b/test/Infrastructure.IntegrationTest/PrivilegedAccessManagement/Repositories/AccessRuleRepositoryTests.cs @@ -0,0 +1,53 @@ +using Bit.Core.Entities; +using Bit.Core.PrivilegedAccessManagement.Entities; +using Bit.Core.PrivilegedAccessManagement.Repositories; +using Bit.Core.Repositories; +using Bit.Infrastructure.IntegrationTest.AdminConsole; +using Xunit; + +namespace Bit.Infrastructure.IntegrationTest.PrivilegedAccessManagement.Repositories; + +public class AccessRuleRepositoryTests +{ + [DatabaseTheory, DatabaseData] + public async Task DeleteAsync_WithGovernedCollections_ClearsAssociationsAndKeepsCollections( + IOrganizationRepository organizationRepository, + ICollectionRepository collectionRepository, + IAccessRuleRepository accessRuleRepository) + { + // Arrange + var organization = await organizationRepository.CreateTestOrganizationAsync(); + + var rule = await accessRuleRepository.CreateAsync(new AccessRule + { + OrganizationId = organization.Id, + Name = "Test Rule", + Rule = """{"kind":"human_approval"}""", + }); + + var collection = new Collection + { + Name = "Governed Collection", + OrganizationId = organization.Id, + }; + await collectionRepository.CreateAsync(collection, [], []); + + await accessRuleRepository.SetCollectionAssociationsAsync( + organization.Id, rule.Id, [collection.Id], []); + + // Sanity check: the collection is governed by the rule before deletion. + var details = await accessRuleRepository.GetDetailsByIdAsync(rule.Id); + Assert.NotNull(details); + Assert.Contains(collection.Id, details.CollectionIds); + + // Act + await accessRuleRepository.DeleteAsync(rule); + + // Assert: the rule is gone, but the collection survives with its association cleared. + Assert.Null(await accessRuleRepository.GetByIdAsync(rule.Id)); + + var actualCollection = await collectionRepository.GetByIdAsync(collection.Id); + Assert.NotNull(actualCollection); + Assert.Null(actualCollection.AccessRuleId); + } +} diff --git a/util/Migrator/DbScripts/2026-06-01_00_AddAccessRuleCollectionAssociations.sql b/util/Migrator/DbScripts/2026-06-01_00_AddAccessRuleCollectionAssociations.sql new file mode 100644 index 000000000000..b0f3716da293 --- /dev/null +++ b/util/Migrator/DbScripts/2026-06-01_00_AddAccessRuleCollectionAssociations.sql @@ -0,0 +1,117 @@ +-- Read an access rule along with the IDs of the collections it governs (second result set) +CREATE OR ALTER PROCEDURE [dbo].[AccessRule_ReadDetailsById] + @Id UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT * + FROM [dbo].[AccessRule] + WHERE [Id] = @Id + + SELECT [Id] + FROM [dbo].[Collection] + WHERE [AccessRuleId] = @Id +END +GO + +-- Read all access rules in an organization (result set 1) along with their governed collections (result set 2) +CREATE OR ALTER PROCEDURE [dbo].[AccessRule_ReadDetailsByOrganizationId] + @OrganizationId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT * + FROM [dbo].[AccessRule] + WHERE [OrganizationId] = @OrganizationId + + SELECT + [AccessRuleId], + [Id] AS [CollectionId] + FROM [dbo].[Collection] + WHERE [OrganizationId] = @OrganizationId + AND [AccessRuleId] IS NOT NULL +END +GO + +-- Replace an access rule's collection associations: clear the rule from @ToClear and point @ToAssign at it. +-- Both sets are scoped to @OrganizationId. +CREATE OR ALTER PROCEDURE [dbo].[Collection_SetAccessRuleAssociations] + @AccessRuleId UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @ToAssign AS [dbo].[GuidIdArray] READONLY, + @ToClear AS [dbo].[GuidIdArray] READONLY +AS +BEGIN + SET NOCOUNT ON + + DECLARE @RevisionDate DATETIME2(7) = SYSUTCDATETIME() + + BEGIN TRANSACTION + + UPDATE + C + SET + C.[AccessRuleId] = NULL, + C.[RevisionDate] = @RevisionDate + FROM + [dbo].[Collection] C + INNER JOIN + @ToClear T ON T.[Id] = C.[Id] + WHERE + C.[OrganizationId] = @OrganizationId + AND C.[AccessRuleId] = @AccessRuleId + + UPDATE + C + SET + C.[AccessRuleId] = @AccessRuleId, + C.[RevisionDate] = @RevisionDate + FROM + [dbo].[Collection] C + INNER JOIN + @ToAssign T ON T.[Id] = C.[Id] + WHERE + C.[OrganizationId] = @OrganizationId + + COMMIT TRANSACTION + + EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationId] @OrganizationId +END +GO + +-- Deleting an access rule first clears it from any collections it governs (FK is NO ACTION), then deletes the rule. +CREATE OR ALTER PROCEDURE [dbo].[AccessRule_DeleteById] + @Id UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + DECLARE @RevisionDate DATETIME2(7) = SYSUTCDATETIME() + DECLARE @OrganizationId UNIQUEIDENTIFIER + + SELECT @OrganizationId = [OrganizationId] + FROM [dbo].[AccessRule] + WHERE [Id] = @Id + + BEGIN TRANSACTION + + UPDATE + [dbo].[Collection] + SET + [AccessRuleId] = NULL, + [RevisionDate] = @RevisionDate + WHERE + [AccessRuleId] = @Id + + DELETE FROM [dbo].[AccessRule] WHERE [Id] = @Id + + COMMIT TRANSACTION + + IF @OrganizationId IS NOT NULL + BEGIN + EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationId] @OrganizationId + END +END +GO From 04bb28e22148c5ba8fab3c940a00fce43cc99bc3 Mon Sep 17 00:00:00 2001 From: Hinton Date: Tue, 2 Jun 2026 17:31:56 +0200 Subject: [PATCH 07/54] Add sync support --- src/Api/Vault/Controllers/SyncController.cs | 43 ++++- .../Models/Response/CipherResponseModel.cs | 31 +++- .../Models/Response/SyncResponseModel.cs | 6 +- .../Vault/Models/Data/PartialCipherData.cs | 57 +++++++ .../Vault/Controllers/SyncControllerTests.cs | 161 ++++++++++++++++++ .../Response/CipherResponseModelTests.cs | 131 ++++++++++++++ .../Models/Data/PartialCipherDataTests.cs | 131 ++++++++++++++ 7 files changed, 552 insertions(+), 8 deletions(-) create mode 100644 src/Core/Vault/Models/Data/PartialCipherData.cs create mode 100644 test/Core.Test/Vault/Models/Data/PartialCipherDataTests.cs diff --git a/src/Api/Vault/Controllers/SyncController.cs b/src/Api/Vault/Controllers/SyncController.cs index 13eddbc98eb1..dfe816e77d5c 100644 --- a/src/Api/Vault/Controllers/SyncController.cs +++ b/src/Api/Vault/Controllers/SyncController.cs @@ -121,6 +121,12 @@ await _providerUserRepository.GetManyOrganizationDetailsByUserAsync(user.Id, collectionCiphersGroupDict = collectionCiphers.GroupBy(c => c.CipherId).ToDictionary(s => s.Key); } + // PAM credential leasing: ciphers reachable only through leasing-enabled collections are delivered + // with reduced data during the passive sync. The active GET /ciphers/{id} path is unchanged. + var partialDataCipherIds = _featureService.IsEnabled(FeatureFlagKeys.Pam) + ? GetPartialDataCipherIds(collections, collectionCiphersGroupDict) + : new HashSet(); + var userTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user); var userHasPremiumFromOrganization = await _userService.HasPremiumFromOrganization(user); var organizationClaimingActiveUser = await _userService.GetOrganizationsClaimingUserAsync(user.Id); @@ -147,10 +153,45 @@ await _providerUserRepository.GetManyOrganizationDetailsByUserAsync(user.Id, var response = new SyncResponseModel(_globalSettings, user, userAccountKeys, userTwoFactorEnabled, userHasPremiumFromOrganization, organizationAbilities, organizationIdsClaimingActiveUser, organizationUserDetails, providerUserDetails, providerUserOrganizationDetails, folders, collections, ciphers, collectionCiphersGroupDict, excludeDomains, policies, sends, webAuthnCredentials, - policiesNew, organizationUserDetailsNew); + policiesNew, organizationUserDetailsNew, partialDataCipherIds); return response; } + /// + /// Returns the IDs of ciphers the user can reach only through leasing-enabled collections + /// (those with an ). A cipher reachable through any non-leasing + /// collection — or owned personally with no collection mapping — is excluded and receives full data. + /// + private static ISet GetPartialDataCipherIds( + IEnumerable collections, + IDictionary> collectionCiphersGroupDict) + { + var partialDataCipherIds = new HashSet(); + if (collections == null || collectionCiphersGroupDict == null) + { + return partialDataCipherIds; + } + + var leasingCollectionIds = collections + .Where(c => c.AccessRuleId.HasValue) + .Select(c => c.Id) + .ToHashSet(); + if (leasingCollectionIds.Count == 0) + { + return partialDataCipherIds; + } + + foreach (var (cipherId, collectionCiphers) in collectionCiphersGroupDict) + { + if (collectionCiphers.Any() && collectionCiphers.All(cc => leasingCollectionIds.Contains(cc.CollectionId))) + { + partialDataCipherIds.Add(cipherId); + } + } + + return partialDataCipherIds; + } + private async Task> GetOrganizationAbilitiesAsync(ICollection ciphers) { var orgIds = ciphers diff --git a/src/Api/Vault/Models/Response/CipherResponseModel.cs b/src/Api/Vault/Models/Response/CipherResponseModel.cs index 808e9e94b921..e760afe95794 100644 --- a/src/Api/Vault/Models/Response/CipherResponseModel.cs +++ b/src/Api/Vault/Models/Response/CipherResponseModel.cs @@ -1,6 +1,7 @@  using System.Text.Json; +using System.Text.Json.Serialization; using Bit.Core.Entities; using Bit.Core.Models.Api; using Bit.Core.Models.Data.Organizations; @@ -15,7 +16,7 @@ namespace Bit.Api.Vault.Models.Response; public class CipherMiniResponseModel : ResponseModel { - public CipherMiniResponseModel(Cipher cipher, IGlobalSettings globalSettings, bool orgUseTotp, string obj = "cipherMini") + public CipherMiniResponseModel(Cipher cipher, IGlobalSettings globalSettings, bool orgUseTotp, string obj = "cipherMini", bool isPartial = false) : base(obj) { if (cipher == null) @@ -35,6 +36,16 @@ public CipherMiniResponseModel(Cipher cipher, IGlobalSettings globalSettings, bo Reprompt = cipher.Reprompt.GetValueOrDefault(CipherRepromptType.None); Key = cipher.Key; + if (isPartial) + { + // Under credential leasing the full data blob and all the obsolete typed fields are withheld; + // the reduced blob is returned only in PartialData, signalling that this cipher is leasing-gated. + // The client decrypts PartialData itself. + Data = null; + PartialData = PartialCipherData.Strip(cipher.Type, cipher.Data); + return; + } + if (cipher.IsDataBlobEncrypted()) { return; @@ -98,6 +109,14 @@ public CipherMiniResponseModel(Cipher cipher, IGlobalSettings globalSettings, bo public CipherType Type { get; set; } public string Data { get; set; } + /// + /// The reduced data blob returned in place of when the caller can only reach this + /// cipher through leasing-enabled collections (PAM credential leasing). Contains the encrypted title + /// and, for logins, the encrypted URIs — never the dropped secrets. Null for non-leased ciphers. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string PartialData { get; set; } + [Obsolete("Use Data instead.")] public string Name { get; set; } @@ -149,8 +168,9 @@ public CipherResponseModel( User user, OrganizationAbility? organizationAbility, IGlobalSettings globalSettings, - string obj = "cipher") - : base(cipher, globalSettings, cipher.OrganizationUseTotp, obj) + string obj = "cipher", + bool isPartial = false) + : base(cipher, globalSettings, cipher.OrganizationUseTotp, obj, isPartial) { FolderId = cipher.FolderId; Favorite = cipher.Favorite; @@ -175,8 +195,9 @@ public CipherDetailsResponseModel( User user, OrganizationAbility? organizationAbility, GlobalSettings globalSettings, - IDictionary> collectionCiphers, string obj = "cipherDetails") - : base(cipher, user, organizationAbility, globalSettings, obj) + IDictionary> collectionCiphers, string obj = "cipherDetails", + bool isPartial = false) + : base(cipher, user, organizationAbility, globalSettings, obj, isPartial) { if (collectionCiphers?.TryGetValue(cipher.Id, out var collectionCipher) ?? false) { diff --git a/src/Api/Vault/Models/Response/SyncResponseModel.cs b/src/Api/Vault/Models/Response/SyncResponseModel.cs index 47bbc16c4976..02203387d351 100644 --- a/src/Api/Vault/Models/Response/SyncResponseModel.cs +++ b/src/Api/Vault/Models/Response/SyncResponseModel.cs @@ -46,7 +46,8 @@ public SyncResponseModel( IEnumerable sends, IEnumerable webAuthnCredentials, IEnumerable policiesNew = null, - IEnumerable organizationUserDetailsNew = null) + IEnumerable organizationUserDetailsNew = null, + ISet partialDataCipherIds = null) : this() { Profile = new ProfileResponseModel(user, userAccountKeysData, organizationUserDetails, providerUserDetails, @@ -59,7 +60,8 @@ public SyncResponseModel( user, GetOrganizationAbility(cipher, organizationAbilities), globalSettings, - collectionCiphersDict)); + collectionCiphersDict, + isPartial: partialDataCipherIds?.Contains(cipher.Id) ?? false)); Collections = collections?.Select( c => new CollectionDetailsResponseModel(c)) ?? new List(); Domains = excludeDomains ? null : new DomainsResponseModel(user, false); diff --git a/src/Core/Vault/Models/Data/PartialCipherData.cs b/src/Core/Vault/Models/Data/PartialCipherData.cs new file mode 100644 index 000000000000..5372cff268cc --- /dev/null +++ b/src/Core/Vault/Models/Data/PartialCipherData.cs @@ -0,0 +1,57 @@ +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json; +using Bit.Core.Utilities; +using Bit.Core.Vault.Enums; + +namespace Bit.Core.Vault.Models.Data; + +/// +/// Produces a "partial" version of a cipher's encrypted Data blob for PAM credential leasing. +/// When a user can only reach a cipher through leasing-enabled collections, sync delivers this reduced +/// blob instead of the full one. +/// +/// +/// Zero-knowledge is preserved: nothing is ever decrypted. This only reshapes the plaintext JSON +/// envelope, keeping the encrypted title (and, for logins, the encrypted URIs) and dropping every other +/// encrypted field (username, password, TOTP, notes, custom fields, etc.). The retained values remain +/// individually-encrypted EncStrings. +/// +public static class PartialCipherData +{ + /// + /// Reduces a cipher's JSON Data blob to the fields allowed under credential leasing. + /// Logins keep Name and Uris; all other types keep only Name. + /// + /// The cipher's type. + /// The full, encrypted JSON data blob. Must be JSON (not an SDK-encrypted blob). + /// A reduced JSON data blob, or the input unchanged when it is null/empty. + public static string Strip(CipherType type, string data) + { + if (string.IsNullOrWhiteSpace(data)) + { + return data; + } + + if (type == CipherType.Login) + { + var login = JsonSerializer.Deserialize(data); + var partial = new CipherLoginData + { + Name = login.Name, + Uris = login.Uris, + }; + return JsonSerializer.Serialize(partial, JsonHelpers.IgnoreWritingNull); + } + + var nameOnly = JsonSerializer.Deserialize(data); + return JsonSerializer.Serialize( + new NameOnlyData { Name = nameOnly.Name }, JsonHelpers.IgnoreWritingNull); + } + + private class NameOnlyData + { + public string Name { get; set; } + } +} diff --git a/test/Api.Test/Vault/Controllers/SyncControllerTests.cs b/test/Api.Test/Vault/Controllers/SyncControllerTests.cs index 4d3458b2f08f..1894b2e5dca8 100644 --- a/test/Api.Test/Vault/Controllers/SyncControllerTests.cs +++ b/test/Api.Test/Vault/Controllers/SyncControllerTests.cs @@ -17,6 +17,7 @@ using Bit.Core.KeyManagement.Models.Data; using Bit.Core.KeyManagement.Queries.Interfaces; using Bit.Core.Models.Data; +using Bit.Core.Models.Data.Organizations; using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Repositories; using Bit.Core.Services; @@ -695,6 +696,166 @@ public async Task Get_PoliciesInAcceptedState_FlagDisabled_DoesNotCallNewReposit Assert.Null(result.Profile.OrganizationsNew); } + [Theory] + [BitAutoData] + public async Task Get_Leasing_FlagEnabled_CipherOnlyInLeasingCollection_ReturnsPartialData( + User user, SutProvider sutProvider) + { + var orgId = Guid.NewGuid(); + var leasingCollectionId = Guid.NewGuid(); + var cipherId = Guid.NewGuid(); + + var cipher = new CipherDetails + { + Id = cipherId, + Type = CipherType.Login, + OrganizationId = orgId, + Data = JsonSerializer.Serialize(new CipherLoginData + { + Name = "2.name|encrypted", + Username = "2.SENTINEL-username|encrypted", + Password = "2.SENTINEL-password|encrypted", + Uris = new[] { new CipherLoginData.CipherLoginUriData { Uri = "2.uri|encrypted" } }, + }), + }; + + SetupLeasingSync(sutProvider, user, orgId, + ciphers: new List { cipher }, + collections: new List { new() { Id = leasingCollectionId, OrganizationId = orgId, AccessRuleId = Guid.NewGuid() } }, + collectionCiphers: new List { new() { CipherId = cipherId, CollectionId = leasingCollectionId } }, + pamFlagEnabled: true); + + var result = await sutProvider.Sut.Get(); + + var synced = Assert.Single(result.Ciphers, c => c.Id == cipherId); + Assert.Null(synced.Data); + Assert.NotNull(synced.PartialData); + Assert.DoesNotContain("SENTINEL", synced.PartialData); + Assert.Contains("2.name|encrypted", synced.PartialData); + Assert.Contains("2.uri|encrypted", synced.PartialData); + } + + [Theory] + [BitAutoData] + public async Task Get_Leasing_FlagEnabled_CipherInMixedCollections_ReturnsFullData( + User user, SutProvider sutProvider) + { + var orgId = Guid.NewGuid(); + var leasingCollectionId = Guid.NewGuid(); + var normalCollectionId = Guid.NewGuid(); + var cipherId = Guid.NewGuid(); + + var cipher = new CipherDetails + { + Id = cipherId, + Type = CipherType.Login, + OrganizationId = orgId, + Data = JsonSerializer.Serialize(new CipherLoginData + { + Name = "2.name|encrypted", + Username = "2.SENTINEL-username|encrypted", + }), + }; + + SetupLeasingSync(sutProvider, user, orgId, + ciphers: new List { cipher }, + collections: new List + { + new() { Id = leasingCollectionId, OrganizationId = orgId, AccessRuleId = Guid.NewGuid() }, + new() { Id = normalCollectionId, OrganizationId = orgId, AccessRuleId = null }, + }, + collectionCiphers: new List + { + new() { CipherId = cipherId, CollectionId = leasingCollectionId }, + new() { CipherId = cipherId, CollectionId = normalCollectionId }, + }, + pamFlagEnabled: true); + + var result = await sutProvider.Sut.Get(); + + var synced = Assert.Single(result.Ciphers, c => c.Id == cipherId); + Assert.Contains("SENTINEL-username|encrypted", synced.Data); + Assert.Null(synced.PartialData); + } + + [Theory] + [BitAutoData] + public async Task Get_Leasing_FlagDisabled_CipherOnlyInLeasingCollection_ReturnsFullData( + User user, SutProvider sutProvider) + { + var orgId = Guid.NewGuid(); + var leasingCollectionId = Guid.NewGuid(); + var cipherId = Guid.NewGuid(); + + var cipher = new CipherDetails + { + Id = cipherId, + Type = CipherType.Login, + OrganizationId = orgId, + Data = JsonSerializer.Serialize(new CipherLoginData + { + Name = "2.name|encrypted", + Username = "2.SENTINEL-username|encrypted", + }), + }; + + SetupLeasingSync(sutProvider, user, orgId, + ciphers: new List { cipher }, + collections: new List { new() { Id = leasingCollectionId, OrganizationId = orgId, AccessRuleId = Guid.NewGuid() } }, + collectionCiphers: new List { new() { CipherId = cipherId, CollectionId = leasingCollectionId } }, + pamFlagEnabled: false); + + var result = await sutProvider.Sut.Get(); + + var synced = Assert.Single(result.Ciphers, c => c.Id == cipherId); + Assert.Contains("SENTINEL-username|encrypted", synced.Data); + Assert.Null(synced.PartialData); + } + + private static void SetupLeasingSync( + SutProvider sutProvider, + User user, + Guid orgId, + List ciphers, + List collections, + List collectionCiphers, + bool pamFlagEnabled) + { + user.EquivalentDomains = null; + user.ExcludedGlobalEquivalentDomains = null; + + var userService = sutProvider.GetDependency(); + userService.GetUserByPrincipalAsync(Arg.Any()).ReturnsForAnyArgs(user); + userService.HasPremiumFromOrganization(user).Returns(false); + + sutProvider.GetDependency().Run(user).Returns(new UserAccountKeysData + { + PublicKeyEncryptionKeyPairData = user.GetPublicKeyEncryptionKeyPair(), + SignatureKeyPairData = null, + }); + + var enabledOrg = new Fixture().Create(); + enabledOrg.Enabled = true; + enabledOrg.OrganizationId = orgId; + sutProvider.GetDependency() + .GetManyDetailsByUserAsync(user.Id, OrganizationUserStatusType.Confirmed) + .Returns(new List { enabledOrg }); + + sutProvider.GetDependency() + .GetManyByUserIdAsync(user.Id, Arg.Any()).Returns(ciphers); + sutProvider.GetDependency() + .GetManyByUserIdAsync(user.Id).Returns(collections); + sutProvider.GetDependency() + .GetManyByUserIdAsync(user.Id).Returns(collectionCiphers); + + sutProvider.GetDependency() + .GetOrganizationAbilitiesAsync(Arg.Any>()) + .Returns(new Dictionary { { orgId, new OrganizationAbility { Id = orgId } } }); + + sutProvider.GetDependency().TwoFactorIsEnabledAsync(user).Returns(false); + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.Pam).Returns(pamFlagEnabled); + } + private async Task AssertMethodsCalledAsync(IUserService userService, ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, IOrganizationUserRepository organizationUserRepository, diff --git a/test/Api.Test/Vault/Models/Response/CipherResponseModelTests.cs b/test/Api.Test/Vault/Models/Response/CipherResponseModelTests.cs index f0f29ddc5324..43898a161880 100644 --- a/test/Api.Test/Vault/Models/Response/CipherResponseModelTests.cs +++ b/test/Api.Test/Vault/Models/Response/CipherResponseModelTests.cs @@ -270,6 +270,137 @@ public void Constructor_Passport_PreservesRawDataField() Assert.Equal(serializedData, response.Data); } + [Fact] + public void Constructor_Partial_Login_KeepsNameAndUrisAndStripsSecrets() + { + var loginData = new CipherLoginData + { + Name = "2.name|encrypted", + Notes = "2.notes|encrypted", + Username = "2.username|encrypted", + Password = "2.password|encrypted", + Totp = "2.totp|encrypted", + Uris = new[] + { + new CipherLoginData.CipherLoginUriData { Uri = "2.uri|encrypted", UriChecksum = "2.checksum|encrypted" }, + }, + }; + + var cipher = new Cipher + { + Id = Guid.NewGuid(), + Type = CipherType.Login, + Data = JsonSerializer.Serialize(loginData), + RevisionDate = DateTime.UtcNow, + CreationDate = DateTime.UtcNow, + }; + + var response = new CipherMiniResponseModel(cipher, _globalSettings, false, isPartial: true); + + // Full data is withheld; the reduced blob is returned only in the separate PartialData field. + Assert.Null(response.Data); + Assert.NotNull(response.PartialData); + Assert.Contains("2.name|encrypted", response.PartialData); + Assert.Contains("2.uri|encrypted", response.PartialData); + + // The obsolete typed fields are withheld entirely — partial content lives only in PartialData. + Assert.Null(response.Name); + Assert.Null(response.Login); + Assert.Null(response.Notes); + + // The reduced blob and the serialized model may not carry the secrets anywhere. + Assert.DoesNotContain("username|encrypted", response.PartialData); + Assert.DoesNotContain("password|encrypted", response.PartialData); + Assert.DoesNotContain("totp|encrypted", response.PartialData); + var serialized = JsonSerializer.Serialize(response); + Assert.DoesNotContain("username|encrypted", serialized); + Assert.DoesNotContain("password|encrypted", serialized); + Assert.DoesNotContain("totp|encrypted", serialized); + } + + [Fact] + public void Constructor_Partial_NonLogin_KeepsOnlyName() + { + var cardData = new CipherCardData + { + Name = "2.name|encrypted", + Notes = "2.notes|encrypted", + Number = "2.number|encrypted", + Code = "2.code|encrypted", + }; + + var cipher = new Cipher + { + Id = Guid.NewGuid(), + Type = CipherType.Card, + Data = JsonSerializer.Serialize(cardData), + RevisionDate = DateTime.UtcNow, + CreationDate = DateTime.UtcNow, + }; + + var response = new CipherMiniResponseModel(cipher, _globalSettings, false, isPartial: true); + + Assert.Null(response.Data); + Assert.NotNull(response.PartialData); + Assert.Contains("2.name|encrypted", response.PartialData); + // Obsolete typed fields withheld; partial content lives only in PartialData. + Assert.Null(response.Name); + Assert.Null(response.Card); + Assert.Null(response.Notes); + Assert.DoesNotContain("number|encrypted", response.PartialData); + Assert.DoesNotContain("code|encrypted", response.PartialData); + } + + [Fact] + public void Constructor_Partial_BlobEncryptedData_WithholdsData() + { + const string opaque = "2.iv|ct|mac"; + var cipher = new Cipher + { + Id = Guid.NewGuid(), + Type = CipherType.Login, + Data = opaque, + RevisionDate = DateTime.UtcNow, + CreationDate = DateTime.UtcNow, + }; + + var response = new CipherMiniResponseModel(cipher, _globalSettings, false, isPartial: true); + + // An opaque blob can't be reshaped, so neither full nor partial data is returned. + Assert.Null(response.Data); + Assert.Null(response.PartialData); + Assert.Null(response.Login); + Assert.Null(response.Name); + } + + [Fact] + public void Constructor_NotPartial_PreservesFullData() + { + var loginData = new CipherLoginData + { + Name = "2.name|encrypted", + Username = "2.username|encrypted", + Password = "2.password|encrypted", + }; + + var serializedData = JsonSerializer.Serialize(loginData); + var cipher = new Cipher + { + Id = Guid.NewGuid(), + Type = CipherType.Login, + Data = serializedData, + RevisionDate = DateTime.UtcNow, + CreationDate = DateTime.UtcNow, + }; + + var response = new CipherMiniResponseModel(cipher, _globalSettings, false, isPartial: false); + + Assert.Equal(serializedData, response.Data); + Assert.Null(response.PartialData); + Assert.Equal("2.username|encrypted", response.Login.Username); + Assert.Equal("2.password|encrypted", response.Login.Password); + } + [Theory] [InlineData(CipherType.Login)] [InlineData(CipherType.SecureNote)] diff --git a/test/Core.Test/Vault/Models/Data/PartialCipherDataTests.cs b/test/Core.Test/Vault/Models/Data/PartialCipherDataTests.cs new file mode 100644 index 000000000000..da0e9073e9c4 --- /dev/null +++ b/test/Core.Test/Vault/Models/Data/PartialCipherDataTests.cs @@ -0,0 +1,131 @@ +using System.Text.Json; +using Bit.Core.Enums; +using Bit.Core.Vault.Enums; +using Bit.Core.Vault.Models.Data; +using Xunit; + +namespace Bit.Core.Test.Vault.Models.Data; + +public class PartialCipherDataTests +{ + private const string EncryptedName = "2.name|encrypted"; + private const string SecretUsername = "2.SENTINEL-username|encrypted"; + private const string SecretPassword = "2.SENTINEL-password|encrypted"; + private const string SecretTotp = "2.SENTINEL-totp|encrypted"; + + [Fact] + public void Strip_Login_KeepsNameAndUrisAndDropsSecrets() + { + var loginData = new CipherLoginData + { + Name = EncryptedName, + Notes = "2.SENTINEL-notes|encrypted", + Username = SecretUsername, + Password = SecretPassword, + PasswordRevisionDate = DateTime.UtcNow, + Totp = SecretTotp, + AutofillOnPageLoad = true, + Uris = new[] + { + new CipherLoginData.CipherLoginUriData { Uri = "2.uri1|encrypted", UriChecksum = "2.checksum1|encrypted", Match = UriMatchType.Domain }, + new CipherLoginData.CipherLoginUriData { Uri = "2.uri2|encrypted" }, + }, + Fido2Credentials = new[] { new CipherLoginFido2CredentialData { CredentialId = "2.SENTINEL-fido2|encrypted" } }, + Fields = new[] { new CipherFieldData { Name = "2.SENTINEL-field|encrypted", Value = "2.SENTINEL-fieldvalue|encrypted", Type = FieldType.Text } }, + PasswordHistory = new[] { new CipherPasswordHistoryData { Password = "2.SENTINEL-history|encrypted", LastUsedDate = DateTime.UtcNow } }, + }; + + var stripped = PartialCipherData.Strip(CipherType.Login, JsonSerializer.Serialize(loginData)); + + var result = JsonSerializer.Deserialize(stripped); + Assert.Equal(EncryptedName, result.Name); + Assert.Equal(2, result.Uris.Count()); + Assert.Equal("2.uri1|encrypted", result.Uris.First().Uri); + Assert.Equal("2.checksum1|encrypted", result.Uris.First().UriChecksum); + Assert.Equal(UriMatchType.Domain, result.Uris.First().Match); + Assert.Null(result.Username); + Assert.Null(result.Password); + Assert.Null(result.PasswordRevisionDate); + Assert.Null(result.Totp); + Assert.Null(result.AutofillOnPageLoad); + Assert.Null(result.Fido2Credentials); + Assert.Null(result.Notes); + Assert.Null(result.Fields); + Assert.Null(result.PasswordHistory); + + // No secret value may appear anywhere in the serialized envelope. + Assert.DoesNotContain("SENTINEL", stripped); + } + + [Theory] + [InlineData(CipherType.SecureNote)] + [InlineData(CipherType.Card)] + [InlineData(CipherType.Identity)] + [InlineData(CipherType.SSHKey)] + [InlineData(CipherType.BankAccount)] + [InlineData(CipherType.DriversLicense)] + [InlineData(CipherType.Passport)] + public void Strip_NonLogin_KeepsOnlyName(CipherType type) + { + // A superset blob containing fields specific to several types plus base/secret fields. + var data = JsonSerializer.Serialize(new + { + Name = EncryptedName, + Notes = "2.SENTINEL-notes|encrypted", + Number = "2.SENTINEL-cardnumber|encrypted", + Code = "2.SENTINEL-cvv|encrypted", + LicenseNumber = "2.SENTINEL-license|encrypted", + PassportNumber = "2.SENTINEL-passport|encrypted", + PrivateKey = "2.SENTINEL-sshkey|encrypted", + Fields = new[] { new { Name = "2.SENTINEL-field|encrypted", Value = "2.SENTINEL-fieldvalue|encrypted" } }, + }); + + var stripped = PartialCipherData.Strip(type, data); + + var result = JsonSerializer.Deserialize(stripped); + Assert.Equal(EncryptedName, result.Name); + Assert.Null(result.Notes); + Assert.Null(result.Number); + Assert.Null(result.Code); + Assert.Null(result.Fields); + Assert.DoesNotContain("SENTINEL", stripped); + } + + [Theory] + [InlineData(CipherType.Login)] + [InlineData(CipherType.Card)] + public void Strip_NullOrEmptyData_ReturnedUnchanged(CipherType type) + { + Assert.Null(PartialCipherData.Strip(type, null)); + Assert.Equal("", PartialCipherData.Strip(type, "")); + Assert.Equal(" ", PartialCipherData.Strip(type, " ")); + } + + [Fact] + public void Strip_NullName_ProducesValidJsonWithoutThrowing() + { + var loginData = new CipherLoginData { Username = SecretUsername }; + + var stripped = PartialCipherData.Strip(CipherType.Login, JsonSerializer.Serialize(loginData)); + + var result = JsonSerializer.Deserialize(stripped); + Assert.Null(result.Name); + Assert.DoesNotContain("SENTINEL", stripped); + } + + [Fact] + public void Strip_IsIdempotent() + { + var loginData = new CipherLoginData + { + Name = EncryptedName, + Username = SecretUsername, + Uris = new[] { new CipherLoginData.CipherLoginUriData { Uri = "2.uri|encrypted" } }, + }; + + var once = PartialCipherData.Strip(CipherType.Login, JsonSerializer.Serialize(loginData)); + var twice = PartialCipherData.Strip(CipherType.Login, once); + + Assert.Equal(once, twice); + } +} From 994b83664bd6bfd9104c18455f1d124cadc9efe9 Mon Sep 17 00:00:00 2001 From: Patrik Svensson Date: Fri, 29 May 2026 11:11:41 +0200 Subject: [PATCH 08/54] Initial naive implementation of rule engine --- .../Engine/AccessDecision.cs | 50 ++ .../Engine/AccessRule.cs | 34 ++ .../Engine/AccessRuleEngine.cs | 126 +++++ .../Engine/AccessRuleEngineContext.cs | 7 + .../Engine/AccessRuleEngineResult.cs | 14 + .../Engine/AccessRuleLease.cs | 17 + .../Engine/AccessRuleRequest.cs | 23 + .../Engine/AccessRuleSignals.cs | 13 + .../Conditions/HumanApprovalCondition.cs | 11 + .../Engine/Conditions/IpRangeCondition.cs | 25 + .../Engine/Conditions/TimeOfDayCondition.cs | 34 ++ .../Engine/ExchangeResult.cs | 29 ++ .../Engine/IAccessCondition.cs | 6 + .../Engine/RequestAccessResult.cs | 32 ++ .../Engine/AccessRuleEngineTests.cs | 434 ++++++++++++++++++ .../Conditions/HumanApprovalConditionTests.cs | 45 ++ .../Conditions/IpRangeConditionTests.cs | 58 +++ .../Conditions/TimeOfDayConditionTests.cs | 112 +++++ .../Engine/Fixture/AccessRuleEngineFixture.cs | 171 +++++++ .../Fixture/FakeAccessRuleLeaseRepository.cs | 53 +++ .../FakeAccessRuleRequestRepository.cs | 52 +++ .../Engine/Fixture/FakeAccessRuleResolver.cs | 14 + 22 files changed, 1360 insertions(+) create mode 100644 src/Core/PrivilegedAccessManagement/Engine/AccessDecision.cs create mode 100644 src/Core/PrivilegedAccessManagement/Engine/AccessRule.cs create mode 100644 src/Core/PrivilegedAccessManagement/Engine/AccessRuleEngine.cs create mode 100644 src/Core/PrivilegedAccessManagement/Engine/AccessRuleEngineContext.cs create mode 100644 src/Core/PrivilegedAccessManagement/Engine/AccessRuleEngineResult.cs create mode 100644 src/Core/PrivilegedAccessManagement/Engine/AccessRuleLease.cs create mode 100644 src/Core/PrivilegedAccessManagement/Engine/AccessRuleRequest.cs create mode 100644 src/Core/PrivilegedAccessManagement/Engine/AccessRuleSignals.cs create mode 100644 src/Core/PrivilegedAccessManagement/Engine/Conditions/HumanApprovalCondition.cs create mode 100644 src/Core/PrivilegedAccessManagement/Engine/Conditions/IpRangeCondition.cs create mode 100644 src/Core/PrivilegedAccessManagement/Engine/Conditions/TimeOfDayCondition.cs create mode 100644 src/Core/PrivilegedAccessManagement/Engine/ExchangeResult.cs create mode 100644 src/Core/PrivilegedAccessManagement/Engine/IAccessCondition.cs create mode 100644 src/Core/PrivilegedAccessManagement/Engine/RequestAccessResult.cs create mode 100644 test/Core.Test/PrivilegedAccessManagement/Engine/AccessRuleEngineTests.cs create mode 100644 test/Core.Test/PrivilegedAccessManagement/Engine/Conditions/HumanApprovalConditionTests.cs create mode 100644 test/Core.Test/PrivilegedAccessManagement/Engine/Conditions/IpRangeConditionTests.cs create mode 100644 test/Core.Test/PrivilegedAccessManagement/Engine/Conditions/TimeOfDayConditionTests.cs create mode 100644 test/Core.Test/PrivilegedAccessManagement/Engine/Fixture/AccessRuleEngineFixture.cs create mode 100644 test/Core.Test/PrivilegedAccessManagement/Engine/Fixture/FakeAccessRuleLeaseRepository.cs create mode 100644 test/Core.Test/PrivilegedAccessManagement/Engine/Fixture/FakeAccessRuleRequestRepository.cs create mode 100644 test/Core.Test/PrivilegedAccessManagement/Engine/Fixture/FakeAccessRuleResolver.cs diff --git a/src/Core/PrivilegedAccessManagement/Engine/AccessDecision.cs b/src/Core/PrivilegedAccessManagement/Engine/AccessDecision.cs new file mode 100644 index 000000000000..12a78cfd8937 --- /dev/null +++ b/src/Core/PrivilegedAccessManagement/Engine/AccessDecision.cs @@ -0,0 +1,50 @@ +namespace Bit.Core.PrivilegedAccessManagement.Engine; + +public enum DecisionKind +{ + Allow, + RequiresApproval, + Deny, +} + +public enum DenyReason +{ + None = 0, + NoLease, + InvalidLease, + NotWithinIpRange, + NotWithinTimeWindow, +} + +public sealed record AccessDecision +{ + public required DecisionKind Kind { get; init; } + public DenyReason Reason { get; init; } = DenyReason.None; + + public static AccessDecision Allow { get; } = new() { Kind = DecisionKind.Allow }; + public static AccessDecision RequiresApproval { get; } = new() { Kind = DecisionKind.RequiresApproval }; + + public static AccessDecision Deny(DenyReason reason) => new() + { + Kind = DecisionKind.Deny, + Reason = reason + }; + + public static AccessDecision Combine(IEnumerable decisions) + { + var requiresApproval = false; + foreach (var decision in decisions) + { + switch (decision.Kind) + { + case DecisionKind.Deny: + return decision; + case DecisionKind.RequiresApproval: + requiresApproval = true; + break; + } + } + + return requiresApproval ? RequiresApproval : Allow; + } +} diff --git a/src/Core/PrivilegedAccessManagement/Engine/AccessRule.cs b/src/Core/PrivilegedAccessManagement/Engine/AccessRule.cs new file mode 100644 index 000000000000..cf29f275a1e3 --- /dev/null +++ b/src/Core/PrivilegedAccessManagement/Engine/AccessRule.cs @@ -0,0 +1,34 @@ +using Bit.Core.Vault.Models.Data; + +namespace Bit.Core.PrivilegedAccessManagement.Engine; + +public sealed record AccessRule +{ + public required string Name { get; init; } + public required TimeSpan Duration { get; init; } + + public bool RequireSingleton { get; init; } + public bool RequireApproval { get; init; } + public List RequiredCidr { get; init; } = []; + + public TimeOfDayConfig? TimeOfDay { get; init; } +} + +public sealed record TimeOfDayConfig +{ + public required string TimeZone { get; init; } // IANA timezone id (e.g. "America/New_York") + + public required IReadOnlyList Windows { get; init; } +} + +public sealed record AccessTimeWindow +{ + public IReadOnlyList Days { get; init; } = []; + public required TimeOnly From { get; init; } + public required TimeOnly To { get; init; } +} + +public interface IAccessRuleResolver +{ + AccessRule? Resolve(CipherDetails cipher); +} diff --git a/src/Core/PrivilegedAccessManagement/Engine/AccessRuleEngine.cs b/src/Core/PrivilegedAccessManagement/Engine/AccessRuleEngine.cs new file mode 100644 index 000000000000..0e3582d1a684 --- /dev/null +++ b/src/Core/PrivilegedAccessManagement/Engine/AccessRuleEngine.cs @@ -0,0 +1,126 @@ +using Bit.Core.PrivilegedAccessManagement.Engine.Conditions; +using Bit.Core.Vault.Models.Data; + +namespace Bit.Core.PrivilegedAccessManagement.Engine; + +public sealed class AccessRuleEngine( + TimeProvider time, + IAccessRuleResolver ruleResolver, + IAccessRuleRequestRepository requests, + IAccessRuleLeaseRepository leases) +{ + private static readonly IReadOnlyList _conditions = + [ + new HumanApprovalCondition(), + new IpRangeCondition(), + new TimeOfDayCondition(), + ]; + + private readonly TimeProvider _time = time ?? throw new ArgumentNullException(nameof(time)); + private readonly IAccessRuleResolver _ruleResolver = ruleResolver ?? throw new ArgumentNullException(nameof(ruleResolver)); + private readonly IAccessRuleRequestRepository _requests = requests ?? throw new ArgumentNullException(nameof(requests)); + private readonly IAccessRuleLeaseRepository _leases = leases ?? throw new ArgumentNullException(nameof(leases)); + + /// + /// Checks whether the user may access the cipher right now. Access is granted only when the user + /// already holds a valid, unexpired lease; this method never evaluates rules or issues leases. + /// + public AccessRuleEngineResult Check(CipherDetails cipher, AccessRuleSignals signals) + { + if (!_leases.TryGet(cipher.Id, signals.Username, out var lease)) + { + return AccessRuleEngineResult.Denied(DenyReason.NoLease); + } + + if (lease.Expires <= _time.GetUtcNow()) + { + return AccessRuleEngineResult.Denied(DenyReason.InvalidLease); + } + + return AccessOutcome.Granted; + } + + /// + /// Requests access to a cipher by creating a pending request that can later be exchanged for a + /// lease. The rule is evaluated here against the requesting user's signals so a request that the + /// rule denies (for example, from a disallowed IP or outside an allowed time window) is rejected + /// up front rather than at exchange time. Human approval is the one gate deferred to exchange: it + /// yields rather than a denial, so it does not block + /// the request from being created. Also fails when the user already holds an active lease or + /// already has a pending request. + /// + public RequestAccessResult RequestAccess(CipherDetails cipher, AccessRuleSignals signals) + { + // An active lease already grants access, so there is nothing to request. + if (_leases.TryGet(cipher.Id, signals.Username, out var lease) && lease.Expires > _time.GetUtcNow()) + { + return RequestAccessResult.Failed(RequestAccessFailReason.ExistingLease); + } + + // A request is already pending for this user and cipher. + if (_requests.TryGet(cipher.Id, signals.Username, out _)) + { + return RequestAccessResult.Failed(RequestAccessFailReason.ExistingRequest); + } + + var rule = _ruleResolver.Resolve(cipher); + if (rule is null) + { + // No rule governs this cipher, so there is no policy under which access could ever be + // granted; reject the request rather than create one that can never be exchanged. + return RequestAccessResult.Failed(RequestAccessFailReason.NoRule); + } + + // Evaluate every condition except approval (which yields RequiresApproval, not a denial) and + // reject the request if the rule denies access for the requesting user's signals. + var context = new AccessRuleEngineContext { Rule = rule, Signals = signals }; + var decision = AccessDecision.Combine(_conditions.Select(condition => condition.Evaluate(context))); + if (decision.Kind == DecisionKind.Deny) + { + return RequestAccessResult.AccessDenied(decision.Reason); + } + + var request = _requests.Create(cipher.Id, signals); + return RequestAccessResult.Created(request); + } + + /// + /// Exchanges a pending request for a lease. The rule's non-approval conditions were already + /// evaluated against the captured signals when the request was created, so the only rule gate + /// applied here is approval; a lease is then issued when the lease-issuance constraints + /// (singleton) are satisfied. + /// + public ExchangeResult ExchangeRequestForLease(CipherDetails cipher, string username) + { + if (!_requests.TryGet(cipher.Id, username, out var request)) + { + return ExchangeResult.Failed(ExchangeFailReason.RequestNotFound); + } + + var rule = _ruleResolver.Resolve(cipher); + if (rule is null) + { + // No rule governs this cipher, so there is no policy under which to issue a lease. + return ExchangeResult.Failed(ExchangeFailReason.NoRule); + } + + // A rule that requires approval cannot be exchanged until the request has been approved. + if (rule.RequireApproval && !request.Approved) + { + return ExchangeResult.Failed(ExchangeFailReason.NotApproved); + } + + // A singleton rule allows only one active lease per cipher at a time. + if (rule.RequireSingleton && _leases.TryGet(cipher.Id, out var held) && held.Expires > _time.GetUtcNow()) + { + return ExchangeResult.Failed(ExchangeFailReason.SingletonHeld); + } + + if (!_leases.TryCreate(request, rule.Duration, out var lease)) + { + return ExchangeResult.Failed(ExchangeFailReason.LeaseCreationFailed); + } + + return ExchangeResult.Created(lease); + } +} diff --git a/src/Core/PrivilegedAccessManagement/Engine/AccessRuleEngineContext.cs b/src/Core/PrivilegedAccessManagement/Engine/AccessRuleEngineContext.cs new file mode 100644 index 000000000000..e2610c23f420 --- /dev/null +++ b/src/Core/PrivilegedAccessManagement/Engine/AccessRuleEngineContext.cs @@ -0,0 +1,7 @@ +namespace Bit.Core.PrivilegedAccessManagement.Engine; + +public sealed class AccessRuleEngineContext +{ + public required AccessRule Rule { get; init; } + public required AccessRuleSignals Signals { get; init; } +} diff --git a/src/Core/PrivilegedAccessManagement/Engine/AccessRuleEngineResult.cs b/src/Core/PrivilegedAccessManagement/Engine/AccessRuleEngineResult.cs new file mode 100644 index 000000000000..29fdf874c801 --- /dev/null +++ b/src/Core/PrivilegedAccessManagement/Engine/AccessRuleEngineResult.cs @@ -0,0 +1,14 @@ +namespace Bit.Core.PrivilegedAccessManagement.Engine; + +public sealed record AccessRuleEngineResult(AccessOutcome Outcome, DenyReason Reason = DenyReason.None) +{ + public static implicit operator AccessRuleEngineResult(AccessOutcome outcome) => new(outcome); + + public static AccessRuleEngineResult Denied(DenyReason reason) => new(AccessOutcome.Denied, reason); +} + +public enum AccessOutcome +{ + Granted, + Denied, +} diff --git a/src/Core/PrivilegedAccessManagement/Engine/AccessRuleLease.cs b/src/Core/PrivilegedAccessManagement/Engine/AccessRuleLease.cs new file mode 100644 index 000000000000..7a16c0be2124 --- /dev/null +++ b/src/Core/PrivilegedAccessManagement/Engine/AccessRuleLease.cs @@ -0,0 +1,17 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Bit.Core.PrivilegedAccessManagement.Engine; + +public sealed class AccessRuleLease +{ + public required Guid CipherId { get; init; } + public required string Username { get; init; } + public required DateTime Expires { get; init; } +} + +public interface IAccessRuleLeaseRepository +{ + bool TryCreate(AccessRuleRequest request, TimeSpan duration, [NotNullWhen(true)] out AccessRuleLease? lease); + bool TryGet(Guid cipherId, string username, [NotNullWhen(true)] out AccessRuleLease? lease); + bool TryGet(Guid cipherId, [NotNullWhen(true)] out AccessRuleLease? lease); +} diff --git a/src/Core/PrivilegedAccessManagement/Engine/AccessRuleRequest.cs b/src/Core/PrivilegedAccessManagement/Engine/AccessRuleRequest.cs new file mode 100644 index 000000000000..84010a09d407 --- /dev/null +++ b/src/Core/PrivilegedAccessManagement/Engine/AccessRuleRequest.cs @@ -0,0 +1,23 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Bit.Core.PrivilegedAccessManagement.Engine; + +public sealed class AccessRuleRequest +{ + public required Guid CipherId { get; init; } + public required string Username { get; init; } + public required bool Approved { get; init; } + + /// + /// The signals captured when access was requested. The lease exchange re-evaluates the rule + /// against these, so the requester cannot alter their context between requesting and exchanging. + /// + public required AccessRuleSignals Signals { get; init; } +} + +public interface IAccessRuleRequestRepository +{ + AccessRuleRequest Create(Guid cipherId, AccessRuleSignals signals); + bool Delete(AccessRuleRequest request); + bool TryGet(Guid cipherId, string username, [NotNullWhen(true)] out AccessRuleRequest? request); +} diff --git a/src/Core/PrivilegedAccessManagement/Engine/AccessRuleSignals.cs b/src/Core/PrivilegedAccessManagement/Engine/AccessRuleSignals.cs new file mode 100644 index 000000000000..7dd2ee21c522 --- /dev/null +++ b/src/Core/PrivilegedAccessManagement/Engine/AccessRuleSignals.cs @@ -0,0 +1,13 @@ +using System.Net; +using Bit.Core.Enums; + +namespace Bit.Core.PrivilegedAccessManagement.Engine; + +public sealed class AccessRuleSignals +{ + public required string Username { get; init; } + public required IPAddress IpAddress { get; init; } + public required bool MultifactorEnabled { get; init; } + public required DateTimeOffset UserTime { get; init; } + public required DeviceType Device { get; init; } +} diff --git a/src/Core/PrivilegedAccessManagement/Engine/Conditions/HumanApprovalCondition.cs b/src/Core/PrivilegedAccessManagement/Engine/Conditions/HumanApprovalCondition.cs new file mode 100644 index 000000000000..051da16bf306 --- /dev/null +++ b/src/Core/PrivilegedAccessManagement/Engine/Conditions/HumanApprovalCondition.cs @@ -0,0 +1,11 @@ +namespace Bit.Core.PrivilegedAccessManagement.Engine.Conditions; + +public sealed class HumanApprovalCondition : IAccessCondition +{ + public AccessDecision Evaluate(AccessRuleEngineContext context) + { + return context.Rule.RequireApproval + ? AccessDecision.RequiresApproval + : AccessDecision.Allow; + } +} diff --git a/src/Core/PrivilegedAccessManagement/Engine/Conditions/IpRangeCondition.cs b/src/Core/PrivilegedAccessManagement/Engine/Conditions/IpRangeCondition.cs new file mode 100644 index 000000000000..9b0975a62bb5 --- /dev/null +++ b/src/Core/PrivilegedAccessManagement/Engine/Conditions/IpRangeCondition.cs @@ -0,0 +1,25 @@ +using System.Net; + +namespace Bit.Core.PrivilegedAccessManagement.Engine.Conditions; + +public sealed class IpRangeCondition : IAccessCondition +{ + public AccessDecision Evaluate(AccessRuleEngineContext context) + { + var requiredCidr = context.Rule.RequiredCidr; + if (requiredCidr.Count == 0) + { + return AccessDecision.Allow; + } + + foreach (var cidr in requiredCidr) + { + if (IPNetwork.TryParse(cidr, out var network) && network.Contains(context.Signals.IpAddress)) + { + return AccessDecision.Allow; + } + } + + return AccessDecision.Deny(DenyReason.NotWithinIpRange); + } +} diff --git a/src/Core/PrivilegedAccessManagement/Engine/Conditions/TimeOfDayCondition.cs b/src/Core/PrivilegedAccessManagement/Engine/Conditions/TimeOfDayCondition.cs new file mode 100644 index 000000000000..83191a078e4d --- /dev/null +++ b/src/Core/PrivilegedAccessManagement/Engine/Conditions/TimeOfDayCondition.cs @@ -0,0 +1,34 @@ +namespace Bit.Core.PrivilegedAccessManagement.Engine.Conditions; + +public sealed class TimeOfDayCondition : IAccessCondition +{ + public AccessDecision Evaluate(AccessRuleEngineContext context) + { + var config = context.Rule.TimeOfDay; + if (config is null) + { + return AccessDecision.Allow; + } + + if (!TimeZoneInfo.TryFindSystemTimeZoneById(config.TimeZone, out var timeZone)) + { + // The window cannot be evaluated without a valid timezone, so fail closed + return AccessDecision.Deny(DenyReason.NotWithinTimeWindow); + } + + var local = TimeZoneInfo.ConvertTime(context.Signals.UserTime, timeZone); + var day = local.DayOfWeek; + var time = TimeOnly.FromTimeSpan(local.TimeOfDay); + + foreach (var window in config.Windows) + { + var dayMatches = window.Days.Count == 0 || window.Days.Contains(day); + if (dayMatches && time >= window.From && time <= window.To) + { + return AccessDecision.Allow; + } + } + + return AccessDecision.Deny(DenyReason.NotWithinTimeWindow); + } +} diff --git a/src/Core/PrivilegedAccessManagement/Engine/ExchangeResult.cs b/src/Core/PrivilegedAccessManagement/Engine/ExchangeResult.cs new file mode 100644 index 000000000000..7b4cc68bcb91 --- /dev/null +++ b/src/Core/PrivilegedAccessManagement/Engine/ExchangeResult.cs @@ -0,0 +1,29 @@ +namespace Bit.Core.PrivilegedAccessManagement.Engine; + +public sealed record ExchangeResult( + ExchangeOutcome Outcome, + AccessRuleLease? Lease = null, + ExchangeFailReason FailReason = ExchangeFailReason.None) +{ + public static ExchangeResult Created(AccessRuleLease lease) => + new(ExchangeOutcome.Created, Lease: lease); + + public static ExchangeResult Failed(ExchangeFailReason reason) => + new(ExchangeOutcome.Failed, FailReason: reason); +} + +public enum ExchangeOutcome +{ + Created, + Failed, +} + +public enum ExchangeFailReason +{ + None = 0, + RequestNotFound, + NoRule, + NotApproved, + SingletonHeld, + LeaseCreationFailed, +} diff --git a/src/Core/PrivilegedAccessManagement/Engine/IAccessCondition.cs b/src/Core/PrivilegedAccessManagement/Engine/IAccessCondition.cs new file mode 100644 index 000000000000..df321528e8ad --- /dev/null +++ b/src/Core/PrivilegedAccessManagement/Engine/IAccessCondition.cs @@ -0,0 +1,6 @@ +namespace Bit.Core.PrivilegedAccessManagement.Engine; + +public interface IAccessCondition +{ + AccessDecision Evaluate(AccessRuleEngineContext context); +} diff --git a/src/Core/PrivilegedAccessManagement/Engine/RequestAccessResult.cs b/src/Core/PrivilegedAccessManagement/Engine/RequestAccessResult.cs new file mode 100644 index 000000000000..b0b35699852d --- /dev/null +++ b/src/Core/PrivilegedAccessManagement/Engine/RequestAccessResult.cs @@ -0,0 +1,32 @@ +namespace Bit.Core.PrivilegedAccessManagement.Engine; + +public sealed record RequestAccessResult( + RequestAccessOutcome Outcome, + AccessRuleRequest? Request = null, + RequestAccessFailReason FailReason = RequestAccessFailReason.None, + DenyReason DenyReason = DenyReason.None) +{ + public static RequestAccessResult Created(AccessRuleRequest request) => + new(RequestAccessOutcome.Created, Request: request); + + public static RequestAccessResult Failed(RequestAccessFailReason reason) => + new(RequestAccessOutcome.Failed, FailReason: reason); + + public static RequestAccessResult AccessDenied(DenyReason denyReason) => + new(RequestAccessOutcome.Failed, FailReason: RequestAccessFailReason.AccessDenied, DenyReason: denyReason); +} + +public enum RequestAccessOutcome +{ + Created, + Failed, +} + +public enum RequestAccessFailReason +{ + None = 0, + ExistingLease, + ExistingRequest, + NoRule, + AccessDenied, +} diff --git a/test/Core.Test/PrivilegedAccessManagement/Engine/AccessRuleEngineTests.cs b/test/Core.Test/PrivilegedAccessManagement/Engine/AccessRuleEngineTests.cs new file mode 100644 index 000000000000..d165d250c6d6 --- /dev/null +++ b/test/Core.Test/PrivilegedAccessManagement/Engine/AccessRuleEngineTests.cs @@ -0,0 +1,434 @@ +using System.Net; +using Bit.Core.PrivilegedAccessManagement.Engine; +using Xunit; + +namespace Bit.Core.Test.PrivilegedAccessManagement.Engine; + +public sealed class AccessRuleEngineTests +{ + // Check: access is granted only when the user already holds a valid, unexpired lease. + + [Fact] + public void Check_NoLease_DeniesWithNoLease() + { + var fixture = new AccessRuleEngineFixture(); + + var result = fixture.Check(fixture.Cipher); + + Assert.Equal(AccessOutcome.Denied, result.Outcome); + Assert.Equal(DenyReason.NoLease, result.Reason); + } + + [Fact] + public void Check_ExpiredLease_DeniesWithInvalidLease() + { + var fixture = new AccessRuleEngineFixture() + .WithExpiredLease(); + + var result = fixture.Check(fixture.Cipher); + + Assert.Equal(AccessOutcome.Denied, result.Outcome); + Assert.Equal(DenyReason.InvalidLease, result.Reason); + } + + [Fact] + public void Check_ValidLease_Grants() + { + var fixture = new AccessRuleEngineFixture() + .WithActiveLease(); + + var result = fixture.Check(fixture.Cipher); + + Assert.Equal(AccessOutcome.Granted, result.Outcome); + } + + [Fact] + public void Check_ValidLeaseHeldByAnotherUser_DeniesWithNoLease() + { + var fixture = new AccessRuleEngineFixture() + .WithActiveLeaseHeldBy(AccessRuleEngineFixture.AnotherUser); + + var result = fixture.Check(fixture.Cipher); + + Assert.Equal(AccessOutcome.Denied, result.Outcome); + Assert.Equal(DenyReason.NoLease, result.Reason); + } + + // RequestAccess: create a pending request unless an active lease or pending request already exists. + + [Fact] + public void RequestAccess_NoLeaseNoRequest_CreatesRequest() + { + var fixture = new AccessRuleEngineFixture(); + + var result = fixture.RequestAccess(fixture.Cipher); + + Assert.Equal(RequestAccessOutcome.Created, result.Outcome); + Assert.NotNull(result.Request); + Assert.Equal(1, fixture.RequestsCreated); + } + + [Fact] + public void RequestAccess_CapturesRequestingUserSignals() + { + var fixture = new AccessRuleEngineFixture() + .FromIpAddress("10.0.0.5"); + + var result = fixture.RequestAccess(fixture.Cipher); + + Assert.NotNull(result.Request); + Assert.Equal(AccessRuleEngineFixture.RequestingUser, result.Request.Username); + Assert.Equal(IPAddress.Parse("10.0.0.5"), result.Request.Signals.IpAddress); + } + + [Fact] + public void RequestAccess_ActiveLeaseExists_FailsWithExistingLease() + { + var fixture = new AccessRuleEngineFixture() + .WithActiveLease(); + + var result = fixture.RequestAccess(fixture.Cipher); + + Assert.Equal(RequestAccessOutcome.Failed, result.Outcome); + Assert.Equal(RequestAccessFailReason.ExistingLease, result.FailReason); + Assert.Equal(0, fixture.RequestsCreated); + } + + [Fact] + public void RequestAccess_ExpiredLeaseExists_CreatesRequest() + { + // An expired lease no longer grants access, so the user may request again. + var fixture = new AccessRuleEngineFixture() + .WithExpiredLease(); + + var result = fixture.RequestAccess(fixture.Cipher); + + Assert.Equal(RequestAccessOutcome.Created, result.Outcome); + Assert.Equal(1, fixture.RequestsCreated); + } + + [Fact] + public void RequestAccess_RequestAlreadyExists_FailsWithExistingRequest() + { + var fixture = new AccessRuleEngineFixture() + .WithPendingRequest(); + + var result = fixture.RequestAccess(fixture.Cipher); + + Assert.Equal(RequestAccessOutcome.Failed, result.Outcome); + Assert.Equal(RequestAccessFailReason.ExistingRequest, result.FailReason); + Assert.Equal(0, fixture.RequestsCreated); + } + + [Fact] + public void RequestAccess_CalledTwice_SecondFailsWithExistingRequest() + { + var fixture = new AccessRuleEngineFixture(); + + var first = fixture.RequestAccess(fixture.Cipher); + var second = fixture.RequestAccess(fixture.Cipher); + + Assert.Equal(RequestAccessOutcome.Created, first.Outcome); + Assert.Equal(RequestAccessOutcome.Failed, second.Outcome); + Assert.Equal(RequestAccessFailReason.ExistingRequest, second.FailReason); + Assert.Equal(1, fixture.RequestsCreated); + } + + [Fact] + public void RequestAccess_AnotherUserHoldsActiveLease_StillCreatesRequest() + { + // The singleton constraint is enforced when exchanging, not when requesting, so another + // user's lease does not block this user from requesting access. + var fixture = new AccessRuleEngineFixture() + .RequiringSingleton() + .WithActiveLeaseHeldBy(AccessRuleEngineFixture.AnotherUser); + + var result = fixture.RequestAccess(fixture.Cipher); + + Assert.Equal(RequestAccessOutcome.Created, result.Outcome); + } + + [Fact] + public void RequestAccess_NoRuleGovernsCipher_FailsWithNoRule() + { + // Without a governing rule there is no policy under which access could ever be granted, so + // the request is rejected rather than created. + var fixture = new AccessRuleEngineFixture() + .WithNoRules(); + + var result = fixture.RequestAccess(fixture.Cipher); + + Assert.Equal(RequestAccessOutcome.Failed, result.Outcome); + Assert.Equal(RequestAccessFailReason.NoRule, result.FailReason); + Assert.Equal(0, fixture.RequestsCreated); + } + + [Fact] + public void RequestAccess_ApprovalRequired_StillCreatesRequest() + { + // Approval is not a denial, so an approval-required rule does not block the request from + // being created; the approval gate is enforced later at exchange. + var fixture = new AccessRuleEngineFixture() + .RequiringApproval(); + + var result = fixture.RequestAccess(fixture.Cipher); + + Assert.Equal(RequestAccessOutcome.Created, result.Outcome); + Assert.Equal(1, fixture.RequestsCreated); + } + + [Fact] + public void RequestAccess_IpAddressOutsideRequiredCidr_FailsWithAccessDeniedNotWithinIpRange() + { + var fixture = new AccessRuleEngineFixture() + .RestrictedToCidr("10.0.0.0/24") + .FromIpAddress("192.168.1.5"); + + var result = fixture.RequestAccess(fixture.Cipher); + + Assert.Equal(RequestAccessOutcome.Failed, result.Outcome); + Assert.Equal(RequestAccessFailReason.AccessDenied, result.FailReason); + Assert.Equal(DenyReason.NotWithinIpRange, result.DenyReason); + Assert.Equal(0, fixture.RequestsCreated); + } + + [Fact] + public void RequestAccess_IpAddressWithinRequiredCidr_CreatesRequest() + { + var fixture = new AccessRuleEngineFixture() + .RestrictedToCidr("10.0.0.0/24") + .FromIpAddress("10.0.0.5"); + + var result = fixture.RequestAccess(fixture.Cipher); + + Assert.Equal(RequestAccessOutcome.Created, result.Outcome); + } + + [Fact] + public void RequestAccess_UnparseableCidrEntryIsSkipped_AndALaterMatchCreatesRequest() + { + var fixture = new AccessRuleEngineFixture() + .RestrictedToCidr("not-a-cidr", "10.0.0.0/24") + .FromIpAddress("10.0.0.5"); + + var result = fixture.RequestAccess(fixture.Cipher); + + Assert.Equal(RequestAccessOutcome.Created, result.Outcome); + } + + [Fact] + public void RequestAccess_OutsideTimeWindow_FailsWithAccessDeniedNotWithinTimeWindow() + { + // The fixture's signal time is 12:00 UTC, outside the 13:00-17:00 window. + var fixture = new AccessRuleEngineFixture() + .RestrictedToTimeWindow("UTC", new TimeOnly(13, 0), new TimeOnly(17, 0)); + + var result = fixture.RequestAccess(fixture.Cipher); + + Assert.Equal(RequestAccessOutcome.Failed, result.Outcome); + Assert.Equal(RequestAccessFailReason.AccessDenied, result.FailReason); + Assert.Equal(DenyReason.NotWithinTimeWindow, result.DenyReason); + Assert.Equal(0, fixture.RequestsCreated); + } + + // ExchangeRequestForLease: gate on approval, enforce lease-issuance constraints, then issue the + // lease. The rule's non-approval conditions were already evaluated at request time. + + [Fact] + public void Exchange_NoRequest_FailsWithRequestNotFound() + { + var fixture = new AccessRuleEngineFixture(); + + var result = fixture.Exchange(fixture.Cipher); + + Assert.Equal(ExchangeOutcome.Failed, result.Outcome); + Assert.Equal(ExchangeFailReason.RequestNotFound, result.FailReason); + } + + [Fact] + public void Exchange_NoRuleGovernsCipher_FailsWithNoRule() + { + // A request was created while a rule governed the cipher, but the rule has since been + // removed; the exchange defensively rejects rather than issue a lease with no policy. + var fixture = new AccessRuleEngineFixture() + .WithPendingRequest() + .WithNoRules(); + + var result = fixture.Exchange(fixture.Cipher); + + Assert.Equal(ExchangeOutcome.Failed, result.Outcome); + Assert.Equal(ExchangeFailReason.NoRule, result.FailReason); + } + + [Fact] + public void Exchange_PermissiveRule_CreatesLease() + { + var fixture = new AccessRuleEngineFixture(); + fixture.RequestAccess(fixture.Cipher); + + var result = fixture.Exchange(fixture.Cipher); + + Assert.Equal(ExchangeOutcome.Created, result.Outcome); + Assert.NotNull(result.Lease); + Assert.Equal(1, fixture.LeasesCreated); + } + + [Fact] + public void Exchange_ApprovalRequiredAndRequestNotApproved_FailsWithNotApproved() + { + var fixture = new AccessRuleEngineFixture() + .RequiringApproval(); + fixture.RequestAccess(fixture.Cipher); + + var result = fixture.Exchange(fixture.Cipher); + + Assert.Equal(ExchangeOutcome.Failed, result.Outcome); + Assert.Equal(ExchangeFailReason.NotApproved, result.FailReason); + Assert.Equal(0, fixture.LeasesCreated); + } + + [Fact] + public void Exchange_ApprovalRequiredAndRequestApproved_CreatesLease() + { + var fixture = new AccessRuleEngineFixture() + .RequiringApproval(); + fixture.RequestAccess(fixture.Cipher); + fixture.ApproveRequest(); + + var result = fixture.Exchange(fixture.Cipher); + + Assert.Equal(ExchangeOutcome.Created, result.Outcome); + Assert.NotNull(result.Lease); + } + + [Fact] + public void Exchange_IpAddressWithinRequiredCidr_CreatesLease() + { + var fixture = new AccessRuleEngineFixture() + .RestrictedToCidr("10.0.0.0/24") + .FromIpAddress("10.0.0.5"); + fixture.RequestAccess(fixture.Cipher); + + var result = fixture.Exchange(fixture.Cipher); + + Assert.Equal(ExchangeOutcome.Created, result.Outcome); + } + + [Fact] + public void Exchange_SingletonRequiredAndAnotherUserHoldsActiveLease_FailsWithSingletonHeld() + { + var fixture = new AccessRuleEngineFixture() + .RequiringSingleton() + .WithActiveLeaseHeldBy(AccessRuleEngineFixture.AnotherUser); + fixture.RequestAccess(fixture.Cipher); + + var result = fixture.Exchange(fixture.Cipher); + + Assert.Equal(ExchangeOutcome.Failed, result.Outcome); + Assert.Equal(ExchangeFailReason.SingletonHeld, result.FailReason); + Assert.Equal(0, fixture.LeasesCreated); + } + + [Fact] + public void Exchange_SingletonRequiredAndNoExistingLease_CreatesLease() + { + var fixture = new AccessRuleEngineFixture() + .RequiringSingleton(); + fixture.RequestAccess(fixture.Cipher); + + var result = fixture.Exchange(fixture.Cipher); + + Assert.Equal(ExchangeOutcome.Created, result.Outcome); + Assert.Equal(1, fixture.LeasesCreated); + } + + [Fact] + public void Exchange_LeaseCreationFails_FailsWithLeaseCreationFailed() + { + var fixture = new AccessRuleEngineFixture() + .WhereLeaseCreationFails(); + fixture.RequestAccess(fixture.Cipher); + + var result = fixture.Exchange(fixture.Cipher); + + Assert.Equal(ExchangeOutcome.Failed, result.Outcome); + Assert.Equal(ExchangeFailReason.LeaseCreationFailed, result.FailReason); + } + + [Fact] + public void Exchange_IgnoresLiveContext_GrantsEvenWhenLiveContextWouldBeDenied() + { + // Request from an allowed address, then move to a denied one before exchanging. The lease is + // still issued because the rule is only evaluated at request time, not at exchange. + var fixture = new AccessRuleEngineFixture() + .RestrictedToCidr("10.0.0.0/24") + .FromIpAddress("10.0.0.5"); + fixture.RequestAccess(fixture.Cipher); + fixture.FromIpAddress("192.168.1.5"); + + var result = fixture.Exchange(fixture.Cipher); + + Assert.Equal(ExchangeOutcome.Created, result.Outcome); + } + + [Fact] + public void RequestAccess_DeniedContext_RejectedBeforeAnyLaterContextChange() + { + // The rule is evaluated against the requesting user's signals, so a request from a denied + // address is rejected immediately; a later move to an allowed address cannot resurrect it + // because no request was ever created. + var fixture = new AccessRuleEngineFixture() + .RestrictedToCidr("10.0.0.0/24") + .FromIpAddress("192.168.1.5"); + + var result = fixture.RequestAccess(fixture.Cipher); + + Assert.Equal(RequestAccessOutcome.Failed, result.Outcome); + Assert.Equal(RequestAccessFailReason.AccessDenied, result.FailReason); + Assert.Equal(DenyReason.NotWithinIpRange, result.DenyReason); + Assert.Equal(0, fixture.RequestsCreated); + } + + [Fact] + public void Exchange_DoesNotConsumeRequest_RequestRemainsAfterLeaseIssued() + { + var fixture = new AccessRuleEngineFixture(); + fixture.RequestAccess(fixture.Cipher); + + var first = fixture.Exchange(fixture.Cipher); + var second = fixture.Exchange(fixture.Cipher); + + Assert.Equal(ExchangeOutcome.Created, first.Outcome); + // The request is left in place, so a later exchange still finds it rather than reporting RequestNotFound. + Assert.NotEqual(ExchangeFailReason.RequestNotFound, second.FailReason); + } + + // The full request -> approve -> exchange -> check lifecycle. + + [Fact] + public void Lifecycle_RequestApproveExchange_ThenCheckGrants() + { + var fixture = new AccessRuleEngineFixture() + .RequiringApproval(); + + // No lease yet, so a check is denied. + Assert.Equal(DenyReason.NoLease, fixture.Check(fixture.Cipher).Reason); + + // The user requests access. + Assert.Equal(RequestAccessOutcome.Created, fixture.RequestAccess(fixture.Cipher).Outcome); + + // The request cannot be exchanged until it is approved. + Assert.Equal(ExchangeFailReason.NotApproved, fixture.Exchange(fixture.Cipher).FailReason); + + // The request is approved out of band. + fixture.ApproveRequest(); + + // The request can now be exchanged for a lease. + var exchange = fixture.Exchange(fixture.Cipher); + Assert.Equal(ExchangeOutcome.Created, exchange.Outcome); + Assert.NotNull(exchange.Lease); + + // With a valid lease in hand, the check now grants access. + Assert.Equal(AccessOutcome.Granted, fixture.Check(fixture.Cipher).Outcome); + } +} diff --git a/test/Core.Test/PrivilegedAccessManagement/Engine/Conditions/HumanApprovalConditionTests.cs b/test/Core.Test/PrivilegedAccessManagement/Engine/Conditions/HumanApprovalConditionTests.cs new file mode 100644 index 000000000000..a920926b1323 --- /dev/null +++ b/test/Core.Test/PrivilegedAccessManagement/Engine/Conditions/HumanApprovalConditionTests.cs @@ -0,0 +1,45 @@ +using System.Net; +using Bit.Core.Enums; +using Bit.Core.PrivilegedAccessManagement.Engine; +using Bit.Core.PrivilegedAccessManagement.Engine.Conditions; +using Xunit; + +namespace Bit.Core.Test.PrivilegedAccessManagement.Engine.Conditions; + +public sealed class HumanApprovalConditionTests +{ + private readonly HumanApprovalCondition _condition = new(); + + [Fact] + public void Evaluate_RuleRequiresApproval_ReturnsRequiresApproval() + { + var context = ContextFor(new AccessRule { Name = "rule", Duration = TimeSpan.FromHours(1), RequireApproval = true }); + + var decision = _condition.Evaluate(context); + + Assert.Equal(DecisionKind.RequiresApproval, decision.Kind); + } + + [Fact] + public void Evaluate_RuleDoesNotRequireApproval_ReturnsAllow() + { + var context = ContextFor(new AccessRule { Name = "rule", Duration = TimeSpan.FromHours(1) }); + + var decision = _condition.Evaluate(context); + + Assert.Equal(DecisionKind.Allow, decision.Kind); + } + + private static AccessRuleEngineContext ContextFor(AccessRule rule) => new() + { + Rule = rule, + Signals = new AccessRuleSignals + { + Username = "alice", + IpAddress = IPAddress.Loopback, + MultifactorEnabled = true, + UserTime = DateTimeOffset.UnixEpoch, + Device = DeviceType.ChromeBrowser, + }, + }; +} diff --git a/test/Core.Test/PrivilegedAccessManagement/Engine/Conditions/IpRangeConditionTests.cs b/test/Core.Test/PrivilegedAccessManagement/Engine/Conditions/IpRangeConditionTests.cs new file mode 100644 index 000000000000..4d37ee2f77d6 --- /dev/null +++ b/test/Core.Test/PrivilegedAccessManagement/Engine/Conditions/IpRangeConditionTests.cs @@ -0,0 +1,58 @@ +using System.Net; +using Bit.Core.Enums; +using Bit.Core.PrivilegedAccessManagement.Engine; +using Bit.Core.PrivilegedAccessManagement.Engine.Conditions; +using Xunit; + +namespace Bit.Core.Test.PrivilegedAccessManagement.Engine.Conditions; + +public sealed class IpRangeConditionTests +{ + private readonly IpRangeCondition _condition = new(); + + [Fact] + public void Evaluate_EmptyAllowlist_ReturnsAllow() + { + var decision = _condition.Evaluate(ContextFor([], "10.0.0.5")); + + Assert.Equal(DecisionKind.Allow, decision.Kind); + } + + [Fact] + public void Evaluate_IpWithinRange_ReturnsAllow() + { + var decision = _condition.Evaluate(ContextFor(["10.0.0.0/24"], "10.0.0.5")); + + Assert.Equal(DecisionKind.Allow, decision.Kind); + } + + [Fact] + public void Evaluate_IpOutsideRange_ReturnsDenyNotWithinIpRange() + { + var decision = _condition.Evaluate(ContextFor(["10.0.0.0/24"], "192.168.1.5")); + + Assert.Equal(DecisionKind.Deny, decision.Kind); + Assert.Equal(DenyReason.NotWithinIpRange, decision.Reason); + } + + [Fact] + public void Evaluate_UnparseableEntryIsSkipped_AndALaterMatchAllows() + { + var decision = _condition.Evaluate(ContextFor(["not-a-cidr", "10.0.0.0/24"], "10.0.0.5")); + + Assert.Equal(DecisionKind.Allow, decision.Kind); + } + + private static AccessRuleEngineContext ContextFor(List requiredCidr, string ipAddress) => new() + { + Rule = new AccessRule { Name = "rule", Duration = TimeSpan.FromHours(1), RequiredCidr = requiredCidr }, + Signals = new AccessRuleSignals + { + Username = "alice", + IpAddress = IPAddress.Parse(ipAddress), + MultifactorEnabled = true, + UserTime = DateTimeOffset.UnixEpoch, + Device = DeviceType.ChromeBrowser, + }, + }; +} diff --git a/test/Core.Test/PrivilegedAccessManagement/Engine/Conditions/TimeOfDayConditionTests.cs b/test/Core.Test/PrivilegedAccessManagement/Engine/Conditions/TimeOfDayConditionTests.cs new file mode 100644 index 000000000000..a02319a570a2 --- /dev/null +++ b/test/Core.Test/PrivilegedAccessManagement/Engine/Conditions/TimeOfDayConditionTests.cs @@ -0,0 +1,112 @@ +using System.Net; +using Bit.Core.Enums; +using Bit.Core.PrivilegedAccessManagement.Engine; +using Bit.Core.PrivilegedAccessManagement.Engine.Conditions; +using Xunit; + +namespace Bit.Core.Test.PrivilegedAccessManagement.Engine.Conditions; + +public sealed class TimeOfDayConditionTests +{ + private readonly TimeOfDayCondition _condition = new(); + + // 2026-01-15 is a Thursday; 2026-01-16 is a Friday. January is EST (UTC-5) in New York. + private static readonly DateTimeOffset ThursdayNoonUtc = new(2026, 1, 15, 12, 0, 0, TimeSpan.Zero); + private static readonly DateTimeOffset FridayNoonUtc = new(2026, 1, 16, 12, 0, 0, TimeSpan.Zero); + + [Fact] + public void Evaluate_NoConfig_ReturnsAllow() + { + var decision = _condition.Evaluate(ContextFor(null, ThursdayNoonUtc)); + + Assert.Equal(DecisionKind.Allow, decision.Kind); + } + + [Fact] + public void Evaluate_WithinWindow_ReturnsAllow() + { + var config = Config("UTC", Window(new TimeOnly(9, 0), new TimeOnly(17, 0), DayOfWeek.Thursday)); + + var decision = _condition.Evaluate(ContextFor(config, ThursdayNoonUtc)); + + Assert.Equal(DecisionKind.Allow, decision.Kind); + } + + [Fact] + public void Evaluate_OutsideWindowTime_ReturnsDeny() + { + var config = Config("UTC", Window(new TimeOnly(9, 0), new TimeOnly(11, 0), DayOfWeek.Thursday)); + + var decision = _condition.Evaluate(ContextFor(config, ThursdayNoonUtc)); + + Assert.Equal(DecisionKind.Deny, decision.Kind); + Assert.Equal(DenyReason.NotWithinTimeWindow, decision.Reason); + } + + [Fact] + public void Evaluate_NonMatchingDay_ReturnsDeny() + { + var config = Config("UTC", Window(new TimeOnly(9, 0), new TimeOnly(17, 0), DayOfWeek.Thursday)); + + var decision = _condition.Evaluate(ContextFor(config, FridayNoonUtc)); + + Assert.Equal(DecisionKind.Deny, decision.Kind); + Assert.Equal(DenyReason.NotWithinTimeWindow, decision.Reason); + } + + [Fact] + public void Evaluate_EmptyDays_MatchesAnyDay() + { + var config = Config("UTC", Window(new TimeOnly(9, 0), new TimeOnly(17, 0))); + + var decision = _condition.Evaluate(ContextFor(config, FridayNoonUtc)); + + Assert.Equal(DecisionKind.Allow, decision.Kind); + } + + [Fact] + public void Evaluate_ConvertsUserTimeIntoConfiguredTimezone() + { + // 20:00 UTC is outside 09:00-17:00 in UTC, but is 15:00 Thursday in New York (EST), inside the window + var config = Config("America/New_York", Window(new TimeOnly(9, 0), new TimeOnly(17, 0), DayOfWeek.Thursday)); + var userTime = new DateTimeOffset(2026, 1, 15, 20, 0, 0, TimeSpan.Zero); + + var decision = _condition.Evaluate(ContextFor(config, userTime)); + + Assert.Equal(DecisionKind.Allow, decision.Kind); + } + + [Fact] + public void Evaluate_InvalidTimezone_ReturnsDeny() + { + var config = Config("Not/AZone", Window(new TimeOnly(0, 0), new TimeOnly(23, 59), DayOfWeek.Thursday)); + + var decision = _condition.Evaluate(ContextFor(config, ThursdayNoonUtc)); + + Assert.Equal(DecisionKind.Deny, decision.Kind); + Assert.Equal(DenyReason.NotWithinTimeWindow, decision.Reason); + } + + private static TimeOfDayConfig Config(string timeZone, params AccessTimeWindow[] windows) + { + return new TimeOfDayConfig { TimeZone = timeZone, Windows = windows }; + } + + private static AccessTimeWindow Window(TimeOnly from, TimeOnly to, params DayOfWeek[] days) + { + return new AccessTimeWindow { Days = days, From = from, To = to }; + } + + private static AccessRuleEngineContext ContextFor(TimeOfDayConfig? config, DateTimeOffset userTime) => new() + { + Rule = new AccessRule { Name = "rule", Duration = TimeSpan.FromHours(1), TimeOfDay = config }, + Signals = new AccessRuleSignals + { + Username = "alice", + IpAddress = IPAddress.Loopback, + MultifactorEnabled = true, + UserTime = userTime, + Device = DeviceType.ChromeBrowser, + }, + }; +} diff --git a/test/Core.Test/PrivilegedAccessManagement/Engine/Fixture/AccessRuleEngineFixture.cs b/test/Core.Test/PrivilegedAccessManagement/Engine/Fixture/AccessRuleEngineFixture.cs new file mode 100644 index 000000000000..9c7ff0d64e8e --- /dev/null +++ b/test/Core.Test/PrivilegedAccessManagement/Engine/Fixture/AccessRuleEngineFixture.cs @@ -0,0 +1,171 @@ +using System.Net; +using Bit.Core.Enums; +using Bit.Core.PrivilegedAccessManagement.Engine; +using Bit.Core.Vault.Models.Data; +using Microsoft.Extensions.Time.Testing; + +namespace Bit.Core.Test.PrivilegedAccessManagement.Engine; + +public sealed class AccessRuleEngineFixture +{ + public const string RequestingUser = "alice"; + public const string AnotherUser = "bob"; + public static readonly DateTimeOffset Now = new(2026, 5, 29, 12, 0, 0, TimeSpan.Zero); + + private readonly FakeTimeProvider _time = new(Now); + private readonly FakeAccessRuleResolver _resolver = new(); + private readonly FakeAccessRuleRequestRepository _requests = new(); + private readonly FakeAccessRuleLeaseRepository _leases; + + private IPAddress _ipAddress = IPAddress.Parse("10.0.0.5"); + + private AccessRule? _rule = new() { Name = "test-rule", Duration = TimeSpan.FromHours(1) }; + + public AccessRuleEngineFixture() + { + _leases = new FakeAccessRuleLeaseRepository(_time); + } + + public CipherDetails Cipher { get; } = new() { Id = Guid.Parse("11111111-1111-1111-1111-111111111111") }; + + public AccessRuleSignals Signals => new() + { + Username = RequestingUser, + IpAddress = _ipAddress, + MultifactorEnabled = true, + UserTime = Now, + Device = DeviceType.ChromeBrowser, + }; + + public int LeasesCreated => _leases.CreatedCount; + public int RequestsCreated => _requests.CreatedCount; + + public AccessRuleEngineFixture WithNoRules() + { + _rule = null; + return this; + } + + public AccessRuleEngineFixture RequiringApproval() + { + _rule = EnsureRuleExist(); + _rule = _rule with { RequireApproval = true }; + return this; + } + + public AccessRuleEngineFixture RequiringSingleton() + { + _rule = EnsureRuleExist(); + _rule = _rule with { RequireSingleton = true }; + return this; + } + + public AccessRuleEngineFixture RestrictedToCidr(params string[] cidrs) + { + _rule = EnsureRuleExist(); + _rule.RequiredCidr.AddRange(cidrs); + return this; + } + + public AccessRuleEngineFixture RestrictedToTimeWindow(string timeZone, TimeOnly from, TimeOnly to, params DayOfWeek[] days) + { + _rule = EnsureRuleExist(); + _rule = _rule with + { + TimeOfDay = new TimeOfDayConfig + { + TimeZone = timeZone, + Windows = [new AccessTimeWindow { Days = days, From = from, To = to }], + }, + }; + return this; + } + + public AccessRuleEngineFixture WithActiveLease() + { + return SeedLease(RequestingUser, Now.UtcDateTime.AddHours(1)); + } + + public AccessRuleEngineFixture WithExpiredLease() + { + return SeedLease(RequestingUser, Now.UtcDateTime.AddHours(-1)); + } + + public AccessRuleEngineFixture WithActiveLeaseHeldBy(string username) + { + return SeedLease(username, Now.UtcDateTime.AddHours(1)); + } + + public AccessRuleEngineFixture WithPendingRequest() + { + return SeedRequest(approved: false); + } + + public AccessRuleEngineFixture ApproveRequest() + { + _requests.Approve(Cipher.Id, RequestingUser); + return this; + } + + public AccessRuleEngineFixture WhereLeaseCreationFails() + { + _leases.FailCreate = true; + return this; + } + + public AccessRuleEngineFixture FromIpAddress(string ip) + { + _ipAddress = IPAddress.Parse(ip); + return this; + } + + public AccessRuleEngineResult Check(CipherDetails cipher) + { + return CreateEngine().Check(cipher, Signals); + } + + public RequestAccessResult RequestAccess(CipherDetails cipher) + { + ApplyRule(cipher); + return CreateEngine().RequestAccess(cipher, Signals); + } + + public ExchangeResult Exchange(CipherDetails cipher, string? username = null) + { + ApplyRule(cipher); + return CreateEngine().ExchangeRequestForLease(cipher, username ?? RequestingUser); + } + + private AccessRuleEngine CreateEngine() => new(_time, _resolver, _requests, _leases); + + private void ApplyRule(CipherDetails cipher) + { + if (_rule != null) + { + _resolver.SetRule(cipher.Id, _rule); + } + } + + private AccessRuleEngineFixture SeedLease(string username, DateTime expires) + { + _leases.Seed(new AccessRuleLease { CipherId = Cipher.Id, Username = username, Expires = expires }); + return this; + } + + private AccessRuleEngineFixture SeedRequest(bool approved) + { + _requests.Seed(new AccessRuleRequest + { + CipherId = Cipher.Id, + Username = RequestingUser, + Approved = approved, + Signals = Signals, + }); + return this; + } + + private AccessRule EnsureRuleExist() + { + return _rule ??= new AccessRule { Name = "test-rule", Duration = TimeSpan.FromHours(1) }; + } +} diff --git a/test/Core.Test/PrivilegedAccessManagement/Engine/Fixture/FakeAccessRuleLeaseRepository.cs b/test/Core.Test/PrivilegedAccessManagement/Engine/Fixture/FakeAccessRuleLeaseRepository.cs new file mode 100644 index 000000000000..fae9c6cd1a90 --- /dev/null +++ b/test/Core.Test/PrivilegedAccessManagement/Engine/Fixture/FakeAccessRuleLeaseRepository.cs @@ -0,0 +1,53 @@ +using Bit.Core.PrivilegedAccessManagement.Engine; + +namespace Bit.Core.Test.PrivilegedAccessManagement.Engine; + +public sealed class FakeAccessRuleLeaseRepository : IAccessRuleLeaseRepository +{ + private readonly TimeProvider _time; + private readonly List _leases = []; + + public FakeAccessRuleLeaseRepository(TimeProvider time) + { + _time = time ?? throw new ArgumentNullException(nameof(time)); + } + + public bool FailCreate { get; set; } + public int CreatedCount { get; private set; } + + public void Seed(AccessRuleLease lease) + { + _leases.Add(lease); + } + + public bool TryCreate(AccessRuleRequest request, TimeSpan duration, out AccessRuleLease? lease) + { + if (FailCreate) + { + lease = null; + return false; + } + + lease = new AccessRuleLease + { + CipherId = request.CipherId, + Username = request.Username, + Expires = _time.GetUtcNow().UtcDateTime.Add(duration), + }; + _leases.Add(lease); + CreatedCount++; + return true; + } + + public bool TryGet(Guid cipherId, string username, out AccessRuleLease? lease) + { + lease = _leases.FirstOrDefault(l => l.CipherId == cipherId && l.Username == username); + return lease is not null; + } + + public bool TryGet(Guid cipherId, out AccessRuleLease? lease) + { + lease = _leases.FirstOrDefault(l => l.CipherId == cipherId); + return lease is not null; + } +} diff --git a/test/Core.Test/PrivilegedAccessManagement/Engine/Fixture/FakeAccessRuleRequestRepository.cs b/test/Core.Test/PrivilegedAccessManagement/Engine/Fixture/FakeAccessRuleRequestRepository.cs new file mode 100644 index 000000000000..a1608fa29515 --- /dev/null +++ b/test/Core.Test/PrivilegedAccessManagement/Engine/Fixture/FakeAccessRuleRequestRepository.cs @@ -0,0 +1,52 @@ +using Bit.Core.PrivilegedAccessManagement.Engine; + +namespace Bit.Core.Test.PrivilegedAccessManagement.Engine; + +public sealed class FakeAccessRuleRequestRepository : IAccessRuleRequestRepository +{ + private readonly List _requests = []; + + public int CreatedCount { get; private set; } + + public void Seed(AccessRuleRequest request) => _requests.Add(request); + + public AccessRuleRequest Create(Guid cipherId, AccessRuleSignals signals) + { + var request = new AccessRuleRequest + { + CipherId = cipherId, + Username = signals.Username, + Approved = false, + Signals = signals, + }; + _requests.Add(request); + CreatedCount++; + return request; + } + + public void Approve(Guid cipherId, string username) + { + var existing = _requests.FirstOrDefault(r => r.CipherId == cipherId && r.Username == username); + if (existing is null) + { + return; + } + + _requests.Remove(existing); + _requests.Add(new AccessRuleRequest + { + CipherId = existing.CipherId, + Username = existing.Username, + Approved = true, + Signals = existing.Signals, + }); + } + + public bool Delete(AccessRuleRequest request) => _requests.Remove(request); + + public bool TryGet(Guid cipherId, string username, out AccessRuleRequest? request) + { + request = _requests.FirstOrDefault(r => r.CipherId == cipherId && r.Username == username); + return request is not null; + } +} diff --git a/test/Core.Test/PrivilegedAccessManagement/Engine/Fixture/FakeAccessRuleResolver.cs b/test/Core.Test/PrivilegedAccessManagement/Engine/Fixture/FakeAccessRuleResolver.cs new file mode 100644 index 000000000000..71820b6b1378 --- /dev/null +++ b/test/Core.Test/PrivilegedAccessManagement/Engine/Fixture/FakeAccessRuleResolver.cs @@ -0,0 +1,14 @@ +using Bit.Core.PrivilegedAccessManagement.Engine; +using Bit.Core.Vault.Models.Data; + +namespace Bit.Core.Test.PrivilegedAccessManagement.Engine; + +public sealed class FakeAccessRuleResolver : IAccessRuleResolver +{ + private readonly Dictionary _rules = []; + + public void SetRule(Guid cipherId, AccessRule rule) => _rules[cipherId] = rule; + + public AccessRule? Resolve(CipherDetails cipher) + => _rules.TryGetValue(cipher.Id, out var rule) ? rule : null; +} From dd340f5dca1807af9c84c95e9c80d6f829954f99 Mon Sep 17 00:00:00 2001 From: Hinton Date: Thu, 4 Jun 2026 14:04:36 +0200 Subject: [PATCH 09/54] Add lease request flow --- .../Controllers/AccessRulesController.cs | 10 +- .../Pam/Controllers/CipherLeaseController.cs | 45 ++++ .../Pam/Models/Request/AccessRequestModel.cs | 27 ++ .../Models/Request/AccessRuleRequestModel.cs | 4 +- .../Response/AccessPreCheckResponseModel.cs | 22 ++ .../Response/AccessRequestResponseModel.cs | 27 ++ .../Response/AccessRuleResponseModel.cs | 4 +- .../Response/LeaseRequestResponseModel.cs | 33 +++ .../Pam/Models/Response/LeaseResponseModel.cs | 29 +++ src/Core/AdminConsole/Entities/Collection.cs | 2 +- ...OrganizationServiceCollectionExtensions.cs | 11 +- .../Engine/AccessDecision.cs | 2 +- .../Engine/AccessRule.cs | 2 +- .../Engine/AccessRuleEngine.cs | 4 +- .../Engine/AccessRuleEngineContext.cs | 2 +- .../Engine/AccessRuleEngineResult.cs | 2 +- .../Engine/AccessRuleLease.cs | 2 +- .../Engine/AccessRuleRequest.cs | 2 +- .../Engine/AccessRuleSignals.cs | 2 +- .../Conditions/HumanApprovalCondition.cs | 2 +- .../Engine/Conditions/IpRangeCondition.cs | 2 +- .../Engine/Conditions/TimeOfDayCondition.cs | 2 +- .../Engine/ExchangeResult.cs | 2 +- .../Engine/IAccessCondition.cs | 2 +- .../Engine/RequestAccessResult.cs | 2 +- .../Entities/AccessRule.cs | 2 +- src/Core/Pam/Entities/Lease.cs | 39 +++ src/Core/Pam/Entities/LeaseDecision.cs | 49 ++++ src/Core/Pam/Entities/LeaseRequest.cs | 55 ++++ src/Core/Pam/Enums/AccessApprovalOutcome.cs | 11 + src/Core/Pam/Enums/LeaseDecisionKind.cs | 19 ++ src/Core/Pam/Enums/LeaseRequestStatus.cs | 14 + src/Core/Pam/Enums/LeaseStatus.cs | 11 + .../Pam/Models/AccessApprovalResolution.cs | 11 + src/Core/Pam/Models/AccessPreCheckResult.cs | 9 + src/Core/Pam/Models/AccessRequestResult.cs | 21 ++ .../Pam/Models/AccessRequestSubmission.cs | 14 + .../Models/AccessRuleDetails.cs | 4 +- .../Models/Rules/AllOfRule.cs | 2 +- .../Models/Rules/HumanApprovalRule.cs | 2 +- .../Models/Rules/IpAllowlistRule.cs | 2 +- .../Models/Rules/Rule.cs | 2 +- .../Models/Rules/TimeOfDayRule.cs | 2 +- .../Commands/CreateAccessRuleCommand.cs | 12 +- .../Commands/DeleteAccessRuleCommand.cs | 6 +- .../Interfaces/ICreateAccessRuleCommand.cs | 6 +- .../Interfaces/IDeleteAccessRuleCommand.cs | 2 +- .../Interfaces/IRequestAccessCommand.cs | 12 + .../Interfaces/IUpdateAccessRuleCommand.cs | 6 +- .../Commands/RequestAccessCommand.cs | 179 +++++++++++++ .../Commands/UpdateAccessRuleCommand.cs | 12 +- .../Queries/AccessPreCheckQuery.cs | 37 +++ .../Interfaces/IAccessPreCheckQuery.cs | 12 + .../Repositories/IAccessRuleRepository.cs | 6 +- src/Core/Pam/Repositories/ILeaseRepository.cs | 20 ++ .../Repositories/ILeaseRequestRepository.cs | 15 ++ .../Pam/Services/AccessApprovalResolver.cs | 93 +++++++ .../Services/AccessRuleValidator.cs | 4 +- .../Pam/Services/IAccessApprovalResolver.cs | 13 + .../Services/IAccessRuleValidator.cs | 2 +- .../DapperServiceCollectionExtensions.cs | 6 +- .../Repositories/AccessRuleRepository.cs | 8 +- .../Pam/Repositories/LeaseRepository.cs | 56 ++++ .../Repositories/LeaseRequestRepository.cs | 33 +++ ...ityFrameworkServiceCollectionExtensions.cs | 4 +- .../Pam/Models/AccessRule.cs | 21 ++ .../Repositories/AccessRuleRepository.cs | 10 +- .../Models/AccessRule.cs | 21 -- .../Repositories/DatabaseContext.cs | 2 +- .../Stored Procedures/AccessRule_Create.sql | 0 .../AccessRule_DeleteById.sql | 0 .../Stored Procedures/AccessRule_ReadById.sql | 0 .../AccessRule_ReadByOrganizationId.sql | 0 .../AccessRule_ReadDetailsById.sql | 0 ...AccessRule_ReadDetailsByOrganizationId.sql | 0 .../Stored Procedures/AccessRule_Update.sql | 0 .../Collection_SetAccessRuleAssociations.sql | 0 .../Stored Procedures/LeaseRequest_Create.sql | 48 ++++ ...ReadActivePendingByRequesterIdCipherId.sql | 18 ++ .../LeaseRequest_ReadById.sql | 13 + .../Lease_CreateAutoApproved.sql | 56 ++++ .../Lease_ReadActiveByRequesterIdCipherId.sql | 21 ++ .../Pam/Stored Procedures/Lease_ReadById.sql | 13 + .../Tables/AccessRule.sql | 0 src/Sql/dbo/Pam/Tables/Lease.sql | 26 ++ src/Sql/dbo/Pam/Tables/LeaseDecision.sql | 18 ++ src/Sql/dbo/Pam/Tables/LeaseRequest.sql | 26 ++ .../Commands/CreateAccessRuleCommandTests.cs | 10 +- .../Commands/DeleteAccessRuleCommandTests.cs | 8 +- .../Pam/Commands/RequestAccessCommandTests.cs | 220 ++++++++++++++++ .../Commands/UpdateAccessRuleCommandTests.cs | 12 +- .../Engine/AccessRuleEngineTests.cs | 4 +- .../Conditions/HumanApprovalConditionTests.cs | 6 +- .../Conditions/IpRangeConditionTests.cs | 6 +- .../Conditions/TimeOfDayConditionTests.cs | 6 +- .../Engine/Fixture/AccessRuleEngineFixture.cs | 4 +- .../Fixture/FakeAccessRuleLeaseRepository.cs | 4 +- .../FakeAccessRuleRequestRepository.cs | 4 +- .../Engine/Fixture/FakeAccessRuleResolver.cs | 4 +- .../Pam/Queries/AccessPreCheckQueryTests.cs | 77 ++++++ .../Services/AccessApprovalResolverTests.cs | 111 ++++++++ .../Services/AccessRuleValidatorTests.cs | 4 +- .../Repositories/AccessRuleRepositoryTests.cs | 6 +- .../Pam/Repositories/LeaseRepositoryTests.cs | 145 +++++++++++ .../2026-06-04_00_AddLeaseTables.sql | 244 ++++++++++++++++++ .../20260526122321_AddAccessRule.Designer.cs | 6 +- .../DatabaseContextModelSnapshot.cs | 6 +- .../20260526122317_AddAccessRule.Designer.cs | 6 +- .../DatabaseContextModelSnapshot.cs | 6 +- .../20260526122325_AddAccessRule.Designer.cs | 6 +- .../DatabaseContextModelSnapshot.cs | 6 +- 111 files changed, 2105 insertions(+), 156 deletions(-) rename src/Api/{PrivilegedAccessManagement => Pam}/Controllers/AccessRulesController.cs (89%) create mode 100644 src/Api/Pam/Controllers/CipherLeaseController.cs create mode 100644 src/Api/Pam/Models/Request/AccessRequestModel.cs rename src/Api/{PrivilegedAccessManagement => Pam}/Models/Request/AccessRuleRequestModel.cs (90%) create mode 100644 src/Api/Pam/Models/Response/AccessPreCheckResponseModel.cs create mode 100644 src/Api/Pam/Models/Response/AccessRequestResponseModel.cs rename src/Api/{PrivilegedAccessManagement => Pam}/Models/Response/AccessRuleResponseModel.cs (91%) create mode 100644 src/Api/Pam/Models/Response/LeaseRequestResponseModel.cs create mode 100644 src/Api/Pam/Models/Response/LeaseResponseModel.cs rename src/Core/{PrivilegedAccessManagement => Pam}/Engine/AccessDecision.cs (95%) rename src/Core/{PrivilegedAccessManagement => Pam}/Engine/AccessRule.cs (94%) rename src/Core/{PrivilegedAccessManagement => Pam}/Engine/AccessRuleEngine.cs (97%) rename src/Core/{PrivilegedAccessManagement => Pam}/Engine/AccessRuleEngineContext.cs (73%) rename src/Core/{PrivilegedAccessManagement => Pam}/Engine/AccessRuleEngineResult.cs (86%) rename src/Core/{PrivilegedAccessManagement => Pam}/Engine/AccessRuleLease.cs (91%) rename src/Core/{PrivilegedAccessManagement => Pam}/Engine/AccessRuleRequest.cs (93%) rename src/Core/{PrivilegedAccessManagement => Pam}/Engine/AccessRuleSignals.cs (87%) rename src/Core/{PrivilegedAccessManagement => Pam}/Engine/Conditions/HumanApprovalCondition.cs (80%) rename src/Core/{PrivilegedAccessManagement => Pam}/Engine/Conditions/IpRangeCondition.cs (90%) rename src/Core/{PrivilegedAccessManagement => Pam}/Engine/Conditions/TimeOfDayCondition.cs (94%) rename src/Core/{PrivilegedAccessManagement => Pam}/Engine/ExchangeResult.cs (91%) rename src/Core/{PrivilegedAccessManagement => Pam}/Engine/IAccessCondition.cs (63%) rename src/Core/{PrivilegedAccessManagement => Pam}/Engine/RequestAccessResult.cs (94%) rename src/Core/{PrivilegedAccessManagement => Pam}/Entities/AccessRule.cs (94%) create mode 100644 src/Core/Pam/Entities/Lease.cs create mode 100644 src/Core/Pam/Entities/LeaseDecision.cs create mode 100644 src/Core/Pam/Entities/LeaseRequest.cs create mode 100644 src/Core/Pam/Enums/AccessApprovalOutcome.cs create mode 100644 src/Core/Pam/Enums/LeaseDecisionKind.cs create mode 100644 src/Core/Pam/Enums/LeaseRequestStatus.cs create mode 100644 src/Core/Pam/Enums/LeaseStatus.cs create mode 100644 src/Core/Pam/Models/AccessApprovalResolution.cs create mode 100644 src/Core/Pam/Models/AccessPreCheckResult.cs create mode 100644 src/Core/Pam/Models/AccessRequestResult.cs create mode 100644 src/Core/Pam/Models/AccessRequestSubmission.cs rename src/Core/{PrivilegedAccessManagement => Pam}/Models/AccessRuleDetails.cs (85%) rename src/Core/{PrivilegedAccessManagement => Pam}/Models/Rules/AllOfRule.cs (75%) rename src/Core/{PrivilegedAccessManagement => Pam}/Models/Rules/HumanApprovalRule.cs (69%) rename src/Core/{PrivilegedAccessManagement => Pam}/Models/Rules/IpAllowlistRule.cs (78%) rename src/Core/{PrivilegedAccessManagement => Pam}/Models/Rules/Rule.cs (91%) rename src/Core/{PrivilegedAccessManagement => Pam}/Models/Rules/TimeOfDayRule.cs (89%) rename src/Core/{PrivilegedAccessManagement => Pam}/OrganizationFeatures/Commands/CreateAccessRuleCommand.cs (89%) rename src/Core/{PrivilegedAccessManagement => Pam}/OrganizationFeatures/Commands/DeleteAccessRuleCommand.cs (73%) rename src/Core/{PrivilegedAccessManagement => Pam}/OrganizationFeatures/Commands/Interfaces/ICreateAccessRuleCommand.cs (57%) rename src/Core/{PrivilegedAccessManagement => Pam}/OrganizationFeatures/Commands/Interfaces/IDeleteAccessRuleCommand.cs (52%) create mode 100644 src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IRequestAccessCommand.cs rename src/Core/{PrivilegedAccessManagement => Pam}/OrganizationFeatures/Commands/Interfaces/IUpdateAccessRuleCommand.cs (62%) create mode 100644 src/Core/Pam/OrganizationFeatures/Commands/RequestAccessCommand.cs rename src/Core/{PrivilegedAccessManagement => Pam}/OrganizationFeatures/Commands/UpdateAccessRuleCommand.cs (90%) create mode 100644 src/Core/Pam/OrganizationFeatures/Queries/AccessPreCheckQuery.cs create mode 100644 src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IAccessPreCheckQuery.cs rename src/Core/{PrivilegedAccessManagement => Pam}/Repositories/IAccessRuleRepository.cs (89%) create mode 100644 src/Core/Pam/Repositories/ILeaseRepository.cs create mode 100644 src/Core/Pam/Repositories/ILeaseRequestRepository.cs create mode 100644 src/Core/Pam/Services/AccessApprovalResolver.cs rename src/Core/{PrivilegedAccessManagement => Pam}/Services/AccessRuleValidator.cs (97%) create mode 100644 src/Core/Pam/Services/IAccessApprovalResolver.cs rename src/Core/{PrivilegedAccessManagement => Pam}/Services/IAccessRuleValidator.cs (90%) rename src/Infrastructure.Dapper/{PrivilegedAccessManagement => Pam}/Repositories/AccessRuleRepository.cs (93%) create mode 100644 src/Infrastructure.Dapper/Pam/Repositories/LeaseRepository.cs create mode 100644 src/Infrastructure.Dapper/Pam/Repositories/LeaseRequestRepository.cs create mode 100644 src/Infrastructure.EntityFramework/Pam/Models/AccessRule.cs rename src/Infrastructure.EntityFramework/{PrivilegedAccessManagement => Pam}/Repositories/AccessRuleRepository.cs (93%) delete mode 100644 src/Infrastructure.EntityFramework/PrivilegedAccessManagement/Models/AccessRule.cs rename src/Sql/dbo/{PrivilegedAccessManagement => Pam}/Stored Procedures/AccessRule_Create.sql (100%) rename src/Sql/dbo/{PrivilegedAccessManagement => Pam}/Stored Procedures/AccessRule_DeleteById.sql (100%) rename src/Sql/dbo/{PrivilegedAccessManagement => Pam}/Stored Procedures/AccessRule_ReadById.sql (100%) rename src/Sql/dbo/{PrivilegedAccessManagement => Pam}/Stored Procedures/AccessRule_ReadByOrganizationId.sql (100%) rename src/Sql/dbo/{PrivilegedAccessManagement => Pam}/Stored Procedures/AccessRule_ReadDetailsById.sql (100%) rename src/Sql/dbo/{PrivilegedAccessManagement => Pam}/Stored Procedures/AccessRule_ReadDetailsByOrganizationId.sql (100%) rename src/Sql/dbo/{PrivilegedAccessManagement => Pam}/Stored Procedures/AccessRule_Update.sql (100%) rename src/Sql/dbo/{PrivilegedAccessManagement => Pam}/Stored Procedures/Collection_SetAccessRuleAssociations.sql (100%) create mode 100644 src/Sql/dbo/Pam/Stored Procedures/LeaseRequest_Create.sql create mode 100644 src/Sql/dbo/Pam/Stored Procedures/LeaseRequest_ReadActivePendingByRequesterIdCipherId.sql create mode 100644 src/Sql/dbo/Pam/Stored Procedures/LeaseRequest_ReadById.sql create mode 100644 src/Sql/dbo/Pam/Stored Procedures/Lease_CreateAutoApproved.sql create mode 100644 src/Sql/dbo/Pam/Stored Procedures/Lease_ReadActiveByRequesterIdCipherId.sql create mode 100644 src/Sql/dbo/Pam/Stored Procedures/Lease_ReadById.sql rename src/Sql/dbo/{PrivilegedAccessManagement => Pam}/Tables/AccessRule.sql (100%) create mode 100644 src/Sql/dbo/Pam/Tables/Lease.sql create mode 100644 src/Sql/dbo/Pam/Tables/LeaseDecision.sql create mode 100644 src/Sql/dbo/Pam/Tables/LeaseRequest.sql rename test/Core.Test/{PrivilegedAccessManagement => Pam}/Commands/CreateAccessRuleCommandTests.cs (96%) rename test/Core.Test/{PrivilegedAccessManagement => Pam}/Commands/DeleteAccessRuleCommandTests.cs (88%) create mode 100644 test/Core.Test/Pam/Commands/RequestAccessCommandTests.cs rename test/Core.Test/{PrivilegedAccessManagement => Pam}/Commands/UpdateAccessRuleCommandTests.cs (96%) rename test/Core.Test/{PrivilegedAccessManagement => Pam}/Engine/AccessRuleEngineTests.cs (99%) rename test/Core.Test/{PrivilegedAccessManagement => Pam}/Engine/Conditions/HumanApprovalConditionTests.cs (86%) rename test/Core.Test/{PrivilegedAccessManagement => Pam}/Engine/Conditions/IpRangeConditionTests.cs (90%) rename test/Core.Test/{PrivilegedAccessManagement => Pam}/Engine/Conditions/TimeOfDayConditionTests.cs (95%) rename test/Core.Test/{PrivilegedAccessManagement => Pam}/Engine/Fixture/AccessRuleEngineFixture.cs (97%) rename test/Core.Test/{PrivilegedAccessManagement => Pam}/Engine/Fixture/FakeAccessRuleLeaseRepository.cs (92%) rename test/Core.Test/{PrivilegedAccessManagement => Pam}/Engine/Fixture/FakeAccessRuleRequestRepository.cs (92%) rename test/Core.Test/{PrivilegedAccessManagement => Pam}/Engine/Fixture/FakeAccessRuleResolver.cs (77%) create mode 100644 test/Core.Test/Pam/Queries/AccessPreCheckQueryTests.cs create mode 100644 test/Core.Test/Pam/Services/AccessApprovalResolverTests.cs rename test/Core.Test/{PrivilegedAccessManagement => Pam}/Services/AccessRuleValidatorTests.cs (97%) rename test/Infrastructure.IntegrationTest/{PrivilegedAccessManagement => Pam}/Repositories/AccessRuleRepositoryTests.cs (90%) create mode 100644 test/Infrastructure.IntegrationTest/Pam/Repositories/LeaseRepositoryTests.cs create mode 100644 util/Migrator/DbScripts/2026-06-04_00_AddLeaseTables.sql diff --git a/src/Api/PrivilegedAccessManagement/Controllers/AccessRulesController.cs b/src/Api/Pam/Controllers/AccessRulesController.cs similarity index 89% rename from src/Api/PrivilegedAccessManagement/Controllers/AccessRulesController.cs rename to src/Api/Pam/Controllers/AccessRulesController.cs index 60bd43c19ab4..0a97e94a4fcf 100644 --- a/src/Api/PrivilegedAccessManagement/Controllers/AccessRulesController.cs +++ b/src/Api/Pam/Controllers/AccessRulesController.cs @@ -1,16 +1,16 @@ using Bit.Api.Models.Response; -using Bit.Api.PrivilegedAccessManagement.Models.Request; -using Bit.Api.PrivilegedAccessManagement.Models.Response; +using Bit.Api.Pam.Models.Request; +using Bit.Api.Pam.Models.Response; using Bit.Core; using Bit.Core.Context; using Bit.Core.Exceptions; -using Bit.Core.PrivilegedAccessManagement.OrganizationFeatures.Commands.Interfaces; -using Bit.Core.PrivilegedAccessManagement.Repositories; +using Bit.Core.Pam.OrganizationFeatures.Commands.Interfaces; +using Bit.Core.Pam.Repositories; using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -namespace Bit.Api.PrivilegedAccessManagement.Controllers; +namespace Bit.Api.Pam.Controllers; [Route("organizations/{orgId:guid}/access-rules")] [Authorize("Application")] diff --git a/src/Api/Pam/Controllers/CipherLeaseController.cs b/src/Api/Pam/Controllers/CipherLeaseController.cs new file mode 100644 index 000000000000..9b28a4020b24 --- /dev/null +++ b/src/Api/Pam/Controllers/CipherLeaseController.cs @@ -0,0 +1,45 @@ +using Bit.Api.Pam.Models.Request; +using Bit.Api.Pam.Models.Response; +using Bit.Core; +using Bit.Core.Pam.OrganizationFeatures.Commands.Interfaces; +using Bit.Core.Pam.OrganizationFeatures.Queries.Interfaces; +using Bit.Core.Services; +using Bit.Core.Utilities; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Bit.Api.Pam.Controllers; + +[Route("ciphers/{id:guid}/lease")] +[Authorize("Application")] +[RequireFeature(FeatureFlagKeys.Pam)] +public class CipherLeaseController( + IUserService userService, + IAccessPreCheckQuery preCheckQuery, + IRequestAccessCommand requestAccessCommand) + : Controller +{ + /// + /// Reports whether leasing this cipher would be approved automatically or require human approval, so the client + /// can present the appropriate workflow. No request is created. + /// + [HttpGet("pre-check")] + public async Task PreCheck(Guid id) + { + var userId = userService.GetProperUserId(User)!.Value; + var result = await preCheckQuery.PreCheckAsync(userId, id); + return new AccessPreCheckResponseModel(id, result); + } + + /// + /// Submits a request to lease this cipher. The automatic path issues an active lease immediately; the human path + /// creates a pending request for an approver. + /// + [HttpPost("")] + public async Task Post(Guid id, [FromBody] AccessRequestModel model) + { + var userId = userService.GetProperUserId(User)!.Value; + var result = await requestAccessCommand.RequestAccessAsync(userId, id, model.ToSubmission()); + return new AccessRequestResponseModel(result); + } +} diff --git a/src/Api/Pam/Models/Request/AccessRequestModel.cs b/src/Api/Pam/Models/Request/AccessRequestModel.cs new file mode 100644 index 000000000000..f6aac131acef --- /dev/null +++ b/src/Api/Pam/Models/Request/AccessRequestModel.cs @@ -0,0 +1,27 @@ +using Bit.Core.Pam.Models; + +namespace Bit.Api.Pam.Models.Request; + +/// +/// A request to lease a cipher. Supply for the automatic path, or +/// / + for the human path. The server validates the shape +/// against the cipher's resolved approval outcome (run a pre-check first). The cipher is identified by the route. +/// +public class AccessRequestModel +{ + public int? DurationSeconds { get; set; } + + public DateTime? Start { get; set; } + + public DateTime? End { get; set; } + + public string? Reason { get; set; } + + public AccessRequestSubmission ToSubmission() => new() + { + DurationSeconds = DurationSeconds, + Start = Start, + End = End, + Reason = Reason, + }; +} diff --git a/src/Api/PrivilegedAccessManagement/Models/Request/AccessRuleRequestModel.cs b/src/Api/Pam/Models/Request/AccessRuleRequestModel.cs similarity index 90% rename from src/Api/PrivilegedAccessManagement/Models/Request/AccessRuleRequestModel.cs rename to src/Api/Pam/Models/Request/AccessRuleRequestModel.cs index c49f2e0e3589..4dc79c598c8d 100644 --- a/src/Api/PrivilegedAccessManagement/Models/Request/AccessRuleRequestModel.cs +++ b/src/Api/Pam/Models/Request/AccessRuleRequestModel.cs @@ -1,8 +1,8 @@ using System.ComponentModel.DataAnnotations; using System.Text.Json; -using Bit.Core.PrivilegedAccessManagement.Entities; +using Bit.Core.Pam.Entities; -namespace Bit.Api.PrivilegedAccessManagement.Models.Request; +namespace Bit.Api.Pam.Models.Request; public class AccessRuleRequestModel { diff --git a/src/Api/Pam/Models/Response/AccessPreCheckResponseModel.cs b/src/Api/Pam/Models/Response/AccessPreCheckResponseModel.cs new file mode 100644 index 000000000000..24955daa0a7b --- /dev/null +++ b/src/Api/Pam/Models/Response/AccessPreCheckResponseModel.cs @@ -0,0 +1,22 @@ +using Bit.Core.Models.Api; +using Bit.Core.Pam.Enums; +using Bit.Core.Pam.Models; + +namespace Bit.Api.Pam.Models.Response; + +public class AccessPreCheckResponseModel : ResponseModel +{ + public AccessPreCheckResponseModel(Guid cipherId, AccessPreCheckResult result) + : base("accessPreCheck") + { + CipherId = cipherId; + Outcome = result.Outcome == AccessApprovalOutcome.Human ? "human" : "automatic"; + } + + public Guid CipherId { get; } + + /// + /// "automatic" when a request would be approved immediately, "human" when it needs an approver. + /// + public string Outcome { get; } +} diff --git a/src/Api/Pam/Models/Response/AccessRequestResponseModel.cs b/src/Api/Pam/Models/Response/AccessRequestResponseModel.cs new file mode 100644 index 000000000000..0eb3950329ab --- /dev/null +++ b/src/Api/Pam/Models/Response/AccessRequestResponseModel.cs @@ -0,0 +1,27 @@ +using Bit.Core.Models.Api; +using Bit.Core.Pam.Enums; +using Bit.Core.Pam.Models; + +namespace Bit.Api.Pam.Models.Response; + +public class AccessRequestResponseModel : ResponseModel +{ + public AccessRequestResponseModel(AccessRequestResult result) + : base("accessRequest") + { + ArgumentNullException.ThrowIfNull(result); + + Outcome = result.Outcome == AccessApprovalOutcome.Human ? "human" : "automatic"; + Lease = result.Lease is null ? null : new LeaseResponseModel(result.Lease); + Request = result.Request is null ? null : new LeaseRequestResponseModel(result.Request); + } + + /// + /// "automatic" when a was issued immediately, "human" when a pending + /// was created. + /// + public string Outcome { get; } + + public LeaseResponseModel? Lease { get; } + public LeaseRequestResponseModel? Request { get; } +} diff --git a/src/Api/PrivilegedAccessManagement/Models/Response/AccessRuleResponseModel.cs b/src/Api/Pam/Models/Response/AccessRuleResponseModel.cs similarity index 91% rename from src/Api/PrivilegedAccessManagement/Models/Response/AccessRuleResponseModel.cs rename to src/Api/Pam/Models/Response/AccessRuleResponseModel.cs index 9c4ef8e636a9..8ba3d83a2b41 100644 --- a/src/Api/PrivilegedAccessManagement/Models/Response/AccessRuleResponseModel.cs +++ b/src/Api/Pam/Models/Response/AccessRuleResponseModel.cs @@ -1,8 +1,8 @@ using System.Text.Json; using Bit.Core.Models.Api; -using Bit.Core.PrivilegedAccessManagement.Models; +using Bit.Core.Pam.Models; -namespace Bit.Api.PrivilegedAccessManagement.Models.Response; +namespace Bit.Api.Pam.Models.Response; public class AccessRuleResponseModel : ResponseModel { diff --git a/src/Api/Pam/Models/Response/LeaseRequestResponseModel.cs b/src/Api/Pam/Models/Response/LeaseRequestResponseModel.cs new file mode 100644 index 000000000000..2366b4dca40c --- /dev/null +++ b/src/Api/Pam/Models/Response/LeaseRequestResponseModel.cs @@ -0,0 +1,33 @@ +using Bit.Core.Models.Api; +using Bit.Core.Pam.Entities; + +namespace Bit.Api.Pam.Models.Response; + +public class LeaseRequestResponseModel : ResponseModel +{ + public LeaseRequestResponseModel(LeaseRequest request) + : base("leaseRequest") + { + ArgumentNullException.ThrowIfNull(request); + + Id = request.Id; + CipherId = request.CipherId; + CollectionId = request.CollectionId; + OrganizationId = request.OrganizationId; + Status = request.Status; + NotBefore = request.NotBefore; + NotAfter = request.NotAfter; + Reason = request.Reason; + CreationDate = request.CreationDate; + } + + public Guid Id { get; } + public Guid CipherId { get; } + public Guid CollectionId { get; } + public Guid OrganizationId { get; } + public Core.Pam.Enums.LeaseRequestStatus Status { get; } + public DateTime NotBefore { get; } + public DateTime NotAfter { get; } + public string? Reason { get; } + public DateTime CreationDate { get; } +} diff --git a/src/Api/Pam/Models/Response/LeaseResponseModel.cs b/src/Api/Pam/Models/Response/LeaseResponseModel.cs new file mode 100644 index 000000000000..f95dd8195f29 --- /dev/null +++ b/src/Api/Pam/Models/Response/LeaseResponseModel.cs @@ -0,0 +1,29 @@ +using Bit.Core.Models.Api; +using Bit.Core.Pam.Entities; + +namespace Bit.Api.Pam.Models.Response; + +public class LeaseResponseModel : ResponseModel +{ + public LeaseResponseModel(Lease lease) + : base("lease") + { + ArgumentNullException.ThrowIfNull(lease); + + Id = lease.Id; + CipherId = lease.CipherId; + CollectionId = lease.CollectionId; + OrganizationId = lease.OrganizationId; + Status = lease.Status; + NotBefore = lease.NotBefore; + NotAfter = lease.NotAfter; + } + + public Guid Id { get; } + public Guid CipherId { get; } + public Guid CollectionId { get; } + public Guid OrganizationId { get; } + public Core.Pam.Enums.LeaseStatus Status { get; } + public DateTime NotBefore { get; } + public DateTime NotAfter { get; } +} diff --git a/src/Core/AdminConsole/Entities/Collection.cs b/src/Core/AdminConsole/Entities/Collection.cs index 273a37de6c4e..b9a1829ab21a 100644 --- a/src/Core/AdminConsole/Entities/Collection.cs +++ b/src/Core/AdminConsole/Entities/Collection.cs @@ -47,7 +47,7 @@ public class Collection : ITableObject /// public string? DefaultUserCollectionEmail { get; set; } /// - /// Reference to a that gates + /// Reference to a that gates /// PAM credential leasing for this collection. Null means leasing is disabled for the collection. /// public Guid? AccessRuleId { get; set; } diff --git a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs index 64b7910c7a6c..b9437448bcfc 100644 --- a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs +++ b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs @@ -40,9 +40,11 @@ using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; using Bit.Core.OrganizationFeatures.OrganizationUsers; using Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces; -using Bit.Core.PrivilegedAccessManagement.OrganizationFeatures.Commands; -using Bit.Core.PrivilegedAccessManagement.OrganizationFeatures.Commands.Interfaces; -using Bit.Core.PrivilegedAccessManagement.Services; +using Bit.Core.Pam.OrganizationFeatures.Commands; +using Bit.Core.Pam.OrganizationFeatures.Commands.Interfaces; +using Bit.Core.Pam.OrganizationFeatures.Queries; +using Bit.Core.Pam.OrganizationFeatures.Queries.Interfaces; +using Bit.Core.Pam.Services; using Bit.Core.Services; using Bit.Core.Settings; using Bit.Core.Tokens; @@ -197,6 +199,9 @@ public static void AddAccessRuleCommands(this IServiceCollection services) services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); } private static void AddOrganizationGroupCommands(this IServiceCollection services) diff --git a/src/Core/PrivilegedAccessManagement/Engine/AccessDecision.cs b/src/Core/Pam/Engine/AccessDecision.cs similarity index 95% rename from src/Core/PrivilegedAccessManagement/Engine/AccessDecision.cs rename to src/Core/Pam/Engine/AccessDecision.cs index 12a78cfd8937..90d848e9dd08 100644 --- a/src/Core/PrivilegedAccessManagement/Engine/AccessDecision.cs +++ b/src/Core/Pam/Engine/AccessDecision.cs @@ -1,4 +1,4 @@ -namespace Bit.Core.PrivilegedAccessManagement.Engine; +namespace Bit.Core.Pam.Engine; public enum DecisionKind { diff --git a/src/Core/PrivilegedAccessManagement/Engine/AccessRule.cs b/src/Core/Pam/Engine/AccessRule.cs similarity index 94% rename from src/Core/PrivilegedAccessManagement/Engine/AccessRule.cs rename to src/Core/Pam/Engine/AccessRule.cs index cf29f275a1e3..6e4a7f246bf0 100644 --- a/src/Core/PrivilegedAccessManagement/Engine/AccessRule.cs +++ b/src/Core/Pam/Engine/AccessRule.cs @@ -1,6 +1,6 @@ using Bit.Core.Vault.Models.Data; -namespace Bit.Core.PrivilegedAccessManagement.Engine; +namespace Bit.Core.Pam.Engine; public sealed record AccessRule { diff --git a/src/Core/PrivilegedAccessManagement/Engine/AccessRuleEngine.cs b/src/Core/Pam/Engine/AccessRuleEngine.cs similarity index 97% rename from src/Core/PrivilegedAccessManagement/Engine/AccessRuleEngine.cs rename to src/Core/Pam/Engine/AccessRuleEngine.cs index 0e3582d1a684..464d9f818c90 100644 --- a/src/Core/PrivilegedAccessManagement/Engine/AccessRuleEngine.cs +++ b/src/Core/Pam/Engine/AccessRuleEngine.cs @@ -1,7 +1,7 @@ -using Bit.Core.PrivilegedAccessManagement.Engine.Conditions; +using Bit.Core.Pam.Engine.Conditions; using Bit.Core.Vault.Models.Data; -namespace Bit.Core.PrivilegedAccessManagement.Engine; +namespace Bit.Core.Pam.Engine; public sealed class AccessRuleEngine( TimeProvider time, diff --git a/src/Core/PrivilegedAccessManagement/Engine/AccessRuleEngineContext.cs b/src/Core/Pam/Engine/AccessRuleEngineContext.cs similarity index 73% rename from src/Core/PrivilegedAccessManagement/Engine/AccessRuleEngineContext.cs rename to src/Core/Pam/Engine/AccessRuleEngineContext.cs index e2610c23f420..1cf0d960af85 100644 --- a/src/Core/PrivilegedAccessManagement/Engine/AccessRuleEngineContext.cs +++ b/src/Core/Pam/Engine/AccessRuleEngineContext.cs @@ -1,4 +1,4 @@ -namespace Bit.Core.PrivilegedAccessManagement.Engine; +namespace Bit.Core.Pam.Engine; public sealed class AccessRuleEngineContext { diff --git a/src/Core/PrivilegedAccessManagement/Engine/AccessRuleEngineResult.cs b/src/Core/Pam/Engine/AccessRuleEngineResult.cs similarity index 86% rename from src/Core/PrivilegedAccessManagement/Engine/AccessRuleEngineResult.cs rename to src/Core/Pam/Engine/AccessRuleEngineResult.cs index 29fdf874c801..19331e464261 100644 --- a/src/Core/PrivilegedAccessManagement/Engine/AccessRuleEngineResult.cs +++ b/src/Core/Pam/Engine/AccessRuleEngineResult.cs @@ -1,4 +1,4 @@ -namespace Bit.Core.PrivilegedAccessManagement.Engine; +namespace Bit.Core.Pam.Engine; public sealed record AccessRuleEngineResult(AccessOutcome Outcome, DenyReason Reason = DenyReason.None) { diff --git a/src/Core/PrivilegedAccessManagement/Engine/AccessRuleLease.cs b/src/Core/Pam/Engine/AccessRuleLease.cs similarity index 91% rename from src/Core/PrivilegedAccessManagement/Engine/AccessRuleLease.cs rename to src/Core/Pam/Engine/AccessRuleLease.cs index 7a16c0be2124..5bd5ebc9bf04 100644 --- a/src/Core/PrivilegedAccessManagement/Engine/AccessRuleLease.cs +++ b/src/Core/Pam/Engine/AccessRuleLease.cs @@ -1,6 +1,6 @@ using System.Diagnostics.CodeAnalysis; -namespace Bit.Core.PrivilegedAccessManagement.Engine; +namespace Bit.Core.Pam.Engine; public sealed class AccessRuleLease { diff --git a/src/Core/PrivilegedAccessManagement/Engine/AccessRuleRequest.cs b/src/Core/Pam/Engine/AccessRuleRequest.cs similarity index 93% rename from src/Core/PrivilegedAccessManagement/Engine/AccessRuleRequest.cs rename to src/Core/Pam/Engine/AccessRuleRequest.cs index 84010a09d407..6ea1063ae0e1 100644 --- a/src/Core/PrivilegedAccessManagement/Engine/AccessRuleRequest.cs +++ b/src/Core/Pam/Engine/AccessRuleRequest.cs @@ -1,6 +1,6 @@ using System.Diagnostics.CodeAnalysis; -namespace Bit.Core.PrivilegedAccessManagement.Engine; +namespace Bit.Core.Pam.Engine; public sealed class AccessRuleRequest { diff --git a/src/Core/PrivilegedAccessManagement/Engine/AccessRuleSignals.cs b/src/Core/Pam/Engine/AccessRuleSignals.cs similarity index 87% rename from src/Core/PrivilegedAccessManagement/Engine/AccessRuleSignals.cs rename to src/Core/Pam/Engine/AccessRuleSignals.cs index 7dd2ee21c522..8b57a44a97c8 100644 --- a/src/Core/PrivilegedAccessManagement/Engine/AccessRuleSignals.cs +++ b/src/Core/Pam/Engine/AccessRuleSignals.cs @@ -1,7 +1,7 @@ using System.Net; using Bit.Core.Enums; -namespace Bit.Core.PrivilegedAccessManagement.Engine; +namespace Bit.Core.Pam.Engine; public sealed class AccessRuleSignals { diff --git a/src/Core/PrivilegedAccessManagement/Engine/Conditions/HumanApprovalCondition.cs b/src/Core/Pam/Engine/Conditions/HumanApprovalCondition.cs similarity index 80% rename from src/Core/PrivilegedAccessManagement/Engine/Conditions/HumanApprovalCondition.cs rename to src/Core/Pam/Engine/Conditions/HumanApprovalCondition.cs index 051da16bf306..9774be03b2a3 100644 --- a/src/Core/PrivilegedAccessManagement/Engine/Conditions/HumanApprovalCondition.cs +++ b/src/Core/Pam/Engine/Conditions/HumanApprovalCondition.cs @@ -1,4 +1,4 @@ -namespace Bit.Core.PrivilegedAccessManagement.Engine.Conditions; +namespace Bit.Core.Pam.Engine.Conditions; public sealed class HumanApprovalCondition : IAccessCondition { diff --git a/src/Core/PrivilegedAccessManagement/Engine/Conditions/IpRangeCondition.cs b/src/Core/Pam/Engine/Conditions/IpRangeCondition.cs similarity index 90% rename from src/Core/PrivilegedAccessManagement/Engine/Conditions/IpRangeCondition.cs rename to src/Core/Pam/Engine/Conditions/IpRangeCondition.cs index 9b0975a62bb5..a9cfd597956d 100644 --- a/src/Core/PrivilegedAccessManagement/Engine/Conditions/IpRangeCondition.cs +++ b/src/Core/Pam/Engine/Conditions/IpRangeCondition.cs @@ -1,6 +1,6 @@ using System.Net; -namespace Bit.Core.PrivilegedAccessManagement.Engine.Conditions; +namespace Bit.Core.Pam.Engine.Conditions; public sealed class IpRangeCondition : IAccessCondition { diff --git a/src/Core/PrivilegedAccessManagement/Engine/Conditions/TimeOfDayCondition.cs b/src/Core/Pam/Engine/Conditions/TimeOfDayCondition.cs similarity index 94% rename from src/Core/PrivilegedAccessManagement/Engine/Conditions/TimeOfDayCondition.cs rename to src/Core/Pam/Engine/Conditions/TimeOfDayCondition.cs index 83191a078e4d..396d0d9ee4d6 100644 --- a/src/Core/PrivilegedAccessManagement/Engine/Conditions/TimeOfDayCondition.cs +++ b/src/Core/Pam/Engine/Conditions/TimeOfDayCondition.cs @@ -1,4 +1,4 @@ -namespace Bit.Core.PrivilegedAccessManagement.Engine.Conditions; +namespace Bit.Core.Pam.Engine.Conditions; public sealed class TimeOfDayCondition : IAccessCondition { diff --git a/src/Core/PrivilegedAccessManagement/Engine/ExchangeResult.cs b/src/Core/Pam/Engine/ExchangeResult.cs similarity index 91% rename from src/Core/PrivilegedAccessManagement/Engine/ExchangeResult.cs rename to src/Core/Pam/Engine/ExchangeResult.cs index 7b4cc68bcb91..e1bbeff8cfc1 100644 --- a/src/Core/PrivilegedAccessManagement/Engine/ExchangeResult.cs +++ b/src/Core/Pam/Engine/ExchangeResult.cs @@ -1,4 +1,4 @@ -namespace Bit.Core.PrivilegedAccessManagement.Engine; +namespace Bit.Core.Pam.Engine; public sealed record ExchangeResult( ExchangeOutcome Outcome, diff --git a/src/Core/PrivilegedAccessManagement/Engine/IAccessCondition.cs b/src/Core/Pam/Engine/IAccessCondition.cs similarity index 63% rename from src/Core/PrivilegedAccessManagement/Engine/IAccessCondition.cs rename to src/Core/Pam/Engine/IAccessCondition.cs index df321528e8ad..273ae82685f2 100644 --- a/src/Core/PrivilegedAccessManagement/Engine/IAccessCondition.cs +++ b/src/Core/Pam/Engine/IAccessCondition.cs @@ -1,4 +1,4 @@ -namespace Bit.Core.PrivilegedAccessManagement.Engine; +namespace Bit.Core.Pam.Engine; public interface IAccessCondition { diff --git a/src/Core/PrivilegedAccessManagement/Engine/RequestAccessResult.cs b/src/Core/Pam/Engine/RequestAccessResult.cs similarity index 94% rename from src/Core/PrivilegedAccessManagement/Engine/RequestAccessResult.cs rename to src/Core/Pam/Engine/RequestAccessResult.cs index b0b35699852d..bc99b5011bae 100644 --- a/src/Core/PrivilegedAccessManagement/Engine/RequestAccessResult.cs +++ b/src/Core/Pam/Engine/RequestAccessResult.cs @@ -1,4 +1,4 @@ -namespace Bit.Core.PrivilegedAccessManagement.Engine; +namespace Bit.Core.Pam.Engine; public sealed record RequestAccessResult( RequestAccessOutcome Outcome, diff --git a/src/Core/PrivilegedAccessManagement/Entities/AccessRule.cs b/src/Core/Pam/Entities/AccessRule.cs similarity index 94% rename from src/Core/PrivilegedAccessManagement/Entities/AccessRule.cs rename to src/Core/Pam/Entities/AccessRule.cs index 1790f6a55321..0a5e6f1d3812 100644 --- a/src/Core/PrivilegedAccessManagement/Entities/AccessRule.cs +++ b/src/Core/Pam/Entities/AccessRule.cs @@ -2,7 +2,7 @@ using Bit.Core.Entities; using Bit.Core.Utilities; -namespace Bit.Core.PrivilegedAccessManagement.Entities; +namespace Bit.Core.Pam.Entities; /// /// A reusable, org-scoped PAM access rule. Referenced by collections (and eventually Secrets Manager diff --git a/src/Core/Pam/Entities/Lease.cs b/src/Core/Pam/Entities/Lease.cs new file mode 100644 index 000000000000..425b28f75563 --- /dev/null +++ b/src/Core/Pam/Entities/Lease.cs @@ -0,0 +1,39 @@ +using Bit.Core.Entities; +using Bit.Core.Pam.Enums; +using Bit.Core.Utilities; + +namespace Bit.Core.Pam.Entities; + +/// +/// An active grant of access to a cipher, born from an approved . Only +/// leases inside their / window +/// authorize access. +/// +public class Lease : ITableObject +{ + public Guid Id { get; set; } + + /// + /// The request that birthed this lease. + /// + public Guid LeaseRequestId { get; set; } + + public Guid OrganizationId { get; set; } + public Guid CollectionId { get; set; } + public Guid CipherId { get; set; } + public Guid RequesterId { get; set; } + + public LeaseStatus Status { get; set; } + public DateTime NotBefore { get; set; } + public DateTime NotAfter { get; set; } + + public DateTime? RevokedDate { get; set; } + public Guid? RevokedBy { get; set; } + + public DateTime CreationDate { get; set; } = DateTime.UtcNow; + + public void SetNewId() + { + Id = CoreHelpers.GenerateComb(); + } +} diff --git a/src/Core/Pam/Entities/LeaseDecision.cs b/src/Core/Pam/Entities/LeaseDecision.cs new file mode 100644 index 000000000000..265e4ea1f164 --- /dev/null +++ b/src/Core/Pam/Entities/LeaseDecision.cs @@ -0,0 +1,49 @@ +using Bit.Core.Entities; +using Bit.Core.Pam.Enums; +using Bit.Core.Utilities; + +namespace Bit.Core.Pam.Entities; + +/// +/// A single decision on a . In v0 there is exactly one decision per request: an automated +/// verdict for auto-approval, or a +/// verdict once approver endpoints land. +/// +public class LeaseDecision : ITableObject +{ + public Guid Id { get; set; } + + public Guid LeaseRequestId { get; set; } + + public LeaseDecisionKind DeciderKind { get; set; } + + /// + /// The human approver. NULL when is . + /// + public Guid? ApproverId { get; set; } + + /// + /// The rule kind that decided (e.g. ip_allowlist). NULL when is + /// . + /// + public string? PolicyKind { get; set; } + + public LeaseDecisionVerdict Decision { get; set; } + + /// + /// Human comment, or a future policy reason string. + /// + public string? Comment { get; set; } + + /// + /// Forward-compatible snapshot of the inputs a policy saw. Null in this slice (no signals are evaluated). + /// + public string? EvaluationContext { get; set; } + + public DateTime CreationDate { get; set; } = DateTime.UtcNow; + + public void SetNewId() + { + Id = CoreHelpers.GenerateComb(); + } +} diff --git a/src/Core/Pam/Entities/LeaseRequest.cs b/src/Core/Pam/Entities/LeaseRequest.cs new file mode 100644 index 000000000000..c049185520d6 --- /dev/null +++ b/src/Core/Pam/Entities/LeaseRequest.cs @@ -0,0 +1,55 @@ +using Bit.Core.Entities; +using Bit.Core.Pam.Enums; +using Bit.Core.Utilities; + +namespace Bit.Core.Pam.Entities; + +/// +/// A request to lease access to a cipher in a leasing-governed collection. Auto-approved requests are created +/// already alongside an active ; requests that require +/// human approval are created and resolved later by an approver. +/// +public class LeaseRequest : ITableObject +{ + public Guid Id { get; set; } + + /// + /// NULL for original requests. Set only for extension requests, which point at the lease being extended. + /// + public Guid? LeaseId { get; set; } + + public Guid OrganizationId { get; set; } + public Guid CollectionId { get; set; } + public Guid CipherId { get; set; } + public Guid RequesterId { get; set; } + + /// + /// The requested access window. For automatic approval this is now; for human approval it is the + /// requester-supplied start. + /// + public DateTime NotBefore { get; set; } + + /// + /// The end of the requested access window. For automatic approval this is now + duration; for human + /// approval it is the requester-supplied end. + /// + public DateTime NotAfter { get; set; } + + /// + /// Optional for automatic approval, required for human approval (enforced in the command). + /// + public string? Reason { get; set; } + + public LeaseRequestStatus Status { get; set; } + public DateTime CreationDate { get; set; } = DateTime.UtcNow; + + /// + /// Set when the request leaves . + /// + public DateTime? ResolvedDate { get; set; } + + public void SetNewId() + { + Id = CoreHelpers.GenerateComb(); + } +} diff --git a/src/Core/Pam/Enums/AccessApprovalOutcome.cs b/src/Core/Pam/Enums/AccessApprovalOutcome.cs new file mode 100644 index 000000000000..354e9c530440 --- /dev/null +++ b/src/Core/Pam/Enums/AccessApprovalOutcome.cs @@ -0,0 +1,11 @@ +namespace Bit.Core.Pam.Enums; + +/// +/// The approval path a lease request will take, surfaced by the pre-check so the client can present the right +/// workflow: (pick a duration) or (pick a window + justify). +/// +public enum AccessApprovalOutcome +{ + Automatic, + Human, +} diff --git a/src/Core/Pam/Enums/LeaseDecisionKind.cs b/src/Core/Pam/Enums/LeaseDecisionKind.cs new file mode 100644 index 000000000000..4744fdf9cffa --- /dev/null +++ b/src/Core/Pam/Enums/LeaseDecisionKind.cs @@ -0,0 +1,19 @@ +namespace Bit.Core.Pam.Enums; + +/// +/// Who made a : an automated policy evaluation or a human approver. +/// +public enum LeaseDecisionKind : byte +{ + Policy = 0, + Human = 1, +} + +/// +/// The verdict recorded on a . +/// +public enum LeaseDecisionVerdict : byte +{ + Approve = 0, + Deny = 1, +} diff --git a/src/Core/Pam/Enums/LeaseRequestStatus.cs b/src/Core/Pam/Enums/LeaseRequestStatus.cs new file mode 100644 index 000000000000..ad1d5e7f0b18 --- /dev/null +++ b/src/Core/Pam/Enums/LeaseRequestStatus.cs @@ -0,0 +1,14 @@ +namespace Bit.Core.Pam.Enums; + +/// +/// Lifecycle of a . A request starts and moves to exactly +/// one terminal state. Auto-approved requests are created already . +/// +public enum LeaseRequestStatus : byte +{ + Pending = 0, + Approved = 1, + Denied = 2, + Cancelled = 3, + ExpiredUnanswered = 4, +} diff --git a/src/Core/Pam/Enums/LeaseStatus.cs b/src/Core/Pam/Enums/LeaseStatus.cs new file mode 100644 index 000000000000..bf6a9e828029 --- /dev/null +++ b/src/Core/Pam/Enums/LeaseStatus.cs @@ -0,0 +1,11 @@ +namespace Bit.Core.Pam.Enums; + +/// +/// Lifecycle of a . Only leases authorize access. +/// +public enum LeaseStatus : byte +{ + Active = 0, + Expired = 1, + Revoked = 2, +} diff --git a/src/Core/Pam/Models/AccessApprovalResolution.cs b/src/Core/Pam/Models/AccessApprovalResolution.cs new file mode 100644 index 000000000000..f320fa0598b2 --- /dev/null +++ b/src/Core/Pam/Models/AccessApprovalResolution.cs @@ -0,0 +1,11 @@ +namespace Bit.Core.Pam.Models; + +/// +/// The leasing context that governs a cipher for a particular caller: which collection's access rule applies, the +/// owning organization, and whether that rule requires human approval. A null resolution means the cipher is not +/// leasing-gated for the caller. +/// +public sealed record AccessApprovalResolution( + Guid OrganizationId, + Guid CollectionId, + bool RequiresHumanApproval); diff --git a/src/Core/Pam/Models/AccessPreCheckResult.cs b/src/Core/Pam/Models/AccessPreCheckResult.cs new file mode 100644 index 000000000000..90d6a43d1e75 --- /dev/null +++ b/src/Core/Pam/Models/AccessPreCheckResult.cs @@ -0,0 +1,9 @@ +using Bit.Core.Pam.Enums; + +namespace Bit.Core.Pam.Models; + +/// +/// The result of a pre-check: whether requesting access to a cipher would be approved automatically or require human +/// approval. +/// +public sealed record AccessPreCheckResult(AccessApprovalOutcome Outcome); diff --git a/src/Core/Pam/Models/AccessRequestResult.cs b/src/Core/Pam/Models/AccessRequestResult.cs new file mode 100644 index 000000000000..193ff6e58118 --- /dev/null +++ b/src/Core/Pam/Models/AccessRequestResult.cs @@ -0,0 +1,21 @@ +using Bit.Core.Pam.Entities; +using Bit.Core.Pam.Enums; + +namespace Bit.Core.Pam.Models; + +/// +/// The result of submitting an access request. On the path a +/// is issued immediately; on the path a pending +/// is created to await an approver. +/// +public sealed record AccessRequestResult( + AccessApprovalOutcome Outcome, + Lease? Lease = null, + LeaseRequest? Request = null) +{ + public static AccessRequestResult Automatic(Lease lease) => + new(AccessApprovalOutcome.Automatic, Lease: lease); + + public static AccessRequestResult Human(LeaseRequest request) => + new(AccessApprovalOutcome.Human, Request: request); +} diff --git a/src/Core/Pam/Models/AccessRequestSubmission.cs b/src/Core/Pam/Models/AccessRequestSubmission.cs new file mode 100644 index 000000000000..a135051d74b4 --- /dev/null +++ b/src/Core/Pam/Models/AccessRequestSubmission.cs @@ -0,0 +1,14 @@ +namespace Bit.Core.Pam.Models; + +/// +/// A request to lease a cipher. The automatic path supplies (and an optional +/// ); the human path supplies a / window and a required +/// . The command validates the shape against the cipher's resolved approval outcome. +/// +public sealed class AccessRequestSubmission +{ + public int? DurationSeconds { get; init; } + public DateTime? Start { get; init; } + public DateTime? End { get; init; } + public string? Reason { get; init; } +} diff --git a/src/Core/PrivilegedAccessManagement/Models/AccessRuleDetails.cs b/src/Core/Pam/Models/AccessRuleDetails.cs similarity index 85% rename from src/Core/PrivilegedAccessManagement/Models/AccessRuleDetails.cs rename to src/Core/Pam/Models/AccessRuleDetails.cs index 50ea70a74dfd..1255fb391fa5 100644 --- a/src/Core/PrivilegedAccessManagement/Models/AccessRuleDetails.cs +++ b/src/Core/Pam/Models/AccessRuleDetails.cs @@ -1,6 +1,6 @@ -using Bit.Core.PrivilegedAccessManagement.Entities; +using Bit.Core.Pam.Entities; -namespace Bit.Core.PrivilegedAccessManagement.Models; +namespace Bit.Core.Pam.Models; /// /// An together with the IDs of the collections it governs. diff --git a/src/Core/PrivilegedAccessManagement/Models/Rules/AllOfRule.cs b/src/Core/Pam/Models/Rules/AllOfRule.cs similarity index 75% rename from src/Core/PrivilegedAccessManagement/Models/Rules/AllOfRule.cs rename to src/Core/Pam/Models/Rules/AllOfRule.cs index 99f6eb68dbdd..9b5763e2aa74 100644 --- a/src/Core/PrivilegedAccessManagement/Models/Rules/AllOfRule.cs +++ b/src/Core/Pam/Models/Rules/AllOfRule.cs @@ -1,4 +1,4 @@ -namespace Bit.Core.PrivilegedAccessManagement.Models.Rules; +namespace Bit.Core.Pam.Models.Rules; /// /// Composite rule that approves only when every child rule approves. diff --git a/src/Core/PrivilegedAccessManagement/Models/Rules/HumanApprovalRule.cs b/src/Core/Pam/Models/Rules/HumanApprovalRule.cs similarity index 69% rename from src/Core/PrivilegedAccessManagement/Models/Rules/HumanApprovalRule.cs rename to src/Core/Pam/Models/Rules/HumanApprovalRule.cs index d2aa6c5f5b2c..15aa3ae4b4dd 100644 --- a/src/Core/PrivilegedAccessManagement/Models/Rules/HumanApprovalRule.cs +++ b/src/Core/Pam/Models/Rules/HumanApprovalRule.cs @@ -1,4 +1,4 @@ -namespace Bit.Core.PrivilegedAccessManagement.Models.Rules; +namespace Bit.Core.Pam.Models.Rules; /// /// Always requires a human decision before a lease can be issued. diff --git a/src/Core/PrivilegedAccessManagement/Models/Rules/IpAllowlistRule.cs b/src/Core/Pam/Models/Rules/IpAllowlistRule.cs similarity index 78% rename from src/Core/PrivilegedAccessManagement/Models/Rules/IpAllowlistRule.cs rename to src/Core/Pam/Models/Rules/IpAllowlistRule.cs index 5aa85e5e13c0..f104ad4b8ae0 100644 --- a/src/Core/PrivilegedAccessManagement/Models/Rules/IpAllowlistRule.cs +++ b/src/Core/Pam/Models/Rules/IpAllowlistRule.cs @@ -1,4 +1,4 @@ -namespace Bit.Core.PrivilegedAccessManagement.Models.Rules; +namespace Bit.Core.Pam.Models.Rules; /// /// Auto-approves a lease when the requester's IP matches a listed CIDR; otherwise denies. diff --git a/src/Core/PrivilegedAccessManagement/Models/Rules/Rule.cs b/src/Core/Pam/Models/Rules/Rule.cs similarity index 91% rename from src/Core/PrivilegedAccessManagement/Models/Rules/Rule.cs rename to src/Core/Pam/Models/Rules/Rule.cs index 5ebc2e3cd5d8..4d372fad4652 100644 --- a/src/Core/PrivilegedAccessManagement/Models/Rules/Rule.cs +++ b/src/Core/Pam/Models/Rules/Rule.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace Bit.Core.PrivilegedAccessManagement.Models.Rules; +namespace Bit.Core.Pam.Models.Rules; /// /// Base type for the structured rule stored on AccessRule.Rule. diff --git a/src/Core/PrivilegedAccessManagement/Models/Rules/TimeOfDayRule.cs b/src/Core/Pam/Models/Rules/TimeOfDayRule.cs similarity index 89% rename from src/Core/PrivilegedAccessManagement/Models/Rules/TimeOfDayRule.cs rename to src/Core/Pam/Models/Rules/TimeOfDayRule.cs index 3733b8ac46ac..d6a1d29a4439 100644 --- a/src/Core/PrivilegedAccessManagement/Models/Rules/TimeOfDayRule.cs +++ b/src/Core/Pam/Models/Rules/TimeOfDayRule.cs @@ -1,4 +1,4 @@ -namespace Bit.Core.PrivilegedAccessManagement.Models.Rules; +namespace Bit.Core.Pam.Models.Rules; /// /// Auto-approves a lease when the request falls inside one of the configured windows, evaluated in diff --git a/src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/CreateAccessRuleCommand.cs b/src/Core/Pam/OrganizationFeatures/Commands/CreateAccessRuleCommand.cs similarity index 89% rename from src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/CreateAccessRuleCommand.cs rename to src/Core/Pam/OrganizationFeatures/Commands/CreateAccessRuleCommand.cs index 78a6ab2abb7c..74ff12503488 100644 --- a/src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/CreateAccessRuleCommand.cs +++ b/src/Core/Pam/OrganizationFeatures/Commands/CreateAccessRuleCommand.cs @@ -1,13 +1,13 @@ using Bit.Core.Entities; using Bit.Core.Exceptions; -using Bit.Core.PrivilegedAccessManagement.Entities; -using Bit.Core.PrivilegedAccessManagement.Models; -using Bit.Core.PrivilegedAccessManagement.OrganizationFeatures.Commands.Interfaces; -using Bit.Core.PrivilegedAccessManagement.Repositories; -using Bit.Core.PrivilegedAccessManagement.Services; +using Bit.Core.Pam.Entities; +using Bit.Core.Pam.Models; +using Bit.Core.Pam.OrganizationFeatures.Commands.Interfaces; +using Bit.Core.Pam.Repositories; +using Bit.Core.Pam.Services; using Bit.Core.Repositories; -namespace Bit.Core.PrivilegedAccessManagement.OrganizationFeatures.Commands; +namespace Bit.Core.Pam.OrganizationFeatures.Commands; public class CreateAccessRuleCommand : ICreateAccessRuleCommand { diff --git a/src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/DeleteAccessRuleCommand.cs b/src/Core/Pam/OrganizationFeatures/Commands/DeleteAccessRuleCommand.cs similarity index 73% rename from src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/DeleteAccessRuleCommand.cs rename to src/Core/Pam/OrganizationFeatures/Commands/DeleteAccessRuleCommand.cs index 64f06317f0cc..4e5c1d6e5931 100644 --- a/src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/DeleteAccessRuleCommand.cs +++ b/src/Core/Pam/OrganizationFeatures/Commands/DeleteAccessRuleCommand.cs @@ -1,8 +1,8 @@ using Bit.Core.Exceptions; -using Bit.Core.PrivilegedAccessManagement.OrganizationFeatures.Commands.Interfaces; -using Bit.Core.PrivilegedAccessManagement.Repositories; +using Bit.Core.Pam.OrganizationFeatures.Commands.Interfaces; +using Bit.Core.Pam.Repositories; -namespace Bit.Core.PrivilegedAccessManagement.OrganizationFeatures.Commands; +namespace Bit.Core.Pam.OrganizationFeatures.Commands; public class DeleteAccessRuleCommand : IDeleteAccessRuleCommand { diff --git a/src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/Interfaces/ICreateAccessRuleCommand.cs b/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/ICreateAccessRuleCommand.cs similarity index 57% rename from src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/Interfaces/ICreateAccessRuleCommand.cs rename to src/Core/Pam/OrganizationFeatures/Commands/Interfaces/ICreateAccessRuleCommand.cs index b11661e81e71..6ae3a7b53542 100644 --- a/src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/Interfaces/ICreateAccessRuleCommand.cs +++ b/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/ICreateAccessRuleCommand.cs @@ -1,7 +1,7 @@ -using Bit.Core.PrivilegedAccessManagement.Entities; -using Bit.Core.PrivilegedAccessManagement.Models; +using Bit.Core.Pam.Entities; +using Bit.Core.Pam.Models; -namespace Bit.Core.PrivilegedAccessManagement.OrganizationFeatures.Commands.Interfaces; +namespace Bit.Core.Pam.OrganizationFeatures.Commands.Interfaces; public interface ICreateAccessRuleCommand { diff --git a/src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/Interfaces/IDeleteAccessRuleCommand.cs b/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IDeleteAccessRuleCommand.cs similarity index 52% rename from src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/Interfaces/IDeleteAccessRuleCommand.cs rename to src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IDeleteAccessRuleCommand.cs index 0b0efbdf1ce6..0d23dd73f096 100644 --- a/src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/Interfaces/IDeleteAccessRuleCommand.cs +++ b/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IDeleteAccessRuleCommand.cs @@ -1,4 +1,4 @@ -namespace Bit.Core.PrivilegedAccessManagement.OrganizationFeatures.Commands.Interfaces; +namespace Bit.Core.Pam.OrganizationFeatures.Commands.Interfaces; public interface IDeleteAccessRuleCommand { diff --git a/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IRequestAccessCommand.cs b/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IRequestAccessCommand.cs new file mode 100644 index 000000000000..a64aaac2de06 --- /dev/null +++ b/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IRequestAccessCommand.cs @@ -0,0 +1,12 @@ +using Bit.Core.Pam.Models; + +namespace Bit.Core.Pam.OrganizationFeatures.Commands.Interfaces; + +public interface IRequestAccessCommand +{ + /// + /// Submits a request to lease a cipher. On the automatic path a lease is issued immediately; on the human path a + /// pending request is created. The submission's shape is validated against the cipher's resolved approval outcome. + /// + Task RequestAccessAsync(Guid userId, Guid cipherId, AccessRequestSubmission submission); +} diff --git a/src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/Interfaces/IUpdateAccessRuleCommand.cs b/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IUpdateAccessRuleCommand.cs similarity index 62% rename from src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/Interfaces/IUpdateAccessRuleCommand.cs rename to src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IUpdateAccessRuleCommand.cs index e75f0d4bfcb4..a53a57a85977 100644 --- a/src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/Interfaces/IUpdateAccessRuleCommand.cs +++ b/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IUpdateAccessRuleCommand.cs @@ -1,7 +1,7 @@ -using Bit.Core.PrivilegedAccessManagement.Entities; -using Bit.Core.PrivilegedAccessManagement.Models; +using Bit.Core.Pam.Entities; +using Bit.Core.Pam.Models; -namespace Bit.Core.PrivilegedAccessManagement.OrganizationFeatures.Commands.Interfaces; +namespace Bit.Core.Pam.OrganizationFeatures.Commands.Interfaces; public interface IUpdateAccessRuleCommand { diff --git a/src/Core/Pam/OrganizationFeatures/Commands/RequestAccessCommand.cs b/src/Core/Pam/OrganizationFeatures/Commands/RequestAccessCommand.cs new file mode 100644 index 000000000000..fde75b65cd50 --- /dev/null +++ b/src/Core/Pam/OrganizationFeatures/Commands/RequestAccessCommand.cs @@ -0,0 +1,179 @@ +using Bit.Core.Exceptions; +using Bit.Core.Pam.Entities; +using Bit.Core.Pam.Enums; +using Bit.Core.Pam.Models; +using Bit.Core.Pam.OrganizationFeatures.Commands.Interfaces; +using Bit.Core.Pam.Repositories; +using Bit.Core.Pam.Services; +using Bit.Core.Vault.Repositories; + +namespace Bit.Core.Pam.OrganizationFeatures.Commands; + +public class RequestAccessCommand : IRequestAccessCommand +{ + /// + /// The maximum lease window length, applied to both the automatic duration and the human-requested window. + /// Global for v0; per-rule configuration is a later concern. + /// + public const int MaxDurationSeconds = 24 * 60 * 60; + + private readonly ICipherRepository _cipherRepository; + private readonly IAccessApprovalResolver _resolver; + private readonly ILeaseRepository _leaseRepository; + private readonly ILeaseRequestRepository _leaseRequestRepository; + private readonly TimeProvider _timeProvider; + + public RequestAccessCommand( + ICipherRepository cipherRepository, + IAccessApprovalResolver resolver, + ILeaseRepository leaseRepository, + ILeaseRequestRepository leaseRequestRepository, + TimeProvider timeProvider) + { + _cipherRepository = cipherRepository; + _resolver = resolver; + _leaseRepository = leaseRepository; + _leaseRequestRepository = leaseRequestRepository; + _timeProvider = timeProvider; + } + + public async Task RequestAccessAsync(Guid userId, Guid cipherId, AccessRequestSubmission submission) + { + var cipher = await _cipherRepository.GetByIdAsync(cipherId, userId); + if (cipher is null) + { + throw new NotFoundException(); + } + + var resolution = await _resolver.ResolveAsync(userId, cipherId); + if (resolution is null) + { + throw new BadRequestException("This item does not require a lease."); + } + + var now = _timeProvider.GetUtcNow().UtcDateTime; + + if (await _leaseRepository.GetActiveByRequesterIdCipherIdAsync(userId, cipherId, now) is not null) + { + throw new BadRequestException("You already have active access to this item."); + } + + if (await _leaseRequestRepository.GetActivePendingByRequesterIdCipherIdAsync(userId, cipherId) is not null) + { + throw new BadRequestException("You already have a pending request for this item."); + } + + return resolution.RequiresHumanApproval + ? await RequestHumanApprovalAsync(userId, cipherId, resolution, submission) + : await IssueAutomaticLeaseAsync(userId, cipherId, resolution, submission, now); + } + + private async Task IssueAutomaticLeaseAsync( + Guid userId, Guid cipherId, AccessApprovalResolution resolution, AccessRequestSubmission submission, DateTime now) + { + if (submission.Start.HasValue || submission.End.HasValue) + { + throw new BadRequestException("This item is approved automatically; provide a duration, not a window."); + } + + if (submission.DurationSeconds is not { } durationSeconds || durationSeconds <= 0) + { + throw new BadRequestException("A positive duration is required."); + } + + if (durationSeconds > MaxDurationSeconds) + { + throw new BadRequestException($"The requested duration exceeds the maximum of {MaxDurationSeconds} seconds."); + } + + var notAfter = now.AddSeconds(durationSeconds); + + var request = new LeaseRequest + { + OrganizationId = resolution.OrganizationId, + CollectionId = resolution.CollectionId, + CipherId = cipherId, + RequesterId = userId, + NotBefore = now, + NotAfter = notAfter, + Reason = string.IsNullOrWhiteSpace(submission.Reason) ? null : submission.Reason, + Status = LeaseRequestStatus.Approved, + CreationDate = now, + ResolvedDate = now, + }; + request.SetNewId(); + + var decision = new LeaseDecision + { + LeaseRequestId = request.Id, + DeciderKind = LeaseDecisionKind.Policy, + Decision = LeaseDecisionVerdict.Approve, + CreationDate = now, + }; + decision.SetNewId(); + + var lease = new Lease + { + LeaseRequestId = request.Id, + OrganizationId = resolution.OrganizationId, + CollectionId = resolution.CollectionId, + CipherId = cipherId, + RequesterId = userId, + Status = LeaseStatus.Active, + NotBefore = now, + NotAfter = notAfter, + CreationDate = now, + }; + lease.SetNewId(); + + await _leaseRepository.CreateAutoApprovedAsync(request, decision, lease, now); + + return AccessRequestResult.Automatic(lease); + } + + private async Task RequestHumanApprovalAsync( + Guid userId, Guid cipherId, AccessApprovalResolution resolution, AccessRequestSubmission submission) + { + if (submission.DurationSeconds.HasValue) + { + throw new BadRequestException("This item requires human approval; provide a start and end date, not a duration."); + } + + if (string.IsNullOrWhiteSpace(submission.Reason)) + { + throw new BadRequestException("A reason is required for items that need human approval."); + } + + if (submission.Start is not { } start || submission.End is not { } end) + { + throw new BadRequestException("A start and end date are required."); + } + + if (start >= end) + { + throw new BadRequestException("The start date must be before the end date."); + } + + if ((end - start).TotalSeconds > MaxDurationSeconds) + { + throw new BadRequestException($"The requested window exceeds the maximum of {MaxDurationSeconds} seconds."); + } + + var now = _timeProvider.GetUtcNow().UtcDateTime; + var request = new LeaseRequest + { + OrganizationId = resolution.OrganizationId, + CollectionId = resolution.CollectionId, + CipherId = cipherId, + RequesterId = userId, + NotBefore = start, + NotAfter = end, + Reason = submission.Reason, + Status = LeaseRequestStatus.Pending, + CreationDate = now, + }; + + var created = await _leaseRequestRepository.CreateAsync(request); + return AccessRequestResult.Human(created); + } +} diff --git a/src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/UpdateAccessRuleCommand.cs b/src/Core/Pam/OrganizationFeatures/Commands/UpdateAccessRuleCommand.cs similarity index 90% rename from src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/UpdateAccessRuleCommand.cs rename to src/Core/Pam/OrganizationFeatures/Commands/UpdateAccessRuleCommand.cs index d66e69c6994e..16b34e367d70 100644 --- a/src/Core/PrivilegedAccessManagement/OrganizationFeatures/Commands/UpdateAccessRuleCommand.cs +++ b/src/Core/Pam/OrganizationFeatures/Commands/UpdateAccessRuleCommand.cs @@ -1,12 +1,12 @@ using Bit.Core.Exceptions; -using Bit.Core.PrivilegedAccessManagement.Entities; -using Bit.Core.PrivilegedAccessManagement.Models; -using Bit.Core.PrivilegedAccessManagement.OrganizationFeatures.Commands.Interfaces; -using Bit.Core.PrivilegedAccessManagement.Repositories; -using Bit.Core.PrivilegedAccessManagement.Services; +using Bit.Core.Pam.Entities; +using Bit.Core.Pam.Models; +using Bit.Core.Pam.OrganizationFeatures.Commands.Interfaces; +using Bit.Core.Pam.Repositories; +using Bit.Core.Pam.Services; using Bit.Core.Repositories; -namespace Bit.Core.PrivilegedAccessManagement.OrganizationFeatures.Commands; +namespace Bit.Core.Pam.OrganizationFeatures.Commands; public class UpdateAccessRuleCommand : IUpdateAccessRuleCommand { diff --git a/src/Core/Pam/OrganizationFeatures/Queries/AccessPreCheckQuery.cs b/src/Core/Pam/OrganizationFeatures/Queries/AccessPreCheckQuery.cs new file mode 100644 index 000000000000..f8dff511e0a0 --- /dev/null +++ b/src/Core/Pam/OrganizationFeatures/Queries/AccessPreCheckQuery.cs @@ -0,0 +1,37 @@ +using Bit.Core.Exceptions; +using Bit.Core.Pam.Enums; +using Bit.Core.Pam.Models; +using Bit.Core.Pam.OrganizationFeatures.Queries.Interfaces; +using Bit.Core.Pam.Services; +using Bit.Core.Vault.Repositories; + +namespace Bit.Core.Pam.OrganizationFeatures.Queries; + +public class AccessPreCheckQuery : IAccessPreCheckQuery +{ + private readonly ICipherRepository _cipherRepository; + private readonly IAccessApprovalResolver _resolver; + + public AccessPreCheckQuery(ICipherRepository cipherRepository, IAccessApprovalResolver resolver) + { + _cipherRepository = cipherRepository; + _resolver = resolver; + } + + public async Task PreCheckAsync(Guid userId, Guid cipherId) + { + // GetByIdAsync filters by access, so a null result means the caller cannot see the cipher. + var cipher = await _cipherRepository.GetByIdAsync(cipherId, userId); + if (cipher is null) + { + throw new NotFoundException(); + } + + var resolution = await _resolver.ResolveAsync(userId, cipherId); + var outcome = resolution?.RequiresHumanApproval == true + ? AccessApprovalOutcome.Human + : AccessApprovalOutcome.Automatic; + + return new AccessPreCheckResult(outcome); + } +} diff --git a/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IAccessPreCheckQuery.cs b/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IAccessPreCheckQuery.cs new file mode 100644 index 000000000000..56640d2d7986 --- /dev/null +++ b/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IAccessPreCheckQuery.cs @@ -0,0 +1,12 @@ +using Bit.Core.Pam.Models; + +namespace Bit.Core.Pam.OrganizationFeatures.Queries.Interfaces; + +public interface IAccessPreCheckQuery +{ + /// + /// Determines, without any side effects, whether the caller requesting access to the cipher would be approved + /// automatically or would require human approval. + /// + Task PreCheckAsync(Guid userId, Guid cipherId); +} diff --git a/src/Core/PrivilegedAccessManagement/Repositories/IAccessRuleRepository.cs b/src/Core/Pam/Repositories/IAccessRuleRepository.cs similarity index 89% rename from src/Core/PrivilegedAccessManagement/Repositories/IAccessRuleRepository.cs rename to src/Core/Pam/Repositories/IAccessRuleRepository.cs index f8891483fdb5..c2fc29ea314e 100644 --- a/src/Core/PrivilegedAccessManagement/Repositories/IAccessRuleRepository.cs +++ b/src/Core/Pam/Repositories/IAccessRuleRepository.cs @@ -1,8 +1,8 @@ -using Bit.Core.PrivilegedAccessManagement.Entities; -using Bit.Core.PrivilegedAccessManagement.Models; +using Bit.Core.Pam.Entities; +using Bit.Core.Pam.Models; using Bit.Core.Repositories; -namespace Bit.Core.PrivilegedAccessManagement.Repositories; +namespace Bit.Core.Pam.Repositories; public interface IAccessRuleRepository : IRepository { diff --git a/src/Core/Pam/Repositories/ILeaseRepository.cs b/src/Core/Pam/Repositories/ILeaseRepository.cs new file mode 100644 index 000000000000..ac4bd1437a71 --- /dev/null +++ b/src/Core/Pam/Repositories/ILeaseRepository.cs @@ -0,0 +1,20 @@ +using Bit.Core.Pam.Entities; + +namespace Bit.Core.Pam.Repositories; + +public interface ILeaseRepository +{ + Task GetByIdAsync(Guid id); + + /// + /// Returns the caller's active lease for the cipher whose window contains , or null. + /// + Task GetActiveByRequesterIdCipherIdAsync(Guid requesterId, Guid cipherId, DateTime now); + + /// + /// Atomically creates an auto-approved , its policy , and an + /// active in a single transaction. The three entities must already have their ids assigned. + /// This is the only way a is created, so the request, decision, and lease never diverge. + /// + Task CreateAutoApprovedAsync(LeaseRequest request, LeaseDecision decision, Lease lease, DateTime now); +} diff --git a/src/Core/Pam/Repositories/ILeaseRequestRepository.cs b/src/Core/Pam/Repositories/ILeaseRequestRepository.cs new file mode 100644 index 000000000000..8ff1bb690a63 --- /dev/null +++ b/src/Core/Pam/Repositories/ILeaseRequestRepository.cs @@ -0,0 +1,15 @@ +using Bit.Core.Pam.Entities; + +namespace Bit.Core.Pam.Repositories; + +public interface ILeaseRequestRepository +{ + Task CreateAsync(LeaseRequest request); + + Task GetByIdAsync(Guid id); + + /// + /// Returns the caller's pending (unresolved) lease request for the cipher, or null if there is none. + /// + Task GetActivePendingByRequesterIdCipherIdAsync(Guid requesterId, Guid cipherId); +} diff --git a/src/Core/Pam/Services/AccessApprovalResolver.cs b/src/Core/Pam/Services/AccessApprovalResolver.cs new file mode 100644 index 000000000000..0c317f5b63a3 --- /dev/null +++ b/src/Core/Pam/Services/AccessApprovalResolver.cs @@ -0,0 +1,93 @@ +using System.Text.Json; +using Bit.Core.Pam.Models; +using Bit.Core.Pam.Models.Rules; +using Bit.Core.Pam.Repositories; +using Bit.Core.Repositories; + +namespace Bit.Core.Pam.Services; + +public class AccessApprovalResolver : IAccessApprovalResolver +{ + private static readonly JsonSerializerOptions _jsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + }; + + private readonly ICollectionCipherRepository _collectionCipherRepository; + private readonly ICollectionRepository _collectionRepository; + private readonly IAccessRuleRepository _accessRuleRepository; + + public AccessApprovalResolver( + ICollectionCipherRepository collectionCipherRepository, + ICollectionRepository collectionRepository, + IAccessRuleRepository accessRuleRepository) + { + _collectionCipherRepository = collectionCipherRepository; + _collectionRepository = collectionRepository; + _accessRuleRepository = accessRuleRepository; + } + + public async Task ResolveAsync(Guid userId, Guid cipherId) + { + var collectionCiphers = await _collectionCipherRepository.GetManyByUserIdCipherIdAsync(userId, cipherId); + if (collectionCiphers.Count == 0) + { + return null; + } + + var collectionIds = collectionCiphers.Select(cc => cc.CollectionId).ToHashSet(); + var collections = await _collectionRepository.GetManyByManyIdsAsync(collectionIds); + + // Deterministic order so the chosen governing collection is stable across calls. + var governed = collections + .Where(c => collectionIds.Contains(c.Id) && c.AccessRuleId.HasValue) + .OrderBy(c => c.Id); + + AccessApprovalResolution? automatic = null; + foreach (var collection in governed) + { + var rule = await _accessRuleRepository.GetByIdAsync(collection.AccessRuleId!.Value); + if (rule is null) + { + continue; + } + + if (RequiresHumanApproval(rule.Rule)) + { + // Most restrictive wins — return as soon as a human-approval rule is found. + return new AccessApprovalResolution(collection.OrganizationId, collection.Id, true); + } + + automatic ??= new AccessApprovalResolution(collection.OrganizationId, collection.Id, false); + } + + return automatic; + } + + /// + /// True when the rule tree contains a human-approval node. A malformed or unparseable rule fails safe to true so + /// access is never silently auto-approved on a rule the server could not understand. + /// + private static bool RequiresHumanApproval(string ruleJson) + { + Rule? rule; + try + { + rule = JsonSerializer.Deserialize(ruleJson, _jsonOptions); + } + catch (JsonException) + { + return true; + } + + return rule is null || ContainsHumanApproval(rule); + } + + private static bool ContainsHumanApproval(Rule rule) => rule switch + { + HumanApprovalRule => true, + AllOfRule all => all.Rules.Any(ContainsHumanApproval), + _ => false, + }; +} diff --git a/src/Core/PrivilegedAccessManagement/Services/AccessRuleValidator.cs b/src/Core/Pam/Services/AccessRuleValidator.cs similarity index 97% rename from src/Core/PrivilegedAccessManagement/Services/AccessRuleValidator.cs rename to src/Core/Pam/Services/AccessRuleValidator.cs index 3d0163f0441f..574f475cf15d 100644 --- a/src/Core/PrivilegedAccessManagement/Services/AccessRuleValidator.cs +++ b/src/Core/Pam/Services/AccessRuleValidator.cs @@ -1,9 +1,9 @@ using System.Net; using System.Text.Json; using System.Text.RegularExpressions; -using Bit.Core.PrivilegedAccessManagement.Models.Rules; +using Bit.Core.Pam.Models.Rules; -namespace Bit.Core.PrivilegedAccessManagement.Services; +namespace Bit.Core.Pam.Services; public sealed partial class AccessRuleValidator : IAccessRuleValidator { diff --git a/src/Core/Pam/Services/IAccessApprovalResolver.cs b/src/Core/Pam/Services/IAccessApprovalResolver.cs new file mode 100644 index 000000000000..583ed8c10301 --- /dev/null +++ b/src/Core/Pam/Services/IAccessApprovalResolver.cs @@ -0,0 +1,13 @@ +using Bit.Core.Pam.Models; + +namespace Bit.Core.Pam.Services; + +public interface IAccessApprovalResolver +{ + /// + /// Resolves the leasing context that governs for the caller, or null when the cipher + /// is not leasing-gated for them (no reachable collection carries an access rule). When more than one governing + /// collection applies, the most restrictive (human-approval) one wins. + /// + Task ResolveAsync(Guid userId, Guid cipherId); +} diff --git a/src/Core/PrivilegedAccessManagement/Services/IAccessRuleValidator.cs b/src/Core/Pam/Services/IAccessRuleValidator.cs similarity index 90% rename from src/Core/PrivilegedAccessManagement/Services/IAccessRuleValidator.cs rename to src/Core/Pam/Services/IAccessRuleValidator.cs index a8b779281270..585fb800c614 100644 --- a/src/Core/PrivilegedAccessManagement/Services/IAccessRuleValidator.cs +++ b/src/Core/Pam/Services/IAccessRuleValidator.cs @@ -1,4 +1,4 @@ -namespace Bit.Core.PrivilegedAccessManagement.Services; +namespace Bit.Core.Pam.Services; public interface IAccessRuleValidator { diff --git a/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs b/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs index d12981f3b0f8..fa15292b4923 100644 --- a/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs +++ b/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs @@ -8,8 +8,8 @@ using Bit.Core.Dirt.Repositories; using Bit.Core.KeyManagement.Repositories; using Bit.Core.NotificationCenter.Repositories; +using Bit.Core.Pam.Repositories; using Bit.Core.Platform.Installations; -using Bit.Core.PrivilegedAccessManagement.Repositories; using Bit.Core.Repositories; using Bit.Core.SecretsManager.Repositories; using Bit.Core.Tools.Repositories; @@ -55,7 +55,9 @@ 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/PrivilegedAccessManagement/Repositories/AccessRuleRepository.cs b/src/Infrastructure.Dapper/Pam/Repositories/AccessRuleRepository.cs similarity index 93% rename from src/Infrastructure.Dapper/PrivilegedAccessManagement/Repositories/AccessRuleRepository.cs rename to src/Infrastructure.Dapper/Pam/Repositories/AccessRuleRepository.cs index 4032d99dcc8a..03d468bf9e4f 100644 --- a/src/Infrastructure.Dapper/PrivilegedAccessManagement/Repositories/AccessRuleRepository.cs +++ b/src/Infrastructure.Dapper/Pam/Repositories/AccessRuleRepository.cs @@ -1,7 +1,7 @@ using System.Data; -using Bit.Core.PrivilegedAccessManagement.Entities; -using Bit.Core.PrivilegedAccessManagement.Models; -using Bit.Core.PrivilegedAccessManagement.Repositories; +using Bit.Core.Pam.Entities; +using Bit.Core.Pam.Models; +using Bit.Core.Pam.Repositories; using Bit.Core.Settings; using Bit.Infrastructure.Dapper.Repositories; using Dapper; @@ -9,7 +9,7 @@ #nullable enable -namespace Bit.Infrastructure.Dapper.PrivilegedAccessManagement.Repositories; +namespace Bit.Infrastructure.Dapper.Pam.Repositories; public class AccessRuleRepository : Repository, IAccessRuleRepository { diff --git a/src/Infrastructure.Dapper/Pam/Repositories/LeaseRepository.cs b/src/Infrastructure.Dapper/Pam/Repositories/LeaseRepository.cs new file mode 100644 index 000000000000..5b6c01afe48f --- /dev/null +++ b/src/Infrastructure.Dapper/Pam/Repositories/LeaseRepository.cs @@ -0,0 +1,56 @@ +using System.Data; +using Bit.Core.Pam.Entities; +using Bit.Core.Pam.Repositories; +using Bit.Core.Settings; +using Bit.Infrastructure.Dapper.Repositories; +using Dapper; +using Microsoft.Data.SqlClient; + +#nullable enable + +namespace Bit.Infrastructure.Dapper.Pam.Repositories; + +public class LeaseRepository : Repository, ILeaseRepository +{ + public LeaseRepository(GlobalSettings globalSettings) + : this(globalSettings.SqlServer.ConnectionString, globalSettings.SqlServer.ReadOnlyConnectionString) + { } + + public LeaseRepository(string connectionString, string readOnlyConnectionString) + : base(connectionString, readOnlyConnectionString) + { } + + public async Task GetActiveByRequesterIdCipherIdAsync(Guid requesterId, Guid cipherId, DateTime now) + { + await using var connection = new SqlConnection(ConnectionString); + var results = await connection.QueryAsync( + $"[{Schema}].[Lease_ReadActiveByRequesterIdCipherId]", + new { RequesterId = requesterId, CipherId = cipherId, Now = now }, + commandType: CommandType.StoredProcedure); + + return results.FirstOrDefault(); + } + + public async Task CreateAutoApprovedAsync(LeaseRequest request, LeaseDecision decision, Lease lease, DateTime now) + { + await using var connection = new SqlConnection(ConnectionString); + await connection.ExecuteAsync( + $"[{Schema}].[Lease_CreateAutoApproved]", + new + { + LeaseRequestId = request.Id, + LeaseId = lease.Id, + LeaseDecisionId = decision.Id, + request.OrganizationId, + request.CollectionId, + request.CipherId, + request.RequesterId, + request.NotBefore, + request.NotAfter, + request.Reason, + decision.PolicyKind, + Now = now, + }, + commandType: CommandType.StoredProcedure); + } +} diff --git a/src/Infrastructure.Dapper/Pam/Repositories/LeaseRequestRepository.cs b/src/Infrastructure.Dapper/Pam/Repositories/LeaseRequestRepository.cs new file mode 100644 index 000000000000..7bd872a794fa --- /dev/null +++ b/src/Infrastructure.Dapper/Pam/Repositories/LeaseRequestRepository.cs @@ -0,0 +1,33 @@ +using System.Data; +using Bit.Core.Pam.Entities; +using Bit.Core.Pam.Repositories; +using Bit.Core.Settings; +using Bit.Infrastructure.Dapper.Repositories; +using Dapper; +using Microsoft.Data.SqlClient; + +#nullable enable + +namespace Bit.Infrastructure.Dapper.Pam.Repositories; + +public class LeaseRequestRepository : Repository, ILeaseRequestRepository +{ + public LeaseRequestRepository(GlobalSettings globalSettings) + : this(globalSettings.SqlServer.ConnectionString, globalSettings.SqlServer.ReadOnlyConnectionString) + { } + + public LeaseRequestRepository(string connectionString, string readOnlyConnectionString) + : base(connectionString, readOnlyConnectionString) + { } + + public async Task GetActivePendingByRequesterIdCipherIdAsync(Guid requesterId, Guid cipherId) + { + await using var connection = new SqlConnection(ConnectionString); + var results = await connection.QueryAsync( + $"[{Schema}].[LeaseRequest_ReadActivePendingByRequesterIdCipherId]", + new { RequesterId = requesterId, CipherId = cipherId }, + commandType: CommandType.StoredProcedure); + + return results.FirstOrDefault(); + } +} diff --git a/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs b/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs index e0544176d16d..94bd0e677532 100644 --- a/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs +++ b/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs @@ -9,8 +9,8 @@ using Bit.Core.Enums; using Bit.Core.KeyManagement.Repositories; using Bit.Core.NotificationCenter.Repositories; +using Bit.Core.Pam.Repositories; using Bit.Core.Platform.Installations; -using Bit.Core.PrivilegedAccessManagement.Repositories; using Bit.Core.Repositories; using Bit.Core.SecretsManager.Repositories; using Bit.Core.Tools.Repositories; @@ -103,7 +103,7 @@ public static void AddPasswordManagerEFRepositories(this IServiceCollection serv services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Infrastructure.EntityFramework/Pam/Models/AccessRule.cs b/src/Infrastructure.EntityFramework/Pam/Models/AccessRule.cs new file mode 100644 index 000000000000..3ea2c533515a --- /dev/null +++ b/src/Infrastructure.EntityFramework/Pam/Models/AccessRule.cs @@ -0,0 +1,21 @@ +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using AutoMapper; +using Bit.Infrastructure.EntityFramework.AdminConsole.Models; + +namespace Bit.Infrastructure.EntityFramework.Pam.Models; + +public class AccessRule : Core.Pam.Entities.AccessRule +{ + public virtual Organization Organization { get; set; } +} + +public class AccessRuleMapperProfile : Profile +{ + public AccessRuleMapperProfile() + { + CreateMap().ReverseMap(); + CreateMap(); + } +} diff --git a/src/Infrastructure.EntityFramework/PrivilegedAccessManagement/Repositories/AccessRuleRepository.cs b/src/Infrastructure.EntityFramework/Pam/Repositories/AccessRuleRepository.cs similarity index 93% rename from src/Infrastructure.EntityFramework/PrivilegedAccessManagement/Repositories/AccessRuleRepository.cs rename to src/Infrastructure.EntityFramework/Pam/Repositories/AccessRuleRepository.cs index 9ef4de0a3ff9..76650121393f 100644 --- a/src/Infrastructure.EntityFramework/PrivilegedAccessManagement/Repositories/AccessRuleRepository.cs +++ b/src/Infrastructure.EntityFramework/Pam/Repositories/AccessRuleRepository.cs @@ -1,15 +1,15 @@ using AutoMapper; -using Bit.Core.PrivilegedAccessManagement.Models; -using Bit.Core.PrivilegedAccessManagement.Repositories; +using Bit.Core.Pam.Models; +using Bit.Core.Pam.Repositories; using Bit.Infrastructure.EntityFramework.Repositories; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; -using CoreEntity = Bit.Core.PrivilegedAccessManagement.Entities.AccessRule; -using EfModel = Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Models.AccessRule; +using CoreEntity = Bit.Core.Pam.Entities.AccessRule; +using EfModel = Bit.Infrastructure.EntityFramework.Pam.Models.AccessRule; #nullable enable -namespace Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Repositories; +namespace Bit.Infrastructure.EntityFramework.Pam.Repositories; public class AccessRuleRepository : Repository, IAccessRuleRepository { diff --git a/src/Infrastructure.EntityFramework/PrivilegedAccessManagement/Models/AccessRule.cs b/src/Infrastructure.EntityFramework/PrivilegedAccessManagement/Models/AccessRule.cs deleted file mode 100644 index d0ff7f758e07..000000000000 --- a/src/Infrastructure.EntityFramework/PrivilegedAccessManagement/Models/AccessRule.cs +++ /dev/null @@ -1,21 +0,0 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using AutoMapper; -using Bit.Infrastructure.EntityFramework.AdminConsole.Models; - -namespace Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Models; - -public class AccessRule : Core.PrivilegedAccessManagement.Entities.AccessRule -{ - public virtual Organization Organization { get; set; } -} - -public class AccessRuleMapperProfile : Profile -{ - public AccessRuleMapperProfile() - { - CreateMap().ReverseMap(); - CreateMap(); - } -} diff --git a/src/Infrastructure.EntityFramework/Repositories/DatabaseContext.cs b/src/Infrastructure.EntityFramework/Repositories/DatabaseContext.cs index ea213d4d6271..a7d4da53dfb7 100644 --- a/src/Infrastructure.EntityFramework/Repositories/DatabaseContext.cs +++ b/src/Infrastructure.EntityFramework/Repositories/DatabaseContext.cs @@ -8,8 +8,8 @@ using Bit.Infrastructure.EntityFramework.Dirt.Models; using Bit.Infrastructure.EntityFramework.Models; using Bit.Infrastructure.EntityFramework.NotificationCenter.Models; +using Bit.Infrastructure.EntityFramework.Pam.Models; using Bit.Infrastructure.EntityFramework.Platform; -using Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Models; using Bit.Infrastructure.EntityFramework.SecretsManager.Models; using Bit.Infrastructure.EntityFramework.Vault.Models; using Microsoft.EntityFrameworkCore; diff --git a/src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/AccessRule_Create.sql b/src/Sql/dbo/Pam/Stored Procedures/AccessRule_Create.sql similarity index 100% rename from src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/AccessRule_Create.sql rename to src/Sql/dbo/Pam/Stored Procedures/AccessRule_Create.sql diff --git a/src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/AccessRule_DeleteById.sql b/src/Sql/dbo/Pam/Stored Procedures/AccessRule_DeleteById.sql similarity index 100% rename from src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/AccessRule_DeleteById.sql rename to src/Sql/dbo/Pam/Stored Procedures/AccessRule_DeleteById.sql diff --git a/src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/AccessRule_ReadById.sql b/src/Sql/dbo/Pam/Stored Procedures/AccessRule_ReadById.sql similarity index 100% rename from src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/AccessRule_ReadById.sql rename to src/Sql/dbo/Pam/Stored Procedures/AccessRule_ReadById.sql diff --git a/src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/AccessRule_ReadByOrganizationId.sql b/src/Sql/dbo/Pam/Stored Procedures/AccessRule_ReadByOrganizationId.sql similarity index 100% rename from src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/AccessRule_ReadByOrganizationId.sql rename to src/Sql/dbo/Pam/Stored Procedures/AccessRule_ReadByOrganizationId.sql diff --git a/src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/AccessRule_ReadDetailsById.sql b/src/Sql/dbo/Pam/Stored Procedures/AccessRule_ReadDetailsById.sql similarity index 100% rename from src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/AccessRule_ReadDetailsById.sql rename to src/Sql/dbo/Pam/Stored Procedures/AccessRule_ReadDetailsById.sql diff --git a/src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/AccessRule_ReadDetailsByOrganizationId.sql b/src/Sql/dbo/Pam/Stored Procedures/AccessRule_ReadDetailsByOrganizationId.sql similarity index 100% rename from src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/AccessRule_ReadDetailsByOrganizationId.sql rename to src/Sql/dbo/Pam/Stored Procedures/AccessRule_ReadDetailsByOrganizationId.sql diff --git a/src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/AccessRule_Update.sql b/src/Sql/dbo/Pam/Stored Procedures/AccessRule_Update.sql similarity index 100% rename from src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/AccessRule_Update.sql rename to src/Sql/dbo/Pam/Stored Procedures/AccessRule_Update.sql diff --git a/src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/Collection_SetAccessRuleAssociations.sql b/src/Sql/dbo/Pam/Stored Procedures/Collection_SetAccessRuleAssociations.sql similarity index 100% rename from src/Sql/dbo/PrivilegedAccessManagement/Stored Procedures/Collection_SetAccessRuleAssociations.sql rename to src/Sql/dbo/Pam/Stored Procedures/Collection_SetAccessRuleAssociations.sql diff --git a/src/Sql/dbo/Pam/Stored Procedures/LeaseRequest_Create.sql b/src/Sql/dbo/Pam/Stored Procedures/LeaseRequest_Create.sql new file mode 100644 index 000000000000..2cafb8cec568 --- /dev/null +++ b/src/Sql/dbo/Pam/Stored Procedures/LeaseRequest_Create.sql @@ -0,0 +1,48 @@ +CREATE PROCEDURE [dbo].[LeaseRequest_Create] + @Id UNIQUEIDENTIFIER OUTPUT, + @LeaseId UNIQUEIDENTIFIER = NULL, + @OrganizationId UNIQUEIDENTIFIER, + @CollectionId UNIQUEIDENTIFIER, + @CipherId UNIQUEIDENTIFIER, + @RequesterId UNIQUEIDENTIFIER, + @NotBefore DATETIME2(7), + @NotAfter DATETIME2(7), + @Reason NVARCHAR(MAX) = NULL, + @Status TINYINT, + @CreationDate DATETIME2(7), + @ResolvedDate DATETIME2(7) = NULL +AS +BEGIN + SET NOCOUNT ON + + INSERT INTO [dbo].[LeaseRequest] + ( + [Id], + [LeaseId], + [OrganizationId], + [CollectionId], + [CipherId], + [RequesterId], + [NotBefore], + [NotAfter], + [Reason], + [Status], + [CreationDate], + [ResolvedDate] + ) + VALUES + ( + @Id, + @LeaseId, + @OrganizationId, + @CollectionId, + @CipherId, + @RequesterId, + @NotBefore, + @NotAfter, + @Reason, + @Status, + @CreationDate, + @ResolvedDate + ) +END diff --git a/src/Sql/dbo/Pam/Stored Procedures/LeaseRequest_ReadActivePendingByRequesterIdCipherId.sql b/src/Sql/dbo/Pam/Stored Procedures/LeaseRequest_ReadActivePendingByRequesterIdCipherId.sql new file mode 100644 index 000000000000..2cd898a0b2f9 --- /dev/null +++ b/src/Sql/dbo/Pam/Stored Procedures/LeaseRequest_ReadActivePendingByRequesterIdCipherId.sql @@ -0,0 +1,18 @@ +CREATE PROCEDURE [dbo].[LeaseRequest_ReadActivePendingByRequesterIdCipherId] + @RequesterId UNIQUEIDENTIFIER, + @CipherId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT TOP 1 + * + FROM + [dbo].[LeaseRequest] + WHERE + [RequesterId] = @RequesterId + AND [CipherId] = @CipherId + AND [Status] = 0 -- Pending + ORDER BY + [CreationDate] DESC +END diff --git a/src/Sql/dbo/Pam/Stored Procedures/LeaseRequest_ReadById.sql b/src/Sql/dbo/Pam/Stored Procedures/LeaseRequest_ReadById.sql new file mode 100644 index 000000000000..3bf0d2fb6b42 --- /dev/null +++ b/src/Sql/dbo/Pam/Stored Procedures/LeaseRequest_ReadById.sql @@ -0,0 +1,13 @@ +CREATE PROCEDURE [dbo].[LeaseRequest_ReadById] + @Id UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT + * + FROM + [dbo].[LeaseRequest] + WHERE + [Id] = @Id +END diff --git a/src/Sql/dbo/Pam/Stored Procedures/Lease_CreateAutoApproved.sql b/src/Sql/dbo/Pam/Stored Procedures/Lease_CreateAutoApproved.sql new file mode 100644 index 000000000000..357660feb9a1 --- /dev/null +++ b/src/Sql/dbo/Pam/Stored Procedures/Lease_CreateAutoApproved.sql @@ -0,0 +1,56 @@ +CREATE PROCEDURE [dbo].[Lease_CreateAutoApproved] + @LeaseRequestId UNIQUEIDENTIFIER, + @LeaseId UNIQUEIDENTIFIER, + @LeaseDecisionId UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @CollectionId UNIQUEIDENTIFIER, + @CipherId UNIQUEIDENTIFIER, + @RequesterId UNIQUEIDENTIFIER, + @NotBefore DATETIME2(7), + @NotAfter DATETIME2(7), + @Reason NVARCHAR(MAX) = NULL, + @PolicyKind NVARCHAR(50) = NULL, + @Now DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + BEGIN TRANSACTION Lease_CreateAutoApproved + + -- The request is created already resolved (Approved). LeaseId stays NULL: it is reserved for extension + -- requests; provenance for an original lease flows the other way, via Lease.LeaseRequestId. + INSERT INTO [dbo].[LeaseRequest] + ( + [Id], [LeaseId], [OrganizationId], [CollectionId], [CipherId], [RequesterId], + [NotBefore], [NotAfter], [Reason], [Status], [CreationDate], [ResolvedDate] + ) + VALUES + ( + @LeaseRequestId, NULL, @OrganizationId, @CollectionId, @CipherId, @RequesterId, + @NotBefore, @NotAfter, @Reason, 1 /* Approved */, @Now, @Now + ) + + INSERT INTO [dbo].[LeaseDecision] + ( + [Id], [LeaseRequestId], [DeciderKind], [ApproverId], [PolicyKind], + [Decision], [Comment], [EvaluationContext], [CreationDate] + ) + VALUES + ( + @LeaseDecisionId, @LeaseRequestId, 0 /* Policy */, NULL, @PolicyKind, + 0 /* Approve */, NULL, NULL, @Now + ) + + INSERT INTO [dbo].[Lease] + ( + [Id], [LeaseRequestId], [OrganizationId], [CollectionId], [CipherId], [RequesterId], + [Status], [NotBefore], [NotAfter], [RevokedDate], [RevokedBy], [CreationDate] + ) + VALUES + ( + @LeaseId, @LeaseRequestId, @OrganizationId, @CollectionId, @CipherId, @RequesterId, + 0 /* Active */, @NotBefore, @NotAfter, NULL, NULL, @Now + ) + + COMMIT TRANSACTION Lease_CreateAutoApproved +END diff --git a/src/Sql/dbo/Pam/Stored Procedures/Lease_ReadActiveByRequesterIdCipherId.sql b/src/Sql/dbo/Pam/Stored Procedures/Lease_ReadActiveByRequesterIdCipherId.sql new file mode 100644 index 000000000000..a59baf6c1832 --- /dev/null +++ b/src/Sql/dbo/Pam/Stored Procedures/Lease_ReadActiveByRequesterIdCipherId.sql @@ -0,0 +1,21 @@ +CREATE PROCEDURE [dbo].[Lease_ReadActiveByRequesterIdCipherId] + @RequesterId UNIQUEIDENTIFIER, + @CipherId UNIQUEIDENTIFIER, + @Now DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + SELECT TOP 1 + * + FROM + [dbo].[Lease] + WHERE + [RequesterId] = @RequesterId + AND [CipherId] = @CipherId + AND [Status] = 0 -- Active + AND [NotBefore] <= @Now + AND [NotAfter] > @Now + ORDER BY + [NotAfter] DESC +END diff --git a/src/Sql/dbo/Pam/Stored Procedures/Lease_ReadById.sql b/src/Sql/dbo/Pam/Stored Procedures/Lease_ReadById.sql new file mode 100644 index 000000000000..b25ad0efceaa --- /dev/null +++ b/src/Sql/dbo/Pam/Stored Procedures/Lease_ReadById.sql @@ -0,0 +1,13 @@ +CREATE PROCEDURE [dbo].[Lease_ReadById] + @Id UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT + * + FROM + [dbo].[Lease] + WHERE + [Id] = @Id +END diff --git a/src/Sql/dbo/PrivilegedAccessManagement/Tables/AccessRule.sql b/src/Sql/dbo/Pam/Tables/AccessRule.sql similarity index 100% rename from src/Sql/dbo/PrivilegedAccessManagement/Tables/AccessRule.sql rename to src/Sql/dbo/Pam/Tables/AccessRule.sql diff --git a/src/Sql/dbo/Pam/Tables/Lease.sql b/src/Sql/dbo/Pam/Tables/Lease.sql new file mode 100644 index 000000000000..24434650582c --- /dev/null +++ b/src/Sql/dbo/Pam/Tables/Lease.sql @@ -0,0 +1,26 @@ +CREATE TABLE [dbo].[Lease] ( + [Id] UNIQUEIDENTIFIER NOT NULL, + [LeaseRequestId] UNIQUEIDENTIFIER NOT NULL, + [OrganizationId] UNIQUEIDENTIFIER NOT NULL, + [CollectionId] UNIQUEIDENTIFIER NOT NULL, + [CipherId] UNIQUEIDENTIFIER NOT NULL, + [RequesterId] UNIQUEIDENTIFIER NOT NULL, + [Status] TINYINT NOT NULL, + [NotBefore] DATETIME2 (7) NOT NULL, + [NotAfter] DATETIME2 (7) NOT NULL, + [RevokedDate] DATETIME2 (7) NULL, + [RevokedBy] UNIQUEIDENTIFIER NULL, + [CreationDate] DATETIME2 (7) NOT NULL, + CONSTRAINT [PK_Lease] PRIMARY KEY CLUSTERED ([Id] ASC), + CONSTRAINT [FK_Lease_LeaseRequest] FOREIGN KEY ([LeaseRequestId]) REFERENCES [dbo].[LeaseRequest] ([Id]), + CONSTRAINT [FK_Lease_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]) ON DELETE CASCADE +); +GO + +CREATE NONCLUSTERED INDEX [IX_Lease_RequesterId_CipherId_Status] + ON [dbo].[Lease] ([RequesterId] ASC, [CipherId] ASC, [Status] ASC); +GO + +CREATE NONCLUSTERED INDEX [IX_Lease_NotAfter_Status] + ON [dbo].[Lease] ([NotAfter] ASC, [Status] ASC); +GO diff --git a/src/Sql/dbo/Pam/Tables/LeaseDecision.sql b/src/Sql/dbo/Pam/Tables/LeaseDecision.sql new file mode 100644 index 000000000000..64795a6b93dc --- /dev/null +++ b/src/Sql/dbo/Pam/Tables/LeaseDecision.sql @@ -0,0 +1,18 @@ +CREATE TABLE [dbo].[LeaseDecision] ( + [Id] UNIQUEIDENTIFIER NOT NULL, + [LeaseRequestId] UNIQUEIDENTIFIER NOT NULL, + [DeciderKind] TINYINT NOT NULL, + [ApproverId] UNIQUEIDENTIFIER NULL, + [PolicyKind] NVARCHAR(50) NULL, + [Decision] TINYINT NOT NULL, + [Comment] NVARCHAR(MAX) NULL, + [EvaluationContext] NVARCHAR(MAX) NULL, + [CreationDate] DATETIME2 (7) NOT NULL, + CONSTRAINT [PK_LeaseDecision] PRIMARY KEY CLUSTERED ([Id] ASC), + CONSTRAINT [FK_LeaseDecision_LeaseRequest] FOREIGN KEY ([LeaseRequestId]) REFERENCES [dbo].[LeaseRequest] ([Id]) ON DELETE CASCADE +); +GO + +CREATE NONCLUSTERED INDEX [IX_LeaseDecision_LeaseRequestId] + ON [dbo].[LeaseDecision] ([LeaseRequestId] ASC); +GO diff --git a/src/Sql/dbo/Pam/Tables/LeaseRequest.sql b/src/Sql/dbo/Pam/Tables/LeaseRequest.sql new file mode 100644 index 000000000000..076483c09e04 --- /dev/null +++ b/src/Sql/dbo/Pam/Tables/LeaseRequest.sql @@ -0,0 +1,26 @@ +CREATE TABLE [dbo].[LeaseRequest] ( + [Id] UNIQUEIDENTIFIER NOT NULL, + [LeaseId] UNIQUEIDENTIFIER NULL, + [OrganizationId] UNIQUEIDENTIFIER NOT NULL, + [CollectionId] UNIQUEIDENTIFIER NOT NULL, + [CipherId] UNIQUEIDENTIFIER NOT NULL, + [RequesterId] UNIQUEIDENTIFIER NOT NULL, + [NotBefore] DATETIME2 (7) NOT NULL, + [NotAfter] DATETIME2 (7) NOT NULL, + [Reason] NVARCHAR(MAX) NULL, + [Status] TINYINT NOT NULL, + [CreationDate] DATETIME2 (7) NOT NULL, + [ResolvedDate] DATETIME2 (7) NULL, + CONSTRAINT [PK_LeaseRequest] PRIMARY KEY CLUSTERED ([Id] ASC), + CONSTRAINT [FK_LeaseRequest_Lease] FOREIGN KEY ([LeaseId]) REFERENCES [dbo].[Lease] ([Id]), + CONSTRAINT [FK_LeaseRequest_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]) ON DELETE CASCADE +); +GO + +CREATE NONCLUSTERED INDEX [IX_LeaseRequest_RequesterId_CipherId_Status] + ON [dbo].[LeaseRequest] ([RequesterId] ASC, [CipherId] ASC, [Status] ASC); +GO + +CREATE NONCLUSTERED INDEX [IX_LeaseRequest_OrganizationId_Status] + ON [dbo].[LeaseRequest] ([OrganizationId] ASC, [Status] ASC); +GO diff --git a/test/Core.Test/PrivilegedAccessManagement/Commands/CreateAccessRuleCommandTests.cs b/test/Core.Test/Pam/Commands/CreateAccessRuleCommandTests.cs similarity index 96% rename from test/Core.Test/PrivilegedAccessManagement/Commands/CreateAccessRuleCommandTests.cs rename to test/Core.Test/Pam/Commands/CreateAccessRuleCommandTests.cs index e4012dfdd62f..26da5cb6fa1a 100644 --- a/test/Core.Test/PrivilegedAccessManagement/Commands/CreateAccessRuleCommandTests.cs +++ b/test/Core.Test/Pam/Commands/CreateAccessRuleCommandTests.cs @@ -1,9 +1,9 @@ using Bit.Core.Entities; using Bit.Core.Exceptions; -using Bit.Core.PrivilegedAccessManagement.Entities; -using Bit.Core.PrivilegedAccessManagement.OrganizationFeatures.Commands; -using Bit.Core.PrivilegedAccessManagement.Repositories; -using Bit.Core.PrivilegedAccessManagement.Services; +using Bit.Core.Pam.Entities; +using Bit.Core.Pam.OrganizationFeatures.Commands; +using Bit.Core.Pam.Repositories; +using Bit.Core.Pam.Services; using Bit.Core.Repositories; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; @@ -11,7 +11,7 @@ using NSubstitute; using Xunit; -namespace Bit.Core.Test.PrivilegedAccessManagement.Commands; +namespace Bit.Core.Test.Pam.Commands; [SutProviderCustomize] public class CreateAccessRuleCommandTests diff --git a/test/Core.Test/PrivilegedAccessManagement/Commands/DeleteAccessRuleCommandTests.cs b/test/Core.Test/Pam/Commands/DeleteAccessRuleCommandTests.cs similarity index 88% rename from test/Core.Test/PrivilegedAccessManagement/Commands/DeleteAccessRuleCommandTests.cs rename to test/Core.Test/Pam/Commands/DeleteAccessRuleCommandTests.cs index 6455ee34ec71..2c2f0e3aadc4 100644 --- a/test/Core.Test/PrivilegedAccessManagement/Commands/DeleteAccessRuleCommandTests.cs +++ b/test/Core.Test/Pam/Commands/DeleteAccessRuleCommandTests.cs @@ -1,13 +1,13 @@ using Bit.Core.Exceptions; -using Bit.Core.PrivilegedAccessManagement.Entities; -using Bit.Core.PrivilegedAccessManagement.OrganizationFeatures.Commands; -using Bit.Core.PrivilegedAccessManagement.Repositories; +using Bit.Core.Pam.Entities; +using Bit.Core.Pam.OrganizationFeatures.Commands; +using Bit.Core.Pam.Repositories; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; using Xunit; -namespace Bit.Core.Test.PrivilegedAccessManagement.Commands; +namespace Bit.Core.Test.Pam.Commands; [SutProviderCustomize] public class DeleteAccessRuleCommandTests diff --git a/test/Core.Test/Pam/Commands/RequestAccessCommandTests.cs b/test/Core.Test/Pam/Commands/RequestAccessCommandTests.cs new file mode 100644 index 000000000000..ea21008d1ac6 --- /dev/null +++ b/test/Core.Test/Pam/Commands/RequestAccessCommandTests.cs @@ -0,0 +1,220 @@ +using Bit.Core.Exceptions; +using Bit.Core.Pam.Entities; +using Bit.Core.Pam.Enums; +using Bit.Core.Pam.Models; +using Bit.Core.Pam.OrganizationFeatures.Commands; +using Bit.Core.Pam.Repositories; +using Bit.Core.Pam.Services; +using Bit.Core.Vault.Models.Data; +using Bit.Core.Vault.Repositories; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.Extensions.Time.Testing; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Pam.Commands; + +[SutProviderCustomize] +public class RequestAccessCommandTests +{ + private static readonly DateTime _now = new(2026, 6, 4, 12, 0, 0, DateTimeKind.Utc); + + [Theory, BitAutoData] + public async Task RequestAccessAsync_CipherNotAccessible_ThrowsNotFound(Guid userId, Guid cipherId) + { + var sutProvider = Setup(); + sutProvider.GetDependency().GetByIdAsync(cipherId, userId).Returns((CipherDetails?)null); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.RequestAccessAsync(userId, cipherId, new AccessRequestSubmission { DurationSeconds = 3600 })); + } + + [Theory, BitAutoData] + public async Task RequestAccessAsync_NotLeasingGated_ThrowsBadRequest(Guid userId, Guid cipherId) + { + var sutProvider = Setup(); + SetupCipher(sutProvider, userId, cipherId); + sutProvider.GetDependency().ResolveAsync(userId, cipherId) + .Returns((AccessApprovalResolution?)null); + + var ex = await Assert.ThrowsAsync( + () => sutProvider.Sut.RequestAccessAsync(userId, cipherId, new AccessRequestSubmission { DurationSeconds = 3600 })); + Assert.Contains("does not require a lease", ex.Message); + } + + [Theory, BitAutoData] + public async Task RequestAccessAsync_Automatic_IssuesActiveLease(Guid userId, Guid cipherId, Guid orgId, Guid collectionId) + { + var sutProvider = Setup(); + SetupCipher(sutProvider, userId, cipherId); + SetupResolution(sutProvider, userId, cipherId, orgId, collectionId, requiresHuman: false); + + var result = await sutProvider.Sut.RequestAccessAsync(userId, cipherId, + new AccessRequestSubmission { DurationSeconds = 3600, Reason = "deploy" }); + + Assert.Equal(AccessApprovalOutcome.Automatic, result.Outcome); + Assert.NotNull(result.Lease); + Assert.Equal(LeaseStatus.Active, result.Lease!.Status); + Assert.Equal(_now, result.Lease.NotBefore); + Assert.Equal(_now.AddSeconds(3600), result.Lease.NotAfter); + await sutProvider.GetDependency().Received(1) + .CreateAutoApprovedAsync(Arg.Any(), Arg.Any(), Arg.Any(), _now); + } + + [Theory, BitAutoData] + public async Task RequestAccessAsync_AutomaticWithWindow_ThrowsBadRequest(Guid userId, Guid cipherId, Guid orgId, Guid collectionId) + { + var sutProvider = Setup(); + SetupCipher(sutProvider, userId, cipherId); + SetupResolution(sutProvider, userId, cipherId, orgId, collectionId, requiresHuman: false); + + var ex = await Assert.ThrowsAsync( + () => sutProvider.Sut.RequestAccessAsync(userId, cipherId, + new AccessRequestSubmission { Start = _now, End = _now.AddHours(1) })); + Assert.Contains("provide a duration", ex.Message); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .CreateAutoApprovedAsync(default!, default!, default!, default); + } + + [Theory, BitAutoData] + public async Task RequestAccessAsync_AutomaticMissingDuration_ThrowsBadRequest(Guid userId, Guid cipherId, Guid orgId, Guid collectionId) + { + var sutProvider = Setup(); + SetupCipher(sutProvider, userId, cipherId); + SetupResolution(sutProvider, userId, cipherId, orgId, collectionId, requiresHuman: false); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.RequestAccessAsync(userId, cipherId, new AccessRequestSubmission())); + } + + [Theory, BitAutoData] + public async Task RequestAccessAsync_AutomaticDurationExceedsMax_ThrowsBadRequest(Guid userId, Guid cipherId, Guid orgId, Guid collectionId) + { + var sutProvider = Setup(); + SetupCipher(sutProvider, userId, cipherId); + SetupResolution(sutProvider, userId, cipherId, orgId, collectionId, requiresHuman: false); + + var ex = await Assert.ThrowsAsync( + () => sutProvider.Sut.RequestAccessAsync(userId, cipherId, + new AccessRequestSubmission { DurationSeconds = RequestAccessCommand.MaxDurationSeconds + 1 })); + Assert.Contains("maximum", ex.Message); + } + + [Theory, BitAutoData] + public async Task RequestAccessAsync_Human_CreatesPendingRequest(Guid userId, Guid cipherId, Guid orgId, Guid collectionId) + { + var sutProvider = Setup(); + SetupCipher(sutProvider, userId, cipherId); + SetupResolution(sutProvider, userId, cipherId, orgId, collectionId, requiresHuman: true); + sutProvider.GetDependency() + .CreateAsync(Arg.Any()) + .Returns(callInfo => callInfo.Arg()); + + var start = _now.AddHours(1); + var end = _now.AddHours(2); + var result = await sutProvider.Sut.RequestAccessAsync(userId, cipherId, + new AccessRequestSubmission { Start = start, End = end, Reason = "audit" }); + + Assert.Equal(AccessApprovalOutcome.Human, result.Outcome); + Assert.NotNull(result.Request); + Assert.Equal(LeaseRequestStatus.Pending, result.Request!.Status); + Assert.Equal(start, result.Request.NotBefore); + Assert.Equal(end, result.Request.NotAfter); + Assert.Equal("audit", result.Request.Reason); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .CreateAutoApprovedAsync(default!, default!, default!, default); + } + + [Theory, BitAutoData] + public async Task RequestAccessAsync_HumanMissingReason_ThrowsBadRequest(Guid userId, Guid cipherId, Guid orgId, Guid collectionId) + { + var sutProvider = Setup(); + SetupCipher(sutProvider, userId, cipherId); + SetupResolution(sutProvider, userId, cipherId, orgId, collectionId, requiresHuman: true); + + var ex = await Assert.ThrowsAsync( + () => sutProvider.Sut.RequestAccessAsync(userId, cipherId, + new AccessRequestSubmission { Start = _now.AddHours(1), End = _now.AddHours(2) })); + Assert.Contains("reason is required", ex.Message); + } + + [Theory, BitAutoData] + public async Task RequestAccessAsync_HumanWithDuration_ThrowsBadRequest(Guid userId, Guid cipherId, Guid orgId, Guid collectionId) + { + var sutProvider = Setup(); + SetupCipher(sutProvider, userId, cipherId); + SetupResolution(sutProvider, userId, cipherId, orgId, collectionId, requiresHuman: true); + + var ex = await Assert.ThrowsAsync( + () => sutProvider.Sut.RequestAccessAsync(userId, cipherId, + new AccessRequestSubmission { DurationSeconds = 3600, Reason = "x" })); + Assert.Contains("requires human approval", ex.Message); + } + + [Theory, BitAutoData] + public async Task RequestAccessAsync_HumanStartNotBeforeEnd_ThrowsBadRequest(Guid userId, Guid cipherId, Guid orgId, Guid collectionId) + { + var sutProvider = Setup(); + SetupCipher(sutProvider, userId, cipherId); + SetupResolution(sutProvider, userId, cipherId, orgId, collectionId, requiresHuman: true); + + var ex = await Assert.ThrowsAsync( + () => sutProvider.Sut.RequestAccessAsync(userId, cipherId, + new AccessRequestSubmission { Start = _now.AddHours(2), End = _now.AddHours(1), Reason = "x" })); + Assert.Contains("before the end date", ex.Message); + } + + [Theory, BitAutoData] + public async Task RequestAccessAsync_ExistingActiveLease_ThrowsBadRequest(Guid userId, Guid cipherId, Guid orgId, Guid collectionId, Lease lease) + { + var sutProvider = Setup(); + SetupCipher(sutProvider, userId, cipherId); + SetupResolution(sutProvider, userId, cipherId, orgId, collectionId, requiresHuman: false); + sutProvider.GetDependency() + .GetActiveByRequesterIdCipherIdAsync(userId, cipherId, _now) + .Returns(lease); + + var ex = await Assert.ThrowsAsync( + () => sutProvider.Sut.RequestAccessAsync(userId, cipherId, new AccessRequestSubmission { DurationSeconds = 3600 })); + Assert.Contains("already have active access", ex.Message); + } + + [Theory, BitAutoData] + public async Task RequestAccessAsync_ExistingPendingRequest_ThrowsBadRequest(Guid userId, Guid cipherId, Guid orgId, Guid collectionId, LeaseRequest pending) + { + var sutProvider = Setup(); + SetupCipher(sutProvider, userId, cipherId); + SetupResolution(sutProvider, userId, cipherId, orgId, collectionId, requiresHuman: true); + sutProvider.GetDependency() + .GetActivePendingByRequesterIdCipherIdAsync(userId, cipherId) + .Returns(pending); + + var ex = await Assert.ThrowsAsync( + () => sutProvider.Sut.RequestAccessAsync(userId, cipherId, + new AccessRequestSubmission { Start = _now.AddHours(1), End = _now.AddHours(2), Reason = "x" })); + Assert.Contains("already have a pending request", ex.Message); + } + + private static SutProvider Setup() + { + var sutProvider = new SutProvider().WithFakeTimeProvider().Create(); + sutProvider.GetDependency().SetUtcNow(_now); + return sutProvider; + } + + private static void SetupCipher(SutProvider sutProvider, Guid userId, Guid cipherId) + { + sutProvider.GetDependency() + .GetByIdAsync(cipherId, userId) + .Returns(new CipherDetails { Id = cipherId }); + } + + private static void SetupResolution(SutProvider sutProvider, Guid userId, Guid cipherId, + Guid orgId, Guid collectionId, bool requiresHuman) + { + sutProvider.GetDependency() + .ResolveAsync(userId, cipherId) + .Returns(new AccessApprovalResolution(orgId, collectionId, requiresHuman)); + } +} diff --git a/test/Core.Test/PrivilegedAccessManagement/Commands/UpdateAccessRuleCommandTests.cs b/test/Core.Test/Pam/Commands/UpdateAccessRuleCommandTests.cs similarity index 96% rename from test/Core.Test/PrivilegedAccessManagement/Commands/UpdateAccessRuleCommandTests.cs rename to test/Core.Test/Pam/Commands/UpdateAccessRuleCommandTests.cs index 384bd4bc5d97..7b434f8c6fed 100644 --- a/test/Core.Test/PrivilegedAccessManagement/Commands/UpdateAccessRuleCommandTests.cs +++ b/test/Core.Test/Pam/Commands/UpdateAccessRuleCommandTests.cs @@ -1,10 +1,10 @@ using Bit.Core.Entities; using Bit.Core.Exceptions; -using Bit.Core.PrivilegedAccessManagement.Entities; -using Bit.Core.PrivilegedAccessManagement.Models; -using Bit.Core.PrivilegedAccessManagement.OrganizationFeatures.Commands; -using Bit.Core.PrivilegedAccessManagement.Repositories; -using Bit.Core.PrivilegedAccessManagement.Services; +using Bit.Core.Pam.Entities; +using Bit.Core.Pam.Models; +using Bit.Core.Pam.OrganizationFeatures.Commands; +using Bit.Core.Pam.Repositories; +using Bit.Core.Pam.Services; using Bit.Core.Repositories; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; @@ -12,7 +12,7 @@ using NSubstitute; using Xunit; -namespace Bit.Core.Test.PrivilegedAccessManagement.Commands; +namespace Bit.Core.Test.Pam.Commands; [SutProviderCustomize] public class UpdateAccessRuleCommandTests diff --git a/test/Core.Test/PrivilegedAccessManagement/Engine/AccessRuleEngineTests.cs b/test/Core.Test/Pam/Engine/AccessRuleEngineTests.cs similarity index 99% rename from test/Core.Test/PrivilegedAccessManagement/Engine/AccessRuleEngineTests.cs rename to test/Core.Test/Pam/Engine/AccessRuleEngineTests.cs index d165d250c6d6..b515c2ec4129 100644 --- a/test/Core.Test/PrivilegedAccessManagement/Engine/AccessRuleEngineTests.cs +++ b/test/Core.Test/Pam/Engine/AccessRuleEngineTests.cs @@ -1,8 +1,8 @@ using System.Net; -using Bit.Core.PrivilegedAccessManagement.Engine; +using Bit.Core.Pam.Engine; using Xunit; -namespace Bit.Core.Test.PrivilegedAccessManagement.Engine; +namespace Bit.Core.Test.Pam.Engine; public sealed class AccessRuleEngineTests { diff --git a/test/Core.Test/PrivilegedAccessManagement/Engine/Conditions/HumanApprovalConditionTests.cs b/test/Core.Test/Pam/Engine/Conditions/HumanApprovalConditionTests.cs similarity index 86% rename from test/Core.Test/PrivilegedAccessManagement/Engine/Conditions/HumanApprovalConditionTests.cs rename to test/Core.Test/Pam/Engine/Conditions/HumanApprovalConditionTests.cs index a920926b1323..73dec97fa606 100644 --- a/test/Core.Test/PrivilegedAccessManagement/Engine/Conditions/HumanApprovalConditionTests.cs +++ b/test/Core.Test/Pam/Engine/Conditions/HumanApprovalConditionTests.cs @@ -1,10 +1,10 @@ using System.Net; using Bit.Core.Enums; -using Bit.Core.PrivilegedAccessManagement.Engine; -using Bit.Core.PrivilegedAccessManagement.Engine.Conditions; +using Bit.Core.Pam.Engine; +using Bit.Core.Pam.Engine.Conditions; using Xunit; -namespace Bit.Core.Test.PrivilegedAccessManagement.Engine.Conditions; +namespace Bit.Core.Test.Pam.Engine.Conditions; public sealed class HumanApprovalConditionTests { diff --git a/test/Core.Test/PrivilegedAccessManagement/Engine/Conditions/IpRangeConditionTests.cs b/test/Core.Test/Pam/Engine/Conditions/IpRangeConditionTests.cs similarity index 90% rename from test/Core.Test/PrivilegedAccessManagement/Engine/Conditions/IpRangeConditionTests.cs rename to test/Core.Test/Pam/Engine/Conditions/IpRangeConditionTests.cs index 4d37ee2f77d6..1c5c3f1df786 100644 --- a/test/Core.Test/PrivilegedAccessManagement/Engine/Conditions/IpRangeConditionTests.cs +++ b/test/Core.Test/Pam/Engine/Conditions/IpRangeConditionTests.cs @@ -1,10 +1,10 @@ using System.Net; using Bit.Core.Enums; -using Bit.Core.PrivilegedAccessManagement.Engine; -using Bit.Core.PrivilegedAccessManagement.Engine.Conditions; +using Bit.Core.Pam.Engine; +using Bit.Core.Pam.Engine.Conditions; using Xunit; -namespace Bit.Core.Test.PrivilegedAccessManagement.Engine.Conditions; +namespace Bit.Core.Test.Pam.Engine.Conditions; public sealed class IpRangeConditionTests { diff --git a/test/Core.Test/PrivilegedAccessManagement/Engine/Conditions/TimeOfDayConditionTests.cs b/test/Core.Test/Pam/Engine/Conditions/TimeOfDayConditionTests.cs similarity index 95% rename from test/Core.Test/PrivilegedAccessManagement/Engine/Conditions/TimeOfDayConditionTests.cs rename to test/Core.Test/Pam/Engine/Conditions/TimeOfDayConditionTests.cs index a02319a570a2..320afbfd5a6a 100644 --- a/test/Core.Test/PrivilegedAccessManagement/Engine/Conditions/TimeOfDayConditionTests.cs +++ b/test/Core.Test/Pam/Engine/Conditions/TimeOfDayConditionTests.cs @@ -1,10 +1,10 @@ using System.Net; using Bit.Core.Enums; -using Bit.Core.PrivilegedAccessManagement.Engine; -using Bit.Core.PrivilegedAccessManagement.Engine.Conditions; +using Bit.Core.Pam.Engine; +using Bit.Core.Pam.Engine.Conditions; using Xunit; -namespace Bit.Core.Test.PrivilegedAccessManagement.Engine.Conditions; +namespace Bit.Core.Test.Pam.Engine.Conditions; public sealed class TimeOfDayConditionTests { diff --git a/test/Core.Test/PrivilegedAccessManagement/Engine/Fixture/AccessRuleEngineFixture.cs b/test/Core.Test/Pam/Engine/Fixture/AccessRuleEngineFixture.cs similarity index 97% rename from test/Core.Test/PrivilegedAccessManagement/Engine/Fixture/AccessRuleEngineFixture.cs rename to test/Core.Test/Pam/Engine/Fixture/AccessRuleEngineFixture.cs index 9c7ff0d64e8e..11d09e2f5cf0 100644 --- a/test/Core.Test/PrivilegedAccessManagement/Engine/Fixture/AccessRuleEngineFixture.cs +++ b/test/Core.Test/Pam/Engine/Fixture/AccessRuleEngineFixture.cs @@ -1,10 +1,10 @@ using System.Net; using Bit.Core.Enums; -using Bit.Core.PrivilegedAccessManagement.Engine; +using Bit.Core.Pam.Engine; using Bit.Core.Vault.Models.Data; using Microsoft.Extensions.Time.Testing; -namespace Bit.Core.Test.PrivilegedAccessManagement.Engine; +namespace Bit.Core.Test.Pam.Engine; public sealed class AccessRuleEngineFixture { diff --git a/test/Core.Test/PrivilegedAccessManagement/Engine/Fixture/FakeAccessRuleLeaseRepository.cs b/test/Core.Test/Pam/Engine/Fixture/FakeAccessRuleLeaseRepository.cs similarity index 92% rename from test/Core.Test/PrivilegedAccessManagement/Engine/Fixture/FakeAccessRuleLeaseRepository.cs rename to test/Core.Test/Pam/Engine/Fixture/FakeAccessRuleLeaseRepository.cs index fae9c6cd1a90..71f64e7b0d77 100644 --- a/test/Core.Test/PrivilegedAccessManagement/Engine/Fixture/FakeAccessRuleLeaseRepository.cs +++ b/test/Core.Test/Pam/Engine/Fixture/FakeAccessRuleLeaseRepository.cs @@ -1,6 +1,6 @@ -using Bit.Core.PrivilegedAccessManagement.Engine; +using Bit.Core.Pam.Engine; -namespace Bit.Core.Test.PrivilegedAccessManagement.Engine; +namespace Bit.Core.Test.Pam.Engine; public sealed class FakeAccessRuleLeaseRepository : IAccessRuleLeaseRepository { diff --git a/test/Core.Test/PrivilegedAccessManagement/Engine/Fixture/FakeAccessRuleRequestRepository.cs b/test/Core.Test/Pam/Engine/Fixture/FakeAccessRuleRequestRepository.cs similarity index 92% rename from test/Core.Test/PrivilegedAccessManagement/Engine/Fixture/FakeAccessRuleRequestRepository.cs rename to test/Core.Test/Pam/Engine/Fixture/FakeAccessRuleRequestRepository.cs index a1608fa29515..fddf7c900408 100644 --- a/test/Core.Test/PrivilegedAccessManagement/Engine/Fixture/FakeAccessRuleRequestRepository.cs +++ b/test/Core.Test/Pam/Engine/Fixture/FakeAccessRuleRequestRepository.cs @@ -1,6 +1,6 @@ -using Bit.Core.PrivilegedAccessManagement.Engine; +using Bit.Core.Pam.Engine; -namespace Bit.Core.Test.PrivilegedAccessManagement.Engine; +namespace Bit.Core.Test.Pam.Engine; public sealed class FakeAccessRuleRequestRepository : IAccessRuleRequestRepository { diff --git a/test/Core.Test/PrivilegedAccessManagement/Engine/Fixture/FakeAccessRuleResolver.cs b/test/Core.Test/Pam/Engine/Fixture/FakeAccessRuleResolver.cs similarity index 77% rename from test/Core.Test/PrivilegedAccessManagement/Engine/Fixture/FakeAccessRuleResolver.cs rename to test/Core.Test/Pam/Engine/Fixture/FakeAccessRuleResolver.cs index 71820b6b1378..6a514e56b44d 100644 --- a/test/Core.Test/PrivilegedAccessManagement/Engine/Fixture/FakeAccessRuleResolver.cs +++ b/test/Core.Test/Pam/Engine/Fixture/FakeAccessRuleResolver.cs @@ -1,7 +1,7 @@ -using Bit.Core.PrivilegedAccessManagement.Engine; +using Bit.Core.Pam.Engine; using Bit.Core.Vault.Models.Data; -namespace Bit.Core.Test.PrivilegedAccessManagement.Engine; +namespace Bit.Core.Test.Pam.Engine; public sealed class FakeAccessRuleResolver : IAccessRuleResolver { diff --git a/test/Core.Test/Pam/Queries/AccessPreCheckQueryTests.cs b/test/Core.Test/Pam/Queries/AccessPreCheckQueryTests.cs new file mode 100644 index 000000000000..baf5e859499e --- /dev/null +++ b/test/Core.Test/Pam/Queries/AccessPreCheckQueryTests.cs @@ -0,0 +1,77 @@ +using Bit.Core.Exceptions; +using Bit.Core.Pam.Enums; +using Bit.Core.Pam.Models; +using Bit.Core.Pam.OrganizationFeatures.Queries; +using Bit.Core.Pam.Services; +using Bit.Core.Vault.Models.Data; +using Bit.Core.Vault.Repositories; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Pam.Queries; + +[SutProviderCustomize] +public class AccessPreCheckQueryTests +{ + [Theory, BitAutoData] + public async Task PreCheckAsync_CipherNotAccessible_ThrowsNotFound( + SutProvider sutProvider, Guid userId, Guid cipherId) + { + sutProvider.GetDependency() + .GetByIdAsync(cipherId, userId) + .Returns((CipherDetails?)null); + + await Assert.ThrowsAsync(() => sutProvider.Sut.PreCheckAsync(userId, cipherId)); + } + + [Theory, BitAutoData] + public async Task PreCheckAsync_HumanApprovalRule_ReturnsHuman( + SutProvider sutProvider, Guid userId, Guid cipherId, Guid orgId, Guid collectionId) + { + SetupCipher(sutProvider, userId, cipherId); + sutProvider.GetDependency() + .ResolveAsync(userId, cipherId) + .Returns(new AccessApprovalResolution(orgId, collectionId, RequiresHumanApproval: true)); + + var result = await sutProvider.Sut.PreCheckAsync(userId, cipherId); + + Assert.Equal(AccessApprovalOutcome.Human, result.Outcome); + } + + [Theory, BitAutoData] + public async Task PreCheckAsync_AutoApproveRule_ReturnsAutomatic( + SutProvider sutProvider, Guid userId, Guid cipherId, Guid orgId, Guid collectionId) + { + SetupCipher(sutProvider, userId, cipherId); + sutProvider.GetDependency() + .ResolveAsync(userId, cipherId) + .Returns(new AccessApprovalResolution(orgId, collectionId, RequiresHumanApproval: false)); + + var result = await sutProvider.Sut.PreCheckAsync(userId, cipherId); + + Assert.Equal(AccessApprovalOutcome.Automatic, result.Outcome); + } + + [Theory, BitAutoData] + public async Task PreCheckAsync_NotLeasingGated_ReturnsAutomatic( + SutProvider sutProvider, Guid userId, Guid cipherId) + { + SetupCipher(sutProvider, userId, cipherId); + sutProvider.GetDependency() + .ResolveAsync(userId, cipherId) + .Returns((AccessApprovalResolution?)null); + + var result = await sutProvider.Sut.PreCheckAsync(userId, cipherId); + + Assert.Equal(AccessApprovalOutcome.Automatic, result.Outcome); + } + + private static void SetupCipher(SutProvider sutProvider, Guid userId, Guid cipherId) + { + sutProvider.GetDependency() + .GetByIdAsync(cipherId, userId) + .Returns(new CipherDetails { Id = cipherId }); + } +} diff --git a/test/Core.Test/Pam/Services/AccessApprovalResolverTests.cs b/test/Core.Test/Pam/Services/AccessApprovalResolverTests.cs new file mode 100644 index 000000000000..716c419904bd --- /dev/null +++ b/test/Core.Test/Pam/Services/AccessApprovalResolverTests.cs @@ -0,0 +1,111 @@ +using Bit.Core.Entities; +using Bit.Core.Pam.Entities; +using Bit.Core.Pam.Repositories; +using Bit.Core.Pam.Services; +using Bit.Core.Repositories; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Pam.Services; + +[SutProviderCustomize] +public class AccessApprovalResolverTests +{ + [Theory, BitAutoData] + public async Task ResolveAsync_NoReachableCollections_ReturnsNull( + SutProvider sutProvider, Guid userId, Guid cipherId) + { + sutProvider.GetDependency() + .GetManyByUserIdCipherIdAsync(userId, cipherId) + .Returns(new List()); + + Assert.Null(await sutProvider.Sut.ResolveAsync(userId, cipherId)); + } + + [Theory, BitAutoData] + public async Task ResolveAsync_CollectionWithoutAccessRule_ReturnsNull( + SutProvider sutProvider, Guid userId, Guid cipherId, Collection collection) + { + collection.AccessRuleId = null; + SetupReachableCollections(sutProvider, userId, cipherId, collection); + + Assert.Null(await sutProvider.Sut.ResolveAsync(userId, cipherId)); + } + + [Theory, BitAutoData] + public async Task ResolveAsync_HumanApprovalRule_RequiresHumanApproval( + SutProvider sutProvider, Guid userId, Guid cipherId, Collection collection, AccessRule rule) + { + rule.Rule = """{"kind":"human_approval"}"""; + SetupGovernedCollection(sutProvider, userId, cipherId, collection, rule); + + var result = await sutProvider.Sut.ResolveAsync(userId, cipherId); + + Assert.NotNull(result); + Assert.True(result!.RequiresHumanApproval); + Assert.Equal(collection.Id, result.CollectionId); + Assert.Equal(collection.OrganizationId, result.OrganizationId); + } + + [Theory, BitAutoData] + public async Task ResolveAsync_IpAllowlistRule_DoesNotRequireHumanApproval( + SutProvider sutProvider, Guid userId, Guid cipherId, Collection collection, AccessRule rule) + { + rule.Rule = """{"kind":"ip_allowlist","cidrs":["10.0.0.0/8"]}"""; + SetupGovernedCollection(sutProvider, userId, cipherId, collection, rule); + + var result = await sutProvider.Sut.ResolveAsync(userId, cipherId); + + Assert.NotNull(result); + Assert.False(result!.RequiresHumanApproval); + } + + [Theory, BitAutoData] + public async Task ResolveAsync_AllOfContainingHumanApproval_RequiresHumanApproval( + SutProvider sutProvider, Guid userId, Guid cipherId, Collection collection, AccessRule rule) + { + rule.Rule = """{"kind":"all_of","rules":[{"kind":"ip_allowlist","cidrs":["10.0.0.0/8"]},{"kind":"human_approval"}]}"""; + SetupGovernedCollection(sutProvider, userId, cipherId, collection, rule); + + var result = await sutProvider.Sut.ResolveAsync(userId, cipherId); + + Assert.NotNull(result); + Assert.True(result!.RequiresHumanApproval); + } + + [Theory, BitAutoData] + public async Task ResolveAsync_MalformedRule_FailsSafeToHumanApproval( + SutProvider sutProvider, Guid userId, Guid cipherId, Collection collection, AccessRule rule) + { + rule.Rule = "not json"; + SetupGovernedCollection(sutProvider, userId, cipherId, collection, rule); + + var result = await sutProvider.Sut.ResolveAsync(userId, cipherId); + + Assert.NotNull(result); + Assert.True(result!.RequiresHumanApproval); + } + + private static void SetupReachableCollections( + SutProvider sutProvider, Guid userId, Guid cipherId, params Collection[] collections) + { + sutProvider.GetDependency() + .GetManyByUserIdCipherIdAsync(userId, cipherId) + .Returns(collections.Select(c => new CollectionCipher { CollectionId = c.Id, CipherId = cipherId }).ToList()); + sutProvider.GetDependency() + .GetManyByManyIdsAsync(Arg.Any>()) + .Returns(collections.ToList()); + } + + private static void SetupGovernedCollection( + SutProvider sutProvider, Guid userId, Guid cipherId, Collection collection, AccessRule rule) + { + collection.AccessRuleId = rule.Id; + SetupReachableCollections(sutProvider, userId, cipherId, collection); + sutProvider.GetDependency() + .GetByIdAsync(rule.Id) + .Returns(rule); + } +} diff --git a/test/Core.Test/PrivilegedAccessManagement/Services/AccessRuleValidatorTests.cs b/test/Core.Test/Pam/Services/AccessRuleValidatorTests.cs similarity index 97% rename from test/Core.Test/PrivilegedAccessManagement/Services/AccessRuleValidatorTests.cs rename to test/Core.Test/Pam/Services/AccessRuleValidatorTests.cs index b4c8c01791e6..14cec8cdb33a 100644 --- a/test/Core.Test/PrivilegedAccessManagement/Services/AccessRuleValidatorTests.cs +++ b/test/Core.Test/Pam/Services/AccessRuleValidatorTests.cs @@ -1,7 +1,7 @@ -using Bit.Core.PrivilegedAccessManagement.Services; +using Bit.Core.Pam.Services; using Xunit; -namespace Bit.Core.Test.PrivilegedAccessManagement.Services; +namespace Bit.Core.Test.Pam.Services; public class AccessRuleValidatorTests { diff --git a/test/Infrastructure.IntegrationTest/PrivilegedAccessManagement/Repositories/AccessRuleRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Pam/Repositories/AccessRuleRepositoryTests.cs similarity index 90% rename from test/Infrastructure.IntegrationTest/PrivilegedAccessManagement/Repositories/AccessRuleRepositoryTests.cs rename to test/Infrastructure.IntegrationTest/Pam/Repositories/AccessRuleRepositoryTests.cs index 920e3ea52148..a5e0f69dbed3 100644 --- a/test/Infrastructure.IntegrationTest/PrivilegedAccessManagement/Repositories/AccessRuleRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/Pam/Repositories/AccessRuleRepositoryTests.cs @@ -1,11 +1,11 @@ using Bit.Core.Entities; -using Bit.Core.PrivilegedAccessManagement.Entities; -using Bit.Core.PrivilegedAccessManagement.Repositories; +using Bit.Core.Pam.Entities; +using Bit.Core.Pam.Repositories; using Bit.Core.Repositories; using Bit.Infrastructure.IntegrationTest.AdminConsole; using Xunit; -namespace Bit.Infrastructure.IntegrationTest.PrivilegedAccessManagement.Repositories; +namespace Bit.Infrastructure.IntegrationTest.Pam.Repositories; public class AccessRuleRepositoryTests { diff --git a/test/Infrastructure.IntegrationTest/Pam/Repositories/LeaseRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Pam/Repositories/LeaseRepositoryTests.cs new file mode 100644 index 000000000000..d15a8bc9d427 --- /dev/null +++ b/test/Infrastructure.IntegrationTest/Pam/Repositories/LeaseRepositoryTests.cs @@ -0,0 +1,145 @@ +using Bit.Core.Pam.Entities; +using Bit.Core.Pam.Enums; +using Bit.Core.Pam.Repositories; +using Bit.Core.Repositories; +using Bit.Core.Utilities; +using Bit.Infrastructure.IntegrationTest.AdminConsole; +using Xunit; + +namespace Bit.Infrastructure.IntegrationTest.Pam.Repositories; + +public class LeaseRepositoryTests +{ + [DatabaseTheory, DatabaseData] + public async Task CreateAutoApprovedAsync_PersistsApprovedRequestDecisionAndActiveLease( + IOrganizationRepository organizationRepository, + ILeaseRepository leaseRepository, + ILeaseRequestRepository leaseRequestRepository) + { + var organization = await organizationRepository.CreateTestOrganizationAsync(); + var now = DateTime.UtcNow; + var cipherId = Guid.NewGuid(); + var requesterId = Guid.NewGuid(); + + var (request, decision, lease) = BuildAutoApproved(organization.Id, cipherId, requesterId, now, now.AddHours(1)); + + await leaseRepository.CreateAutoApprovedAsync(request, decision, lease, now); + + var persistedRequest = await leaseRequestRepository.GetByIdAsync(request.Id); + Assert.NotNull(persistedRequest); + Assert.Equal(LeaseRequestStatus.Approved, persistedRequest!.Status); + Assert.NotNull(persistedRequest.ResolvedDate); + + var persistedLease = await leaseRepository.GetByIdAsync(lease.Id); + Assert.NotNull(persistedLease); + Assert.Equal(LeaseStatus.Active, persistedLease!.Status); + Assert.Equal(request.Id, persistedLease.LeaseRequestId); + } + + [DatabaseTheory, DatabaseData] + public async Task GetActiveByRequesterIdCipherIdAsync_WithinWindow_ReturnsLease( + IOrganizationRepository organizationRepository, + ILeaseRepository leaseRepository) + { + var organization = await organizationRepository.CreateTestOrganizationAsync(); + var now = DateTime.UtcNow; + var cipherId = Guid.NewGuid(); + var requesterId = Guid.NewGuid(); + + var (request, decision, lease) = BuildAutoApproved( + organization.Id, cipherId, requesterId, now.AddMinutes(-5), now.AddHours(1)); + await leaseRepository.CreateAutoApprovedAsync(request, decision, lease, now); + + var active = await leaseRepository.GetActiveByRequesterIdCipherIdAsync(requesterId, cipherId, now); + + Assert.NotNull(active); + Assert.Equal(lease.Id, active!.Id); + } + + [DatabaseTheory, DatabaseData] + public async Task GetActiveByRequesterIdCipherIdAsync_OutsideWindow_ReturnsNull( + IOrganizationRepository organizationRepository, + ILeaseRepository leaseRepository) + { + var organization = await organizationRepository.CreateTestOrganizationAsync(); + var now = DateTime.UtcNow; + var cipherId = Guid.NewGuid(); + var requesterId = Guid.NewGuid(); + + // A lease whose window has already elapsed. + var (request, decision, lease) = BuildAutoApproved( + organization.Id, cipherId, requesterId, now.AddHours(-2), now.AddHours(-1)); + await leaseRepository.CreateAutoApprovedAsync(request, decision, lease, now.AddHours(-2)); + + var active = await leaseRepository.GetActiveByRequesterIdCipherIdAsync(requesterId, cipherId, now); + + Assert.Null(active); + } + + [DatabaseTheory, DatabaseData] + public async Task GetActivePendingByRequesterIdCipherIdAsync_ReturnsPendingRequest( + IOrganizationRepository organizationRepository, + ILeaseRequestRepository leaseRequestRepository) + { + var organization = await organizationRepository.CreateTestOrganizationAsync(); + var now = DateTime.UtcNow; + var cipherId = Guid.NewGuid(); + var requesterId = Guid.NewGuid(); + + var request = await leaseRequestRepository.CreateAsync(new LeaseRequest + { + OrganizationId = organization.Id, + CollectionId = Guid.NewGuid(), + CipherId = cipherId, + RequesterId = requesterId, + NotBefore = now.AddHours(1), + NotAfter = now.AddHours(2), + Reason = "audit", + Status = LeaseRequestStatus.Pending, + CreationDate = now, + }); + + var pending = await leaseRequestRepository.GetActivePendingByRequesterIdCipherIdAsync(requesterId, cipherId); + + Assert.NotNull(pending); + Assert.Equal(request.Id, pending!.Id); + Assert.Equal("audit", pending.Reason); + } + + private static (LeaseRequest, LeaseDecision, Lease) BuildAutoApproved( + Guid organizationId, Guid cipherId, Guid requesterId, DateTime notBefore, DateTime notAfter) + { + var collectionId = Guid.NewGuid(); + var request = new LeaseRequest + { + Id = CoreHelpers.GenerateComb(), + OrganizationId = organizationId, + CollectionId = collectionId, + CipherId = cipherId, + RequesterId = requesterId, + NotBefore = notBefore, + NotAfter = notAfter, + Status = LeaseRequestStatus.Approved, + }; + var decision = new LeaseDecision + { + Id = CoreHelpers.GenerateComb(), + LeaseRequestId = request.Id, + DeciderKind = LeaseDecisionKind.Policy, + Decision = LeaseDecisionVerdict.Approve, + }; + var lease = new Lease + { + Id = CoreHelpers.GenerateComb(), + LeaseRequestId = request.Id, + OrganizationId = organizationId, + CollectionId = collectionId, + CipherId = cipherId, + RequesterId = requesterId, + Status = LeaseStatus.Active, + NotBefore = notBefore, + NotAfter = notAfter, + }; + return (request, decision, lease); + } +} diff --git a/util/Migrator/DbScripts/2026-06-04_00_AddLeaseTables.sql b/util/Migrator/DbScripts/2026-06-04_00_AddLeaseTables.sql new file mode 100644 index 000000000000..e4459debbb5c --- /dev/null +++ b/util/Migrator/DbScripts/2026-06-04_00_AddLeaseTables.sql @@ -0,0 +1,244 @@ +-- PAM Credential Leasing: LeaseRequest / Lease / LeaseDecision tables + procedures. + +-- LeaseRequest (created first; the FK to Lease is added later, once Lease exists). +IF OBJECT_ID('[dbo].[LeaseRequest]') IS NULL +BEGIN + CREATE TABLE [dbo].[LeaseRequest] ( + [Id] UNIQUEIDENTIFIER NOT NULL, + [LeaseId] UNIQUEIDENTIFIER NULL, + [OrganizationId] UNIQUEIDENTIFIER NOT NULL, + [CollectionId] UNIQUEIDENTIFIER NOT NULL, + [CipherId] UNIQUEIDENTIFIER NOT NULL, + [RequesterId] UNIQUEIDENTIFIER NOT NULL, + [NotBefore] DATETIME2 (7) NOT NULL, + [NotAfter] DATETIME2 (7) NOT NULL, + [Reason] NVARCHAR(MAX) NULL, + [Status] TINYINT NOT NULL, + [CreationDate] DATETIME2 (7) NOT NULL, + [ResolvedDate] DATETIME2 (7) NULL, + CONSTRAINT [PK_LeaseRequest] PRIMARY KEY CLUSTERED ([Id] ASC), + CONSTRAINT [FK_LeaseRequest_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]) ON DELETE CASCADE + ); +END +GO + +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE [Name] = 'IX_LeaseRequest_RequesterId_CipherId_Status' AND object_id = OBJECT_ID('[dbo].[LeaseRequest]')) +BEGIN + CREATE NONCLUSTERED INDEX [IX_LeaseRequest_RequesterId_CipherId_Status] + ON [dbo].[LeaseRequest] ([RequesterId] ASC, [CipherId] ASC, [Status] ASC); +END +GO + +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE [Name] = 'IX_LeaseRequest_OrganizationId_Status' AND object_id = OBJECT_ID('[dbo].[LeaseRequest]')) +BEGIN + CREATE NONCLUSTERED INDEX [IX_LeaseRequest_OrganizationId_Status] + ON [dbo].[LeaseRequest] ([OrganizationId] ASC, [Status] ASC); +END +GO + +-- Lease +IF OBJECT_ID('[dbo].[Lease]') IS NULL +BEGIN + CREATE TABLE [dbo].[Lease] ( + [Id] UNIQUEIDENTIFIER NOT NULL, + [LeaseRequestId] UNIQUEIDENTIFIER NOT NULL, + [OrganizationId] UNIQUEIDENTIFIER NOT NULL, + [CollectionId] UNIQUEIDENTIFIER NOT NULL, + [CipherId] UNIQUEIDENTIFIER NOT NULL, + [RequesterId] UNIQUEIDENTIFIER NOT NULL, + [Status] TINYINT NOT NULL, + [NotBefore] DATETIME2 (7) NOT NULL, + [NotAfter] DATETIME2 (7) NOT NULL, + [RevokedDate] DATETIME2 (7) NULL, + [RevokedBy] UNIQUEIDENTIFIER NULL, + [CreationDate] DATETIME2 (7) NOT NULL, + CONSTRAINT [PK_Lease] PRIMARY KEY CLUSTERED ([Id] ASC), + CONSTRAINT [FK_Lease_LeaseRequest] FOREIGN KEY ([LeaseRequestId]) REFERENCES [dbo].[LeaseRequest] ([Id]), + CONSTRAINT [FK_Lease_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]) ON DELETE CASCADE + ); +END +GO + +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE [Name] = 'IX_Lease_RequesterId_CipherId_Status' AND object_id = OBJECT_ID('[dbo].[Lease]')) +BEGIN + CREATE NONCLUSTERED INDEX [IX_Lease_RequesterId_CipherId_Status] + ON [dbo].[Lease] ([RequesterId] ASC, [CipherId] ASC, [Status] ASC); +END +GO + +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE [Name] = 'IX_Lease_NotAfter_Status' AND object_id = OBJECT_ID('[dbo].[Lease]')) +BEGIN + CREATE NONCLUSTERED INDEX [IX_Lease_NotAfter_Status] + ON [dbo].[Lease] ([NotAfter] ASC, [Status] ASC); +END +GO + +-- Now that Lease exists, add the reciprocal FK from LeaseRequest.LeaseId (used by future extension requests). +IF OBJECT_ID('[dbo].[FK_LeaseRequest_Lease]', 'F') IS NULL +BEGIN + ALTER TABLE [dbo].[LeaseRequest] + ADD CONSTRAINT [FK_LeaseRequest_Lease] FOREIGN KEY ([LeaseId]) REFERENCES [dbo].[Lease] ([Id]); +END +GO + +-- LeaseDecision +IF OBJECT_ID('[dbo].[LeaseDecision]') IS NULL +BEGIN + CREATE TABLE [dbo].[LeaseDecision] ( + [Id] UNIQUEIDENTIFIER NOT NULL, + [LeaseRequestId] UNIQUEIDENTIFIER NOT NULL, + [DeciderKind] TINYINT NOT NULL, + [ApproverId] UNIQUEIDENTIFIER NULL, + [PolicyKind] NVARCHAR(50) NULL, + [Decision] TINYINT NOT NULL, + [Comment] NVARCHAR(MAX) NULL, + [EvaluationContext] NVARCHAR(MAX) NULL, + [CreationDate] DATETIME2 (7) NOT NULL, + CONSTRAINT [PK_LeaseDecision] PRIMARY KEY CLUSTERED ([Id] ASC), + CONSTRAINT [FK_LeaseDecision_LeaseRequest] FOREIGN KEY ([LeaseRequestId]) REFERENCES [dbo].[LeaseRequest] ([Id]) ON DELETE CASCADE + ); +END +GO + +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE [Name] = 'IX_LeaseDecision_LeaseRequestId' AND object_id = OBJECT_ID('[dbo].[LeaseDecision]')) +BEGIN + CREATE NONCLUSTERED INDEX [IX_LeaseDecision_LeaseRequestId] + ON [dbo].[LeaseDecision] ([LeaseRequestId] ASC); +END +GO + +-- Stored procedures +CREATE OR ALTER PROCEDURE [dbo].[LeaseRequest_Create] + @Id UNIQUEIDENTIFIER OUTPUT, + @LeaseId UNIQUEIDENTIFIER = NULL, + @OrganizationId UNIQUEIDENTIFIER, + @CollectionId UNIQUEIDENTIFIER, + @CipherId UNIQUEIDENTIFIER, + @RequesterId UNIQUEIDENTIFIER, + @NotBefore DATETIME2(7), + @NotAfter DATETIME2(7), + @Reason NVARCHAR(MAX) = NULL, + @Status TINYINT, + @CreationDate DATETIME2(7), + @ResolvedDate DATETIME2(7) = NULL +AS +BEGIN + SET NOCOUNT ON + + INSERT INTO [dbo].[LeaseRequest] + ( + [Id], [LeaseId], [OrganizationId], [CollectionId], [CipherId], [RequesterId], + [NotBefore], [NotAfter], [Reason], [Status], [CreationDate], [ResolvedDate] + ) + VALUES + ( + @Id, @LeaseId, @OrganizationId, @CollectionId, @CipherId, @RequesterId, + @NotBefore, @NotAfter, @Reason, @Status, @CreationDate, @ResolvedDate + ) +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[LeaseRequest_ReadById] + @Id UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + SELECT * FROM [dbo].[LeaseRequest] WHERE [Id] = @Id +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[LeaseRequest_ReadActivePendingByRequesterIdCipherId] + @RequesterId UNIQUEIDENTIFIER, + @CipherId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + SELECT TOP 1 * + FROM [dbo].[LeaseRequest] + WHERE [RequesterId] = @RequesterId AND [CipherId] = @CipherId AND [Status] = 0 -- Pending + ORDER BY [CreationDate] DESC +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[Lease_ReadById] + @Id UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + SELECT * FROM [dbo].[Lease] WHERE [Id] = @Id +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[Lease_ReadActiveByRequesterIdCipherId] + @RequesterId UNIQUEIDENTIFIER, + @CipherId UNIQUEIDENTIFIER, + @Now DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + SELECT TOP 1 * + FROM [dbo].[Lease] + WHERE [RequesterId] = @RequesterId + AND [CipherId] = @CipherId + AND [Status] = 0 -- Active + AND [NotBefore] <= @Now + AND [NotAfter] > @Now + ORDER BY [NotAfter] DESC +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[Lease_CreateAutoApproved] + @LeaseRequestId UNIQUEIDENTIFIER, + @LeaseId UNIQUEIDENTIFIER, + @LeaseDecisionId UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @CollectionId UNIQUEIDENTIFIER, + @CipherId UNIQUEIDENTIFIER, + @RequesterId UNIQUEIDENTIFIER, + @NotBefore DATETIME2(7), + @NotAfter DATETIME2(7), + @Reason NVARCHAR(MAX) = NULL, + @PolicyKind NVARCHAR(50) = NULL, + @Now DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + BEGIN TRANSACTION Lease_CreateAutoApproved + + INSERT INTO [dbo].[LeaseRequest] + ( + [Id], [LeaseId], [OrganizationId], [CollectionId], [CipherId], [RequesterId], + [NotBefore], [NotAfter], [Reason], [Status], [CreationDate], [ResolvedDate] + ) + VALUES + ( + @LeaseRequestId, NULL, @OrganizationId, @CollectionId, @CipherId, @RequesterId, + @NotBefore, @NotAfter, @Reason, 1 /* Approved */, @Now, @Now + ) + + INSERT INTO [dbo].[LeaseDecision] + ( + [Id], [LeaseRequestId], [DeciderKind], [ApproverId], [PolicyKind], + [Decision], [Comment], [EvaluationContext], [CreationDate] + ) + VALUES + ( + @LeaseDecisionId, @LeaseRequestId, 0 /* Policy */, NULL, @PolicyKind, + 0 /* Approve */, NULL, NULL, @Now + ) + + INSERT INTO [dbo].[Lease] + ( + [Id], [LeaseRequestId], [OrganizationId], [CollectionId], [CipherId], [RequesterId], + [Status], [NotBefore], [NotAfter], [RevokedDate], [RevokedBy], [CreationDate] + ) + VALUES + ( + @LeaseId, @LeaseRequestId, @OrganizationId, @CollectionId, @CipherId, @RequesterId, + 0 /* Active */, @NotBefore, @NotAfter, NULL, NULL, @Now + ) + + COMMIT TRANSACTION Lease_CreateAutoApproved +END +GO diff --git a/util/MySqlMigrations/Migrations/20260526122321_AddAccessRule.Designer.cs b/util/MySqlMigrations/Migrations/20260526122321_AddAccessRule.Designer.cs index 1d1ae65841c4..0ee88d7956c7 100644 --- a/util/MySqlMigrations/Migrations/20260526122321_AddAccessRule.Designer.cs +++ b/util/MySqlMigrations/Migrations/20260526122321_AddAccessRule.Designer.cs @@ -2372,7 +2372,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("Installation", (string)null); }); - modelBuilder.Entity("Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Models.AccessRule", b => + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Pam.Models.AccessRule", b => { b.Property("Id") .HasColumnType("char(36)"); @@ -2916,7 +2916,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Collection", b => { - b.HasOne("Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Models.AccessRule", null) + b.HasOne("Bit.Infrastructure.EntityFramework.Pam.Models.AccessRule", null) .WithMany() .HasForeignKey("AccessRuleId") .OnDelete(DeleteBehavior.Restrict); @@ -3450,7 +3450,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Navigation("User"); }); - modelBuilder.Entity("Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Models.AccessRule", b => + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Pam.Models.AccessRule", b => { b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") .WithMany() diff --git a/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs b/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs index e863e737ae52..3019158d2c35 100644 --- a/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs +++ b/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs @@ -2374,7 +2374,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Installation", (string)null); }); - modelBuilder.Entity("Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Models.AccessRule", b => + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Pam.Models.AccessRule", b => { b.Property("Id") .HasColumnType("char(36)"); @@ -2918,7 +2918,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Collection", b => { - b.HasOne("Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Models.AccessRule", null) + b.HasOne("Bit.Infrastructure.EntityFramework.Pam.Models.AccessRule", null) .WithMany() .HasForeignKey("AccessRuleId") .OnDelete(DeleteBehavior.Restrict); @@ -3452,7 +3452,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("User"); }); - modelBuilder.Entity("Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Models.AccessRule", b => + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Pam.Models.AccessRule", b => { b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") .WithMany() diff --git a/util/PostgresMigrations/Migrations/20260526122317_AddAccessRule.Designer.cs b/util/PostgresMigrations/Migrations/20260526122317_AddAccessRule.Designer.cs index 2e94ab439b82..035f3b24d487 100644 --- a/util/PostgresMigrations/Migrations/20260526122317_AddAccessRule.Designer.cs +++ b/util/PostgresMigrations/Migrations/20260526122317_AddAccessRule.Designer.cs @@ -2378,7 +2378,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("Installation", (string)null); }); - modelBuilder.Entity("Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Models.AccessRule", b => + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Pam.Models.AccessRule", b => { b.Property("Id") .HasColumnType("uuid"); @@ -2922,7 +2922,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Collection", b => { - b.HasOne("Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Models.AccessRule", null) + b.HasOne("Bit.Infrastructure.EntityFramework.Pam.Models.AccessRule", null) .WithMany() .HasForeignKey("AccessRuleId") .OnDelete(DeleteBehavior.Restrict); @@ -3456,7 +3456,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Navigation("User"); }); - modelBuilder.Entity("Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Models.AccessRule", b => + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Pam.Models.AccessRule", b => { b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") .WithMany() diff --git a/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs b/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs index ed7e2bdd1750..19effc1069df 100644 --- a/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs +++ b/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs @@ -2380,7 +2380,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Installation", (string)null); }); - modelBuilder.Entity("Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Models.AccessRule", b => + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Pam.Models.AccessRule", b => { b.Property("Id") .HasColumnType("uuid"); @@ -2924,7 +2924,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Collection", b => { - b.HasOne("Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Models.AccessRule", null) + b.HasOne("Bit.Infrastructure.EntityFramework.Pam.Models.AccessRule", null) .WithMany() .HasForeignKey("AccessRuleId") .OnDelete(DeleteBehavior.Restrict); @@ -3458,7 +3458,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("User"); }); - modelBuilder.Entity("Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Models.AccessRule", b => + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Pam.Models.AccessRule", b => { b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") .WithMany() diff --git a/util/SqliteMigrations/Migrations/20260526122325_AddAccessRule.Designer.cs b/util/SqliteMigrations/Migrations/20260526122325_AddAccessRule.Designer.cs index 50b82702dc8f..ca7258fdf796 100644 --- a/util/SqliteMigrations/Migrations/20260526122325_AddAccessRule.Designer.cs +++ b/util/SqliteMigrations/Migrations/20260526122325_AddAccessRule.Designer.cs @@ -2361,7 +2361,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("Installation", (string)null); }); - modelBuilder.Entity("Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Models.AccessRule", b => + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Pam.Models.AccessRule", b => { b.Property("Id") .HasColumnType("TEXT"); @@ -2905,7 +2905,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Collection", b => { - b.HasOne("Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Models.AccessRule", null) + b.HasOne("Bit.Infrastructure.EntityFramework.Pam.Models.AccessRule", null) .WithMany() .HasForeignKey("AccessRuleId") .OnDelete(DeleteBehavior.Restrict); @@ -3439,7 +3439,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Navigation("User"); }); - modelBuilder.Entity("Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Models.AccessRule", b => + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Pam.Models.AccessRule", b => { b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") .WithMany() diff --git a/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs b/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs index 5b1b937e32b5..743df2bf70d9 100644 --- a/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs +++ b/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs @@ -2363,7 +2363,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Installation", (string)null); }); - modelBuilder.Entity("Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Models.AccessRule", b => + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Pam.Models.AccessRule", b => { b.Property("Id") .HasColumnType("TEXT"); @@ -2907,7 +2907,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Collection", b => { - b.HasOne("Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Models.AccessRule", null) + b.HasOne("Bit.Infrastructure.EntityFramework.Pam.Models.AccessRule", null) .WithMany() .HasForeignKey("AccessRuleId") .OnDelete(DeleteBehavior.Restrict); @@ -3441,7 +3441,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("User"); }); - modelBuilder.Entity("Bit.Infrastructure.EntityFramework.PrivilegedAccessManagement.Models.AccessRule", b => + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Pam.Models.AccessRule", b => { b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") .WithMany() From 47e8cd4772ff815ef5a0449071a5a34fce3f76a0 Mon Sep 17 00:00:00 2001 From: Patrik Svensson Date: Thu, 4 Jun 2026 15:18:08 +0200 Subject: [PATCH 10/54] Add endpoint for getting a leased cipher --- .../Pam/Controllers/CipherLeaseController.cs | 38 ++++++++- ...OrganizationServiceCollectionExtensions.cs | 1 + .../Queries/GetLeasedCipherQuery.cs | 38 +++++++++ .../Interfaces/IGetLeasedCipherQuery.cs | 13 +++ .../Controllers/CipherLeaseControllerTests.cs | 69 ++++++++++++++++ .../Pam/Queries/GetLeasedCipherQueryTests.cs | 82 +++++++++++++++++++ 6 files changed, 240 insertions(+), 1 deletion(-) create mode 100644 src/Core/Pam/OrganizationFeatures/Queries/GetLeasedCipherQuery.cs create mode 100644 src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IGetLeasedCipherQuery.cs create mode 100644 test/Api.Test/Pam/Controllers/CipherLeaseControllerTests.cs create mode 100644 test/Core.Test/Pam/Queries/GetLeasedCipherQueryTests.cs diff --git a/src/Api/Pam/Controllers/CipherLeaseController.cs b/src/Api/Pam/Controllers/CipherLeaseController.cs index 9b28a4020b24..429dcaff94bd 100644 --- a/src/Api/Pam/Controllers/CipherLeaseController.cs +++ b/src/Api/Pam/Controllers/CipherLeaseController.cs @@ -1,9 +1,13 @@ using Bit.Api.Pam.Models.Request; using Bit.Api.Pam.Models.Response; +using Bit.Api.Vault.Models.Response; using Bit.Core; +using Bit.Core.Exceptions; using Bit.Core.Pam.OrganizationFeatures.Commands.Interfaces; using Bit.Core.Pam.OrganizationFeatures.Queries.Interfaces; +using Bit.Core.Repositories; using Bit.Core.Services; +using Bit.Core.Settings; using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -16,7 +20,11 @@ namespace Bit.Api.Pam.Controllers; public class CipherLeaseController( IUserService userService, IAccessPreCheckQuery preCheckQuery, - IRequestAccessCommand requestAccessCommand) + IRequestAccessCommand requestAccessCommand, + IGetLeasedCipherQuery getLeasedCipherQuery, + IApplicationCacheService applicationCacheService, + ICollectionCipherRepository collectionCipherRepository, + GlobalSettings globalSettings) : Controller { /// @@ -42,4 +50,32 @@ public async Task Post(Guid id, [FromBody] AccessReq var result = await requestAccessCommand.RequestAccessAsync(userId, id, model.ToSubmission()); return new AccessRequestResponseModel(result); } + + /// + /// Returns the cipher with its complete data, but only if the caller currently holds an active lease for it. + /// This is the read-back counterpart to the partial data sync returns for leasing-gated ciphers. The data is + /// still client-encrypted; the lease only gates whether the server hands it over. + /// + [HttpGet("cipher")] + public async Task GetCipher(Guid id) + { + var user = await userService.GetUserByPrincipalAsync(User); + if (user == null) + { + throw new NotFoundException(); + } + + var cipher = await getLeasedCipherQuery.GetLeasedCipherAsync(user.Id, id); + if (cipher == null) + { + throw new NotFoundException(); + } + + var organizationAbility = cipher.OrganizationId.HasValue + ? await applicationCacheService.GetOrganizationAbilityAsync(cipher.OrganizationId.Value) + : null; + var collectionCiphers = await collectionCipherRepository.GetManyByUserIdCipherIdAsync(user.Id, id); + + return new CipherDetailsResponseModel(cipher, user, organizationAbility, globalSettings, collectionCiphers); + } } diff --git a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs index b9437448bcfc..e8a23d598544 100644 --- a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs +++ b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs @@ -201,6 +201,7 @@ public static void AddAccessRuleCommands(this IServiceCollection services) services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); } diff --git a/src/Core/Pam/OrganizationFeatures/Queries/GetLeasedCipherQuery.cs b/src/Core/Pam/OrganizationFeatures/Queries/GetLeasedCipherQuery.cs new file mode 100644 index 000000000000..39265e28e58e --- /dev/null +++ b/src/Core/Pam/OrganizationFeatures/Queries/GetLeasedCipherQuery.cs @@ -0,0 +1,38 @@ +using Bit.Core.Pam.OrganizationFeatures.Queries.Interfaces; +using Bit.Core.Pam.Repositories; +using Bit.Core.Vault.Models.Data; +using Bit.Core.Vault.Repositories; + +namespace Bit.Core.Pam.OrganizationFeatures.Queries; + +public class GetLeasedCipherQuery : IGetLeasedCipherQuery +{ + private readonly ICipherRepository _cipherRepository; + private readonly ILeaseRepository _leaseRepository; + private readonly TimeProvider _timeProvider; + + public GetLeasedCipherQuery( + ICipherRepository cipherRepository, + ILeaseRepository leaseRepository, + TimeProvider timeProvider) + { + _cipherRepository = cipherRepository; + _leaseRepository = leaseRepository; + _timeProvider = timeProvider; + } + + public async Task GetLeasedCipherAsync(Guid userId, Guid cipherId) + { + var now = _timeProvider.GetUtcNow().UtcDateTime; + + // Without an active lease whose window contains now, the caller is not entitled to the full data right now. + var lease = await _leaseRepository.GetActiveByRequesterIdCipherIdAsync(userId, cipherId, now); + if (lease is null) + { + return null; + } + + // GetByIdAsync filters by access, so a null result means the caller cannot see the cipher. + return await _cipherRepository.GetByIdAsync(cipherId, userId); + } +} diff --git a/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IGetLeasedCipherQuery.cs b/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IGetLeasedCipherQuery.cs new file mode 100644 index 000000000000..49d76d562e9d --- /dev/null +++ b/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IGetLeasedCipherQuery.cs @@ -0,0 +1,13 @@ +using Bit.Core.Vault.Models.Data; + +namespace Bit.Core.Pam.OrganizationFeatures.Queries.Interfaces; + +public interface IGetLeasedCipherQuery +{ + /// + /// Returns the cipher with its complete data, but only if the caller currently holds an active lease for it and + /// can otherwise access it. Returns null when there is no active lease or the caller cannot access the cipher, so + /// callers cannot distinguish "no lease" from "no such cipher". + /// + Task GetLeasedCipherAsync(Guid userId, Guid cipherId); +} diff --git a/test/Api.Test/Pam/Controllers/CipherLeaseControllerTests.cs b/test/Api.Test/Pam/Controllers/CipherLeaseControllerTests.cs new file mode 100644 index 000000000000..2c4782e909cd --- /dev/null +++ b/test/Api.Test/Pam/Controllers/CipherLeaseControllerTests.cs @@ -0,0 +1,69 @@ +using System.Security.Claims; +using Bit.Api.Pam.Controllers; +using Bit.Api.Vault.Models.Response; +using Bit.Core.Entities; +using Bit.Core.Exceptions; +using Bit.Core.Models.Data.Organizations; +using Bit.Core.Pam.OrganizationFeatures.Queries.Interfaces; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Vault.Models.Data; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using NSubstitute.ReturnsExtensions; +using Xunit; +using CipherType = Bit.Core.Vault.Enums.CipherType; + +namespace Bit.Api.Test.Pam.Controllers; + +[ControllerCustomize(typeof(CipherLeaseController))] +[SutProviderCustomize] +public class CipherLeaseControllerTests +{ + [Theory, BitAutoData] + public async Task GetCipher_NoLeasedCipher_ThrowsNotFound( + Guid id, User user, SutProvider sutProvider) + { + sutProvider.GetDependency() + .GetUserByPrincipalAsync(Arg.Any()) + .Returns(user); + sutProvider.GetDependency() + .GetLeasedCipherAsync(user.Id, id) + .ReturnsNull(); + + await Assert.ThrowsAsync(() => sutProvider.Sut.GetCipher(id)); + } + + [Theory, BitAutoData] + public async Task GetCipher_LeasedCipher_ReturnsFullData( + Guid id, Guid organizationId, User user, SutProvider sutProvider) + { + var cipher = new CipherDetails + { + Id = id, + OrganizationId = organizationId, + Type = CipherType.Login, + Data = "2.iv|ct|mac", + }; + sutProvider.GetDependency() + .GetUserByPrincipalAsync(Arg.Any()) + .Returns(user); + sutProvider.GetDependency() + .GetLeasedCipherAsync(user.Id, id) + .Returns(cipher); + sutProvider.GetDependency() + .GetManyByUserIdCipherIdAsync(user.Id, id) + .Returns(new List()); + sutProvider.GetDependency() + .GetOrganizationAbilityAsync(organizationId) + .Returns(new OrganizationAbility { Id = organizationId }); + + var result = await sutProvider.Sut.GetCipher(id); + + Assert.IsType(result); + Assert.Equal(id, result.Id); + Assert.Equal("2.iv|ct|mac", result.Data); // full data present + Assert.Null(result.PartialData); // isPartial == false + } +} diff --git a/test/Core.Test/Pam/Queries/GetLeasedCipherQueryTests.cs b/test/Core.Test/Pam/Queries/GetLeasedCipherQueryTests.cs new file mode 100644 index 000000000000..53257c2f86d8 --- /dev/null +++ b/test/Core.Test/Pam/Queries/GetLeasedCipherQueryTests.cs @@ -0,0 +1,82 @@ +using Bit.Core.Pam.Entities; +using Bit.Core.Pam.OrganizationFeatures.Queries; +using Bit.Core.Pam.Repositories; +using Bit.Core.Vault.Models.Data; +using Bit.Core.Vault.Repositories; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.Extensions.Time.Testing; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Pam.Queries; + +[SutProviderCustomize] +public class GetLeasedCipherQueryTests +{ + private static readonly DateTime _now = new(2026, 6, 4, 12, 0, 0, DateTimeKind.Utc); + + [Theory, BitAutoData] + public async Task GetLeasedCipherAsync_NoActiveLease_ReturnsNull(Guid userId, Guid cipherId) + { + var sutProvider = Setup(); + sutProvider.GetDependency() + .GetActiveByRequesterIdCipherIdAsync(userId, cipherId, _now) + .Returns((Lease?)null); + + var result = await sutProvider.Sut.GetLeasedCipherAsync(userId, cipherId); + + Assert.Null(result); + // Accessibility is never consulted when there is no lease. + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .GetByIdAsync(default, default); + } + + [Theory, BitAutoData] + public async Task GetLeasedCipherAsync_ActiveLeaseButCipherNotAccessible_ReturnsNull( + Guid userId, Guid cipherId, Lease lease) + { + var sutProvider = Setup(); + sutProvider.GetDependency() + .GetActiveByRequesterIdCipherIdAsync(userId, cipherId, _now) + .Returns(lease); + sutProvider.GetDependency() + .GetByIdAsync(cipherId, userId) + .Returns((CipherDetails?)null); + + var result = await sutProvider.Sut.GetLeasedCipherAsync(userId, cipherId); + + Assert.Null(result); + } + + [Theory, BitAutoData] + public async Task GetLeasedCipherAsync_ActiveLeaseAndAccessible_ReturnsCipher( + Guid userId, Guid cipherId, Lease lease) + { + var sutProvider = Setup(); + var cipher = new CipherDetails { Id = cipherId, Data = "2.iv|ct|mac" }; + sutProvider.GetDependency() + .GetActiveByRequesterIdCipherIdAsync(userId, cipherId, _now) + .Returns(lease); + sutProvider.GetDependency() + .GetByIdAsync(cipherId, userId) + .Returns(cipher); + + var result = await sutProvider.Sut.GetLeasedCipherAsync(userId, cipherId); + + Assert.NotNull(result); + Assert.Equal(cipherId, result!.Id); + Assert.Equal("2.iv|ct|mac", result.Data); + // The active-lease lookup uses the TimeProvider's now. + await sutProvider.GetDependency().Received(1) + .GetActiveByRequesterIdCipherIdAsync(userId, cipherId, _now); + } + + private static SutProvider Setup() + { + var sutProvider = new SutProvider().WithFakeTimeProvider().Create(); + sutProvider.GetDependency().SetUtcNow(_now); + return sutProvider; + } +} From 00a96e3fa5b382bf11468c41579f5aded19764bf Mon Sep 17 00:00:00 2001 From: Hinton Date: Fri, 5 Jun 2026 11:55:37 +0200 Subject: [PATCH 11/54] Add approver inbox endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete the server side of the PAM Approver Inbox: the four HTTP endpoints the web client calls plus the RefreshApproverInbox push. - GET /leasing/inbox/requests — pending queue for collections the caller can Manage - GET /leasing/inbox/history — resolved queue (90-day window) - POST /leasing/requests/{id}/decision — approve/deny a pending request - POST /leasing/leases/{id}/revoke — revoke an active lease Inbox queries and the decision/revoke commands authorize via a reusable Manage-on-collection predicate (IApproverCollectionAccessQuery); list endpoints filter by the manageable set, decision/revoke check a single collection. The decision endpoint records a human LeaseDecision and the revoke endpoint persists its reason as a LeaseDecision (no Lease schema change). State conflicts return 409; self-approval is blocked as 400 (Bitwarden clients treat 403 as a forced logout). Each inbox-affecting change (new pending request, decision, revoke) pushes RefreshApproverInbox (PushType 28) to every user who can Manage the collection, resolved via a new ICollectionRepository .GetManagingUserIdsAsync (Dapper + EF parity). Lease repositories remain Dapper/MSSQL-only. Adds unit tests (commands, queries, status mapper, predicate service, notifier, controller) and SqlServer integration tests for the new repository methods. --- .../Controllers/ApproverInboxController.cs | 72 ++++++ .../Request/LeaseDecisionRequestModel.cs | 31 +++ .../Models/Request/LeaseRevokeRequestModel.cs | 9 + .../InboxAccessRequestResponseModel.cs | 81 +++++++ .../Repositories/ICollectionRepository.cs | 8 + ...OrganizationServiceCollectionExtensions.cs | 6 + .../Pam/Models/InboxLeaseRequestDetails.cs | 44 ++++ src/Core/Pam/Models/InboxRequestStatus.cs | 28 +++ .../Pam/Models/LeaseDecisionSubmission.cs | 12 + .../Commands/DecideLeaseRequestCommand.cs | 94 ++++++++ .../Interfaces/IDecideLeaseRequestCommand.cs | 20 ++ .../Interfaces/IRevokeLeaseCommand.cs | 14 ++ .../Commands/RequestAccessCommand.cs | 7 + .../Commands/RevokeLeaseCommand.cs | 63 +++++ .../Queries/GetInboxHistoryQuery.cs | 40 ++++ .../Queries/GetInboxRequestsQuery.cs | 31 +++ .../Interfaces/IGetInboxHistoryQuery.cs | 12 + .../Interfaces/IGetInboxRequestsQuery.cs | 12 + src/Core/Pam/Repositories/ILeaseRepository.cs | 7 + .../Repositories/ILeaseRequestRepository.cs | 21 ++ .../Services/ApproverCollectionAccessQuery.cs | 62 +++++ .../Pam/Services/ApproverInboxNotifier.cs | 27 +++ .../IApproverCollectionAccessQuery.cs | 19 ++ .../Pam/Services/IApproverInboxNotifier.cs | 11 + .../Platform/Push/IPushNotificationService.cs | 16 ++ src/Core/Platform/Push/PushType.cs | 3 + .../Repositories/CollectionRepository.cs | 13 ++ .../Pam/Repositories/LeaseRepository.cs | 17 ++ .../Repositories/LeaseRequestRepository.cs | 54 +++++ .../Repositories/CollectionRepository.cs | 77 +++++++ src/Notifications/HubHelpers.cs | 12 + .../Collection_ReadManagingUserIds.sql | 50 ++++ ...equest_ReadInboxHistoryByCollectionIds.sql | 50 ++++ ...equest_ReadInboxPendingByCollectionIds.sql | 50 ++++ .../LeaseRequest_ResolveWithDecision.sql | 35 +++ .../Pam/Stored Procedures/Lease_Revoke.sql | 35 +++ .../ApproverInboxControllerTests.cs | 94 ++++++++ .../DecideLeaseRequestCommandTests.cs | 132 +++++++++++ .../Pam/Commands/RequestAccessCommandTests.cs | 16 ++ .../Pam/Commands/RevokeLeaseCommandTests.cs | 86 +++++++ .../Pam/Models/InboxRequestStatusTests.cs | 20 ++ .../Pam/Queries/GetInboxHistoryQueryTests.cs | 56 +++++ .../Pam/Queries/GetInboxRequestsQueryTests.cs | 45 ++++ .../ApproverCollectionAccessQueryTests.cs | 107 +++++++++ .../Services/ApproverInboxNotifierTests.cs | 41 ++++ ...ectionRepositoryGetManagingUserIdsTests.cs | 132 +++++++++++ .../Pam/Repositories/LeaseRepositoryTests.cs | 35 +++ .../LeaseRequestRepositoryTests.cs | 138 +++++++++++ .../2026-06-05_00_AddApproverInboxSprocs.sql | 216 ++++++++++++++++++ 49 files changed, 2261 insertions(+) create mode 100644 src/Api/Pam/Controllers/ApproverInboxController.cs create mode 100644 src/Api/Pam/Models/Request/LeaseDecisionRequestModel.cs create mode 100644 src/Api/Pam/Models/Request/LeaseRevokeRequestModel.cs create mode 100644 src/Api/Pam/Models/Response/InboxAccessRequestResponseModel.cs create mode 100644 src/Core/Pam/Models/InboxLeaseRequestDetails.cs create mode 100644 src/Core/Pam/Models/InboxRequestStatus.cs create mode 100644 src/Core/Pam/Models/LeaseDecisionSubmission.cs create mode 100644 src/Core/Pam/OrganizationFeatures/Commands/DecideLeaseRequestCommand.cs create mode 100644 src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IDecideLeaseRequestCommand.cs create mode 100644 src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IRevokeLeaseCommand.cs create mode 100644 src/Core/Pam/OrganizationFeatures/Commands/RevokeLeaseCommand.cs create mode 100644 src/Core/Pam/OrganizationFeatures/Queries/GetInboxHistoryQuery.cs create mode 100644 src/Core/Pam/OrganizationFeatures/Queries/GetInboxRequestsQuery.cs create mode 100644 src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IGetInboxHistoryQuery.cs create mode 100644 src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IGetInboxRequestsQuery.cs create mode 100644 src/Core/Pam/Services/ApproverCollectionAccessQuery.cs create mode 100644 src/Core/Pam/Services/ApproverInboxNotifier.cs create mode 100644 src/Core/Pam/Services/IApproverCollectionAccessQuery.cs create mode 100644 src/Core/Pam/Services/IApproverInboxNotifier.cs create mode 100644 src/Sql/dbo/AdminConsole/Stored Procedures/Collection_ReadManagingUserIds.sql create mode 100644 src/Sql/dbo/Pam/Stored Procedures/LeaseRequest_ReadInboxHistoryByCollectionIds.sql create mode 100644 src/Sql/dbo/Pam/Stored Procedures/LeaseRequest_ReadInboxPendingByCollectionIds.sql create mode 100644 src/Sql/dbo/Pam/Stored Procedures/LeaseRequest_ResolveWithDecision.sql create mode 100644 src/Sql/dbo/Pam/Stored Procedures/Lease_Revoke.sql create mode 100644 test/Api.Test/Pam/Controllers/ApproverInboxControllerTests.cs create mode 100644 test/Core.Test/Pam/Commands/DecideLeaseRequestCommandTests.cs create mode 100644 test/Core.Test/Pam/Commands/RevokeLeaseCommandTests.cs create mode 100644 test/Core.Test/Pam/Models/InboxRequestStatusTests.cs create mode 100644 test/Core.Test/Pam/Queries/GetInboxHistoryQueryTests.cs create mode 100644 test/Core.Test/Pam/Queries/GetInboxRequestsQueryTests.cs create mode 100644 test/Core.Test/Pam/Services/ApproverCollectionAccessQueryTests.cs create mode 100644 test/Core.Test/Pam/Services/ApproverInboxNotifierTests.cs create mode 100644 test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/CollectionRepositoryGetManagingUserIdsTests.cs create mode 100644 test/Infrastructure.IntegrationTest/Pam/Repositories/LeaseRequestRepositoryTests.cs create mode 100644 util/Migrator/DbScripts/2026-06-05_00_AddApproverInboxSprocs.sql diff --git a/src/Api/Pam/Controllers/ApproverInboxController.cs b/src/Api/Pam/Controllers/ApproverInboxController.cs new file mode 100644 index 000000000000..300ac12de48d --- /dev/null +++ b/src/Api/Pam/Controllers/ApproverInboxController.cs @@ -0,0 +1,72 @@ +using Bit.Api.Models.Response; +using Bit.Api.Pam.Models.Request; +using Bit.Api.Pam.Models.Response; +using Bit.Core; +using Bit.Core.Pam.OrganizationFeatures.Commands.Interfaces; +using Bit.Core.Pam.OrganizationFeatures.Queries.Interfaces; +using Bit.Core.Services; +using Bit.Core.Utilities; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Bit.Api.Pam.Controllers; + +[Route("leasing")] +[Authorize("Application")] +[RequireFeature(FeatureFlagKeys.Pam)] +public class ApproverInboxController( + IUserService userService, + IGetInboxRequestsQuery getInboxRequestsQuery, + IGetInboxHistoryQuery getInboxHistoryQuery, + IDecideLeaseRequestCommand decideLeaseRequestCommand, + IRevokeLeaseCommand revokeLeaseCommand) + : Controller +{ + /// + /// Returns the caller's pending approver queue: requests on collections the caller can Manage that are still + /// awaiting a decision. + /// + [HttpGet("inbox/requests")] + public async Task> GetRequests() + { + var userId = userService.GetProperUserId(User)!.Value; + var requests = await getInboxRequestsQuery.GetPendingAsync(userId); + return new ListResponseModel( + requests.Select(r => new InboxAccessRequestResponseModel(r))); + } + + /// + /// Returns the caller's resolved approver queue (decision history and lease outcomes) within the retention window. + /// + [HttpGet("inbox/history")] + public async Task> GetHistory() + { + var userId = userService.GetProperUserId(User)!.Value; + var history = await getInboxHistoryQuery.GetHistoryAsync(userId); + return new ListResponseModel( + history.Select(r => new InboxAccessRequestResponseModel(r))); + } + + /// + /// Approves or denies a pending lease request. The caller must be able to Manage the request's collection and may + /// not decide their own request. + /// + [HttpPost("requests/{id:guid}/decision")] + public async Task Decide(Guid id, [FromBody] LeaseDecisionRequestModel model) + { + var userId = userService.GetProperUserId(User)!.Value; + var result = await decideLeaseRequestCommand.DecideAsync(userId, id, model.ToSubmission()); + return new InboxAccessRequestResponseModel(result); + } + + /// + /// Revokes an active lease early. The caller must be able to Manage the lease's collection. + /// + [HttpPost("leases/{id:guid}/revoke")] + public async Task Revoke(Guid id, [FromBody] LeaseRevokeRequestModel model) + { + var userId = userService.GetProperUserId(User)!.Value; + await revokeLeaseCommand.RevokeAsync(userId, id, model.Reason); + return NoContent(); + } +} diff --git a/src/Api/Pam/Models/Request/LeaseDecisionRequestModel.cs b/src/Api/Pam/Models/Request/LeaseDecisionRequestModel.cs new file mode 100644 index 000000000000..957b2a5800cb --- /dev/null +++ b/src/Api/Pam/Models/Request/LeaseDecisionRequestModel.cs @@ -0,0 +1,31 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Core.Exceptions; +using Bit.Core.Pam.Enums; +using Bit.Core.Pam.Models; + +namespace Bit.Api.Pam.Models.Request; + +/// +/// An approver's decision on a pending lease request. is "approve" or "deny"; +/// is optional. +/// +public class LeaseDecisionRequestModel +{ + [Required] + public string Decision { get; set; } = null!; + + public string? Comment { get; set; } + + public LeaseDecisionSubmission ToSubmission() => new() + { + Verdict = ParseVerdict(Decision), + Comment = Comment, + }; + + private static LeaseDecisionVerdict ParseVerdict(string decision) => decision?.ToLowerInvariant() switch + { + "approve" => LeaseDecisionVerdict.Approve, + "deny" => LeaseDecisionVerdict.Deny, + _ => throw new BadRequestException("Decision must be either 'approve' or 'deny'."), + }; +} diff --git a/src/Api/Pam/Models/Request/LeaseRevokeRequestModel.cs b/src/Api/Pam/Models/Request/LeaseRevokeRequestModel.cs new file mode 100644 index 000000000000..2fbd79e63260 --- /dev/null +++ b/src/Api/Pam/Models/Request/LeaseRevokeRequestModel.cs @@ -0,0 +1,9 @@ +namespace Bit.Api.Pam.Models.Request; + +/// +/// A request to revoke an active lease early. is optional and retained for the audit trail. +/// +public class LeaseRevokeRequestModel +{ + public string? Reason { get; set; } +} diff --git a/src/Api/Pam/Models/Response/InboxAccessRequestResponseModel.cs b/src/Api/Pam/Models/Response/InboxAccessRequestResponseModel.cs new file mode 100644 index 000000000000..6bab0247b8a7 --- /dev/null +++ b/src/Api/Pam/Models/Response/InboxAccessRequestResponseModel.cs @@ -0,0 +1,81 @@ +using Bit.Core.Models.Api; +using Bit.Core.Pam.Models; + +namespace Bit.Api.Pam.Models.Response; + +/// +/// An approver-inbox row: the access request plus the denormalized display fields the client renders. Matches the +/// client's InboxAccessRequestResponse shape. Fields without a backing store in v1 (, +/// , ) are always null. +/// +public class InboxAccessRequestResponseModel : ResponseModel +{ + public InboxAccessRequestResponseModel(InboxLeaseRequestDetails details) + : base("inboxAccessRequest") + { + ArgumentNullException.ThrowIfNull(details); + + Id = details.Id; + CipherId = details.CipherId; + CollectionId = details.CollectionId; + OrganizationId = details.OrganizationId; + RequesterUserId = details.RequesterId; + Status = InboxRequestStatus.From(details.Status, details.ProducedLeaseId.HasValue); + RequestedNotBefore = details.NotBefore; + RequestedNotAfter = details.NotAfter; + RequestedTtlSeconds = (int)(details.NotAfter - details.NotBefore).TotalSeconds; + Reason = details.Reason; + SubmittedAt = details.CreationDate; + ResolvedAt = details.ResolvedDate; + ResolverUserId = details.ResolverId; + ResolverComment = details.ResolverComment; + LeaseId = details.ProducedLeaseId; + ExtensionOfLeaseId = details.ExtensionOfLeaseId; + CipherName = details.CipherName; + CollectionName = details.CollectionName; + RequesterName = details.RequesterName; + RequesterEmail = details.RequesterEmail; + } + + public Guid Id { get; } + public Guid CipherId { get; } + public Guid CollectionId { get; } + + /// The access rule that gated the cipher at submit time. Not tracked in v1. + public string? RuleId => null; + + public Guid OrganizationId { get; } + public Guid RequesterUserId { get; } + + /// pending | approved | activated | denied | cancelled | expired. + public string Status { get; } + + public DateTime RequestedNotBefore { get; } + public DateTime RequestedNotAfter { get; } + public int RequestedTtlSeconds { get; } + public string? Reason { get; } + public DateTime SubmittedAt { get; } + public DateTime? ResolvedAt { get; } + + /// Distinct from ; set when a ticket lapses. Not tracked in v1. + public DateTime? ExpiredAt => null; + + public Guid? ResolverUserId { get; } + public string? ResolverComment { get; } + + /// Set once an approved ticket has produced a lease. + public Guid? LeaseId { get; } + + /// The parent lease if this is an extension request. + public Guid? ExtensionOfLeaseId { get; } + + /// Only meaningful for approved on-demand tickets. Belongs to the out-of-scope redemption flow. + public DateTime? RedemptionDeadline => null; + + /// The cipher's client-encrypted name. The only cipher attribute exposed by the inbox. + public string? CipherName { get; } + + public string? CollectionName { get; } + public string? RequesterName { get; } + public string? RequesterEmail { get; } +} diff --git a/src/Core/AdminConsole/Repositories/ICollectionRepository.cs b/src/Core/AdminConsole/Repositories/ICollectionRepository.cs index 4f34c7a3187c..4848de9893a3 100644 --- a/src/Core/AdminConsole/Repositories/ICollectionRepository.cs +++ b/src/Core/AdminConsole/Repositories/ICollectionRepository.cs @@ -59,6 +59,14 @@ public interface ICollectionRepository : IRepository Task DeleteUserAsync(Guid collectionId, Guid organizationUserId); Task UpdateUsersAsync(Guid id, IEnumerable users); Task> GetManyUsersByIdAsync(Guid id); + + /// + /// Returns the distinct user ids of every confirmed member who can Manage the collection: direct Manage + /// assignments, Manage via group, org Owners/Admins (when the organization allows admin access to all collection + /// items), and Custom users with the EditAnyCollection permission. + /// + Task> GetManagingUserIdsAsync(Guid collectionId); + Task DeleteManyAsync(IEnumerable collectionIds); /// diff --git a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs index e8a23d598544..ba49bffa24a8 100644 --- a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs +++ b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs @@ -203,6 +203,12 @@ public static void AddAccessRuleCommands(this IServiceCollection services) services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); } private static void AddOrganizationGroupCommands(this IServiceCollection services) diff --git a/src/Core/Pam/Models/InboxLeaseRequestDetails.cs b/src/Core/Pam/Models/InboxLeaseRequestDetails.cs new file mode 100644 index 000000000000..be03d95aa6f1 --- /dev/null +++ b/src/Core/Pam/Models/InboxLeaseRequestDetails.cs @@ -0,0 +1,44 @@ +using Bit.Core.Pam.Enums; + +namespace Bit.Core.Pam.Models; + +/// +/// A lease request projected for the approver inbox: every field plus the +/// denormalized display data the client needs (cipher/collection names, requester identity), the lease the request +/// produced (if any), and the human resolver's identity/comment. Populated by a single join in the read procedures so +/// the client avoids an N+1. +/// +public class InboxLeaseRequestDetails +{ + public Guid Id { get; set; } + + /// The parent lease for an extension request; null for original requests. + public Guid? ExtensionOfLeaseId { get; set; } + + public Guid OrganizationId { get; set; } + public Guid CollectionId { get; set; } + public Guid CipherId { get; set; } + public Guid RequesterId { get; set; } + public DateTime NotBefore { get; set; } + public DateTime NotAfter { get; set; } + public string? Reason { get; set; } + public LeaseRequestStatus Status { get; set; } + public DateTime CreationDate { get; set; } + public DateTime? ResolvedDate { get; set; } + + /// The lease this request birthed once redeemed, or null if it has not produced a lease. + public Guid? ProducedLeaseId { get; set; } + + /// The human approver who resolved the request, or null (e.g. still pending or auto-resolved). + public Guid? ResolverId { get; set; } + + /// The human approver's comment, if any. + public string? ResolverComment { get; set; } + + /// The cipher's client-encrypted name. The only cipher attribute the inbox exposes. + public string? CipherName { get; set; } + + public string? CollectionName { get; set; } + public string? RequesterName { get; set; } + public string? RequesterEmail { get; set; } +} diff --git a/src/Core/Pam/Models/InboxRequestStatus.cs b/src/Core/Pam/Models/InboxRequestStatus.cs new file mode 100644 index 000000000000..847d7f1e9687 --- /dev/null +++ b/src/Core/Pam/Models/InboxRequestStatus.cs @@ -0,0 +1,28 @@ +using Bit.Core.Pam.Enums; + +namespace Bit.Core.Pam.Models; + +/// +/// Maps the backend (plus whether the request has produced a lease) to the status +/// vocabulary the approver-inbox client expects: pending | approved | activated | denied | cancelled | expired. +/// An approved request that has produced a lease is reported as activated. +/// +public static class InboxRequestStatus +{ + public const string Pending = "pending"; + public const string Approved = "approved"; + public const string Activated = "activated"; + public const string Denied = "denied"; + public const string Cancelled = "cancelled"; + public const string Expired = "expired"; + + public static string From(LeaseRequestStatus status, bool hasLease) => status switch + { + LeaseRequestStatus.Pending => Pending, + LeaseRequestStatus.Approved => hasLease ? Activated : Approved, + LeaseRequestStatus.Denied => Denied, + LeaseRequestStatus.Cancelled => Cancelled, + LeaseRequestStatus.ExpiredUnanswered => Expired, + _ => throw new ArgumentOutOfRangeException(nameof(status), status, null), + }; +} diff --git a/src/Core/Pam/Models/LeaseDecisionSubmission.cs b/src/Core/Pam/Models/LeaseDecisionSubmission.cs new file mode 100644 index 000000000000..d3597640eb55 --- /dev/null +++ b/src/Core/Pam/Models/LeaseDecisionSubmission.cs @@ -0,0 +1,12 @@ +using Bit.Core.Pam.Enums; + +namespace Bit.Core.Pam.Models; + +/// +/// An approver's decision on a pending lease request: approve or deny, with an optional comment. +/// +public sealed class LeaseDecisionSubmission +{ + public required LeaseDecisionVerdict Verdict { get; init; } + public string? Comment { get; init; } +} diff --git a/src/Core/Pam/OrganizationFeatures/Commands/DecideLeaseRequestCommand.cs b/src/Core/Pam/OrganizationFeatures/Commands/DecideLeaseRequestCommand.cs new file mode 100644 index 000000000000..cb20d9494334 --- /dev/null +++ b/src/Core/Pam/OrganizationFeatures/Commands/DecideLeaseRequestCommand.cs @@ -0,0 +1,94 @@ +using Bit.Core.Exceptions; +using Bit.Core.Pam.Entities; +using Bit.Core.Pam.Enums; +using Bit.Core.Pam.Models; +using Bit.Core.Pam.OrganizationFeatures.Commands.Interfaces; +using Bit.Core.Pam.Repositories; +using Bit.Core.Pam.Services; + +namespace Bit.Core.Pam.OrganizationFeatures.Commands; + +public class DecideLeaseRequestCommand : IDecideLeaseRequestCommand +{ + private readonly ILeaseRequestRepository _leaseRequestRepository; + private readonly IApproverCollectionAccessQuery _approverCollectionAccessQuery; + private readonly IApproverInboxNotifier _approverInboxNotifier; + private readonly TimeProvider _timeProvider; + + public DecideLeaseRequestCommand( + ILeaseRequestRepository leaseRequestRepository, + IApproverCollectionAccessQuery approverCollectionAccessQuery, + IApproverInboxNotifier approverInboxNotifier, + TimeProvider timeProvider) + { + _leaseRequestRepository = leaseRequestRepository; + _approverCollectionAccessQuery = approverCollectionAccessQuery; + _approverInboxNotifier = approverInboxNotifier; + _timeProvider = timeProvider; + } + + public async Task DecideAsync(Guid userId, Guid requestId, LeaseDecisionSubmission submission) + { + var request = await _leaseRequestRepository.GetByIdAsync(requestId); + + // 404 for both missing and not-visible, so the caller can't probe for requests they don't manage. + if (request is null || !await _approverCollectionAccessQuery.CanManageCollectionAsync(userId, request.CollectionId)) + { + throw new NotFoundException(); + } + + if (request.Status != LeaseRequestStatus.Pending) + { + throw new ConflictException("This request has already been resolved."); + } + + // Self-approval is blocked server-side even though the client disables the buttons. Surfaced as 400 rather + // than 403 because Bitwarden clients treat 403 as a forced logout. + if (request.RequesterId == userId) + { + throw new BadRequestException("You cannot decide your own request."); + } + + var now = _timeProvider.GetUtcNow().UtcDateTime; + var status = submission.Verdict == LeaseDecisionVerdict.Approve + ? LeaseRequestStatus.Approved + : LeaseRequestStatus.Denied; + + var decision = new LeaseDecision + { + LeaseRequestId = request.Id, + DeciderKind = LeaseDecisionKind.Human, + ApproverId = userId, + Decision = submission.Verdict, + Comment = string.IsNullOrWhiteSpace(submission.Comment) ? null : submission.Comment, + CreationDate = now, + }; + decision.SetNewId(); + + await _leaseRequestRepository.ResolveWithDecisionAsync(request, decision, status, now); + + // The request just left the pending queue; tell every approver of this collection to re-fetch. + await _approverInboxNotifier.NotifyCollectionApproversAsync(request.CollectionId); + + // The client repaints the row from Status, ResolvedAt, and ResolverComment, so those must be accurate; the + // denormalized display fields already live on the client's existing row. Project from what we just wrote + // rather than re-reading. + return new InboxLeaseRequestDetails + { + Id = request.Id, + ExtensionOfLeaseId = request.LeaseId, + OrganizationId = request.OrganizationId, + CollectionId = request.CollectionId, + CipherId = request.CipherId, + RequesterId = request.RequesterId, + NotBefore = request.NotBefore, + NotAfter = request.NotAfter, + Reason = request.Reason, + Status = status, + CreationDate = request.CreationDate, + ResolvedDate = now, + ResolverId = decision.ApproverId, + ResolverComment = decision.Comment, + }; + } +} diff --git a/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IDecideLeaseRequestCommand.cs b/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IDecideLeaseRequestCommand.cs new file mode 100644 index 000000000000..8059d2b2fd8b --- /dev/null +++ b/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IDecideLeaseRequestCommand.cs @@ -0,0 +1,20 @@ +using Bit.Core.Pam.Models; + +namespace Bit.Core.Pam.OrganizationFeatures.Commands.Interfaces; + +public interface IDecideLeaseRequestCommand +{ + /// + /// Approves or denies a pending lease request on behalf of an approver. The caller must be able to Manage the + /// request's collection and must not be the requester. Returns the updated inbox row. + /// + /// + /// The request does not exist or the caller cannot Manage its collection. + /// + /// The request is no longer pending. + /// + /// The caller is the requester (self-approval). The spec calls for 403 here, but Bitwarden clients treat 403 as a + /// forced logout, so this is surfaced as 400 — matching the existing convention in the Admin Console controllers. + /// + Task DecideAsync(Guid userId, Guid requestId, LeaseDecisionSubmission submission); +} diff --git a/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IRevokeLeaseCommand.cs b/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IRevokeLeaseCommand.cs new file mode 100644 index 000000000000..6d8fd72c447d --- /dev/null +++ b/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IRevokeLeaseCommand.cs @@ -0,0 +1,14 @@ +namespace Bit.Core.Pam.OrganizationFeatures.Commands.Interfaces; + +public interface IRevokeLeaseCommand +{ + /// + /// Revokes an active lease early. The caller must be able to Manage the lease's collection. The optional reason is + /// retained for the audit trail. + /// + /// + /// The lease does not exist or the caller cannot Manage its collection. + /// + /// The lease is not active. + Task RevokeAsync(Guid userId, Guid leaseId, string? reason); +} diff --git a/src/Core/Pam/OrganizationFeatures/Commands/RequestAccessCommand.cs b/src/Core/Pam/OrganizationFeatures/Commands/RequestAccessCommand.cs index fde75b65cd50..2d3658fff408 100644 --- a/src/Core/Pam/OrganizationFeatures/Commands/RequestAccessCommand.cs +++ b/src/Core/Pam/OrganizationFeatures/Commands/RequestAccessCommand.cs @@ -21,6 +21,7 @@ public class RequestAccessCommand : IRequestAccessCommand private readonly IAccessApprovalResolver _resolver; private readonly ILeaseRepository _leaseRepository; private readonly ILeaseRequestRepository _leaseRequestRepository; + private readonly IApproverInboxNotifier _approverInboxNotifier; private readonly TimeProvider _timeProvider; public RequestAccessCommand( @@ -28,12 +29,14 @@ public RequestAccessCommand( IAccessApprovalResolver resolver, ILeaseRepository leaseRepository, ILeaseRequestRepository leaseRequestRepository, + IApproverInboxNotifier approverInboxNotifier, TimeProvider timeProvider) { _cipherRepository = cipherRepository; _resolver = resolver; _leaseRepository = leaseRepository; _leaseRequestRepository = leaseRequestRepository; + _approverInboxNotifier = approverInboxNotifier; _timeProvider = timeProvider; } @@ -174,6 +177,10 @@ private async Task RequestHumanApprovalAsync( }; var created = await _leaseRequestRepository.CreateAsync(request); + + // A new request just entered the pending queue; tell every approver of this collection to re-fetch. + await _approverInboxNotifier.NotifyCollectionApproversAsync(created.CollectionId); + return AccessRequestResult.Human(created); } } diff --git a/src/Core/Pam/OrganizationFeatures/Commands/RevokeLeaseCommand.cs b/src/Core/Pam/OrganizationFeatures/Commands/RevokeLeaseCommand.cs new file mode 100644 index 000000000000..98ca3c5272f6 --- /dev/null +++ b/src/Core/Pam/OrganizationFeatures/Commands/RevokeLeaseCommand.cs @@ -0,0 +1,63 @@ +using Bit.Core.Exceptions; +using Bit.Core.Pam.Entities; +using Bit.Core.Pam.Enums; +using Bit.Core.Pam.OrganizationFeatures.Commands.Interfaces; +using Bit.Core.Pam.Repositories; +using Bit.Core.Pam.Services; + +namespace Bit.Core.Pam.OrganizationFeatures.Commands; + +public class RevokeLeaseCommand : IRevokeLeaseCommand +{ + private readonly ILeaseRepository _leaseRepository; + private readonly IApproverCollectionAccessQuery _approverCollectionAccessQuery; + private readonly IApproverInboxNotifier _approverInboxNotifier; + private readonly TimeProvider _timeProvider; + + public RevokeLeaseCommand( + ILeaseRepository leaseRepository, + IApproverCollectionAccessQuery approverCollectionAccessQuery, + IApproverInboxNotifier approverInboxNotifier, + TimeProvider timeProvider) + { + _leaseRepository = leaseRepository; + _approverCollectionAccessQuery = approverCollectionAccessQuery; + _approverInboxNotifier = approverInboxNotifier; + _timeProvider = timeProvider; + } + + public async Task RevokeAsync(Guid userId, Guid leaseId, string? reason) + { + var lease = await _leaseRepository.GetByIdAsync(leaseId); + + // 404 for both missing and not-visible, so the caller can't probe for leases they don't manage. + if (lease is null || !await _approverCollectionAccessQuery.CanManageCollectionAsync(userId, lease.CollectionId)) + { + throw new NotFoundException(); + } + + if (lease.Status != LeaseStatus.Active) + { + throw new ConflictException("This lease is not active."); + } + + var now = _timeProvider.GetUtcNow().UtcDateTime; + + // The reason has no dedicated column, so it is preserved as a human decision against the originating request. + var auditDecision = new LeaseDecision + { + LeaseRequestId = lease.LeaseRequestId, + DeciderKind = LeaseDecisionKind.Human, + ApproverId = userId, + Decision = LeaseDecisionVerdict.Deny, + Comment = string.IsNullOrWhiteSpace(reason) ? null : reason, + CreationDate = now, + }; + auditDecision.SetNewId(); + + await _leaseRepository.RevokeAsync(lease, auditDecision, now); + + // The active lease just drained; tell every approver of this collection to re-fetch. + await _approverInboxNotifier.NotifyCollectionApproversAsync(lease.CollectionId); + } +} diff --git a/src/Core/Pam/OrganizationFeatures/Queries/GetInboxHistoryQuery.cs b/src/Core/Pam/OrganizationFeatures/Queries/GetInboxHistoryQuery.cs new file mode 100644 index 000000000000..102b4efc135b --- /dev/null +++ b/src/Core/Pam/OrganizationFeatures/Queries/GetInboxHistoryQuery.cs @@ -0,0 +1,40 @@ +using Bit.Core.Pam.Models; +using Bit.Core.Pam.OrganizationFeatures.Queries.Interfaces; +using Bit.Core.Pam.Repositories; +using Bit.Core.Pam.Services; + +namespace Bit.Core.Pam.OrganizationFeatures.Queries; + +public class GetInboxHistoryQuery : IGetInboxHistoryQuery +{ + /// + /// How far back the resolved history reaches. Older activity may be omitted. v1 has no pagination. + /// + public const int HistoryRetentionDays = 90; + + private readonly IApproverCollectionAccessQuery _approverCollectionAccessQuery; + private readonly ILeaseRequestRepository _leaseRequestRepository; + private readonly TimeProvider _timeProvider; + + public GetInboxHistoryQuery( + IApproverCollectionAccessQuery approverCollectionAccessQuery, + ILeaseRequestRepository leaseRequestRepository, + TimeProvider timeProvider) + { + _approverCollectionAccessQuery = approverCollectionAccessQuery; + _leaseRequestRepository = leaseRequestRepository; + _timeProvider = timeProvider; + } + + public async Task> GetHistoryAsync(Guid userId) + { + var manageableCollectionIds = await _approverCollectionAccessQuery.GetManageableCollectionIdsAsync(userId); + if (manageableCollectionIds.Count == 0) + { + return new List(); + } + + var since = _timeProvider.GetUtcNow().UtcDateTime.AddDays(-HistoryRetentionDays); + return await _leaseRequestRepository.GetManyInboxHistoryByCollectionIdsAsync(manageableCollectionIds, since); + } +} diff --git a/src/Core/Pam/OrganizationFeatures/Queries/GetInboxRequestsQuery.cs b/src/Core/Pam/OrganizationFeatures/Queries/GetInboxRequestsQuery.cs new file mode 100644 index 000000000000..b1c731500e58 --- /dev/null +++ b/src/Core/Pam/OrganizationFeatures/Queries/GetInboxRequestsQuery.cs @@ -0,0 +1,31 @@ +using Bit.Core.Pam.Models; +using Bit.Core.Pam.OrganizationFeatures.Queries.Interfaces; +using Bit.Core.Pam.Repositories; +using Bit.Core.Pam.Services; + +namespace Bit.Core.Pam.OrganizationFeatures.Queries; + +public class GetInboxRequestsQuery : IGetInboxRequestsQuery +{ + private readonly IApproverCollectionAccessQuery _approverCollectionAccessQuery; + private readonly ILeaseRequestRepository _leaseRequestRepository; + + public GetInboxRequestsQuery( + IApproverCollectionAccessQuery approverCollectionAccessQuery, + ILeaseRequestRepository leaseRequestRepository) + { + _approverCollectionAccessQuery = approverCollectionAccessQuery; + _leaseRequestRepository = leaseRequestRepository; + } + + public async Task> GetPendingAsync(Guid userId) + { + var manageableCollectionIds = await _approverCollectionAccessQuery.GetManageableCollectionIdsAsync(userId); + if (manageableCollectionIds.Count == 0) + { + return new List(); + } + + return await _leaseRequestRepository.GetManyInboxPendingByCollectionIdsAsync(manageableCollectionIds); + } +} diff --git a/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IGetInboxHistoryQuery.cs b/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IGetInboxHistoryQuery.cs new file mode 100644 index 000000000000..cb97c1919714 --- /dev/null +++ b/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IGetInboxHistoryQuery.cs @@ -0,0 +1,12 @@ +using Bit.Core.Pam.Models; + +namespace Bit.Core.Pam.OrganizationFeatures.Queries.Interfaces; + +public interface IGetInboxHistoryQuery +{ + /// + /// Returns the resolved lease requests (no longer pending) the user can approve, within the history retention + /// window, for collections the user can Manage. Returns an empty collection when the user manages none. + /// + Task> GetHistoryAsync(Guid userId); +} diff --git a/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IGetInboxRequestsQuery.cs b/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IGetInboxRequestsQuery.cs new file mode 100644 index 000000000000..bfb8c67cf8ca --- /dev/null +++ b/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IGetInboxRequestsQuery.cs @@ -0,0 +1,12 @@ +using Bit.Core.Pam.Models; + +namespace Bit.Core.Pam.OrganizationFeatures.Queries.Interfaces; + +public interface IGetInboxRequestsQuery +{ + /// + /// Returns the pending lease requests the user can approve — those on collections the user can Manage. Returns an + /// empty collection when the user manages none. + /// + Task> GetPendingAsync(Guid userId); +} diff --git a/src/Core/Pam/Repositories/ILeaseRepository.cs b/src/Core/Pam/Repositories/ILeaseRepository.cs index ac4bd1437a71..c7505c643f8f 100644 --- a/src/Core/Pam/Repositories/ILeaseRepository.cs +++ b/src/Core/Pam/Repositories/ILeaseRepository.cs @@ -17,4 +17,11 @@ public interface ILeaseRepository /// This is the only way a is created, so the request, decision, and lease never diverge. /// Task CreateAutoApprovedAsync(LeaseRequest request, LeaseDecision decision, Lease lease, DateTime now); + + /// + /// Atomically revokes an active lease (setting its revoked date and revoker) and records the revocation reason as + /// a human against the lease's originating request. The decision must already + /// have its id assigned. + /// + Task RevokeAsync(Lease lease, LeaseDecision auditDecision, DateTime now); } diff --git a/src/Core/Pam/Repositories/ILeaseRequestRepository.cs b/src/Core/Pam/Repositories/ILeaseRequestRepository.cs index 8ff1bb690a63..dd79721997c8 100644 --- a/src/Core/Pam/Repositories/ILeaseRequestRepository.cs +++ b/src/Core/Pam/Repositories/ILeaseRequestRepository.cs @@ -1,4 +1,6 @@ using Bit.Core.Pam.Entities; +using Bit.Core.Pam.Enums; +using Bit.Core.Pam.Models; namespace Bit.Core.Pam.Repositories; @@ -12,4 +14,23 @@ public interface ILeaseRequestRepository /// Returns the caller's pending (unresolved) lease request for the cipher, or null if there is none. /// Task GetActivePendingByRequesterIdCipherIdAsync(Guid requesterId, Guid cipherId); + + /// + /// Returns the pending approver-inbox rows for the given collections, joined with their denormalized display + /// fields. An empty yields an empty result. + /// + Task> GetManyInboxPendingByCollectionIdsAsync(IEnumerable collectionIds); + + /// + /// Returns the resolved approver-inbox rows (anything no longer pending) created on or after + /// for the given collections. An empty yields an empty + /// result. + /// + Task> GetManyInboxHistoryByCollectionIdsAsync(IEnumerable collectionIds, DateTime since); + + /// + /// Atomically transitions a pending request to (setting its resolved date) and records + /// the approver's human . Both entities must already have their ids assigned. + /// + Task ResolveWithDecisionAsync(LeaseRequest request, LeaseDecision decision, LeaseRequestStatus status, DateTime now); } diff --git a/src/Core/Pam/Services/ApproverCollectionAccessQuery.cs b/src/Core/Pam/Services/ApproverCollectionAccessQuery.cs new file mode 100644 index 000000000000..260c9839032f --- /dev/null +++ b/src/Core/Pam/Services/ApproverCollectionAccessQuery.cs @@ -0,0 +1,62 @@ +using Bit.Core.Context; +using Bit.Core.Enums; +using Bit.Core.Repositories; +using Bit.Core.Services; + +namespace Bit.Core.Pam.Services; + +public class ApproverCollectionAccessQuery : IApproverCollectionAccessQuery +{ + private readonly ICollectionRepository _collectionRepository; + private readonly ICurrentContext _currentContext; + private readonly IApplicationCacheService _applicationCacheService; + + public ApproverCollectionAccessQuery( + ICollectionRepository collectionRepository, + ICurrentContext currentContext, + IApplicationCacheService applicationCacheService) + { + _collectionRepository = collectionRepository; + _currentContext = currentContext; + _applicationCacheService = applicationCacheService; + } + + public async Task> GetManageableCollectionIdsAsync(Guid userId) + { + // Collections the user is assigned with Manage (the repository aggregates Manage across direct and group + // access), mirroring BulkCollectionAuthorizationHandler. + var assigned = await _collectionRepository.GetManyByUserIdAsync(userId); + var manageable = assigned.Where(c => c.Manage).Select(c => c.Id).ToHashSet(); + + // Owners/Admins (when the org permits) and EditAnyCollection custom users can manage every collection in the + // organization, so fold those collections in too. + foreach (var org in _currentContext.Organizations) + { + var canManageAll = org.Permissions.EditAnyCollection; + if (!canManageAll && org.Type is OrganizationUserType.Owner or OrganizationUserType.Admin) + { + var ability = await _applicationCacheService.GetOrganizationAbilityAsync(org.Id); + canManageAll = ability?.AllowAdminAccessToAllCollectionItems ?? false; + } + + if (!canManageAll) + { + continue; + } + + var orgCollections = await _collectionRepository.GetManyByOrganizationIdAsync(org.Id); + foreach (var collection in orgCollections) + { + manageable.Add(collection.Id); + } + } + + return manageable; + } + + public async Task CanManageCollectionAsync(Guid userId, Guid collectionId) + { + var manageable = await GetManageableCollectionIdsAsync(userId); + return manageable.Contains(collectionId); + } +} diff --git a/src/Core/Pam/Services/ApproverInboxNotifier.cs b/src/Core/Pam/Services/ApproverInboxNotifier.cs new file mode 100644 index 000000000000..aadca81f86a5 --- /dev/null +++ b/src/Core/Pam/Services/ApproverInboxNotifier.cs @@ -0,0 +1,27 @@ +using Bit.Core.Platform.Push; +using Bit.Core.Repositories; + +namespace Bit.Core.Pam.Services; + +public class ApproverInboxNotifier : IApproverInboxNotifier +{ + private readonly ICollectionRepository _collectionRepository; + private readonly IPushNotificationService _pushNotificationService; + + public ApproverInboxNotifier( + ICollectionRepository collectionRepository, + IPushNotificationService pushNotificationService) + { + _collectionRepository = collectionRepository; + _pushNotificationService = pushNotificationService; + } + + public async Task NotifyCollectionApproversAsync(Guid collectionId) + { + var userIds = await _collectionRepository.GetManagingUserIdsAsync(collectionId); + foreach (var userId in userIds) + { + await _pushNotificationService.PushRefreshApproverInboxAsync(userId); + } + } +} diff --git a/src/Core/Pam/Services/IApproverCollectionAccessQuery.cs b/src/Core/Pam/Services/IApproverCollectionAccessQuery.cs new file mode 100644 index 000000000000..cd7317b8b9b6 --- /dev/null +++ b/src/Core/Pam/Services/IApproverCollectionAccessQuery.cs @@ -0,0 +1,19 @@ +namespace Bit.Core.Pam.Services; + +/// +/// Resolves which collections the current user can Manage — the single authorization predicate for the approver +/// inbox. A user "approves" a request iff they can Manage the collection that holds the request's cipher. The list +/// endpoints use the full set as a filter; the decision/revoke endpoints check a single collection. +/// +public interface IApproverCollectionAccessQuery +{ + /// + /// The ids of every collection the user can Manage: collections they are assigned with Manage (directly or via + /// group), plus all collections in any organization where they are an Owner/Admin (when the org allows admin + /// access to all collection items) or hold the EditAnyCollection permission. + /// + Task> GetManageableCollectionIdsAsync(Guid userId); + + /// Whether the user can Manage the given collection. + Task CanManageCollectionAsync(Guid userId, Guid collectionId); +} diff --git a/src/Core/Pam/Services/IApproverInboxNotifier.cs b/src/Core/Pam/Services/IApproverInboxNotifier.cs new file mode 100644 index 000000000000..122606e1f906 --- /dev/null +++ b/src/Core/Pam/Services/IApproverInboxNotifier.cs @@ -0,0 +1,11 @@ +namespace Bit.Core.Pam.Services; + +/// +/// Pushes the RefreshApproverInbox signal to every user who can Manage a collection, telling their clients to +/// re-fetch the approver inbox. Fired whenever something the inbox renders changes (a new pending request, a request +/// leaving pending, a lease being revoked). +/// +public interface IApproverInboxNotifier +{ + Task NotifyCollectionApproversAsync(Guid collectionId); +} diff --git a/src/Core/Platform/Push/IPushNotificationService.cs b/src/Core/Platform/Push/IPushNotificationService.cs index 125057be1ff8..7be20ae5ed40 100644 --- a/src/Core/Platform/Push/IPushNotificationService.cs +++ b/src/Core/Platform/Push/IPushNotificationService.cs @@ -425,6 +425,22 @@ Task PushRefreshSecurityTasksAsync(Guid userId) UserId = userId, #pragma warning disable BWP0001 // Type or member is obsolete Date = TimeProvider.GetUtcNow().UtcDateTime, +#pragma warning restore BWP0001 // Type or member is obsolete + }, + ExcludeCurrentContext = false, + }); + + Task PushRefreshApproverInboxAsync(Guid userId) + => PushAsync(new PushNotification + { + Type = PushType.RefreshApproverInbox, + Target = NotificationTarget.User, + TargetId = userId, + Payload = new UserPushNotification + { + UserId = userId, +#pragma warning disable BWP0001 // Type or member is obsolete + Date = TimeProvider.GetUtcNow().UtcDateTime, #pragma warning restore BWP0001 // Type or member is obsolete }, ExcludeCurrentContext = false, diff --git a/src/Core/Platform/Push/PushType.cs b/src/Core/Platform/Push/PushType.cs index c1569d5108fd..c271abaee169 100644 --- a/src/Core/Platform/Push/PushType.cs +++ b/src/Core/Platform/Push/PushType.cs @@ -105,4 +105,7 @@ public enum PushType : byte [NotificationInfo("@bitwarden/team-billing-dev", typeof(Billing.Models.PremiumStatusPushNotification))] PremiumStatusChanged = 27, + + [NotificationInfo("@bitwarden/team-vault-dev", typeof(Models.UserPushNotification))] + RefreshApproverInbox = 28, } diff --git a/src/Infrastructure.Dapper/AdminConsole/Repositories/CollectionRepository.cs b/src/Infrastructure.Dapper/AdminConsole/Repositories/CollectionRepository.cs index 85316ad63dea..a74d523671ce 100644 --- a/src/Infrastructure.Dapper/AdminConsole/Repositories/CollectionRepository.cs +++ b/src/Infrastructure.Dapper/AdminConsole/Repositories/CollectionRepository.cs @@ -370,6 +370,19 @@ public async Task> GetManyUsersByIdAsync( } } + public async Task> GetManagingUserIdsAsync(Guid collectionId) + { + using (var connection = new SqlConnection(ConnectionString)) + { + var results = await connection.QueryAsync( + $"[{Schema}].[Collection_ReadManagingUserIds]", + new { CollectionId = collectionId }, + commandType: CommandType.StoredProcedure); + + return results.ToList(); + } + } + public async Task CreateDefaultCollectionsAsync(Guid organizationId, IEnumerable organizationUserIds, string defaultCollectionName) { organizationUserIds = organizationUserIds.ToList(); diff --git a/src/Infrastructure.Dapper/Pam/Repositories/LeaseRepository.cs b/src/Infrastructure.Dapper/Pam/Repositories/LeaseRepository.cs index 5b6c01afe48f..705b01db4f05 100644 --- a/src/Infrastructure.Dapper/Pam/Repositories/LeaseRepository.cs +++ b/src/Infrastructure.Dapper/Pam/Repositories/LeaseRepository.cs @@ -53,4 +53,21 @@ await connection.ExecuteAsync( }, commandType: CommandType.StoredProcedure); } + + public async Task RevokeAsync(Lease lease, LeaseDecision auditDecision, DateTime now) + { + await using var connection = new SqlConnection(ConnectionString); + await connection.ExecuteAsync( + $"[{Schema}].[Lease_Revoke]", + new + { + LeaseId = lease.Id, + LeaseRequestId = lease.LeaseRequestId, + RevokedBy = auditDecision.ApproverId, + LeaseDecisionId = auditDecision.Id, + Reason = auditDecision.Comment, + Now = now, + }, + commandType: CommandType.StoredProcedure); + } } diff --git a/src/Infrastructure.Dapper/Pam/Repositories/LeaseRequestRepository.cs b/src/Infrastructure.Dapper/Pam/Repositories/LeaseRequestRepository.cs index 7bd872a794fa..1fc7ecd7e6c3 100644 --- a/src/Infrastructure.Dapper/Pam/Repositories/LeaseRequestRepository.cs +++ b/src/Infrastructure.Dapper/Pam/Repositories/LeaseRequestRepository.cs @@ -1,5 +1,7 @@ using System.Data; using Bit.Core.Pam.Entities; +using Bit.Core.Pam.Enums; +using Bit.Core.Pam.Models; using Bit.Core.Pam.Repositories; using Bit.Core.Settings; using Bit.Infrastructure.Dapper.Repositories; @@ -30,4 +32,56 @@ public LeaseRequestRepository(string connectionString, string readOnlyConnection return results.FirstOrDefault(); } + + public async Task> GetManyInboxPendingByCollectionIdsAsync(IEnumerable collectionIds) + { + var ids = collectionIds.ToList(); + if (ids.Count == 0) + { + return new List(); + } + + await using var connection = new SqlConnection(ConnectionString); + var results = await connection.QueryAsync( + $"[{Schema}].[LeaseRequest_ReadInboxPendingByCollectionIds]", + new { CollectionIds = ids.ToGuidIdArrayTVP() }, + commandType: CommandType.StoredProcedure); + + return results.ToList(); + } + + public async Task> GetManyInboxHistoryByCollectionIdsAsync(IEnumerable collectionIds, DateTime since) + { + var ids = collectionIds.ToList(); + if (ids.Count == 0) + { + return new List(); + } + + await using var connection = new SqlConnection(ConnectionString); + var results = await connection.QueryAsync( + $"[{Schema}].[LeaseRequest_ReadInboxHistoryByCollectionIds]", + new { CollectionIds = ids.ToGuidIdArrayTVP(), Since = since }, + commandType: CommandType.StoredProcedure); + + return results.ToList(); + } + + public async Task ResolveWithDecisionAsync(LeaseRequest request, LeaseDecision decision, LeaseRequestStatus status, DateTime now) + { + await using var connection = new SqlConnection(ConnectionString); + await connection.ExecuteAsync( + $"[{Schema}].[LeaseRequest_ResolveWithDecision]", + new + { + LeaseRequestId = request.Id, + Status = status, + LeaseDecisionId = decision.Id, + ApproverId = decision.ApproverId, + Decision = decision.Decision, + decision.Comment, + Now = now, + }, + commandType: CommandType.StoredProcedure); + } } diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/CollectionRepository.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/CollectionRepository.cs index 0df120fedb83..098cd1bd88a9 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/CollectionRepository.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/CollectionRepository.cs @@ -3,6 +3,7 @@ using Bit.Core.Enums; using Bit.Core.Models.Data; using Bit.Core.Repositories; +using Bit.Core.Utilities; using Bit.Infrastructure.EntityFramework.AdminConsole.Models; using Bit.Infrastructure.EntityFramework.AdminConsole.Repositories.Queries; using Bit.Infrastructure.EntityFramework.Repositories; @@ -538,6 +539,82 @@ public async Task> GetManyUsersByIdAsync( } } + public async Task> GetManagingUserIdsAsync(Guid collectionId) + { + using (var scope = ServiceScopeFactory.CreateScope()) + { + var dbContext = GetDatabaseContext(scope); + + var collection = await dbContext.Collections.FindAsync(collectionId); + if (collection == null) + { + return Array.Empty(); + } + + var organizationId = collection.OrganizationId; + + // Confirmed members of the organization with a linked account. + var confirmedOrgUsers = await dbContext.OrganizationUsers + .Where(ou => ou.OrganizationId == organizationId + && ou.Status == OrganizationUserStatusType.Confirmed + && ou.UserId != null) + .Select(ou => new { ou.Id, ou.UserId, ou.Type, ou.Permissions }) + .ToListAsync(); + var orgUsersById = confirmedOrgUsers.ToDictionary(ou => ou.Id); + + var managerUserIds = new HashSet(); + + void AddIfConfirmed(Guid organizationUserId) + { + if (orgUsersById.TryGetValue(organizationUserId, out var ou) && ou.UserId.HasValue) + { + managerUserIds.Add(ou.UserId.Value); + } + } + + // Direct Manage assignments. + var directManageOrgUserIds = await dbContext.CollectionUsers + .Where(cu => cu.CollectionId == collectionId && cu.Manage) + .Select(cu => cu.OrganizationUserId) + .ToListAsync(); + foreach (var organizationUserId in directManageOrgUserIds) + { + AddIfConfirmed(organizationUserId); + } + + // Manage via group membership. + var groupManageOrgUserIds = await (from cg in dbContext.CollectionGroups + join gu in dbContext.GroupUsers on cg.GroupId equals gu.GroupId + where cg.CollectionId == collectionId && cg.Manage + select gu.OrganizationUserId) + .ToListAsync(); + foreach (var organizationUserId in groupManageOrgUserIds) + { + AddIfConfirmed(organizationUserId); + } + + // Org Owners/Admins (when the org permits) and Custom users with EditAnyCollection (permission parsed in + // memory because the JSON column isn't portably queryable across providers). + var allowAdminAccess = await dbContext.Organizations + .Where(o => o.Id == organizationId) + .Select(o => o.AllowAdminAccessToAllCollectionItems) + .FirstOrDefaultAsync(); + foreach (var ou in confirmedOrgUsers) + { + var isAdminManager = allowAdminAccess + && ou.Type is OrganizationUserType.Owner or OrganizationUserType.Admin; + var hasEditAnyCollection = ou.Type == OrganizationUserType.Custom + && CoreHelpers.LoadClassFromJsonData(ou.Permissions).EditAnyCollection; + if ((isAdminManager || hasEditAnyCollection) && ou.UserId.HasValue) + { + managerUserIds.Add(ou.UserId.Value); + } + } + + return managerUserIds.ToList(); + } + } + public async Task ReplaceAsync(Core.Entities.Collection collection, IEnumerable? groups, IEnumerable? users) { diff --git a/src/Notifications/HubHelpers.cs b/src/Notifications/HubHelpers.cs index aaa64cb80c67..714ce894433d 100644 --- a/src/Notifications/HubHelpers.cs +++ b/src/Notifications/HubHelpers.cs @@ -232,6 +232,18 @@ await _hubContext.Clients.Group(NotificationsHub.GetOrganizationGroup( await _hubContext.Clients.User(pendingTasksData.Payload.UserId.ToString()) .SendAsync(_receiveMessageMethod, pendingTasksData, cancellationToken); break; + case PushType.RefreshApproverInbox: + var approverInboxData = + JsonSerializer.Deserialize>(notificationJson, + _deserializerOptions); + if (approverInboxData is null) + { + break; + } + + await _hubContext.Clients.User(approverInboxData.Payload.UserId.ToString()) + .SendAsync(_receiveMessageMethod, approverInboxData, cancellationToken); + break; case PushType.PolicyChanged: await policyChangedNotificationHandler(notificationJson, cancellationToken); break; diff --git a/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_ReadManagingUserIds.sql b/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_ReadManagingUserIds.sql new file mode 100644 index 000000000000..b0faaa9e1a03 --- /dev/null +++ b/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_ReadManagingUserIds.sql @@ -0,0 +1,50 @@ +CREATE PROCEDURE [dbo].[Collection_ReadManagingUserIds] + @CollectionId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + -- Every confirmed member who can Manage the collection: direct Manage assignments, Manage via group membership, + -- plus org Owners/Admins (when the org allows admin access to all collection items) and Custom users with the + -- EditAnyCollection permission. Returns distinct user ids. + DECLARE @OrganizationId UNIQUEIDENTIFIER + SELECT @OrganizationId = [OrganizationId] FROM [dbo].[Collection] WHERE [Id] = @CollectionId + + SELECT DISTINCT [UserId] + FROM + ( + SELECT OU.[UserId] + FROM [dbo].[CollectionUser] CU + INNER JOIN [dbo].[OrganizationUser] OU ON OU.[Id] = CU.[OrganizationUserId] + WHERE CU.[CollectionId] = @CollectionId + AND CU.[Manage] = 1 + AND OU.[Status] = 2 -- Confirmed + AND OU.[UserId] IS NOT NULL + + UNION + + SELECT OU.[UserId] + FROM [dbo].[CollectionGroup] CG + INNER JOIN [dbo].[GroupUser] GU ON GU.[GroupId] = CG.[GroupId] + INNER JOIN [dbo].[OrganizationUser] OU ON OU.[Id] = GU.[OrganizationUserId] + WHERE CG.[CollectionId] = @CollectionId + AND CG.[Manage] = 1 + AND OU.[Status] = 2 -- Confirmed + AND OU.[UserId] IS NOT NULL + + UNION + + SELECT OU.[UserId] + FROM [dbo].[OrganizationUser] OU + INNER JOIN [dbo].[Organization] O ON O.[Id] = OU.[OrganizationId] + WHERE OU.[OrganizationId] = @OrganizationId + AND OU.[Status] = 2 -- Confirmed + AND OU.[UserId] IS NOT NULL + AND ( + (O.[AllowAdminAccessToAllCollectionItems] = 1 AND OU.[Type] IN (0, 1)) -- Owner, Admin + OR (OU.[Type] = 4 -- Custom + AND ISJSON(OU.[Permissions]) = 1 + AND JSON_VALUE(OU.[Permissions], '$.editAnyCollection') = 'true') + ) + ) AS ManagingUsers +END diff --git a/src/Sql/dbo/Pam/Stored Procedures/LeaseRequest_ReadInboxHistoryByCollectionIds.sql b/src/Sql/dbo/Pam/Stored Procedures/LeaseRequest_ReadInboxHistoryByCollectionIds.sql new file mode 100644 index 000000000000..f9c314bfc0e1 --- /dev/null +++ b/src/Sql/dbo/Pam/Stored Procedures/LeaseRequest_ReadInboxHistoryByCollectionIds.sql @@ -0,0 +1,50 @@ +CREATE PROCEDURE [dbo].[LeaseRequest_ReadInboxHistoryByCollectionIds] + @CollectionIds [dbo].[GuidIdArray] READONLY, + @Since DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + -- The approver history: resolved requests (anything no longer Pending) created on or after @Since, for the + -- supplied (caller-manageable) collections. Same projection as the pending inbox. History rows that produced a + -- lease carry ProducedLeaseId so the client can target the Revoke action at the lease. + SELECT + LR.[Id], + LR.[LeaseId] AS [ExtensionOfLeaseId], + LR.[OrganizationId], + LR.[CollectionId], + LR.[CipherId], + LR.[RequesterId], + LR.[NotBefore], + LR.[NotAfter], + LR.[Reason], + LR.[Status], + LR.[CreationDate], + LR.[ResolvedDate], + PL.[Id] AS [ProducedLeaseId], + RES.[ApproverId] AS [ResolverId], + RES.[Comment] AS [ResolverComment], + JSON_VALUE(C.[Data], '$.Name') AS [CipherName], + COL.[Name] AS [CollectionName], + U.[Name] AS [RequesterName], + U.[Email] AS [RequesterEmail] + FROM [dbo].[LeaseRequest] LR + INNER JOIN @CollectionIds CI ON CI.[Id] = LR.[CollectionId] + LEFT JOIN [dbo].[Cipher] C ON C.[Id] = LR.[CipherId] + LEFT JOIN [dbo].[Collection] COL ON COL.[Id] = LR.[CollectionId] + LEFT JOIN [dbo].[User] U ON U.[Id] = LR.[RequesterId] + OUTER APPLY ( + SELECT TOP 1 L.[Id] + FROM [dbo].[Lease] L + WHERE L.[LeaseRequestId] = LR.[Id] + ORDER BY L.[CreationDate] DESC + ) PL + OUTER APPLY ( + SELECT TOP 1 LD.[ApproverId], LD.[Comment] + FROM [dbo].[LeaseDecision] LD + WHERE LD.[LeaseRequestId] = LR.[Id] AND LD.[DeciderKind] = 1 -- Human + ORDER BY LD.[CreationDate] ASC + ) RES + WHERE LR.[Status] <> 0 -- not Pending + AND LR.[CreationDate] >= @Since +END diff --git a/src/Sql/dbo/Pam/Stored Procedures/LeaseRequest_ReadInboxPendingByCollectionIds.sql b/src/Sql/dbo/Pam/Stored Procedures/LeaseRequest_ReadInboxPendingByCollectionIds.sql new file mode 100644 index 000000000000..51adb6e8b491 --- /dev/null +++ b/src/Sql/dbo/Pam/Stored Procedures/LeaseRequest_ReadInboxPendingByCollectionIds.sql @@ -0,0 +1,50 @@ +CREATE PROCEDURE [dbo].[LeaseRequest_ReadInboxPendingByCollectionIds] + @CollectionIds [dbo].[GuidIdArray] READONLY +AS +BEGIN + SET NOCOUNT ON + + -- The approver inbox: pending requests for the supplied (caller-manageable) collections, joined with the + -- denormalized display fields the client needs (cipher/collection names, requester identity) so it avoids an N+1. + -- ResolverId/ResolverComment come from the EARLIEST human decision so a later revocation decision (also human, + -- recorded against the same request) never overwrites the original approve/deny resolver. ProducedLeaseId is the + -- lease that the request birthed, if any. ExtensionOfLeaseId is the parent lease for extension requests. + SELECT + LR.[Id], + LR.[LeaseId] AS [ExtensionOfLeaseId], + LR.[OrganizationId], + LR.[CollectionId], + LR.[CipherId], + LR.[RequesterId], + LR.[NotBefore], + LR.[NotAfter], + LR.[Reason], + LR.[Status], + LR.[CreationDate], + LR.[ResolvedDate], + PL.[Id] AS [ProducedLeaseId], + RES.[ApproverId] AS [ResolverId], + RES.[Comment] AS [ResolverComment], + JSON_VALUE(C.[Data], '$.Name') AS [CipherName], + COL.[Name] AS [CollectionName], + U.[Name] AS [RequesterName], + U.[Email] AS [RequesterEmail] + FROM [dbo].[LeaseRequest] LR + INNER JOIN @CollectionIds CI ON CI.[Id] = LR.[CollectionId] + LEFT JOIN [dbo].[Cipher] C ON C.[Id] = LR.[CipherId] + LEFT JOIN [dbo].[Collection] COL ON COL.[Id] = LR.[CollectionId] + LEFT JOIN [dbo].[User] U ON U.[Id] = LR.[RequesterId] + OUTER APPLY ( + SELECT TOP 1 L.[Id] + FROM [dbo].[Lease] L + WHERE L.[LeaseRequestId] = LR.[Id] + ORDER BY L.[CreationDate] DESC + ) PL + OUTER APPLY ( + SELECT TOP 1 LD.[ApproverId], LD.[Comment] + FROM [dbo].[LeaseDecision] LD + WHERE LD.[LeaseRequestId] = LR.[Id] AND LD.[DeciderKind] = 1 -- Human + ORDER BY LD.[CreationDate] ASC + ) RES + WHERE LR.[Status] = 0 -- Pending +END diff --git a/src/Sql/dbo/Pam/Stored Procedures/LeaseRequest_ResolveWithDecision.sql b/src/Sql/dbo/Pam/Stored Procedures/LeaseRequest_ResolveWithDecision.sql new file mode 100644 index 000000000000..01cda2d5d9ca --- /dev/null +++ b/src/Sql/dbo/Pam/Stored Procedures/LeaseRequest_ResolveWithDecision.sql @@ -0,0 +1,35 @@ +CREATE PROCEDURE [dbo].[LeaseRequest_ResolveWithDecision] + @LeaseRequestId UNIQUEIDENTIFIER, + @Status TINYINT, + @LeaseDecisionId UNIQUEIDENTIFIER, + @ApproverId UNIQUEIDENTIFIER, + @Decision TINYINT, + @Comment NVARCHAR(MAX) = NULL, + @Now DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + -- Atomically resolve a pending request and record the human approver's decision. The caller has already verified + -- (and the application enforces) that the request is still Pending; the WHERE guard keeps the write idempotent + -- under a race so a second approver can't move an already-resolved request. + BEGIN TRANSACTION LeaseRequest_ResolveWithDecision + + UPDATE [dbo].[LeaseRequest] + SET [Status] = @Status, + [ResolvedDate] = @Now + WHERE [Id] = @LeaseRequestId AND [Status] = 0 -- Pending + + INSERT INTO [dbo].[LeaseDecision] + ( + [Id], [LeaseRequestId], [DeciderKind], [ApproverId], [PolicyKind], + [Decision], [Comment], [EvaluationContext], [CreationDate] + ) + VALUES + ( + @LeaseDecisionId, @LeaseRequestId, 1 /* Human */, @ApproverId, NULL, + @Decision, @Comment, NULL, @Now + ) + + COMMIT TRANSACTION LeaseRequest_ResolveWithDecision +END diff --git a/src/Sql/dbo/Pam/Stored Procedures/Lease_Revoke.sql b/src/Sql/dbo/Pam/Stored Procedures/Lease_Revoke.sql new file mode 100644 index 000000000000..e04fba7a1fed --- /dev/null +++ b/src/Sql/dbo/Pam/Stored Procedures/Lease_Revoke.sql @@ -0,0 +1,35 @@ +CREATE PROCEDURE [dbo].[Lease_Revoke] + @LeaseId UNIQUEIDENTIFIER, + @LeaseRequestId UNIQUEIDENTIFIER, + @RevokedBy UNIQUEIDENTIFIER, + @LeaseDecisionId UNIQUEIDENTIFIER, + @Reason NVARCHAR(MAX) = NULL, + @Now DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + -- Atomically revoke an active lease and capture who/why. The revocation reason has no dedicated column, so it is + -- preserved as a human LeaseDecision (Deny) against the lease's originating request, keeping the audit trail + -- without a schema change. The WHERE guard keeps revocation idempotent if two approvers race. + BEGIN TRANSACTION Lease_Revoke + + UPDATE [dbo].[Lease] + SET [Status] = 2 /* Revoked */, + [RevokedDate] = @Now, + [RevokedBy] = @RevokedBy + WHERE [Id] = @LeaseId AND [Status] = 0 -- Active + + INSERT INTO [dbo].[LeaseDecision] + ( + [Id], [LeaseRequestId], [DeciderKind], [ApproverId], [PolicyKind], + [Decision], [Comment], [EvaluationContext], [CreationDate] + ) + VALUES + ( + @LeaseDecisionId, @LeaseRequestId, 1 /* Human */, @RevokedBy, NULL, + 1 /* Deny */, @Reason, NULL, @Now + ) + + COMMIT TRANSACTION Lease_Revoke +END diff --git a/test/Api.Test/Pam/Controllers/ApproverInboxControllerTests.cs b/test/Api.Test/Pam/Controllers/ApproverInboxControllerTests.cs new file mode 100644 index 000000000000..902ca3ef672b --- /dev/null +++ b/test/Api.Test/Pam/Controllers/ApproverInboxControllerTests.cs @@ -0,0 +1,94 @@ +using System.Security.Claims; +using Bit.Api.Pam.Controllers; +using Bit.Api.Pam.Models.Request; +using Bit.Core.Exceptions; +using Bit.Core.Pam.Enums; +using Bit.Core.Pam.Models; +using Bit.Core.Pam.OrganizationFeatures.Commands.Interfaces; +using Bit.Core.Pam.OrganizationFeatures.Queries.Interfaces; +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.AspNetCore.Mvc; +using NSubstitute; +using Xunit; + +namespace Bit.Api.Test.Pam.Controllers; + +[ControllerCustomize(typeof(ApproverInboxController))] +[SutProviderCustomize] +public class ApproverInboxControllerTests +{ + [Theory, BitAutoData] + public async Task GetRequests_ReturnsMappedPendingRows( + Guid userId, InboxLeaseRequestDetails row, SutProvider sutProvider) + { + SetupUser(sutProvider, userId); + row.Status = LeaseRequestStatus.Pending; + sutProvider.GetDependency().GetPendingAsync(userId).Returns([row]); + + var result = await sutProvider.Sut.GetRequests(); + + Assert.Single(result.Data); + Assert.Equal(row.Id, result.Data.First().Id); + } + + [Theory, BitAutoData] + public async Task GetHistory_ReturnsMappedHistoryRows( + Guid userId, InboxLeaseRequestDetails row, SutProvider sutProvider) + { + SetupUser(sutProvider, userId); + row.Status = LeaseRequestStatus.Approved; + sutProvider.GetDependency().GetHistoryAsync(userId).Returns([row]); + + var result = await sutProvider.Sut.GetHistory(); + + Assert.Single(result.Data); + } + + [Theory, BitAutoData] + public async Task Decide_ReturnsUpdatedRow( + Guid userId, Guid requestId, InboxLeaseRequestDetails updated, SutProvider sutProvider) + { + SetupUser(sutProvider, userId); + updated.Status = LeaseRequestStatus.Approved; + updated.ProducedLeaseId = null; + sutProvider.GetDependency() + .DecideAsync(userId, requestId, Arg.Any()) + .Returns(updated); + + var result = await sutProvider.Sut.Decide(requestId, new LeaseDecisionRequestModel { Decision = "approve" }); + + Assert.Equal(updated.Id, result.Id); + Assert.Equal(InboxRequestStatus.Approved, result.Status); + } + + [Theory, BitAutoData] + public async Task Decide_InvalidDecision_ThrowsBadRequest( + Guid userId, Guid requestId, SutProvider sutProvider) + { + SetupUser(sutProvider, userId); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.Decide(requestId, new LeaseDecisionRequestModel { Decision = "maybe" })); + } + + [Theory, BitAutoData] + public async Task Revoke_ReturnsNoContent( + Guid userId, Guid leaseId, SutProvider sutProvider) + { + SetupUser(sutProvider, userId); + + var result = await sutProvider.Sut.Revoke(leaseId, new LeaseRevokeRequestModel { Reason = "policy" }); + + Assert.IsType(result); + await sutProvider.GetDependency().Received(1).RevokeAsync(userId, leaseId, "policy"); + } + + private static void SetupUser(SutProvider sutProvider, Guid userId) + { + sutProvider.GetDependency() + .GetProperUserId(Arg.Any()) + .Returns(userId); + } +} diff --git a/test/Core.Test/Pam/Commands/DecideLeaseRequestCommandTests.cs b/test/Core.Test/Pam/Commands/DecideLeaseRequestCommandTests.cs new file mode 100644 index 000000000000..79bd04045445 --- /dev/null +++ b/test/Core.Test/Pam/Commands/DecideLeaseRequestCommandTests.cs @@ -0,0 +1,132 @@ +using Bit.Core.Exceptions; +using Bit.Core.Pam.Entities; +using Bit.Core.Pam.Enums; +using Bit.Core.Pam.Models; +using Bit.Core.Pam.OrganizationFeatures.Commands; +using Bit.Core.Pam.Repositories; +using Bit.Core.Pam.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.Extensions.Time.Testing; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Pam.Commands; + +[SutProviderCustomize] +public class DecideLeaseRequestCommandTests +{ + private static readonly DateTime _now = new(2026, 6, 5, 12, 0, 0, DateTimeKind.Utc); + + [Theory, BitAutoData] + public async Task DecideAsync_RequestMissing_ThrowsNotFound(Guid userId, Guid requestId) + { + var sutProvider = Setup(); + sutProvider.GetDependency().GetByIdAsync(requestId).Returns((LeaseRequest?)null); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.DecideAsync(userId, requestId, Approve())); + } + + [Theory, BitAutoData] + public async Task DecideAsync_NotManageable_ThrowsNotFound(Guid userId, LeaseRequest request) + { + var sutProvider = Setup(); + request.Status = LeaseRequestStatus.Pending; + sutProvider.GetDependency().GetByIdAsync(request.Id).Returns(request); + sutProvider.GetDependency() + .CanManageCollectionAsync(userId, request.CollectionId).Returns(false); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.DecideAsync(userId, request.Id, Approve())); + } + + [Theory, BitAutoData] + public async Task DecideAsync_NotPending_ThrowsConflict(Guid userId, LeaseRequest request) + { + var sutProvider = Setup(); + request.Status = LeaseRequestStatus.Approved; + SetupManageableRequest(sutProvider, userId, request); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.DecideAsync(userId, request.Id, Approve())); + } + + [Theory, BitAutoData] + public async Task DecideAsync_SelfApproval_ThrowsBadRequest(Guid userId, LeaseRequest request) + { + var sutProvider = Setup(); + request.Status = LeaseRequestStatus.Pending; + request.RequesterId = userId; + SetupManageableRequest(sutProvider, userId, request); + + var ex = await Assert.ThrowsAsync( + () => sutProvider.Sut.DecideAsync(userId, request.Id, Approve())); + Assert.Contains("your own request", ex.Message); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .ResolveWithDecisionAsync(default!, default!, default, default); + } + + [Theory, BitAutoData] + public async Task DecideAsync_Approve_ResolvesAndWritesHumanDecision(Guid userId, LeaseRequest request) + { + var sutProvider = Setup(); + request.Status = LeaseRequestStatus.Pending; + SetupManageableRequest(sutProvider, userId, request); + + var result = await sutProvider.Sut.DecideAsync(userId, request.Id, Approve("looks good")); + + Assert.Equal(LeaseRequestStatus.Approved, result.Status); + Assert.Equal(_now, result.ResolvedDate); + Assert.Equal(userId, result.ResolverId); + Assert.Equal("looks good", result.ResolverComment); + await sutProvider.GetDependency().Received(1).ResolveWithDecisionAsync( + request, + Arg.Is(d => + d.DeciderKind == LeaseDecisionKind.Human && + d.ApproverId == userId && + d.Decision == LeaseDecisionVerdict.Approve && + d.Comment == "looks good"), + LeaseRequestStatus.Approved, + _now); + await sutProvider.GetDependency().Received(1) + .NotifyCollectionApproversAsync(request.CollectionId); + } + + [Theory, BitAutoData] + public async Task DecideAsync_Deny_ResolvesAsDenied(Guid userId, LeaseRequest request) + { + var sutProvider = Setup(); + request.Status = LeaseRequestStatus.Pending; + SetupManageableRequest(sutProvider, userId, request); + + var result = await sutProvider.Sut.DecideAsync(userId, request.Id, Deny()); + + Assert.Equal(LeaseRequestStatus.Denied, result.Status); + await sutProvider.GetDependency().Received(1).ResolveWithDecisionAsync( + request, + Arg.Is(d => d.Decision == LeaseDecisionVerdict.Deny), + LeaseRequestStatus.Denied, + _now); + } + + private static LeaseDecisionSubmission Approve(string? comment = null) => + new() { Verdict = LeaseDecisionVerdict.Approve, Comment = comment }; + + private static LeaseDecisionSubmission Deny(string? comment = null) => + new() { Verdict = LeaseDecisionVerdict.Deny, Comment = comment }; + + private static SutProvider Setup() + { + var sutProvider = new SutProvider().WithFakeTimeProvider().Create(); + sutProvider.GetDependency().SetUtcNow(_now); + return sutProvider; + } + + private static void SetupManageableRequest(SutProvider sutProvider, Guid userId, LeaseRequest request) + { + sutProvider.GetDependency().GetByIdAsync(request.Id).Returns(request); + sutProvider.GetDependency() + .CanManageCollectionAsync(userId, request.CollectionId).Returns(true); + } +} diff --git a/test/Core.Test/Pam/Commands/RequestAccessCommandTests.cs b/test/Core.Test/Pam/Commands/RequestAccessCommandTests.cs index ea21008d1ac6..f91ab8b2ff28 100644 --- a/test/Core.Test/Pam/Commands/RequestAccessCommandTests.cs +++ b/test/Core.Test/Pam/Commands/RequestAccessCommandTests.cs @@ -124,6 +124,22 @@ public async Task RequestAccessAsync_Human_CreatesPendingRequest(Guid userId, Gu Assert.Equal("audit", result.Request.Reason); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() .CreateAutoApprovedAsync(default!, default!, default!, default); + await sutProvider.GetDependency().Received(1) + .NotifyCollectionApproversAsync(collectionId); + } + + [Theory, BitAutoData] + public async Task RequestAccessAsync_Automatic_DoesNotNotifyApprovers(Guid userId, Guid cipherId, Guid orgId, Guid collectionId) + { + var sutProvider = Setup(); + SetupCipher(sutProvider, userId, cipherId); + SetupResolution(sutProvider, userId, cipherId, orgId, collectionId, requiresHuman: false); + + await sutProvider.Sut.RequestAccessAsync(userId, cipherId, + new AccessRequestSubmission { DurationSeconds = 3600, Reason = "deploy" }); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .NotifyCollectionApproversAsync(default); } [Theory, BitAutoData] diff --git a/test/Core.Test/Pam/Commands/RevokeLeaseCommandTests.cs b/test/Core.Test/Pam/Commands/RevokeLeaseCommandTests.cs new file mode 100644 index 000000000000..fad588a4bd26 --- /dev/null +++ b/test/Core.Test/Pam/Commands/RevokeLeaseCommandTests.cs @@ -0,0 +1,86 @@ +using Bit.Core.Exceptions; +using Bit.Core.Pam.Entities; +using Bit.Core.Pam.Enums; +using Bit.Core.Pam.OrganizationFeatures.Commands; +using Bit.Core.Pam.Repositories; +using Bit.Core.Pam.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.Extensions.Time.Testing; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Pam.Commands; + +[SutProviderCustomize] +public class RevokeLeaseCommandTests +{ + private static readonly DateTime _now = new(2026, 6, 5, 12, 0, 0, DateTimeKind.Utc); + + [Theory, BitAutoData] + public async Task RevokeAsync_LeaseMissing_ThrowsNotFound(Guid userId, Guid leaseId) + { + var sutProvider = Setup(); + sutProvider.GetDependency().GetByIdAsync(leaseId).Returns((Lease?)null); + + await Assert.ThrowsAsync(() => sutProvider.Sut.RevokeAsync(userId, leaseId, null)); + } + + [Theory, BitAutoData] + public async Task RevokeAsync_NotManageable_ThrowsNotFound(Guid userId, Lease lease) + { + var sutProvider = Setup(); + lease.Status = LeaseStatus.Active; + sutProvider.GetDependency().GetByIdAsync(lease.Id).Returns(lease); + sutProvider.GetDependency() + .CanManageCollectionAsync(userId, lease.CollectionId).Returns(false); + + await Assert.ThrowsAsync(() => sutProvider.Sut.RevokeAsync(userId, lease.Id, null)); + } + + [Theory, BitAutoData] + public async Task RevokeAsync_NotActive_ThrowsConflict(Guid userId, Lease lease) + { + var sutProvider = Setup(); + lease.Status = LeaseStatus.Revoked; + SetupManageableLease(sutProvider, userId, lease); + + await Assert.ThrowsAsync(() => sutProvider.Sut.RevokeAsync(userId, lease.Id, null)); + } + + [Theory, BitAutoData] + public async Task RevokeAsync_Active_RevokesAndWritesAuditDecision(Guid userId, Lease lease) + { + var sutProvider = Setup(); + lease.Status = LeaseStatus.Active; + SetupManageableLease(sutProvider, userId, lease); + + await sutProvider.Sut.RevokeAsync(userId, lease.Id, "policy change"); + + await sutProvider.GetDependency().Received(1).RevokeAsync( + lease, + Arg.Is(d => + d.LeaseRequestId == lease.LeaseRequestId && + d.DeciderKind == LeaseDecisionKind.Human && + d.ApproverId == userId && + d.Decision == LeaseDecisionVerdict.Deny && + d.Comment == "policy change"), + _now); + await sutProvider.GetDependency().Received(1) + .NotifyCollectionApproversAsync(lease.CollectionId); + } + + private static SutProvider Setup() + { + var sutProvider = new SutProvider().WithFakeTimeProvider().Create(); + sutProvider.GetDependency().SetUtcNow(_now); + return sutProvider; + } + + private static void SetupManageableLease(SutProvider sutProvider, Guid userId, Lease lease) + { + sutProvider.GetDependency().GetByIdAsync(lease.Id).Returns(lease); + sutProvider.GetDependency() + .CanManageCollectionAsync(userId, lease.CollectionId).Returns(true); + } +} diff --git a/test/Core.Test/Pam/Models/InboxRequestStatusTests.cs b/test/Core.Test/Pam/Models/InboxRequestStatusTests.cs new file mode 100644 index 000000000000..75b8cf16ceea --- /dev/null +++ b/test/Core.Test/Pam/Models/InboxRequestStatusTests.cs @@ -0,0 +1,20 @@ +using Bit.Core.Pam.Enums; +using Bit.Core.Pam.Models; +using Xunit; + +namespace Bit.Core.Test.Pam.Models; + +public class InboxRequestStatusTests +{ + [Theory] + [InlineData(LeaseRequestStatus.Pending, false, InboxRequestStatus.Pending)] + [InlineData(LeaseRequestStatus.Approved, false, InboxRequestStatus.Approved)] + [InlineData(LeaseRequestStatus.Approved, true, InboxRequestStatus.Activated)] + [InlineData(LeaseRequestStatus.Denied, false, InboxRequestStatus.Denied)] + [InlineData(LeaseRequestStatus.Cancelled, false, InboxRequestStatus.Cancelled)] + [InlineData(LeaseRequestStatus.ExpiredUnanswered, false, InboxRequestStatus.Expired)] + public void From_MapsToFrontendVocabulary(LeaseRequestStatus status, bool hasLease, string expected) + { + Assert.Equal(expected, InboxRequestStatus.From(status, hasLease)); + } +} diff --git a/test/Core.Test/Pam/Queries/GetInboxHistoryQueryTests.cs b/test/Core.Test/Pam/Queries/GetInboxHistoryQueryTests.cs new file mode 100644 index 000000000000..12cd592b7aa7 --- /dev/null +++ b/test/Core.Test/Pam/Queries/GetInboxHistoryQueryTests.cs @@ -0,0 +1,56 @@ +using Bit.Core.Pam.Models; +using Bit.Core.Pam.OrganizationFeatures.Queries; +using Bit.Core.Pam.Repositories; +using Bit.Core.Pam.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.Extensions.Time.Testing; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Pam.Queries; + +[SutProviderCustomize] +public class GetInboxHistoryQueryTests +{ + private static readonly DateTime _now = new(2026, 6, 5, 12, 0, 0, DateTimeKind.Utc); + + [Theory, BitAutoData] + public async Task GetHistoryAsync_NoManageableCollections_ReturnsEmptyWithoutQuerying(Guid userId) + { + var sutProvider = Setup(); + sutProvider.GetDependency() + .GetManageableCollectionIdsAsync(userId).Returns([]); + + var result = await sutProvider.Sut.GetHistoryAsync(userId); + + Assert.Empty(result); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .GetManyInboxHistoryByCollectionIdsAsync(default!, default); + } + + [Theory, BitAutoData] + public async Task GetHistoryAsync_QueriesWithRetentionWindow(Guid userId, Guid collectionId, InboxLeaseRequestDetails row) + { + var sutProvider = Setup(); + var manageable = new HashSet { collectionId }; + sutProvider.GetDependency() + .GetManageableCollectionIdsAsync(userId).Returns(manageable); + var expectedSince = _now.AddDays(-GetInboxHistoryQuery.HistoryRetentionDays); + sutProvider.GetDependency() + .GetManyInboxHistoryByCollectionIdsAsync(manageable, expectedSince).Returns([row]); + + var result = await sutProvider.Sut.GetHistoryAsync(userId); + + Assert.Single(result); + await sutProvider.GetDependency().Received(1) + .GetManyInboxHistoryByCollectionIdsAsync(manageable, expectedSince); + } + + private static SutProvider Setup() + { + var sutProvider = new SutProvider().WithFakeTimeProvider().Create(); + sutProvider.GetDependency().SetUtcNow(_now); + return sutProvider; + } +} diff --git a/test/Core.Test/Pam/Queries/GetInboxRequestsQueryTests.cs b/test/Core.Test/Pam/Queries/GetInboxRequestsQueryTests.cs new file mode 100644 index 000000000000..c6b4e046ea75 --- /dev/null +++ b/test/Core.Test/Pam/Queries/GetInboxRequestsQueryTests.cs @@ -0,0 +1,45 @@ +using Bit.Core.Pam.Models; +using Bit.Core.Pam.OrganizationFeatures.Queries; +using Bit.Core.Pam.Repositories; +using Bit.Core.Pam.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Pam.Queries; + +[SutProviderCustomize] +public class GetInboxRequestsQueryTests +{ + [Theory, BitAutoData] + public async Task GetPendingAsync_NoManageableCollections_ReturnsEmptyWithoutQuerying( + SutProvider sutProvider, Guid userId) + { + sutProvider.GetDependency() + .GetManageableCollectionIdsAsync(userId).Returns([]); + + var result = await sutProvider.Sut.GetPendingAsync(userId); + + Assert.Empty(result); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .GetManyInboxPendingByCollectionIdsAsync(default!); + } + + [Theory, BitAutoData] + public async Task GetPendingAsync_ManageableCollections_FiltersByThatSet( + SutProvider sutProvider, Guid userId, Guid collectionId, InboxLeaseRequestDetails row) + { + var manageable = new HashSet { collectionId }; + sutProvider.GetDependency() + .GetManageableCollectionIdsAsync(userId).Returns(manageable); + sutProvider.GetDependency() + .GetManyInboxPendingByCollectionIdsAsync(manageable).Returns([row]); + + var result = await sutProvider.Sut.GetPendingAsync(userId); + + Assert.Single(result); + await sutProvider.GetDependency().Received(1) + .GetManyInboxPendingByCollectionIdsAsync(manageable); + } +} diff --git a/test/Core.Test/Pam/Services/ApproverCollectionAccessQueryTests.cs b/test/Core.Test/Pam/Services/ApproverCollectionAccessQueryTests.cs new file mode 100644 index 000000000000..2547e0361ab2 --- /dev/null +++ b/test/Core.Test/Pam/Services/ApproverCollectionAccessQueryTests.cs @@ -0,0 +1,107 @@ +using Bit.Core.Context; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Models.Data; +using Bit.Core.Models.Data.Organizations; +using Bit.Core.Pam.Services; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Pam.Services; + +[SutProviderCustomize] +public class ApproverCollectionAccessQueryTests +{ + [Theory, BitAutoData] + public async Task GetManageableCollectionIdsAsync_ReturnsOnlyAssignedManageCollections( + SutProvider sutProvider, Guid userId, Guid manageId, Guid readOnlyId) + { + sutProvider.GetDependency().GetManyByUserIdAsync(userId).Returns(new List + { + new() { Id = manageId, Manage = true }, + new() { Id = readOnlyId, Manage = false }, + }); + sutProvider.GetDependency().Organizations.Returns(new List()); + + var result = await sutProvider.Sut.GetManageableCollectionIdsAsync(userId); + + Assert.Contains(manageId, result); + Assert.DoesNotContain(readOnlyId, result); + } + + [Theory, BitAutoData] + public async Task GetManageableCollectionIdsAsync_OwnerWithAdminAccess_IncludesAllOrgCollections( + SutProvider sutProvider, Guid userId, Guid orgId, Guid orgCollectionId) + { + sutProvider.GetDependency().GetManyByUserIdAsync(userId) + .Returns(new List()); + sutProvider.GetDependency().Organizations.Returns(new List + { + new() { Id = orgId, Type = OrganizationUserType.Owner }, + }); + sutProvider.GetDependency().GetOrganizationAbilityAsync(orgId) + .Returns(new OrganizationAbility { Id = orgId, AllowAdminAccessToAllCollectionItems = true }); + sutProvider.GetDependency().GetManyByOrganizationIdAsync(orgId) + .Returns(new List { new() { Id = orgCollectionId, OrganizationId = orgId } }); + + var result = await sutProvider.Sut.GetManageableCollectionIdsAsync(userId); + + Assert.Contains(orgCollectionId, result); + } + + [Theory, BitAutoData] + public async Task GetManageableCollectionIdsAsync_OwnerWithoutAdminAccess_DoesNotIncludeAllOrgCollections( + SutProvider sutProvider, Guid userId, Guid orgId) + { + sutProvider.GetDependency().GetManyByUserIdAsync(userId) + .Returns(new List()); + sutProvider.GetDependency().Organizations.Returns(new List + { + new() { Id = orgId, Type = OrganizationUserType.Owner }, + }); + sutProvider.GetDependency().GetOrganizationAbilityAsync(orgId) + .Returns(new OrganizationAbility { Id = orgId, AllowAdminAccessToAllCollectionItems = false }); + + var result = await sutProvider.Sut.GetManageableCollectionIdsAsync(userId); + + Assert.Empty(result); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .GetManyByOrganizationIdAsync(default); + } + + [Theory, BitAutoData] + public async Task GetManageableCollectionIdsAsync_EditAnyCollection_IncludesAllOrgCollections( + SutProvider sutProvider, Guid userId, Guid orgId, Guid orgCollectionId) + { + sutProvider.GetDependency().GetManyByUserIdAsync(userId) + .Returns(new List()); + sutProvider.GetDependency().Organizations.Returns(new List + { + new() { Id = orgId, Type = OrganizationUserType.Custom, Permissions = new Permissions { EditAnyCollection = true } }, + }); + sutProvider.GetDependency().GetManyByOrganizationIdAsync(orgId) + .Returns(new List { new() { Id = orgCollectionId, OrganizationId = orgId } }); + + var result = await sutProvider.Sut.GetManageableCollectionIdsAsync(userId); + + Assert.Contains(orgCollectionId, result); + } + + [Theory, BitAutoData] + public async Task CanManageCollectionAsync_DelegatesToManageableSet( + SutProvider sutProvider, Guid userId, Guid manageId, Guid otherId) + { + sutProvider.GetDependency().GetManyByUserIdAsync(userId).Returns(new List + { + new() { Id = manageId, Manage = true }, + }); + sutProvider.GetDependency().Organizations.Returns(new List()); + + Assert.True(await sutProvider.Sut.CanManageCollectionAsync(userId, manageId)); + Assert.False(await sutProvider.Sut.CanManageCollectionAsync(userId, otherId)); + } +} diff --git a/test/Core.Test/Pam/Services/ApproverInboxNotifierTests.cs b/test/Core.Test/Pam/Services/ApproverInboxNotifierTests.cs new file mode 100644 index 000000000000..9ca237122634 --- /dev/null +++ b/test/Core.Test/Pam/Services/ApproverInboxNotifierTests.cs @@ -0,0 +1,41 @@ +using Bit.Core.Pam.Services; +using Bit.Core.Platform.Push; +using Bit.Core.Repositories; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Pam.Services; + +[SutProviderCustomize] +public class ApproverInboxNotifierTests +{ + [Theory, BitAutoData] + public async Task NotifyCollectionApproversAsync_PushesToEachManager( + SutProvider sutProvider, Guid collectionId, Guid userA, Guid userB) + { + sutProvider.GetDependency() + .GetManagingUserIdsAsync(collectionId) + .Returns(new List { userA, userB }); + + await sutProvider.Sut.NotifyCollectionApproversAsync(collectionId); + + await sutProvider.GetDependency().Received(1).PushRefreshApproverInboxAsync(userA); + await sutProvider.GetDependency().Received(1).PushRefreshApproverInboxAsync(userB); + } + + [Theory, BitAutoData] + public async Task NotifyCollectionApproversAsync_NoManagers_PushesNothing( + SutProvider sutProvider, Guid collectionId) + { + sutProvider.GetDependency() + .GetManagingUserIdsAsync(collectionId) + .Returns(new List()); + + await sutProvider.Sut.NotifyCollectionApproversAsync(collectionId); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .PushRefreshApproverInboxAsync(default); + } +} diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/CollectionRepositoryGetManagingUserIdsTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/CollectionRepositoryGetManagingUserIdsTests.cs new file mode 100644 index 000000000000..a4250fdcded0 --- /dev/null +++ b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/CollectionRepositoryGetManagingUserIdsTests.cs @@ -0,0 +1,132 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Models.Data; +using Bit.Core.Repositories; +using Xunit; + +namespace Bit.Infrastructure.IntegrationTest.AdminConsole.Repositories.CollectionRepository; + +public class CollectionRepositoryGetManagingUserIdsTests +{ + [DatabaseTheory, DatabaseData] + public async Task GetManagingUserIdsAsync_DirectManageUser_Included_NonManageExcluded( + IUserRepository userRepository, + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, + ICollectionRepository collectionRepository) + { + var organization = await organizationRepository.CreateTestOrganizationAsync(); + + var manager = await userRepository.CreateTestUserAsync("manager"); + var managerOrgUser = await CreateConfirmedUserAsync(organizationUserRepository, organization, manager); + + var viewer = await userRepository.CreateTestUserAsync("viewer"); + var viewerOrgUser = await CreateConfirmedUserAsync(organizationUserRepository, organization, viewer); + + var collection = new Collection { Name = "Leased", OrganizationId = organization.Id }; + await collectionRepository.CreateAsync(collection, groups: [], users: + [ + new CollectionAccessSelection { Id = managerOrgUser.Id, Manage = true }, + new CollectionAccessSelection { Id = viewerOrgUser.Id, Manage = false, ReadOnly = true }, + ]); + + var userIds = await collectionRepository.GetManagingUserIdsAsync(collection.Id); + + Assert.Contains(manager.Id, userIds); + Assert.DoesNotContain(viewer.Id, userIds); + } + + [DatabaseTheory, DatabaseData] + public async Task GetManagingUserIdsAsync_GroupManageMember_Included( + IUserRepository userRepository, + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, + ICollectionRepository collectionRepository, + IGroupRepository groupRepository) + { + var organization = await organizationRepository.CreateTestOrganizationAsync(); + var member = await userRepository.CreateTestUserAsync("groupmember"); + var memberOrgUser = await CreateConfirmedUserAsync(organizationUserRepository, organization, member); + + var group = await groupRepository.CreateTestGroupAsync(organization); + await groupRepository.UpdateUsersAsync(group.Id, [memberOrgUser.Id], DateTime.UtcNow); + + var collection = new Collection { Name = "Leased", OrganizationId = organization.Id }; + await collectionRepository.CreateAsync(collection, groups: + [ + new CollectionAccessSelection { Id = group.Id, Manage = true }, + ], users: []); + + var userIds = await collectionRepository.GetManagingUserIdsAsync(collection.Id); + + Assert.Contains(member.Id, userIds); + } + + [DatabaseTheory, DatabaseData] + public async Task GetManagingUserIdsAsync_OwnerWithAdminAccess_Included( + IUserRepository userRepository, + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, + ICollectionRepository collectionRepository) + { + var organization = await organizationRepository.CreateTestOrganizationAsync(); + organization.AllowAdminAccessToAllCollectionItems = true; + await organizationRepository.ReplaceAsync(organization); + + var owner = await userRepository.CreateTestUserAsync("owner"); + await organizationUserRepository.CreateAsync(new OrganizationUser + { + OrganizationId = organization.Id, + UserId = owner.Id, + Status = OrganizationUserStatusType.Confirmed, + Type = OrganizationUserType.Owner, + }); + + // A collection the owner is not directly assigned to. + var collection = await collectionRepository.CreateTestCollectionAsync(organization); + + var userIds = await collectionRepository.GetManagingUserIdsAsync(collection.Id); + + Assert.Contains(owner.Id, userIds); + } + + [DatabaseTheory, DatabaseData] + public async Task GetManagingUserIdsAsync_OwnerWithoutAdminAccess_Excluded( + IUserRepository userRepository, + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, + ICollectionRepository collectionRepository) + { + var organization = await organizationRepository.CreateTestOrganizationAsync(); + // The test-org helper enables admin access by default, so turn it off for this case. + organization.AllowAdminAccessToAllCollectionItems = false; + await organizationRepository.ReplaceAsync(organization); + + var owner = await userRepository.CreateTestUserAsync("owner"); + await organizationUserRepository.CreateAsync(new OrganizationUser + { + OrganizationId = organization.Id, + UserId = owner.Id, + Status = OrganizationUserStatusType.Confirmed, + Type = OrganizationUserType.Owner, + }); + + var collection = await collectionRepository.CreateTestCollectionAsync(organization); + + var userIds = await collectionRepository.GetManagingUserIdsAsync(collection.Id); + + Assert.DoesNotContain(owner.Id, userIds); + } + + private static Task CreateConfirmedUserAsync( + IOrganizationUserRepository organizationUserRepository, Organization organization, User user) + => organizationUserRepository.CreateAsync(new OrganizationUser + { + OrganizationId = organization.Id, + UserId = user.Id, + Status = OrganizationUserStatusType.Confirmed, + Type = OrganizationUserType.User, + }); +} diff --git a/test/Infrastructure.IntegrationTest/Pam/Repositories/LeaseRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Pam/Repositories/LeaseRepositoryTests.cs index d15a8bc9d427..985d74f1c7f1 100644 --- a/test/Infrastructure.IntegrationTest/Pam/Repositories/LeaseRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/Pam/Repositories/LeaseRepositoryTests.cs @@ -106,6 +106,41 @@ public async Task GetActivePendingByRequesterIdCipherIdAsync_ReturnsPendingReque Assert.Equal("audit", pending.Reason); } + [DatabaseTheory, DatabaseData] + public async Task RevokeAsync_RevokesLeaseAndRecordsAuditDecision( + IOrganizationRepository organizationRepository, + ILeaseRepository leaseRepository) + { + var organization = await organizationRepository.CreateTestOrganizationAsync(); + var now = DateTime.UtcNow; + var cipherId = Guid.NewGuid(); + var requesterId = Guid.NewGuid(); + var revokerId = Guid.NewGuid(); + + var (request, decision, lease) = BuildAutoApproved( + organization.Id, cipherId, requesterId, now.AddMinutes(-5), now.AddHours(1)); + await leaseRepository.CreateAutoApprovedAsync(request, decision, lease, now); + + var auditDecision = new LeaseDecision + { + Id = CoreHelpers.GenerateComb(), + LeaseRequestId = lease.LeaseRequestId, + DeciderKind = LeaseDecisionKind.Human, + ApproverId = revokerId, + Decision = LeaseDecisionVerdict.Deny, + Comment = "policy change", + CreationDate = now, + }; + + await leaseRepository.RevokeAsync(lease, auditDecision, now); + + var persisted = await leaseRepository.GetByIdAsync(lease.Id); + Assert.NotNull(persisted); + Assert.Equal(LeaseStatus.Revoked, persisted!.Status); + Assert.Equal(revokerId, persisted.RevokedBy); + Assert.NotNull(persisted.RevokedDate); + } + private static (LeaseRequest, LeaseDecision, Lease) BuildAutoApproved( Guid organizationId, Guid cipherId, Guid requesterId, DateTime notBefore, DateTime notAfter) { diff --git a/test/Infrastructure.IntegrationTest/Pam/Repositories/LeaseRequestRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Pam/Repositories/LeaseRequestRepositoryTests.cs new file mode 100644 index 000000000000..1ce108a38618 --- /dev/null +++ b/test/Infrastructure.IntegrationTest/Pam/Repositories/LeaseRequestRepositoryTests.cs @@ -0,0 +1,138 @@ +using Bit.Core.Pam.Entities; +using Bit.Core.Pam.Enums; +using Bit.Core.Pam.Repositories; +using Bit.Core.Repositories; +using Bit.Core.Utilities; +using Bit.Infrastructure.IntegrationTest.AdminConsole; +using Xunit; + +namespace Bit.Infrastructure.IntegrationTest.Pam.Repositories; + +public class LeaseRequestRepositoryTests +{ + [DatabaseTheory, DatabaseData] + public async Task GetManyInboxPendingByCollectionIdsAsync_ReturnsPendingWithDenormalizedFields( + IUserRepository userRepository, + IOrganizationRepository organizationRepository, + ICollectionRepository collectionRepository, + ILeaseRequestRepository leaseRequestRepository) + { + var requester = await userRepository.CreateTestUserAsync("requester"); + var organization = await organizationRepository.CreateTestOrganizationAsync(); + var collection = await collectionRepository.CreateTestCollectionAsync(organization); + var now = DateTime.UtcNow; + + var pending = await leaseRequestRepository.CreateAsync(BuildRequest( + organization.Id, collection.Id, requester.Id, LeaseRequestStatus.Pending, now)); + // A resolved request on the same collection must NOT appear in the pending inbox. + await leaseRequestRepository.CreateAsync(BuildRequest( + organization.Id, collection.Id, requester.Id, LeaseRequestStatus.Denied, now)); + + var pendingRows = await leaseRequestRepository.GetManyInboxPendingByCollectionIdsAsync([collection.Id]); + + var row = Assert.Single(pendingRows); + Assert.Equal(pending.Id, row.Id); + Assert.Equal(LeaseRequestStatus.Pending, row.Status); + Assert.Equal(collection.Name, row.CollectionName); + Assert.Equal(requester.Email, row.RequesterEmail); + } + + [DatabaseTheory, DatabaseData] + public async Task GetManyInboxPendingByCollectionIdsAsync_OtherCollection_NotReturned( + IOrganizationRepository organizationRepository, + ICollectionRepository collectionRepository, + ILeaseRequestRepository leaseRequestRepository) + { + var organization = await organizationRepository.CreateTestOrganizationAsync(); + var collection = await collectionRepository.CreateTestCollectionAsync(organization); + var now = DateTime.UtcNow; + await leaseRequestRepository.CreateAsync(BuildRequest( + organization.Id, collection.Id, Guid.NewGuid(), LeaseRequestStatus.Pending, now)); + + var rows = await leaseRequestRepository.GetManyInboxPendingByCollectionIdsAsync([Guid.NewGuid()]); + + Assert.Empty(rows); + } + + [DatabaseTheory, DatabaseData] + public async Task GetManyInboxHistoryByCollectionIdsAsync_RespectsStatusAndWindow( + IOrganizationRepository organizationRepository, + ICollectionRepository collectionRepository, + ILeaseRequestRepository leaseRequestRepository) + { + var organization = await organizationRepository.CreateTestOrganizationAsync(); + var collection = await collectionRepository.CreateTestCollectionAsync(organization); + var now = DateTime.UtcNow; + + var resolved = await leaseRequestRepository.CreateAsync(BuildRequest( + organization.Id, collection.Id, Guid.NewGuid(), LeaseRequestStatus.Approved, now)); + // Pending requests are excluded from history. + await leaseRequestRepository.CreateAsync(BuildRequest( + organization.Id, collection.Id, Guid.NewGuid(), LeaseRequestStatus.Pending, now)); + // A resolved request older than the window is excluded. + await leaseRequestRepository.CreateAsync(BuildRequest( + organization.Id, collection.Id, Guid.NewGuid(), LeaseRequestStatus.Denied, now.AddDays(-120))); + + var history = await leaseRequestRepository.GetManyInboxHistoryByCollectionIdsAsync( + [collection.Id], now.AddDays(-90)); + + var row = Assert.Single(history); + Assert.Equal(resolved.Id, row.Id); + } + + [DatabaseTheory, DatabaseData] + public async Task ResolveWithDecisionAsync_ResolvesRequestAndRecordsHumanDecision( + IOrganizationRepository organizationRepository, + ICollectionRepository collectionRepository, + ILeaseRequestRepository leaseRequestRepository) + { + var organization = await organizationRepository.CreateTestOrganizationAsync(); + var collection = await collectionRepository.CreateTestCollectionAsync(organization); + var now = DateTime.UtcNow; + var approverId = Guid.NewGuid(); + + var request = await leaseRequestRepository.CreateAsync(BuildRequest( + organization.Id, collection.Id, Guid.NewGuid(), LeaseRequestStatus.Pending, now)); + + var decision = new LeaseDecision + { + Id = CoreHelpers.GenerateComb(), + LeaseRequestId = request.Id, + DeciderKind = LeaseDecisionKind.Human, + ApproverId = approverId, + Decision = LeaseDecisionVerdict.Approve, + Comment = "approved for audit", + CreationDate = now, + }; + + await leaseRequestRepository.ResolveWithDecisionAsync(request, decision, LeaseRequestStatus.Approved, now); + + var persisted = await leaseRequestRepository.GetByIdAsync(request.Id); + Assert.NotNull(persisted); + Assert.Equal(LeaseRequestStatus.Approved, persisted!.Status); + Assert.NotNull(persisted.ResolvedDate); + + // The human decision surfaces as the resolver in the inbox projection. + var history = await leaseRequestRepository.GetManyInboxHistoryByCollectionIdsAsync( + [collection.Id], now.AddDays(-1)); + var row = Assert.Single(history); + Assert.Equal(approverId, row.ResolverId); + Assert.Equal("approved for audit", row.ResolverComment); + } + + private static LeaseRequest BuildRequest( + Guid organizationId, Guid collectionId, Guid requesterId, LeaseRequestStatus status, DateTime creationDate) + => new() + { + OrganizationId = organizationId, + CollectionId = collectionId, + CipherId = Guid.NewGuid(), + RequesterId = requesterId, + NotBefore = creationDate.AddHours(1), + NotAfter = creationDate.AddHours(2), + Reason = "audit", + Status = status, + CreationDate = creationDate, + ResolvedDate = status == LeaseRequestStatus.Pending ? null : creationDate, + }; +} diff --git a/util/Migrator/DbScripts/2026-06-05_00_AddApproverInboxSprocs.sql b/util/Migrator/DbScripts/2026-06-05_00_AddApproverInboxSprocs.sql new file mode 100644 index 000000000000..a5586c7a9698 --- /dev/null +++ b/util/Migrator/DbScripts/2026-06-05_00_AddApproverInboxSprocs.sql @@ -0,0 +1,216 @@ + +-- PAM Approver Inbox: read procedures for the pending/history queues, the resolve-with-decision and lease-revoke +-- mutations, and Collection_ReadManagingUserIds (the collection managers resolved for the RefreshApproverInbox push). + +CREATE OR ALTER PROCEDURE [dbo].[LeaseRequest_ReadInboxPendingByCollectionIds] + @CollectionIds [dbo].[GuidIdArray] READONLY +AS +BEGIN + SET NOCOUNT ON + + SELECT + LR.[Id], + LR.[LeaseId] AS [ExtensionOfLeaseId], + LR.[OrganizationId], + LR.[CollectionId], + LR.[CipherId], + LR.[RequesterId], + LR.[NotBefore], + LR.[NotAfter], + LR.[Reason], + LR.[Status], + LR.[CreationDate], + LR.[ResolvedDate], + PL.[Id] AS [ProducedLeaseId], + RES.[ApproverId] AS [ResolverId], + RES.[Comment] AS [ResolverComment], + JSON_VALUE(C.[Data], '$.Name') AS [CipherName], + COL.[Name] AS [CollectionName], + U.[Name] AS [RequesterName], + U.[Email] AS [RequesterEmail] + FROM [dbo].[LeaseRequest] LR + INNER JOIN @CollectionIds CI ON CI.[Id] = LR.[CollectionId] + LEFT JOIN [dbo].[Cipher] C ON C.[Id] = LR.[CipherId] + LEFT JOIN [dbo].[Collection] COL ON COL.[Id] = LR.[CollectionId] + LEFT JOIN [dbo].[User] U ON U.[Id] = LR.[RequesterId] + OUTER APPLY ( + SELECT TOP 1 L.[Id] + FROM [dbo].[Lease] L + WHERE L.[LeaseRequestId] = LR.[Id] + ORDER BY L.[CreationDate] DESC + ) PL + OUTER APPLY ( + SELECT TOP 1 LD.[ApproverId], LD.[Comment] + FROM [dbo].[LeaseDecision] LD + WHERE LD.[LeaseRequestId] = LR.[Id] AND LD.[DeciderKind] = 1 -- Human + ORDER BY LD.[CreationDate] ASC + ) RES + WHERE LR.[Status] = 0 -- Pending +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[LeaseRequest_ReadInboxHistoryByCollectionIds] + @CollectionIds [dbo].[GuidIdArray] READONLY, + @Since DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + SELECT + LR.[Id], + LR.[LeaseId] AS [ExtensionOfLeaseId], + LR.[OrganizationId], + LR.[CollectionId], + LR.[CipherId], + LR.[RequesterId], + LR.[NotBefore], + LR.[NotAfter], + LR.[Reason], + LR.[Status], + LR.[CreationDate], + LR.[ResolvedDate], + PL.[Id] AS [ProducedLeaseId], + RES.[ApproverId] AS [ResolverId], + RES.[Comment] AS [ResolverComment], + JSON_VALUE(C.[Data], '$.Name') AS [CipherName], + COL.[Name] AS [CollectionName], + U.[Name] AS [RequesterName], + U.[Email] AS [RequesterEmail] + FROM [dbo].[LeaseRequest] LR + INNER JOIN @CollectionIds CI ON CI.[Id] = LR.[CollectionId] + LEFT JOIN [dbo].[Cipher] C ON C.[Id] = LR.[CipherId] + LEFT JOIN [dbo].[Collection] COL ON COL.[Id] = LR.[CollectionId] + LEFT JOIN [dbo].[User] U ON U.[Id] = LR.[RequesterId] + OUTER APPLY ( + SELECT TOP 1 L.[Id] + FROM [dbo].[Lease] L + WHERE L.[LeaseRequestId] = LR.[Id] + ORDER BY L.[CreationDate] DESC + ) PL + OUTER APPLY ( + SELECT TOP 1 LD.[ApproverId], LD.[Comment] + FROM [dbo].[LeaseDecision] LD + WHERE LD.[LeaseRequestId] = LR.[Id] AND LD.[DeciderKind] = 1 -- Human + ORDER BY LD.[CreationDate] ASC + ) RES + WHERE LR.[Status] <> 0 -- not Pending + AND LR.[CreationDate] >= @Since +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[LeaseRequest_ResolveWithDecision] + @LeaseRequestId UNIQUEIDENTIFIER, + @Status TINYINT, + @LeaseDecisionId UNIQUEIDENTIFIER, + @ApproverId UNIQUEIDENTIFIER, + @Decision TINYINT, + @Comment NVARCHAR(MAX) = NULL, + @Now DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + BEGIN TRANSACTION LeaseRequest_ResolveWithDecision + + UPDATE [dbo].[LeaseRequest] + SET [Status] = @Status, + [ResolvedDate] = @Now + WHERE [Id] = @LeaseRequestId AND [Status] = 0 -- Pending + + INSERT INTO [dbo].[LeaseDecision] + ( + [Id], [LeaseRequestId], [DeciderKind], [ApproverId], [PolicyKind], + [Decision], [Comment], [EvaluationContext], [CreationDate] + ) + VALUES + ( + @LeaseDecisionId, @LeaseRequestId, 1 /* Human */, @ApproverId, NULL, + @Decision, @Comment, NULL, @Now + ) + + COMMIT TRANSACTION LeaseRequest_ResolveWithDecision +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[Lease_Revoke] + @LeaseId UNIQUEIDENTIFIER, + @LeaseRequestId UNIQUEIDENTIFIER, + @RevokedBy UNIQUEIDENTIFIER, + @LeaseDecisionId UNIQUEIDENTIFIER, + @Reason NVARCHAR(MAX) = NULL, + @Now DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + BEGIN TRANSACTION Lease_Revoke + + UPDATE [dbo].[Lease] + SET [Status] = 2 /* Revoked */, + [RevokedDate] = @Now, + [RevokedBy] = @RevokedBy + WHERE [Id] = @LeaseId AND [Status] = 0 -- Active + + INSERT INTO [dbo].[LeaseDecision] + ( + [Id], [LeaseRequestId], [DeciderKind], [ApproverId], [PolicyKind], + [Decision], [Comment], [EvaluationContext], [CreationDate] + ) + VALUES + ( + @LeaseDecisionId, @LeaseRequestId, 1 /* Human */, @RevokedBy, NULL, + 1 /* Deny */, @Reason, NULL, @Now + ) + + COMMIT TRANSACTION Lease_Revoke +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[Collection_ReadManagingUserIds] + @CollectionId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + DECLARE @OrganizationId UNIQUEIDENTIFIER + SELECT @OrganizationId = [OrganizationId] FROM [dbo].[Collection] WHERE [Id] = @CollectionId + + SELECT DISTINCT [UserId] + FROM + ( + SELECT OU.[UserId] + FROM [dbo].[CollectionUser] CU + INNER JOIN [dbo].[OrganizationUser] OU ON OU.[Id] = CU.[OrganizationUserId] + WHERE CU.[CollectionId] = @CollectionId + AND CU.[Manage] = 1 + AND OU.[Status] = 2 -- Confirmed + AND OU.[UserId] IS NOT NULL + + UNION + + SELECT OU.[UserId] + FROM [dbo].[CollectionGroup] CG + INNER JOIN [dbo].[GroupUser] GU ON GU.[GroupId] = CG.[GroupId] + INNER JOIN [dbo].[OrganizationUser] OU ON OU.[Id] = GU.[OrganizationUserId] + WHERE CG.[CollectionId] = @CollectionId + AND CG.[Manage] = 1 + AND OU.[Status] = 2 -- Confirmed + AND OU.[UserId] IS NOT NULL + + UNION + + SELECT OU.[UserId] + FROM [dbo].[OrganizationUser] OU + INNER JOIN [dbo].[Organization] O ON O.[Id] = OU.[OrganizationId] + WHERE OU.[OrganizationId] = @OrganizationId + AND OU.[Status] = 2 -- Confirmed + AND OU.[UserId] IS NOT NULL + AND ( + (O.[AllowAdminAccessToAllCollectionItems] = 1 AND OU.[Type] IN (0, 1)) -- Owner, Admin + OR (OU.[Type] = 4 -- Custom + AND ISJSON(OU.[Permissions]) = 1 + AND JSON_VALUE(OU.[Permissions], '$.editAnyCollection') = 'true') + ) + ) AS ManagingUsers +END +GO From e5cd361b207d2f62a2a7f5fbf355b36a0afedd46 Mon Sep 17 00:00:00 2001 From: Patrik Svensson Date: Fri, 5 Jun 2026 11:49:18 +0200 Subject: [PATCH 12/54] Enforce access rules during lease creation via AccessRuleEngine --- ...OrganizationServiceCollectionExtensions.cs | 2 + src/Core/Pam/Engine/AccessDecision.cs | 3 +- src/Core/Pam/Engine/AccessPolicyEngine.cs | 90 ++++ src/Core/Pam/Engine/AccessPolicySignals.cs | 14 + src/Core/Pam/Engine/AccessRule.cs | 34 -- src/Core/Pam/Engine/AccessRuleEngine.cs | 126 ----- .../Pam/Engine/AccessRuleEngineContext.cs | 7 - src/Core/Pam/Engine/AccessRuleEngineResult.cs | 14 - src/Core/Pam/Engine/AccessRuleLease.cs | 17 - src/Core/Pam/Engine/AccessRuleRequest.cs | 23 - src/Core/Pam/Engine/AccessRuleSignals.cs | 13 - .../Conditions/HumanApprovalCondition.cs | 11 - .../Pam/Engine/Conditions/IpRangeCondition.cs | 25 - .../Engine/Conditions/TimeOfDayCondition.cs | 34 -- src/Core/Pam/Engine/ExchangeResult.cs | 29 -- src/Core/Pam/Engine/IAccessCondition.cs | 6 - src/Core/Pam/Engine/IAccessPolicyEngine.cs | 14 + src/Core/Pam/Engine/RequestAccessResult.cs | 32 -- .../Pam/Models/AccessApprovalResolution.cs | 10 +- .../Commands/RequestAccessCommand.cs | 33 +- .../Queries/GetLeasedCipherQuery.cs | 37 +- .../Pam/Services/AccessApprovalResolver.cs | 25 +- .../Pam/Commands/RequestAccessCommandTests.cs | 30 +- .../Pam/Engine/AccessPolicyEngineTests.cs | 242 ++++++++++ .../Pam/Engine/AccessRuleEngineTests.cs | 434 ------------------ .../Conditions/HumanApprovalConditionTests.cs | 45 -- .../Conditions/IpRangeConditionTests.cs | 58 --- .../Conditions/TimeOfDayConditionTests.cs | 112 ----- .../Engine/Fixture/AccessRuleEngineFixture.cs | 171 ------- .../Fixture/FakeAccessRuleLeaseRepository.cs | 53 --- .../FakeAccessRuleRequestRepository.cs | 52 --- .../Engine/Fixture/FakeAccessRuleResolver.cs | 14 - .../Pam/Queries/AccessPreCheckQueryTests.cs | 5 +- .../Pam/Queries/GetLeasedCipherQueryTests.cs | 78 +++- .../Services/AccessApprovalResolverTests.cs | 7 + 35 files changed, 564 insertions(+), 1336 deletions(-) create mode 100644 src/Core/Pam/Engine/AccessPolicyEngine.cs create mode 100644 src/Core/Pam/Engine/AccessPolicySignals.cs delete mode 100644 src/Core/Pam/Engine/AccessRule.cs delete mode 100644 src/Core/Pam/Engine/AccessRuleEngine.cs delete mode 100644 src/Core/Pam/Engine/AccessRuleEngineContext.cs delete mode 100644 src/Core/Pam/Engine/AccessRuleEngineResult.cs delete mode 100644 src/Core/Pam/Engine/AccessRuleLease.cs delete mode 100644 src/Core/Pam/Engine/AccessRuleRequest.cs delete mode 100644 src/Core/Pam/Engine/AccessRuleSignals.cs delete mode 100644 src/Core/Pam/Engine/Conditions/HumanApprovalCondition.cs delete mode 100644 src/Core/Pam/Engine/Conditions/IpRangeCondition.cs delete mode 100644 src/Core/Pam/Engine/Conditions/TimeOfDayCondition.cs delete mode 100644 src/Core/Pam/Engine/ExchangeResult.cs delete mode 100644 src/Core/Pam/Engine/IAccessCondition.cs create mode 100644 src/Core/Pam/Engine/IAccessPolicyEngine.cs delete mode 100644 src/Core/Pam/Engine/RequestAccessResult.cs create mode 100644 test/Core.Test/Pam/Engine/AccessPolicyEngineTests.cs delete mode 100644 test/Core.Test/Pam/Engine/AccessRuleEngineTests.cs delete mode 100644 test/Core.Test/Pam/Engine/Conditions/HumanApprovalConditionTests.cs delete mode 100644 test/Core.Test/Pam/Engine/Conditions/IpRangeConditionTests.cs delete mode 100644 test/Core.Test/Pam/Engine/Conditions/TimeOfDayConditionTests.cs delete mode 100644 test/Core.Test/Pam/Engine/Fixture/AccessRuleEngineFixture.cs delete mode 100644 test/Core.Test/Pam/Engine/Fixture/FakeAccessRuleLeaseRepository.cs delete mode 100644 test/Core.Test/Pam/Engine/Fixture/FakeAccessRuleRequestRepository.cs delete mode 100644 test/Core.Test/Pam/Engine/Fixture/FakeAccessRuleResolver.cs diff --git a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs index ba49bffa24a8..9571e3b02980 100644 --- a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs +++ b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs @@ -40,6 +40,7 @@ using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; using Bit.Core.OrganizationFeatures.OrganizationUsers; using Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.Pam.Engine; using Bit.Core.Pam.OrganizationFeatures.Commands; using Bit.Core.Pam.OrganizationFeatures.Commands.Interfaces; using Bit.Core.Pam.OrganizationFeatures.Queries; @@ -200,6 +201,7 @@ public static void AddAccessRuleCommands(this IServiceCollection services) services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddSingleton(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/Core/Pam/Engine/AccessDecision.cs b/src/Core/Pam/Engine/AccessDecision.cs index 90d848e9dd08..a907636a7627 100644 --- a/src/Core/Pam/Engine/AccessDecision.cs +++ b/src/Core/Pam/Engine/AccessDecision.cs @@ -10,10 +10,9 @@ public enum DecisionKind public enum DenyReason { None = 0, - NoLease, - InvalidLease, NotWithinIpRange, NotWithinTimeWindow, + UnsupportedRule, } public sealed record AccessDecision diff --git a/src/Core/Pam/Engine/AccessPolicyEngine.cs b/src/Core/Pam/Engine/AccessPolicyEngine.cs new file mode 100644 index 000000000000..bfcb8519a4c7 --- /dev/null +++ b/src/Core/Pam/Engine/AccessPolicyEngine.cs @@ -0,0 +1,90 @@ +using System.Globalization; +using System.Net; +using Bit.Core.Pam.Models.Rules; + +namespace Bit.Core.Pam.Engine; + +/// +/// Recursively evaluates the polymorphic tree against the caller's signals. Each leaf rule +/// yields an ; combines its children with deny taking +/// precedence over a pending approval, which in turn takes precedence over allow. Unparseable inputs fail closed. +/// +public sealed class AccessPolicyEngine : IAccessPolicyEngine +{ + // The rule JSON encodes days as the lowercase three-letter abbreviations the validator accepts. + private static readonly IReadOnlyDictionary _days = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["sun"] = DayOfWeek.Sunday, + ["mon"] = DayOfWeek.Monday, + ["tue"] = DayOfWeek.Tuesday, + ["wed"] = DayOfWeek.Wednesday, + ["thu"] = DayOfWeek.Thursday, + ["fri"] = DayOfWeek.Friday, + ["sat"] = DayOfWeek.Saturday, + }; + + public AccessDecision Evaluate(Rule rule, AccessPolicySignals signals) => rule switch + { + HumanApprovalRule => AccessDecision.RequiresApproval, + IpAllowlistRule ip => EvaluateIpAllowlist(ip, signals), + TimeOfDayRule time => EvaluateTimeOfDay(time, signals), + AllOfRule all => AccessDecision.Combine(all.Rules.Select(child => Evaluate(child, signals))), + // A rule kind the engine does not understand cannot be shown to be satisfied, so deny. + _ => AccessDecision.Deny(DenyReason.UnsupportedRule), + }; + + private static AccessDecision EvaluateIpAllowlist(IpAllowlistRule rule, AccessPolicySignals signals) + { + // An allowlist with no entries permits no address; combined with an unknown caller IP, both fail closed. + if (rule.Cidrs.Count == 0 || signals.IpAddress is null) + { + return AccessDecision.Deny(DenyReason.NotWithinIpRange); + } + + foreach (var cidr in rule.Cidrs) + { + if (IPNetwork.TryParse(cidr, out var network) && network.Contains(signals.IpAddress)) + { + return AccessDecision.Allow; + } + } + + return AccessDecision.Deny(DenyReason.NotWithinIpRange); + } + + private static AccessDecision EvaluateTimeOfDay(TimeOfDayRule rule, AccessPolicySignals signals) + { + if (!TimeZoneInfo.TryFindSystemTimeZoneById(rule.Tz, out var timeZone)) + { + // The window cannot be evaluated without a valid timezone, so fail closed. + return AccessDecision.Deny(DenyReason.NotWithinTimeWindow); + } + + var local = TimeZoneInfo.ConvertTime(signals.Timestamp, timeZone); + var day = local.DayOfWeek; + var time = TimeOnly.FromTimeSpan(local.TimeOfDay); + + foreach (var window in rule.Windows) + { + if (WindowContains(window, day, time)) + { + return AccessDecision.Allow; + } + } + + return AccessDecision.Deny(DenyReason.NotWithinTimeWindow); + } + + private static bool WindowContains(TimeWindow window, DayOfWeek day, TimeOnly time) + { + var dayMatches = window.Days.Any(d => _days.TryGetValue(d, out var parsed) && parsed == day); + if (!dayMatches) + { + return false; + } + + return TimeOnly.TryParseExact(window.From, "HH:mm", CultureInfo.InvariantCulture, DateTimeStyles.None, out var from) + && TimeOnly.TryParseExact(window.To, "HH:mm", CultureInfo.InvariantCulture, DateTimeStyles.None, out var to) + && time >= from && time <= to; + } +} diff --git a/src/Core/Pam/Engine/AccessPolicySignals.cs b/src/Core/Pam/Engine/AccessPolicySignals.cs new file mode 100644 index 000000000000..2872ec598964 --- /dev/null +++ b/src/Core/Pam/Engine/AccessPolicySignals.cs @@ -0,0 +1,14 @@ +using System.Net; + +namespace Bit.Core.Pam.Engine; + +/// +/// The request-time inputs an access rule is evaluated against: the caller's source IP and the instant the +/// evaluation is performed. is null when the caller's address cannot be determined, which +/// IP-restricted rules treat as a denial so access never opens up on a missing signal. +/// +public sealed record AccessPolicySignals +{ + public required IPAddress? IpAddress { get; init; } + public required DateTimeOffset Timestamp { get; init; } +} diff --git a/src/Core/Pam/Engine/AccessRule.cs b/src/Core/Pam/Engine/AccessRule.cs deleted file mode 100644 index 6e4a7f246bf0..000000000000 --- a/src/Core/Pam/Engine/AccessRule.cs +++ /dev/null @@ -1,34 +0,0 @@ -using Bit.Core.Vault.Models.Data; - -namespace Bit.Core.Pam.Engine; - -public sealed record AccessRule -{ - public required string Name { get; init; } - public required TimeSpan Duration { get; init; } - - public bool RequireSingleton { get; init; } - public bool RequireApproval { get; init; } - public List RequiredCidr { get; init; } = []; - - public TimeOfDayConfig? TimeOfDay { get; init; } -} - -public sealed record TimeOfDayConfig -{ - public required string TimeZone { get; init; } // IANA timezone id (e.g. "America/New_York") - - public required IReadOnlyList Windows { get; init; } -} - -public sealed record AccessTimeWindow -{ - public IReadOnlyList Days { get; init; } = []; - public required TimeOnly From { get; init; } - public required TimeOnly To { get; init; } -} - -public interface IAccessRuleResolver -{ - AccessRule? Resolve(CipherDetails cipher); -} diff --git a/src/Core/Pam/Engine/AccessRuleEngine.cs b/src/Core/Pam/Engine/AccessRuleEngine.cs deleted file mode 100644 index 464d9f818c90..000000000000 --- a/src/Core/Pam/Engine/AccessRuleEngine.cs +++ /dev/null @@ -1,126 +0,0 @@ -using Bit.Core.Pam.Engine.Conditions; -using Bit.Core.Vault.Models.Data; - -namespace Bit.Core.Pam.Engine; - -public sealed class AccessRuleEngine( - TimeProvider time, - IAccessRuleResolver ruleResolver, - IAccessRuleRequestRepository requests, - IAccessRuleLeaseRepository leases) -{ - private static readonly IReadOnlyList _conditions = - [ - new HumanApprovalCondition(), - new IpRangeCondition(), - new TimeOfDayCondition(), - ]; - - private readonly TimeProvider _time = time ?? throw new ArgumentNullException(nameof(time)); - private readonly IAccessRuleResolver _ruleResolver = ruleResolver ?? throw new ArgumentNullException(nameof(ruleResolver)); - private readonly IAccessRuleRequestRepository _requests = requests ?? throw new ArgumentNullException(nameof(requests)); - private readonly IAccessRuleLeaseRepository _leases = leases ?? throw new ArgumentNullException(nameof(leases)); - - /// - /// Checks whether the user may access the cipher right now. Access is granted only when the user - /// already holds a valid, unexpired lease; this method never evaluates rules or issues leases. - /// - public AccessRuleEngineResult Check(CipherDetails cipher, AccessRuleSignals signals) - { - if (!_leases.TryGet(cipher.Id, signals.Username, out var lease)) - { - return AccessRuleEngineResult.Denied(DenyReason.NoLease); - } - - if (lease.Expires <= _time.GetUtcNow()) - { - return AccessRuleEngineResult.Denied(DenyReason.InvalidLease); - } - - return AccessOutcome.Granted; - } - - /// - /// Requests access to a cipher by creating a pending request that can later be exchanged for a - /// lease. The rule is evaluated here against the requesting user's signals so a request that the - /// rule denies (for example, from a disallowed IP or outside an allowed time window) is rejected - /// up front rather than at exchange time. Human approval is the one gate deferred to exchange: it - /// yields rather than a denial, so it does not block - /// the request from being created. Also fails when the user already holds an active lease or - /// already has a pending request. - /// - public RequestAccessResult RequestAccess(CipherDetails cipher, AccessRuleSignals signals) - { - // An active lease already grants access, so there is nothing to request. - if (_leases.TryGet(cipher.Id, signals.Username, out var lease) && lease.Expires > _time.GetUtcNow()) - { - return RequestAccessResult.Failed(RequestAccessFailReason.ExistingLease); - } - - // A request is already pending for this user and cipher. - if (_requests.TryGet(cipher.Id, signals.Username, out _)) - { - return RequestAccessResult.Failed(RequestAccessFailReason.ExistingRequest); - } - - var rule = _ruleResolver.Resolve(cipher); - if (rule is null) - { - // No rule governs this cipher, so there is no policy under which access could ever be - // granted; reject the request rather than create one that can never be exchanged. - return RequestAccessResult.Failed(RequestAccessFailReason.NoRule); - } - - // Evaluate every condition except approval (which yields RequiresApproval, not a denial) and - // reject the request if the rule denies access for the requesting user's signals. - var context = new AccessRuleEngineContext { Rule = rule, Signals = signals }; - var decision = AccessDecision.Combine(_conditions.Select(condition => condition.Evaluate(context))); - if (decision.Kind == DecisionKind.Deny) - { - return RequestAccessResult.AccessDenied(decision.Reason); - } - - var request = _requests.Create(cipher.Id, signals); - return RequestAccessResult.Created(request); - } - - /// - /// Exchanges a pending request for a lease. The rule's non-approval conditions were already - /// evaluated against the captured signals when the request was created, so the only rule gate - /// applied here is approval; a lease is then issued when the lease-issuance constraints - /// (singleton) are satisfied. - /// - public ExchangeResult ExchangeRequestForLease(CipherDetails cipher, string username) - { - if (!_requests.TryGet(cipher.Id, username, out var request)) - { - return ExchangeResult.Failed(ExchangeFailReason.RequestNotFound); - } - - var rule = _ruleResolver.Resolve(cipher); - if (rule is null) - { - // No rule governs this cipher, so there is no policy under which to issue a lease. - return ExchangeResult.Failed(ExchangeFailReason.NoRule); - } - - // A rule that requires approval cannot be exchanged until the request has been approved. - if (rule.RequireApproval && !request.Approved) - { - return ExchangeResult.Failed(ExchangeFailReason.NotApproved); - } - - // A singleton rule allows only one active lease per cipher at a time. - if (rule.RequireSingleton && _leases.TryGet(cipher.Id, out var held) && held.Expires > _time.GetUtcNow()) - { - return ExchangeResult.Failed(ExchangeFailReason.SingletonHeld); - } - - if (!_leases.TryCreate(request, rule.Duration, out var lease)) - { - return ExchangeResult.Failed(ExchangeFailReason.LeaseCreationFailed); - } - - return ExchangeResult.Created(lease); - } -} diff --git a/src/Core/Pam/Engine/AccessRuleEngineContext.cs b/src/Core/Pam/Engine/AccessRuleEngineContext.cs deleted file mode 100644 index 1cf0d960af85..000000000000 --- a/src/Core/Pam/Engine/AccessRuleEngineContext.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Bit.Core.Pam.Engine; - -public sealed class AccessRuleEngineContext -{ - public required AccessRule Rule { get; init; } - public required AccessRuleSignals Signals { get; init; } -} diff --git a/src/Core/Pam/Engine/AccessRuleEngineResult.cs b/src/Core/Pam/Engine/AccessRuleEngineResult.cs deleted file mode 100644 index 19331e464261..000000000000 --- a/src/Core/Pam/Engine/AccessRuleEngineResult.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace Bit.Core.Pam.Engine; - -public sealed record AccessRuleEngineResult(AccessOutcome Outcome, DenyReason Reason = DenyReason.None) -{ - public static implicit operator AccessRuleEngineResult(AccessOutcome outcome) => new(outcome); - - public static AccessRuleEngineResult Denied(DenyReason reason) => new(AccessOutcome.Denied, reason); -} - -public enum AccessOutcome -{ - Granted, - Denied, -} diff --git a/src/Core/Pam/Engine/AccessRuleLease.cs b/src/Core/Pam/Engine/AccessRuleLease.cs deleted file mode 100644 index 5bd5ebc9bf04..000000000000 --- a/src/Core/Pam/Engine/AccessRuleLease.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System.Diagnostics.CodeAnalysis; - -namespace Bit.Core.Pam.Engine; - -public sealed class AccessRuleLease -{ - public required Guid CipherId { get; init; } - public required string Username { get; init; } - public required DateTime Expires { get; init; } -} - -public interface IAccessRuleLeaseRepository -{ - bool TryCreate(AccessRuleRequest request, TimeSpan duration, [NotNullWhen(true)] out AccessRuleLease? lease); - bool TryGet(Guid cipherId, string username, [NotNullWhen(true)] out AccessRuleLease? lease); - bool TryGet(Guid cipherId, [NotNullWhen(true)] out AccessRuleLease? lease); -} diff --git a/src/Core/Pam/Engine/AccessRuleRequest.cs b/src/Core/Pam/Engine/AccessRuleRequest.cs deleted file mode 100644 index 6ea1063ae0e1..000000000000 --- a/src/Core/Pam/Engine/AccessRuleRequest.cs +++ /dev/null @@ -1,23 +0,0 @@ -using System.Diagnostics.CodeAnalysis; - -namespace Bit.Core.Pam.Engine; - -public sealed class AccessRuleRequest -{ - public required Guid CipherId { get; init; } - public required string Username { get; init; } - public required bool Approved { get; init; } - - /// - /// The signals captured when access was requested. The lease exchange re-evaluates the rule - /// against these, so the requester cannot alter their context between requesting and exchanging. - /// - public required AccessRuleSignals Signals { get; init; } -} - -public interface IAccessRuleRequestRepository -{ - AccessRuleRequest Create(Guid cipherId, AccessRuleSignals signals); - bool Delete(AccessRuleRequest request); - bool TryGet(Guid cipherId, string username, [NotNullWhen(true)] out AccessRuleRequest? request); -} diff --git a/src/Core/Pam/Engine/AccessRuleSignals.cs b/src/Core/Pam/Engine/AccessRuleSignals.cs deleted file mode 100644 index 8b57a44a97c8..000000000000 --- a/src/Core/Pam/Engine/AccessRuleSignals.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System.Net; -using Bit.Core.Enums; - -namespace Bit.Core.Pam.Engine; - -public sealed class AccessRuleSignals -{ - public required string Username { get; init; } - public required IPAddress IpAddress { get; init; } - public required bool MultifactorEnabled { get; init; } - public required DateTimeOffset UserTime { get; init; } - public required DeviceType Device { get; init; } -} diff --git a/src/Core/Pam/Engine/Conditions/HumanApprovalCondition.cs b/src/Core/Pam/Engine/Conditions/HumanApprovalCondition.cs deleted file mode 100644 index 9774be03b2a3..000000000000 --- a/src/Core/Pam/Engine/Conditions/HumanApprovalCondition.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace Bit.Core.Pam.Engine.Conditions; - -public sealed class HumanApprovalCondition : IAccessCondition -{ - public AccessDecision Evaluate(AccessRuleEngineContext context) - { - return context.Rule.RequireApproval - ? AccessDecision.RequiresApproval - : AccessDecision.Allow; - } -} diff --git a/src/Core/Pam/Engine/Conditions/IpRangeCondition.cs b/src/Core/Pam/Engine/Conditions/IpRangeCondition.cs deleted file mode 100644 index a9cfd597956d..000000000000 --- a/src/Core/Pam/Engine/Conditions/IpRangeCondition.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System.Net; - -namespace Bit.Core.Pam.Engine.Conditions; - -public sealed class IpRangeCondition : IAccessCondition -{ - public AccessDecision Evaluate(AccessRuleEngineContext context) - { - var requiredCidr = context.Rule.RequiredCidr; - if (requiredCidr.Count == 0) - { - return AccessDecision.Allow; - } - - foreach (var cidr in requiredCidr) - { - if (IPNetwork.TryParse(cidr, out var network) && network.Contains(context.Signals.IpAddress)) - { - return AccessDecision.Allow; - } - } - - return AccessDecision.Deny(DenyReason.NotWithinIpRange); - } -} diff --git a/src/Core/Pam/Engine/Conditions/TimeOfDayCondition.cs b/src/Core/Pam/Engine/Conditions/TimeOfDayCondition.cs deleted file mode 100644 index 396d0d9ee4d6..000000000000 --- a/src/Core/Pam/Engine/Conditions/TimeOfDayCondition.cs +++ /dev/null @@ -1,34 +0,0 @@ -namespace Bit.Core.Pam.Engine.Conditions; - -public sealed class TimeOfDayCondition : IAccessCondition -{ - public AccessDecision Evaluate(AccessRuleEngineContext context) - { - var config = context.Rule.TimeOfDay; - if (config is null) - { - return AccessDecision.Allow; - } - - if (!TimeZoneInfo.TryFindSystemTimeZoneById(config.TimeZone, out var timeZone)) - { - // The window cannot be evaluated without a valid timezone, so fail closed - return AccessDecision.Deny(DenyReason.NotWithinTimeWindow); - } - - var local = TimeZoneInfo.ConvertTime(context.Signals.UserTime, timeZone); - var day = local.DayOfWeek; - var time = TimeOnly.FromTimeSpan(local.TimeOfDay); - - foreach (var window in config.Windows) - { - var dayMatches = window.Days.Count == 0 || window.Days.Contains(day); - if (dayMatches && time >= window.From && time <= window.To) - { - return AccessDecision.Allow; - } - } - - return AccessDecision.Deny(DenyReason.NotWithinTimeWindow); - } -} diff --git a/src/Core/Pam/Engine/ExchangeResult.cs b/src/Core/Pam/Engine/ExchangeResult.cs deleted file mode 100644 index e1bbeff8cfc1..000000000000 --- a/src/Core/Pam/Engine/ExchangeResult.cs +++ /dev/null @@ -1,29 +0,0 @@ -namespace Bit.Core.Pam.Engine; - -public sealed record ExchangeResult( - ExchangeOutcome Outcome, - AccessRuleLease? Lease = null, - ExchangeFailReason FailReason = ExchangeFailReason.None) -{ - public static ExchangeResult Created(AccessRuleLease lease) => - new(ExchangeOutcome.Created, Lease: lease); - - public static ExchangeResult Failed(ExchangeFailReason reason) => - new(ExchangeOutcome.Failed, FailReason: reason); -} - -public enum ExchangeOutcome -{ - Created, - Failed, -} - -public enum ExchangeFailReason -{ - None = 0, - RequestNotFound, - NoRule, - NotApproved, - SingletonHeld, - LeaseCreationFailed, -} diff --git a/src/Core/Pam/Engine/IAccessCondition.cs b/src/Core/Pam/Engine/IAccessCondition.cs deleted file mode 100644 index 273ae82685f2..000000000000 --- a/src/Core/Pam/Engine/IAccessCondition.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Bit.Core.Pam.Engine; - -public interface IAccessCondition -{ - AccessDecision Evaluate(AccessRuleEngineContext context); -} diff --git a/src/Core/Pam/Engine/IAccessPolicyEngine.cs b/src/Core/Pam/Engine/IAccessPolicyEngine.cs new file mode 100644 index 000000000000..42c21ab72bae --- /dev/null +++ b/src/Core/Pam/Engine/IAccessPolicyEngine.cs @@ -0,0 +1,14 @@ +using Bit.Core.Pam.Models.Rules; + +namespace Bit.Core.Pam.Engine; + +/// +/// Evaluates the structured access that governs a cipher against the request-time +/// , deciding whether access is allowed, denied, or gated on human approval. +/// The engine is pure: it reads no state and issues no leases. Lease lifecycle is owned by the lease commands and +/// queries, which call the engine to decide whether a lease may be issued or its data handed over. +/// +public interface IAccessPolicyEngine +{ + AccessDecision Evaluate(Rule rule, AccessPolicySignals signals); +} diff --git a/src/Core/Pam/Engine/RequestAccessResult.cs b/src/Core/Pam/Engine/RequestAccessResult.cs deleted file mode 100644 index bc99b5011bae..000000000000 --- a/src/Core/Pam/Engine/RequestAccessResult.cs +++ /dev/null @@ -1,32 +0,0 @@ -namespace Bit.Core.Pam.Engine; - -public sealed record RequestAccessResult( - RequestAccessOutcome Outcome, - AccessRuleRequest? Request = null, - RequestAccessFailReason FailReason = RequestAccessFailReason.None, - DenyReason DenyReason = DenyReason.None) -{ - public static RequestAccessResult Created(AccessRuleRequest request) => - new(RequestAccessOutcome.Created, Request: request); - - public static RequestAccessResult Failed(RequestAccessFailReason reason) => - new(RequestAccessOutcome.Failed, FailReason: reason); - - public static RequestAccessResult AccessDenied(DenyReason denyReason) => - new(RequestAccessOutcome.Failed, FailReason: RequestAccessFailReason.AccessDenied, DenyReason: denyReason); -} - -public enum RequestAccessOutcome -{ - Created, - Failed, -} - -public enum RequestAccessFailReason -{ - None = 0, - ExistingLease, - ExistingRequest, - NoRule, - AccessDenied, -} diff --git a/src/Core/Pam/Models/AccessApprovalResolution.cs b/src/Core/Pam/Models/AccessApprovalResolution.cs index f320fa0598b2..ebc5aa686b5a 100644 --- a/src/Core/Pam/Models/AccessApprovalResolution.cs +++ b/src/Core/Pam/Models/AccessApprovalResolution.cs @@ -1,11 +1,15 @@ -namespace Bit.Core.Pam.Models; +using Bit.Core.Pam.Models.Rules; + +namespace Bit.Core.Pam.Models; /// /// The leasing context that governs a cipher for a particular caller: which collection's access rule applies, the -/// owning organization, and whether that rule requires human approval. A null resolution means the cipher is not +/// owning organization, whether that rule requires human approval, and the parsed itself so the +/// policy engine can evaluate it against the caller's signals. A null resolution means the cipher is not /// leasing-gated for the caller. /// public sealed record AccessApprovalResolution( Guid OrganizationId, Guid CollectionId, - bool RequiresHumanApproval); + bool RequiresHumanApproval, + Rule Rule); diff --git a/src/Core/Pam/OrganizationFeatures/Commands/RequestAccessCommand.cs b/src/Core/Pam/OrganizationFeatures/Commands/RequestAccessCommand.cs index 2d3658fff408..cfbd4f1d7fb8 100644 --- a/src/Core/Pam/OrganizationFeatures/Commands/RequestAccessCommand.cs +++ b/src/Core/Pam/OrganizationFeatures/Commands/RequestAccessCommand.cs @@ -1,4 +1,7 @@ -using Bit.Core.Exceptions; +using System.Net; +using Bit.Core.Context; +using Bit.Core.Exceptions; +using Bit.Core.Pam.Engine; using Bit.Core.Pam.Entities; using Bit.Core.Pam.Enums; using Bit.Core.Pam.Models; @@ -19,6 +22,8 @@ public class RequestAccessCommand : IRequestAccessCommand private readonly ICipherRepository _cipherRepository; private readonly IAccessApprovalResolver _resolver; + private readonly IAccessPolicyEngine _policyEngine; + private readonly ICurrentContext _currentContext; private readonly ILeaseRepository _leaseRepository; private readonly ILeaseRequestRepository _leaseRequestRepository; private readonly IApproverInboxNotifier _approverInboxNotifier; @@ -27,6 +32,8 @@ public class RequestAccessCommand : IRequestAccessCommand public RequestAccessCommand( ICipherRepository cipherRepository, IAccessApprovalResolver resolver, + IAccessPolicyEngine policyEngine, + ICurrentContext currentContext, ILeaseRepository leaseRepository, ILeaseRequestRepository leaseRequestRepository, IApproverInboxNotifier approverInboxNotifier, @@ -34,6 +41,8 @@ public RequestAccessCommand( { _cipherRepository = cipherRepository; _resolver = resolver; + _policyEngine = policyEngine; + _currentContext = currentContext; _leaseRepository = leaseRepository; _leaseRequestRepository = leaseRequestRepository; _approverInboxNotifier = approverInboxNotifier; @@ -89,6 +98,15 @@ private async Task IssueAutomaticLeaseAsync( throw new BadRequestException($"The requested duration exceeds the maximum of {MaxDurationSeconds} seconds."); } + // The cipher must satisfy its access rule's conditions (source IP, time of day, ...) before an automatic + // lease is issued. The resolver only routes a rule here when it carries no human-approval gate, so the + // engine never asks for approval on this path; any non-allow outcome is a denial we surface to the caller. + var policyDecision = _policyEngine.Evaluate(resolution.Rule, BuildSignals(now)); + if (policyDecision.Kind != DecisionKind.Allow) + { + throw new BadRequestException(DenyMessage(policyDecision)); + } + var notAfter = now.AddSeconds(durationSeconds); var request = new LeaseRequest @@ -183,4 +201,17 @@ private async Task RequestHumanApprovalAsync( return AccessRequestResult.Human(created); } + + private AccessPolicySignals BuildSignals(DateTime now) => new() + { + IpAddress = IPAddress.TryParse(_currentContext.IpAddress, out var ip) ? ip : null, + Timestamp = new DateTimeOffset(now, TimeSpan.Zero), + }; + + private static string DenyMessage(AccessDecision decision) => decision.Reason switch + { + DenyReason.NotWithinIpRange => "Access to this item is not permitted from your current network.", + DenyReason.NotWithinTimeWindow => "Access to this item is not permitted at this time.", + _ => "Access to this item is not permitted right now.", + }; } diff --git a/src/Core/Pam/OrganizationFeatures/Queries/GetLeasedCipherQuery.cs b/src/Core/Pam/OrganizationFeatures/Queries/GetLeasedCipherQuery.cs index 39265e28e58e..d26f68278dd5 100644 --- a/src/Core/Pam/OrganizationFeatures/Queries/GetLeasedCipherQuery.cs +++ b/src/Core/Pam/OrganizationFeatures/Queries/GetLeasedCipherQuery.cs @@ -1,5 +1,9 @@ -using Bit.Core.Pam.OrganizationFeatures.Queries.Interfaces; +using System.Net; +using Bit.Core.Context; +using Bit.Core.Pam.Engine; +using Bit.Core.Pam.OrganizationFeatures.Queries.Interfaces; using Bit.Core.Pam.Repositories; +using Bit.Core.Pam.Services; using Bit.Core.Vault.Models.Data; using Bit.Core.Vault.Repositories; @@ -9,29 +13,56 @@ public class GetLeasedCipherQuery : IGetLeasedCipherQuery { private readonly ICipherRepository _cipherRepository; private readonly ILeaseRepository _leaseRepository; + private readonly IAccessApprovalResolver _resolver; + private readonly IAccessPolicyEngine _policyEngine; + private readonly ICurrentContext _currentContext; private readonly TimeProvider _timeProvider; public GetLeasedCipherQuery( ICipherRepository cipherRepository, ILeaseRepository leaseRepository, + IAccessApprovalResolver resolver, + IAccessPolicyEngine policyEngine, + ICurrentContext currentContext, TimeProvider timeProvider) { _cipherRepository = cipherRepository; _leaseRepository = leaseRepository; + _resolver = resolver; + _policyEngine = policyEngine; + _currentContext = currentContext; _timeProvider = timeProvider; } public async Task GetLeasedCipherAsync(Guid userId, Guid cipherId) { - var now = _timeProvider.GetUtcNow().UtcDateTime; + var now = _timeProvider.GetUtcNow(); // Without an active lease whose window contains now, the caller is not entitled to the full data right now. - var lease = await _leaseRepository.GetActiveByRequesterIdCipherIdAsync(userId, cipherId, now); + var lease = await _leaseRepository.GetActiveByRequesterIdCipherIdAsync(userId, cipherId, now.UtcDateTime); if (lease is null) { return null; } + // A lease grants a window, but the access rule's environmental conditions (source IP, time of day) must + // still hold at the moment the data is handed over. Approval is not re-checked here: holding the lease is + // proof it was already granted, so only an outright denial withholds the data. + var resolution = await _resolver.ResolveAsync(userId, cipherId); + if (resolution is not null) + { + var signals = new AccessPolicySignals + { + IpAddress = IPAddress.TryParse(_currentContext.IpAddress, out var ip) ? ip : null, + Timestamp = now, + }; + + if (_policyEngine.Evaluate(resolution.Rule, signals).Kind == DecisionKind.Deny) + { + return null; + } + } + // GetByIdAsync filters by access, so a null result means the caller cannot see the cipher. return await _cipherRepository.GetByIdAsync(cipherId, userId); } diff --git a/src/Core/Pam/Services/AccessApprovalResolver.cs b/src/Core/Pam/Services/AccessApprovalResolver.cs index 0c317f5b63a3..a7f505c5f866 100644 --- a/src/Core/Pam/Services/AccessApprovalResolver.cs +++ b/src/Core/Pam/Services/AccessApprovalResolver.cs @@ -47,41 +47,40 @@ public AccessApprovalResolver( AccessApprovalResolution? automatic = null; foreach (var collection in governed) { - var rule = await _accessRuleRepository.GetByIdAsync(collection.AccessRuleId!.Value); - if (rule is null) + var accessRule = await _accessRuleRepository.GetByIdAsync(collection.AccessRuleId!.Value); + if (accessRule is null) { continue; } - if (RequiresHumanApproval(rule.Rule)) + var rule = Parse(accessRule.Rule); + if (ContainsHumanApproval(rule)) { // Most restrictive wins — return as soon as a human-approval rule is found. - return new AccessApprovalResolution(collection.OrganizationId, collection.Id, true); + return new AccessApprovalResolution(collection.OrganizationId, collection.Id, true, rule); } - automatic ??= new AccessApprovalResolution(collection.OrganizationId, collection.Id, false); + automatic ??= new AccessApprovalResolution(collection.OrganizationId, collection.Id, false, rule); } return automatic; } /// - /// True when the rule tree contains a human-approval node. A malformed or unparseable rule fails safe to true so - /// access is never silently auto-approved on a rule the server could not understand. + /// Parses the stored rule JSON into a . A malformed or unparseable rule fails safe to a + /// so access is never silently auto-approved on a rule the server could not + /// understand; the human-approval path then routes it to an approver rather than issuing an automatic lease. /// - private static bool RequiresHumanApproval(string ruleJson) + private static Rule Parse(string ruleJson) { - Rule? rule; try { - rule = JsonSerializer.Deserialize(ruleJson, _jsonOptions); + return JsonSerializer.Deserialize(ruleJson, _jsonOptions) ?? new HumanApprovalRule(); } catch (JsonException) { - return true; + return new HumanApprovalRule(); } - - return rule is null || ContainsHumanApproval(rule); } private static bool ContainsHumanApproval(Rule rule) => rule switch diff --git a/test/Core.Test/Pam/Commands/RequestAccessCommandTests.cs b/test/Core.Test/Pam/Commands/RequestAccessCommandTests.cs index f91ab8b2ff28..c119642b7164 100644 --- a/test/Core.Test/Pam/Commands/RequestAccessCommandTests.cs +++ b/test/Core.Test/Pam/Commands/RequestAccessCommandTests.cs @@ -1,7 +1,9 @@ using Bit.Core.Exceptions; +using Bit.Core.Pam.Engine; using Bit.Core.Pam.Entities; using Bit.Core.Pam.Enums; using Bit.Core.Pam.Models; +using Bit.Core.Pam.Models.Rules; using Bit.Core.Pam.OrganizationFeatures.Commands; using Bit.Core.Pam.Repositories; using Bit.Core.Pam.Services; @@ -49,6 +51,7 @@ public async Task RequestAccessAsync_Automatic_IssuesActiveLease(Guid userId, Gu var sutProvider = Setup(); SetupCipher(sutProvider, userId, cipherId); SetupResolution(sutProvider, userId, cipherId, orgId, collectionId, requiresHuman: false); + SetupPolicyDecision(sutProvider, AccessDecision.Allow); var result = await sutProvider.Sut.RequestAccessAsync(userId, cipherId, new AccessRequestSubmission { DurationSeconds = 3600, Reason = "deploy" }); @@ -101,6 +104,23 @@ public async Task RequestAccessAsync_AutomaticDurationExceedsMax_ThrowsBadReques Assert.Contains("maximum", ex.Message); } + [Theory, BitAutoData] + public async Task RequestAccessAsync_AutomaticPolicyDenied_ThrowsBadRequestAndIssuesNoLease( + Guid userId, Guid cipherId, Guid orgId, Guid collectionId) + { + var sutProvider = Setup(); + SetupCipher(sutProvider, userId, cipherId); + SetupResolution(sutProvider, userId, cipherId, orgId, collectionId, requiresHuman: false); + SetupPolicyDecision(sutProvider, AccessDecision.Deny(DenyReason.NotWithinIpRange)); + + var ex = await Assert.ThrowsAsync( + () => sutProvider.Sut.RequestAccessAsync(userId, cipherId, new AccessRequestSubmission { DurationSeconds = 3600 })); + Assert.Contains("network", ex.Message); + // A rule the caller fails to satisfy must not produce a lease. + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .CreateAutoApprovedAsync(default!, default!, default!, default); + } + [Theory, BitAutoData] public async Task RequestAccessAsync_Human_CreatesPendingRequest(Guid userId, Guid cipherId, Guid orgId, Guid collectionId) { @@ -229,8 +249,16 @@ private static void SetupCipher(SutProvider sutProvider, G private static void SetupResolution(SutProvider sutProvider, Guid userId, Guid cipherId, Guid orgId, Guid collectionId, bool requiresHuman) { + var rule = requiresHuman ? new HumanApprovalRule() : (Rule)new IpAllowlistRule { Cidrs = ["10.0.0.0/8"] }; sutProvider.GetDependency() .ResolveAsync(userId, cipherId) - .Returns(new AccessApprovalResolution(orgId, collectionId, requiresHuman)); + .Returns(new AccessApprovalResolution(orgId, collectionId, requiresHuman, rule)); + } + + private static void SetupPolicyDecision(SutProvider sutProvider, AccessDecision decision) + { + sutProvider.GetDependency() + .Evaluate(Arg.Any(), Arg.Any()) + .Returns(decision); } } diff --git a/test/Core.Test/Pam/Engine/AccessPolicyEngineTests.cs b/test/Core.Test/Pam/Engine/AccessPolicyEngineTests.cs new file mode 100644 index 000000000000..02ff497041ab --- /dev/null +++ b/test/Core.Test/Pam/Engine/AccessPolicyEngineTests.cs @@ -0,0 +1,242 @@ +using System.Net; +using Bit.Core.Pam.Engine; +using Bit.Core.Pam.Models.Rules; +using Xunit; + +namespace Bit.Core.Test.Pam.Engine; + +public class AccessPolicyEngineTests +{ + // 2026-06-04T12:00:00Z is a Thursday, so "thu" windows match in UTC. + private static readonly DateTimeOffset _now = new(2026, 6, 4, 12, 0, 0, TimeSpan.Zero); + + private readonly AccessPolicyEngine _sut = new(); + + private static AccessPolicySignals Signals(IPAddress? ip = null, DateTimeOffset? at = null) => new() + { + IpAddress = ip, + Timestamp = at ?? _now, + }; + + [Fact] + public void Evaluate_HumanApproval_RequiresApproval() + { + var decision = _sut.Evaluate(new HumanApprovalRule(), Signals()); + + Assert.Equal(DecisionKind.RequiresApproval, decision.Kind); + } + + [Fact] + public void Evaluate_IpAllowlist_IpInRange_Allows() + { + var rule = new IpAllowlistRule { Cidrs = ["10.0.0.0/8"] }; + + var decision = _sut.Evaluate(rule, Signals(IPAddress.Parse("10.1.2.3"))); + + Assert.Equal(DecisionKind.Allow, decision.Kind); + } + + [Fact] + public void Evaluate_IpAllowlist_IpOutOfRange_Denies() + { + var rule = new IpAllowlistRule { Cidrs = ["10.0.0.0/8"] }; + + var decision = _sut.Evaluate(rule, Signals(IPAddress.Parse("192.168.1.1"))); + + Assert.Equal(DecisionKind.Deny, decision.Kind); + Assert.Equal(DenyReason.NotWithinIpRange, decision.Reason); + } + + [Fact] + public void Evaluate_IpAllowlist_UnknownIp_DeniesClosed() + { + var rule = new IpAllowlistRule { Cidrs = ["10.0.0.0/8"] }; + + var decision = _sut.Evaluate(rule, Signals(ip: null)); + + Assert.Equal(DecisionKind.Deny, decision.Kind); + Assert.Equal(DenyReason.NotWithinIpRange, decision.Reason); + } + + [Fact] + public void Evaluate_IpAllowlist_NoEntries_DeniesClosed() + { + var decision = _sut.Evaluate(new IpAllowlistRule(), Signals(IPAddress.Parse("10.1.2.3"))); + + Assert.Equal(DecisionKind.Deny, decision.Kind); + Assert.Equal(DenyReason.NotWithinIpRange, decision.Reason); + } + + [Fact] + public void Evaluate_TimeOfDay_WithinWindow_Allows() + { + var rule = new TimeOfDayRule + { + Tz = "UTC", + Windows = [new TimeWindow { Days = ["thu"], From = "09:00", To = "17:00" }], + }; + + var decision = _sut.Evaluate(rule, Signals()); + + Assert.Equal(DecisionKind.Allow, decision.Kind); + } + + [Fact] + public void Evaluate_TimeOfDay_OutsideTimeWindow_Denies() + { + var rule = new TimeOfDayRule + { + Tz = "UTC", + Windows = [new TimeWindow { Days = ["thu"], From = "00:00", To = "06:00" }], + }; + + var decision = _sut.Evaluate(rule, Signals()); + + Assert.Equal(DecisionKind.Deny, decision.Kind); + Assert.Equal(DenyReason.NotWithinTimeWindow, decision.Reason); + } + + [Fact] + public void Evaluate_TimeOfDay_DayNotListed_Denies() + { + var rule = new TimeOfDayRule + { + Tz = "UTC", + Windows = [new TimeWindow { Days = ["fri"], From = "00:00", To = "23:59" }], + }; + + var decision = _sut.Evaluate(rule, Signals()); + + Assert.Equal(DecisionKind.Deny, decision.Kind); + Assert.Equal(DenyReason.NotWithinTimeWindow, decision.Reason); + } + + [Fact] + public void Evaluate_TimeOfDay_EvaluatesInConfiguredTimezone() + { + // 23:00 UTC is 19:00 (Thursday) in America/New_York during June DST, inside the window. + var rule = new TimeOfDayRule + { + Tz = "America/New_York", + Windows = [new TimeWindow { Days = ["thu"], From = "18:00", To = "20:00" }], + }; + + var decision = _sut.Evaluate(rule, Signals(at: new DateTimeOffset(2026, 6, 4, 23, 0, 0, TimeSpan.Zero))); + + Assert.Equal(DecisionKind.Allow, decision.Kind); + } + + [Fact] + public void Evaluate_TimeOfDay_UnknownTimezone_DeniesClosed() + { + var rule = new TimeOfDayRule + { + Tz = "Not/AZone", + Windows = [new TimeWindow { Days = ["thu"], From = "00:00", To = "23:59" }], + }; + + var decision = _sut.Evaluate(rule, Signals()); + + Assert.Equal(DecisionKind.Deny, decision.Kind); + Assert.Equal(DenyReason.NotWithinTimeWindow, decision.Reason); + } + + [Fact] + public void Evaluate_AllOf_AllAllow_Allows() + { + var rule = new AllOfRule + { + Rules = + [ + new IpAllowlistRule { Cidrs = ["10.0.0.0/8"] }, + new TimeOfDayRule { Tz = "UTC", Windows = [new TimeWindow { Days = ["thu"], From = "09:00", To = "17:00" }] }, + ], + }; + + var decision = _sut.Evaluate(rule, Signals(IPAddress.Parse("10.1.2.3"))); + + Assert.Equal(DecisionKind.Allow, decision.Kind); + } + + [Fact] + public void Evaluate_AllOf_OneDenies_DeniesWithThatReason() + { + var rule = new AllOfRule + { + Rules = + [ + new IpAllowlistRule { Cidrs = ["10.0.0.0/8"] }, + new TimeOfDayRule { Tz = "UTC", Windows = [new TimeWindow { Days = ["thu"], From = "00:00", To = "06:00" }] }, + ], + }; + + var decision = _sut.Evaluate(rule, Signals(IPAddress.Parse("10.1.2.3"))); + + Assert.Equal(DecisionKind.Deny, decision.Kind); + Assert.Equal(DenyReason.NotWithinTimeWindow, decision.Reason); + } + + [Fact] + public void Evaluate_AllOf_AllowPlusHumanApproval_RequiresApproval() + { + var rule = new AllOfRule + { + Rules = + [ + new IpAllowlistRule { Cidrs = ["10.0.0.0/8"] }, + new HumanApprovalRule(), + ], + }; + + var decision = _sut.Evaluate(rule, Signals(IPAddress.Parse("10.1.2.3"))); + + Assert.Equal(DecisionKind.RequiresApproval, decision.Kind); + } + + [Fact] + public void Evaluate_AllOf_DenyOutranksApproval() + { + // A denying condition beats a pending approval: there is nothing to approve if access is barred outright. + var rule = new AllOfRule + { + Rules = + [ + new HumanApprovalRule(), + new IpAllowlistRule { Cidrs = ["10.0.0.0/8"] }, + ], + }; + + var decision = _sut.Evaluate(rule, Signals(IPAddress.Parse("192.168.1.1"))); + + Assert.Equal(DecisionKind.Deny, decision.Kind); + Assert.Equal(DenyReason.NotWithinIpRange, decision.Reason); + } + + [Fact] + public void Evaluate_NestedAllOf_Allows() + { + var rule = new AllOfRule + { + Rules = + [ + new AllOfRule { Rules = [new IpAllowlistRule { Cidrs = ["10.0.0.0/8"] }] }, + new TimeOfDayRule { Tz = "UTC", Windows = [new TimeWindow { Days = ["thu"], From = "09:00", To = "17:00" }] }, + ], + }; + + var decision = _sut.Evaluate(rule, Signals(IPAddress.Parse("10.1.2.3"))); + + Assert.Equal(DecisionKind.Allow, decision.Kind); + } + + [Fact] + public void Evaluate_UnsupportedRuleKind_DeniesClosed() + { + var decision = _sut.Evaluate(new UnknownRule(), Signals()); + + Assert.Equal(DecisionKind.Deny, decision.Kind); + Assert.Equal(DenyReason.UnsupportedRule, decision.Reason); + } + + private sealed class UnknownRule : Rule; +} diff --git a/test/Core.Test/Pam/Engine/AccessRuleEngineTests.cs b/test/Core.Test/Pam/Engine/AccessRuleEngineTests.cs deleted file mode 100644 index b515c2ec4129..000000000000 --- a/test/Core.Test/Pam/Engine/AccessRuleEngineTests.cs +++ /dev/null @@ -1,434 +0,0 @@ -using System.Net; -using Bit.Core.Pam.Engine; -using Xunit; - -namespace Bit.Core.Test.Pam.Engine; - -public sealed class AccessRuleEngineTests -{ - // Check: access is granted only when the user already holds a valid, unexpired lease. - - [Fact] - public void Check_NoLease_DeniesWithNoLease() - { - var fixture = new AccessRuleEngineFixture(); - - var result = fixture.Check(fixture.Cipher); - - Assert.Equal(AccessOutcome.Denied, result.Outcome); - Assert.Equal(DenyReason.NoLease, result.Reason); - } - - [Fact] - public void Check_ExpiredLease_DeniesWithInvalidLease() - { - var fixture = new AccessRuleEngineFixture() - .WithExpiredLease(); - - var result = fixture.Check(fixture.Cipher); - - Assert.Equal(AccessOutcome.Denied, result.Outcome); - Assert.Equal(DenyReason.InvalidLease, result.Reason); - } - - [Fact] - public void Check_ValidLease_Grants() - { - var fixture = new AccessRuleEngineFixture() - .WithActiveLease(); - - var result = fixture.Check(fixture.Cipher); - - Assert.Equal(AccessOutcome.Granted, result.Outcome); - } - - [Fact] - public void Check_ValidLeaseHeldByAnotherUser_DeniesWithNoLease() - { - var fixture = new AccessRuleEngineFixture() - .WithActiveLeaseHeldBy(AccessRuleEngineFixture.AnotherUser); - - var result = fixture.Check(fixture.Cipher); - - Assert.Equal(AccessOutcome.Denied, result.Outcome); - Assert.Equal(DenyReason.NoLease, result.Reason); - } - - // RequestAccess: create a pending request unless an active lease or pending request already exists. - - [Fact] - public void RequestAccess_NoLeaseNoRequest_CreatesRequest() - { - var fixture = new AccessRuleEngineFixture(); - - var result = fixture.RequestAccess(fixture.Cipher); - - Assert.Equal(RequestAccessOutcome.Created, result.Outcome); - Assert.NotNull(result.Request); - Assert.Equal(1, fixture.RequestsCreated); - } - - [Fact] - public void RequestAccess_CapturesRequestingUserSignals() - { - var fixture = new AccessRuleEngineFixture() - .FromIpAddress("10.0.0.5"); - - var result = fixture.RequestAccess(fixture.Cipher); - - Assert.NotNull(result.Request); - Assert.Equal(AccessRuleEngineFixture.RequestingUser, result.Request.Username); - Assert.Equal(IPAddress.Parse("10.0.0.5"), result.Request.Signals.IpAddress); - } - - [Fact] - public void RequestAccess_ActiveLeaseExists_FailsWithExistingLease() - { - var fixture = new AccessRuleEngineFixture() - .WithActiveLease(); - - var result = fixture.RequestAccess(fixture.Cipher); - - Assert.Equal(RequestAccessOutcome.Failed, result.Outcome); - Assert.Equal(RequestAccessFailReason.ExistingLease, result.FailReason); - Assert.Equal(0, fixture.RequestsCreated); - } - - [Fact] - public void RequestAccess_ExpiredLeaseExists_CreatesRequest() - { - // An expired lease no longer grants access, so the user may request again. - var fixture = new AccessRuleEngineFixture() - .WithExpiredLease(); - - var result = fixture.RequestAccess(fixture.Cipher); - - Assert.Equal(RequestAccessOutcome.Created, result.Outcome); - Assert.Equal(1, fixture.RequestsCreated); - } - - [Fact] - public void RequestAccess_RequestAlreadyExists_FailsWithExistingRequest() - { - var fixture = new AccessRuleEngineFixture() - .WithPendingRequest(); - - var result = fixture.RequestAccess(fixture.Cipher); - - Assert.Equal(RequestAccessOutcome.Failed, result.Outcome); - Assert.Equal(RequestAccessFailReason.ExistingRequest, result.FailReason); - Assert.Equal(0, fixture.RequestsCreated); - } - - [Fact] - public void RequestAccess_CalledTwice_SecondFailsWithExistingRequest() - { - var fixture = new AccessRuleEngineFixture(); - - var first = fixture.RequestAccess(fixture.Cipher); - var second = fixture.RequestAccess(fixture.Cipher); - - Assert.Equal(RequestAccessOutcome.Created, first.Outcome); - Assert.Equal(RequestAccessOutcome.Failed, second.Outcome); - Assert.Equal(RequestAccessFailReason.ExistingRequest, second.FailReason); - Assert.Equal(1, fixture.RequestsCreated); - } - - [Fact] - public void RequestAccess_AnotherUserHoldsActiveLease_StillCreatesRequest() - { - // The singleton constraint is enforced when exchanging, not when requesting, so another - // user's lease does not block this user from requesting access. - var fixture = new AccessRuleEngineFixture() - .RequiringSingleton() - .WithActiveLeaseHeldBy(AccessRuleEngineFixture.AnotherUser); - - var result = fixture.RequestAccess(fixture.Cipher); - - Assert.Equal(RequestAccessOutcome.Created, result.Outcome); - } - - [Fact] - public void RequestAccess_NoRuleGovernsCipher_FailsWithNoRule() - { - // Without a governing rule there is no policy under which access could ever be granted, so - // the request is rejected rather than created. - var fixture = new AccessRuleEngineFixture() - .WithNoRules(); - - var result = fixture.RequestAccess(fixture.Cipher); - - Assert.Equal(RequestAccessOutcome.Failed, result.Outcome); - Assert.Equal(RequestAccessFailReason.NoRule, result.FailReason); - Assert.Equal(0, fixture.RequestsCreated); - } - - [Fact] - public void RequestAccess_ApprovalRequired_StillCreatesRequest() - { - // Approval is not a denial, so an approval-required rule does not block the request from - // being created; the approval gate is enforced later at exchange. - var fixture = new AccessRuleEngineFixture() - .RequiringApproval(); - - var result = fixture.RequestAccess(fixture.Cipher); - - Assert.Equal(RequestAccessOutcome.Created, result.Outcome); - Assert.Equal(1, fixture.RequestsCreated); - } - - [Fact] - public void RequestAccess_IpAddressOutsideRequiredCidr_FailsWithAccessDeniedNotWithinIpRange() - { - var fixture = new AccessRuleEngineFixture() - .RestrictedToCidr("10.0.0.0/24") - .FromIpAddress("192.168.1.5"); - - var result = fixture.RequestAccess(fixture.Cipher); - - Assert.Equal(RequestAccessOutcome.Failed, result.Outcome); - Assert.Equal(RequestAccessFailReason.AccessDenied, result.FailReason); - Assert.Equal(DenyReason.NotWithinIpRange, result.DenyReason); - Assert.Equal(0, fixture.RequestsCreated); - } - - [Fact] - public void RequestAccess_IpAddressWithinRequiredCidr_CreatesRequest() - { - var fixture = new AccessRuleEngineFixture() - .RestrictedToCidr("10.0.0.0/24") - .FromIpAddress("10.0.0.5"); - - var result = fixture.RequestAccess(fixture.Cipher); - - Assert.Equal(RequestAccessOutcome.Created, result.Outcome); - } - - [Fact] - public void RequestAccess_UnparseableCidrEntryIsSkipped_AndALaterMatchCreatesRequest() - { - var fixture = new AccessRuleEngineFixture() - .RestrictedToCidr("not-a-cidr", "10.0.0.0/24") - .FromIpAddress("10.0.0.5"); - - var result = fixture.RequestAccess(fixture.Cipher); - - Assert.Equal(RequestAccessOutcome.Created, result.Outcome); - } - - [Fact] - public void RequestAccess_OutsideTimeWindow_FailsWithAccessDeniedNotWithinTimeWindow() - { - // The fixture's signal time is 12:00 UTC, outside the 13:00-17:00 window. - var fixture = new AccessRuleEngineFixture() - .RestrictedToTimeWindow("UTC", new TimeOnly(13, 0), new TimeOnly(17, 0)); - - var result = fixture.RequestAccess(fixture.Cipher); - - Assert.Equal(RequestAccessOutcome.Failed, result.Outcome); - Assert.Equal(RequestAccessFailReason.AccessDenied, result.FailReason); - Assert.Equal(DenyReason.NotWithinTimeWindow, result.DenyReason); - Assert.Equal(0, fixture.RequestsCreated); - } - - // ExchangeRequestForLease: gate on approval, enforce lease-issuance constraints, then issue the - // lease. The rule's non-approval conditions were already evaluated at request time. - - [Fact] - public void Exchange_NoRequest_FailsWithRequestNotFound() - { - var fixture = new AccessRuleEngineFixture(); - - var result = fixture.Exchange(fixture.Cipher); - - Assert.Equal(ExchangeOutcome.Failed, result.Outcome); - Assert.Equal(ExchangeFailReason.RequestNotFound, result.FailReason); - } - - [Fact] - public void Exchange_NoRuleGovernsCipher_FailsWithNoRule() - { - // A request was created while a rule governed the cipher, but the rule has since been - // removed; the exchange defensively rejects rather than issue a lease with no policy. - var fixture = new AccessRuleEngineFixture() - .WithPendingRequest() - .WithNoRules(); - - var result = fixture.Exchange(fixture.Cipher); - - Assert.Equal(ExchangeOutcome.Failed, result.Outcome); - Assert.Equal(ExchangeFailReason.NoRule, result.FailReason); - } - - [Fact] - public void Exchange_PermissiveRule_CreatesLease() - { - var fixture = new AccessRuleEngineFixture(); - fixture.RequestAccess(fixture.Cipher); - - var result = fixture.Exchange(fixture.Cipher); - - Assert.Equal(ExchangeOutcome.Created, result.Outcome); - Assert.NotNull(result.Lease); - Assert.Equal(1, fixture.LeasesCreated); - } - - [Fact] - public void Exchange_ApprovalRequiredAndRequestNotApproved_FailsWithNotApproved() - { - var fixture = new AccessRuleEngineFixture() - .RequiringApproval(); - fixture.RequestAccess(fixture.Cipher); - - var result = fixture.Exchange(fixture.Cipher); - - Assert.Equal(ExchangeOutcome.Failed, result.Outcome); - Assert.Equal(ExchangeFailReason.NotApproved, result.FailReason); - Assert.Equal(0, fixture.LeasesCreated); - } - - [Fact] - public void Exchange_ApprovalRequiredAndRequestApproved_CreatesLease() - { - var fixture = new AccessRuleEngineFixture() - .RequiringApproval(); - fixture.RequestAccess(fixture.Cipher); - fixture.ApproveRequest(); - - var result = fixture.Exchange(fixture.Cipher); - - Assert.Equal(ExchangeOutcome.Created, result.Outcome); - Assert.NotNull(result.Lease); - } - - [Fact] - public void Exchange_IpAddressWithinRequiredCidr_CreatesLease() - { - var fixture = new AccessRuleEngineFixture() - .RestrictedToCidr("10.0.0.0/24") - .FromIpAddress("10.0.0.5"); - fixture.RequestAccess(fixture.Cipher); - - var result = fixture.Exchange(fixture.Cipher); - - Assert.Equal(ExchangeOutcome.Created, result.Outcome); - } - - [Fact] - public void Exchange_SingletonRequiredAndAnotherUserHoldsActiveLease_FailsWithSingletonHeld() - { - var fixture = new AccessRuleEngineFixture() - .RequiringSingleton() - .WithActiveLeaseHeldBy(AccessRuleEngineFixture.AnotherUser); - fixture.RequestAccess(fixture.Cipher); - - var result = fixture.Exchange(fixture.Cipher); - - Assert.Equal(ExchangeOutcome.Failed, result.Outcome); - Assert.Equal(ExchangeFailReason.SingletonHeld, result.FailReason); - Assert.Equal(0, fixture.LeasesCreated); - } - - [Fact] - public void Exchange_SingletonRequiredAndNoExistingLease_CreatesLease() - { - var fixture = new AccessRuleEngineFixture() - .RequiringSingleton(); - fixture.RequestAccess(fixture.Cipher); - - var result = fixture.Exchange(fixture.Cipher); - - Assert.Equal(ExchangeOutcome.Created, result.Outcome); - Assert.Equal(1, fixture.LeasesCreated); - } - - [Fact] - public void Exchange_LeaseCreationFails_FailsWithLeaseCreationFailed() - { - var fixture = new AccessRuleEngineFixture() - .WhereLeaseCreationFails(); - fixture.RequestAccess(fixture.Cipher); - - var result = fixture.Exchange(fixture.Cipher); - - Assert.Equal(ExchangeOutcome.Failed, result.Outcome); - Assert.Equal(ExchangeFailReason.LeaseCreationFailed, result.FailReason); - } - - [Fact] - public void Exchange_IgnoresLiveContext_GrantsEvenWhenLiveContextWouldBeDenied() - { - // Request from an allowed address, then move to a denied one before exchanging. The lease is - // still issued because the rule is only evaluated at request time, not at exchange. - var fixture = new AccessRuleEngineFixture() - .RestrictedToCidr("10.0.0.0/24") - .FromIpAddress("10.0.0.5"); - fixture.RequestAccess(fixture.Cipher); - fixture.FromIpAddress("192.168.1.5"); - - var result = fixture.Exchange(fixture.Cipher); - - Assert.Equal(ExchangeOutcome.Created, result.Outcome); - } - - [Fact] - public void RequestAccess_DeniedContext_RejectedBeforeAnyLaterContextChange() - { - // The rule is evaluated against the requesting user's signals, so a request from a denied - // address is rejected immediately; a later move to an allowed address cannot resurrect it - // because no request was ever created. - var fixture = new AccessRuleEngineFixture() - .RestrictedToCidr("10.0.0.0/24") - .FromIpAddress("192.168.1.5"); - - var result = fixture.RequestAccess(fixture.Cipher); - - Assert.Equal(RequestAccessOutcome.Failed, result.Outcome); - Assert.Equal(RequestAccessFailReason.AccessDenied, result.FailReason); - Assert.Equal(DenyReason.NotWithinIpRange, result.DenyReason); - Assert.Equal(0, fixture.RequestsCreated); - } - - [Fact] - public void Exchange_DoesNotConsumeRequest_RequestRemainsAfterLeaseIssued() - { - var fixture = new AccessRuleEngineFixture(); - fixture.RequestAccess(fixture.Cipher); - - var first = fixture.Exchange(fixture.Cipher); - var second = fixture.Exchange(fixture.Cipher); - - Assert.Equal(ExchangeOutcome.Created, first.Outcome); - // The request is left in place, so a later exchange still finds it rather than reporting RequestNotFound. - Assert.NotEqual(ExchangeFailReason.RequestNotFound, second.FailReason); - } - - // The full request -> approve -> exchange -> check lifecycle. - - [Fact] - public void Lifecycle_RequestApproveExchange_ThenCheckGrants() - { - var fixture = new AccessRuleEngineFixture() - .RequiringApproval(); - - // No lease yet, so a check is denied. - Assert.Equal(DenyReason.NoLease, fixture.Check(fixture.Cipher).Reason); - - // The user requests access. - Assert.Equal(RequestAccessOutcome.Created, fixture.RequestAccess(fixture.Cipher).Outcome); - - // The request cannot be exchanged until it is approved. - Assert.Equal(ExchangeFailReason.NotApproved, fixture.Exchange(fixture.Cipher).FailReason); - - // The request is approved out of band. - fixture.ApproveRequest(); - - // The request can now be exchanged for a lease. - var exchange = fixture.Exchange(fixture.Cipher); - Assert.Equal(ExchangeOutcome.Created, exchange.Outcome); - Assert.NotNull(exchange.Lease); - - // With a valid lease in hand, the check now grants access. - Assert.Equal(AccessOutcome.Granted, fixture.Check(fixture.Cipher).Outcome); - } -} diff --git a/test/Core.Test/Pam/Engine/Conditions/HumanApprovalConditionTests.cs b/test/Core.Test/Pam/Engine/Conditions/HumanApprovalConditionTests.cs deleted file mode 100644 index 73dec97fa606..000000000000 --- a/test/Core.Test/Pam/Engine/Conditions/HumanApprovalConditionTests.cs +++ /dev/null @@ -1,45 +0,0 @@ -using System.Net; -using Bit.Core.Enums; -using Bit.Core.Pam.Engine; -using Bit.Core.Pam.Engine.Conditions; -using Xunit; - -namespace Bit.Core.Test.Pam.Engine.Conditions; - -public sealed class HumanApprovalConditionTests -{ - private readonly HumanApprovalCondition _condition = new(); - - [Fact] - public void Evaluate_RuleRequiresApproval_ReturnsRequiresApproval() - { - var context = ContextFor(new AccessRule { Name = "rule", Duration = TimeSpan.FromHours(1), RequireApproval = true }); - - var decision = _condition.Evaluate(context); - - Assert.Equal(DecisionKind.RequiresApproval, decision.Kind); - } - - [Fact] - public void Evaluate_RuleDoesNotRequireApproval_ReturnsAllow() - { - var context = ContextFor(new AccessRule { Name = "rule", Duration = TimeSpan.FromHours(1) }); - - var decision = _condition.Evaluate(context); - - Assert.Equal(DecisionKind.Allow, decision.Kind); - } - - private static AccessRuleEngineContext ContextFor(AccessRule rule) => new() - { - Rule = rule, - Signals = new AccessRuleSignals - { - Username = "alice", - IpAddress = IPAddress.Loopback, - MultifactorEnabled = true, - UserTime = DateTimeOffset.UnixEpoch, - Device = DeviceType.ChromeBrowser, - }, - }; -} diff --git a/test/Core.Test/Pam/Engine/Conditions/IpRangeConditionTests.cs b/test/Core.Test/Pam/Engine/Conditions/IpRangeConditionTests.cs deleted file mode 100644 index 1c5c3f1df786..000000000000 --- a/test/Core.Test/Pam/Engine/Conditions/IpRangeConditionTests.cs +++ /dev/null @@ -1,58 +0,0 @@ -using System.Net; -using Bit.Core.Enums; -using Bit.Core.Pam.Engine; -using Bit.Core.Pam.Engine.Conditions; -using Xunit; - -namespace Bit.Core.Test.Pam.Engine.Conditions; - -public sealed class IpRangeConditionTests -{ - private readonly IpRangeCondition _condition = new(); - - [Fact] - public void Evaluate_EmptyAllowlist_ReturnsAllow() - { - var decision = _condition.Evaluate(ContextFor([], "10.0.0.5")); - - Assert.Equal(DecisionKind.Allow, decision.Kind); - } - - [Fact] - public void Evaluate_IpWithinRange_ReturnsAllow() - { - var decision = _condition.Evaluate(ContextFor(["10.0.0.0/24"], "10.0.0.5")); - - Assert.Equal(DecisionKind.Allow, decision.Kind); - } - - [Fact] - public void Evaluate_IpOutsideRange_ReturnsDenyNotWithinIpRange() - { - var decision = _condition.Evaluate(ContextFor(["10.0.0.0/24"], "192.168.1.5")); - - Assert.Equal(DecisionKind.Deny, decision.Kind); - Assert.Equal(DenyReason.NotWithinIpRange, decision.Reason); - } - - [Fact] - public void Evaluate_UnparseableEntryIsSkipped_AndALaterMatchAllows() - { - var decision = _condition.Evaluate(ContextFor(["not-a-cidr", "10.0.0.0/24"], "10.0.0.5")); - - Assert.Equal(DecisionKind.Allow, decision.Kind); - } - - private static AccessRuleEngineContext ContextFor(List requiredCidr, string ipAddress) => new() - { - Rule = new AccessRule { Name = "rule", Duration = TimeSpan.FromHours(1), RequiredCidr = requiredCidr }, - Signals = new AccessRuleSignals - { - Username = "alice", - IpAddress = IPAddress.Parse(ipAddress), - MultifactorEnabled = true, - UserTime = DateTimeOffset.UnixEpoch, - Device = DeviceType.ChromeBrowser, - }, - }; -} diff --git a/test/Core.Test/Pam/Engine/Conditions/TimeOfDayConditionTests.cs b/test/Core.Test/Pam/Engine/Conditions/TimeOfDayConditionTests.cs deleted file mode 100644 index 320afbfd5a6a..000000000000 --- a/test/Core.Test/Pam/Engine/Conditions/TimeOfDayConditionTests.cs +++ /dev/null @@ -1,112 +0,0 @@ -using System.Net; -using Bit.Core.Enums; -using Bit.Core.Pam.Engine; -using Bit.Core.Pam.Engine.Conditions; -using Xunit; - -namespace Bit.Core.Test.Pam.Engine.Conditions; - -public sealed class TimeOfDayConditionTests -{ - private readonly TimeOfDayCondition _condition = new(); - - // 2026-01-15 is a Thursday; 2026-01-16 is a Friday. January is EST (UTC-5) in New York. - private static readonly DateTimeOffset ThursdayNoonUtc = new(2026, 1, 15, 12, 0, 0, TimeSpan.Zero); - private static readonly DateTimeOffset FridayNoonUtc = new(2026, 1, 16, 12, 0, 0, TimeSpan.Zero); - - [Fact] - public void Evaluate_NoConfig_ReturnsAllow() - { - var decision = _condition.Evaluate(ContextFor(null, ThursdayNoonUtc)); - - Assert.Equal(DecisionKind.Allow, decision.Kind); - } - - [Fact] - public void Evaluate_WithinWindow_ReturnsAllow() - { - var config = Config("UTC", Window(new TimeOnly(9, 0), new TimeOnly(17, 0), DayOfWeek.Thursday)); - - var decision = _condition.Evaluate(ContextFor(config, ThursdayNoonUtc)); - - Assert.Equal(DecisionKind.Allow, decision.Kind); - } - - [Fact] - public void Evaluate_OutsideWindowTime_ReturnsDeny() - { - var config = Config("UTC", Window(new TimeOnly(9, 0), new TimeOnly(11, 0), DayOfWeek.Thursday)); - - var decision = _condition.Evaluate(ContextFor(config, ThursdayNoonUtc)); - - Assert.Equal(DecisionKind.Deny, decision.Kind); - Assert.Equal(DenyReason.NotWithinTimeWindow, decision.Reason); - } - - [Fact] - public void Evaluate_NonMatchingDay_ReturnsDeny() - { - var config = Config("UTC", Window(new TimeOnly(9, 0), new TimeOnly(17, 0), DayOfWeek.Thursday)); - - var decision = _condition.Evaluate(ContextFor(config, FridayNoonUtc)); - - Assert.Equal(DecisionKind.Deny, decision.Kind); - Assert.Equal(DenyReason.NotWithinTimeWindow, decision.Reason); - } - - [Fact] - public void Evaluate_EmptyDays_MatchesAnyDay() - { - var config = Config("UTC", Window(new TimeOnly(9, 0), new TimeOnly(17, 0))); - - var decision = _condition.Evaluate(ContextFor(config, FridayNoonUtc)); - - Assert.Equal(DecisionKind.Allow, decision.Kind); - } - - [Fact] - public void Evaluate_ConvertsUserTimeIntoConfiguredTimezone() - { - // 20:00 UTC is outside 09:00-17:00 in UTC, but is 15:00 Thursday in New York (EST), inside the window - var config = Config("America/New_York", Window(new TimeOnly(9, 0), new TimeOnly(17, 0), DayOfWeek.Thursday)); - var userTime = new DateTimeOffset(2026, 1, 15, 20, 0, 0, TimeSpan.Zero); - - var decision = _condition.Evaluate(ContextFor(config, userTime)); - - Assert.Equal(DecisionKind.Allow, decision.Kind); - } - - [Fact] - public void Evaluate_InvalidTimezone_ReturnsDeny() - { - var config = Config("Not/AZone", Window(new TimeOnly(0, 0), new TimeOnly(23, 59), DayOfWeek.Thursday)); - - var decision = _condition.Evaluate(ContextFor(config, ThursdayNoonUtc)); - - Assert.Equal(DecisionKind.Deny, decision.Kind); - Assert.Equal(DenyReason.NotWithinTimeWindow, decision.Reason); - } - - private static TimeOfDayConfig Config(string timeZone, params AccessTimeWindow[] windows) - { - return new TimeOfDayConfig { TimeZone = timeZone, Windows = windows }; - } - - private static AccessTimeWindow Window(TimeOnly from, TimeOnly to, params DayOfWeek[] days) - { - return new AccessTimeWindow { Days = days, From = from, To = to }; - } - - private static AccessRuleEngineContext ContextFor(TimeOfDayConfig? config, DateTimeOffset userTime) => new() - { - Rule = new AccessRule { Name = "rule", Duration = TimeSpan.FromHours(1), TimeOfDay = config }, - Signals = new AccessRuleSignals - { - Username = "alice", - IpAddress = IPAddress.Loopback, - MultifactorEnabled = true, - UserTime = userTime, - Device = DeviceType.ChromeBrowser, - }, - }; -} diff --git a/test/Core.Test/Pam/Engine/Fixture/AccessRuleEngineFixture.cs b/test/Core.Test/Pam/Engine/Fixture/AccessRuleEngineFixture.cs deleted file mode 100644 index 11d09e2f5cf0..000000000000 --- a/test/Core.Test/Pam/Engine/Fixture/AccessRuleEngineFixture.cs +++ /dev/null @@ -1,171 +0,0 @@ -using System.Net; -using Bit.Core.Enums; -using Bit.Core.Pam.Engine; -using Bit.Core.Vault.Models.Data; -using Microsoft.Extensions.Time.Testing; - -namespace Bit.Core.Test.Pam.Engine; - -public sealed class AccessRuleEngineFixture -{ - public const string RequestingUser = "alice"; - public const string AnotherUser = "bob"; - public static readonly DateTimeOffset Now = new(2026, 5, 29, 12, 0, 0, TimeSpan.Zero); - - private readonly FakeTimeProvider _time = new(Now); - private readonly FakeAccessRuleResolver _resolver = new(); - private readonly FakeAccessRuleRequestRepository _requests = new(); - private readonly FakeAccessRuleLeaseRepository _leases; - - private IPAddress _ipAddress = IPAddress.Parse("10.0.0.5"); - - private AccessRule? _rule = new() { Name = "test-rule", Duration = TimeSpan.FromHours(1) }; - - public AccessRuleEngineFixture() - { - _leases = new FakeAccessRuleLeaseRepository(_time); - } - - public CipherDetails Cipher { get; } = new() { Id = Guid.Parse("11111111-1111-1111-1111-111111111111") }; - - public AccessRuleSignals Signals => new() - { - Username = RequestingUser, - IpAddress = _ipAddress, - MultifactorEnabled = true, - UserTime = Now, - Device = DeviceType.ChromeBrowser, - }; - - public int LeasesCreated => _leases.CreatedCount; - public int RequestsCreated => _requests.CreatedCount; - - public AccessRuleEngineFixture WithNoRules() - { - _rule = null; - return this; - } - - public AccessRuleEngineFixture RequiringApproval() - { - _rule = EnsureRuleExist(); - _rule = _rule with { RequireApproval = true }; - return this; - } - - public AccessRuleEngineFixture RequiringSingleton() - { - _rule = EnsureRuleExist(); - _rule = _rule with { RequireSingleton = true }; - return this; - } - - public AccessRuleEngineFixture RestrictedToCidr(params string[] cidrs) - { - _rule = EnsureRuleExist(); - _rule.RequiredCidr.AddRange(cidrs); - return this; - } - - public AccessRuleEngineFixture RestrictedToTimeWindow(string timeZone, TimeOnly from, TimeOnly to, params DayOfWeek[] days) - { - _rule = EnsureRuleExist(); - _rule = _rule with - { - TimeOfDay = new TimeOfDayConfig - { - TimeZone = timeZone, - Windows = [new AccessTimeWindow { Days = days, From = from, To = to }], - }, - }; - return this; - } - - public AccessRuleEngineFixture WithActiveLease() - { - return SeedLease(RequestingUser, Now.UtcDateTime.AddHours(1)); - } - - public AccessRuleEngineFixture WithExpiredLease() - { - return SeedLease(RequestingUser, Now.UtcDateTime.AddHours(-1)); - } - - public AccessRuleEngineFixture WithActiveLeaseHeldBy(string username) - { - return SeedLease(username, Now.UtcDateTime.AddHours(1)); - } - - public AccessRuleEngineFixture WithPendingRequest() - { - return SeedRequest(approved: false); - } - - public AccessRuleEngineFixture ApproveRequest() - { - _requests.Approve(Cipher.Id, RequestingUser); - return this; - } - - public AccessRuleEngineFixture WhereLeaseCreationFails() - { - _leases.FailCreate = true; - return this; - } - - public AccessRuleEngineFixture FromIpAddress(string ip) - { - _ipAddress = IPAddress.Parse(ip); - return this; - } - - public AccessRuleEngineResult Check(CipherDetails cipher) - { - return CreateEngine().Check(cipher, Signals); - } - - public RequestAccessResult RequestAccess(CipherDetails cipher) - { - ApplyRule(cipher); - return CreateEngine().RequestAccess(cipher, Signals); - } - - public ExchangeResult Exchange(CipherDetails cipher, string? username = null) - { - ApplyRule(cipher); - return CreateEngine().ExchangeRequestForLease(cipher, username ?? RequestingUser); - } - - private AccessRuleEngine CreateEngine() => new(_time, _resolver, _requests, _leases); - - private void ApplyRule(CipherDetails cipher) - { - if (_rule != null) - { - _resolver.SetRule(cipher.Id, _rule); - } - } - - private AccessRuleEngineFixture SeedLease(string username, DateTime expires) - { - _leases.Seed(new AccessRuleLease { CipherId = Cipher.Id, Username = username, Expires = expires }); - return this; - } - - private AccessRuleEngineFixture SeedRequest(bool approved) - { - _requests.Seed(new AccessRuleRequest - { - CipherId = Cipher.Id, - Username = RequestingUser, - Approved = approved, - Signals = Signals, - }); - return this; - } - - private AccessRule EnsureRuleExist() - { - return _rule ??= new AccessRule { Name = "test-rule", Duration = TimeSpan.FromHours(1) }; - } -} diff --git a/test/Core.Test/Pam/Engine/Fixture/FakeAccessRuleLeaseRepository.cs b/test/Core.Test/Pam/Engine/Fixture/FakeAccessRuleLeaseRepository.cs deleted file mode 100644 index 71f64e7b0d77..000000000000 --- a/test/Core.Test/Pam/Engine/Fixture/FakeAccessRuleLeaseRepository.cs +++ /dev/null @@ -1,53 +0,0 @@ -using Bit.Core.Pam.Engine; - -namespace Bit.Core.Test.Pam.Engine; - -public sealed class FakeAccessRuleLeaseRepository : IAccessRuleLeaseRepository -{ - private readonly TimeProvider _time; - private readonly List _leases = []; - - public FakeAccessRuleLeaseRepository(TimeProvider time) - { - _time = time ?? throw new ArgumentNullException(nameof(time)); - } - - public bool FailCreate { get; set; } - public int CreatedCount { get; private set; } - - public void Seed(AccessRuleLease lease) - { - _leases.Add(lease); - } - - public bool TryCreate(AccessRuleRequest request, TimeSpan duration, out AccessRuleLease? lease) - { - if (FailCreate) - { - lease = null; - return false; - } - - lease = new AccessRuleLease - { - CipherId = request.CipherId, - Username = request.Username, - Expires = _time.GetUtcNow().UtcDateTime.Add(duration), - }; - _leases.Add(lease); - CreatedCount++; - return true; - } - - public bool TryGet(Guid cipherId, string username, out AccessRuleLease? lease) - { - lease = _leases.FirstOrDefault(l => l.CipherId == cipherId && l.Username == username); - return lease is not null; - } - - public bool TryGet(Guid cipherId, out AccessRuleLease? lease) - { - lease = _leases.FirstOrDefault(l => l.CipherId == cipherId); - return lease is not null; - } -} diff --git a/test/Core.Test/Pam/Engine/Fixture/FakeAccessRuleRequestRepository.cs b/test/Core.Test/Pam/Engine/Fixture/FakeAccessRuleRequestRepository.cs deleted file mode 100644 index fddf7c900408..000000000000 --- a/test/Core.Test/Pam/Engine/Fixture/FakeAccessRuleRequestRepository.cs +++ /dev/null @@ -1,52 +0,0 @@ -using Bit.Core.Pam.Engine; - -namespace Bit.Core.Test.Pam.Engine; - -public sealed class FakeAccessRuleRequestRepository : IAccessRuleRequestRepository -{ - private readonly List _requests = []; - - public int CreatedCount { get; private set; } - - public void Seed(AccessRuleRequest request) => _requests.Add(request); - - public AccessRuleRequest Create(Guid cipherId, AccessRuleSignals signals) - { - var request = new AccessRuleRequest - { - CipherId = cipherId, - Username = signals.Username, - Approved = false, - Signals = signals, - }; - _requests.Add(request); - CreatedCount++; - return request; - } - - public void Approve(Guid cipherId, string username) - { - var existing = _requests.FirstOrDefault(r => r.CipherId == cipherId && r.Username == username); - if (existing is null) - { - return; - } - - _requests.Remove(existing); - _requests.Add(new AccessRuleRequest - { - CipherId = existing.CipherId, - Username = existing.Username, - Approved = true, - Signals = existing.Signals, - }); - } - - public bool Delete(AccessRuleRequest request) => _requests.Remove(request); - - public bool TryGet(Guid cipherId, string username, out AccessRuleRequest? request) - { - request = _requests.FirstOrDefault(r => r.CipherId == cipherId && r.Username == username); - return request is not null; - } -} diff --git a/test/Core.Test/Pam/Engine/Fixture/FakeAccessRuleResolver.cs b/test/Core.Test/Pam/Engine/Fixture/FakeAccessRuleResolver.cs deleted file mode 100644 index 6a514e56b44d..000000000000 --- a/test/Core.Test/Pam/Engine/Fixture/FakeAccessRuleResolver.cs +++ /dev/null @@ -1,14 +0,0 @@ -using Bit.Core.Pam.Engine; -using Bit.Core.Vault.Models.Data; - -namespace Bit.Core.Test.Pam.Engine; - -public sealed class FakeAccessRuleResolver : IAccessRuleResolver -{ - private readonly Dictionary _rules = []; - - public void SetRule(Guid cipherId, AccessRule rule) => _rules[cipherId] = rule; - - public AccessRule? Resolve(CipherDetails cipher) - => _rules.TryGetValue(cipher.Id, out var rule) ? rule : null; -} diff --git a/test/Core.Test/Pam/Queries/AccessPreCheckQueryTests.cs b/test/Core.Test/Pam/Queries/AccessPreCheckQueryTests.cs index baf5e859499e..66079b925154 100644 --- a/test/Core.Test/Pam/Queries/AccessPreCheckQueryTests.cs +++ b/test/Core.Test/Pam/Queries/AccessPreCheckQueryTests.cs @@ -1,6 +1,7 @@ using Bit.Core.Exceptions; using Bit.Core.Pam.Enums; using Bit.Core.Pam.Models; +using Bit.Core.Pam.Models.Rules; using Bit.Core.Pam.OrganizationFeatures.Queries; using Bit.Core.Pam.Services; using Bit.Core.Vault.Models.Data; @@ -33,7 +34,7 @@ public async Task PreCheckAsync_HumanApprovalRule_ReturnsHuman( SetupCipher(sutProvider, userId, cipherId); sutProvider.GetDependency() .ResolveAsync(userId, cipherId) - .Returns(new AccessApprovalResolution(orgId, collectionId, RequiresHumanApproval: true)); + .Returns(new AccessApprovalResolution(orgId, collectionId, RequiresHumanApproval: true, new HumanApprovalRule())); var result = await sutProvider.Sut.PreCheckAsync(userId, cipherId); @@ -47,7 +48,7 @@ public async Task PreCheckAsync_AutoApproveRule_ReturnsAutomatic( SetupCipher(sutProvider, userId, cipherId); sutProvider.GetDependency() .ResolveAsync(userId, cipherId) - .Returns(new AccessApprovalResolution(orgId, collectionId, RequiresHumanApproval: false)); + .Returns(new AccessApprovalResolution(orgId, collectionId, RequiresHumanApproval: false, new IpAllowlistRule { Cidrs = ["10.0.0.0/8"] })); var result = await sutProvider.Sut.PreCheckAsync(userId, cipherId); diff --git a/test/Core.Test/Pam/Queries/GetLeasedCipherQueryTests.cs b/test/Core.Test/Pam/Queries/GetLeasedCipherQueryTests.cs index 53257c2f86d8..9aef1cee2b28 100644 --- a/test/Core.Test/Pam/Queries/GetLeasedCipherQueryTests.cs +++ b/test/Core.Test/Pam/Queries/GetLeasedCipherQueryTests.cs @@ -1,6 +1,10 @@ -using Bit.Core.Pam.Entities; +using Bit.Core.Pam.Engine; +using Bit.Core.Pam.Entities; +using Bit.Core.Pam.Models; +using Bit.Core.Pam.Models.Rules; using Bit.Core.Pam.OrganizationFeatures.Queries; using Bit.Core.Pam.Repositories; +using Bit.Core.Pam.Services; using Bit.Core.Vault.Models.Data; using Bit.Core.Vault.Repositories; using Bit.Test.Common.AutoFixture; @@ -73,10 +77,82 @@ await sutProvider.GetDependency().Received(1) .GetActiveByRequesterIdCipherIdAsync(userId, cipherId, _now); } + [Theory, BitAutoData] + public async Task GetLeasedCipherAsync_PolicyDenied_WithholdsDataAndReturnsNull( + Guid userId, Guid cipherId, Lease lease, Guid orgId, Guid collectionId) + { + var sutProvider = Setup(); + sutProvider.GetDependency() + .GetActiveByRequesterIdCipherIdAsync(userId, cipherId, _now) + .Returns(lease); + SetupResolution(sutProvider, userId, cipherId, orgId, collectionId); + SetupPolicyDecision(sutProvider, AccessDecision.Deny(DenyReason.NotWithinIpRange)); + + var result = await sutProvider.Sut.GetLeasedCipherAsync(userId, cipherId); + + Assert.Null(result); + // A denied policy must withhold the data: the cipher is never read. + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .GetByIdAsync(default, default); + } + + [Theory, BitAutoData] + public async Task GetLeasedCipherAsync_PolicyAllowed_ReturnsCipher( + Guid userId, Guid cipherId, Lease lease, Guid orgId, Guid collectionId) + { + var sutProvider = Setup(); + var cipher = new CipherDetails { Id = cipherId }; + sutProvider.GetDependency() + .GetActiveByRequesterIdCipherIdAsync(userId, cipherId, _now) + .Returns(lease); + sutProvider.GetDependency().GetByIdAsync(cipherId, userId).Returns(cipher); + SetupResolution(sutProvider, userId, cipherId, orgId, collectionId); + SetupPolicyDecision(sutProvider, AccessDecision.Allow); + + var result = await sutProvider.Sut.GetLeasedCipherAsync(userId, cipherId); + + Assert.Same(cipher, result); + } + + [Theory, BitAutoData] + public async Task GetLeasedCipherAsync_PolicyRequiresApproval_StillReturnsCipher( + Guid userId, Guid cipherId, Lease lease, Guid orgId, Guid collectionId) + { + var sutProvider = Setup(); + var cipher = new CipherDetails { Id = cipherId }; + sutProvider.GetDependency() + .GetActiveByRequesterIdCipherIdAsync(userId, cipherId, _now) + .Returns(lease); + sutProvider.GetDependency().GetByIdAsync(cipherId, userId).Returns(cipher); + SetupResolution(sutProvider, userId, cipherId, orgId, collectionId); + // Holding the lease is proof approval was already granted, so a deferred-approval outcome must not re-gate. + SetupPolicyDecision(sutProvider, AccessDecision.RequiresApproval); + + var result = await sutProvider.Sut.GetLeasedCipherAsync(userId, cipherId); + + Assert.Same(cipher, result); + } + private static SutProvider Setup() { var sutProvider = new SutProvider().WithFakeTimeProvider().Create(); sutProvider.GetDependency().SetUtcNow(_now); return sutProvider; } + + private static void SetupResolution(SutProvider sutProvider, Guid userId, Guid cipherId, + Guid orgId, Guid collectionId) + { + sutProvider.GetDependency() + .ResolveAsync(userId, cipherId) + .Returns(new AccessApprovalResolution(orgId, collectionId, false, new IpAllowlistRule { Cidrs = ["10.0.0.0/8"] })); + } + + private static void SetupPolicyDecision(SutProvider sutProvider, AccessDecision decision) + { + sutProvider.GetDependency() + .Evaluate(Arg.Any(), Arg.Any()) + .Returns(decision); + } } diff --git a/test/Core.Test/Pam/Services/AccessApprovalResolverTests.cs b/test/Core.Test/Pam/Services/AccessApprovalResolverTests.cs index 716c419904bd..82d72316a7ce 100644 --- a/test/Core.Test/Pam/Services/AccessApprovalResolverTests.cs +++ b/test/Core.Test/Pam/Services/AccessApprovalResolverTests.cs @@ -1,5 +1,6 @@ using Bit.Core.Entities; using Bit.Core.Pam.Entities; +using Bit.Core.Pam.Models.Rules; using Bit.Core.Pam.Repositories; using Bit.Core.Pam.Services; using Bit.Core.Repositories; @@ -47,6 +48,7 @@ public async Task ResolveAsync_HumanApprovalRule_RequiresHumanApproval( Assert.True(result!.RequiresHumanApproval); Assert.Equal(collection.Id, result.CollectionId); Assert.Equal(collection.OrganizationId, result.OrganizationId); + Assert.IsType(result.Rule); } [Theory, BitAutoData] @@ -60,6 +62,8 @@ public async Task ResolveAsync_IpAllowlistRule_DoesNotRequireHumanApproval( Assert.NotNull(result); Assert.False(result!.RequiresHumanApproval); + var ip = Assert.IsType(result.Rule); + Assert.Equal("10.0.0.0/8", Assert.Single(ip.Cidrs)); } [Theory, BitAutoData] @@ -73,6 +77,7 @@ public async Task ResolveAsync_AllOfContainingHumanApproval_RequiresHumanApprova Assert.NotNull(result); Assert.True(result!.RequiresHumanApproval); + Assert.IsType(result.Rule); } [Theory, BitAutoData] @@ -86,6 +91,8 @@ public async Task ResolveAsync_MalformedRule_FailsSafeToHumanApproval( Assert.NotNull(result); Assert.True(result!.RequiresHumanApproval); + // An unparseable rule fails safe to human approval rather than surfacing a rule the engine cannot evaluate. + Assert.IsType(result.Rule); } private static void SetupReachableCollections( From 27cfb5de01834516b0d0ca49d596909889f3980b Mon Sep 17 00:00:00 2001 From: Hinton Date: Fri, 5 Jun 2026 13:52:58 +0200 Subject: [PATCH 13/54] Update pre-check to validate if we have active lease --- .../Response/AccessPreCheckResponseModel.cs | 5 +- src/Core/Pam/Models/AccessPreCheckResult.cs | 7 +- .../Commands/DecideLeaseRequestCommand.cs | 28 +++++- .../Queries/AccessPreCheckQuery.cs | 20 ++++- .../Repositories/ILeaseRequestRepository.cs | 8 +- .../Repositories/LeaseRequestRepository.cs | 3 +- .../LeaseRequest_ResolveWithDecision.sql | 18 ++++ .../DecideLeaseRequestCommandTests.cs | 15 +++- .../Pam/Queries/AccessPreCheckQueryTests.cs | 18 ++++ .../LeaseRequestRepositoryTests.cs | 87 +++++++++++++++++-- .../2026-06-05_01_CreateLeaseOnApproval.sql | 60 +++++++++++++ 11 files changed, 250 insertions(+), 19 deletions(-) create mode 100644 util/Migrator/DbScripts/2026-06-05_01_CreateLeaseOnApproval.sql diff --git a/src/Api/Pam/Models/Response/AccessPreCheckResponseModel.cs b/src/Api/Pam/Models/Response/AccessPreCheckResponseModel.cs index 24955daa0a7b..5a8c69c1cce4 100644 --- a/src/Api/Pam/Models/Response/AccessPreCheckResponseModel.cs +++ b/src/Api/Pam/Models/Response/AccessPreCheckResponseModel.cs @@ -10,12 +10,15 @@ public AccessPreCheckResponseModel(Guid cipherId, AccessPreCheckResult result) : base("accessPreCheck") { CipherId = cipherId; - Outcome = result.Outcome == AccessApprovalOutcome.Human ? "human" : "automatic"; + Outcome = result.HasActiveLease + ? "active" + : result.Outcome == AccessApprovalOutcome.Human ? "human" : "automatic"; } public Guid CipherId { get; } /// + /// "active" when the caller already holds an active lease (reveal the credential, no request needed), /// "automatic" when a request would be approved immediately, "human" when it needs an approver. /// public string Outcome { get; } diff --git a/src/Core/Pam/Models/AccessPreCheckResult.cs b/src/Core/Pam/Models/AccessPreCheckResult.cs index 90d6a43d1e75..a137bd824147 100644 --- a/src/Core/Pam/Models/AccessPreCheckResult.cs +++ b/src/Core/Pam/Models/AccessPreCheckResult.cs @@ -3,7 +3,8 @@ namespace Bit.Core.Pam.Models; /// -/// The result of a pre-check: whether requesting access to a cipher would be approved automatically or require human -/// approval. +/// The result of a pre-check. When is true the caller already holds an active lease for +/// the cipher, so the client should reveal the credential rather than prompt for a new request; otherwise +/// describes whether a fresh request would be approved automatically or require human approval. /// -public sealed record AccessPreCheckResult(AccessApprovalOutcome Outcome); +public sealed record AccessPreCheckResult(AccessApprovalOutcome Outcome, bool HasActiveLease = false); diff --git a/src/Core/Pam/OrganizationFeatures/Commands/DecideLeaseRequestCommand.cs b/src/Core/Pam/OrganizationFeatures/Commands/DecideLeaseRequestCommand.cs index cb20d9494334..52ed457f367f 100644 --- a/src/Core/Pam/OrganizationFeatures/Commands/DecideLeaseRequestCommand.cs +++ b/src/Core/Pam/OrganizationFeatures/Commands/DecideLeaseRequestCommand.cs @@ -50,9 +50,8 @@ public async Task DecideAsync(Guid userId, Guid reques } var now = _timeProvider.GetUtcNow().UtcDateTime; - var status = submission.Verdict == LeaseDecisionVerdict.Approve - ? LeaseRequestStatus.Approved - : LeaseRequestStatus.Denied; + var approved = submission.Verdict == LeaseDecisionVerdict.Approve; + var status = approved ? LeaseRequestStatus.Approved : LeaseRequestStatus.Denied; var decision = new LeaseDecision { @@ -65,7 +64,28 @@ public async Task DecideAsync(Guid userId, Guid reques }; decision.SetNewId(); - await _leaseRequestRepository.ResolveWithDecisionAsync(request, decision, status, now); + // Approval mints the active lease that actually authorizes access, spanning the request's approved window. + // Without it the requester would be Approved but hold no lease, so pre-check and the cipher read would both + // deny them. A denial creates no lease. + Lease? lease = null; + if (approved) + { + lease = new Lease + { + LeaseRequestId = request.Id, + OrganizationId = request.OrganizationId, + CollectionId = request.CollectionId, + CipherId = request.CipherId, + RequesterId = request.RequesterId, + Status = LeaseStatus.Active, + NotBefore = request.NotBefore, + NotAfter = request.NotAfter, + CreationDate = now, + }; + lease.SetNewId(); + } + + await _leaseRequestRepository.ResolveWithDecisionAsync(request, decision, status, lease, now); // The request just left the pending queue; tell every approver of this collection to re-fetch. await _approverInboxNotifier.NotifyCollectionApproversAsync(request.CollectionId); diff --git a/src/Core/Pam/OrganizationFeatures/Queries/AccessPreCheckQuery.cs b/src/Core/Pam/OrganizationFeatures/Queries/AccessPreCheckQuery.cs index f8dff511e0a0..72425c95d33d 100644 --- a/src/Core/Pam/OrganizationFeatures/Queries/AccessPreCheckQuery.cs +++ b/src/Core/Pam/OrganizationFeatures/Queries/AccessPreCheckQuery.cs @@ -2,6 +2,7 @@ using Bit.Core.Pam.Enums; using Bit.Core.Pam.Models; using Bit.Core.Pam.OrganizationFeatures.Queries.Interfaces; +using Bit.Core.Pam.Repositories; using Bit.Core.Pam.Services; using Bit.Core.Vault.Repositories; @@ -11,11 +12,19 @@ public class AccessPreCheckQuery : IAccessPreCheckQuery { private readonly ICipherRepository _cipherRepository; private readonly IAccessApprovalResolver _resolver; + private readonly ILeaseRepository _leaseRepository; + private readonly TimeProvider _timeProvider; - public AccessPreCheckQuery(ICipherRepository cipherRepository, IAccessApprovalResolver resolver) + public AccessPreCheckQuery( + ICipherRepository cipherRepository, + IAccessApprovalResolver resolver, + ILeaseRepository leaseRepository, + TimeProvider timeProvider) { _cipherRepository = cipherRepository; _resolver = resolver; + _leaseRepository = leaseRepository; + _timeProvider = timeProvider; } public async Task PreCheckAsync(Guid userId, Guid cipherId) @@ -27,6 +36,15 @@ public async Task PreCheckAsync(Guid userId, Guid cipherId throw new NotFoundException(); } + var now = _timeProvider.GetUtcNow().UtcDateTime; + + // A caller who already holds an active lease should be sent straight to the credential, not prompted to make + // a request that RequestAccessCommand would reject. This mirrors the active-lease guard there. + if (await _leaseRepository.GetActiveByRequesterIdCipherIdAsync(userId, cipherId, now) is not null) + { + return new AccessPreCheckResult(AccessApprovalOutcome.Automatic, HasActiveLease: true); + } + var resolution = await _resolver.ResolveAsync(userId, cipherId); var outcome = resolution?.RequiresHumanApproval == true ? AccessApprovalOutcome.Human diff --git a/src/Core/Pam/Repositories/ILeaseRequestRepository.cs b/src/Core/Pam/Repositories/ILeaseRequestRepository.cs index dd79721997c8..3802e61bde5c 100644 --- a/src/Core/Pam/Repositories/ILeaseRequestRepository.cs +++ b/src/Core/Pam/Repositories/ILeaseRequestRepository.cs @@ -29,8 +29,10 @@ public interface ILeaseRequestRepository Task> GetManyInboxHistoryByCollectionIdsAsync(IEnumerable collectionIds, DateTime since); /// - /// Atomically transitions a pending request to (setting its resolved date) and records - /// the approver's human . Both entities must already have their ids assigned. + /// Atomically transitions a pending request to (setting its resolved date), records the + /// approver's human , and — on approval — creates the active + /// that authorizes access, spanning the request's approved window. Pass as null when + /// denying. Every supplied entity must already have its id assigned. /// - Task ResolveWithDecisionAsync(LeaseRequest request, LeaseDecision decision, LeaseRequestStatus status, DateTime now); + Task ResolveWithDecisionAsync(LeaseRequest request, LeaseDecision decision, LeaseRequestStatus status, Lease? lease, DateTime now); } diff --git a/src/Infrastructure.Dapper/Pam/Repositories/LeaseRequestRepository.cs b/src/Infrastructure.Dapper/Pam/Repositories/LeaseRequestRepository.cs index 1fc7ecd7e6c3..2623afa64314 100644 --- a/src/Infrastructure.Dapper/Pam/Repositories/LeaseRequestRepository.cs +++ b/src/Infrastructure.Dapper/Pam/Repositories/LeaseRequestRepository.cs @@ -67,7 +67,7 @@ public async Task> GetManyInboxHistoryByCo return results.ToList(); } - public async Task ResolveWithDecisionAsync(LeaseRequest request, LeaseDecision decision, LeaseRequestStatus status, DateTime now) + public async Task ResolveWithDecisionAsync(LeaseRequest request, LeaseDecision decision, LeaseRequestStatus status, Lease? lease, DateTime now) { await using var connection = new SqlConnection(ConnectionString); await connection.ExecuteAsync( @@ -80,6 +80,7 @@ await connection.ExecuteAsync( ApproverId = decision.ApproverId, Decision = decision.Decision, decision.Comment, + LeaseId = lease?.Id, Now = now, }, commandType: CommandType.StoredProcedure); diff --git a/src/Sql/dbo/Pam/Stored Procedures/LeaseRequest_ResolveWithDecision.sql b/src/Sql/dbo/Pam/Stored Procedures/LeaseRequest_ResolveWithDecision.sql index 01cda2d5d9ca..0e378d4734a2 100644 --- a/src/Sql/dbo/Pam/Stored Procedures/LeaseRequest_ResolveWithDecision.sql +++ b/src/Sql/dbo/Pam/Stored Procedures/LeaseRequest_ResolveWithDecision.sql @@ -5,6 +5,7 @@ CREATE PROCEDURE [dbo].[LeaseRequest_ResolveWithDecision] @ApproverId UNIQUEIDENTIFIER, @Decision TINYINT, @Comment NVARCHAR(MAX) = NULL, + @LeaseId UNIQUEIDENTIFIER = NULL, @Now DATETIME2(7) AS BEGIN @@ -31,5 +32,22 @@ BEGIN @Decision, @Comment, NULL, @Now ) + -- An approval mints the active lease that authorizes access, mirroring [Lease_CreateAutoApproved] on the automatic + -- path. @LeaseId is supplied only when approving; the lease window is the request's approved window, so the lease + -- is found by [Lease_ReadActiveByRequesterIdCipherId] once @Now falls inside it. + IF @LeaseId IS NOT NULL + BEGIN + INSERT INTO [dbo].[Lease] + ( + [Id], [LeaseRequestId], [OrganizationId], [CollectionId], [CipherId], [RequesterId], + [Status], [NotBefore], [NotAfter], [RevokedDate], [RevokedBy], [CreationDate] + ) + SELECT + @LeaseId, [Id], [OrganizationId], [CollectionId], [CipherId], [RequesterId], + 0 /* Active */, [NotBefore], [NotAfter], NULL, NULL, @Now + FROM [dbo].[LeaseRequest] + WHERE [Id] = @LeaseRequestId + END + COMMIT TRANSACTION LeaseRequest_ResolveWithDecision END diff --git a/test/Core.Test/Pam/Commands/DecideLeaseRequestCommandTests.cs b/test/Core.Test/Pam/Commands/DecideLeaseRequestCommandTests.cs index 79bd04045445..33c5ad57cb70 100644 --- a/test/Core.Test/Pam/Commands/DecideLeaseRequestCommandTests.cs +++ b/test/Core.Test/Pam/Commands/DecideLeaseRequestCommandTests.cs @@ -64,7 +64,7 @@ public async Task DecideAsync_SelfApproval_ThrowsBadRequest(Guid userId, LeaseRe () => sutProvider.Sut.DecideAsync(userId, request.Id, Approve())); Assert.Contains("your own request", ex.Message); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() - .ResolveWithDecisionAsync(default!, default!, default, default); + .ResolveWithDecisionAsync(default!, default!, default, default, default); } [Theory, BitAutoData] @@ -88,6 +88,17 @@ await sutProvider.GetDependency().Received(1).ResolveWi d.Decision == LeaseDecisionVerdict.Approve && d.Comment == "looks good"), LeaseRequestStatus.Approved, + // Approval mints an active lease spanning the request's approved window. + Arg.Is(l => + l.LeaseRequestId == request.Id && + l.OrganizationId == request.OrganizationId && + l.CollectionId == request.CollectionId && + l.CipherId == request.CipherId && + l.RequesterId == request.RequesterId && + l.Status == LeaseStatus.Active && + l.NotBefore == request.NotBefore && + l.NotAfter == request.NotAfter && + l.Id != default), _now); await sutProvider.GetDependency().Received(1) .NotifyCollectionApproversAsync(request.CollectionId); @@ -107,6 +118,8 @@ await sutProvider.GetDependency().Received(1).ResolveWi request, Arg.Is(d => d.Decision == LeaseDecisionVerdict.Deny), LeaseRequestStatus.Denied, + // A denial creates no lease. + null, _now); } diff --git a/test/Core.Test/Pam/Queries/AccessPreCheckQueryTests.cs b/test/Core.Test/Pam/Queries/AccessPreCheckQueryTests.cs index 66079b925154..291b78d7e7ce 100644 --- a/test/Core.Test/Pam/Queries/AccessPreCheckQueryTests.cs +++ b/test/Core.Test/Pam/Queries/AccessPreCheckQueryTests.cs @@ -1,8 +1,10 @@ using Bit.Core.Exceptions; +using Bit.Core.Pam.Entities; using Bit.Core.Pam.Enums; using Bit.Core.Pam.Models; using Bit.Core.Pam.Models.Rules; using Bit.Core.Pam.OrganizationFeatures.Queries; +using Bit.Core.Pam.Repositories; using Bit.Core.Pam.Services; using Bit.Core.Vault.Models.Data; using Bit.Core.Vault.Repositories; @@ -55,6 +57,22 @@ public async Task PreCheckAsync_AutoApproveRule_ReturnsAutomatic( Assert.Equal(AccessApprovalOutcome.Automatic, result.Outcome); } + [Theory, BitAutoData] + public async Task PreCheckAsync_ExistingActiveLease_ReturnsHasActiveLease( + SutProvider sutProvider, Guid userId, Guid cipherId, Lease activeLease) + { + SetupCipher(sutProvider, userId, cipherId); + sutProvider.GetDependency() + .GetActiveByRequesterIdCipherIdAsync(userId, cipherId, Arg.Any()) + .Returns(activeLease); + + var result = await sutProvider.Sut.PreCheckAsync(userId, cipherId); + + Assert.True(result.HasActiveLease); + // The approval path is irrelevant once a lease is held, so the rule resolver is never consulted. + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().ResolveAsync(default, default); + } + [Theory, BitAutoData] public async Task PreCheckAsync_NotLeasingGated_ReturnsAutomatic( SutProvider sutProvider, Guid userId, Guid cipherId) diff --git a/test/Infrastructure.IntegrationTest/Pam/Repositories/LeaseRequestRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Pam/Repositories/LeaseRequestRepositoryTests.cs index 1ce108a38618..626a7580ddc8 100644 --- a/test/Infrastructure.IntegrationTest/Pam/Repositories/LeaseRequestRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/Pam/Repositories/LeaseRequestRepositoryTests.cs @@ -81,18 +81,30 @@ await leaseRequestRepository.CreateAsync(BuildRequest( } [DatabaseTheory, DatabaseData] - public async Task ResolveWithDecisionAsync_ResolvesRequestAndRecordsHumanDecision( + public async Task ResolveWithDecisionAsync_Approve_ResolvesRequestRecordsDecisionAndMintsActiveLease( IOrganizationRepository organizationRepository, ICollectionRepository collectionRepository, - ILeaseRequestRepository leaseRequestRepository) + ILeaseRequestRepository leaseRequestRepository, + ILeaseRepository leaseRepository) { var organization = await organizationRepository.CreateTestOrganizationAsync(); var collection = await collectionRepository.CreateTestCollectionAsync(organization); var now = DateTime.UtcNow; var approverId = Guid.NewGuid(); - var request = await leaseRequestRepository.CreateAsync(BuildRequest( - organization.Id, collection.Id, Guid.NewGuid(), LeaseRequestStatus.Pending, now)); + // Window straddles now so the minted lease is immediately active and findable by the requester. + var request = await leaseRequestRepository.CreateAsync(new LeaseRequest + { + OrganizationId = organization.Id, + CollectionId = collection.Id, + CipherId = Guid.NewGuid(), + RequesterId = Guid.NewGuid(), + NotBefore = now.AddHours(-1), + NotAfter = now.AddHours(1), + Reason = "audit", + Status = LeaseRequestStatus.Pending, + CreationDate = now, + }); var decision = new LeaseDecision { @@ -105,7 +117,21 @@ public async Task ResolveWithDecisionAsync_ResolvesRequestAndRecordsHumanDecisio CreationDate = now, }; - await leaseRequestRepository.ResolveWithDecisionAsync(request, decision, LeaseRequestStatus.Approved, now); + var lease = new Lease + { + Id = CoreHelpers.GenerateComb(), + LeaseRequestId = request.Id, + OrganizationId = request.OrganizationId, + CollectionId = request.CollectionId, + CipherId = request.CipherId, + RequesterId = request.RequesterId, + Status = LeaseStatus.Active, + NotBefore = request.NotBefore, + NotAfter = request.NotAfter, + CreationDate = now, + }; + + await leaseRequestRepository.ResolveWithDecisionAsync(request, decision, LeaseRequestStatus.Approved, lease, now); var persisted = await leaseRequestRepository.GetByIdAsync(request.Id); Assert.NotNull(persisted); @@ -118,6 +144,57 @@ public async Task ResolveWithDecisionAsync_ResolvesRequestAndRecordsHumanDecisio var row = Assert.Single(history); Assert.Equal(approverId, row.ResolverId); Assert.Equal("approved for audit", row.ResolverComment); + + // The approval minted an active lease spanning the request's window, so the requester now holds access. + var active = await leaseRepository.GetActiveByRequesterIdCipherIdAsync(request.RequesterId, request.CipherId, now); + Assert.NotNull(active); + Assert.Equal(lease.Id, active!.Id); + Assert.Equal(LeaseStatus.Active, active.Status); + Assert.Equal(request.Id, active.LeaseRequestId); + } + + [DatabaseTheory, DatabaseData] + public async Task ResolveWithDecisionAsync_Deny_ResolvesWithoutLease( + IOrganizationRepository organizationRepository, + ICollectionRepository collectionRepository, + ILeaseRequestRepository leaseRequestRepository, + ILeaseRepository leaseRepository) + { + var organization = await organizationRepository.CreateTestOrganizationAsync(); + var collection = await collectionRepository.CreateTestCollectionAsync(organization); + var now = DateTime.UtcNow; + + var request = await leaseRequestRepository.CreateAsync(new LeaseRequest + { + OrganizationId = organization.Id, + CollectionId = collection.Id, + CipherId = Guid.NewGuid(), + RequesterId = Guid.NewGuid(), + NotBefore = now.AddHours(-1), + NotAfter = now.AddHours(1), + Reason = "audit", + Status = LeaseRequestStatus.Pending, + CreationDate = now, + }); + + var decision = new LeaseDecision + { + Id = CoreHelpers.GenerateComb(), + LeaseRequestId = request.Id, + DeciderKind = LeaseDecisionKind.Human, + ApproverId = Guid.NewGuid(), + Decision = LeaseDecisionVerdict.Deny, + CreationDate = now, + }; + + await leaseRequestRepository.ResolveWithDecisionAsync(request, decision, LeaseRequestStatus.Denied, null, now); + + var persisted = await leaseRequestRepository.GetByIdAsync(request.Id); + Assert.Equal(LeaseRequestStatus.Denied, persisted!.Status); + + // A denial grants nothing: no active lease exists for the requester. + var active = await leaseRepository.GetActiveByRequesterIdCipherIdAsync(request.RequesterId, request.CipherId, now); + Assert.Null(active); } private static LeaseRequest BuildRequest( diff --git a/util/Migrator/DbScripts/2026-06-05_01_CreateLeaseOnApproval.sql b/util/Migrator/DbScripts/2026-06-05_01_CreateLeaseOnApproval.sql new file mode 100644 index 000000000000..b25130c14a7e --- /dev/null +++ b/util/Migrator/DbScripts/2026-06-05_01_CreateLeaseOnApproval.sql @@ -0,0 +1,60 @@ +-- PAM Credential Leasing: when an approver approves a human request, mint the active lease that authorizes access. +-- Previously [LeaseRequest_ResolveWithDecision] only flipped the request to Approved and recorded the decision, so the +-- human path never produced a [Lease] and the approved requester could not read the credential. This adds an optional +-- @LeaseId; when supplied (approvals only), the lease is created in the same transaction with the request's approved +-- window, mirroring [Lease_CreateAutoApproved] on the automatic path. + +CREATE OR ALTER PROCEDURE [dbo].[LeaseRequest_ResolveWithDecision] + @LeaseRequestId UNIQUEIDENTIFIER, + @Status TINYINT, + @LeaseDecisionId UNIQUEIDENTIFIER, + @ApproverId UNIQUEIDENTIFIER, + @Decision TINYINT, + @Comment NVARCHAR(MAX) = NULL, + @LeaseId UNIQUEIDENTIFIER = NULL, + @Now DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + -- Atomically resolve a pending request and record the human approver's decision. The caller has already verified + -- (and the application enforces) that the request is still Pending; the WHERE guard keeps the write idempotent + -- under a race so a second approver can't move an already-resolved request. + BEGIN TRANSACTION LeaseRequest_ResolveWithDecision + + UPDATE [dbo].[LeaseRequest] + SET [Status] = @Status, + [ResolvedDate] = @Now + WHERE [Id] = @LeaseRequestId AND [Status] = 0 -- Pending + + INSERT INTO [dbo].[LeaseDecision] + ( + [Id], [LeaseRequestId], [DeciderKind], [ApproverId], [PolicyKind], + [Decision], [Comment], [EvaluationContext], [CreationDate] + ) + VALUES + ( + @LeaseDecisionId, @LeaseRequestId, 1 /* Human */, @ApproverId, NULL, + @Decision, @Comment, NULL, @Now + ) + + -- An approval mints the active lease that authorizes access, mirroring [Lease_CreateAutoApproved] on the automatic + -- path. @LeaseId is supplied only when approving; the lease window is the request's approved window, so the lease + -- is found by [Lease_ReadActiveByRequesterIdCipherId] once @Now falls inside it. + IF @LeaseId IS NOT NULL + BEGIN + INSERT INTO [dbo].[Lease] + ( + [Id], [LeaseRequestId], [OrganizationId], [CollectionId], [CipherId], [RequesterId], + [Status], [NotBefore], [NotAfter], [RevokedDate], [RevokedBy], [CreationDate] + ) + SELECT + @LeaseId, [Id], [OrganizationId], [CollectionId], [CipherId], [RequesterId], + 0 /* Active */, [NotBefore], [NotAfter], NULL, NULL, @Now + FROM [dbo].[LeaseRequest] + WHERE [Id] = @LeaseRequestId + END + + COMMIT TRANSACTION LeaseRequest_ResolveWithDecision +END +GO From 2c2a297b69f92dd66e3fbb015c989cc45193d9d6 Mon Sep 17 00:00:00 2001 From: Hinton Date: Mon, 8 Jun 2026 09:50:45 +0200 Subject: [PATCH 14/54] Add endpointns for pending and active leases --- .../Pam/Controllers/CipherLeaseController.cs | 13 ++ .../Controllers/MemberLeasingController.cs | 47 +++++++ .../Response/CipherLeaseStateResponseModel.cs | 37 +++++ .../Response/MemberLeaseResponseModel.cs | 59 ++++++++ ...OrganizationServiceCollectionExtensions.cs | 3 + src/Core/Pam/Models/CipherLeaseStateResult.cs | 10 ++ src/Core/Pam/Models/LeaseStatusName.cs | 22 +++ .../Queries/GetCipherLeaseStateQuery.cs | 73 ++++++++++ .../Interfaces/IGetCipherLeaseStateQuery.cs | 13 ++ .../Interfaces/IListMyAccessRequestsQuery.cs | 12 ++ .../Interfaces/IListMyActiveLeasesQuery.cs | 12 ++ .../Queries/ListMyAccessRequestsQuery.cs | 18 +++ .../Queries/ListMyActiveLeasesQuery.cs | 20 +++ src/Core/Pam/Repositories/ILeaseRepository.cs | 6 + .../Repositories/ILeaseRequestRepository.cs | 6 + .../Pam/Repositories/LeaseRepository.cs | 11 ++ .../Repositories/LeaseRequestRepository.cs | 11 ++ .../LeaseRequest_ReadManyByRequesterId.sql | 41 ++++++ .../Lease_ReadManyActiveByRequesterId.sql | 19 +++ .../Controllers/CipherLeaseControllerTests.cs | 20 +++ .../MemberLeasingControllerTests.cs | 67 ++++++++++ .../Models/MemberLeaseResponseModelTests.cs | 33 +++++ .../Pam/Models/LeaseStatusNameTests.cs | 17 +++ .../Queries/GetCipherLeaseStateQueryTests.cs | 126 ++++++++++++++++++ .../Queries/ListMyAccessRequestsQueryTests.cs | 40 ++++++ .../Queries/ListMyActiveLeasesQueryTests.cs | 40 ++++++ .../Pam/Repositories/LeaseRepositoryTests.cs | 30 +++++ .../LeaseRequestRepositoryTests.cs | 28 ++++ ...2026-06-05_02_AddMemberLeaseReadSprocs.sql | 67 ++++++++++ 29 files changed, 901 insertions(+) create mode 100644 src/Api/Pam/Controllers/MemberLeasingController.cs create mode 100644 src/Api/Pam/Models/Response/CipherLeaseStateResponseModel.cs create mode 100644 src/Api/Pam/Models/Response/MemberLeaseResponseModel.cs create mode 100644 src/Core/Pam/Models/CipherLeaseStateResult.cs create mode 100644 src/Core/Pam/Models/LeaseStatusName.cs create mode 100644 src/Core/Pam/OrganizationFeatures/Queries/GetCipherLeaseStateQuery.cs create mode 100644 src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IGetCipherLeaseStateQuery.cs create mode 100644 src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IListMyAccessRequestsQuery.cs create mode 100644 src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IListMyActiveLeasesQuery.cs create mode 100644 src/Core/Pam/OrganizationFeatures/Queries/ListMyAccessRequestsQuery.cs create mode 100644 src/Core/Pam/OrganizationFeatures/Queries/ListMyActiveLeasesQuery.cs create mode 100644 src/Sql/dbo/Pam/Stored Procedures/LeaseRequest_ReadManyByRequesterId.sql create mode 100644 src/Sql/dbo/Pam/Stored Procedures/Lease_ReadManyActiveByRequesterId.sql create mode 100644 test/Api.Test/Pam/Controllers/MemberLeasingControllerTests.cs create mode 100644 test/Api.Test/Pam/Models/MemberLeaseResponseModelTests.cs create mode 100644 test/Core.Test/Pam/Models/LeaseStatusNameTests.cs create mode 100644 test/Core.Test/Pam/Queries/GetCipherLeaseStateQueryTests.cs create mode 100644 test/Core.Test/Pam/Queries/ListMyAccessRequestsQueryTests.cs create mode 100644 test/Core.Test/Pam/Queries/ListMyActiveLeasesQueryTests.cs create mode 100644 util/Migrator/DbScripts/2026-06-05_02_AddMemberLeaseReadSprocs.sql diff --git a/src/Api/Pam/Controllers/CipherLeaseController.cs b/src/Api/Pam/Controllers/CipherLeaseController.cs index 429dcaff94bd..2aea8ab90e1a 100644 --- a/src/Api/Pam/Controllers/CipherLeaseController.cs +++ b/src/Api/Pam/Controllers/CipherLeaseController.cs @@ -20,6 +20,7 @@ namespace Bit.Api.Pam.Controllers; public class CipherLeaseController( IUserService userService, IAccessPreCheckQuery preCheckQuery, + IGetCipherLeaseStateQuery cipherLeaseStateQuery, IRequestAccessCommand requestAccessCommand, IGetLeasedCipherQuery getLeasedCipherQuery, IApplicationCacheService applicationCacheService, @@ -39,6 +40,18 @@ public async Task PreCheck(Guid id) return new AccessPreCheckResponseModel(id, result); } + /// + /// Returns a single snapshot of the caller's lease state for this cipher — their active lease and pending request, + /// if any — powering the cipher-view banner and the vault-row badge. Side-effect free. + /// + [HttpGet("state")] + public async Task State(Guid id) + { + var userId = userService.GetProperUserId(User)!.Value; + var result = await cipherLeaseStateQuery.GetStateAsync(userId, id); + return new CipherLeaseStateResponseModel(result); + } + /// /// Submits a request to lease this cipher. The automatic path issues an active lease immediately; the human path /// creates a pending request for an approver. diff --git a/src/Api/Pam/Controllers/MemberLeasingController.cs b/src/Api/Pam/Controllers/MemberLeasingController.cs new file mode 100644 index 000000000000..f71a6d756bec --- /dev/null +++ b/src/Api/Pam/Controllers/MemberLeasingController.cs @@ -0,0 +1,47 @@ +using Bit.Api.Pam.Models.Response; +using Bit.Core; +using Bit.Core.Pam.OrganizationFeatures.Queries.Interfaces; +using Bit.Core.Services; +using Bit.Core.Utilities; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Bit.Api.Pam.Controllers; + +/// +/// Caller-scoped leasing reads: a user's own access requests and active leases, spanning every organization they +/// belong to. Distinct from the approver-facing surface on . Both share the +/// leasing route prefix; the templates don't overlap. +/// +[Route("leasing")] +[Authorize("Application")] +[RequireFeature(FeatureFlagKeys.Pam)] +public class MemberLeasingController( + IUserService userService, + IListMyAccessRequestsQuery listMyAccessRequestsQuery, + IListMyActiveLeasesQuery listMyActiveLeasesQuery) + : Controller +{ + /// + /// Returns the caller's own access requests across all their organizations, regardless of status, as a plain + /// array. The client re-sorts and splits into pending/recent. + /// + [HttpGet("requests/mine")] + public async Task> GetMyRequests() + { + var userId = userService.GetProperUserId(User)!.Value; + var requests = await listMyAccessRequestsQuery.GetMineAsync(userId); + return requests.Select(r => new InboxAccessRequestResponseModel(r)); + } + + /// + /// Returns the caller's currently-active leases across all their organizations as a plain array. + /// + [HttpGet("leases/mine/active")] + public async Task> GetMyActiveLeases() + { + var userId = userService.GetProperUserId(User)!.Value; + var leases = await listMyActiveLeasesQuery.GetMineActiveAsync(userId); + return leases.Select(l => new MemberLeaseResponseModel(l)); + } +} diff --git a/src/Api/Pam/Models/Response/CipherLeaseStateResponseModel.cs b/src/Api/Pam/Models/Response/CipherLeaseStateResponseModel.cs new file mode 100644 index 000000000000..357688a8bdf6 --- /dev/null +++ b/src/Api/Pam/Models/Response/CipherLeaseStateResponseModel.cs @@ -0,0 +1,37 @@ +using Bit.Core.Models.Api; +using Bit.Core.Pam.Models; + +namespace Bit.Api.Pam.Models.Response; + +/// +/// A single-snapshot read of the caller's lease state for one cipher, powering the cipher-view banner and the +/// vault-row badge. is always null in v0: approval mints an active +/// lease immediately, so there is no approved-but-unredeemed ticket to redeem. The active lease and pending request +/// carry the real state. +/// +public class CipherLeaseStateResponseModel : ResponseModel +{ + public CipherLeaseStateResponseModel(CipherLeaseStateResult result) + : base("cipherLeaseState") + { + ArgumentNullException.ThrowIfNull(result); + + CipherId = result.CipherId; + Lease = new CipherLeaseSnapshot + { + ActiveLease = result.ActiveLease is null ? null : new MemberLeaseResponseModel(result.ActiveLease), + PendingRequest = result.PendingRequest is null ? null : new InboxAccessRequestResponseModel(result.PendingRequest), + ApprovedTicket = null, + }; + } + + public Guid CipherId { get; } + public CipherLeaseSnapshot Lease { get; } + + public class CipherLeaseSnapshot + { + public MemberLeaseResponseModel? ActiveLease { get; init; } + public InboxAccessRequestResponseModel? PendingRequest { get; init; } + public InboxAccessRequestResponseModel? ApprovedTicket { get; init; } + } +} diff --git a/src/Api/Pam/Models/Response/MemberLeaseResponseModel.cs b/src/Api/Pam/Models/Response/MemberLeaseResponseModel.cs new file mode 100644 index 000000000000..5a031691ef25 --- /dev/null +++ b/src/Api/Pam/Models/Response/MemberLeaseResponseModel.cs @@ -0,0 +1,59 @@ +using Bit.Core.Models.Api; +using Bit.Core.Pam.Entities; +using Bit.Core.Pam.Models; + +namespace Bit.Api.Pam.Models.Response; + +/// +/// A lease as its grantee sees it. Matches the client's LeaseResponse shape — a richer view than the minimal +/// returned by the request flow, with the originating request, grantee, string status +/// vocabulary, and revocation fields. Powers the caller-scoped "my active leases" surface and the cipher-lease-state +/// snapshot. Fields without a backing store in v1 (, ) are null. +/// +public class MemberLeaseResponseModel : ResponseModel +{ + public MemberLeaseResponseModel(Lease lease) + : base("lease") + { + ArgumentNullException.ThrowIfNull(lease); + + Id = lease.Id; + RequestId = lease.LeaseRequestId; + CipherId = lease.CipherId; + CollectionId = lease.CollectionId; + OrganizationId = lease.OrganizationId; + GranteeUserId = lease.RequesterId; + Status = LeaseStatusName.From(lease.Status); + NotBefore = lease.NotBefore; + NotAfter = lease.NotAfter; + RevokedAt = lease.RevokedDate; + RevokedByUserId = lease.RevokedBy; + } + + public Guid Id { get; } + + /// The request this lease was born from. + public Guid RequestId { get; } + + public Guid CipherId { get; } + public Guid CollectionId { get; } + + /// The access rule that gated the cipher at grant time. Not tracked in v1. + public string? RuleId => null; + + public Guid OrganizationId { get; } + + /// The user the lease was granted to (the original requester). + public Guid GranteeUserId { get; } + + /// active | expired | revoked. + public string Status { get; } + + public DateTime NotBefore { get; } + public DateTime NotAfter { get; } + public DateTime? RevokedAt { get; } + public Guid? RevokedByUserId { get; } + + /// The reason captured on early revocation. Recorded on the audit decision, not surfaced here in v1. + public string? RevocationReason => null; +} diff --git a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs index 9571e3b02980..d7fa48d4b545 100644 --- a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs +++ b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs @@ -211,6 +211,9 @@ public static void AddAccessRuleCommands(this IServiceCollection services) services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); } private static void AddOrganizationGroupCommands(this IServiceCollection services) diff --git a/src/Core/Pam/Models/CipherLeaseStateResult.cs b/src/Core/Pam/Models/CipherLeaseStateResult.cs new file mode 100644 index 000000000000..a06459150bf7 --- /dev/null +++ b/src/Core/Pam/Models/CipherLeaseStateResult.cs @@ -0,0 +1,10 @@ +using Bit.Core.Pam.Entities; + +namespace Bit.Core.Pam.Models; + +/// +/// The caller's lease state for a single cipher: the active lease they hold (if any) and their pending request (if +/// any). The approved-but-unredeemed "ticket" the client models has no server counterpart in v0 — approval mints an +/// active lease immediately — so it is always absent here. +/// +public record CipherLeaseStateResult(Guid CipherId, Lease? ActiveLease, InboxLeaseRequestDetails? PendingRequest); diff --git a/src/Core/Pam/Models/LeaseStatusName.cs b/src/Core/Pam/Models/LeaseStatusName.cs new file mode 100644 index 000000000000..ef52de717eaa --- /dev/null +++ b/src/Core/Pam/Models/LeaseStatusName.cs @@ -0,0 +1,22 @@ +using Bit.Core.Pam.Enums; + +namespace Bit.Core.Pam.Models; + +/// +/// Maps the backend to the status vocabulary the leasing client expects: +/// active | expired | revoked. Mirrors for the request side. +/// +public static class LeaseStatusName +{ + public const string Active = "active"; + public const string Expired = "expired"; + public const string Revoked = "revoked"; + + public static string From(LeaseStatus status) => status switch + { + LeaseStatus.Active => Active, + LeaseStatus.Expired => Expired, + LeaseStatus.Revoked => Revoked, + _ => throw new ArgumentOutOfRangeException(nameof(status), status, null), + }; +} diff --git a/src/Core/Pam/OrganizationFeatures/Queries/GetCipherLeaseStateQuery.cs b/src/Core/Pam/OrganizationFeatures/Queries/GetCipherLeaseStateQuery.cs new file mode 100644 index 000000000000..ec253426af64 --- /dev/null +++ b/src/Core/Pam/OrganizationFeatures/Queries/GetCipherLeaseStateQuery.cs @@ -0,0 +1,73 @@ +using Bit.Core.Exceptions; +using Bit.Core.Pam.Entities; +using Bit.Core.Pam.Models; +using Bit.Core.Pam.OrganizationFeatures.Queries.Interfaces; +using Bit.Core.Pam.Repositories; +using Bit.Core.Pam.Services; +using Bit.Core.Vault.Repositories; + +namespace Bit.Core.Pam.OrganizationFeatures.Queries; + +public class GetCipherLeaseStateQuery : IGetCipherLeaseStateQuery +{ + private readonly ICipherRepository _cipherRepository; + private readonly IAccessApprovalResolver _resolver; + private readonly ILeaseRepository _leaseRepository; + private readonly ILeaseRequestRepository _leaseRequestRepository; + private readonly TimeProvider _timeProvider; + + public GetCipherLeaseStateQuery( + ICipherRepository cipherRepository, + IAccessApprovalResolver resolver, + ILeaseRepository leaseRepository, + ILeaseRequestRepository leaseRequestRepository, + TimeProvider timeProvider) + { + _cipherRepository = cipherRepository; + _resolver = resolver; + _leaseRepository = leaseRepository; + _leaseRequestRepository = leaseRequestRepository; + _timeProvider = timeProvider; + } + + public async Task GetStateAsync(Guid userId, Guid cipherId) + { + // GetByIdAsync filters by access, so a null result means the caller cannot see the cipher. + var cipher = await _cipherRepository.GetByIdAsync(cipherId, userId); + if (cipher is null) + { + throw new NotFoundException(); + } + + var now = _timeProvider.GetUtcNow().UtcDateTime; + var activeLease = await _leaseRepository.GetActiveByRequesterIdCipherIdAsync(userId, cipherId, now); + var pending = await _leaseRequestRepository.GetActivePendingByRequesterIdCipherIdAsync(userId, cipherId); + + // 404 when the cipher isn't leasing-gated and there's nothing to report. We still return a snapshot when the + // caller holds a lease or a pending request even if the rule was since removed, so their state isn't hidden. + if (activeLease is null && pending is null && await _resolver.ResolveAsync(userId, cipherId) is null) + { + throw new NotFoundException(); + } + + return new CipherLeaseStateResult(cipherId, activeLease, pending is null ? null : ToDetails(pending)); + } + + // A pending request has produced no lease and has no resolver yet; the inbox display-name fields aren't needed for + // this caller-scoped snapshot, so they stay null. + private static InboxLeaseRequestDetails ToDetails(LeaseRequest request) => new() + { + Id = request.Id, + ExtensionOfLeaseId = request.LeaseId, + OrganizationId = request.OrganizationId, + CollectionId = request.CollectionId, + CipherId = request.CipherId, + RequesterId = request.RequesterId, + NotBefore = request.NotBefore, + NotAfter = request.NotAfter, + Reason = request.Reason, + Status = request.Status, + CreationDate = request.CreationDate, + ResolvedDate = request.ResolvedDate, + }; +} diff --git a/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IGetCipherLeaseStateQuery.cs b/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IGetCipherLeaseStateQuery.cs new file mode 100644 index 000000000000..a9a6e680ee78 --- /dev/null +++ b/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IGetCipherLeaseStateQuery.cs @@ -0,0 +1,13 @@ +using Bit.Core.Pam.Models; + +namespace Bit.Core.Pam.OrganizationFeatures.Queries.Interfaces; + +public interface IGetCipherLeaseStateQuery +{ + /// + /// Returns the caller's lease state for a single leasing-gated cipher — their active lease and pending request, if + /// any. Throws when the caller cannot see the cipher, or when + /// the cipher is not leasing-gated and the caller holds nothing to report. + /// + Task GetStateAsync(Guid userId, Guid cipherId); +} diff --git a/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IListMyAccessRequestsQuery.cs b/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IListMyAccessRequestsQuery.cs new file mode 100644 index 000000000000..1f4004583802 --- /dev/null +++ b/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IListMyAccessRequestsQuery.cs @@ -0,0 +1,12 @@ +using Bit.Core.Pam.Models; + +namespace Bit.Core.Pam.OrganizationFeatures.Queries.Interfaces; + +public interface IListMyAccessRequestsQuery +{ + /// + /// Returns the caller's own lease requests across every organization they belong to, regardless of status. Returns + /// an empty collection when they have none. + /// + Task> GetMineAsync(Guid userId); +} diff --git a/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IListMyActiveLeasesQuery.cs b/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IListMyActiveLeasesQuery.cs new file mode 100644 index 000000000000..298b334041f1 --- /dev/null +++ b/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IListMyActiveLeasesQuery.cs @@ -0,0 +1,12 @@ +using Bit.Core.Pam.Entities; + +namespace Bit.Core.Pam.OrganizationFeatures.Queries.Interfaces; + +public interface IListMyActiveLeasesQuery +{ + /// + /// Returns the caller's currently-active leases across every organization they belong to. Returns an empty + /// collection when none are active. + /// + Task> GetMineActiveAsync(Guid userId); +} diff --git a/src/Core/Pam/OrganizationFeatures/Queries/ListMyAccessRequestsQuery.cs b/src/Core/Pam/OrganizationFeatures/Queries/ListMyAccessRequestsQuery.cs new file mode 100644 index 000000000000..42268118917b --- /dev/null +++ b/src/Core/Pam/OrganizationFeatures/Queries/ListMyAccessRequestsQuery.cs @@ -0,0 +1,18 @@ +using Bit.Core.Pam.Models; +using Bit.Core.Pam.OrganizationFeatures.Queries.Interfaces; +using Bit.Core.Pam.Repositories; + +namespace Bit.Core.Pam.OrganizationFeatures.Queries; + +public class ListMyAccessRequestsQuery : IListMyAccessRequestsQuery +{ + private readonly ILeaseRequestRepository _leaseRequestRepository; + + public ListMyAccessRequestsQuery(ILeaseRequestRepository leaseRequestRepository) + { + _leaseRequestRepository = leaseRequestRepository; + } + + public Task> GetMineAsync(Guid userId) => + _leaseRequestRepository.GetManyByRequesterIdAsync(userId); +} diff --git a/src/Core/Pam/OrganizationFeatures/Queries/ListMyActiveLeasesQuery.cs b/src/Core/Pam/OrganizationFeatures/Queries/ListMyActiveLeasesQuery.cs new file mode 100644 index 000000000000..ff0c783d2baa --- /dev/null +++ b/src/Core/Pam/OrganizationFeatures/Queries/ListMyActiveLeasesQuery.cs @@ -0,0 +1,20 @@ +using Bit.Core.Pam.Entities; +using Bit.Core.Pam.OrganizationFeatures.Queries.Interfaces; +using Bit.Core.Pam.Repositories; + +namespace Bit.Core.Pam.OrganizationFeatures.Queries; + +public class ListMyActiveLeasesQuery : IListMyActiveLeasesQuery +{ + private readonly ILeaseRepository _leaseRepository; + private readonly TimeProvider _timeProvider; + + public ListMyActiveLeasesQuery(ILeaseRepository leaseRepository, TimeProvider timeProvider) + { + _leaseRepository = leaseRepository; + _timeProvider = timeProvider; + } + + public Task> GetMineActiveAsync(Guid userId) => + _leaseRepository.GetManyActiveByRequesterIdAsync(userId, _timeProvider.GetUtcNow().UtcDateTime); +} diff --git a/src/Core/Pam/Repositories/ILeaseRepository.cs b/src/Core/Pam/Repositories/ILeaseRepository.cs index c7505c643f8f..9bec20439779 100644 --- a/src/Core/Pam/Repositories/ILeaseRepository.cs +++ b/src/Core/Pam/Repositories/ILeaseRepository.cs @@ -11,6 +11,12 @@ public interface ILeaseRepository /// Task GetActiveByRequesterIdCipherIdAsync(Guid requesterId, Guid cipherId, DateTime now); + /// + /// Returns the caller's currently-active leases (status Active, window containing , not + /// revoked) across every organization they belong to. Returns an empty collection when none are active. + /// + Task> GetManyActiveByRequesterIdAsync(Guid requesterId, DateTime now); + /// /// Atomically creates an auto-approved , its policy , and an /// active in a single transaction. The three entities must already have their ids assigned. diff --git a/src/Core/Pam/Repositories/ILeaseRequestRepository.cs b/src/Core/Pam/Repositories/ILeaseRequestRepository.cs index 3802e61bde5c..fa5ed83f74aa 100644 --- a/src/Core/Pam/Repositories/ILeaseRequestRepository.cs +++ b/src/Core/Pam/Repositories/ILeaseRequestRepository.cs @@ -15,6 +15,12 @@ public interface ILeaseRequestRepository /// Task GetActivePendingByRequesterIdCipherIdAsync(Guid requesterId, Guid cipherId); + /// + /// Returns the caller's own lease requests across every organization they belong to, regardless of status, most + /// recent first and capped server-side. Display-name fields are not populated for this caller-scoped surface. + /// + Task> GetManyByRequesterIdAsync(Guid requesterId); + /// /// Returns the pending approver-inbox rows for the given collections, joined with their denormalized display /// fields. An empty yields an empty result. diff --git a/src/Infrastructure.Dapper/Pam/Repositories/LeaseRepository.cs b/src/Infrastructure.Dapper/Pam/Repositories/LeaseRepository.cs index 705b01db4f05..0fb261d333d2 100644 --- a/src/Infrastructure.Dapper/Pam/Repositories/LeaseRepository.cs +++ b/src/Infrastructure.Dapper/Pam/Repositories/LeaseRepository.cs @@ -31,6 +31,17 @@ public LeaseRepository(string connectionString, string readOnlyConnectionString) return results.FirstOrDefault(); } + public async Task> GetManyActiveByRequesterIdAsync(Guid requesterId, DateTime now) + { + await using var connection = new SqlConnection(ConnectionString); + var results = await connection.QueryAsync( + $"[{Schema}].[Lease_ReadManyActiveByRequesterId]", + new { RequesterId = requesterId, Now = now }, + commandType: CommandType.StoredProcedure); + + return results.ToList(); + } + public async Task CreateAutoApprovedAsync(LeaseRequest request, LeaseDecision decision, Lease lease, DateTime now) { await using var connection = new SqlConnection(ConnectionString); diff --git a/src/Infrastructure.Dapper/Pam/Repositories/LeaseRequestRepository.cs b/src/Infrastructure.Dapper/Pam/Repositories/LeaseRequestRepository.cs index 2623afa64314..30ec4e6d27ab 100644 --- a/src/Infrastructure.Dapper/Pam/Repositories/LeaseRequestRepository.cs +++ b/src/Infrastructure.Dapper/Pam/Repositories/LeaseRequestRepository.cs @@ -33,6 +33,17 @@ public LeaseRequestRepository(string connectionString, string readOnlyConnection return results.FirstOrDefault(); } + public async Task> GetManyByRequesterIdAsync(Guid requesterId) + { + await using var connection = new SqlConnection(ConnectionString); + var results = await connection.QueryAsync( + $"[{Schema}].[LeaseRequest_ReadManyByRequesterId]", + new { RequesterId = requesterId }, + commandType: CommandType.StoredProcedure); + + return results.ToList(); + } + public async Task> GetManyInboxPendingByCollectionIdsAsync(IEnumerable collectionIds) { var ids = collectionIds.ToList(); diff --git a/src/Sql/dbo/Pam/Stored Procedures/LeaseRequest_ReadManyByRequesterId.sql b/src/Sql/dbo/Pam/Stored Procedures/LeaseRequest_ReadManyByRequesterId.sql new file mode 100644 index 000000000000..e0cf1a715e93 --- /dev/null +++ b/src/Sql/dbo/Pam/Stored Procedures/LeaseRequest_ReadManyByRequesterId.sql @@ -0,0 +1,41 @@ +CREATE PROCEDURE [dbo].[LeaseRequest_ReadManyByRequesterId] + @RequesterId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + -- The caller's own requests across every org, all statuses. Unlike the approver-inbox reads this is a + -- caller-scoped self-read, so the cipher/collection/requester display-name joins are intentionally omitted + -- (those name fields stay null). Capped at the 250 most recent; the client renders far fewer. + SELECT TOP (250) + LR.[Id], + LR.[LeaseId] AS [ExtensionOfLeaseId], + LR.[OrganizationId], + LR.[CollectionId], + LR.[CipherId], + LR.[RequesterId], + LR.[NotBefore], + LR.[NotAfter], + LR.[Reason], + LR.[Status], + LR.[CreationDate], + LR.[ResolvedDate], + PL.[Id] AS [ProducedLeaseId], + RES.[ApproverId] AS [ResolverId], + RES.[Comment] AS [ResolverComment] + FROM [dbo].[LeaseRequest] LR + OUTER APPLY ( + SELECT TOP 1 L.[Id] + FROM [dbo].[Lease] L + WHERE L.[LeaseRequestId] = LR.[Id] + ORDER BY L.[CreationDate] DESC + ) PL + OUTER APPLY ( + SELECT TOP 1 LD.[ApproverId], LD.[Comment] + FROM [dbo].[LeaseDecision] LD + WHERE LD.[LeaseRequestId] = LR.[Id] AND LD.[DeciderKind] = 1 -- Human + ORDER BY LD.[CreationDate] ASC + ) RES + WHERE LR.[RequesterId] = @RequesterId + ORDER BY LR.[CreationDate] DESC +END diff --git a/src/Sql/dbo/Pam/Stored Procedures/Lease_ReadManyActiveByRequesterId.sql b/src/Sql/dbo/Pam/Stored Procedures/Lease_ReadManyActiveByRequesterId.sql new file mode 100644 index 000000000000..ce726633f06b --- /dev/null +++ b/src/Sql/dbo/Pam/Stored Procedures/Lease_ReadManyActiveByRequesterId.sql @@ -0,0 +1,19 @@ +CREATE PROCEDURE [dbo].[Lease_ReadManyActiveByRequesterId] + @RequesterId UNIQUEIDENTIFIER, + @Now DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + SELECT + * + FROM + [dbo].[Lease] + WHERE + [RequesterId] = @RequesterId + AND [Status] = 0 -- Active + AND [NotBefore] <= @Now + AND [NotAfter] > @Now + ORDER BY + [NotAfter] ASC +END diff --git a/test/Api.Test/Pam/Controllers/CipherLeaseControllerTests.cs b/test/Api.Test/Pam/Controllers/CipherLeaseControllerTests.cs index 2c4782e909cd..eaed3a21e135 100644 --- a/test/Api.Test/Pam/Controllers/CipherLeaseControllerTests.cs +++ b/test/Api.Test/Pam/Controllers/CipherLeaseControllerTests.cs @@ -21,6 +21,26 @@ namespace Bit.Api.Test.Pam.Controllers; [SutProviderCustomize] public class CipherLeaseControllerTests { + [Theory, BitAutoData] + public async Task State_ReturnsSnapshotFromQuery( + Guid id, Guid userId, Bit.Core.Pam.Entities.Lease activeLease, SutProvider sutProvider) + { + sutProvider.GetDependency() + .GetProperUserId(Arg.Any()) + .Returns(userId); + sutProvider.GetDependency() + .GetStateAsync(userId, id) + .Returns(new Bit.Core.Pam.Models.CipherLeaseStateResult(id, activeLease, null)); + + var result = await sutProvider.Sut.State(id); + + Assert.Equal(id, result.CipherId); + Assert.NotNull(result.Lease.ActiveLease); + Assert.Equal(activeLease.Id, result.Lease.ActiveLease!.Id); + Assert.Null(result.Lease.PendingRequest); + Assert.Null(result.Lease.ApprovedTicket); // always null in v0 — no redemption flow + } + [Theory, BitAutoData] public async Task GetCipher_NoLeasedCipher_ThrowsNotFound( Guid id, User user, SutProvider sutProvider) diff --git a/test/Api.Test/Pam/Controllers/MemberLeasingControllerTests.cs b/test/Api.Test/Pam/Controllers/MemberLeasingControllerTests.cs new file mode 100644 index 000000000000..ff39113e23d0 --- /dev/null +++ b/test/Api.Test/Pam/Controllers/MemberLeasingControllerTests.cs @@ -0,0 +1,67 @@ +using System.Security.Claims; +using Bit.Api.Pam.Controllers; +using Bit.Core.Pam.Entities; +using Bit.Core.Pam.Enums; +using Bit.Core.Pam.Models; +using Bit.Core.Pam.OrganizationFeatures.Queries.Interfaces; +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Api.Test.Pam.Controllers; + +[ControllerCustomize(typeof(MemberLeasingController))] +[SutProviderCustomize] +public class MemberLeasingControllerTests +{ + [Theory, BitAutoData] + public async Task GetMyRequests_ReturnsMappedRows( + Guid userId, InboxLeaseRequestDetails row, SutProvider sutProvider) + { + SetupUser(sutProvider, userId); + row.Status = LeaseRequestStatus.Pending; + sutProvider.GetDependency().GetMineAsync(userId).Returns([row]); + + var result = (await sutProvider.Sut.GetMyRequests()).ToList(); + + Assert.Single(result); + Assert.Equal(row.Id, result[0].Id); + Assert.Equal(InboxRequestStatus.Pending, result[0].Status); + } + + [Theory, BitAutoData] + public async Task GetMyActiveLeases_ReturnsMappedLeases( + Guid userId, Lease lease, SutProvider sutProvider) + { + SetupUser(sutProvider, userId); + lease.Status = LeaseStatus.Active; + sutProvider.GetDependency().GetMineActiveAsync(userId).Returns([lease]); + + var result = (await sutProvider.Sut.GetMyActiveLeases()).ToList(); + + Assert.Single(result); + Assert.Equal(lease.Id, result[0].Id); + Assert.Equal(LeaseStatusName.Active, result[0].Status); + } + + [Theory, BitAutoData] + public async Task GetMyRequests_NoRows_ReturnsEmpty( + Guid userId, SutProvider sutProvider) + { + SetupUser(sutProvider, userId); + sutProvider.GetDependency().GetMineAsync(userId).Returns([]); + + var result = await sutProvider.Sut.GetMyRequests(); + + Assert.Empty(result); + } + + private static void SetupUser(SutProvider sutProvider, Guid userId) + { + sutProvider.GetDependency() + .GetProperUserId(Arg.Any()) + .Returns(userId); + } +} diff --git a/test/Api.Test/Pam/Models/MemberLeaseResponseModelTests.cs b/test/Api.Test/Pam/Models/MemberLeaseResponseModelTests.cs new file mode 100644 index 000000000000..f90889eed88b --- /dev/null +++ b/test/Api.Test/Pam/Models/MemberLeaseResponseModelTests.cs @@ -0,0 +1,33 @@ +using Bit.Api.Pam.Models.Response; +using Bit.Core.Pam.Entities; +using Bit.Core.Pam.Enums; +using Bit.Core.Pam.Models; +using Bit.Test.Common.AutoFixture.Attributes; +using Xunit; + +namespace Bit.Api.Test.Pam.Models; + +public class MemberLeaseResponseModelTests +{ + [Theory, BitAutoData] + public void Ctor_MapsLeaseToClientShape(Lease lease) + { + lease.Status = LeaseStatus.Active; + + var model = new MemberLeaseResponseModel(lease); + + Assert.Equal(lease.Id, model.Id); + Assert.Equal(lease.LeaseRequestId, model.RequestId); + Assert.Equal(lease.CipherId, model.CipherId); + Assert.Equal(lease.CollectionId, model.CollectionId); + Assert.Equal(lease.OrganizationId, model.OrganizationId); + Assert.Equal(lease.RequesterId, model.GranteeUserId); + Assert.Equal(LeaseStatusName.Active, model.Status); + Assert.Equal(lease.NotBefore, model.NotBefore); + Assert.Equal(lease.NotAfter, model.NotAfter); + Assert.Equal(lease.RevokedDate, model.RevokedAt); + Assert.Equal(lease.RevokedBy, model.RevokedByUserId); + Assert.Null(model.RuleId); + Assert.Null(model.RevocationReason); + } +} diff --git a/test/Core.Test/Pam/Models/LeaseStatusNameTests.cs b/test/Core.Test/Pam/Models/LeaseStatusNameTests.cs new file mode 100644 index 000000000000..93fed456e3ff --- /dev/null +++ b/test/Core.Test/Pam/Models/LeaseStatusNameTests.cs @@ -0,0 +1,17 @@ +using Bit.Core.Pam.Enums; +using Bit.Core.Pam.Models; +using Xunit; + +namespace Bit.Core.Test.Pam.Models; + +public class LeaseStatusNameTests +{ + [Theory] + [InlineData(LeaseStatus.Active, LeaseStatusName.Active)] + [InlineData(LeaseStatus.Expired, LeaseStatusName.Expired)] + [InlineData(LeaseStatus.Revoked, LeaseStatusName.Revoked)] + public void From_MapsToFrontendVocabulary(LeaseStatus status, string expected) + { + Assert.Equal(expected, LeaseStatusName.From(status)); + } +} diff --git a/test/Core.Test/Pam/Queries/GetCipherLeaseStateQueryTests.cs b/test/Core.Test/Pam/Queries/GetCipherLeaseStateQueryTests.cs new file mode 100644 index 000000000000..b63291a7cded --- /dev/null +++ b/test/Core.Test/Pam/Queries/GetCipherLeaseStateQueryTests.cs @@ -0,0 +1,126 @@ +using Bit.Core.Exceptions; +using Bit.Core.Pam.Entities; +using Bit.Core.Pam.Enums; +using Bit.Core.Pam.Models; +using Bit.Core.Pam.Models.Rules; +using Bit.Core.Pam.OrganizationFeatures.Queries; +using Bit.Core.Pam.Repositories; +using Bit.Core.Pam.Services; +using Bit.Core.Vault.Models.Data; +using Bit.Core.Vault.Repositories; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Pam.Queries; + +[SutProviderCustomize] +public class GetCipherLeaseStateQueryTests +{ + [Theory, BitAutoData] + public async Task GetStateAsync_CipherNotAccessible_ThrowsNotFound( + SutProvider sutProvider, Guid userId, Guid cipherId) + { + sutProvider.GetDependency() + .GetByIdAsync(cipherId, userId) + .Returns((CipherDetails?)null); + + await Assert.ThrowsAsync(() => sutProvider.Sut.GetStateAsync(userId, cipherId)); + } + + [Theory, BitAutoData] + public async Task GetStateAsync_NotGatedAndNothingHeld_ThrowsNotFound( + SutProvider sutProvider, Guid userId, Guid cipherId) + { + SetupCipher(sutProvider, userId, cipherId); + // No active lease, no pending request, and the resolver finds no governing rule. + sutProvider.GetDependency() + .ResolveAsync(userId, cipherId) + .Returns((AccessApprovalResolution?)null); + + await Assert.ThrowsAsync(() => sutProvider.Sut.GetStateAsync(userId, cipherId)); + } + + [Theory, BitAutoData] + public async Task GetStateAsync_ActiveLease_ReturnsSnapshotWithLease( + SutProvider sutProvider, Guid userId, Guid cipherId, Lease activeLease) + { + SetupCipher(sutProvider, userId, cipherId); + sutProvider.GetDependency() + .GetActiveByRequesterIdCipherIdAsync(userId, cipherId, Arg.Any()) + .Returns(activeLease); + + var result = await sutProvider.Sut.GetStateAsync(userId, cipherId); + + Assert.Equal(cipherId, result.CipherId); + Assert.Same(activeLease, result.ActiveLease); + Assert.Null(result.PendingRequest); + } + + [Theory, BitAutoData] + public async Task GetStateAsync_LeaseHeldButRuleRemoved_StillReturnsSnapshot( + SutProvider sutProvider, Guid userId, Guid cipherId, Lease activeLease) + { + SetupCipher(sutProvider, userId, cipherId); + sutProvider.GetDependency() + .GetActiveByRequesterIdCipherIdAsync(userId, cipherId, Arg.Any()) + .Returns(activeLease); + // Rule since removed: resolver returns null, but the held lease must not be hidden. + sutProvider.GetDependency() + .ResolveAsync(userId, cipherId) + .Returns((AccessApprovalResolution?)null); + + var result = await sutProvider.Sut.GetStateAsync(userId, cipherId); + + Assert.Same(activeLease, result.ActiveLease); + } + + [Theory, BitAutoData] + public async Task GetStateAsync_PendingRequest_MapsToDetails( + SutProvider sutProvider, Guid userId, Guid cipherId, LeaseRequest pending) + { + SetupCipher(sutProvider, userId, cipherId); + pending.CipherId = cipherId; + pending.RequesterId = userId; + pending.Status = LeaseRequestStatus.Pending; + sutProvider.GetDependency() + .GetActivePendingByRequesterIdCipherIdAsync(userId, cipherId) + .Returns(pending); + + var result = await sutProvider.Sut.GetStateAsync(userId, cipherId); + + Assert.Null(result.ActiveLease); + Assert.NotNull(result.PendingRequest); + Assert.Equal(pending.Id, result.PendingRequest!.Id); + Assert.Equal(pending.LeaseId, result.PendingRequest.ExtensionOfLeaseId); + Assert.Equal(LeaseRequestStatus.Pending, result.PendingRequest.Status); + // Pending has produced no lease and has no resolver yet; display-name fields are not populated. + Assert.Null(result.PendingRequest.ProducedLeaseId); + Assert.Null(result.PendingRequest.ResolverId); + Assert.Null(result.PendingRequest.CipherName); + } + + [Theory, BitAutoData] + public async Task GetStateAsync_GatedButEmpty_ReturnsEmptySnapshot( + SutProvider sutProvider, Guid userId, Guid cipherId, Guid orgId, Guid collectionId) + { + SetupCipher(sutProvider, userId, cipherId); + sutProvider.GetDependency() + .ResolveAsync(userId, cipherId) + .Returns(new AccessApprovalResolution(orgId, collectionId, RequiresHumanApproval: true, new HumanApprovalRule())); + + var result = await sutProvider.Sut.GetStateAsync(userId, cipherId); + + Assert.Equal(cipherId, result.CipherId); + Assert.Null(result.ActiveLease); + Assert.Null(result.PendingRequest); + } + + private static void SetupCipher(SutProvider sutProvider, Guid userId, Guid cipherId) + { + sutProvider.GetDependency() + .GetByIdAsync(cipherId, userId) + .Returns(new CipherDetails { Id = cipherId }); + } +} diff --git a/test/Core.Test/Pam/Queries/ListMyAccessRequestsQueryTests.cs b/test/Core.Test/Pam/Queries/ListMyAccessRequestsQueryTests.cs new file mode 100644 index 000000000000..5dfefdb02ca2 --- /dev/null +++ b/test/Core.Test/Pam/Queries/ListMyAccessRequestsQueryTests.cs @@ -0,0 +1,40 @@ +using Bit.Core.Pam.Models; +using Bit.Core.Pam.OrganizationFeatures.Queries; +using Bit.Core.Pam.Repositories; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Pam.Queries; + +[SutProviderCustomize] +public class ListMyAccessRequestsQueryTests +{ + [Theory, BitAutoData] + public async Task GetMineAsync_ReturnsRequesterRows( + SutProvider sutProvider, Guid userId, InboxLeaseRequestDetails row) + { + sutProvider.GetDependency() + .GetManyByRequesterIdAsync(userId) + .Returns([row]); + + var result = await sutProvider.Sut.GetMineAsync(userId); + + Assert.Single(result); + Assert.Equal(row.Id, result.First().Id); + } + + [Theory, BitAutoData] + public async Task GetMineAsync_NoRequests_ReturnsEmpty( + SutProvider sutProvider, Guid userId) + { + sutProvider.GetDependency() + .GetManyByRequesterIdAsync(userId) + .Returns([]); + + var result = await sutProvider.Sut.GetMineAsync(userId); + + Assert.Empty(result); + } +} diff --git a/test/Core.Test/Pam/Queries/ListMyActiveLeasesQueryTests.cs b/test/Core.Test/Pam/Queries/ListMyActiveLeasesQueryTests.cs new file mode 100644 index 000000000000..6f0ff91b017a --- /dev/null +++ b/test/Core.Test/Pam/Queries/ListMyActiveLeasesQueryTests.cs @@ -0,0 +1,40 @@ +using Bit.Core.Pam.Entities; +using Bit.Core.Pam.OrganizationFeatures.Queries; +using Bit.Core.Pam.Repositories; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Pam.Queries; + +[SutProviderCustomize] +public class ListMyActiveLeasesQueryTests +{ + [Theory, BitAutoData] + public async Task GetMineActiveAsync_ReturnsActiveLeases( + SutProvider sutProvider, Guid userId, Lease lease) + { + sutProvider.GetDependency() + .GetManyActiveByRequesterIdAsync(userId, Arg.Any()) + .Returns([lease]); + + var result = await sutProvider.Sut.GetMineActiveAsync(userId); + + Assert.Single(result); + Assert.Equal(lease.Id, result.First().Id); + } + + [Theory, BitAutoData] + public async Task GetMineActiveAsync_NoLeases_ReturnsEmpty( + SutProvider sutProvider, Guid userId) + { + sutProvider.GetDependency() + .GetManyActiveByRequesterIdAsync(userId, Arg.Any()) + .Returns([]); + + var result = await sutProvider.Sut.GetMineActiveAsync(userId); + + Assert.Empty(result); + } +} diff --git a/test/Infrastructure.IntegrationTest/Pam/Repositories/LeaseRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Pam/Repositories/LeaseRepositoryTests.cs index 985d74f1c7f1..3af85123b04f 100644 --- a/test/Infrastructure.IntegrationTest/Pam/Repositories/LeaseRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/Pam/Repositories/LeaseRepositoryTests.cs @@ -106,6 +106,36 @@ public async Task GetActivePendingByRequesterIdCipherIdAsync_ReturnsPendingReque Assert.Equal("audit", pending.Reason); } + [DatabaseTheory, DatabaseData] + public async Task GetManyActiveByRequesterIdAsync_ReturnsOnlyActiveLeasesInWindow( + IOrganizationRepository organizationRepository, + ILeaseRepository leaseRepository) + { + var organization = await organizationRepository.CreateTestOrganizationAsync(); + var now = DateTime.UtcNow; + var requesterId = Guid.NewGuid(); + + // Active, in-window lease for the requester. + var (activeReq, activeDec, activeLease) = BuildAutoApproved( + organization.Id, Guid.NewGuid(), requesterId, now.AddMinutes(-5), now.AddHours(1)); + await leaseRepository.CreateAutoApprovedAsync(activeReq, activeDec, activeLease, now); + + // Expired lease for the same requester — must be excluded. + var (expiredReq, expiredDec, expiredLease) = BuildAutoApproved( + organization.Id, Guid.NewGuid(), requesterId, now.AddHours(-2), now.AddHours(-1)); + await leaseRepository.CreateAutoApprovedAsync(expiredReq, expiredDec, expiredLease, now.AddHours(-2)); + + // Active lease for a different requester — must be excluded. + var (otherReq, otherDec, otherLease) = BuildAutoApproved( + organization.Id, Guid.NewGuid(), Guid.NewGuid(), now.AddMinutes(-5), now.AddHours(1)); + await leaseRepository.CreateAutoApprovedAsync(otherReq, otherDec, otherLease, now); + + var result = await leaseRepository.GetManyActiveByRequesterIdAsync(requesterId, now); + + Assert.Single(result); + Assert.Equal(activeLease.Id, result.First().Id); + } + [DatabaseTheory, DatabaseData] public async Task RevokeAsync_RevokesLeaseAndRecordsAuditDecision( IOrganizationRepository organizationRepository, diff --git a/test/Infrastructure.IntegrationTest/Pam/Repositories/LeaseRequestRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Pam/Repositories/LeaseRequestRepositoryTests.cs index 626a7580ddc8..146042e81b07 100644 --- a/test/Infrastructure.IntegrationTest/Pam/Repositories/LeaseRequestRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/Pam/Repositories/LeaseRequestRepositoryTests.cs @@ -197,6 +197,34 @@ public async Task ResolveWithDecisionAsync_Deny_ResolvesWithoutLease( Assert.Null(active); } + [DatabaseTheory, DatabaseData] + public async Task GetManyByRequesterIdAsync_ReturnsOwnRequestsRegardlessOfStatus( + IOrganizationRepository organizationRepository, + ICollectionRepository collectionRepository, + ILeaseRequestRepository leaseRequestRepository) + { + var organization = await organizationRepository.CreateTestOrganizationAsync(); + var collection = await collectionRepository.CreateTestCollectionAsync(organization); + var now = DateTime.UtcNow; + var requesterId = Guid.NewGuid(); + + var pending = await leaseRequestRepository.CreateAsync(BuildRequest( + organization.Id, collection.Id, requesterId, LeaseRequestStatus.Pending, now)); + var denied = await leaseRequestRepository.CreateAsync(BuildRequest( + organization.Id, collection.Id, requesterId, LeaseRequestStatus.Denied, now.AddMinutes(-1))); + // A different user's request on the same collection must not appear. + await leaseRequestRepository.CreateAsync(BuildRequest( + organization.Id, collection.Id, Guid.NewGuid(), LeaseRequestStatus.Pending, now)); + + var mine = await leaseRequestRepository.GetManyByRequesterIdAsync(requesterId); + + Assert.Equal(2, mine.Count); + Assert.Contains(mine, r => r.Id == pending.Id); + Assert.Contains(mine, r => r.Id == denied.Id); + // Caller-scoped self-read omits the display-name joins. + Assert.All(mine, r => Assert.Null(r.CollectionName)); + } + private static LeaseRequest BuildRequest( Guid organizationId, Guid collectionId, Guid requesterId, LeaseRequestStatus status, DateTime creationDate) => new() diff --git a/util/Migrator/DbScripts/2026-06-05_02_AddMemberLeaseReadSprocs.sql b/util/Migrator/DbScripts/2026-06-05_02_AddMemberLeaseReadSprocs.sql new file mode 100644 index 000000000000..a11d09a7f84d --- /dev/null +++ b/util/Migrator/DbScripts/2026-06-05_02_AddMemberLeaseReadSprocs.sql @@ -0,0 +1,67 @@ +-- PAM member read endpoints: caller-scoped reads for the cipher-lease banner, vault-row badge, and the +-- "My access requests" page. Lease_ReadManyActiveByRequesterId backs "my active leases"; +-- LeaseRequest_ReadManyByRequesterId backs "my requests" (all statuses, names omitted — caller-scoped self-read). + +CREATE OR ALTER PROCEDURE [dbo].[Lease_ReadManyActiveByRequesterId] + @RequesterId UNIQUEIDENTIFIER, + @Now DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + SELECT + * + FROM + [dbo].[Lease] + WHERE + [RequesterId] = @RequesterId + AND [Status] = 0 -- Active + AND [NotBefore] <= @Now + AND [NotAfter] > @Now + ORDER BY + [NotAfter] ASC +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[LeaseRequest_ReadManyByRequesterId] + @RequesterId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + -- The caller's own requests across every org, all statuses. Unlike the approver-inbox reads this is a + -- caller-scoped self-read, so the cipher/collection/requester display-name joins are intentionally omitted + -- (those name fields stay null). Capped at the 250 most recent; the client renders far fewer. + SELECT TOP (250) + LR.[Id], + LR.[LeaseId] AS [ExtensionOfLeaseId], + LR.[OrganizationId], + LR.[CollectionId], + LR.[CipherId], + LR.[RequesterId], + LR.[NotBefore], + LR.[NotAfter], + LR.[Reason], + LR.[Status], + LR.[CreationDate], + LR.[ResolvedDate], + PL.[Id] AS [ProducedLeaseId], + RES.[ApproverId] AS [ResolverId], + RES.[Comment] AS [ResolverComment] + FROM [dbo].[LeaseRequest] LR + OUTER APPLY ( + SELECT TOP 1 L.[Id] + FROM [dbo].[Lease] L + WHERE L.[LeaseRequestId] = LR.[Id] + ORDER BY L.[CreationDate] DESC + ) PL + OUTER APPLY ( + SELECT TOP 1 LD.[ApproverId], LD.[Comment] + FROM [dbo].[LeaseDecision] LD + WHERE LD.[LeaseRequestId] = LR.[Id] AND LD.[DeciderKind] = 1 -- Human + ORDER BY LD.[CreationDate] ASC + ) RES + WHERE LR.[RequesterId] = @RequesterId + ORDER BY LR.[CreationDate] DESC +END +GO From decdf8f3d90ad7ce3d9297421080ba172e4e0b5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anders=20=C3=85berg?= Date: Wed, 10 Jun 2026 16:05:59 +0200 Subject: [PATCH 15/54] Enable PAM feature flag by default for dev/demo --- src/Core/Constants.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 7c87237a0bce..235043bcb707 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -310,6 +310,11 @@ public static List GetAllKeys() public static Dictionary GetLocalOverrideFlagValues() { // place overriding values when needed locally (offline), or return null - return null; + return new Dictionary + { + // PAM is enabled by default for dev/demo purposes only. + // This MUST be set to false (or removed) before going to production. + { Pam, "true" }, + }; } } From 80117576dc48805b51268b27786cd3eda70fb2fd Mon Sep 17 00:00:00 2001 From: Patrik Svensson Date: Wed, 10 Jun 2026 21:17:32 +0200 Subject: [PATCH 16/54] Unify PAM nomenclature around the Access* family AccessRule (Conditions) -> AccessRequest -> AccessDecision (Verdict) -> AccessLease, with the AccessCondition tree replacing the polymorphic Rule model and the engine renamed to AccessRuleEngine returning AccessEvaluation. Requester/approver replace grantee/resolver; ticket/redeem/policy vocabulary removed. Tables, sprocs, and the branch migrations are renamed in place, so dev databases must be recreated. Routes and controller names are unchanged. --- .../Controllers/ApproverInboxController.cs | 34 +-- .../Pam/Controllers/CipherLeaseController.cs | 16 +- .../Controllers/MemberLeasingController.cs | 12 +- .../Request/AccessDecisionRequestModel.cs | 31 +++ ...el.cs => AccessLeaseRevokeRequestModel.cs} | 2 +- ....cs => AccessRequestCreateRequestModel.cs} | 2 +- .../Models/Request/AccessRuleRequestModel.cs | 8 +- .../Request/LeaseDecisionRequestModel.cs | 31 --- ...seModel.cs => AccessLeaseResponseModel.cs} | 24 +- .../Response/AccessPreCheckResponseModel.cs | 13 +- ...s => AccessRequestDetailsResponseModel.cs} | 42 +-- .../Response/AccessRequestResponseModel.cs | 36 ++- .../AccessRequestResultResponseModel.cs | 27 ++ .../Response/AccessRuleResponseModel.cs | 10 +- .../CipherAccessStateResponseModel.cs | 30 +++ .../Response/CipherLeaseStateResponseModel.cs | 37 --- .../Response/LeaseRequestResponseModel.cs | 33 --- .../Pam/Models/Response/LeaseResponseModel.cs | 29 --- ...OrganizationServiceCollectionExtensions.cs | 22 +- src/Core/Pam/Engine/AccessDecision.cs | 49 ---- src/Core/Pam/Engine/AccessEvaluation.cs | 49 ++++ src/Core/Pam/Engine/AccessPolicyEngine.cs | 90 ------- src/Core/Pam/Engine/AccessRuleEngine.cs | 91 +++++++ ...ccessPolicySignals.cs => AccessSignals.cs} | 2 +- src/Core/Pam/Engine/IAccessPolicyEngine.cs | 14 - src/Core/Pam/Engine/IAccessRuleEngine.cs | 14 + src/Core/Pam/Entities/AccessDecision.cs | 49 ++++ .../Pam/Entities/{Lease.cs => AccessLease.cs} | 10 +- .../{LeaseRequest.cs => AccessRequest.cs} | 12 +- src/Core/Pam/Entities/AccessRule.cs | 5 +- src/Core/Pam/Entities/LeaseDecision.cs | 49 ---- ...provalOutcome.cs => AccessApprovalMode.cs} | 2 +- src/Core/Pam/Enums/AccessDeciderKind.cs | 19 ++ src/Core/Pam/Enums/AccessLeaseStatus.cs | 11 + ...equestStatus.cs => AccessRequestStatus.cs} | 4 +- src/Core/Pam/Enums/LeaseDecisionKind.cs | 19 -- src/Core/Pam/Enums/LeaseStatus.cs | 11 - .../Pam/Models/AccessApprovalResolution.cs | 15 -- ...mission.cs => AccessDecisionSubmission.cs} | 4 +- src/Core/Pam/Models/AccessLeaseStatusNames.cs | 22 ++ src/Core/Pam/Models/AccessPreCheckResult.cs | 5 +- ...uestDetails.cs => AccessRequestDetails.cs} | 10 +- src/Core/Pam/Models/AccessRequestResult.cs | 20 +- ...tStatus.cs => AccessRequestStatusNames.cs} | 16 +- src/Core/Pam/Models/AccessRuleDetails.cs | 2 +- src/Core/Pam/Models/CipherAccessState.cs | 10 + src/Core/Pam/Models/CipherLeaseStateResult.cs | 10 - .../Pam/Models/Conditions/AccessCondition.cs | 14 + .../Pam/Models/Conditions/AllOfCondition.cs | 9 + .../Conditions/HumanApprovalCondition.cs | 6 + .../IpAllowlistCondition.cs} | 4 +- .../TimeOfDayCondition.cs} | 4 +- src/Core/Pam/Models/GoverningRule.cs | 15 ++ src/Core/Pam/Models/LeaseStatusName.cs | 22 -- src/Core/Pam/Models/Rules/AllOfRule.cs | 9 - .../Pam/Models/Rules/HumanApprovalRule.cs | 6 - src/Core/Pam/Models/Rules/Rule.cs | 14 - .../Commands/CreateAccessRuleCommand.cs | 2 +- ...mmand.cs => DecideAccessRequestCommand.cs} | 48 ++-- ...mand.cs => IDecideAccessRequestCommand.cs} | 4 +- ...ommand.cs => IRevokeAccessLeaseCommand.cs} | 2 +- ...mand.cs => ISubmitAccessRequestCommand.cs} | 4 +- ...Command.cs => RevokeAccessLeaseCommand.cs} | 24 +- ...mmand.cs => SubmitAccessRequestCommand.cs} | 94 +++---- .../Commands/UpdateAccessRuleCommand.cs | 4 +- .../Queries/AccessPreCheckQuery.cs | 26 +- ...eQuery.cs => GetCipherAccessStateQuery.cs} | 32 +-- .../Queries/GetLeasedCipherQuery.cs | 26 +- ...Query.cs => IGetCipherAccessStateQuery.cs} | 4 +- ...toryQuery.cs => IListInboxHistoryQuery.cs} | 4 +- ...stsQuery.cs => IListInboxRequestsQuery.cs} | 4 +- .../Interfaces/IListMyAccessRequestsQuery.cs | 2 +- ...y.cs => IListMyActiveAccessLeasesQuery.cs} | 4 +- ...storyQuery.cs => ListInboxHistoryQuery.cs} | 16 +- ...estsQuery.cs => ListInboxRequestsQuery.cs} | 16 +- .../Queries/ListMyAccessRequestsQuery.cs | 10 +- .../Queries/ListMyActiveAccessLeasesQuery.cs | 20 ++ .../Queries/ListMyActiveLeasesQuery.cs | 20 -- .../Repositories/IAccessLeaseRepository.cs | 33 +++ ...ository.cs => IAccessRequestRepository.cs} | 16 +- src/Core/Pam/Repositories/ILeaseRepository.cs | 33 --- src/Core/Pam/Services/AccessRuleValidator.cs | 70 ++--- ...alResolver.cs => GoverningRuleResolver.cs} | 41 +-- src/Core/Pam/Services/IAccessRuleValidator.cs | 4 +- ...lResolver.cs => IGoverningRuleResolver.cs} | 4 +- .../DapperServiceCollectionExtensions.cs | 4 +- ...Repository.cs => AccessLeaseRepository.cs} | 40 +-- ...pository.cs => AccessRequestRepository.cs} | 46 ++-- .../AccessLease_CreateAutoApproved.sql | 56 ++++ ...Lease_ReadActiveByRequesterIdCipherId.sql} | 4 +- ..._ReadById.sql => AccessLease_ReadById.sql} | 4 +- ...cessLease_ReadManyActiveByRequesterId.sql} | 4 +- .../Stored Procedures/AccessLease_Revoke.sql | 35 +++ ...st_Create.sql => AccessRequest_Create.sql} | 10 +- ...eadActivePendingByRequesterIdCipherId.sql} | 4 +- ...eadById.sql => AccessRequest_ReadById.sql} | 4 +- ...quest_ReadInboxHistoryByCollectionIds.sql} | 18 +- ...quest_ReadInboxPendingByCollectionIds.sql} | 20 +- ...> AccessRequest_ReadManyByRequesterId.sql} | 18 +- .../AccessRequest_ResolveWithDecision.sql | 53 ++++ .../Stored Procedures/AccessRule_Create.sql | 6 +- .../Stored Procedures/AccessRule_Update.sql | 4 +- .../LeaseRequest_ResolveWithDecision.sql | 53 ---- .../Lease_CreateAutoApproved.sql | 56 ---- .../Pam/Stored Procedures/Lease_Revoke.sql | 35 --- src/Sql/dbo/Pam/Tables/AccessDecision.sql | 18 ++ src/Sql/dbo/Pam/Tables/AccessLease.sql | 26 ++ src/Sql/dbo/Pam/Tables/AccessRequest.sql | 26 ++ src/Sql/dbo/Pam/Tables/AccessRule.sql | 2 +- src/Sql/dbo/Pam/Tables/Lease.sql | 26 -- src/Sql/dbo/Pam/Tables/LeaseDecision.sql | 18 -- src/Sql/dbo/Pam/Tables/LeaseRequest.sql | 26 -- .../ApproverInboxControllerTests.cs | 30 +-- .../Controllers/CipherLeaseControllerTests.cs | 14 +- .../MemberLeasingControllerTests.cs | 14 +- ...ts.cs => AccessLeaseResponseModelTests.cs} | 14 +- .../Commands/CreateAccessRuleCommandTests.cs | 28 +- ....cs => DecideAccessRequestCommandTests.cs} | 74 +++--- ...ts.cs => RevokeAccessLeaseCommandTests.cs} | 36 +-- ....cs => SubmitAccessRequestCommandTests.cs} | 121 ++++----- .../Commands/UpdateAccessRuleCommandTests.cs | 22 +- .../Pam/Engine/AccessPolicyEngineTests.cs | 242 ----------------- .../Pam/Engine/AccessRuleEngineTests.cs | 242 +++++++++++++++++ .../Pam/Models/AccessLeaseStatusNamesTests.cs | 17 ++ .../Models/AccessRequestStatusNamesTests.cs | 20 ++ .../Pam/Models/InboxRequestStatusTests.cs | 20 -- .../Pam/Models/LeaseStatusNameTests.cs | 17 -- .../Pam/Queries/AccessPreCheckQueryTests.cs | 28 +- ...s.cs => GetCipherAccessStateQueryTests.cs} | 46 ++-- .../Pam/Queries/GetLeasedCipherQueryTests.cs | 52 ++-- ...Tests.cs => ListInboxHistoryQueryTests.cs} | 16 +- ...ests.cs => ListInboxRequestsQueryTests.cs} | 12 +- .../Queries/ListMyAccessRequestsQueryTests.cs | 6 +- ... => ListMyActiveAccessLeasesQueryTests.cs} | 10 +- .../Pam/Services/AccessRuleValidatorTests.cs | 36 +-- ...Tests.cs => GoverningRuleResolverTests.cs} | 40 +-- ...Tests.cs => AccessLeaseRepositoryTests.cs} | 84 +++--- ...sts.cs => AccessRequestRepositoryTests.cs} | 126 ++++----- .../Repositories/AccessRuleRepositoryTests.cs | 2 +- .../DbScripts/2026-05-21_00_AddAccessRule.sql | 12 +- .../2026-06-04_00_AddAccessLeaseTables.sql | 244 ++++++++++++++++++ .../2026-06-04_00_AddLeaseTables.sql | 244 ------------------ .../2026-06-05_00_AddApproverInboxSprocs.sql | 86 +++--- ...6-06-05_01_CreateAccessLeaseOnApproval.sql | 60 +++++ .../2026-06-05_01_CreateLeaseOnApproval.sql | 60 ----- ... 2026-06-05_02_AddRequesterReadSprocs.sql} | 24 +- .../20260526122321_AddAccessRule.Designer.cs | 2 +- .../20260526122321_AddAccessRule.cs | 2 +- .../DatabaseContextModelSnapshot.cs | 2 +- .../20260526122317_AddAccessRule.Designer.cs | 2 +- .../20260526122317_AddAccessRule.cs | 2 +- .../DatabaseContextModelSnapshot.cs | 2 +- .../20260526122325_AddAccessRule.Designer.cs | 2 +- .../20260526122325_AddAccessRule.cs | 2 +- .../DatabaseContextModelSnapshot.cs | 2 +- 155 files changed, 2217 insertions(+), 2239 deletions(-) create mode 100644 src/Api/Pam/Models/Request/AccessDecisionRequestModel.cs rename src/Api/Pam/Models/Request/{LeaseRevokeRequestModel.cs => AccessLeaseRevokeRequestModel.cs} (84%) rename src/Api/Pam/Models/Request/{AccessRequestModel.cs => AccessRequestCreateRequestModel.cs} (94%) delete mode 100644 src/Api/Pam/Models/Request/LeaseDecisionRequestModel.cs rename src/Api/Pam/Models/Response/{MemberLeaseResponseModel.cs => AccessLeaseResponseModel.cs} (63%) rename src/Api/Pam/Models/Response/{InboxAccessRequestResponseModel.cs => AccessRequestDetailsResponseModel.cs} (58%) create mode 100644 src/Api/Pam/Models/Response/AccessRequestResultResponseModel.cs create mode 100644 src/Api/Pam/Models/Response/CipherAccessStateResponseModel.cs delete mode 100644 src/Api/Pam/Models/Response/CipherLeaseStateResponseModel.cs delete mode 100644 src/Api/Pam/Models/Response/LeaseRequestResponseModel.cs delete mode 100644 src/Api/Pam/Models/Response/LeaseResponseModel.cs delete mode 100644 src/Core/Pam/Engine/AccessDecision.cs create mode 100644 src/Core/Pam/Engine/AccessEvaluation.cs delete mode 100644 src/Core/Pam/Engine/AccessPolicyEngine.cs create mode 100644 src/Core/Pam/Engine/AccessRuleEngine.cs rename src/Core/Pam/Engine/{AccessPolicySignals.cs => AccessSignals.cs} (92%) delete mode 100644 src/Core/Pam/Engine/IAccessPolicyEngine.cs create mode 100644 src/Core/Pam/Engine/IAccessRuleEngine.cs create mode 100644 src/Core/Pam/Entities/AccessDecision.cs rename src/Core/Pam/Entities/{Lease.cs => AccessLease.cs} (74%) rename src/Core/Pam/Entities/{LeaseRequest.cs => AccessRequest.cs} (76%) delete mode 100644 src/Core/Pam/Entities/LeaseDecision.cs rename src/Core/Pam/Enums/{AccessApprovalOutcome.cs => AccessApprovalMode.cs} (90%) create mode 100644 src/Core/Pam/Enums/AccessDeciderKind.cs create mode 100644 src/Core/Pam/Enums/AccessLeaseStatus.cs rename src/Core/Pam/Enums/{LeaseRequestStatus.cs => AccessRequestStatus.cs} (62%) delete mode 100644 src/Core/Pam/Enums/LeaseDecisionKind.cs delete mode 100644 src/Core/Pam/Enums/LeaseStatus.cs delete mode 100644 src/Core/Pam/Models/AccessApprovalResolution.cs rename src/Core/Pam/Models/{LeaseDecisionSubmission.cs => AccessDecisionSubmission.cs} (68%) create mode 100644 src/Core/Pam/Models/AccessLeaseStatusNames.cs rename src/Core/Pam/Models/{InboxLeaseRequestDetails.cs => AccessRequestDetails.cs} (87%) rename src/Core/Pam/Models/{InboxRequestStatus.cs => AccessRequestStatusNames.cs} (55%) create mode 100644 src/Core/Pam/Models/CipherAccessState.cs delete mode 100644 src/Core/Pam/Models/CipherLeaseStateResult.cs create mode 100644 src/Core/Pam/Models/Conditions/AccessCondition.cs create mode 100644 src/Core/Pam/Models/Conditions/AllOfCondition.cs create mode 100644 src/Core/Pam/Models/Conditions/HumanApprovalCondition.cs rename src/Core/Pam/Models/{Rules/IpAllowlistRule.cs => Conditions/IpAllowlistCondition.cs} (64%) rename src/Core/Pam/Models/{Rules/TimeOfDayRule.cs => Conditions/TimeOfDayCondition.cs} (83%) create mode 100644 src/Core/Pam/Models/GoverningRule.cs delete mode 100644 src/Core/Pam/Models/LeaseStatusName.cs delete mode 100644 src/Core/Pam/Models/Rules/AllOfRule.cs delete mode 100644 src/Core/Pam/Models/Rules/HumanApprovalRule.cs delete mode 100644 src/Core/Pam/Models/Rules/Rule.cs rename src/Core/Pam/OrganizationFeatures/Commands/{DecideLeaseRequestCommand.cs => DecideAccessRequestCommand.cs} (70%) rename src/Core/Pam/OrganizationFeatures/Commands/Interfaces/{IDecideLeaseRequestCommand.cs => IDecideAccessRequestCommand.cs} (85%) rename src/Core/Pam/OrganizationFeatures/Commands/Interfaces/{IRevokeLeaseCommand.cs => IRevokeAccessLeaseCommand.cs} (93%) rename src/Core/Pam/OrganizationFeatures/Commands/Interfaces/{IRequestAccessCommand.cs => ISubmitAccessRequestCommand.cs} (71%) rename src/Core/Pam/OrganizationFeatures/Commands/{RevokeLeaseCommand.cs => RevokeAccessLeaseCommand.cs} (72%) rename src/Core/Pam/OrganizationFeatures/Commands/{RequestAccessCommand.cs => SubmitAccessRequestCommand.cs} (65%) rename src/Core/Pam/OrganizationFeatures/Queries/{GetCipherLeaseStateQuery.cs => GetCipherAccessStateQuery.cs} (63%) rename src/Core/Pam/OrganizationFeatures/Queries/Interfaces/{IGetCipherLeaseStateQuery.cs => IGetCipherAccessStateQuery.cs} (79%) rename src/Core/Pam/OrganizationFeatures/Queries/Interfaces/{IGetInboxHistoryQuery.cs => IListInboxHistoryQuery.cs} (75%) rename src/Core/Pam/OrganizationFeatures/Queries/Interfaces/{IGetInboxRequestsQuery.cs => IListInboxRequestsQuery.cs} (72%) rename src/Core/Pam/OrganizationFeatures/Queries/Interfaces/{IListMyActiveLeasesQuery.cs => IListMyActiveAccessLeasesQuery.cs} (71%) rename src/Core/Pam/OrganizationFeatures/Queries/{GetInboxHistoryQuery.cs => ListInboxHistoryQuery.cs} (65%) rename src/Core/Pam/OrganizationFeatures/Queries/{GetInboxRequestsQuery.cs => ListInboxRequestsQuery.cs} (54%) create mode 100644 src/Core/Pam/OrganizationFeatures/Queries/ListMyActiveAccessLeasesQuery.cs delete mode 100644 src/Core/Pam/OrganizationFeatures/Queries/ListMyActiveLeasesQuery.cs create mode 100644 src/Core/Pam/Repositories/IAccessLeaseRepository.cs rename src/Core/Pam/Repositories/{ILeaseRequestRepository.cs => IAccessRequestRepository.cs} (67%) delete mode 100644 src/Core/Pam/Repositories/ILeaseRepository.cs rename src/Core/Pam/Services/{AccessApprovalResolver.cs => GoverningRuleResolver.cs} (58%) rename src/Core/Pam/Services/{IAccessApprovalResolver.cs => IGoverningRuleResolver.cs} (77%) rename src/Infrastructure.Dapper/Pam/Repositories/{LeaseRepository.cs => AccessLeaseRepository.cs} (56%) rename src/Infrastructure.Dapper/Pam/Repositories/{LeaseRequestRepository.cs => AccessRequestRepository.cs} (52%) create mode 100644 src/Sql/dbo/Pam/Stored Procedures/AccessLease_CreateAutoApproved.sql rename src/Sql/dbo/Pam/Stored Procedures/{Lease_ReadActiveByRequesterIdCipherId.sql => AccessLease_ReadActiveByRequesterIdCipherId.sql} (79%) rename src/Sql/dbo/Pam/Stored Procedures/{Lease_ReadById.sql => AccessLease_ReadById.sql} (61%) rename src/Sql/dbo/Pam/Stored Procedures/{Lease_ReadManyActiveByRequesterId.sql => AccessLease_ReadManyActiveByRequesterId.sql} (76%) create mode 100644 src/Sql/dbo/Pam/Stored Procedures/AccessLease_Revoke.sql rename src/Sql/dbo/Pam/Stored Procedures/{LeaseRequest_Create.sql => AccessRequest_Create.sql} (82%) rename src/Sql/dbo/Pam/Stored Procedures/{LeaseRequest_ReadActivePendingByRequesterIdCipherId.sql => AccessRequest_ReadActivePendingByRequesterIdCipherId.sql} (73%) rename src/Sql/dbo/Pam/Stored Procedures/{LeaseRequest_ReadById.sql => AccessRequest_ReadById.sql} (60%) rename src/Sql/dbo/Pam/Stored Procedures/{LeaseRequest_ReadInboxHistoryByCollectionIds.sql => AccessRequest_ReadInboxHistoryByCollectionIds.sql} (77%) rename src/Sql/dbo/Pam/Stored Procedures/{LeaseRequest_ReadInboxPendingByCollectionIds.sql => AccessRequest_ReadInboxPendingByCollectionIds.sql} (76%) rename src/Sql/dbo/Pam/Stored Procedures/{LeaseRequest_ReadManyByRequesterId.sql => AccessRequest_ReadManyByRequesterId.sql} (70%) create mode 100644 src/Sql/dbo/Pam/Stored Procedures/AccessRequest_ResolveWithDecision.sql delete mode 100644 src/Sql/dbo/Pam/Stored Procedures/LeaseRequest_ResolveWithDecision.sql delete mode 100644 src/Sql/dbo/Pam/Stored Procedures/Lease_CreateAutoApproved.sql delete mode 100644 src/Sql/dbo/Pam/Stored Procedures/Lease_Revoke.sql create mode 100644 src/Sql/dbo/Pam/Tables/AccessDecision.sql create mode 100644 src/Sql/dbo/Pam/Tables/AccessLease.sql create mode 100644 src/Sql/dbo/Pam/Tables/AccessRequest.sql delete mode 100644 src/Sql/dbo/Pam/Tables/Lease.sql delete mode 100644 src/Sql/dbo/Pam/Tables/LeaseDecision.sql delete mode 100644 src/Sql/dbo/Pam/Tables/LeaseRequest.sql rename test/Api.Test/Pam/Models/{MemberLeaseResponseModelTests.cs => AccessLeaseResponseModelTests.cs} (66%) rename test/Core.Test/Pam/Commands/{DecideLeaseRequestCommandTests.cs => DecideAccessRequestCommandTests.cs} (62%) rename test/Core.Test/Pam/Commands/{RevokeLeaseCommandTests.cs => RevokeAccessLeaseCommandTests.cs} (65%) rename test/Core.Test/Pam/Commands/{RequestAccessCommandTests.cs => SubmitAccessRequestCommandTests.cs} (59%) delete mode 100644 test/Core.Test/Pam/Engine/AccessPolicyEngineTests.cs create mode 100644 test/Core.Test/Pam/Engine/AccessRuleEngineTests.cs create mode 100644 test/Core.Test/Pam/Models/AccessLeaseStatusNamesTests.cs create mode 100644 test/Core.Test/Pam/Models/AccessRequestStatusNamesTests.cs delete mode 100644 test/Core.Test/Pam/Models/InboxRequestStatusTests.cs delete mode 100644 test/Core.Test/Pam/Models/LeaseStatusNameTests.cs rename test/Core.Test/Pam/Queries/{GetCipherLeaseStateQueryTests.cs => GetCipherAccessStateQueryTests.cs} (66%) rename test/Core.Test/Pam/Queries/{GetInboxHistoryQueryTests.cs => ListInboxHistoryQueryTests.cs} (74%) rename test/Core.Test/Pam/Queries/{GetInboxRequestsQueryTests.cs => ListInboxRequestsQueryTests.cs} (73%) rename test/Core.Test/Pam/Queries/{ListMyActiveLeasesQueryTests.cs => ListMyActiveAccessLeasesQueryTests.cs} (73%) rename test/Core.Test/Pam/Services/{AccessApprovalResolverTests.cs => GoverningRuleResolverTests.cs} (65%) rename test/Infrastructure.IntegrationTest/Pam/Repositories/{LeaseRepositoryTests.cs => AccessLeaseRepositoryTests.cs} (66%) rename test/Infrastructure.IntegrationTest/Pam/Repositories/{LeaseRequestRepositoryTests.cs => AccessRequestRepositoryTests.cs} (59%) create mode 100644 util/Migrator/DbScripts/2026-06-04_00_AddAccessLeaseTables.sql delete mode 100644 util/Migrator/DbScripts/2026-06-04_00_AddLeaseTables.sql create mode 100644 util/Migrator/DbScripts/2026-06-05_01_CreateAccessLeaseOnApproval.sql delete mode 100644 util/Migrator/DbScripts/2026-06-05_01_CreateLeaseOnApproval.sql rename util/Migrator/DbScripts/{2026-06-05_02_AddMemberLeaseReadSprocs.sql => 2026-06-05_02_AddRequesterReadSprocs.sql} (70%) diff --git a/src/Api/Pam/Controllers/ApproverInboxController.cs b/src/Api/Pam/Controllers/ApproverInboxController.cs index 300ac12de48d..bddf4d14df87 100644 --- a/src/Api/Pam/Controllers/ApproverInboxController.cs +++ b/src/Api/Pam/Controllers/ApproverInboxController.cs @@ -16,10 +16,10 @@ namespace Bit.Api.Pam.Controllers; [RequireFeature(FeatureFlagKeys.Pam)] public class ApproverInboxController( IUserService userService, - IGetInboxRequestsQuery getInboxRequestsQuery, - IGetInboxHistoryQuery getInboxHistoryQuery, - IDecideLeaseRequestCommand decideLeaseRequestCommand, - IRevokeLeaseCommand revokeLeaseCommand) + IListInboxRequestsQuery listInboxRequestsQuery, + IListInboxHistoryQuery listInboxHistoryQuery, + IDecideAccessRequestCommand decideAccessRequestCommand, + IRevokeAccessLeaseCommand revokeAccessLeaseCommand) : Controller { /// @@ -27,24 +27,24 @@ public class ApproverInboxController( /// awaiting a decision. /// [HttpGet("inbox/requests")] - public async Task> GetRequests() + public async Task> GetRequests() { var userId = userService.GetProperUserId(User)!.Value; - var requests = await getInboxRequestsQuery.GetPendingAsync(userId); - return new ListResponseModel( - requests.Select(r => new InboxAccessRequestResponseModel(r))); + var requests = await listInboxRequestsQuery.GetPendingAsync(userId); + return new ListResponseModel( + requests.Select(r => new AccessRequestDetailsResponseModel(r))); } /// /// Returns the caller's resolved approver queue (decision history and lease outcomes) within the retention window. /// [HttpGet("inbox/history")] - public async Task> GetHistory() + public async Task> GetHistory() { var userId = userService.GetProperUserId(User)!.Value; - var history = await getInboxHistoryQuery.GetHistoryAsync(userId); - return new ListResponseModel( - history.Select(r => new InboxAccessRequestResponseModel(r))); + var history = await listInboxHistoryQuery.GetHistoryAsync(userId); + return new ListResponseModel( + history.Select(r => new AccessRequestDetailsResponseModel(r))); } /// @@ -52,21 +52,21 @@ public async Task> GetHistory /// not decide their own request. /// [HttpPost("requests/{id:guid}/decision")] - public async Task Decide(Guid id, [FromBody] LeaseDecisionRequestModel model) + public async Task Decide(Guid id, [FromBody] AccessDecisionRequestModel model) { var userId = userService.GetProperUserId(User)!.Value; - var result = await decideLeaseRequestCommand.DecideAsync(userId, id, model.ToSubmission()); - return new InboxAccessRequestResponseModel(result); + var result = await decideAccessRequestCommand.DecideAsync(userId, id, model.ToSubmission()); + return new AccessRequestDetailsResponseModel(result); } /// /// Revokes an active lease early. The caller must be able to Manage the lease's collection. /// [HttpPost("leases/{id:guid}/revoke")] - public async Task Revoke(Guid id, [FromBody] LeaseRevokeRequestModel model) + public async Task Revoke(Guid id, [FromBody] AccessLeaseRevokeRequestModel model) { var userId = userService.GetProperUserId(User)!.Value; - await revokeLeaseCommand.RevokeAsync(userId, id, model.Reason); + await revokeAccessLeaseCommand.RevokeAsync(userId, id, model.Reason); return NoContent(); } } diff --git a/src/Api/Pam/Controllers/CipherLeaseController.cs b/src/Api/Pam/Controllers/CipherLeaseController.cs index 2aea8ab90e1a..7ee7d6ebda04 100644 --- a/src/Api/Pam/Controllers/CipherLeaseController.cs +++ b/src/Api/Pam/Controllers/CipherLeaseController.cs @@ -20,8 +20,8 @@ namespace Bit.Api.Pam.Controllers; public class CipherLeaseController( IUserService userService, IAccessPreCheckQuery preCheckQuery, - IGetCipherLeaseStateQuery cipherLeaseStateQuery, - IRequestAccessCommand requestAccessCommand, + IGetCipherAccessStateQuery cipherAccessStateQuery, + ISubmitAccessRequestCommand submitAccessRequestCommand, IGetLeasedCipherQuery getLeasedCipherQuery, IApplicationCacheService applicationCacheService, ICollectionCipherRepository collectionCipherRepository, @@ -45,11 +45,11 @@ public async Task PreCheck(Guid id) /// if any — powering the cipher-view banner and the vault-row badge. Side-effect free. /// [HttpGet("state")] - public async Task State(Guid id) + public async Task State(Guid id) { var userId = userService.GetProperUserId(User)!.Value; - var result = await cipherLeaseStateQuery.GetStateAsync(userId, id); - return new CipherLeaseStateResponseModel(result); + var result = await cipherAccessStateQuery.GetStateAsync(userId, id); + return new CipherAccessStateResponseModel(result); } /// @@ -57,11 +57,11 @@ public async Task State(Guid id) /// creates a pending request for an approver. /// [HttpPost("")] - public async Task Post(Guid id, [FromBody] AccessRequestModel model) + public async Task Post(Guid id, [FromBody] AccessRequestCreateRequestModel model) { var userId = userService.GetProperUserId(User)!.Value; - var result = await requestAccessCommand.RequestAccessAsync(userId, id, model.ToSubmission()); - return new AccessRequestResponseModel(result); + var result = await submitAccessRequestCommand.SubmitAsync(userId, id, model.ToSubmission()); + return new AccessRequestResultResponseModel(result); } /// diff --git a/src/Api/Pam/Controllers/MemberLeasingController.cs b/src/Api/Pam/Controllers/MemberLeasingController.cs index f71a6d756bec..84c7a17e7114 100644 --- a/src/Api/Pam/Controllers/MemberLeasingController.cs +++ b/src/Api/Pam/Controllers/MemberLeasingController.cs @@ -19,7 +19,7 @@ namespace Bit.Api.Pam.Controllers; public class MemberLeasingController( IUserService userService, IListMyAccessRequestsQuery listMyAccessRequestsQuery, - IListMyActiveLeasesQuery listMyActiveLeasesQuery) + IListMyActiveAccessLeasesQuery listMyActiveAccessLeasesQuery) : Controller { /// @@ -27,21 +27,21 @@ public class MemberLeasingController( /// array. The client re-sorts and splits into pending/recent. /// [HttpGet("requests/mine")] - public async Task> GetMyRequests() + public async Task> GetMyRequests() { var userId = userService.GetProperUserId(User)!.Value; var requests = await listMyAccessRequestsQuery.GetMineAsync(userId); - return requests.Select(r => new InboxAccessRequestResponseModel(r)); + return requests.Select(r => new AccessRequestDetailsResponseModel(r)); } /// /// Returns the caller's currently-active leases across all their organizations as a plain array. /// [HttpGet("leases/mine/active")] - public async Task> GetMyActiveLeases() + public async Task> GetMyActiveLeases() { var userId = userService.GetProperUserId(User)!.Value; - var leases = await listMyActiveLeasesQuery.GetMineActiveAsync(userId); - return leases.Select(l => new MemberLeaseResponseModel(l)); + var leases = await listMyActiveAccessLeasesQuery.GetMineActiveAsync(userId); + return leases.Select(l => new AccessLeaseResponseModel(l)); } } diff --git a/src/Api/Pam/Models/Request/AccessDecisionRequestModel.cs b/src/Api/Pam/Models/Request/AccessDecisionRequestModel.cs new file mode 100644 index 000000000000..b9b3b1bda6fa --- /dev/null +++ b/src/Api/Pam/Models/Request/AccessDecisionRequestModel.cs @@ -0,0 +1,31 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Core.Exceptions; +using Bit.Core.Pam.Enums; +using Bit.Core.Pam.Models; + +namespace Bit.Api.Pam.Models.Request; + +/// +/// An approver's decision on a pending access request. is "approve" or "deny"; +/// is optional. +/// +public class AccessDecisionRequestModel +{ + [Required] + public string Verdict { get; set; } = null!; + + public string? Comment { get; set; } + + public AccessDecisionSubmission ToSubmission() => new() + { + Verdict = ParseVerdict(Verdict), + Comment = Comment, + }; + + private static AccessDecisionVerdict ParseVerdict(string verdict) => verdict?.ToLowerInvariant() switch + { + "approve" => AccessDecisionVerdict.Approve, + "deny" => AccessDecisionVerdict.Deny, + _ => throw new BadRequestException("Verdict must be either 'approve' or 'deny'."), + }; +} diff --git a/src/Api/Pam/Models/Request/LeaseRevokeRequestModel.cs b/src/Api/Pam/Models/Request/AccessLeaseRevokeRequestModel.cs similarity index 84% rename from src/Api/Pam/Models/Request/LeaseRevokeRequestModel.cs rename to src/Api/Pam/Models/Request/AccessLeaseRevokeRequestModel.cs index 2fbd79e63260..6f63123255c3 100644 --- a/src/Api/Pam/Models/Request/LeaseRevokeRequestModel.cs +++ b/src/Api/Pam/Models/Request/AccessLeaseRevokeRequestModel.cs @@ -3,7 +3,7 @@ /// /// A request to revoke an active lease early. is optional and retained for the audit trail. /// -public class LeaseRevokeRequestModel +public class AccessLeaseRevokeRequestModel { public string? Reason { get; set; } } diff --git a/src/Api/Pam/Models/Request/AccessRequestModel.cs b/src/Api/Pam/Models/Request/AccessRequestCreateRequestModel.cs similarity index 94% rename from src/Api/Pam/Models/Request/AccessRequestModel.cs rename to src/Api/Pam/Models/Request/AccessRequestCreateRequestModel.cs index f6aac131acef..631a4f3149ea 100644 --- a/src/Api/Pam/Models/Request/AccessRequestModel.cs +++ b/src/Api/Pam/Models/Request/AccessRequestCreateRequestModel.cs @@ -7,7 +7,7 @@ namespace Bit.Api.Pam.Models.Request; /// / + for the human path. The server validates the shape /// against the cipher's resolved approval outcome (run a pre-check first). The cipher is identified by the route. /// -public class AccessRequestModel +public class AccessRequestCreateRequestModel { public int? DurationSeconds { get; set; } diff --git a/src/Api/Pam/Models/Request/AccessRuleRequestModel.cs b/src/Api/Pam/Models/Request/AccessRuleRequestModel.cs index 4dc79c598c8d..ac23ad10486a 100644 --- a/src/Api/Pam/Models/Request/AccessRuleRequestModel.cs +++ b/src/Api/Pam/Models/Request/AccessRuleRequestModel.cs @@ -13,7 +13,7 @@ public class AccessRuleRequestModel public string? Description { get; set; } [Required] - public object Rule { get; set; } = null!; + public object Conditions { get; set; } = null!; /// /// The complete set of collections this rule governs. The rule's associations are replaced to match @@ -27,13 +27,13 @@ public class AccessRuleRequestModel OrganizationId = organizationId, Name = Name, Description = Description, - Rule = SerializeRule(Rule), + Conditions = SerializeConditions(Conditions), }; - private static string SerializeRule(object rule) => rule switch + private static string SerializeConditions(object conditions) => conditions switch { JsonElement je when je.ValueKind == JsonValueKind.Null => string.Empty, JsonElement je => je.GetRawText(), - _ => JsonSerializer.Serialize(rule), + _ => JsonSerializer.Serialize(conditions), }; } diff --git a/src/Api/Pam/Models/Request/LeaseDecisionRequestModel.cs b/src/Api/Pam/Models/Request/LeaseDecisionRequestModel.cs deleted file mode 100644 index 957b2a5800cb..000000000000 --- a/src/Api/Pam/Models/Request/LeaseDecisionRequestModel.cs +++ /dev/null @@ -1,31 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using Bit.Core.Exceptions; -using Bit.Core.Pam.Enums; -using Bit.Core.Pam.Models; - -namespace Bit.Api.Pam.Models.Request; - -/// -/// An approver's decision on a pending lease request. is "approve" or "deny"; -/// is optional. -/// -public class LeaseDecisionRequestModel -{ - [Required] - public string Decision { get; set; } = null!; - - public string? Comment { get; set; } - - public LeaseDecisionSubmission ToSubmission() => new() - { - Verdict = ParseVerdict(Decision), - Comment = Comment, - }; - - private static LeaseDecisionVerdict ParseVerdict(string decision) => decision?.ToLowerInvariant() switch - { - "approve" => LeaseDecisionVerdict.Approve, - "deny" => LeaseDecisionVerdict.Deny, - _ => throw new BadRequestException("Decision must be either 'approve' or 'deny'."), - }; -} diff --git a/src/Api/Pam/Models/Response/MemberLeaseResponseModel.cs b/src/Api/Pam/Models/Response/AccessLeaseResponseModel.cs similarity index 63% rename from src/Api/Pam/Models/Response/MemberLeaseResponseModel.cs rename to src/Api/Pam/Models/Response/AccessLeaseResponseModel.cs index 5a031691ef25..8a619d245ab9 100644 --- a/src/Api/Pam/Models/Response/MemberLeaseResponseModel.cs +++ b/src/Api/Pam/Models/Response/AccessLeaseResponseModel.cs @@ -1,29 +1,29 @@ -using Bit.Core.Models.Api; +using Bit.Core.Models.Api; using Bit.Core.Pam.Entities; using Bit.Core.Pam.Models; namespace Bit.Api.Pam.Models.Response; /// -/// A lease as its grantee sees it. Matches the client's LeaseResponse shape — a richer view than the minimal -/// returned by the request flow, with the originating request, grantee, string status -/// vocabulary, and revocation fields. Powers the caller-scoped "my active leases" surface and the cipher-lease-state -/// snapshot. Fields without a backing store in v1 (, ) are null. +/// An access lease as its requester sees it: the originating request, string status vocabulary, and revocation +/// fields. Powers the request-submission envelope, the caller-scoped "my active leases" surface, and the cipher +/// access-state snapshot. Fields without a backing store in v1 (, +/// ) are null. /// -public class MemberLeaseResponseModel : ResponseModel +public class AccessLeaseResponseModel : ResponseModel { - public MemberLeaseResponseModel(Lease lease) - : base("lease") + public AccessLeaseResponseModel(AccessLease lease) + : base("accessLease") { ArgumentNullException.ThrowIfNull(lease); Id = lease.Id; - RequestId = lease.LeaseRequestId; + RequestId = lease.AccessRequestId; CipherId = lease.CipherId; CollectionId = lease.CollectionId; OrganizationId = lease.OrganizationId; - GranteeUserId = lease.RequesterId; - Status = LeaseStatusName.From(lease.Status); + RequesterId = lease.RequesterId; + Status = AccessLeaseStatusNames.From(lease.Status); NotBefore = lease.NotBefore; NotAfter = lease.NotAfter; RevokedAt = lease.RevokedDate; @@ -44,7 +44,7 @@ public MemberLeaseResponseModel(Lease lease) public Guid OrganizationId { get; } /// The user the lease was granted to (the original requester). - public Guid GranteeUserId { get; } + public Guid RequesterId { get; } /// active | expired | revoked. public string Status { get; } diff --git a/src/Api/Pam/Models/Response/AccessPreCheckResponseModel.cs b/src/Api/Pam/Models/Response/AccessPreCheckResponseModel.cs index 5a8c69c1cce4..672fdaba5905 100644 --- a/src/Api/Pam/Models/Response/AccessPreCheckResponseModel.cs +++ b/src/Api/Pam/Models/Response/AccessPreCheckResponseModel.cs @@ -10,16 +10,19 @@ public AccessPreCheckResponseModel(Guid cipherId, AccessPreCheckResult result) : base("accessPreCheck") { CipherId = cipherId; - Outcome = result.HasActiveLease - ? "active" - : result.Outcome == AccessApprovalOutcome.Human ? "human" : "automatic"; + ApprovalMode = result.ApprovalMode == AccessApprovalMode.Human ? "human" : "automatic"; + HasActiveLease = result.HasActiveLease; } public Guid CipherId { get; } /// - /// "active" when the caller already holds an active lease (reveal the credential, no request needed), /// "automatic" when a request would be approved immediately, "human" when it needs an approver. /// - public string Outcome { get; } + public string ApprovalMode { get; } + + /// + /// True when the caller already holds an active lease: reveal the credential, no request needed. + /// + public bool HasActiveLease { get; } } diff --git a/src/Api/Pam/Models/Response/InboxAccessRequestResponseModel.cs b/src/Api/Pam/Models/Response/AccessRequestDetailsResponseModel.cs similarity index 58% rename from src/Api/Pam/Models/Response/InboxAccessRequestResponseModel.cs rename to src/Api/Pam/Models/Response/AccessRequestDetailsResponseModel.cs index 6bab0247b8a7..1ba2fe91d4ba 100644 --- a/src/Api/Pam/Models/Response/InboxAccessRequestResponseModel.cs +++ b/src/Api/Pam/Models/Response/AccessRequestDetailsResponseModel.cs @@ -1,17 +1,17 @@ -using Bit.Core.Models.Api; +using Bit.Core.Models.Api; using Bit.Core.Pam.Models; namespace Bit.Api.Pam.Models.Response; /// -/// An approver-inbox row: the access request plus the denormalized display fields the client renders. Matches the -/// client's InboxAccessRequestResponse shape. Fields without a backing store in v1 (, -/// , ) are always null. +/// An access request with its denormalized display fields (cipher/collection names, requester identity), serving the +/// approver inbox, the caller's own request list, and the cipher access-state snapshot. Fields without a backing +/// store in v1 (, , ) are always null. /// -public class InboxAccessRequestResponseModel : ResponseModel +public class AccessRequestDetailsResponseModel : ResponseModel { - public InboxAccessRequestResponseModel(InboxLeaseRequestDetails details) - : base("inboxAccessRequest") + public AccessRequestDetailsResponseModel(AccessRequestDetails details) + : base("accessRequestDetails") { ArgumentNullException.ThrowIfNull(details); @@ -19,17 +19,17 @@ public InboxAccessRequestResponseModel(InboxLeaseRequestDetails details) CipherId = details.CipherId; CollectionId = details.CollectionId; OrganizationId = details.OrganizationId; - RequesterUserId = details.RequesterId; - Status = InboxRequestStatus.From(details.Status, details.ProducedLeaseId.HasValue); + RequesterId = details.RequesterId; + Status = AccessRequestStatusNames.From(details.Status, details.ProducedLeaseId.HasValue); RequestedNotBefore = details.NotBefore; RequestedNotAfter = details.NotAfter; RequestedTtlSeconds = (int)(details.NotAfter - details.NotBefore).TotalSeconds; Reason = details.Reason; SubmittedAt = details.CreationDate; ResolvedAt = details.ResolvedDate; - ResolverUserId = details.ResolverId; - ResolverComment = details.ResolverComment; - LeaseId = details.ProducedLeaseId; + ApproverId = details.ApproverId; + ApproverComment = details.ApproverComment; + ProducedLeaseId = details.ProducedLeaseId; ExtensionOfLeaseId = details.ExtensionOfLeaseId; CipherName = details.CipherName; CollectionName = details.CollectionName; @@ -45,7 +45,7 @@ public InboxAccessRequestResponseModel(InboxLeaseRequestDetails details) public string? RuleId => null; public Guid OrganizationId { get; } - public Guid RequesterUserId { get; } + public Guid RequesterId { get; } /// pending | approved | activated | denied | cancelled | expired. public string Status { get; } @@ -57,20 +57,22 @@ public InboxAccessRequestResponseModel(InboxLeaseRequestDetails details) public DateTime SubmittedAt { get; } public DateTime? ResolvedAt { get; } - /// Distinct from ; set when a ticket lapses. Not tracked in v1. + /// Distinct from ; set when an approved request lapses unactivated. Not tracked in v1. public DateTime? ExpiredAt => null; - public Guid? ResolverUserId { get; } - public string? ResolverComment { get; } + /// The human approver who decided the request, or null (e.g. still pending or decided automatically). + public Guid? ApproverId { get; } - /// Set once an approved ticket has produced a lease. - public Guid? LeaseId { get; } + public string? ApproverComment { get; } + + /// Set once an approved request has produced a lease. + public Guid? ProducedLeaseId { get; } /// The parent lease if this is an extension request. public Guid? ExtensionOfLeaseId { get; } - /// Only meaningful for approved on-demand tickets. Belongs to the out-of-scope redemption flow. - public DateTime? RedemptionDeadline => null; + /// Only meaningful for approved on-demand requests. Belongs to the out-of-scope activation flow. + public DateTime? ActivationDeadline => null; /// The cipher's client-encrypted name. The only cipher attribute exposed by the inbox. public string? CipherName { get; } diff --git a/src/Api/Pam/Models/Response/AccessRequestResponseModel.cs b/src/Api/Pam/Models/Response/AccessRequestResponseModel.cs index 0eb3950329ab..ca7f7cca298c 100644 --- a/src/Api/Pam/Models/Response/AccessRequestResponseModel.cs +++ b/src/Api/Pam/Models/Response/AccessRequestResponseModel.cs @@ -1,27 +1,37 @@ using Bit.Core.Models.Api; -using Bit.Core.Pam.Enums; +using Bit.Core.Pam.Entities; using Bit.Core.Pam.Models; namespace Bit.Api.Pam.Models.Response; public class AccessRequestResponseModel : ResponseModel { - public AccessRequestResponseModel(AccessRequestResult result) + public AccessRequestResponseModel(AccessRequest request) : base("accessRequest") { - ArgumentNullException.ThrowIfNull(result); + ArgumentNullException.ThrowIfNull(request); - Outcome = result.Outcome == AccessApprovalOutcome.Human ? "human" : "automatic"; - Lease = result.Lease is null ? null : new LeaseResponseModel(result.Lease); - Request = result.Request is null ? null : new LeaseRequestResponseModel(result.Request); + Id = request.Id; + CipherId = request.CipherId; + CollectionId = request.CollectionId; + OrganizationId = request.OrganizationId; + Status = AccessRequestStatusNames.From(request.Status, hasLease: false); + NotBefore = request.NotBefore; + NotAfter = request.NotAfter; + Reason = request.Reason; + CreationDate = request.CreationDate; } - /// - /// "automatic" when a was issued immediately, "human" when a pending - /// was created. - /// - public string Outcome { get; } + public Guid Id { get; } + public Guid CipherId { get; } + public Guid CollectionId { get; } + public Guid OrganizationId { get; } - public LeaseResponseModel? Lease { get; } - public LeaseRequestResponseModel? Request { get; } + /// pending | approved | activated | denied | cancelled | expired. + public string Status { get; } + + public DateTime NotBefore { get; } + public DateTime NotAfter { get; } + public string? Reason { get; } + public DateTime CreationDate { get; } } diff --git a/src/Api/Pam/Models/Response/AccessRequestResultResponseModel.cs b/src/Api/Pam/Models/Response/AccessRequestResultResponseModel.cs new file mode 100644 index 000000000000..5fb3be619294 --- /dev/null +++ b/src/Api/Pam/Models/Response/AccessRequestResultResponseModel.cs @@ -0,0 +1,27 @@ +using Bit.Core.Models.Api; +using Bit.Core.Pam.Enums; +using Bit.Core.Pam.Models; + +namespace Bit.Api.Pam.Models.Response; + +public class AccessRequestResultResponseModel : ResponseModel +{ + public AccessRequestResultResponseModel(AccessRequestResult result) + : base("accessRequestResult") + { + ArgumentNullException.ThrowIfNull(result); + + ApprovalMode = result.ApprovalMode == AccessApprovalMode.Human ? "human" : "automatic"; + Lease = result.Lease is null ? null : new AccessLeaseResponseModel(result.Lease); + Request = result.Request is null ? null : new AccessRequestResponseModel(result.Request); + } + + /// + /// "automatic" when a was issued immediately, "human" when a pending + /// was created. + /// + public string ApprovalMode { get; } + + public AccessLeaseResponseModel? Lease { get; } + public AccessRequestResponseModel? Request { get; } +} diff --git a/src/Api/Pam/Models/Response/AccessRuleResponseModel.cs b/src/Api/Pam/Models/Response/AccessRuleResponseModel.cs index 8ba3d83a2b41..420da672aa47 100644 --- a/src/Api/Pam/Models/Response/AccessRuleResponseModel.cs +++ b/src/Api/Pam/Models/Response/AccessRuleResponseModel.cs @@ -15,7 +15,7 @@ public AccessRuleResponseModel(AccessRuleDetails rule) OrganizationId = rule.OrganizationId; Name = rule.Name; Description = rule.Description; - Rule = TryParseRule(rule.Rule); + Conditions = TryParseConditions(rule.Conditions); CreationDate = rule.CreationDate; RevisionDate = rule.RevisionDate; Collections = rule.CollectionIds.ToList(); @@ -25,20 +25,20 @@ public AccessRuleResponseModel(AccessRuleDetails rule) public Guid OrganizationId { get; } public string Name { get; } public string? Description { get; } - public JsonElement? Rule { get; } + public JsonElement? Conditions { get; } public DateTime CreationDate { get; } public DateTime RevisionDate { get; } public IEnumerable Collections { get; } - private static JsonElement? TryParseRule(string? ruleJson) + private static JsonElement? TryParseConditions(string? conditionsJson) { - if (string.IsNullOrEmpty(ruleJson)) + if (string.IsNullOrEmpty(conditionsJson)) { return null; } try { - return JsonDocument.Parse(ruleJson).RootElement; + return JsonDocument.Parse(conditionsJson).RootElement; } catch (JsonException) { diff --git a/src/Api/Pam/Models/Response/CipherAccessStateResponseModel.cs b/src/Api/Pam/Models/Response/CipherAccessStateResponseModel.cs new file mode 100644 index 000000000000..dcc9e6abf0ee --- /dev/null +++ b/src/Api/Pam/Models/Response/CipherAccessStateResponseModel.cs @@ -0,0 +1,30 @@ +using Bit.Core.Models.Api; +using Bit.Core.Pam.Models; + +namespace Bit.Api.Pam.Models.Response; + +/// +/// A single-snapshot read of the caller's access state for one cipher, powering the cipher-view banner and the +/// vault-row badge. is always null in v0: approval mints an active lease immediately, +/// so there is no approved-but-not-yet-activated request. The active lease and pending request carry the real state. +/// +public class CipherAccessStateResponseModel : ResponseModel +{ + public CipherAccessStateResponseModel(CipherAccessState state) + : base("cipherAccessState") + { + ArgumentNullException.ThrowIfNull(state); + + CipherId = state.CipherId; + ActiveLease = state.ActiveLease is null ? null : new AccessLeaseResponseModel(state.ActiveLease); + PendingRequest = state.PendingRequest is null ? null : new AccessRequestDetailsResponseModel(state.PendingRequest); + } + + public Guid CipherId { get; } + + public AccessLeaseResponseModel? ActiveLease { get; } + public AccessRequestDetailsResponseModel? PendingRequest { get; } + + /// An approved request awaiting activation. Always null in v0 — approval mints the lease immediately. + public AccessRequestDetailsResponseModel? ApprovedRequest => null; +} diff --git a/src/Api/Pam/Models/Response/CipherLeaseStateResponseModel.cs b/src/Api/Pam/Models/Response/CipherLeaseStateResponseModel.cs deleted file mode 100644 index 357688a8bdf6..000000000000 --- a/src/Api/Pam/Models/Response/CipherLeaseStateResponseModel.cs +++ /dev/null @@ -1,37 +0,0 @@ -using Bit.Core.Models.Api; -using Bit.Core.Pam.Models; - -namespace Bit.Api.Pam.Models.Response; - -/// -/// A single-snapshot read of the caller's lease state for one cipher, powering the cipher-view banner and the -/// vault-row badge. is always null in v0: approval mints an active -/// lease immediately, so there is no approved-but-unredeemed ticket to redeem. The active lease and pending request -/// carry the real state. -/// -public class CipherLeaseStateResponseModel : ResponseModel -{ - public CipherLeaseStateResponseModel(CipherLeaseStateResult result) - : base("cipherLeaseState") - { - ArgumentNullException.ThrowIfNull(result); - - CipherId = result.CipherId; - Lease = new CipherLeaseSnapshot - { - ActiveLease = result.ActiveLease is null ? null : new MemberLeaseResponseModel(result.ActiveLease), - PendingRequest = result.PendingRequest is null ? null : new InboxAccessRequestResponseModel(result.PendingRequest), - ApprovedTicket = null, - }; - } - - public Guid CipherId { get; } - public CipherLeaseSnapshot Lease { get; } - - public class CipherLeaseSnapshot - { - public MemberLeaseResponseModel? ActiveLease { get; init; } - public InboxAccessRequestResponseModel? PendingRequest { get; init; } - public InboxAccessRequestResponseModel? ApprovedTicket { get; init; } - } -} diff --git a/src/Api/Pam/Models/Response/LeaseRequestResponseModel.cs b/src/Api/Pam/Models/Response/LeaseRequestResponseModel.cs deleted file mode 100644 index 2366b4dca40c..000000000000 --- a/src/Api/Pam/Models/Response/LeaseRequestResponseModel.cs +++ /dev/null @@ -1,33 +0,0 @@ -using Bit.Core.Models.Api; -using Bit.Core.Pam.Entities; - -namespace Bit.Api.Pam.Models.Response; - -public class LeaseRequestResponseModel : ResponseModel -{ - public LeaseRequestResponseModel(LeaseRequest request) - : base("leaseRequest") - { - ArgumentNullException.ThrowIfNull(request); - - Id = request.Id; - CipherId = request.CipherId; - CollectionId = request.CollectionId; - OrganizationId = request.OrganizationId; - Status = request.Status; - NotBefore = request.NotBefore; - NotAfter = request.NotAfter; - Reason = request.Reason; - CreationDate = request.CreationDate; - } - - public Guid Id { get; } - public Guid CipherId { get; } - public Guid CollectionId { get; } - public Guid OrganizationId { get; } - public Core.Pam.Enums.LeaseRequestStatus Status { get; } - public DateTime NotBefore { get; } - public DateTime NotAfter { get; } - public string? Reason { get; } - public DateTime CreationDate { get; } -} diff --git a/src/Api/Pam/Models/Response/LeaseResponseModel.cs b/src/Api/Pam/Models/Response/LeaseResponseModel.cs deleted file mode 100644 index f95dd8195f29..000000000000 --- a/src/Api/Pam/Models/Response/LeaseResponseModel.cs +++ /dev/null @@ -1,29 +0,0 @@ -using Bit.Core.Models.Api; -using Bit.Core.Pam.Entities; - -namespace Bit.Api.Pam.Models.Response; - -public class LeaseResponseModel : ResponseModel -{ - public LeaseResponseModel(Lease lease) - : base("lease") - { - ArgumentNullException.ThrowIfNull(lease); - - Id = lease.Id; - CipherId = lease.CipherId; - CollectionId = lease.CollectionId; - OrganizationId = lease.OrganizationId; - Status = lease.Status; - NotBefore = lease.NotBefore; - NotAfter = lease.NotAfter; - } - - public Guid Id { get; } - public Guid CipherId { get; } - public Guid CollectionId { get; } - public Guid OrganizationId { get; } - public Core.Pam.Enums.LeaseStatus Status { get; } - public DateTime NotBefore { get; } - public DateTime NotAfter { get; } -} diff --git a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs index d7fa48d4b545..b03ed093aa9d 100644 --- a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs +++ b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs @@ -73,7 +73,7 @@ public static void AddOrganizationServices(this IServiceCollection services, IGl services.AddOrganizationSponsorshipCommands(globalSettings); services.AddOrganizationApiKeyCommandsQueries(); services.AddOrganizationCollectionCommands(); - services.AddAccessRuleCommands(); + services.AddPamServices(); services.AddOrganizationGroupCommands(); services.AddOrganizationInviteLinkCommandsQueries(); services.AddOrganizationDomainCommandsQueries(); @@ -194,26 +194,26 @@ public static void AddOrganizationCollectionCommands(this IServiceCollection ser services.AddScoped(); } - public static void AddAccessRuleCommands(this IServiceCollection services) + public static void AddPamServices(this IServiceCollection services) { services.AddSingleton(); services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddScoped(); - services.AddSingleton(); + services.AddScoped(); + services.AddSingleton(); 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(); services.AddScoped(); - services.AddScoped(); + services.AddScoped(); } private static void AddOrganizationGroupCommands(this IServiceCollection services) diff --git a/src/Core/Pam/Engine/AccessDecision.cs b/src/Core/Pam/Engine/AccessDecision.cs deleted file mode 100644 index a907636a7627..000000000000 --- a/src/Core/Pam/Engine/AccessDecision.cs +++ /dev/null @@ -1,49 +0,0 @@ -namespace Bit.Core.Pam.Engine; - -public enum DecisionKind -{ - Allow, - RequiresApproval, - Deny, -} - -public enum DenyReason -{ - None = 0, - NotWithinIpRange, - NotWithinTimeWindow, - UnsupportedRule, -} - -public sealed record AccessDecision -{ - public required DecisionKind Kind { get; init; } - public DenyReason Reason { get; init; } = DenyReason.None; - - public static AccessDecision Allow { get; } = new() { Kind = DecisionKind.Allow }; - public static AccessDecision RequiresApproval { get; } = new() { Kind = DecisionKind.RequiresApproval }; - - public static AccessDecision Deny(DenyReason reason) => new() - { - Kind = DecisionKind.Deny, - Reason = reason - }; - - public static AccessDecision Combine(IEnumerable decisions) - { - var requiresApproval = false; - foreach (var decision in decisions) - { - switch (decision.Kind) - { - case DecisionKind.Deny: - return decision; - case DecisionKind.RequiresApproval: - requiresApproval = true; - break; - } - } - - return requiresApproval ? RequiresApproval : Allow; - } -} diff --git a/src/Core/Pam/Engine/AccessEvaluation.cs b/src/Core/Pam/Engine/AccessEvaluation.cs new file mode 100644 index 000000000000..b613da14e032 --- /dev/null +++ b/src/Core/Pam/Engine/AccessEvaluation.cs @@ -0,0 +1,49 @@ +namespace Bit.Core.Pam.Engine; + +public enum AccessEvaluationOutcome +{ + Allow, + RequiresApproval, + Deny, +} + +public enum DenyReason +{ + None = 0, + NotWithinIpRange, + NotWithinTimeWindow, + UnsupportedCondition, +} + +public sealed record AccessEvaluation +{ + public required AccessEvaluationOutcome Outcome { get; init; } + public DenyReason Reason { get; init; } = DenyReason.None; + + public static AccessEvaluation Allow { get; } = new() { Outcome = AccessEvaluationOutcome.Allow }; + public static AccessEvaluation RequiresApproval { get; } = new() { Outcome = AccessEvaluationOutcome.RequiresApproval }; + + public static AccessEvaluation Deny(DenyReason reason) => new() + { + Outcome = AccessEvaluationOutcome.Deny, + Reason = reason + }; + + public static AccessEvaluation Combine(IEnumerable evaluations) + { + var requiresApproval = false; + foreach (var evaluation in evaluations) + { + switch (evaluation.Outcome) + { + case AccessEvaluationOutcome.Deny: + return evaluation; + case AccessEvaluationOutcome.RequiresApproval: + requiresApproval = true; + break; + } + } + + return requiresApproval ? RequiresApproval : Allow; + } +} diff --git a/src/Core/Pam/Engine/AccessPolicyEngine.cs b/src/Core/Pam/Engine/AccessPolicyEngine.cs deleted file mode 100644 index bfcb8519a4c7..000000000000 --- a/src/Core/Pam/Engine/AccessPolicyEngine.cs +++ /dev/null @@ -1,90 +0,0 @@ -using System.Globalization; -using System.Net; -using Bit.Core.Pam.Models.Rules; - -namespace Bit.Core.Pam.Engine; - -/// -/// Recursively evaluates the polymorphic tree against the caller's signals. Each leaf rule -/// yields an ; combines its children with deny taking -/// precedence over a pending approval, which in turn takes precedence over allow. Unparseable inputs fail closed. -/// -public sealed class AccessPolicyEngine : IAccessPolicyEngine -{ - // The rule JSON encodes days as the lowercase three-letter abbreviations the validator accepts. - private static readonly IReadOnlyDictionary _days = new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["sun"] = DayOfWeek.Sunday, - ["mon"] = DayOfWeek.Monday, - ["tue"] = DayOfWeek.Tuesday, - ["wed"] = DayOfWeek.Wednesday, - ["thu"] = DayOfWeek.Thursday, - ["fri"] = DayOfWeek.Friday, - ["sat"] = DayOfWeek.Saturday, - }; - - public AccessDecision Evaluate(Rule rule, AccessPolicySignals signals) => rule switch - { - HumanApprovalRule => AccessDecision.RequiresApproval, - IpAllowlistRule ip => EvaluateIpAllowlist(ip, signals), - TimeOfDayRule time => EvaluateTimeOfDay(time, signals), - AllOfRule all => AccessDecision.Combine(all.Rules.Select(child => Evaluate(child, signals))), - // A rule kind the engine does not understand cannot be shown to be satisfied, so deny. - _ => AccessDecision.Deny(DenyReason.UnsupportedRule), - }; - - private static AccessDecision EvaluateIpAllowlist(IpAllowlistRule rule, AccessPolicySignals signals) - { - // An allowlist with no entries permits no address; combined with an unknown caller IP, both fail closed. - if (rule.Cidrs.Count == 0 || signals.IpAddress is null) - { - return AccessDecision.Deny(DenyReason.NotWithinIpRange); - } - - foreach (var cidr in rule.Cidrs) - { - if (IPNetwork.TryParse(cidr, out var network) && network.Contains(signals.IpAddress)) - { - return AccessDecision.Allow; - } - } - - return AccessDecision.Deny(DenyReason.NotWithinIpRange); - } - - private static AccessDecision EvaluateTimeOfDay(TimeOfDayRule rule, AccessPolicySignals signals) - { - if (!TimeZoneInfo.TryFindSystemTimeZoneById(rule.Tz, out var timeZone)) - { - // The window cannot be evaluated without a valid timezone, so fail closed. - return AccessDecision.Deny(DenyReason.NotWithinTimeWindow); - } - - var local = TimeZoneInfo.ConvertTime(signals.Timestamp, timeZone); - var day = local.DayOfWeek; - var time = TimeOnly.FromTimeSpan(local.TimeOfDay); - - foreach (var window in rule.Windows) - { - if (WindowContains(window, day, time)) - { - return AccessDecision.Allow; - } - } - - return AccessDecision.Deny(DenyReason.NotWithinTimeWindow); - } - - private static bool WindowContains(TimeWindow window, DayOfWeek day, TimeOnly time) - { - var dayMatches = window.Days.Any(d => _days.TryGetValue(d, out var parsed) && parsed == day); - if (!dayMatches) - { - return false; - } - - return TimeOnly.TryParseExact(window.From, "HH:mm", CultureInfo.InvariantCulture, DateTimeStyles.None, out var from) - && TimeOnly.TryParseExact(window.To, "HH:mm", CultureInfo.InvariantCulture, DateTimeStyles.None, out var to) - && time >= from && time <= to; - } -} diff --git a/src/Core/Pam/Engine/AccessRuleEngine.cs b/src/Core/Pam/Engine/AccessRuleEngine.cs new file mode 100644 index 000000000000..f22b8d8130c7 --- /dev/null +++ b/src/Core/Pam/Engine/AccessRuleEngine.cs @@ -0,0 +1,91 @@ +using System.Globalization; +using System.Net; +using Bit.Core.Pam.Models.Conditions; + +namespace Bit.Core.Pam.Engine; + +/// +/// Recursively evaluates the polymorphic tree against the caller's signals. Each leaf +/// condition yields an ; combines its children with deny +/// taking precedence over a pending approval, which in turn takes precedence over allow. Unparseable inputs fail +/// closed. +/// +public sealed class AccessRuleEngine : IAccessRuleEngine +{ + // The conditions JSON encodes days as the lowercase three-letter abbreviations the validator accepts. + private static readonly IReadOnlyDictionary _days = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["sun"] = DayOfWeek.Sunday, + ["mon"] = DayOfWeek.Monday, + ["tue"] = DayOfWeek.Tuesday, + ["wed"] = DayOfWeek.Wednesday, + ["thu"] = DayOfWeek.Thursday, + ["fri"] = DayOfWeek.Friday, + ["sat"] = DayOfWeek.Saturday, + }; + + public AccessEvaluation Evaluate(AccessCondition condition, AccessSignals signals) => condition switch + { + HumanApprovalCondition => AccessEvaluation.RequiresApproval, + IpAllowlistCondition ip => EvaluateIpAllowlist(ip, signals), + TimeOfDayCondition time => EvaluateTimeOfDay(time, signals), + AllOfCondition all => AccessEvaluation.Combine(all.Conditions.Select(child => Evaluate(child, signals))), + // A condition kind the engine does not understand cannot be shown to be satisfied, so deny. + _ => AccessEvaluation.Deny(DenyReason.UnsupportedCondition), + }; + + private static AccessEvaluation EvaluateIpAllowlist(IpAllowlistCondition condition, AccessSignals signals) + { + // An allowlist with no entries permits no address; combined with an unknown caller IP, both fail closed. + if (condition.Cidrs.Count == 0 || signals.IpAddress is null) + { + return AccessEvaluation.Deny(DenyReason.NotWithinIpRange); + } + + foreach (var cidr in condition.Cidrs) + { + if (IPNetwork.TryParse(cidr, out var network) && network.Contains(signals.IpAddress)) + { + return AccessEvaluation.Allow; + } + } + + return AccessEvaluation.Deny(DenyReason.NotWithinIpRange); + } + + private static AccessEvaluation EvaluateTimeOfDay(TimeOfDayCondition condition, AccessSignals signals) + { + if (!TimeZoneInfo.TryFindSystemTimeZoneById(condition.Tz, out var timeZone)) + { + // The window cannot be evaluated without a valid timezone, so fail closed. + return AccessEvaluation.Deny(DenyReason.NotWithinTimeWindow); + } + + var local = TimeZoneInfo.ConvertTime(signals.Timestamp, timeZone); + var day = local.DayOfWeek; + var time = TimeOnly.FromTimeSpan(local.TimeOfDay); + + foreach (var window in condition.Windows) + { + if (WindowContains(window, day, time)) + { + return AccessEvaluation.Allow; + } + } + + return AccessEvaluation.Deny(DenyReason.NotWithinTimeWindow); + } + + private static bool WindowContains(TimeWindow window, DayOfWeek day, TimeOnly time) + { + var dayMatches = window.Days.Any(d => _days.TryGetValue(d, out var parsed) && parsed == day); + if (!dayMatches) + { + return false; + } + + return TimeOnly.TryParseExact(window.From, "HH:mm", CultureInfo.InvariantCulture, DateTimeStyles.None, out var from) + && TimeOnly.TryParseExact(window.To, "HH:mm", CultureInfo.InvariantCulture, DateTimeStyles.None, out var to) + && time >= from && time <= to; + } +} diff --git a/src/Core/Pam/Engine/AccessPolicySignals.cs b/src/Core/Pam/Engine/AccessSignals.cs similarity index 92% rename from src/Core/Pam/Engine/AccessPolicySignals.cs rename to src/Core/Pam/Engine/AccessSignals.cs index 2872ec598964..f19bb185c02c 100644 --- a/src/Core/Pam/Engine/AccessPolicySignals.cs +++ b/src/Core/Pam/Engine/AccessSignals.cs @@ -7,7 +7,7 @@ namespace Bit.Core.Pam.Engine; /// evaluation is performed. is null when the caller's address cannot be determined, which /// IP-restricted rules treat as a denial so access never opens up on a missing signal. /// -public sealed record AccessPolicySignals +public sealed record AccessSignals { public required IPAddress? IpAddress { get; init; } public required DateTimeOffset Timestamp { get; init; } diff --git a/src/Core/Pam/Engine/IAccessPolicyEngine.cs b/src/Core/Pam/Engine/IAccessPolicyEngine.cs deleted file mode 100644 index 42c21ab72bae..000000000000 --- a/src/Core/Pam/Engine/IAccessPolicyEngine.cs +++ /dev/null @@ -1,14 +0,0 @@ -using Bit.Core.Pam.Models.Rules; - -namespace Bit.Core.Pam.Engine; - -/// -/// Evaluates the structured access that governs a cipher against the request-time -/// , deciding whether access is allowed, denied, or gated on human approval. -/// The engine is pure: it reads no state and issues no leases. Lease lifecycle is owned by the lease commands and -/// queries, which call the engine to decide whether a lease may be issued or its data handed over. -/// -public interface IAccessPolicyEngine -{ - AccessDecision Evaluate(Rule rule, AccessPolicySignals signals); -} diff --git a/src/Core/Pam/Engine/IAccessRuleEngine.cs b/src/Core/Pam/Engine/IAccessRuleEngine.cs new file mode 100644 index 000000000000..d256afe1a45f --- /dev/null +++ b/src/Core/Pam/Engine/IAccessRuleEngine.cs @@ -0,0 +1,14 @@ +using Bit.Core.Pam.Models.Conditions; + +namespace Bit.Core.Pam.Engine; + +/// +/// Evaluates an access rule's tree against the request-time +/// , deciding whether access is allowed, denied, or gated on human approval. +/// The engine is pure: it reads no state and issues no leases. Lease lifecycle is owned by the lease commands and +/// queries, which call the engine to decide whether a lease may be issued or its data handed over. +/// +public interface IAccessRuleEngine +{ + AccessEvaluation Evaluate(AccessCondition condition, AccessSignals signals); +} diff --git a/src/Core/Pam/Entities/AccessDecision.cs b/src/Core/Pam/Entities/AccessDecision.cs new file mode 100644 index 000000000000..b25d06d200e4 --- /dev/null +++ b/src/Core/Pam/Entities/AccessDecision.cs @@ -0,0 +1,49 @@ +using Bit.Core.Entities; +using Bit.Core.Pam.Enums; +using Bit.Core.Utilities; + +namespace Bit.Core.Pam.Entities; + +/// +/// A single decision on a . In v0 there is exactly one decision per request: an automated +/// verdict for auto-approval, or a +/// verdict once approver endpoints land. +/// +public class AccessDecision : ITableObject +{ + public Guid Id { get; set; } + + public Guid AccessRequestId { get; set; } + + public AccessDeciderKind DeciderKind { get; set; } + + /// + /// The human approver. NULL when is . + /// + public Guid? ApproverId { get; set; } + + /// + /// The condition kind that decided (e.g. ip_allowlist). NULL when is + /// . + /// + public string? ConditionKind { get; set; } + + public AccessDecisionVerdict Verdict { get; set; } + + /// + /// Human comment, or a future automatic-evaluation reason string. + /// + public string? Comment { get; set; } + + /// + /// Forward-compatible snapshot of the inputs the evaluation saw. Null in this slice (no signals are evaluated). + /// + public string? EvaluationContext { get; set; } + + public DateTime CreationDate { get; set; } = DateTime.UtcNow; + + public void SetNewId() + { + Id = CoreHelpers.GenerateComb(); + } +} diff --git a/src/Core/Pam/Entities/Lease.cs b/src/Core/Pam/Entities/AccessLease.cs similarity index 74% rename from src/Core/Pam/Entities/Lease.cs rename to src/Core/Pam/Entities/AccessLease.cs index 425b28f75563..7a889dc99fc0 100644 --- a/src/Core/Pam/Entities/Lease.cs +++ b/src/Core/Pam/Entities/AccessLease.cs @@ -5,25 +5,25 @@ namespace Bit.Core.Pam.Entities; /// -/// An active grant of access to a cipher, born from an approved . Only -/// leases inside their / window +/// An active grant of access to a cipher, born from an approved . Only +/// leases inside their / window /// authorize access. /// -public class Lease : ITableObject +public class AccessLease : ITableObject { public Guid Id { get; set; } /// /// The request that birthed this lease. /// - public Guid LeaseRequestId { get; set; } + public Guid AccessRequestId { get; set; } public Guid OrganizationId { get; set; } public Guid CollectionId { get; set; } public Guid CipherId { get; set; } public Guid RequesterId { get; set; } - public LeaseStatus Status { get; set; } + public AccessLeaseStatus Status { get; set; } public DateTime NotBefore { get; set; } public DateTime NotAfter { get; set; } diff --git a/src/Core/Pam/Entities/LeaseRequest.cs b/src/Core/Pam/Entities/AccessRequest.cs similarity index 76% rename from src/Core/Pam/Entities/LeaseRequest.cs rename to src/Core/Pam/Entities/AccessRequest.cs index c049185520d6..1ee47ea05ebc 100644 --- a/src/Core/Pam/Entities/LeaseRequest.cs +++ b/src/Core/Pam/Entities/AccessRequest.cs @@ -6,17 +6,17 @@ namespace Bit.Core.Pam.Entities; /// /// A request to lease access to a cipher in a leasing-governed collection. Auto-approved requests are created -/// already alongside an active ; requests that require -/// human approval are created and resolved later by an approver. +/// already alongside an active ; requests that require +/// human approval are created and resolved later by an approver. /// -public class LeaseRequest : ITableObject +public class AccessRequest : ITableObject { public Guid Id { get; set; } /// /// NULL for original requests. Set only for extension requests, which point at the lease being extended. /// - public Guid? LeaseId { get; set; } + public Guid? ExtensionOfLeaseId { get; set; } public Guid OrganizationId { get; set; } public Guid CollectionId { get; set; } @@ -40,11 +40,11 @@ public class LeaseRequest : ITableObject /// public string? Reason { get; set; } - public LeaseRequestStatus Status { get; set; } + public AccessRequestStatus Status { get; set; } public DateTime CreationDate { get; set; } = DateTime.UtcNow; /// - /// Set when the request leaves . + /// Set when the request leaves . /// public DateTime? ResolvedDate { get; set; } diff --git a/src/Core/Pam/Entities/AccessRule.cs b/src/Core/Pam/Entities/AccessRule.cs index 0a5e6f1d3812..b8fb8d9fd9fb 100644 --- a/src/Core/Pam/Entities/AccessRule.cs +++ b/src/Core/Pam/Entities/AccessRule.cs @@ -19,9 +19,10 @@ public class AccessRule : ITableObject public string? Description { get; set; } /// - /// JSON rule document. Validated by AccessRuleValidator before being persisted. + /// JSON conditions document (an AccessCondition tree). Validated by AccessRuleValidator before + /// being persisted. /// - public string Rule { get; set; } = null!; + public string Conditions { get; set; } = null!; public DateTime CreationDate { get; set; } = DateTime.UtcNow; public DateTime RevisionDate { get; set; } = DateTime.UtcNow; diff --git a/src/Core/Pam/Entities/LeaseDecision.cs b/src/Core/Pam/Entities/LeaseDecision.cs deleted file mode 100644 index 265e4ea1f164..000000000000 --- a/src/Core/Pam/Entities/LeaseDecision.cs +++ /dev/null @@ -1,49 +0,0 @@ -using Bit.Core.Entities; -using Bit.Core.Pam.Enums; -using Bit.Core.Utilities; - -namespace Bit.Core.Pam.Entities; - -/// -/// A single decision on a . In v0 there is exactly one decision per request: an automated -/// verdict for auto-approval, or a -/// verdict once approver endpoints land. -/// -public class LeaseDecision : ITableObject -{ - public Guid Id { get; set; } - - public Guid LeaseRequestId { get; set; } - - public LeaseDecisionKind DeciderKind { get; set; } - - /// - /// The human approver. NULL when is . - /// - public Guid? ApproverId { get; set; } - - /// - /// The rule kind that decided (e.g. ip_allowlist). NULL when is - /// . - /// - public string? PolicyKind { get; set; } - - public LeaseDecisionVerdict Decision { get; set; } - - /// - /// Human comment, or a future policy reason string. - /// - public string? Comment { get; set; } - - /// - /// Forward-compatible snapshot of the inputs a policy saw. Null in this slice (no signals are evaluated). - /// - public string? EvaluationContext { get; set; } - - public DateTime CreationDate { get; set; } = DateTime.UtcNow; - - public void SetNewId() - { - Id = CoreHelpers.GenerateComb(); - } -} diff --git a/src/Core/Pam/Enums/AccessApprovalOutcome.cs b/src/Core/Pam/Enums/AccessApprovalMode.cs similarity index 90% rename from src/Core/Pam/Enums/AccessApprovalOutcome.cs rename to src/Core/Pam/Enums/AccessApprovalMode.cs index 354e9c530440..fbb2910bbe68 100644 --- a/src/Core/Pam/Enums/AccessApprovalOutcome.cs +++ b/src/Core/Pam/Enums/AccessApprovalMode.cs @@ -4,7 +4,7 @@ /// The approval path a lease request will take, surfaced by the pre-check so the client can present the right /// workflow: (pick a duration) or (pick a window + justify). /// -public enum AccessApprovalOutcome +public enum AccessApprovalMode { Automatic, Human, diff --git a/src/Core/Pam/Enums/AccessDeciderKind.cs b/src/Core/Pam/Enums/AccessDeciderKind.cs new file mode 100644 index 000000000000..2b423c43b7bc --- /dev/null +++ b/src/Core/Pam/Enums/AccessDeciderKind.cs @@ -0,0 +1,19 @@ +namespace Bit.Core.Pam.Enums; + +/// +/// Who made a : an automatic condition evaluation or a human approver. +/// +public enum AccessDeciderKind : byte +{ + Automatic = 0, + Human = 1, +} + +/// +/// The verdict recorded on a . +/// +public enum AccessDecisionVerdict : byte +{ + Approve = 0, + Deny = 1, +} diff --git a/src/Core/Pam/Enums/AccessLeaseStatus.cs b/src/Core/Pam/Enums/AccessLeaseStatus.cs new file mode 100644 index 000000000000..45ed140b53ab --- /dev/null +++ b/src/Core/Pam/Enums/AccessLeaseStatus.cs @@ -0,0 +1,11 @@ +namespace Bit.Core.Pam.Enums; + +/// +/// Lifecycle of a . Only leases authorize access. +/// +public enum AccessLeaseStatus : byte +{ + Active = 0, + Expired = 1, + Revoked = 2, +} diff --git a/src/Core/Pam/Enums/LeaseRequestStatus.cs b/src/Core/Pam/Enums/AccessRequestStatus.cs similarity index 62% rename from src/Core/Pam/Enums/LeaseRequestStatus.cs rename to src/Core/Pam/Enums/AccessRequestStatus.cs index ad1d5e7f0b18..ddeba23dc1ae 100644 --- a/src/Core/Pam/Enums/LeaseRequestStatus.cs +++ b/src/Core/Pam/Enums/AccessRequestStatus.cs @@ -1,10 +1,10 @@ namespace Bit.Core.Pam.Enums; /// -/// Lifecycle of a . A request starts and moves to exactly +/// Lifecycle of a . A request starts and moves to exactly /// one terminal state. Auto-approved requests are created already . /// -public enum LeaseRequestStatus : byte +public enum AccessRequestStatus : byte { Pending = 0, Approved = 1, diff --git a/src/Core/Pam/Enums/LeaseDecisionKind.cs b/src/Core/Pam/Enums/LeaseDecisionKind.cs deleted file mode 100644 index 4744fdf9cffa..000000000000 --- a/src/Core/Pam/Enums/LeaseDecisionKind.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace Bit.Core.Pam.Enums; - -/// -/// Who made a : an automated policy evaluation or a human approver. -/// -public enum LeaseDecisionKind : byte -{ - Policy = 0, - Human = 1, -} - -/// -/// The verdict recorded on a . -/// -public enum LeaseDecisionVerdict : byte -{ - Approve = 0, - Deny = 1, -} diff --git a/src/Core/Pam/Enums/LeaseStatus.cs b/src/Core/Pam/Enums/LeaseStatus.cs deleted file mode 100644 index bf6a9e828029..000000000000 --- a/src/Core/Pam/Enums/LeaseStatus.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace Bit.Core.Pam.Enums; - -/// -/// Lifecycle of a . Only leases authorize access. -/// -public enum LeaseStatus : byte -{ - Active = 0, - Expired = 1, - Revoked = 2, -} diff --git a/src/Core/Pam/Models/AccessApprovalResolution.cs b/src/Core/Pam/Models/AccessApprovalResolution.cs deleted file mode 100644 index ebc5aa686b5a..000000000000 --- a/src/Core/Pam/Models/AccessApprovalResolution.cs +++ /dev/null @@ -1,15 +0,0 @@ -using Bit.Core.Pam.Models.Rules; - -namespace Bit.Core.Pam.Models; - -/// -/// The leasing context that governs a cipher for a particular caller: which collection's access rule applies, the -/// owning organization, whether that rule requires human approval, and the parsed itself so the -/// policy engine can evaluate it against the caller's signals. A null resolution means the cipher is not -/// leasing-gated for the caller. -/// -public sealed record AccessApprovalResolution( - Guid OrganizationId, - Guid CollectionId, - bool RequiresHumanApproval, - Rule Rule); diff --git a/src/Core/Pam/Models/LeaseDecisionSubmission.cs b/src/Core/Pam/Models/AccessDecisionSubmission.cs similarity index 68% rename from src/Core/Pam/Models/LeaseDecisionSubmission.cs rename to src/Core/Pam/Models/AccessDecisionSubmission.cs index d3597640eb55..9c6e5dd5a90c 100644 --- a/src/Core/Pam/Models/LeaseDecisionSubmission.cs +++ b/src/Core/Pam/Models/AccessDecisionSubmission.cs @@ -5,8 +5,8 @@ namespace Bit.Core.Pam.Models; /// /// An approver's decision on a pending lease request: approve or deny, with an optional comment. /// -public sealed class LeaseDecisionSubmission +public sealed class AccessDecisionSubmission { - public required LeaseDecisionVerdict Verdict { get; init; } + public required AccessDecisionVerdict Verdict { get; init; } public string? Comment { get; init; } } diff --git a/src/Core/Pam/Models/AccessLeaseStatusNames.cs b/src/Core/Pam/Models/AccessLeaseStatusNames.cs new file mode 100644 index 000000000000..7a21942edf12 --- /dev/null +++ b/src/Core/Pam/Models/AccessLeaseStatusNames.cs @@ -0,0 +1,22 @@ +using Bit.Core.Pam.Enums; + +namespace Bit.Core.Pam.Models; + +/// +/// Maps the backend to the status vocabulary the leasing client expects: +/// active | expired | revoked. Mirrors for the request side. +/// +public static class AccessLeaseStatusNames +{ + public const string Active = "active"; + public const string Expired = "expired"; + public const string Revoked = "revoked"; + + public static string From(AccessLeaseStatus status) => status switch + { + AccessLeaseStatus.Active => Active, + AccessLeaseStatus.Expired => Expired, + AccessLeaseStatus.Revoked => Revoked, + _ => throw new ArgumentOutOfRangeException(nameof(status), status, null), + }; +} diff --git a/src/Core/Pam/Models/AccessPreCheckResult.cs b/src/Core/Pam/Models/AccessPreCheckResult.cs index a137bd824147..0e5ad9b87e52 100644 --- a/src/Core/Pam/Models/AccessPreCheckResult.cs +++ b/src/Core/Pam/Models/AccessPreCheckResult.cs @@ -5,6 +5,7 @@ namespace Bit.Core.Pam.Models; /// /// The result of a pre-check. When is true the caller already holds an active lease for /// the cipher, so the client should reveal the credential rather than prompt for a new request; otherwise -/// describes whether a fresh request would be approved automatically or require human approval. +/// describes whether a fresh request would be approved automatically or require human +/// approval. /// -public sealed record AccessPreCheckResult(AccessApprovalOutcome Outcome, bool HasActiveLease = false); +public sealed record AccessPreCheckResult(AccessApprovalMode ApprovalMode, bool HasActiveLease = false); diff --git a/src/Core/Pam/Models/InboxLeaseRequestDetails.cs b/src/Core/Pam/Models/AccessRequestDetails.cs similarity index 87% rename from src/Core/Pam/Models/InboxLeaseRequestDetails.cs rename to src/Core/Pam/Models/AccessRequestDetails.cs index be03d95aa6f1..35aa9add1192 100644 --- a/src/Core/Pam/Models/InboxLeaseRequestDetails.cs +++ b/src/Core/Pam/Models/AccessRequestDetails.cs @@ -3,12 +3,12 @@ namespace Bit.Core.Pam.Models; /// -/// A lease request projected for the approver inbox: every field plus the +/// A lease request projected for the approver inbox: every field plus the /// denormalized display data the client needs (cipher/collection names, requester identity), the lease the request /// produced (if any), and the human resolver's identity/comment. Populated by a single join in the read procedures so /// the client avoids an N+1. /// -public class InboxLeaseRequestDetails +public class AccessRequestDetails { public Guid Id { get; set; } @@ -22,7 +22,7 @@ public class InboxLeaseRequestDetails public DateTime NotBefore { get; set; } public DateTime NotAfter { get; set; } public string? Reason { get; set; } - public LeaseRequestStatus Status { get; set; } + public AccessRequestStatus Status { get; set; } public DateTime CreationDate { get; set; } public DateTime? ResolvedDate { get; set; } @@ -30,10 +30,10 @@ public class InboxLeaseRequestDetails public Guid? ProducedLeaseId { get; set; } /// The human approver who resolved the request, or null (e.g. still pending or auto-resolved). - public Guid? ResolverId { get; set; } + public Guid? ApproverId { get; set; } /// The human approver's comment, if any. - public string? ResolverComment { get; set; } + public string? ApproverComment { get; set; } /// The cipher's client-encrypted name. The only cipher attribute the inbox exposes. public string? CipherName { get; set; } diff --git a/src/Core/Pam/Models/AccessRequestResult.cs b/src/Core/Pam/Models/AccessRequestResult.cs index 193ff6e58118..5f25008edeed 100644 --- a/src/Core/Pam/Models/AccessRequestResult.cs +++ b/src/Core/Pam/Models/AccessRequestResult.cs @@ -4,18 +4,18 @@ namespace Bit.Core.Pam.Models; /// -/// The result of submitting an access request. On the path a -/// is issued immediately; on the path a pending -/// is created to await an approver. +/// The result of submitting an access request. On the path an +/// is issued immediately; on the path a pending +/// is created to await an approver. /// public sealed record AccessRequestResult( - AccessApprovalOutcome Outcome, - Lease? Lease = null, - LeaseRequest? Request = null) + AccessApprovalMode ApprovalMode, + AccessLease? Lease = null, + AccessRequest? Request = null) { - public static AccessRequestResult Automatic(Lease lease) => - new(AccessApprovalOutcome.Automatic, Lease: lease); + public static AccessRequestResult Automatic(AccessLease lease) => + new(AccessApprovalMode.Automatic, Lease: lease); - public static AccessRequestResult Human(LeaseRequest request) => - new(AccessApprovalOutcome.Human, Request: request); + public static AccessRequestResult Human(AccessRequest request) => + new(AccessApprovalMode.Human, Request: request); } diff --git a/src/Core/Pam/Models/InboxRequestStatus.cs b/src/Core/Pam/Models/AccessRequestStatusNames.cs similarity index 55% rename from src/Core/Pam/Models/InboxRequestStatus.cs rename to src/Core/Pam/Models/AccessRequestStatusNames.cs index 847d7f1e9687..7d5cfcd33cad 100644 --- a/src/Core/Pam/Models/InboxRequestStatus.cs +++ b/src/Core/Pam/Models/AccessRequestStatusNames.cs @@ -3,11 +3,11 @@ namespace Bit.Core.Pam.Models; /// -/// Maps the backend (plus whether the request has produced a lease) to the status +/// Maps the backend (plus whether the request has produced a lease) to the status /// vocabulary the approver-inbox client expects: pending | approved | activated | denied | cancelled | expired. /// An approved request that has produced a lease is reported as activated. /// -public static class InboxRequestStatus +public static class AccessRequestStatusNames { public const string Pending = "pending"; public const string Approved = "approved"; @@ -16,13 +16,13 @@ public static class InboxRequestStatus public const string Cancelled = "cancelled"; public const string Expired = "expired"; - public static string From(LeaseRequestStatus status, bool hasLease) => status switch + public static string From(AccessRequestStatus status, bool hasLease) => status switch { - LeaseRequestStatus.Pending => Pending, - LeaseRequestStatus.Approved => hasLease ? Activated : Approved, - LeaseRequestStatus.Denied => Denied, - LeaseRequestStatus.Cancelled => Cancelled, - LeaseRequestStatus.ExpiredUnanswered => Expired, + AccessRequestStatus.Pending => Pending, + AccessRequestStatus.Approved => hasLease ? Activated : Approved, + AccessRequestStatus.Denied => Denied, + AccessRequestStatus.Cancelled => Cancelled, + AccessRequestStatus.ExpiredUnanswered => Expired, _ => throw new ArgumentOutOfRangeException(nameof(status), status, null), }; } diff --git a/src/Core/Pam/Models/AccessRuleDetails.cs b/src/Core/Pam/Models/AccessRuleDetails.cs index 1255fb391fa5..10e3d2c16ef0 100644 --- a/src/Core/Pam/Models/AccessRuleDetails.cs +++ b/src/Core/Pam/Models/AccessRuleDetails.cs @@ -15,7 +15,7 @@ public class AccessRuleDetails : AccessRule OrganizationId = rule.OrganizationId, Name = rule.Name, Description = rule.Description, - Rule = rule.Rule, + Conditions = rule.Conditions, CreationDate = rule.CreationDate, RevisionDate = rule.RevisionDate, CollectionIds = collectionIds, diff --git a/src/Core/Pam/Models/CipherAccessState.cs b/src/Core/Pam/Models/CipherAccessState.cs new file mode 100644 index 000000000000..c3af393c378f --- /dev/null +++ b/src/Core/Pam/Models/CipherAccessState.cs @@ -0,0 +1,10 @@ +using Bit.Core.Pam.Entities; + +namespace Bit.Core.Pam.Models; + +/// +/// The caller's access state for a single cipher: the active lease they hold (if any) and their pending request (if +/// any). The approved-but-not-yet-activated request the client models has no server counterpart in v0 — approval +/// mints an active lease immediately — so it is always absent here. +/// +public record CipherAccessState(Guid CipherId, AccessLease? ActiveLease, AccessRequestDetails? PendingRequest); diff --git a/src/Core/Pam/Models/CipherLeaseStateResult.cs b/src/Core/Pam/Models/CipherLeaseStateResult.cs deleted file mode 100644 index a06459150bf7..000000000000 --- a/src/Core/Pam/Models/CipherLeaseStateResult.cs +++ /dev/null @@ -1,10 +0,0 @@ -using Bit.Core.Pam.Entities; - -namespace Bit.Core.Pam.Models; - -/// -/// The caller's lease state for a single cipher: the active lease they hold (if any) and their pending request (if -/// any). The approved-but-unredeemed "ticket" the client models has no server counterpart in v0 — approval mints an -/// active lease immediately — so it is always absent here. -/// -public record CipherLeaseStateResult(Guid CipherId, Lease? ActiveLease, InboxLeaseRequestDetails? PendingRequest); diff --git a/src/Core/Pam/Models/Conditions/AccessCondition.cs b/src/Core/Pam/Models/Conditions/AccessCondition.cs new file mode 100644 index 000000000000..127ce293a5a5 --- /dev/null +++ b/src/Core/Pam/Models/Conditions/AccessCondition.cs @@ -0,0 +1,14 @@ +using System.Text.Json.Serialization; + +namespace Bit.Core.Pam.Models.Conditions; + +/// +/// Base type for the structured conditions document stored on AccessRule.Conditions. +/// Polymorphic deserialization is keyed by the JSON kind property. +/// +[JsonPolymorphic(TypeDiscriminatorPropertyName = "kind", UnknownDerivedTypeHandling = JsonUnknownDerivedTypeHandling.FailSerialization)] +[JsonDerivedType(typeof(HumanApprovalCondition), "human_approval")] +[JsonDerivedType(typeof(IpAllowlistCondition), "ip_allowlist")] +[JsonDerivedType(typeof(TimeOfDayCondition), "time_of_day")] +[JsonDerivedType(typeof(AllOfCondition), "all_of")] +public abstract class AccessCondition; diff --git a/src/Core/Pam/Models/Conditions/AllOfCondition.cs b/src/Core/Pam/Models/Conditions/AllOfCondition.cs new file mode 100644 index 000000000000..a5c077c1d8da --- /dev/null +++ b/src/Core/Pam/Models/Conditions/AllOfCondition.cs @@ -0,0 +1,9 @@ +namespace Bit.Core.Pam.Models.Conditions; + +/// +/// Composite condition that approves only when every child condition approves. +/// +public sealed class AllOfCondition : AccessCondition +{ + public IReadOnlyList Conditions { get; init; } = []; +} diff --git a/src/Core/Pam/Models/Conditions/HumanApprovalCondition.cs b/src/Core/Pam/Models/Conditions/HumanApprovalCondition.cs new file mode 100644 index 000000000000..4f14c8833810 --- /dev/null +++ b/src/Core/Pam/Models/Conditions/HumanApprovalCondition.cs @@ -0,0 +1,6 @@ +namespace Bit.Core.Pam.Models.Conditions; + +/// +/// Always requires a human decision before a lease can be issued. +/// +public sealed class HumanApprovalCondition : AccessCondition; diff --git a/src/Core/Pam/Models/Rules/IpAllowlistRule.cs b/src/Core/Pam/Models/Conditions/IpAllowlistCondition.cs similarity index 64% rename from src/Core/Pam/Models/Rules/IpAllowlistRule.cs rename to src/Core/Pam/Models/Conditions/IpAllowlistCondition.cs index f104ad4b8ae0..fc2f186a9bc2 100644 --- a/src/Core/Pam/Models/Rules/IpAllowlistRule.cs +++ b/src/Core/Pam/Models/Conditions/IpAllowlistCondition.cs @@ -1,9 +1,9 @@ -namespace Bit.Core.Pam.Models.Rules; +namespace Bit.Core.Pam.Models.Conditions; /// /// Auto-approves a lease when the requester's IP matches a listed CIDR; otherwise denies. /// -public sealed class IpAllowlistRule : Rule +public sealed class IpAllowlistCondition : AccessCondition { public IReadOnlyList Cidrs { get; init; } = []; } diff --git a/src/Core/Pam/Models/Rules/TimeOfDayRule.cs b/src/Core/Pam/Models/Conditions/TimeOfDayCondition.cs similarity index 83% rename from src/Core/Pam/Models/Rules/TimeOfDayRule.cs rename to src/Core/Pam/Models/Conditions/TimeOfDayCondition.cs index d6a1d29a4439..996bfa911d6a 100644 --- a/src/Core/Pam/Models/Rules/TimeOfDayRule.cs +++ b/src/Core/Pam/Models/Conditions/TimeOfDayCondition.cs @@ -1,10 +1,10 @@ -namespace Bit.Core.Pam.Models.Rules; +namespace Bit.Core.Pam.Models.Conditions; /// /// Auto-approves a lease when the request falls inside one of the configured windows, evaluated in /// the named IANA timezone; otherwise denies. /// -public sealed class TimeOfDayRule : Rule +public sealed class TimeOfDayCondition : AccessCondition { public string Tz { get; init; } = string.Empty; public IReadOnlyList Windows { get; init; } = []; diff --git a/src/Core/Pam/Models/GoverningRule.cs b/src/Core/Pam/Models/GoverningRule.cs new file mode 100644 index 000000000000..9f352ae47007 --- /dev/null +++ b/src/Core/Pam/Models/GoverningRule.cs @@ -0,0 +1,15 @@ +using Bit.Core.Pam.Models.Conditions; + +namespace Bit.Core.Pam.Models; + +/// +/// The access rule that governs a cipher for a particular caller: which collection's rule applies, the owning +/// organization, whether the rule requires human approval, and the parsed tree so the +/// rule engine can evaluate it against the caller's signals. A null governing rule means the cipher is not +/// leasing-gated for the caller. +/// +public sealed record GoverningRule( + Guid OrganizationId, + Guid CollectionId, + bool RequiresHumanApproval, + AccessCondition Condition); diff --git a/src/Core/Pam/Models/LeaseStatusName.cs b/src/Core/Pam/Models/LeaseStatusName.cs deleted file mode 100644 index ef52de717eaa..000000000000 --- a/src/Core/Pam/Models/LeaseStatusName.cs +++ /dev/null @@ -1,22 +0,0 @@ -using Bit.Core.Pam.Enums; - -namespace Bit.Core.Pam.Models; - -/// -/// Maps the backend to the status vocabulary the leasing client expects: -/// active | expired | revoked. Mirrors for the request side. -/// -public static class LeaseStatusName -{ - public const string Active = "active"; - public const string Expired = "expired"; - public const string Revoked = "revoked"; - - public static string From(LeaseStatus status) => status switch - { - LeaseStatus.Active => Active, - LeaseStatus.Expired => Expired, - LeaseStatus.Revoked => Revoked, - _ => throw new ArgumentOutOfRangeException(nameof(status), status, null), - }; -} diff --git a/src/Core/Pam/Models/Rules/AllOfRule.cs b/src/Core/Pam/Models/Rules/AllOfRule.cs deleted file mode 100644 index 9b5763e2aa74..000000000000 --- a/src/Core/Pam/Models/Rules/AllOfRule.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Bit.Core.Pam.Models.Rules; - -/// -/// Composite rule that approves only when every child rule approves. -/// -public sealed class AllOfRule : Rule -{ - public IReadOnlyList Rules { get; init; } = []; -} diff --git a/src/Core/Pam/Models/Rules/HumanApprovalRule.cs b/src/Core/Pam/Models/Rules/HumanApprovalRule.cs deleted file mode 100644 index 15aa3ae4b4dd..000000000000 --- a/src/Core/Pam/Models/Rules/HumanApprovalRule.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Bit.Core.Pam.Models.Rules; - -/// -/// Always requires a human decision before a lease can be issued. -/// -public sealed class HumanApprovalRule : Rule; diff --git a/src/Core/Pam/Models/Rules/Rule.cs b/src/Core/Pam/Models/Rules/Rule.cs deleted file mode 100644 index 4d372fad4652..000000000000 --- a/src/Core/Pam/Models/Rules/Rule.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Bit.Core.Pam.Models.Rules; - -/// -/// Base type for the structured rule stored on AccessRule.Rule. -/// Polymorphic deserialization is keyed by the JSON kind property. -/// -[JsonPolymorphic(TypeDiscriminatorPropertyName = "kind", UnknownDerivedTypeHandling = JsonUnknownDerivedTypeHandling.FailSerialization)] -[JsonDerivedType(typeof(HumanApprovalRule), "human_approval")] -[JsonDerivedType(typeof(IpAllowlistRule), "ip_allowlist")] -[JsonDerivedType(typeof(TimeOfDayRule), "time_of_day")] -[JsonDerivedType(typeof(AllOfRule), "all_of")] -public abstract class Rule; diff --git a/src/Core/Pam/OrganizationFeatures/Commands/CreateAccessRuleCommand.cs b/src/Core/Pam/OrganizationFeatures/Commands/CreateAccessRuleCommand.cs index 74ff12503488..496edb2fb07d 100644 --- a/src/Core/Pam/OrganizationFeatures/Commands/CreateAccessRuleCommand.cs +++ b/src/Core/Pam/OrganizationFeatures/Commands/CreateAccessRuleCommand.cs @@ -35,7 +35,7 @@ public async Task CreateAsync(AccessRule rule, IEnumerable DecideAsync(Guid userId, Guid requestId, LeaseDecisionSubmission submission) + public async Task DecideAsync(Guid userId, Guid requestId, AccessDecisionSubmission submission) { - var request = await _leaseRequestRepository.GetByIdAsync(requestId); + var request = await _accessRequestRepository.GetByIdAsync(requestId); // 404 for both missing and not-visible, so the caller can't probe for requests they don't manage. if (request is null || !await _approverCollectionAccessQuery.CanManageCollectionAsync(userId, request.CollectionId)) @@ -37,7 +37,7 @@ public async Task DecideAsync(Guid userId, Guid reques throw new NotFoundException(); } - if (request.Status != LeaseRequestStatus.Pending) + if (request.Status != AccessRequestStatus.Pending) { throw new ConflictException("This request has already been resolved."); } @@ -50,15 +50,15 @@ public async Task DecideAsync(Guid userId, Guid reques } var now = _timeProvider.GetUtcNow().UtcDateTime; - var approved = submission.Verdict == LeaseDecisionVerdict.Approve; - var status = approved ? LeaseRequestStatus.Approved : LeaseRequestStatus.Denied; + var approved = submission.Verdict == AccessDecisionVerdict.Approve; + var status = approved ? AccessRequestStatus.Approved : AccessRequestStatus.Denied; - var decision = new LeaseDecision + var decision = new AccessDecision { - LeaseRequestId = request.Id, - DeciderKind = LeaseDecisionKind.Human, + AccessRequestId = request.Id, + DeciderKind = AccessDeciderKind.Human, ApproverId = userId, - Decision = submission.Verdict, + Verdict = submission.Verdict, Comment = string.IsNullOrWhiteSpace(submission.Comment) ? null : submission.Comment, CreationDate = now, }; @@ -67,17 +67,17 @@ public async Task DecideAsync(Guid userId, Guid reques // Approval mints the active lease that actually authorizes access, spanning the request's approved window. // Without it the requester would be Approved but hold no lease, so pre-check and the cipher read would both // deny them. A denial creates no lease. - Lease? lease = null; + AccessLease? lease = null; if (approved) { - lease = new Lease + lease = new AccessLease { - LeaseRequestId = request.Id, + AccessRequestId = request.Id, OrganizationId = request.OrganizationId, CollectionId = request.CollectionId, CipherId = request.CipherId, RequesterId = request.RequesterId, - Status = LeaseStatus.Active, + Status = AccessLeaseStatus.Active, NotBefore = request.NotBefore, NotAfter = request.NotAfter, CreationDate = now, @@ -85,18 +85,18 @@ public async Task DecideAsync(Guid userId, Guid reques lease.SetNewId(); } - await _leaseRequestRepository.ResolveWithDecisionAsync(request, decision, status, lease, now); + await _accessRequestRepository.ResolveWithDecisionAsync(request, decision, status, lease, now); // The request just left the pending queue; tell every approver of this collection to re-fetch. await _approverInboxNotifier.NotifyCollectionApproversAsync(request.CollectionId); - // The client repaints the row from Status, ResolvedAt, and ResolverComment, so those must be accurate; the + // The client repaints the row from Status, ResolvedAt, and ApproverComment, so those must be accurate; the // denormalized display fields already live on the client's existing row. Project from what we just wrote // rather than re-reading. - return new InboxLeaseRequestDetails + return new AccessRequestDetails { Id = request.Id, - ExtensionOfLeaseId = request.LeaseId, + ExtensionOfLeaseId = request.ExtensionOfLeaseId, OrganizationId = request.OrganizationId, CollectionId = request.CollectionId, CipherId = request.CipherId, @@ -107,8 +107,8 @@ public async Task DecideAsync(Guid userId, Guid reques Status = status, CreationDate = request.CreationDate, ResolvedDate = now, - ResolverId = decision.ApproverId, - ResolverComment = decision.Comment, + ApproverId = decision.ApproverId, + ApproverComment = decision.Comment, }; } } diff --git a/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IDecideLeaseRequestCommand.cs b/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IDecideAccessRequestCommand.cs similarity index 85% rename from src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IDecideLeaseRequestCommand.cs rename to src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IDecideAccessRequestCommand.cs index 8059d2b2fd8b..faa9224deff7 100644 --- a/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IDecideLeaseRequestCommand.cs +++ b/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IDecideAccessRequestCommand.cs @@ -2,7 +2,7 @@ namespace Bit.Core.Pam.OrganizationFeatures.Commands.Interfaces; -public interface IDecideLeaseRequestCommand +public interface IDecideAccessRequestCommand { /// /// Approves or denies a pending lease request on behalf of an approver. The caller must be able to Manage the @@ -16,5 +16,5 @@ public interface IDecideLeaseRequestCommand /// The caller is the requester (self-approval). The spec calls for 403 here, but Bitwarden clients treat 403 as a /// forced logout, so this is surfaced as 400 — matching the existing convention in the Admin Console controllers. /// - Task DecideAsync(Guid userId, Guid requestId, LeaseDecisionSubmission submission); + Task DecideAsync(Guid userId, Guid requestId, AccessDecisionSubmission submission); } diff --git a/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IRevokeLeaseCommand.cs b/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IRevokeAccessLeaseCommand.cs similarity index 93% rename from src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IRevokeLeaseCommand.cs rename to src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IRevokeAccessLeaseCommand.cs index 6d8fd72c447d..f9d44664b86b 100644 --- a/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IRevokeLeaseCommand.cs +++ b/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IRevokeAccessLeaseCommand.cs @@ -1,6 +1,6 @@ namespace Bit.Core.Pam.OrganizationFeatures.Commands.Interfaces; -public interface IRevokeLeaseCommand +public interface IRevokeAccessLeaseCommand { /// /// Revokes an active lease early. The caller must be able to Manage the lease's collection. The optional reason is diff --git a/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IRequestAccessCommand.cs b/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/ISubmitAccessRequestCommand.cs similarity index 71% rename from src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IRequestAccessCommand.cs rename to src/Core/Pam/OrganizationFeatures/Commands/Interfaces/ISubmitAccessRequestCommand.cs index a64aaac2de06..2503ae78bf55 100644 --- a/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IRequestAccessCommand.cs +++ b/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/ISubmitAccessRequestCommand.cs @@ -2,11 +2,11 @@ namespace Bit.Core.Pam.OrganizationFeatures.Commands.Interfaces; -public interface IRequestAccessCommand +public interface ISubmitAccessRequestCommand { /// /// Submits a request to lease a cipher. On the automatic path a lease is issued immediately; on the human path a /// pending request is created. The submission's shape is validated against the cipher's resolved approval outcome. /// - Task RequestAccessAsync(Guid userId, Guid cipherId, AccessRequestSubmission submission); + Task SubmitAsync(Guid userId, Guid cipherId, AccessRequestSubmission submission); } diff --git a/src/Core/Pam/OrganizationFeatures/Commands/RevokeLeaseCommand.cs b/src/Core/Pam/OrganizationFeatures/Commands/RevokeAccessLeaseCommand.cs similarity index 72% rename from src/Core/Pam/OrganizationFeatures/Commands/RevokeLeaseCommand.cs rename to src/Core/Pam/OrganizationFeatures/Commands/RevokeAccessLeaseCommand.cs index 98ca3c5272f6..d7210e637e80 100644 --- a/src/Core/Pam/OrganizationFeatures/Commands/RevokeLeaseCommand.cs +++ b/src/Core/Pam/OrganizationFeatures/Commands/RevokeAccessLeaseCommand.cs @@ -7,20 +7,20 @@ namespace Bit.Core.Pam.OrganizationFeatures.Commands; -public class RevokeLeaseCommand : IRevokeLeaseCommand +public class RevokeAccessLeaseCommand : IRevokeAccessLeaseCommand { - private readonly ILeaseRepository _leaseRepository; + private readonly IAccessLeaseRepository _accessLeaseRepository; private readonly IApproverCollectionAccessQuery _approverCollectionAccessQuery; private readonly IApproverInboxNotifier _approverInboxNotifier; private readonly TimeProvider _timeProvider; - public RevokeLeaseCommand( - ILeaseRepository leaseRepository, + public RevokeAccessLeaseCommand( + IAccessLeaseRepository accessLeaseRepository, IApproverCollectionAccessQuery approverCollectionAccessQuery, IApproverInboxNotifier approverInboxNotifier, TimeProvider timeProvider) { - _leaseRepository = leaseRepository; + _accessLeaseRepository = accessLeaseRepository; _approverCollectionAccessQuery = approverCollectionAccessQuery; _approverInboxNotifier = approverInboxNotifier; _timeProvider = timeProvider; @@ -28,7 +28,7 @@ public RevokeLeaseCommand( public async Task RevokeAsync(Guid userId, Guid leaseId, string? reason) { - var lease = await _leaseRepository.GetByIdAsync(leaseId); + var lease = await _accessLeaseRepository.GetByIdAsync(leaseId); // 404 for both missing and not-visible, so the caller can't probe for leases they don't manage. if (lease is null || !await _approverCollectionAccessQuery.CanManageCollectionAsync(userId, lease.CollectionId)) @@ -36,7 +36,7 @@ public async Task RevokeAsync(Guid userId, Guid leaseId, string? reason) throw new NotFoundException(); } - if (lease.Status != LeaseStatus.Active) + if (lease.Status != AccessLeaseStatus.Active) { throw new ConflictException("This lease is not active."); } @@ -44,18 +44,18 @@ public async Task RevokeAsync(Guid userId, Guid leaseId, string? reason) var now = _timeProvider.GetUtcNow().UtcDateTime; // The reason has no dedicated column, so it is preserved as a human decision against the originating request. - var auditDecision = new LeaseDecision + var auditDecision = new AccessDecision { - LeaseRequestId = lease.LeaseRequestId, - DeciderKind = LeaseDecisionKind.Human, + AccessRequestId = lease.AccessRequestId, + DeciderKind = AccessDeciderKind.Human, ApproverId = userId, - Decision = LeaseDecisionVerdict.Deny, + Verdict = AccessDecisionVerdict.Deny, Comment = string.IsNullOrWhiteSpace(reason) ? null : reason, CreationDate = now, }; auditDecision.SetNewId(); - await _leaseRepository.RevokeAsync(lease, auditDecision, now); + await _accessLeaseRepository.RevokeAsync(lease, auditDecision, now); // The active lease just drained; tell every approver of this collection to re-fetch. await _approverInboxNotifier.NotifyCollectionApproversAsync(lease.CollectionId); diff --git a/src/Core/Pam/OrganizationFeatures/Commands/RequestAccessCommand.cs b/src/Core/Pam/OrganizationFeatures/Commands/SubmitAccessRequestCommand.cs similarity index 65% rename from src/Core/Pam/OrganizationFeatures/Commands/RequestAccessCommand.cs rename to src/Core/Pam/OrganizationFeatures/Commands/SubmitAccessRequestCommand.cs index cfbd4f1d7fb8..407717f38b4d 100644 --- a/src/Core/Pam/OrganizationFeatures/Commands/RequestAccessCommand.cs +++ b/src/Core/Pam/OrganizationFeatures/Commands/SubmitAccessRequestCommand.cs @@ -12,7 +12,7 @@ namespace Bit.Core.Pam.OrganizationFeatures.Commands; -public class RequestAccessCommand : IRequestAccessCommand +public class SubmitAccessRequestCommand : ISubmitAccessRequestCommand { /// /// The maximum lease window length, applied to both the automatic duration and the human-requested window. @@ -21,35 +21,35 @@ public class RequestAccessCommand : IRequestAccessCommand public const int MaxDurationSeconds = 24 * 60 * 60; private readonly ICipherRepository _cipherRepository; - private readonly IAccessApprovalResolver _resolver; - private readonly IAccessPolicyEngine _policyEngine; + private readonly IGoverningRuleResolver _resolver; + private readonly IAccessRuleEngine _ruleEngine; private readonly ICurrentContext _currentContext; - private readonly ILeaseRepository _leaseRepository; - private readonly ILeaseRequestRepository _leaseRequestRepository; + private readonly IAccessLeaseRepository _accessLeaseRepository; + private readonly IAccessRequestRepository _accessRequestRepository; private readonly IApproverInboxNotifier _approverInboxNotifier; private readonly TimeProvider _timeProvider; - public RequestAccessCommand( + public SubmitAccessRequestCommand( ICipherRepository cipherRepository, - IAccessApprovalResolver resolver, - IAccessPolicyEngine policyEngine, + IGoverningRuleResolver resolver, + IAccessRuleEngine ruleEngine, ICurrentContext currentContext, - ILeaseRepository leaseRepository, - ILeaseRequestRepository leaseRequestRepository, + IAccessLeaseRepository accessLeaseRepository, + IAccessRequestRepository accessRequestRepository, IApproverInboxNotifier approverInboxNotifier, TimeProvider timeProvider) { _cipherRepository = cipherRepository; _resolver = resolver; - _policyEngine = policyEngine; + _ruleEngine = ruleEngine; _currentContext = currentContext; - _leaseRepository = leaseRepository; - _leaseRequestRepository = leaseRequestRepository; + _accessLeaseRepository = accessLeaseRepository; + _accessRequestRepository = accessRequestRepository; _approverInboxNotifier = approverInboxNotifier; _timeProvider = timeProvider; } - public async Task RequestAccessAsync(Guid userId, Guid cipherId, AccessRequestSubmission submission) + public async Task SubmitAsync(Guid userId, Guid cipherId, AccessRequestSubmission submission) { var cipher = await _cipherRepository.GetByIdAsync(cipherId, userId); if (cipher is null) @@ -57,31 +57,31 @@ public async Task RequestAccessAsync(Guid userId, Guid ciph throw new NotFoundException(); } - var resolution = await _resolver.ResolveAsync(userId, cipherId); - if (resolution is null) + var governingRule = await _resolver.ResolveAsync(userId, cipherId); + if (governingRule is null) { throw new BadRequestException("This item does not require a lease."); } var now = _timeProvider.GetUtcNow().UtcDateTime; - if (await _leaseRepository.GetActiveByRequesterIdCipherIdAsync(userId, cipherId, now) is not null) + if (await _accessLeaseRepository.GetActiveByRequesterIdCipherIdAsync(userId, cipherId, now) is not null) { throw new BadRequestException("You already have active access to this item."); } - if (await _leaseRequestRepository.GetActivePendingByRequesterIdCipherIdAsync(userId, cipherId) is not null) + if (await _accessRequestRepository.GetActivePendingByRequesterIdCipherIdAsync(userId, cipherId) is not null) { throw new BadRequestException("You already have a pending request for this item."); } - return resolution.RequiresHumanApproval - ? await RequestHumanApprovalAsync(userId, cipherId, resolution, submission) - : await IssueAutomaticLeaseAsync(userId, cipherId, resolution, submission, now); + return governingRule.RequiresHumanApproval + ? await RequestHumanApprovalAsync(userId, cipherId, governingRule, submission) + : await IssueAutomaticLeaseAsync(userId, cipherId, governingRule, submission, now); } private async Task IssueAutomaticLeaseAsync( - Guid userId, Guid cipherId, AccessApprovalResolution resolution, AccessRequestSubmission submission, DateTime now) + Guid userId, Guid cipherId, GoverningRule governingRule, AccessRequestSubmission submission, DateTime now) { if (submission.Start.HasValue || submission.End.HasValue) { @@ -101,59 +101,59 @@ private async Task IssueAutomaticLeaseAsync( // The cipher must satisfy its access rule's conditions (source IP, time of day, ...) before an automatic // lease is issued. The resolver only routes a rule here when it carries no human-approval gate, so the // engine never asks for approval on this path; any non-allow outcome is a denial we surface to the caller. - var policyDecision = _policyEngine.Evaluate(resolution.Rule, BuildSignals(now)); - if (policyDecision.Kind != DecisionKind.Allow) + var evaluation = _ruleEngine.Evaluate(governingRule.Condition, BuildSignals(now)); + if (evaluation.Outcome != AccessEvaluationOutcome.Allow) { - throw new BadRequestException(DenyMessage(policyDecision)); + throw new BadRequestException(DenyMessage(evaluation)); } var notAfter = now.AddSeconds(durationSeconds); - var request = new LeaseRequest + var request = new AccessRequest { - OrganizationId = resolution.OrganizationId, - CollectionId = resolution.CollectionId, + OrganizationId = governingRule.OrganizationId, + CollectionId = governingRule.CollectionId, CipherId = cipherId, RequesterId = userId, NotBefore = now, NotAfter = notAfter, Reason = string.IsNullOrWhiteSpace(submission.Reason) ? null : submission.Reason, - Status = LeaseRequestStatus.Approved, + Status = AccessRequestStatus.Approved, CreationDate = now, ResolvedDate = now, }; request.SetNewId(); - var decision = new LeaseDecision + var decision = new AccessDecision { - LeaseRequestId = request.Id, - DeciderKind = LeaseDecisionKind.Policy, - Decision = LeaseDecisionVerdict.Approve, + AccessRequestId = request.Id, + DeciderKind = AccessDeciderKind.Automatic, + Verdict = AccessDecisionVerdict.Approve, CreationDate = now, }; decision.SetNewId(); - var lease = new Lease + var lease = new AccessLease { - LeaseRequestId = request.Id, - OrganizationId = resolution.OrganizationId, - CollectionId = resolution.CollectionId, + AccessRequestId = request.Id, + OrganizationId = governingRule.OrganizationId, + CollectionId = governingRule.CollectionId, CipherId = cipherId, RequesterId = userId, - Status = LeaseStatus.Active, + Status = AccessLeaseStatus.Active, NotBefore = now, NotAfter = notAfter, CreationDate = now, }; lease.SetNewId(); - await _leaseRepository.CreateAutoApprovedAsync(request, decision, lease, now); + await _accessLeaseRepository.CreateAutoApprovedAsync(request, decision, lease, now); return AccessRequestResult.Automatic(lease); } private async Task RequestHumanApprovalAsync( - Guid userId, Guid cipherId, AccessApprovalResolution resolution, AccessRequestSubmission submission) + Guid userId, Guid cipherId, GoverningRule governingRule, AccessRequestSubmission submission) { if (submission.DurationSeconds.HasValue) { @@ -181,20 +181,20 @@ private async Task RequestHumanApprovalAsync( } var now = _timeProvider.GetUtcNow().UtcDateTime; - var request = new LeaseRequest + var request = new AccessRequest { - OrganizationId = resolution.OrganizationId, - CollectionId = resolution.CollectionId, + OrganizationId = governingRule.OrganizationId, + CollectionId = governingRule.CollectionId, CipherId = cipherId, RequesterId = userId, NotBefore = start, NotAfter = end, Reason = submission.Reason, - Status = LeaseRequestStatus.Pending, + Status = AccessRequestStatus.Pending, CreationDate = now, }; - var created = await _leaseRequestRepository.CreateAsync(request); + var created = await _accessRequestRepository.CreateAsync(request); // A new request just entered the pending queue; tell every approver of this collection to re-fetch. await _approverInboxNotifier.NotifyCollectionApproversAsync(created.CollectionId); @@ -202,13 +202,13 @@ private async Task RequestHumanApprovalAsync( return AccessRequestResult.Human(created); } - private AccessPolicySignals BuildSignals(DateTime now) => new() + private AccessSignals BuildSignals(DateTime now) => new() { IpAddress = IPAddress.TryParse(_currentContext.IpAddress, out var ip) ? ip : null, Timestamp = new DateTimeOffset(now, TimeSpan.Zero), }; - private static string DenyMessage(AccessDecision decision) => decision.Reason switch + private static string DenyMessage(AccessEvaluation evaluation) => evaluation.Reason switch { DenyReason.NotWithinIpRange => "Access to this item is not permitted from your current network.", DenyReason.NotWithinTimeWindow => "Access to this item is not permitted at this time.", diff --git a/src/Core/Pam/OrganizationFeatures/Commands/UpdateAccessRuleCommand.cs b/src/Core/Pam/OrganizationFeatures/Commands/UpdateAccessRuleCommand.cs index 16b34e367d70..d0a113686855 100644 --- a/src/Core/Pam/OrganizationFeatures/Commands/UpdateAccessRuleCommand.cs +++ b/src/Core/Pam/OrganizationFeatures/Commands/UpdateAccessRuleCommand.cs @@ -41,7 +41,7 @@ public async Task UpdateAsync(Guid organizationId, Guid id, A throw new NotFoundException(); } - var validation = _validator.Validate(update.Rule); + var validation = _validator.Validate(update.Conditions); if (!validation.IsValid) { throw new BadRequestException(validation.Error!); @@ -63,7 +63,7 @@ public async Task UpdateAsync(Guid organizationId, Guid id, A OrganizationId = existing.OrganizationId, Name = update.Name, Description = update.Description, - Rule = update.Rule, + Conditions = update.Conditions, CreationDate = existing.CreationDate, RevisionDate = _timeProvider.GetUtcNow().UtcDateTime, }; diff --git a/src/Core/Pam/OrganizationFeatures/Queries/AccessPreCheckQuery.cs b/src/Core/Pam/OrganizationFeatures/Queries/AccessPreCheckQuery.cs index 72425c95d33d..6a92e791d73b 100644 --- a/src/Core/Pam/OrganizationFeatures/Queries/AccessPreCheckQuery.cs +++ b/src/Core/Pam/OrganizationFeatures/Queries/AccessPreCheckQuery.cs @@ -11,19 +11,19 @@ namespace Bit.Core.Pam.OrganizationFeatures.Queries; public class AccessPreCheckQuery : IAccessPreCheckQuery { private readonly ICipherRepository _cipherRepository; - private readonly IAccessApprovalResolver _resolver; - private readonly ILeaseRepository _leaseRepository; + private readonly IGoverningRuleResolver _resolver; + private readonly IAccessLeaseRepository _accessLeaseRepository; private readonly TimeProvider _timeProvider; public AccessPreCheckQuery( ICipherRepository cipherRepository, - IAccessApprovalResolver resolver, - ILeaseRepository leaseRepository, + IGoverningRuleResolver resolver, + IAccessLeaseRepository accessLeaseRepository, TimeProvider timeProvider) { _cipherRepository = cipherRepository; _resolver = resolver; - _leaseRepository = leaseRepository; + _accessLeaseRepository = accessLeaseRepository; _timeProvider = timeProvider; } @@ -39,17 +39,17 @@ public async Task PreCheckAsync(Guid userId, Guid cipherId var now = _timeProvider.GetUtcNow().UtcDateTime; // A caller who already holds an active lease should be sent straight to the credential, not prompted to make - // a request that RequestAccessCommand would reject. This mirrors the active-lease guard there. - if (await _leaseRepository.GetActiveByRequesterIdCipherIdAsync(userId, cipherId, now) is not null) + // a request that SubmitAccessRequestCommand would reject. This mirrors the active-lease guard there. + if (await _accessLeaseRepository.GetActiveByRequesterIdCipherIdAsync(userId, cipherId, now) is not null) { - return new AccessPreCheckResult(AccessApprovalOutcome.Automatic, HasActiveLease: true); + return new AccessPreCheckResult(AccessApprovalMode.Automatic, HasActiveLease: true); } - var resolution = await _resolver.ResolveAsync(userId, cipherId); - var outcome = resolution?.RequiresHumanApproval == true - ? AccessApprovalOutcome.Human - : AccessApprovalOutcome.Automatic; + var governingRule = await _resolver.ResolveAsync(userId, cipherId); + var approvalMode = governingRule?.RequiresHumanApproval == true + ? AccessApprovalMode.Human + : AccessApprovalMode.Automatic; - return new AccessPreCheckResult(outcome); + return new AccessPreCheckResult(approvalMode); } } diff --git a/src/Core/Pam/OrganizationFeatures/Queries/GetCipherLeaseStateQuery.cs b/src/Core/Pam/OrganizationFeatures/Queries/GetCipherAccessStateQuery.cs similarity index 63% rename from src/Core/Pam/OrganizationFeatures/Queries/GetCipherLeaseStateQuery.cs rename to src/Core/Pam/OrganizationFeatures/Queries/GetCipherAccessStateQuery.cs index ec253426af64..206f0e964968 100644 --- a/src/Core/Pam/OrganizationFeatures/Queries/GetCipherLeaseStateQuery.cs +++ b/src/Core/Pam/OrganizationFeatures/Queries/GetCipherAccessStateQuery.cs @@ -8,29 +8,29 @@ namespace Bit.Core.Pam.OrganizationFeatures.Queries; -public class GetCipherLeaseStateQuery : IGetCipherLeaseStateQuery +public class GetCipherAccessStateQuery : IGetCipherAccessStateQuery { private readonly ICipherRepository _cipherRepository; - private readonly IAccessApprovalResolver _resolver; - private readonly ILeaseRepository _leaseRepository; - private readonly ILeaseRequestRepository _leaseRequestRepository; + private readonly IGoverningRuleResolver _resolver; + private readonly IAccessLeaseRepository _accessLeaseRepository; + private readonly IAccessRequestRepository _accessRequestRepository; private readonly TimeProvider _timeProvider; - public GetCipherLeaseStateQuery( + public GetCipherAccessStateQuery( ICipherRepository cipherRepository, - IAccessApprovalResolver resolver, - ILeaseRepository leaseRepository, - ILeaseRequestRepository leaseRequestRepository, + IGoverningRuleResolver resolver, + IAccessLeaseRepository accessLeaseRepository, + IAccessRequestRepository accessRequestRepository, TimeProvider timeProvider) { _cipherRepository = cipherRepository; _resolver = resolver; - _leaseRepository = leaseRepository; - _leaseRequestRepository = leaseRequestRepository; + _accessLeaseRepository = accessLeaseRepository; + _accessRequestRepository = accessRequestRepository; _timeProvider = timeProvider; } - public async Task GetStateAsync(Guid userId, Guid cipherId) + public async Task GetStateAsync(Guid userId, Guid cipherId) { // GetByIdAsync filters by access, so a null result means the caller cannot see the cipher. var cipher = await _cipherRepository.GetByIdAsync(cipherId, userId); @@ -40,8 +40,8 @@ public async Task GetStateAsync(Guid userId, Guid cipher } var now = _timeProvider.GetUtcNow().UtcDateTime; - var activeLease = await _leaseRepository.GetActiveByRequesterIdCipherIdAsync(userId, cipherId, now); - var pending = await _leaseRequestRepository.GetActivePendingByRequesterIdCipherIdAsync(userId, cipherId); + var activeLease = await _accessLeaseRepository.GetActiveByRequesterIdCipherIdAsync(userId, cipherId, now); + var pending = await _accessRequestRepository.GetActivePendingByRequesterIdCipherIdAsync(userId, cipherId); // 404 when the cipher isn't leasing-gated and there's nothing to report. We still return a snapshot when the // caller holds a lease or a pending request even if the rule was since removed, so their state isn't hidden. @@ -50,15 +50,15 @@ public async Task GetStateAsync(Guid userId, Guid cipher throw new NotFoundException(); } - return new CipherLeaseStateResult(cipherId, activeLease, pending is null ? null : ToDetails(pending)); + return new CipherAccessState(cipherId, activeLease, pending is null ? null : ToDetails(pending)); } // A pending request has produced no lease and has no resolver yet; the inbox display-name fields aren't needed for // this caller-scoped snapshot, so they stay null. - private static InboxLeaseRequestDetails ToDetails(LeaseRequest request) => new() + private static AccessRequestDetails ToDetails(AccessRequest request) => new() { Id = request.Id, - ExtensionOfLeaseId = request.LeaseId, + ExtensionOfLeaseId = request.ExtensionOfLeaseId, OrganizationId = request.OrganizationId, CollectionId = request.CollectionId, CipherId = request.CipherId, diff --git a/src/Core/Pam/OrganizationFeatures/Queries/GetLeasedCipherQuery.cs b/src/Core/Pam/OrganizationFeatures/Queries/GetLeasedCipherQuery.cs index d26f68278dd5..2f299eea258d 100644 --- a/src/Core/Pam/OrganizationFeatures/Queries/GetLeasedCipherQuery.cs +++ b/src/Core/Pam/OrganizationFeatures/Queries/GetLeasedCipherQuery.cs @@ -12,24 +12,24 @@ namespace Bit.Core.Pam.OrganizationFeatures.Queries; public class GetLeasedCipherQuery : IGetLeasedCipherQuery { private readonly ICipherRepository _cipherRepository; - private readonly ILeaseRepository _leaseRepository; - private readonly IAccessApprovalResolver _resolver; - private readonly IAccessPolicyEngine _policyEngine; + private readonly IAccessLeaseRepository _accessLeaseRepository; + private readonly IGoverningRuleResolver _resolver; + private readonly IAccessRuleEngine _ruleEngine; private readonly ICurrentContext _currentContext; private readonly TimeProvider _timeProvider; public GetLeasedCipherQuery( ICipherRepository cipherRepository, - ILeaseRepository leaseRepository, - IAccessApprovalResolver resolver, - IAccessPolicyEngine policyEngine, + IAccessLeaseRepository accessLeaseRepository, + IGoverningRuleResolver resolver, + IAccessRuleEngine ruleEngine, ICurrentContext currentContext, TimeProvider timeProvider) { _cipherRepository = cipherRepository; - _leaseRepository = leaseRepository; + _accessLeaseRepository = accessLeaseRepository; _resolver = resolver; - _policyEngine = policyEngine; + _ruleEngine = ruleEngine; _currentContext = currentContext; _timeProvider = timeProvider; } @@ -39,7 +39,7 @@ public GetLeasedCipherQuery( var now = _timeProvider.GetUtcNow(); // Without an active lease whose window contains now, the caller is not entitled to the full data right now. - var lease = await _leaseRepository.GetActiveByRequesterIdCipherIdAsync(userId, cipherId, now.UtcDateTime); + var lease = await _accessLeaseRepository.GetActiveByRequesterIdCipherIdAsync(userId, cipherId, now.UtcDateTime); if (lease is null) { return null; @@ -48,16 +48,16 @@ public GetLeasedCipherQuery( // A lease grants a window, but the access rule's environmental conditions (source IP, time of day) must // still hold at the moment the data is handed over. Approval is not re-checked here: holding the lease is // proof it was already granted, so only an outright denial withholds the data. - var resolution = await _resolver.ResolveAsync(userId, cipherId); - if (resolution is not null) + var governingRule = await _resolver.ResolveAsync(userId, cipherId); + if (governingRule is not null) { - var signals = new AccessPolicySignals + var signals = new AccessSignals { IpAddress = IPAddress.TryParse(_currentContext.IpAddress, out var ip) ? ip : null, Timestamp = now, }; - if (_policyEngine.Evaluate(resolution.Rule, signals).Kind == DecisionKind.Deny) + if (_ruleEngine.Evaluate(governingRule.Condition, signals).Outcome == AccessEvaluationOutcome.Deny) { return null; } diff --git a/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IGetCipherLeaseStateQuery.cs b/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IGetCipherAccessStateQuery.cs similarity index 79% rename from src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IGetCipherLeaseStateQuery.cs rename to src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IGetCipherAccessStateQuery.cs index a9a6e680ee78..faf668df6fb7 100644 --- a/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IGetCipherLeaseStateQuery.cs +++ b/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IGetCipherAccessStateQuery.cs @@ -2,12 +2,12 @@ namespace Bit.Core.Pam.OrganizationFeatures.Queries.Interfaces; -public interface IGetCipherLeaseStateQuery +public interface IGetCipherAccessStateQuery { /// /// Returns the caller's lease state for a single leasing-gated cipher — their active lease and pending request, if /// any. Throws when the caller cannot see the cipher, or when /// the cipher is not leasing-gated and the caller holds nothing to report. /// - Task GetStateAsync(Guid userId, Guid cipherId); + Task GetStateAsync(Guid userId, Guid cipherId); } diff --git a/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IGetInboxHistoryQuery.cs b/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IListInboxHistoryQuery.cs similarity index 75% rename from src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IGetInboxHistoryQuery.cs rename to src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IListInboxHistoryQuery.cs index cb97c1919714..9fa4a0f21c08 100644 --- a/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IGetInboxHistoryQuery.cs +++ b/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IListInboxHistoryQuery.cs @@ -2,11 +2,11 @@ namespace Bit.Core.Pam.OrganizationFeatures.Queries.Interfaces; -public interface IGetInboxHistoryQuery +public interface IListInboxHistoryQuery { /// /// Returns the resolved lease requests (no longer pending) the user can approve, within the history retention /// window, for collections the user can Manage. Returns an empty collection when the user manages none. /// - Task> GetHistoryAsync(Guid userId); + Task> GetHistoryAsync(Guid userId); } diff --git a/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IGetInboxRequestsQuery.cs b/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IListInboxRequestsQuery.cs similarity index 72% rename from src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IGetInboxRequestsQuery.cs rename to src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IListInboxRequestsQuery.cs index bfb8c67cf8ca..8806ce94c9c7 100644 --- a/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IGetInboxRequestsQuery.cs +++ b/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IListInboxRequestsQuery.cs @@ -2,11 +2,11 @@ namespace Bit.Core.Pam.OrganizationFeatures.Queries.Interfaces; -public interface IGetInboxRequestsQuery +public interface IListInboxRequestsQuery { /// /// Returns the pending lease requests the user can approve — those on collections the user can Manage. Returns an /// empty collection when the user manages none. /// - Task> GetPendingAsync(Guid userId); + Task> GetPendingAsync(Guid userId); } diff --git a/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IListMyAccessRequestsQuery.cs b/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IListMyAccessRequestsQuery.cs index 1f4004583802..6e600eafdc9f 100644 --- a/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IListMyAccessRequestsQuery.cs +++ b/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IListMyAccessRequestsQuery.cs @@ -8,5 +8,5 @@ public interface IListMyAccessRequestsQuery /// Returns the caller's own lease requests across every organization they belong to, regardless of status. Returns /// an empty collection when they have none. /// - Task> GetMineAsync(Guid userId); + Task> GetMineAsync(Guid userId); } diff --git a/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IListMyActiveLeasesQuery.cs b/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IListMyActiveAccessLeasesQuery.cs similarity index 71% rename from src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IListMyActiveLeasesQuery.cs rename to src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IListMyActiveAccessLeasesQuery.cs index 298b334041f1..bb28775d63a8 100644 --- a/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IListMyActiveLeasesQuery.cs +++ b/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IListMyActiveAccessLeasesQuery.cs @@ -2,11 +2,11 @@ namespace Bit.Core.Pam.OrganizationFeatures.Queries.Interfaces; -public interface IListMyActiveLeasesQuery +public interface IListMyActiveAccessLeasesQuery { /// /// Returns the caller's currently-active leases across every organization they belong to. Returns an empty /// collection when none are active. /// - Task> GetMineActiveAsync(Guid userId); + Task> GetMineActiveAsync(Guid userId); } diff --git a/src/Core/Pam/OrganizationFeatures/Queries/GetInboxHistoryQuery.cs b/src/Core/Pam/OrganizationFeatures/Queries/ListInboxHistoryQuery.cs similarity index 65% rename from src/Core/Pam/OrganizationFeatures/Queries/GetInboxHistoryQuery.cs rename to src/Core/Pam/OrganizationFeatures/Queries/ListInboxHistoryQuery.cs index 102b4efc135b..0249b20d11ac 100644 --- a/src/Core/Pam/OrganizationFeatures/Queries/GetInboxHistoryQuery.cs +++ b/src/Core/Pam/OrganizationFeatures/Queries/ListInboxHistoryQuery.cs @@ -5,7 +5,7 @@ namespace Bit.Core.Pam.OrganizationFeatures.Queries; -public class GetInboxHistoryQuery : IGetInboxHistoryQuery +public class ListInboxHistoryQuery : IListInboxHistoryQuery { /// /// How far back the resolved history reaches. Older activity may be omitted. v1 has no pagination. @@ -13,28 +13,28 @@ public class GetInboxHistoryQuery : IGetInboxHistoryQuery public const int HistoryRetentionDays = 90; private readonly IApproverCollectionAccessQuery _approverCollectionAccessQuery; - private readonly ILeaseRequestRepository _leaseRequestRepository; + private readonly IAccessRequestRepository _accessRequestRepository; private readonly TimeProvider _timeProvider; - public GetInboxHistoryQuery( + public ListInboxHistoryQuery( IApproverCollectionAccessQuery approverCollectionAccessQuery, - ILeaseRequestRepository leaseRequestRepository, + IAccessRequestRepository accessRequestRepository, TimeProvider timeProvider) { _approverCollectionAccessQuery = approverCollectionAccessQuery; - _leaseRequestRepository = leaseRequestRepository; + _accessRequestRepository = accessRequestRepository; _timeProvider = timeProvider; } - public async Task> GetHistoryAsync(Guid userId) + public async Task> GetHistoryAsync(Guid userId) { var manageableCollectionIds = await _approverCollectionAccessQuery.GetManageableCollectionIdsAsync(userId); if (manageableCollectionIds.Count == 0) { - return new List(); + return new List(); } var since = _timeProvider.GetUtcNow().UtcDateTime.AddDays(-HistoryRetentionDays); - return await _leaseRequestRepository.GetManyInboxHistoryByCollectionIdsAsync(manageableCollectionIds, since); + return await _accessRequestRepository.GetManyInboxHistoryByCollectionIdsAsync(manageableCollectionIds, since); } } diff --git a/src/Core/Pam/OrganizationFeatures/Queries/GetInboxRequestsQuery.cs b/src/Core/Pam/OrganizationFeatures/Queries/ListInboxRequestsQuery.cs similarity index 54% rename from src/Core/Pam/OrganizationFeatures/Queries/GetInboxRequestsQuery.cs rename to src/Core/Pam/OrganizationFeatures/Queries/ListInboxRequestsQuery.cs index b1c731500e58..d0901b9976c2 100644 --- a/src/Core/Pam/OrganizationFeatures/Queries/GetInboxRequestsQuery.cs +++ b/src/Core/Pam/OrganizationFeatures/Queries/ListInboxRequestsQuery.cs @@ -5,27 +5,27 @@ namespace Bit.Core.Pam.OrganizationFeatures.Queries; -public class GetInboxRequestsQuery : IGetInboxRequestsQuery +public class ListInboxRequestsQuery : IListInboxRequestsQuery { private readonly IApproverCollectionAccessQuery _approverCollectionAccessQuery; - private readonly ILeaseRequestRepository _leaseRequestRepository; + private readonly IAccessRequestRepository _accessRequestRepository; - public GetInboxRequestsQuery( + public ListInboxRequestsQuery( IApproverCollectionAccessQuery approverCollectionAccessQuery, - ILeaseRequestRepository leaseRequestRepository) + IAccessRequestRepository accessRequestRepository) { _approverCollectionAccessQuery = approverCollectionAccessQuery; - _leaseRequestRepository = leaseRequestRepository; + _accessRequestRepository = accessRequestRepository; } - public async Task> GetPendingAsync(Guid userId) + public async Task> GetPendingAsync(Guid userId) { var manageableCollectionIds = await _approverCollectionAccessQuery.GetManageableCollectionIdsAsync(userId); if (manageableCollectionIds.Count == 0) { - return new List(); + return new List(); } - return await _leaseRequestRepository.GetManyInboxPendingByCollectionIdsAsync(manageableCollectionIds); + return await _accessRequestRepository.GetManyInboxPendingByCollectionIdsAsync(manageableCollectionIds); } } diff --git a/src/Core/Pam/OrganizationFeatures/Queries/ListMyAccessRequestsQuery.cs b/src/Core/Pam/OrganizationFeatures/Queries/ListMyAccessRequestsQuery.cs index 42268118917b..d6959db280a6 100644 --- a/src/Core/Pam/OrganizationFeatures/Queries/ListMyAccessRequestsQuery.cs +++ b/src/Core/Pam/OrganizationFeatures/Queries/ListMyAccessRequestsQuery.cs @@ -6,13 +6,13 @@ namespace Bit.Core.Pam.OrganizationFeatures.Queries; public class ListMyAccessRequestsQuery : IListMyAccessRequestsQuery { - private readonly ILeaseRequestRepository _leaseRequestRepository; + private readonly IAccessRequestRepository _accessRequestRepository; - public ListMyAccessRequestsQuery(ILeaseRequestRepository leaseRequestRepository) + public ListMyAccessRequestsQuery(IAccessRequestRepository accessRequestRepository) { - _leaseRequestRepository = leaseRequestRepository; + _accessRequestRepository = accessRequestRepository; } - public Task> GetMineAsync(Guid userId) => - _leaseRequestRepository.GetManyByRequesterIdAsync(userId); + public Task> GetMineAsync(Guid userId) => + _accessRequestRepository.GetManyByRequesterIdAsync(userId); } diff --git a/src/Core/Pam/OrganizationFeatures/Queries/ListMyActiveAccessLeasesQuery.cs b/src/Core/Pam/OrganizationFeatures/Queries/ListMyActiveAccessLeasesQuery.cs new file mode 100644 index 000000000000..09a818ab4b24 --- /dev/null +++ b/src/Core/Pam/OrganizationFeatures/Queries/ListMyActiveAccessLeasesQuery.cs @@ -0,0 +1,20 @@ +using Bit.Core.Pam.Entities; +using Bit.Core.Pam.OrganizationFeatures.Queries.Interfaces; +using Bit.Core.Pam.Repositories; + +namespace Bit.Core.Pam.OrganizationFeatures.Queries; + +public class ListMyActiveAccessLeasesQuery : IListMyActiveAccessLeasesQuery +{ + private readonly IAccessLeaseRepository _accessLeaseRepository; + private readonly TimeProvider _timeProvider; + + public ListMyActiveAccessLeasesQuery(IAccessLeaseRepository accessLeaseRepository, TimeProvider timeProvider) + { + _accessLeaseRepository = accessLeaseRepository; + _timeProvider = timeProvider; + } + + public Task> GetMineActiveAsync(Guid userId) => + _accessLeaseRepository.GetManyActiveByRequesterIdAsync(userId, _timeProvider.GetUtcNow().UtcDateTime); +} diff --git a/src/Core/Pam/OrganizationFeatures/Queries/ListMyActiveLeasesQuery.cs b/src/Core/Pam/OrganizationFeatures/Queries/ListMyActiveLeasesQuery.cs deleted file mode 100644 index ff0c783d2baa..000000000000 --- a/src/Core/Pam/OrganizationFeatures/Queries/ListMyActiveLeasesQuery.cs +++ /dev/null @@ -1,20 +0,0 @@ -using Bit.Core.Pam.Entities; -using Bit.Core.Pam.OrganizationFeatures.Queries.Interfaces; -using Bit.Core.Pam.Repositories; - -namespace Bit.Core.Pam.OrganizationFeatures.Queries; - -public class ListMyActiveLeasesQuery : IListMyActiveLeasesQuery -{ - private readonly ILeaseRepository _leaseRepository; - private readonly TimeProvider _timeProvider; - - public ListMyActiveLeasesQuery(ILeaseRepository leaseRepository, TimeProvider timeProvider) - { - _leaseRepository = leaseRepository; - _timeProvider = timeProvider; - } - - public Task> GetMineActiveAsync(Guid userId) => - _leaseRepository.GetManyActiveByRequesterIdAsync(userId, _timeProvider.GetUtcNow().UtcDateTime); -} diff --git a/src/Core/Pam/Repositories/IAccessLeaseRepository.cs b/src/Core/Pam/Repositories/IAccessLeaseRepository.cs new file mode 100644 index 000000000000..fca9ada8c0f6 --- /dev/null +++ b/src/Core/Pam/Repositories/IAccessLeaseRepository.cs @@ -0,0 +1,33 @@ +using Bit.Core.Pam.Entities; + +namespace Bit.Core.Pam.Repositories; + +public interface IAccessLeaseRepository +{ + Task GetByIdAsync(Guid id); + + /// + /// Returns the caller's active lease for the cipher whose window contains , or null. + /// + Task GetActiveByRequesterIdCipherIdAsync(Guid requesterId, Guid cipherId, DateTime now); + + /// + /// Returns the caller's currently-active leases (status Active, window containing , not + /// revoked) across every organization they belong to. Returns an empty collection when none are active. + /// + Task> GetManyActiveByRequesterIdAsync(Guid requesterId, DateTime now); + + /// + /// Atomically creates an auto-approved , its automatic , and an + /// active in a single transaction. The three entities must already have their ids assigned. + /// This is the only way a is created, so the request, decision, and lease never diverge. + /// + Task CreateAutoApprovedAsync(AccessRequest request, AccessDecision decision, AccessLease lease, DateTime now); + + /// + /// Atomically revokes an active lease (setting its revoked date and revoker) and records the revocation reason as + /// a human against the lease's originating request. The decision must already + /// have its id assigned. + /// + Task RevokeAsync(AccessLease lease, AccessDecision auditDecision, DateTime now); +} diff --git a/src/Core/Pam/Repositories/ILeaseRequestRepository.cs b/src/Core/Pam/Repositories/IAccessRequestRepository.cs similarity index 67% rename from src/Core/Pam/Repositories/ILeaseRequestRepository.cs rename to src/Core/Pam/Repositories/IAccessRequestRepository.cs index fa5ed83f74aa..a04d8579b3bb 100644 --- a/src/Core/Pam/Repositories/ILeaseRequestRepository.cs +++ b/src/Core/Pam/Repositories/IAccessRequestRepository.cs @@ -4,35 +4,35 @@ namespace Bit.Core.Pam.Repositories; -public interface ILeaseRequestRepository +public interface IAccessRequestRepository { - Task CreateAsync(LeaseRequest request); + Task CreateAsync(AccessRequest request); - Task GetByIdAsync(Guid id); + Task GetByIdAsync(Guid id); /// /// Returns the caller's pending (unresolved) lease request for the cipher, or null if there is none. /// - Task GetActivePendingByRequesterIdCipherIdAsync(Guid requesterId, Guid cipherId); + Task GetActivePendingByRequesterIdCipherIdAsync(Guid requesterId, Guid cipherId); /// /// Returns the caller's own lease requests across every organization they belong to, regardless of status, most /// recent first and capped server-side. Display-name fields are not populated for this caller-scoped surface. /// - Task> GetManyByRequesterIdAsync(Guid requesterId); + Task> GetManyByRequesterIdAsync(Guid requesterId); /// /// Returns the pending approver-inbox rows for the given collections, joined with their denormalized display /// fields. An empty yields an empty result. /// - Task> GetManyInboxPendingByCollectionIdsAsync(IEnumerable collectionIds); + Task> GetManyInboxPendingByCollectionIdsAsync(IEnumerable collectionIds); /// /// Returns the resolved approver-inbox rows (anything no longer pending) created on or after /// for the given collections. An empty yields an empty /// result. /// - Task> GetManyInboxHistoryByCollectionIdsAsync(IEnumerable collectionIds, DateTime since); + Task> GetManyInboxHistoryByCollectionIdsAsync(IEnumerable collectionIds, DateTime since); /// /// Atomically transitions a pending request to (setting its resolved date), records the @@ -40,5 +40,5 @@ public interface ILeaseRequestRepository /// that authorizes access, spanning the request's approved window. Pass as null when /// denying. Every supplied entity must already have its id assigned. /// - Task ResolveWithDecisionAsync(LeaseRequest request, LeaseDecision decision, LeaseRequestStatus status, Lease? lease, DateTime now); + Task ResolveWithDecisionAsync(AccessRequest request, AccessDecision decision, AccessRequestStatus status, AccessLease? lease, DateTime now); } diff --git a/src/Core/Pam/Repositories/ILeaseRepository.cs b/src/Core/Pam/Repositories/ILeaseRepository.cs deleted file mode 100644 index 9bec20439779..000000000000 --- a/src/Core/Pam/Repositories/ILeaseRepository.cs +++ /dev/null @@ -1,33 +0,0 @@ -using Bit.Core.Pam.Entities; - -namespace Bit.Core.Pam.Repositories; - -public interface ILeaseRepository -{ - Task GetByIdAsync(Guid id); - - /// - /// Returns the caller's active lease for the cipher whose window contains , or null. - /// - Task GetActiveByRequesterIdCipherIdAsync(Guid requesterId, Guid cipherId, DateTime now); - - /// - /// Returns the caller's currently-active leases (status Active, window containing , not - /// revoked) across every organization they belong to. Returns an empty collection when none are active. - /// - Task> GetManyActiveByRequesterIdAsync(Guid requesterId, DateTime now); - - /// - /// Atomically creates an auto-approved , its policy , and an - /// active in a single transaction. The three entities must already have their ids assigned. - /// This is the only way a is created, so the request, decision, and lease never diverge. - /// - Task CreateAutoApprovedAsync(LeaseRequest request, LeaseDecision decision, Lease lease, DateTime now); - - /// - /// Atomically revokes an active lease (setting its revoked date and revoker) and records the revocation reason as - /// a human against the lease's originating request. The decision must already - /// have its id assigned. - /// - Task RevokeAsync(Lease lease, LeaseDecision auditDecision, DateTime now); -} diff --git a/src/Core/Pam/Services/AccessRuleValidator.cs b/src/Core/Pam/Services/AccessRuleValidator.cs index 574f475cf15d..525c9cc2eec8 100644 --- a/src/Core/Pam/Services/AccessRuleValidator.cs +++ b/src/Core/Pam/Services/AccessRuleValidator.cs @@ -1,7 +1,7 @@ using System.Net; using System.Text.Json; using System.Text.RegularExpressions; -using Bit.Core.Pam.Models.Rules; +using Bit.Core.Pam.Models.Conditions; namespace Bit.Core.Pam.Services; @@ -22,56 +22,56 @@ public sealed partial class AccessRuleValidator : IAccessRuleValidator [GeneratedRegex(@"^([01][0-9]|2[0-3]):[0-5][0-9]$")] private static partial Regex TimeOfDayRegex(); - public AccessRuleValidationResult Validate(string? ruleJson) + public AccessRuleValidationResult Validate(string? conditionsJson) { - if (ruleJson is null) + if (conditionsJson is null) { return AccessRuleValidationResult.Valid; } - if (string.IsNullOrWhiteSpace(ruleJson)) + if (string.IsNullOrWhiteSpace(conditionsJson)) { - return AccessRuleValidationResult.Invalid("Rule JSON cannot be empty."); + return AccessRuleValidationResult.Invalid("Conditions JSON cannot be empty."); } - Rule? rule; + AccessCondition? condition; try { - rule = JsonSerializer.Deserialize(ruleJson, JsonOptions); + condition = JsonSerializer.Deserialize(conditionsJson, JsonOptions); } catch (JsonException ex) { - return AccessRuleValidationResult.Invalid($"Rule JSON is malformed: {ex.Message}"); + return AccessRuleValidationResult.Invalid($"Conditions JSON is malformed: {ex.Message}"); } - if (rule is null) + if (condition is null) { - return AccessRuleValidationResult.Invalid("Rule must be an object."); + return AccessRuleValidationResult.Invalid("Conditions must be an object."); } - return ValidateRule(rule, depth: 0); + return ValidateCondition(condition, depth: 0); } - private static AccessRuleValidationResult ValidateRule(Rule rule, int depth) + private static AccessRuleValidationResult ValidateCondition(AccessCondition condition, int depth) { - return rule switch + return condition switch { - HumanApprovalRule => AccessRuleValidationResult.Valid, - IpAllowlistRule ip => ValidateIpAllowlist(ip), - TimeOfDayRule tod => ValidateTimeOfDay(tod), - AllOfRule all => ValidateAllOf(all, depth), - _ => AccessRuleValidationResult.Invalid($"Unsupported rule kind: {rule.GetType().Name}."), + HumanApprovalCondition => AccessRuleValidationResult.Valid, + IpAllowlistCondition ip => ValidateIpAllowlist(ip), + TimeOfDayCondition tod => ValidateTimeOfDay(tod), + AllOfCondition all => ValidateAllOf(all, depth), + _ => AccessRuleValidationResult.Invalid($"Unsupported condition kind: {condition.GetType().Name}."), }; } - private static AccessRuleValidationResult ValidateIpAllowlist(IpAllowlistRule rule) + private static AccessRuleValidationResult ValidateIpAllowlist(IpAllowlistCondition condition) { - if (rule.Cidrs.Count == 0) + if (condition.Cidrs.Count == 0) { return AccessRuleValidationResult.Invalid("ip_allowlist requires at least one CIDR."); } - foreach (var cidr in rule.Cidrs) + foreach (var cidr in condition.Cidrs) { if (string.IsNullOrWhiteSpace(cidr) || !IPNetwork.TryParse(cidr, out _)) { @@ -82,32 +82,32 @@ private static AccessRuleValidationResult ValidateIpAllowlist(IpAllowlistRule ru return AccessRuleValidationResult.Valid; } - private static AccessRuleValidationResult ValidateTimeOfDay(TimeOfDayRule rule) + private static AccessRuleValidationResult ValidateTimeOfDay(TimeOfDayCondition condition) { - if (string.IsNullOrWhiteSpace(rule.Tz)) + if (string.IsNullOrWhiteSpace(condition.Tz)) { return AccessRuleValidationResult.Invalid("time_of_day requires a tz."); } try { - TimeZoneInfo.FindSystemTimeZoneById(rule.Tz); + TimeZoneInfo.FindSystemTimeZoneById(condition.Tz); } catch (TimeZoneNotFoundException) { - return AccessRuleValidationResult.Invalid($"Unknown timezone: '{rule.Tz}'."); + return AccessRuleValidationResult.Invalid($"Unknown timezone: '{condition.Tz}'."); } catch (InvalidTimeZoneException) { - return AccessRuleValidationResult.Invalid($"Invalid timezone: '{rule.Tz}'."); + return AccessRuleValidationResult.Invalid($"Invalid timezone: '{condition.Tz}'."); } - if (rule.Windows.Count == 0) + if (condition.Windows.Count == 0) { return AccessRuleValidationResult.Invalid("time_of_day requires at least one window."); } - foreach (var window in rule.Windows) + foreach (var window in condition.Windows) { if (window.Days.Count == 0) { @@ -136,26 +136,26 @@ private static AccessRuleValidationResult ValidateTimeOfDay(TimeOfDayRule rule) return AccessRuleValidationResult.Valid; } - private static AccessRuleValidationResult ValidateAllOf(AllOfRule rule, int depth) + private static AccessRuleValidationResult ValidateAllOf(AllOfCondition condition, int depth) { if (depth >= MaxCompositeDepth) { return AccessRuleValidationResult.Invalid($"all_of nesting exceeds maximum depth of {MaxCompositeDepth}."); } - if (rule.Rules.Count == 0) + if (condition.Conditions.Count == 0) { - return AccessRuleValidationResult.Invalid("all_of requires at least one child rule."); + return AccessRuleValidationResult.Invalid("all_of requires at least one child condition."); } - if (rule.Rules.Count > MaxCompositeChildren) + if (condition.Conditions.Count > MaxCompositeChildren) { - return AccessRuleValidationResult.Invalid($"all_of cannot contain more than {MaxCompositeChildren} child rules."); + return AccessRuleValidationResult.Invalid($"all_of cannot contain more than {MaxCompositeChildren} child conditions."); } - foreach (var child in rule.Rules) + foreach (var child in condition.Conditions) { - var childResult = ValidateRule(child, depth + 1); + var childResult = ValidateCondition(child, depth + 1); if (!childResult.IsValid) { return childResult; diff --git a/src/Core/Pam/Services/AccessApprovalResolver.cs b/src/Core/Pam/Services/GoverningRuleResolver.cs similarity index 58% rename from src/Core/Pam/Services/AccessApprovalResolver.cs rename to src/Core/Pam/Services/GoverningRuleResolver.cs index a7f505c5f866..79e69c4eb480 100644 --- a/src/Core/Pam/Services/AccessApprovalResolver.cs +++ b/src/Core/Pam/Services/GoverningRuleResolver.cs @@ -1,12 +1,12 @@ -using System.Text.Json; +using System.Text.Json; using Bit.Core.Pam.Models; -using Bit.Core.Pam.Models.Rules; +using Bit.Core.Pam.Models.Conditions; using Bit.Core.Pam.Repositories; using Bit.Core.Repositories; namespace Bit.Core.Pam.Services; -public class AccessApprovalResolver : IAccessApprovalResolver +public class GoverningRuleResolver : IGoverningRuleResolver { private static readonly JsonSerializerOptions _jsonOptions = new() { @@ -18,7 +18,7 @@ public class AccessApprovalResolver : IAccessApprovalResolver private readonly ICollectionRepository _collectionRepository; private readonly IAccessRuleRepository _accessRuleRepository; - public AccessApprovalResolver( + public GoverningRuleResolver( ICollectionCipherRepository collectionCipherRepository, ICollectionRepository collectionRepository, IAccessRuleRepository accessRuleRepository) @@ -28,7 +28,7 @@ public AccessApprovalResolver( _accessRuleRepository = accessRuleRepository; } - public async Task ResolveAsync(Guid userId, Guid cipherId) + public async Task ResolveAsync(Guid userId, Guid cipherId) { var collectionCiphers = await _collectionCipherRepository.GetManyByUserIdCipherIdAsync(userId, cipherId); if (collectionCiphers.Count == 0) @@ -44,7 +44,7 @@ public AccessApprovalResolver( .Where(c => collectionIds.Contains(c.Id) && c.AccessRuleId.HasValue) .OrderBy(c => c.Id); - AccessApprovalResolution? automatic = null; + GoverningRule? automatic = null; foreach (var collection in governed) { var accessRule = await _accessRuleRepository.GetByIdAsync(collection.AccessRuleId!.Value); @@ -53,40 +53,41 @@ public AccessApprovalResolver( continue; } - var rule = Parse(accessRule.Rule); - if (ContainsHumanApproval(rule)) + var condition = Parse(accessRule.Conditions); + if (ContainsHumanApproval(condition)) { - // Most restrictive wins — return as soon as a human-approval rule is found. - return new AccessApprovalResolution(collection.OrganizationId, collection.Id, true, rule); + // Most restrictive wins — return as soon as a human-approval condition is found. + return new GoverningRule(collection.OrganizationId, collection.Id, true, condition); } - automatic ??= new AccessApprovalResolution(collection.OrganizationId, collection.Id, false, rule); + automatic ??= new GoverningRule(collection.OrganizationId, collection.Id, false, condition); } return automatic; } /// - /// Parses the stored rule JSON into a . A malformed or unparseable rule fails safe to a - /// so access is never silently auto-approved on a rule the server could not - /// understand; the human-approval path then routes it to an approver rather than issuing an automatic lease. + /// Parses the stored conditions JSON into an . A malformed or unparseable document + /// fails safe to a so access is never silently auto-approved on conditions + /// the server could not understand; the human-approval path then routes it to an approver rather than issuing an + /// automatic lease. /// - private static Rule Parse(string ruleJson) + private static AccessCondition Parse(string conditionsJson) { try { - return JsonSerializer.Deserialize(ruleJson, _jsonOptions) ?? new HumanApprovalRule(); + return JsonSerializer.Deserialize(conditionsJson, _jsonOptions) ?? new HumanApprovalCondition(); } catch (JsonException) { - return new HumanApprovalRule(); + return new HumanApprovalCondition(); } } - private static bool ContainsHumanApproval(Rule rule) => rule switch + private static bool ContainsHumanApproval(AccessCondition condition) => condition switch { - HumanApprovalRule => true, - AllOfRule all => all.Rules.Any(ContainsHumanApproval), + HumanApprovalCondition => true, + AllOfCondition all => all.Conditions.Any(ContainsHumanApproval), _ => false, }; } diff --git a/src/Core/Pam/Services/IAccessRuleValidator.cs b/src/Core/Pam/Services/IAccessRuleValidator.cs index 585fb800c614..1215e4e160dd 100644 --- a/src/Core/Pam/Services/IAccessRuleValidator.cs +++ b/src/Core/Pam/Services/IAccessRuleValidator.cs @@ -3,10 +3,10 @@ public interface IAccessRuleValidator { /// - /// Validates a raw JSON rule. A null or empty rule is treated as "no rule + /// Validates a raw JSON conditions document. A null or empty document is treated as "no conditions /// configured" and considered valid; callers decide how to treat that semantically. /// - AccessRuleValidationResult Validate(string? ruleJson); + AccessRuleValidationResult Validate(string? conditionsJson); } public sealed record AccessRuleValidationResult(bool IsValid, string? Error) diff --git a/src/Core/Pam/Services/IAccessApprovalResolver.cs b/src/Core/Pam/Services/IGoverningRuleResolver.cs similarity index 77% rename from src/Core/Pam/Services/IAccessApprovalResolver.cs rename to src/Core/Pam/Services/IGoverningRuleResolver.cs index 583ed8c10301..f0f0d065a0a7 100644 --- a/src/Core/Pam/Services/IAccessApprovalResolver.cs +++ b/src/Core/Pam/Services/IGoverningRuleResolver.cs @@ -2,12 +2,12 @@ namespace Bit.Core.Pam.Services; -public interface IAccessApprovalResolver +public interface IGoverningRuleResolver { /// /// Resolves the leasing context that governs for the caller, or null when the cipher /// is not leasing-gated for them (no reachable collection carries an access rule). When more than one governing /// collection applies, the most restrictive (human-approval) one wins. /// - Task ResolveAsync(Guid userId, Guid cipherId); + Task ResolveAsync(Guid userId, Guid cipherId); } diff --git a/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs b/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs index fa15292b4923..5ceb7a742e9e 100644 --- a/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs +++ b/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs @@ -56,8 +56,8 @@ 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/LeaseRepository.cs b/src/Infrastructure.Dapper/Pam/Repositories/AccessLeaseRepository.cs similarity index 56% rename from src/Infrastructure.Dapper/Pam/Repositories/LeaseRepository.cs rename to src/Infrastructure.Dapper/Pam/Repositories/AccessLeaseRepository.cs index 0fb261d333d2..d28333c751f2 100644 --- a/src/Infrastructure.Dapper/Pam/Repositories/LeaseRepository.cs +++ b/src/Infrastructure.Dapper/Pam/Repositories/AccessLeaseRepository.cs @@ -10,48 +10,48 @@ namespace Bit.Infrastructure.Dapper.Pam.Repositories; -public class LeaseRepository : Repository, ILeaseRepository +public class AccessLeaseRepository : Repository, IAccessLeaseRepository { - public LeaseRepository(GlobalSettings globalSettings) + public AccessLeaseRepository(GlobalSettings globalSettings) : this(globalSettings.SqlServer.ConnectionString, globalSettings.SqlServer.ReadOnlyConnectionString) { } - public LeaseRepository(string connectionString, string readOnlyConnectionString) + public AccessLeaseRepository(string connectionString, string readOnlyConnectionString) : base(connectionString, readOnlyConnectionString) { } - public async Task GetActiveByRequesterIdCipherIdAsync(Guid requesterId, Guid cipherId, DateTime now) + public async Task GetActiveByRequesterIdCipherIdAsync(Guid requesterId, Guid cipherId, DateTime now) { await using var connection = new SqlConnection(ConnectionString); - var results = await connection.QueryAsync( - $"[{Schema}].[Lease_ReadActiveByRequesterIdCipherId]", + var results = await connection.QueryAsync( + $"[{Schema}].[AccessLease_ReadActiveByRequesterIdCipherId]", new { RequesterId = requesterId, CipherId = cipherId, Now = now }, commandType: CommandType.StoredProcedure); return results.FirstOrDefault(); } - public async Task> GetManyActiveByRequesterIdAsync(Guid requesterId, DateTime now) + public async Task> GetManyActiveByRequesterIdAsync(Guid requesterId, DateTime now) { await using var connection = new SqlConnection(ConnectionString); - var results = await connection.QueryAsync( - $"[{Schema}].[Lease_ReadManyActiveByRequesterId]", + var results = await connection.QueryAsync( + $"[{Schema}].[AccessLease_ReadManyActiveByRequesterId]", new { RequesterId = requesterId, Now = now }, commandType: CommandType.StoredProcedure); return results.ToList(); } - public async Task CreateAutoApprovedAsync(LeaseRequest request, LeaseDecision decision, Lease lease, DateTime now) + public async Task CreateAutoApprovedAsync(AccessRequest request, AccessDecision decision, AccessLease lease, DateTime now) { await using var connection = new SqlConnection(ConnectionString); await connection.ExecuteAsync( - $"[{Schema}].[Lease_CreateAutoApproved]", + $"[{Schema}].[AccessLease_CreateAutoApproved]", new { - LeaseRequestId = request.Id, - LeaseId = lease.Id, - LeaseDecisionId = decision.Id, + AccessRequestId = request.Id, + AccessLeaseId = lease.Id, + AccessDecisionId = decision.Id, request.OrganizationId, request.CollectionId, request.CipherId, @@ -59,23 +59,23 @@ await connection.ExecuteAsync( request.NotBefore, request.NotAfter, request.Reason, - decision.PolicyKind, + decision.ConditionKind, Now = now, }, commandType: CommandType.StoredProcedure); } - public async Task RevokeAsync(Lease lease, LeaseDecision auditDecision, DateTime now) + public async Task RevokeAsync(AccessLease lease, AccessDecision auditDecision, DateTime now) { await using var connection = new SqlConnection(ConnectionString); await connection.ExecuteAsync( - $"[{Schema}].[Lease_Revoke]", + $"[{Schema}].[AccessLease_Revoke]", new { - LeaseId = lease.Id, - LeaseRequestId = lease.LeaseRequestId, + AccessLeaseId = lease.Id, + AccessRequestId = lease.AccessRequestId, RevokedBy = auditDecision.ApproverId, - LeaseDecisionId = auditDecision.Id, + AccessDecisionId = auditDecision.Id, Reason = auditDecision.Comment, Now = now, }, diff --git a/src/Infrastructure.Dapper/Pam/Repositories/LeaseRequestRepository.cs b/src/Infrastructure.Dapper/Pam/Repositories/AccessRequestRepository.cs similarity index 52% rename from src/Infrastructure.Dapper/Pam/Repositories/LeaseRequestRepository.cs rename to src/Infrastructure.Dapper/Pam/Repositories/AccessRequestRepository.cs index 30ec4e6d27ab..aac27f1f93c8 100644 --- a/src/Infrastructure.Dapper/Pam/Repositories/LeaseRequestRepository.cs +++ b/src/Infrastructure.Dapper/Pam/Repositories/AccessRequestRepository.cs @@ -12,86 +12,86 @@ namespace Bit.Infrastructure.Dapper.Pam.Repositories; -public class LeaseRequestRepository : Repository, ILeaseRequestRepository +public class AccessRequestRepository : Repository, IAccessRequestRepository { - public LeaseRequestRepository(GlobalSettings globalSettings) + public AccessRequestRepository(GlobalSettings globalSettings) : this(globalSettings.SqlServer.ConnectionString, globalSettings.SqlServer.ReadOnlyConnectionString) { } - public LeaseRequestRepository(string connectionString, string readOnlyConnectionString) + public AccessRequestRepository(string connectionString, string readOnlyConnectionString) : base(connectionString, readOnlyConnectionString) { } - public async Task GetActivePendingByRequesterIdCipherIdAsync(Guid requesterId, Guid cipherId) + public async Task GetActivePendingByRequesterIdCipherIdAsync(Guid requesterId, Guid cipherId) { await using var connection = new SqlConnection(ConnectionString); - var results = await connection.QueryAsync( - $"[{Schema}].[LeaseRequest_ReadActivePendingByRequesterIdCipherId]", + var results = await connection.QueryAsync( + $"[{Schema}].[AccessRequest_ReadActivePendingByRequesterIdCipherId]", new { RequesterId = requesterId, CipherId = cipherId }, commandType: CommandType.StoredProcedure); return results.FirstOrDefault(); } - public async Task> GetManyByRequesterIdAsync(Guid requesterId) + public async Task> GetManyByRequesterIdAsync(Guid requesterId) { await using var connection = new SqlConnection(ConnectionString); - var results = await connection.QueryAsync( - $"[{Schema}].[LeaseRequest_ReadManyByRequesterId]", + var results = await connection.QueryAsync( + $"[{Schema}].[AccessRequest_ReadManyByRequesterId]", new { RequesterId = requesterId }, commandType: CommandType.StoredProcedure); return results.ToList(); } - public async Task> GetManyInboxPendingByCollectionIdsAsync(IEnumerable collectionIds) + public async Task> GetManyInboxPendingByCollectionIdsAsync(IEnumerable collectionIds) { var ids = collectionIds.ToList(); if (ids.Count == 0) { - return new List(); + return new List(); } await using var connection = new SqlConnection(ConnectionString); - var results = await connection.QueryAsync( - $"[{Schema}].[LeaseRequest_ReadInboxPendingByCollectionIds]", + var results = await connection.QueryAsync( + $"[{Schema}].[AccessRequest_ReadInboxPendingByCollectionIds]", new { CollectionIds = ids.ToGuidIdArrayTVP() }, commandType: CommandType.StoredProcedure); return results.ToList(); } - public async Task> GetManyInboxHistoryByCollectionIdsAsync(IEnumerable collectionIds, DateTime since) + public async Task> GetManyInboxHistoryByCollectionIdsAsync(IEnumerable collectionIds, DateTime since) { var ids = collectionIds.ToList(); if (ids.Count == 0) { - return new List(); + return new List(); } await using var connection = new SqlConnection(ConnectionString); - var results = await connection.QueryAsync( - $"[{Schema}].[LeaseRequest_ReadInboxHistoryByCollectionIds]", + var results = await connection.QueryAsync( + $"[{Schema}].[AccessRequest_ReadInboxHistoryByCollectionIds]", new { CollectionIds = ids.ToGuidIdArrayTVP(), Since = since }, commandType: CommandType.StoredProcedure); return results.ToList(); } - public async Task ResolveWithDecisionAsync(LeaseRequest request, LeaseDecision decision, LeaseRequestStatus status, Lease? lease, DateTime now) + public async Task ResolveWithDecisionAsync(AccessRequest request, AccessDecision decision, AccessRequestStatus status, AccessLease? lease, DateTime now) { await using var connection = new SqlConnection(ConnectionString); await connection.ExecuteAsync( - $"[{Schema}].[LeaseRequest_ResolveWithDecision]", + $"[{Schema}].[AccessRequest_ResolveWithDecision]", new { - LeaseRequestId = request.Id, + AccessRequestId = request.Id, Status = status, - LeaseDecisionId = decision.Id, + AccessDecisionId = decision.Id, ApproverId = decision.ApproverId, - Decision = decision.Decision, + Verdict = decision.Verdict, decision.Comment, - LeaseId = lease?.Id, + AccessLeaseId = lease?.Id, Now = now, }, commandType: CommandType.StoredProcedure); diff --git a/src/Sql/dbo/Pam/Stored Procedures/AccessLease_CreateAutoApproved.sql b/src/Sql/dbo/Pam/Stored Procedures/AccessLease_CreateAutoApproved.sql new file mode 100644 index 000000000000..691089752135 --- /dev/null +++ b/src/Sql/dbo/Pam/Stored Procedures/AccessLease_CreateAutoApproved.sql @@ -0,0 +1,56 @@ +CREATE PROCEDURE [dbo].[AccessLease_CreateAutoApproved] + @AccessRequestId UNIQUEIDENTIFIER, + @AccessLeaseId UNIQUEIDENTIFIER, + @AccessDecisionId UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @CollectionId UNIQUEIDENTIFIER, + @CipherId UNIQUEIDENTIFIER, + @RequesterId UNIQUEIDENTIFIER, + @NotBefore DATETIME2(7), + @NotAfter DATETIME2(7), + @Reason NVARCHAR(MAX) = NULL, + @ConditionKind NVARCHAR(50) = NULL, + @Now DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + BEGIN TRANSACTION AccessLease_CreateAutoApproved + + -- The request is created already resolved (Approved). ExtensionOfLeaseId stays NULL: it is reserved for extension + -- requests; provenance for an original lease flows the other way, via AccessLease.AccessRequestId. + INSERT INTO [dbo].[AccessRequest] + ( + [Id], [ExtensionOfLeaseId], [OrganizationId], [CollectionId], [CipherId], [RequesterId], + [NotBefore], [NotAfter], [Reason], [Status], [CreationDate], [ResolvedDate] + ) + VALUES + ( + @AccessRequestId, NULL, @OrganizationId, @CollectionId, @CipherId, @RequesterId, + @NotBefore, @NotAfter, @Reason, 1 /* Approved */, @Now, @Now + ) + + INSERT INTO [dbo].[AccessDecision] + ( + [Id], [AccessRequestId], [DeciderKind], [ApproverId], [ConditionKind], + [Verdict], [Comment], [EvaluationContext], [CreationDate] + ) + VALUES + ( + @AccessDecisionId, @AccessRequestId, 0 /* Automatic */, NULL, @ConditionKind, + 0 /* Approve */, NULL, NULL, @Now + ) + + INSERT INTO [dbo].[AccessLease] + ( + [Id], [AccessRequestId], [OrganizationId], [CollectionId], [CipherId], [RequesterId], + [Status], [NotBefore], [NotAfter], [RevokedDate], [RevokedBy], [CreationDate] + ) + VALUES + ( + @AccessLeaseId, @AccessRequestId, @OrganizationId, @CollectionId, @CipherId, @RequesterId, + 0 /* Active */, @NotBefore, @NotAfter, NULL, NULL, @Now + ) + + COMMIT TRANSACTION AccessLease_CreateAutoApproved +END diff --git a/src/Sql/dbo/Pam/Stored Procedures/Lease_ReadActiveByRequesterIdCipherId.sql b/src/Sql/dbo/Pam/Stored Procedures/AccessLease_ReadActiveByRequesterIdCipherId.sql similarity index 79% rename from src/Sql/dbo/Pam/Stored Procedures/Lease_ReadActiveByRequesterIdCipherId.sql rename to src/Sql/dbo/Pam/Stored Procedures/AccessLease_ReadActiveByRequesterIdCipherId.sql index a59baf6c1832..0739a53896de 100644 --- a/src/Sql/dbo/Pam/Stored Procedures/Lease_ReadActiveByRequesterIdCipherId.sql +++ b/src/Sql/dbo/Pam/Stored Procedures/AccessLease_ReadActiveByRequesterIdCipherId.sql @@ -1,4 +1,4 @@ -CREATE PROCEDURE [dbo].[Lease_ReadActiveByRequesterIdCipherId] +CREATE PROCEDURE [dbo].[AccessLease_ReadActiveByRequesterIdCipherId] @RequesterId UNIQUEIDENTIFIER, @CipherId UNIQUEIDENTIFIER, @Now DATETIME2(7) @@ -9,7 +9,7 @@ BEGIN SELECT TOP 1 * FROM - [dbo].[Lease] + [dbo].[AccessLease] WHERE [RequesterId] = @RequesterId AND [CipherId] = @CipherId diff --git a/src/Sql/dbo/Pam/Stored Procedures/Lease_ReadById.sql b/src/Sql/dbo/Pam/Stored Procedures/AccessLease_ReadById.sql similarity index 61% rename from src/Sql/dbo/Pam/Stored Procedures/Lease_ReadById.sql rename to src/Sql/dbo/Pam/Stored Procedures/AccessLease_ReadById.sql index b25ad0efceaa..1f33b649baf5 100644 --- a/src/Sql/dbo/Pam/Stored Procedures/Lease_ReadById.sql +++ b/src/Sql/dbo/Pam/Stored Procedures/AccessLease_ReadById.sql @@ -1,4 +1,4 @@ -CREATE PROCEDURE [dbo].[Lease_ReadById] +CREATE PROCEDURE [dbo].[AccessLease_ReadById] @Id UNIQUEIDENTIFIER AS BEGIN @@ -7,7 +7,7 @@ BEGIN SELECT * FROM - [dbo].[Lease] + [dbo].[AccessLease] WHERE [Id] = @Id END diff --git a/src/Sql/dbo/Pam/Stored Procedures/Lease_ReadManyActiveByRequesterId.sql b/src/Sql/dbo/Pam/Stored Procedures/AccessLease_ReadManyActiveByRequesterId.sql similarity index 76% rename from src/Sql/dbo/Pam/Stored Procedures/Lease_ReadManyActiveByRequesterId.sql rename to src/Sql/dbo/Pam/Stored Procedures/AccessLease_ReadManyActiveByRequesterId.sql index ce726633f06b..e1d08bb94c4f 100644 --- a/src/Sql/dbo/Pam/Stored Procedures/Lease_ReadManyActiveByRequesterId.sql +++ b/src/Sql/dbo/Pam/Stored Procedures/AccessLease_ReadManyActiveByRequesterId.sql @@ -1,4 +1,4 @@ -CREATE PROCEDURE [dbo].[Lease_ReadManyActiveByRequesterId] +CREATE PROCEDURE [dbo].[AccessLease_ReadManyActiveByRequesterId] @RequesterId UNIQUEIDENTIFIER, @Now DATETIME2(7) AS @@ -8,7 +8,7 @@ BEGIN SELECT * FROM - [dbo].[Lease] + [dbo].[AccessLease] WHERE [RequesterId] = @RequesterId AND [Status] = 0 -- Active diff --git a/src/Sql/dbo/Pam/Stored Procedures/AccessLease_Revoke.sql b/src/Sql/dbo/Pam/Stored Procedures/AccessLease_Revoke.sql new file mode 100644 index 000000000000..503194a4452e --- /dev/null +++ b/src/Sql/dbo/Pam/Stored Procedures/AccessLease_Revoke.sql @@ -0,0 +1,35 @@ +CREATE PROCEDURE [dbo].[AccessLease_Revoke] + @AccessLeaseId UNIQUEIDENTIFIER, + @AccessRequestId UNIQUEIDENTIFIER, + @RevokedBy UNIQUEIDENTIFIER, + @AccessDecisionId UNIQUEIDENTIFIER, + @Reason NVARCHAR(MAX) = NULL, + @Now DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + -- Atomically revoke an active lease and capture who/why. The revocation reason has no dedicated column, so it is + -- preserved as a human AccessDecision (Deny) against the lease's originating request, keeping the audit trail + -- without a schema change. The WHERE guard keeps revocation idempotent if two approvers race. + BEGIN TRANSACTION AccessLease_Revoke + + UPDATE [dbo].[AccessLease] + SET [Status] = 2 /* Revoked */, + [RevokedDate] = @Now, + [RevokedBy] = @RevokedBy + WHERE [Id] = @AccessLeaseId AND [Status] = 0 -- Active + + INSERT INTO [dbo].[AccessDecision] + ( + [Id], [AccessRequestId], [DeciderKind], [ApproverId], [ConditionKind], + [Verdict], [Comment], [EvaluationContext], [CreationDate] + ) + VALUES + ( + @AccessDecisionId, @AccessRequestId, 1 /* Human */, @RevokedBy, NULL, + 1 /* Deny */, @Reason, NULL, @Now + ) + + COMMIT TRANSACTION AccessLease_Revoke +END diff --git a/src/Sql/dbo/Pam/Stored Procedures/LeaseRequest_Create.sql b/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_Create.sql similarity index 82% rename from src/Sql/dbo/Pam/Stored Procedures/LeaseRequest_Create.sql rename to src/Sql/dbo/Pam/Stored Procedures/AccessRequest_Create.sql index 2cafb8cec568..f7a8d26be7e1 100644 --- a/src/Sql/dbo/Pam/Stored Procedures/LeaseRequest_Create.sql +++ b/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_Create.sql @@ -1,6 +1,6 @@ -CREATE PROCEDURE [dbo].[LeaseRequest_Create] +CREATE PROCEDURE [dbo].[AccessRequest_Create] @Id UNIQUEIDENTIFIER OUTPUT, - @LeaseId UNIQUEIDENTIFIER = NULL, + @ExtensionOfLeaseId UNIQUEIDENTIFIER = NULL, @OrganizationId UNIQUEIDENTIFIER, @CollectionId UNIQUEIDENTIFIER, @CipherId UNIQUEIDENTIFIER, @@ -15,10 +15,10 @@ AS BEGIN SET NOCOUNT ON - INSERT INTO [dbo].[LeaseRequest] + INSERT INTO [dbo].[AccessRequest] ( [Id], - [LeaseId], + [ExtensionOfLeaseId], [OrganizationId], [CollectionId], [CipherId], @@ -33,7 +33,7 @@ BEGIN VALUES ( @Id, - @LeaseId, + @ExtensionOfLeaseId, @OrganizationId, @CollectionId, @CipherId, diff --git a/src/Sql/dbo/Pam/Stored Procedures/LeaseRequest_ReadActivePendingByRequesterIdCipherId.sql b/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_ReadActivePendingByRequesterIdCipherId.sql similarity index 73% rename from src/Sql/dbo/Pam/Stored Procedures/LeaseRequest_ReadActivePendingByRequesterIdCipherId.sql rename to src/Sql/dbo/Pam/Stored Procedures/AccessRequest_ReadActivePendingByRequesterIdCipherId.sql index 2cd898a0b2f9..23e21e5731c3 100644 --- a/src/Sql/dbo/Pam/Stored Procedures/LeaseRequest_ReadActivePendingByRequesterIdCipherId.sql +++ b/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_ReadActivePendingByRequesterIdCipherId.sql @@ -1,4 +1,4 @@ -CREATE PROCEDURE [dbo].[LeaseRequest_ReadActivePendingByRequesterIdCipherId] +CREATE PROCEDURE [dbo].[AccessRequest_ReadActivePendingByRequesterIdCipherId] @RequesterId UNIQUEIDENTIFIER, @CipherId UNIQUEIDENTIFIER AS @@ -8,7 +8,7 @@ BEGIN SELECT TOP 1 * FROM - [dbo].[LeaseRequest] + [dbo].[AccessRequest] WHERE [RequesterId] = @RequesterId AND [CipherId] = @CipherId diff --git a/src/Sql/dbo/Pam/Stored Procedures/LeaseRequest_ReadById.sql b/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_ReadById.sql similarity index 60% rename from src/Sql/dbo/Pam/Stored Procedures/LeaseRequest_ReadById.sql rename to src/Sql/dbo/Pam/Stored Procedures/AccessRequest_ReadById.sql index 3bf0d2fb6b42..fb011b14bfda 100644 --- a/src/Sql/dbo/Pam/Stored Procedures/LeaseRequest_ReadById.sql +++ b/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_ReadById.sql @@ -1,4 +1,4 @@ -CREATE PROCEDURE [dbo].[LeaseRequest_ReadById] +CREATE PROCEDURE [dbo].[AccessRequest_ReadById] @Id UNIQUEIDENTIFIER AS BEGIN @@ -7,7 +7,7 @@ BEGIN SELECT * FROM - [dbo].[LeaseRequest] + [dbo].[AccessRequest] WHERE [Id] = @Id END diff --git a/src/Sql/dbo/Pam/Stored Procedures/LeaseRequest_ReadInboxHistoryByCollectionIds.sql b/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_ReadInboxHistoryByCollectionIds.sql similarity index 77% rename from src/Sql/dbo/Pam/Stored Procedures/LeaseRequest_ReadInboxHistoryByCollectionIds.sql rename to src/Sql/dbo/Pam/Stored Procedures/AccessRequest_ReadInboxHistoryByCollectionIds.sql index f9c314bfc0e1..aab04951e639 100644 --- a/src/Sql/dbo/Pam/Stored Procedures/LeaseRequest_ReadInboxHistoryByCollectionIds.sql +++ b/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_ReadInboxHistoryByCollectionIds.sql @@ -1,4 +1,4 @@ -CREATE PROCEDURE [dbo].[LeaseRequest_ReadInboxHistoryByCollectionIds] +CREATE PROCEDURE [dbo].[AccessRequest_ReadInboxHistoryByCollectionIds] @CollectionIds [dbo].[GuidIdArray] READONLY, @Since DATETIME2(7) AS @@ -10,7 +10,7 @@ BEGIN -- lease carry ProducedLeaseId so the client can target the Revoke action at the lease. SELECT LR.[Id], - LR.[LeaseId] AS [ExtensionOfLeaseId], + LR.[ExtensionOfLeaseId], LR.[OrganizationId], LR.[CollectionId], LR.[CipherId], @@ -22,27 +22,27 @@ BEGIN LR.[CreationDate], LR.[ResolvedDate], PL.[Id] AS [ProducedLeaseId], - RES.[ApproverId] AS [ResolverId], - RES.[Comment] AS [ResolverComment], + RES.[ApproverId] AS [ApproverId], + RES.[Comment] AS [ApproverComment], JSON_VALUE(C.[Data], '$.Name') AS [CipherName], COL.[Name] AS [CollectionName], U.[Name] AS [RequesterName], U.[Email] AS [RequesterEmail] - FROM [dbo].[LeaseRequest] LR + FROM [dbo].[AccessRequest] LR INNER JOIN @CollectionIds CI ON CI.[Id] = LR.[CollectionId] LEFT JOIN [dbo].[Cipher] C ON C.[Id] = LR.[CipherId] LEFT JOIN [dbo].[Collection] COL ON COL.[Id] = LR.[CollectionId] LEFT JOIN [dbo].[User] U ON U.[Id] = LR.[RequesterId] OUTER APPLY ( SELECT TOP 1 L.[Id] - FROM [dbo].[Lease] L - WHERE L.[LeaseRequestId] = LR.[Id] + FROM [dbo].[AccessLease] L + WHERE L.[AccessRequestId] = LR.[Id] ORDER BY L.[CreationDate] DESC ) PL OUTER APPLY ( SELECT TOP 1 LD.[ApproverId], LD.[Comment] - FROM [dbo].[LeaseDecision] LD - WHERE LD.[LeaseRequestId] = LR.[Id] AND LD.[DeciderKind] = 1 -- Human + FROM [dbo].[AccessDecision] LD + WHERE LD.[AccessRequestId] = LR.[Id] AND LD.[DeciderKind] = 1 -- Human ORDER BY LD.[CreationDate] ASC ) RES WHERE LR.[Status] <> 0 -- not Pending diff --git a/src/Sql/dbo/Pam/Stored Procedures/LeaseRequest_ReadInboxPendingByCollectionIds.sql b/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_ReadInboxPendingByCollectionIds.sql similarity index 76% rename from src/Sql/dbo/Pam/Stored Procedures/LeaseRequest_ReadInboxPendingByCollectionIds.sql rename to src/Sql/dbo/Pam/Stored Procedures/AccessRequest_ReadInboxPendingByCollectionIds.sql index 51adb6e8b491..ce234fe60be8 100644 --- a/src/Sql/dbo/Pam/Stored Procedures/LeaseRequest_ReadInboxPendingByCollectionIds.sql +++ b/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_ReadInboxPendingByCollectionIds.sql @@ -1,4 +1,4 @@ -CREATE PROCEDURE [dbo].[LeaseRequest_ReadInboxPendingByCollectionIds] +CREATE PROCEDURE [dbo].[AccessRequest_ReadInboxPendingByCollectionIds] @CollectionIds [dbo].[GuidIdArray] READONLY AS BEGIN @@ -6,12 +6,12 @@ BEGIN -- The approver inbox: pending requests for the supplied (caller-manageable) collections, joined with the -- denormalized display fields the client needs (cipher/collection names, requester identity) so it avoids an N+1. - -- ResolverId/ResolverComment come from the EARLIEST human decision so a later revocation decision (also human, + -- ApproverId/ApproverComment come from the EARLIEST human decision so a later revocation decision (also human, -- recorded against the same request) never overwrites the original approve/deny resolver. ProducedLeaseId is the -- lease that the request birthed, if any. ExtensionOfLeaseId is the parent lease for extension requests. SELECT LR.[Id], - LR.[LeaseId] AS [ExtensionOfLeaseId], + LR.[ExtensionOfLeaseId], LR.[OrganizationId], LR.[CollectionId], LR.[CipherId], @@ -23,27 +23,27 @@ BEGIN LR.[CreationDate], LR.[ResolvedDate], PL.[Id] AS [ProducedLeaseId], - RES.[ApproverId] AS [ResolverId], - RES.[Comment] AS [ResolverComment], + RES.[ApproverId] AS [ApproverId], + RES.[Comment] AS [ApproverComment], JSON_VALUE(C.[Data], '$.Name') AS [CipherName], COL.[Name] AS [CollectionName], U.[Name] AS [RequesterName], U.[Email] AS [RequesterEmail] - FROM [dbo].[LeaseRequest] LR + FROM [dbo].[AccessRequest] LR INNER JOIN @CollectionIds CI ON CI.[Id] = LR.[CollectionId] LEFT JOIN [dbo].[Cipher] C ON C.[Id] = LR.[CipherId] LEFT JOIN [dbo].[Collection] COL ON COL.[Id] = LR.[CollectionId] LEFT JOIN [dbo].[User] U ON U.[Id] = LR.[RequesterId] OUTER APPLY ( SELECT TOP 1 L.[Id] - FROM [dbo].[Lease] L - WHERE L.[LeaseRequestId] = LR.[Id] + FROM [dbo].[AccessLease] L + WHERE L.[AccessRequestId] = LR.[Id] ORDER BY L.[CreationDate] DESC ) PL OUTER APPLY ( SELECT TOP 1 LD.[ApproverId], LD.[Comment] - FROM [dbo].[LeaseDecision] LD - WHERE LD.[LeaseRequestId] = LR.[Id] AND LD.[DeciderKind] = 1 -- Human + FROM [dbo].[AccessDecision] LD + WHERE LD.[AccessRequestId] = LR.[Id] AND LD.[DeciderKind] = 1 -- Human ORDER BY LD.[CreationDate] ASC ) RES WHERE LR.[Status] = 0 -- Pending diff --git a/src/Sql/dbo/Pam/Stored Procedures/LeaseRequest_ReadManyByRequesterId.sql b/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_ReadManyByRequesterId.sql similarity index 70% rename from src/Sql/dbo/Pam/Stored Procedures/LeaseRequest_ReadManyByRequesterId.sql rename to src/Sql/dbo/Pam/Stored Procedures/AccessRequest_ReadManyByRequesterId.sql index e0cf1a715e93..380a2af19593 100644 --- a/src/Sql/dbo/Pam/Stored Procedures/LeaseRequest_ReadManyByRequesterId.sql +++ b/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_ReadManyByRequesterId.sql @@ -1,4 +1,4 @@ -CREATE PROCEDURE [dbo].[LeaseRequest_ReadManyByRequesterId] +CREATE PROCEDURE [dbo].[AccessRequest_ReadManyByRequesterId] @RequesterId UNIQUEIDENTIFIER AS BEGIN @@ -9,7 +9,7 @@ BEGIN -- (those name fields stay null). Capped at the 250 most recent; the client renders far fewer. SELECT TOP (250) LR.[Id], - LR.[LeaseId] AS [ExtensionOfLeaseId], + LR.[ExtensionOfLeaseId], LR.[OrganizationId], LR.[CollectionId], LR.[CipherId], @@ -21,19 +21,19 @@ BEGIN LR.[CreationDate], LR.[ResolvedDate], PL.[Id] AS [ProducedLeaseId], - RES.[ApproverId] AS [ResolverId], - RES.[Comment] AS [ResolverComment] - FROM [dbo].[LeaseRequest] LR + RES.[ApproverId] AS [ApproverId], + RES.[Comment] AS [ApproverComment] + FROM [dbo].[AccessRequest] LR OUTER APPLY ( SELECT TOP 1 L.[Id] - FROM [dbo].[Lease] L - WHERE L.[LeaseRequestId] = LR.[Id] + FROM [dbo].[AccessLease] L + WHERE L.[AccessRequestId] = LR.[Id] ORDER BY L.[CreationDate] DESC ) PL OUTER APPLY ( SELECT TOP 1 LD.[ApproverId], LD.[Comment] - FROM [dbo].[LeaseDecision] LD - WHERE LD.[LeaseRequestId] = LR.[Id] AND LD.[DeciderKind] = 1 -- Human + FROM [dbo].[AccessDecision] LD + WHERE LD.[AccessRequestId] = LR.[Id] AND LD.[DeciderKind] = 1 -- Human ORDER BY LD.[CreationDate] ASC ) RES WHERE LR.[RequesterId] = @RequesterId diff --git a/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_ResolveWithDecision.sql b/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_ResolveWithDecision.sql new file mode 100644 index 000000000000..d78070c2af7c --- /dev/null +++ b/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_ResolveWithDecision.sql @@ -0,0 +1,53 @@ +CREATE PROCEDURE [dbo].[AccessRequest_ResolveWithDecision] + @AccessRequestId UNIQUEIDENTIFIER, + @Status TINYINT, + @AccessDecisionId UNIQUEIDENTIFIER, + @ApproverId UNIQUEIDENTIFIER, + @Verdict TINYINT, + @Comment NVARCHAR(MAX) = NULL, + @AccessLeaseId UNIQUEIDENTIFIER = NULL, + @Now DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + -- Atomically resolve a pending request and record the human approver's decision. The caller has already verified + -- (and the application enforces) that the request is still Pending; the WHERE guard keeps the write idempotent + -- under a race so a second approver can't move an already-resolved request. + BEGIN TRANSACTION AccessRequest_Resolve + + UPDATE [dbo].[AccessRequest] + SET [Status] = @Status, + [ResolvedDate] = @Now + WHERE [Id] = @AccessRequestId AND [Status] = 0 -- Pending + + INSERT INTO [dbo].[AccessDecision] + ( + [Id], [AccessRequestId], [DeciderKind], [ApproverId], [ConditionKind], + [Verdict], [Comment], [EvaluationContext], [CreationDate] + ) + VALUES + ( + @AccessDecisionId, @AccessRequestId, 1 /* Human */, @ApproverId, NULL, + @Verdict, @Comment, NULL, @Now + ) + + -- An approval mints the active lease that authorizes access, mirroring [AccessLease_CreateAutoApproved] on the automatic + -- path. @AccessLeaseId is supplied only when approving; the lease window is the request's approved window, so the lease + -- is found by [AccessLease_ReadActiveByRequesterIdCipherId] once @Now falls inside it. + IF @AccessLeaseId IS NOT NULL + BEGIN + INSERT INTO [dbo].[AccessLease] + ( + [Id], [AccessRequestId], [OrganizationId], [CollectionId], [CipherId], [RequesterId], + [Status], [NotBefore], [NotAfter], [RevokedDate], [RevokedBy], [CreationDate] + ) + SELECT + @AccessLeaseId, [Id], [OrganizationId], [CollectionId], [CipherId], [RequesterId], + 0 /* Active */, [NotBefore], [NotAfter], NULL, NULL, @Now + FROM [dbo].[AccessRequest] + WHERE [Id] = @AccessRequestId + END + + COMMIT TRANSACTION AccessRequest_Resolve +END diff --git a/src/Sql/dbo/Pam/Stored Procedures/AccessRule_Create.sql b/src/Sql/dbo/Pam/Stored Procedures/AccessRule_Create.sql index 0a2bf8a4c3f3..cf1326af2fe6 100644 --- a/src/Sql/dbo/Pam/Stored Procedures/AccessRule_Create.sql +++ b/src/Sql/dbo/Pam/Stored Procedures/AccessRule_Create.sql @@ -3,7 +3,7 @@ CREATE PROCEDURE [dbo].[AccessRule_Create] @OrganizationId UNIQUEIDENTIFIER, @Name NVARCHAR(256), @Description NVARCHAR(MAX) = NULL, - @Rule NVARCHAR(MAX), + @Conditions NVARCHAR(MAX), @CreationDate DATETIME2(7), @RevisionDate DATETIME2(7) AS @@ -16,7 +16,7 @@ BEGIN [OrganizationId], [Name], [Description], - [Rule], + [Conditions], [CreationDate], [RevisionDate] ) @@ -26,7 +26,7 @@ BEGIN @OrganizationId, @Name, @Description, - @Rule, + @Conditions, @CreationDate, @RevisionDate ) diff --git a/src/Sql/dbo/Pam/Stored Procedures/AccessRule_Update.sql b/src/Sql/dbo/Pam/Stored Procedures/AccessRule_Update.sql index 65fb49128a11..209557799268 100644 --- a/src/Sql/dbo/Pam/Stored Procedures/AccessRule_Update.sql +++ b/src/Sql/dbo/Pam/Stored Procedures/AccessRule_Update.sql @@ -3,7 +3,7 @@ CREATE PROCEDURE [dbo].[AccessRule_Update] @OrganizationId UNIQUEIDENTIFIER, @Name NVARCHAR(256), @Description NVARCHAR(MAX) = NULL, - @Rule NVARCHAR(MAX), + @Conditions NVARCHAR(MAX), @CreationDate DATETIME2(7), @RevisionDate DATETIME2(7) AS @@ -16,7 +16,7 @@ BEGIN [OrganizationId] = @OrganizationId, [Name] = @Name, [Description] = @Description, - [Rule] = @Rule, + [Conditions] = @Conditions, [CreationDate] = @CreationDate, [RevisionDate] = @RevisionDate WHERE diff --git a/src/Sql/dbo/Pam/Stored Procedures/LeaseRequest_ResolveWithDecision.sql b/src/Sql/dbo/Pam/Stored Procedures/LeaseRequest_ResolveWithDecision.sql deleted file mode 100644 index 0e378d4734a2..000000000000 --- a/src/Sql/dbo/Pam/Stored Procedures/LeaseRequest_ResolveWithDecision.sql +++ /dev/null @@ -1,53 +0,0 @@ -CREATE PROCEDURE [dbo].[LeaseRequest_ResolveWithDecision] - @LeaseRequestId UNIQUEIDENTIFIER, - @Status TINYINT, - @LeaseDecisionId UNIQUEIDENTIFIER, - @ApproverId UNIQUEIDENTIFIER, - @Decision TINYINT, - @Comment NVARCHAR(MAX) = NULL, - @LeaseId UNIQUEIDENTIFIER = NULL, - @Now DATETIME2(7) -AS -BEGIN - SET NOCOUNT ON - - -- Atomically resolve a pending request and record the human approver's decision. The caller has already verified - -- (and the application enforces) that the request is still Pending; the WHERE guard keeps the write idempotent - -- under a race so a second approver can't move an already-resolved request. - BEGIN TRANSACTION LeaseRequest_ResolveWithDecision - - UPDATE [dbo].[LeaseRequest] - SET [Status] = @Status, - [ResolvedDate] = @Now - WHERE [Id] = @LeaseRequestId AND [Status] = 0 -- Pending - - INSERT INTO [dbo].[LeaseDecision] - ( - [Id], [LeaseRequestId], [DeciderKind], [ApproverId], [PolicyKind], - [Decision], [Comment], [EvaluationContext], [CreationDate] - ) - VALUES - ( - @LeaseDecisionId, @LeaseRequestId, 1 /* Human */, @ApproverId, NULL, - @Decision, @Comment, NULL, @Now - ) - - -- An approval mints the active lease that authorizes access, mirroring [Lease_CreateAutoApproved] on the automatic - -- path. @LeaseId is supplied only when approving; the lease window is the request's approved window, so the lease - -- is found by [Lease_ReadActiveByRequesterIdCipherId] once @Now falls inside it. - IF @LeaseId IS NOT NULL - BEGIN - INSERT INTO [dbo].[Lease] - ( - [Id], [LeaseRequestId], [OrganizationId], [CollectionId], [CipherId], [RequesterId], - [Status], [NotBefore], [NotAfter], [RevokedDate], [RevokedBy], [CreationDate] - ) - SELECT - @LeaseId, [Id], [OrganizationId], [CollectionId], [CipherId], [RequesterId], - 0 /* Active */, [NotBefore], [NotAfter], NULL, NULL, @Now - FROM [dbo].[LeaseRequest] - WHERE [Id] = @LeaseRequestId - END - - COMMIT TRANSACTION LeaseRequest_ResolveWithDecision -END diff --git a/src/Sql/dbo/Pam/Stored Procedures/Lease_CreateAutoApproved.sql b/src/Sql/dbo/Pam/Stored Procedures/Lease_CreateAutoApproved.sql deleted file mode 100644 index 357660feb9a1..000000000000 --- a/src/Sql/dbo/Pam/Stored Procedures/Lease_CreateAutoApproved.sql +++ /dev/null @@ -1,56 +0,0 @@ -CREATE PROCEDURE [dbo].[Lease_CreateAutoApproved] - @LeaseRequestId UNIQUEIDENTIFIER, - @LeaseId UNIQUEIDENTIFIER, - @LeaseDecisionId UNIQUEIDENTIFIER, - @OrganizationId UNIQUEIDENTIFIER, - @CollectionId UNIQUEIDENTIFIER, - @CipherId UNIQUEIDENTIFIER, - @RequesterId UNIQUEIDENTIFIER, - @NotBefore DATETIME2(7), - @NotAfter DATETIME2(7), - @Reason NVARCHAR(MAX) = NULL, - @PolicyKind NVARCHAR(50) = NULL, - @Now DATETIME2(7) -AS -BEGIN - SET NOCOUNT ON - - BEGIN TRANSACTION Lease_CreateAutoApproved - - -- The request is created already resolved (Approved). LeaseId stays NULL: it is reserved for extension - -- requests; provenance for an original lease flows the other way, via Lease.LeaseRequestId. - INSERT INTO [dbo].[LeaseRequest] - ( - [Id], [LeaseId], [OrganizationId], [CollectionId], [CipherId], [RequesterId], - [NotBefore], [NotAfter], [Reason], [Status], [CreationDate], [ResolvedDate] - ) - VALUES - ( - @LeaseRequestId, NULL, @OrganizationId, @CollectionId, @CipherId, @RequesterId, - @NotBefore, @NotAfter, @Reason, 1 /* Approved */, @Now, @Now - ) - - INSERT INTO [dbo].[LeaseDecision] - ( - [Id], [LeaseRequestId], [DeciderKind], [ApproverId], [PolicyKind], - [Decision], [Comment], [EvaluationContext], [CreationDate] - ) - VALUES - ( - @LeaseDecisionId, @LeaseRequestId, 0 /* Policy */, NULL, @PolicyKind, - 0 /* Approve */, NULL, NULL, @Now - ) - - INSERT INTO [dbo].[Lease] - ( - [Id], [LeaseRequestId], [OrganizationId], [CollectionId], [CipherId], [RequesterId], - [Status], [NotBefore], [NotAfter], [RevokedDate], [RevokedBy], [CreationDate] - ) - VALUES - ( - @LeaseId, @LeaseRequestId, @OrganizationId, @CollectionId, @CipherId, @RequesterId, - 0 /* Active */, @NotBefore, @NotAfter, NULL, NULL, @Now - ) - - COMMIT TRANSACTION Lease_CreateAutoApproved -END diff --git a/src/Sql/dbo/Pam/Stored Procedures/Lease_Revoke.sql b/src/Sql/dbo/Pam/Stored Procedures/Lease_Revoke.sql deleted file mode 100644 index e04fba7a1fed..000000000000 --- a/src/Sql/dbo/Pam/Stored Procedures/Lease_Revoke.sql +++ /dev/null @@ -1,35 +0,0 @@ -CREATE PROCEDURE [dbo].[Lease_Revoke] - @LeaseId UNIQUEIDENTIFIER, - @LeaseRequestId UNIQUEIDENTIFIER, - @RevokedBy UNIQUEIDENTIFIER, - @LeaseDecisionId UNIQUEIDENTIFIER, - @Reason NVARCHAR(MAX) = NULL, - @Now DATETIME2(7) -AS -BEGIN - SET NOCOUNT ON - - -- Atomically revoke an active lease and capture who/why. The revocation reason has no dedicated column, so it is - -- preserved as a human LeaseDecision (Deny) against the lease's originating request, keeping the audit trail - -- without a schema change. The WHERE guard keeps revocation idempotent if two approvers race. - BEGIN TRANSACTION Lease_Revoke - - UPDATE [dbo].[Lease] - SET [Status] = 2 /* Revoked */, - [RevokedDate] = @Now, - [RevokedBy] = @RevokedBy - WHERE [Id] = @LeaseId AND [Status] = 0 -- Active - - INSERT INTO [dbo].[LeaseDecision] - ( - [Id], [LeaseRequestId], [DeciderKind], [ApproverId], [PolicyKind], - [Decision], [Comment], [EvaluationContext], [CreationDate] - ) - VALUES - ( - @LeaseDecisionId, @LeaseRequestId, 1 /* Human */, @RevokedBy, NULL, - 1 /* Deny */, @Reason, NULL, @Now - ) - - COMMIT TRANSACTION Lease_Revoke -END diff --git a/src/Sql/dbo/Pam/Tables/AccessDecision.sql b/src/Sql/dbo/Pam/Tables/AccessDecision.sql new file mode 100644 index 000000000000..5acc0c8744fb --- /dev/null +++ b/src/Sql/dbo/Pam/Tables/AccessDecision.sql @@ -0,0 +1,18 @@ +CREATE TABLE [dbo].[AccessDecision] ( + [Id] UNIQUEIDENTIFIER NOT NULL, + [AccessRequestId] UNIQUEIDENTIFIER NOT NULL, + [DeciderKind] TINYINT NOT NULL, + [ApproverId] UNIQUEIDENTIFIER NULL, + [ConditionKind] NVARCHAR(50) NULL, + [Verdict] TINYINT NOT NULL, + [Comment] NVARCHAR(MAX) NULL, + [EvaluationContext] NVARCHAR(MAX) NULL, + [CreationDate] DATETIME2 (7) NOT NULL, + CONSTRAINT [PK_AccessDecision] PRIMARY KEY CLUSTERED ([Id] ASC), + CONSTRAINT [FK_AccessDecision_AccessRequest] FOREIGN KEY ([AccessRequestId]) REFERENCES [dbo].[AccessRequest] ([Id]) ON DELETE CASCADE +); +GO + +CREATE NONCLUSTERED INDEX [IX_AccessDecision_AccessRequestId] + ON [dbo].[AccessDecision] ([AccessRequestId] ASC); +GO diff --git a/src/Sql/dbo/Pam/Tables/AccessLease.sql b/src/Sql/dbo/Pam/Tables/AccessLease.sql new file mode 100644 index 000000000000..9d8f67d40168 --- /dev/null +++ b/src/Sql/dbo/Pam/Tables/AccessLease.sql @@ -0,0 +1,26 @@ +CREATE TABLE [dbo].[AccessLease] ( + [Id] UNIQUEIDENTIFIER NOT NULL, + [AccessRequestId] UNIQUEIDENTIFIER NOT NULL, + [OrganizationId] UNIQUEIDENTIFIER NOT NULL, + [CollectionId] UNIQUEIDENTIFIER NOT NULL, + [CipherId] UNIQUEIDENTIFIER NOT NULL, + [RequesterId] UNIQUEIDENTIFIER NOT NULL, + [Status] TINYINT NOT NULL, + [NotBefore] DATETIME2 (7) NOT NULL, + [NotAfter] DATETIME2 (7) NOT NULL, + [RevokedDate] DATETIME2 (7) NULL, + [RevokedBy] UNIQUEIDENTIFIER NULL, + [CreationDate] DATETIME2 (7) NOT NULL, + CONSTRAINT [PK_AccessLease] PRIMARY KEY CLUSTERED ([Id] ASC), + CONSTRAINT [FK_AccessLease_AccessRequest] FOREIGN KEY ([AccessRequestId]) REFERENCES [dbo].[AccessRequest] ([Id]), + CONSTRAINT [FK_AccessLease_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]) ON DELETE CASCADE +); +GO + +CREATE NONCLUSTERED INDEX [IX_AccessLease_RequesterId_CipherId_Status] + ON [dbo].[AccessLease] ([RequesterId] ASC, [CipherId] ASC, [Status] ASC); +GO + +CREATE NONCLUSTERED INDEX [IX_AccessLease_NotAfter_Status] + ON [dbo].[AccessLease] ([NotAfter] ASC, [Status] ASC); +GO diff --git a/src/Sql/dbo/Pam/Tables/AccessRequest.sql b/src/Sql/dbo/Pam/Tables/AccessRequest.sql new file mode 100644 index 000000000000..57b92eff3e57 --- /dev/null +++ b/src/Sql/dbo/Pam/Tables/AccessRequest.sql @@ -0,0 +1,26 @@ +CREATE TABLE [dbo].[AccessRequest] ( + [Id] UNIQUEIDENTIFIER NOT NULL, + [ExtensionOfLeaseId] UNIQUEIDENTIFIER NULL, + [OrganizationId] UNIQUEIDENTIFIER NOT NULL, + [CollectionId] UNIQUEIDENTIFIER NOT NULL, + [CipherId] UNIQUEIDENTIFIER NOT NULL, + [RequesterId] UNIQUEIDENTIFIER NOT NULL, + [NotBefore] DATETIME2 (7) NOT NULL, + [NotAfter] DATETIME2 (7) NOT NULL, + [Reason] NVARCHAR(MAX) NULL, + [Status] TINYINT NOT NULL, + [CreationDate] DATETIME2 (7) NOT NULL, + [ResolvedDate] DATETIME2 (7) NULL, + CONSTRAINT [PK_AccessRequest] PRIMARY KEY CLUSTERED ([Id] ASC), + CONSTRAINT [FK_AccessRequest_AccessLease] FOREIGN KEY ([ExtensionOfLeaseId]) REFERENCES [dbo].[AccessLease] ([Id]), + CONSTRAINT [FK_AccessRequest_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]) ON DELETE CASCADE +); +GO + +CREATE NONCLUSTERED INDEX [IX_AccessRequest_RequesterId_CipherId_Status] + ON [dbo].[AccessRequest] ([RequesterId] ASC, [CipherId] ASC, [Status] ASC); +GO + +CREATE NONCLUSTERED INDEX [IX_AccessRequest_OrganizationId_Status] + ON [dbo].[AccessRequest] ([OrganizationId] ASC, [Status] ASC); +GO diff --git a/src/Sql/dbo/Pam/Tables/AccessRule.sql b/src/Sql/dbo/Pam/Tables/AccessRule.sql index e9f9c3cb34dc..29db15f0f1d9 100644 --- a/src/Sql/dbo/Pam/Tables/AccessRule.sql +++ b/src/Sql/dbo/Pam/Tables/AccessRule.sql @@ -3,7 +3,7 @@ CREATE TABLE [dbo].[AccessRule] ( [OrganizationId] UNIQUEIDENTIFIER NOT NULL, [Name] NVARCHAR(256) NOT NULL, [Description] NVARCHAR(MAX) NULL, - [Rule] NVARCHAR(MAX) NOT NULL, + [Conditions] NVARCHAR(MAX) NOT NULL, [CreationDate] DATETIME2(7) NOT NULL, [RevisionDate] DATETIME2(7) NOT NULL, CONSTRAINT [PK_AccessRule] PRIMARY KEY CLUSTERED ([Id] ASC), diff --git a/src/Sql/dbo/Pam/Tables/Lease.sql b/src/Sql/dbo/Pam/Tables/Lease.sql deleted file mode 100644 index 24434650582c..000000000000 --- a/src/Sql/dbo/Pam/Tables/Lease.sql +++ /dev/null @@ -1,26 +0,0 @@ -CREATE TABLE [dbo].[Lease] ( - [Id] UNIQUEIDENTIFIER NOT NULL, - [LeaseRequestId] UNIQUEIDENTIFIER NOT NULL, - [OrganizationId] UNIQUEIDENTIFIER NOT NULL, - [CollectionId] UNIQUEIDENTIFIER NOT NULL, - [CipherId] UNIQUEIDENTIFIER NOT NULL, - [RequesterId] UNIQUEIDENTIFIER NOT NULL, - [Status] TINYINT NOT NULL, - [NotBefore] DATETIME2 (7) NOT NULL, - [NotAfter] DATETIME2 (7) NOT NULL, - [RevokedDate] DATETIME2 (7) NULL, - [RevokedBy] UNIQUEIDENTIFIER NULL, - [CreationDate] DATETIME2 (7) NOT NULL, - CONSTRAINT [PK_Lease] PRIMARY KEY CLUSTERED ([Id] ASC), - CONSTRAINT [FK_Lease_LeaseRequest] FOREIGN KEY ([LeaseRequestId]) REFERENCES [dbo].[LeaseRequest] ([Id]), - CONSTRAINT [FK_Lease_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]) ON DELETE CASCADE -); -GO - -CREATE NONCLUSTERED INDEX [IX_Lease_RequesterId_CipherId_Status] - ON [dbo].[Lease] ([RequesterId] ASC, [CipherId] ASC, [Status] ASC); -GO - -CREATE NONCLUSTERED INDEX [IX_Lease_NotAfter_Status] - ON [dbo].[Lease] ([NotAfter] ASC, [Status] ASC); -GO diff --git a/src/Sql/dbo/Pam/Tables/LeaseDecision.sql b/src/Sql/dbo/Pam/Tables/LeaseDecision.sql deleted file mode 100644 index 64795a6b93dc..000000000000 --- a/src/Sql/dbo/Pam/Tables/LeaseDecision.sql +++ /dev/null @@ -1,18 +0,0 @@ -CREATE TABLE [dbo].[LeaseDecision] ( - [Id] UNIQUEIDENTIFIER NOT NULL, - [LeaseRequestId] UNIQUEIDENTIFIER NOT NULL, - [DeciderKind] TINYINT NOT NULL, - [ApproverId] UNIQUEIDENTIFIER NULL, - [PolicyKind] NVARCHAR(50) NULL, - [Decision] TINYINT NOT NULL, - [Comment] NVARCHAR(MAX) NULL, - [EvaluationContext] NVARCHAR(MAX) NULL, - [CreationDate] DATETIME2 (7) NOT NULL, - CONSTRAINT [PK_LeaseDecision] PRIMARY KEY CLUSTERED ([Id] ASC), - CONSTRAINT [FK_LeaseDecision_LeaseRequest] FOREIGN KEY ([LeaseRequestId]) REFERENCES [dbo].[LeaseRequest] ([Id]) ON DELETE CASCADE -); -GO - -CREATE NONCLUSTERED INDEX [IX_LeaseDecision_LeaseRequestId] - ON [dbo].[LeaseDecision] ([LeaseRequestId] ASC); -GO diff --git a/src/Sql/dbo/Pam/Tables/LeaseRequest.sql b/src/Sql/dbo/Pam/Tables/LeaseRequest.sql deleted file mode 100644 index 076483c09e04..000000000000 --- a/src/Sql/dbo/Pam/Tables/LeaseRequest.sql +++ /dev/null @@ -1,26 +0,0 @@ -CREATE TABLE [dbo].[LeaseRequest] ( - [Id] UNIQUEIDENTIFIER NOT NULL, - [LeaseId] UNIQUEIDENTIFIER NULL, - [OrganizationId] UNIQUEIDENTIFIER NOT NULL, - [CollectionId] UNIQUEIDENTIFIER NOT NULL, - [CipherId] UNIQUEIDENTIFIER NOT NULL, - [RequesterId] UNIQUEIDENTIFIER NOT NULL, - [NotBefore] DATETIME2 (7) NOT NULL, - [NotAfter] DATETIME2 (7) NOT NULL, - [Reason] NVARCHAR(MAX) NULL, - [Status] TINYINT NOT NULL, - [CreationDate] DATETIME2 (7) NOT NULL, - [ResolvedDate] DATETIME2 (7) NULL, - CONSTRAINT [PK_LeaseRequest] PRIMARY KEY CLUSTERED ([Id] ASC), - CONSTRAINT [FK_LeaseRequest_Lease] FOREIGN KEY ([LeaseId]) REFERENCES [dbo].[Lease] ([Id]), - CONSTRAINT [FK_LeaseRequest_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]) ON DELETE CASCADE -); -GO - -CREATE NONCLUSTERED INDEX [IX_LeaseRequest_RequesterId_CipherId_Status] - ON [dbo].[LeaseRequest] ([RequesterId] ASC, [CipherId] ASC, [Status] ASC); -GO - -CREATE NONCLUSTERED INDEX [IX_LeaseRequest_OrganizationId_Status] - ON [dbo].[LeaseRequest] ([OrganizationId] ASC, [Status] ASC); -GO diff --git a/test/Api.Test/Pam/Controllers/ApproverInboxControllerTests.cs b/test/Api.Test/Pam/Controllers/ApproverInboxControllerTests.cs index 902ca3ef672b..3b5eb4e8be8d 100644 --- a/test/Api.Test/Pam/Controllers/ApproverInboxControllerTests.cs +++ b/test/Api.Test/Pam/Controllers/ApproverInboxControllerTests.cs @@ -21,11 +21,11 @@ public class ApproverInboxControllerTests { [Theory, BitAutoData] public async Task GetRequests_ReturnsMappedPendingRows( - Guid userId, InboxLeaseRequestDetails row, SutProvider sutProvider) + Guid userId, AccessRequestDetails row, SutProvider sutProvider) { SetupUser(sutProvider, userId); - row.Status = LeaseRequestStatus.Pending; - sutProvider.GetDependency().GetPendingAsync(userId).Returns([row]); + row.Status = AccessRequestStatus.Pending; + sutProvider.GetDependency().GetPendingAsync(userId).Returns([row]); var result = await sutProvider.Sut.GetRequests(); @@ -35,11 +35,11 @@ public async Task GetRequests_ReturnsMappedPendingRows( [Theory, BitAutoData] public async Task GetHistory_ReturnsMappedHistoryRows( - Guid userId, InboxLeaseRequestDetails row, SutProvider sutProvider) + Guid userId, AccessRequestDetails row, SutProvider sutProvider) { SetupUser(sutProvider, userId); - row.Status = LeaseRequestStatus.Approved; - sutProvider.GetDependency().GetHistoryAsync(userId).Returns([row]); + row.Status = AccessRequestStatus.Approved; + sutProvider.GetDependency().GetHistoryAsync(userId).Returns([row]); var result = await sutProvider.Sut.GetHistory(); @@ -48,19 +48,19 @@ public async Task GetHistory_ReturnsMappedHistoryRows( [Theory, BitAutoData] public async Task Decide_ReturnsUpdatedRow( - Guid userId, Guid requestId, InboxLeaseRequestDetails updated, SutProvider sutProvider) + Guid userId, Guid requestId, AccessRequestDetails updated, SutProvider sutProvider) { SetupUser(sutProvider, userId); - updated.Status = LeaseRequestStatus.Approved; + updated.Status = AccessRequestStatus.Approved; updated.ProducedLeaseId = null; - sutProvider.GetDependency() - .DecideAsync(userId, requestId, Arg.Any()) + sutProvider.GetDependency() + .DecideAsync(userId, requestId, Arg.Any()) .Returns(updated); - var result = await sutProvider.Sut.Decide(requestId, new LeaseDecisionRequestModel { Decision = "approve" }); + var result = await sutProvider.Sut.Decide(requestId, new AccessDecisionRequestModel { Verdict = "approve" }); Assert.Equal(updated.Id, result.Id); - Assert.Equal(InboxRequestStatus.Approved, result.Status); + Assert.Equal(AccessRequestStatusNames.Approved, result.Status); } [Theory, BitAutoData] @@ -70,7 +70,7 @@ public async Task Decide_InvalidDecision_ThrowsBadRequest( SetupUser(sutProvider, userId); await Assert.ThrowsAsync( - () => sutProvider.Sut.Decide(requestId, new LeaseDecisionRequestModel { Decision = "maybe" })); + () => sutProvider.Sut.Decide(requestId, new AccessDecisionRequestModel { Verdict = "maybe" })); } [Theory, BitAutoData] @@ -79,10 +79,10 @@ public async Task Revoke_ReturnsNoContent( { SetupUser(sutProvider, userId); - var result = await sutProvider.Sut.Revoke(leaseId, new LeaseRevokeRequestModel { Reason = "policy" }); + var result = await sutProvider.Sut.Revoke(leaseId, new AccessLeaseRevokeRequestModel { Reason = "policy" }); Assert.IsType(result); - await sutProvider.GetDependency().Received(1).RevokeAsync(userId, leaseId, "policy"); + await sutProvider.GetDependency().Received(1).RevokeAsync(userId, leaseId, "policy"); } private static void SetupUser(SutProvider sutProvider, Guid userId) diff --git a/test/Api.Test/Pam/Controllers/CipherLeaseControllerTests.cs b/test/Api.Test/Pam/Controllers/CipherLeaseControllerTests.cs index eaed3a21e135..4e35cd915e3c 100644 --- a/test/Api.Test/Pam/Controllers/CipherLeaseControllerTests.cs +++ b/test/Api.Test/Pam/Controllers/CipherLeaseControllerTests.cs @@ -23,22 +23,22 @@ public class CipherLeaseControllerTests { [Theory, BitAutoData] public async Task State_ReturnsSnapshotFromQuery( - Guid id, Guid userId, Bit.Core.Pam.Entities.Lease activeLease, SutProvider sutProvider) + Guid id, Guid userId, Bit.Core.Pam.Entities.AccessLease activeLease, SutProvider sutProvider) { sutProvider.GetDependency() .GetProperUserId(Arg.Any()) .Returns(userId); - sutProvider.GetDependency() + sutProvider.GetDependency() .GetStateAsync(userId, id) - .Returns(new Bit.Core.Pam.Models.CipherLeaseStateResult(id, activeLease, null)); + .Returns(new Bit.Core.Pam.Models.CipherAccessState(id, activeLease, null)); var result = await sutProvider.Sut.State(id); Assert.Equal(id, result.CipherId); - Assert.NotNull(result.Lease.ActiveLease); - Assert.Equal(activeLease.Id, result.Lease.ActiveLease!.Id); - Assert.Null(result.Lease.PendingRequest); - Assert.Null(result.Lease.ApprovedTicket); // always null in v0 — no redemption flow + Assert.NotNull(result.ActiveLease); + Assert.Equal(activeLease.Id, result.ActiveLease!.Id); + Assert.Null(result.PendingRequest); + Assert.Null(result.ApprovedRequest); // always null in v0 — no activation flow } [Theory, BitAutoData] diff --git a/test/Api.Test/Pam/Controllers/MemberLeasingControllerTests.cs b/test/Api.Test/Pam/Controllers/MemberLeasingControllerTests.cs index ff39113e23d0..8f65aadfd968 100644 --- a/test/Api.Test/Pam/Controllers/MemberLeasingControllerTests.cs +++ b/test/Api.Test/Pam/Controllers/MemberLeasingControllerTests.cs @@ -18,32 +18,32 @@ public class MemberLeasingControllerTests { [Theory, BitAutoData] public async Task GetMyRequests_ReturnsMappedRows( - Guid userId, InboxLeaseRequestDetails row, SutProvider sutProvider) + Guid userId, AccessRequestDetails row, SutProvider sutProvider) { SetupUser(sutProvider, userId); - row.Status = LeaseRequestStatus.Pending; + row.Status = AccessRequestStatus.Pending; sutProvider.GetDependency().GetMineAsync(userId).Returns([row]); var result = (await sutProvider.Sut.GetMyRequests()).ToList(); Assert.Single(result); Assert.Equal(row.Id, result[0].Id); - Assert.Equal(InboxRequestStatus.Pending, result[0].Status); + Assert.Equal(AccessRequestStatusNames.Pending, result[0].Status); } [Theory, BitAutoData] public async Task GetMyActiveLeases_ReturnsMappedLeases( - Guid userId, Lease lease, SutProvider sutProvider) + Guid userId, AccessLease lease, SutProvider sutProvider) { SetupUser(sutProvider, userId); - lease.Status = LeaseStatus.Active; - sutProvider.GetDependency().GetMineActiveAsync(userId).Returns([lease]); + lease.Status = AccessLeaseStatus.Active; + sutProvider.GetDependency().GetMineActiveAsync(userId).Returns([lease]); var result = (await sutProvider.Sut.GetMyActiveLeases()).ToList(); Assert.Single(result); Assert.Equal(lease.Id, result[0].Id); - Assert.Equal(LeaseStatusName.Active, result[0].Status); + Assert.Equal(AccessLeaseStatusNames.Active, result[0].Status); } [Theory, BitAutoData] diff --git a/test/Api.Test/Pam/Models/MemberLeaseResponseModelTests.cs b/test/Api.Test/Pam/Models/AccessLeaseResponseModelTests.cs similarity index 66% rename from test/Api.Test/Pam/Models/MemberLeaseResponseModelTests.cs rename to test/Api.Test/Pam/Models/AccessLeaseResponseModelTests.cs index f90889eed88b..b3837419bb21 100644 --- a/test/Api.Test/Pam/Models/MemberLeaseResponseModelTests.cs +++ b/test/Api.Test/Pam/Models/AccessLeaseResponseModelTests.cs @@ -7,22 +7,22 @@ namespace Bit.Api.Test.Pam.Models; -public class MemberLeaseResponseModelTests +public class AccessLeaseResponseModelTests { [Theory, BitAutoData] - public void Ctor_MapsLeaseToClientShape(Lease lease) + public void Ctor_MapsLeaseToClientShape(AccessLease lease) { - lease.Status = LeaseStatus.Active; + lease.Status = AccessLeaseStatus.Active; - var model = new MemberLeaseResponseModel(lease); + var model = new AccessLeaseResponseModel(lease); Assert.Equal(lease.Id, model.Id); - Assert.Equal(lease.LeaseRequestId, model.RequestId); + Assert.Equal(lease.AccessRequestId, model.RequestId); Assert.Equal(lease.CipherId, model.CipherId); Assert.Equal(lease.CollectionId, model.CollectionId); Assert.Equal(lease.OrganizationId, model.OrganizationId); - Assert.Equal(lease.RequesterId, model.GranteeUserId); - Assert.Equal(LeaseStatusName.Active, model.Status); + Assert.Equal(lease.RequesterId, model.RequesterId); + Assert.Equal(AccessLeaseStatusNames.Active, model.Status); Assert.Equal(lease.NotBefore, model.NotBefore); Assert.Equal(lease.NotAfter, model.NotAfter); Assert.Equal(lease.RevokedDate, model.RevokedAt); diff --git a/test/Core.Test/Pam/Commands/CreateAccessRuleCommandTests.cs b/test/Core.Test/Pam/Commands/CreateAccessRuleCommandTests.cs index 26da5cb6fa1a..11bd6f11c639 100644 --- a/test/Core.Test/Pam/Commands/CreateAccessRuleCommandTests.cs +++ b/test/Core.Test/Pam/Commands/CreateAccessRuleCommandTests.cs @@ -23,9 +23,9 @@ public async Task CreateAsync_HappyPath_PersistsWithTimestampsAndValidates(Acces { var sutProvider = SetupSutProvider(); rule.Name = "VPN + business hours"; - rule.Rule = """{"kind":"human_approval"}"""; + rule.Conditions = """{"kind":"human_approval"}"""; sutProvider.GetDependency() - .Validate(rule.Rule) + .Validate(rule.Conditions) .Returns(AccessRuleValidationResult.Valid); sutProvider.GetDependency() .GetManyByOrganizationIdAsync(rule.OrganizationId) @@ -47,14 +47,14 @@ public async Task CreateAsync_WithCollections_AssociatesAndReturnsThem(AccessRul { var sutProvider = SetupSutProvider(); rule.Name = "VPN + business hours"; - rule.Rule = """{"kind":"human_approval"}"""; + rule.Conditions = """{"kind":"human_approval"}"""; collectionA.OrganizationId = rule.OrganizationId; collectionA.AccessRuleId = null; collectionB.OrganizationId = rule.OrganizationId; collectionB.AccessRuleId = null; var collectionIds = new[] { collectionA.Id, collectionB.Id }; sutProvider.GetDependency() - .Validate(rule.Rule) + .Validate(rule.Conditions) .Returns(AccessRuleValidationResult.Valid); sutProvider.GetDependency() .GetManyByOrganizationIdAsync(rule.OrganizationId) @@ -79,10 +79,10 @@ public async Task CreateAsync_CollectionInDifferentOrg_ThrowsBadRequest(AccessRu { var sutProvider = SetupSutProvider(); rule.Name = "test"; - rule.Rule = """{"kind":"human_approval"}"""; + rule.Conditions = """{"kind":"human_approval"}"""; collection.OrganizationId = Guid.NewGuid(); sutProvider.GetDependency() - .Validate(rule.Rule) + .Validate(rule.Conditions) .Returns(AccessRuleValidationResult.Valid); sutProvider.GetDependency() .GetManyByOrganizationIdAsync(rule.OrganizationId) @@ -102,11 +102,11 @@ public async Task CreateAsync_CollectionGovernedByAnotherRule_ThrowsBadRequest(A { var sutProvider = SetupSutProvider(); rule.Name = "test"; - rule.Rule = """{"kind":"human_approval"}"""; + rule.Conditions = """{"kind":"human_approval"}"""; collection.OrganizationId = rule.OrganizationId; collection.AccessRuleId = Guid.NewGuid(); sutProvider.GetDependency() - .Validate(rule.Rule) + .Validate(rule.Conditions) .Returns(AccessRuleValidationResult.Valid); sutProvider.GetDependency() .GetManyByOrganizationIdAsync(rule.OrganizationId) @@ -126,9 +126,9 @@ public async Task CreateAsync_CollectionNotFound_ThrowsBadRequest(AccessRule rul { var sutProvider = SetupSutProvider(); rule.Name = "test"; - rule.Rule = """{"kind":"human_approval"}"""; + rule.Conditions = """{"kind":"human_approval"}"""; sutProvider.GetDependency() - .Validate(rule.Rule) + .Validate(rule.Conditions) .Returns(AccessRuleValidationResult.Valid); sutProvider.GetDependency() .GetManyByOrganizationIdAsync(rule.OrganizationId) @@ -159,9 +159,9 @@ public async Task CreateAsync_InvalidRule_ThrowsBadRequest(AccessRule rule) { var sutProvider = SetupSutProvider(); rule.Name = "test"; - rule.Rule = """{"kind":"bogus"}"""; + rule.Conditions = """{"kind":"bogus"}"""; sutProvider.GetDependency() - .Validate(rule.Rule) + .Validate(rule.Conditions) .Returns(AccessRuleValidationResult.Invalid("Unsupported rule kind")); var ex = await Assert.ThrowsAsync(() => sutProvider.Sut.CreateAsync(rule, [])); @@ -174,11 +174,11 @@ public async Task CreateAsync_DuplicateName_ThrowsBadRequest(AccessRule rule, Ac { var sutProvider = SetupSutProvider(); rule.Name = "duplicate"; - rule.Rule = """{"kind":"human_approval"}"""; + rule.Conditions = """{"kind":"human_approval"}"""; existing.OrganizationId = rule.OrganizationId; existing.Name = "Duplicate"; // case-insensitive collision sutProvider.GetDependency() - .Validate(rule.Rule) + .Validate(rule.Conditions) .Returns(AccessRuleValidationResult.Valid); sutProvider.GetDependency() .GetManyByOrganizationIdAsync(rule.OrganizationId) diff --git a/test/Core.Test/Pam/Commands/DecideLeaseRequestCommandTests.cs b/test/Core.Test/Pam/Commands/DecideAccessRequestCommandTests.cs similarity index 62% rename from test/Core.Test/Pam/Commands/DecideLeaseRequestCommandTests.cs rename to test/Core.Test/Pam/Commands/DecideAccessRequestCommandTests.cs index 33c5ad57cb70..8d3692b9397d 100644 --- a/test/Core.Test/Pam/Commands/DecideLeaseRequestCommandTests.cs +++ b/test/Core.Test/Pam/Commands/DecideAccessRequestCommandTests.cs @@ -14,7 +14,7 @@ namespace Bit.Core.Test.Pam.Commands; [SutProviderCustomize] -public class DecideLeaseRequestCommandTests +public class DecideAccessRequestCommandTests { private static readonly DateTime _now = new(2026, 6, 5, 12, 0, 0, DateTimeKind.Utc); @@ -22,18 +22,18 @@ public class DecideLeaseRequestCommandTests public async Task DecideAsync_RequestMissing_ThrowsNotFound(Guid userId, Guid requestId) { var sutProvider = Setup(); - sutProvider.GetDependency().GetByIdAsync(requestId).Returns((LeaseRequest?)null); + sutProvider.GetDependency().GetByIdAsync(requestId).Returns((AccessRequest?)null); await Assert.ThrowsAsync( () => sutProvider.Sut.DecideAsync(userId, requestId, Approve())); } [Theory, BitAutoData] - public async Task DecideAsync_NotManageable_ThrowsNotFound(Guid userId, LeaseRequest request) + public async Task DecideAsync_NotManageable_ThrowsNotFound(Guid userId, AccessRequest request) { var sutProvider = Setup(); - request.Status = LeaseRequestStatus.Pending; - sutProvider.GetDependency().GetByIdAsync(request.Id).Returns(request); + request.Status = AccessRequestStatus.Pending; + sutProvider.GetDependency().GetByIdAsync(request.Id).Returns(request); sutProvider.GetDependency() .CanManageCollectionAsync(userId, request.CollectionId).Returns(false); @@ -42,10 +42,10 @@ await Assert.ThrowsAsync( } [Theory, BitAutoData] - public async Task DecideAsync_NotPending_ThrowsConflict(Guid userId, LeaseRequest request) + public async Task DecideAsync_NotPending_ThrowsConflict(Guid userId, AccessRequest request) { var sutProvider = Setup(); - request.Status = LeaseRequestStatus.Approved; + request.Status = AccessRequestStatus.Approved; SetupManageableRequest(sutProvider, userId, request); await Assert.ThrowsAsync( @@ -53,49 +53,49 @@ await Assert.ThrowsAsync( } [Theory, BitAutoData] - public async Task DecideAsync_SelfApproval_ThrowsBadRequest(Guid userId, LeaseRequest request) + public async Task DecideAsync_SelfApproval_ThrowsBadRequest(Guid userId, AccessRequest request) { var sutProvider = Setup(); - request.Status = LeaseRequestStatus.Pending; + request.Status = AccessRequestStatus.Pending; request.RequesterId = userId; SetupManageableRequest(sutProvider, userId, request); var ex = await Assert.ThrowsAsync( () => sutProvider.Sut.DecideAsync(userId, request.Id, Approve())); Assert.Contains("your own request", ex.Message); - await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() .ResolveWithDecisionAsync(default!, default!, default, default, default); } [Theory, BitAutoData] - public async Task DecideAsync_Approve_ResolvesAndWritesHumanDecision(Guid userId, LeaseRequest request) + public async Task DecideAsync_Approve_ResolvesAndWritesHumanDecision(Guid userId, AccessRequest request) { var sutProvider = Setup(); - request.Status = LeaseRequestStatus.Pending; + request.Status = AccessRequestStatus.Pending; SetupManageableRequest(sutProvider, userId, request); var result = await sutProvider.Sut.DecideAsync(userId, request.Id, Approve("looks good")); - Assert.Equal(LeaseRequestStatus.Approved, result.Status); + Assert.Equal(AccessRequestStatus.Approved, result.Status); Assert.Equal(_now, result.ResolvedDate); - Assert.Equal(userId, result.ResolverId); - Assert.Equal("looks good", result.ResolverComment); - await sutProvider.GetDependency().Received(1).ResolveWithDecisionAsync( + Assert.Equal(userId, result.ApproverId); + Assert.Equal("looks good", result.ApproverComment); + await sutProvider.GetDependency().Received(1).ResolveWithDecisionAsync( request, - Arg.Is(d => - d.DeciderKind == LeaseDecisionKind.Human && + Arg.Is(d => + d.DeciderKind == AccessDeciderKind.Human && d.ApproverId == userId && - d.Decision == LeaseDecisionVerdict.Approve && + d.Verdict == AccessDecisionVerdict.Approve && d.Comment == "looks good"), - LeaseRequestStatus.Approved, + AccessRequestStatus.Approved, // Approval mints an active lease spanning the request's approved window. - Arg.Is(l => - l.LeaseRequestId == request.Id && + Arg.Is(l => + l.AccessRequestId == request.Id && l.OrganizationId == request.OrganizationId && l.CollectionId == request.CollectionId && l.CipherId == request.CipherId && l.RequesterId == request.RequesterId && - l.Status == LeaseStatus.Active && + l.Status == AccessLeaseStatus.Active && l.NotBefore == request.NotBefore && l.NotAfter == request.NotAfter && l.Id != default), @@ -105,40 +105,40 @@ await sutProvider.GetDependency().Received(1) } [Theory, BitAutoData] - public async Task DecideAsync_Deny_ResolvesAsDenied(Guid userId, LeaseRequest request) + public async Task DecideAsync_Deny_ResolvesAsDenied(Guid userId, AccessRequest request) { var sutProvider = Setup(); - request.Status = LeaseRequestStatus.Pending; + request.Status = AccessRequestStatus.Pending; SetupManageableRequest(sutProvider, userId, request); var result = await sutProvider.Sut.DecideAsync(userId, request.Id, Deny()); - Assert.Equal(LeaseRequestStatus.Denied, result.Status); - await sutProvider.GetDependency().Received(1).ResolveWithDecisionAsync( + Assert.Equal(AccessRequestStatus.Denied, result.Status); + await sutProvider.GetDependency().Received(1).ResolveWithDecisionAsync( request, - Arg.Is(d => d.Decision == LeaseDecisionVerdict.Deny), - LeaseRequestStatus.Denied, + Arg.Is(d => d.Verdict == AccessDecisionVerdict.Deny), + AccessRequestStatus.Denied, // A denial creates no lease. null, _now); } - private static LeaseDecisionSubmission Approve(string? comment = null) => - new() { Verdict = LeaseDecisionVerdict.Approve, Comment = comment }; + private static AccessDecisionSubmission Approve(string? comment = null) => + new() { Verdict = AccessDecisionVerdict.Approve, Comment = comment }; - private static LeaseDecisionSubmission Deny(string? comment = null) => - new() { Verdict = LeaseDecisionVerdict.Deny, Comment = comment }; + private static AccessDecisionSubmission Deny(string? comment = null) => + new() { Verdict = AccessDecisionVerdict.Deny, Comment = comment }; - private static SutProvider Setup() + private static SutProvider Setup() { - var sutProvider = new SutProvider().WithFakeTimeProvider().Create(); + var sutProvider = new SutProvider().WithFakeTimeProvider().Create(); sutProvider.GetDependency().SetUtcNow(_now); return sutProvider; } - private static void SetupManageableRequest(SutProvider sutProvider, Guid userId, LeaseRequest request) + private static void SetupManageableRequest(SutProvider sutProvider, Guid userId, AccessRequest request) { - sutProvider.GetDependency().GetByIdAsync(request.Id).Returns(request); + sutProvider.GetDependency().GetByIdAsync(request.Id).Returns(request); sutProvider.GetDependency() .CanManageCollectionAsync(userId, request.CollectionId).Returns(true); } diff --git a/test/Core.Test/Pam/Commands/RevokeLeaseCommandTests.cs b/test/Core.Test/Pam/Commands/RevokeAccessLeaseCommandTests.cs similarity index 65% rename from test/Core.Test/Pam/Commands/RevokeLeaseCommandTests.cs rename to test/Core.Test/Pam/Commands/RevokeAccessLeaseCommandTests.cs index fad588a4bd26..ab624d1f5d46 100644 --- a/test/Core.Test/Pam/Commands/RevokeLeaseCommandTests.cs +++ b/test/Core.Test/Pam/Commands/RevokeAccessLeaseCommandTests.cs @@ -13,7 +13,7 @@ namespace Bit.Core.Test.Pam.Commands; [SutProviderCustomize] -public class RevokeLeaseCommandTests +public class RevokeAccessLeaseCommandTests { private static readonly DateTime _now = new(2026, 6, 5, 12, 0, 0, DateTimeKind.Utc); @@ -21,17 +21,17 @@ public class RevokeLeaseCommandTests public async Task RevokeAsync_LeaseMissing_ThrowsNotFound(Guid userId, Guid leaseId) { var sutProvider = Setup(); - sutProvider.GetDependency().GetByIdAsync(leaseId).Returns((Lease?)null); + sutProvider.GetDependency().GetByIdAsync(leaseId).Returns((AccessLease?)null); await Assert.ThrowsAsync(() => sutProvider.Sut.RevokeAsync(userId, leaseId, null)); } [Theory, BitAutoData] - public async Task RevokeAsync_NotManageable_ThrowsNotFound(Guid userId, Lease lease) + public async Task RevokeAsync_NotManageable_ThrowsNotFound(Guid userId, AccessLease lease) { var sutProvider = Setup(); - lease.Status = LeaseStatus.Active; - sutProvider.GetDependency().GetByIdAsync(lease.Id).Returns(lease); + lease.Status = AccessLeaseStatus.Active; + sutProvider.GetDependency().GetByIdAsync(lease.Id).Returns(lease); sutProvider.GetDependency() .CanManageCollectionAsync(userId, lease.CollectionId).Returns(false); @@ -39,47 +39,47 @@ public async Task RevokeAsync_NotManageable_ThrowsNotFound(Guid userId, Lease le } [Theory, BitAutoData] - public async Task RevokeAsync_NotActive_ThrowsConflict(Guid userId, Lease lease) + public async Task RevokeAsync_NotActive_ThrowsConflict(Guid userId, AccessLease lease) { var sutProvider = Setup(); - lease.Status = LeaseStatus.Revoked; + lease.Status = AccessLeaseStatus.Revoked; SetupManageableLease(sutProvider, userId, lease); await Assert.ThrowsAsync(() => sutProvider.Sut.RevokeAsync(userId, lease.Id, null)); } [Theory, BitAutoData] - public async Task RevokeAsync_Active_RevokesAndWritesAuditDecision(Guid userId, Lease lease) + public async Task RevokeAsync_Active_RevokesAndWritesAuditDecision(Guid userId, AccessLease lease) { var sutProvider = Setup(); - lease.Status = LeaseStatus.Active; + lease.Status = AccessLeaseStatus.Active; SetupManageableLease(sutProvider, userId, lease); await sutProvider.Sut.RevokeAsync(userId, lease.Id, "policy change"); - await sutProvider.GetDependency().Received(1).RevokeAsync( + await sutProvider.GetDependency().Received(1).RevokeAsync( lease, - Arg.Is(d => - d.LeaseRequestId == lease.LeaseRequestId && - d.DeciderKind == LeaseDecisionKind.Human && + Arg.Is(d => + d.AccessRequestId == lease.AccessRequestId && + d.DeciderKind == AccessDeciderKind.Human && d.ApproverId == userId && - d.Decision == LeaseDecisionVerdict.Deny && + d.Verdict == AccessDecisionVerdict.Deny && d.Comment == "policy change"), _now); await sutProvider.GetDependency().Received(1) .NotifyCollectionApproversAsync(lease.CollectionId); } - private static SutProvider Setup() + private static SutProvider Setup() { - var sutProvider = new SutProvider().WithFakeTimeProvider().Create(); + var sutProvider = new SutProvider().WithFakeTimeProvider().Create(); sutProvider.GetDependency().SetUtcNow(_now); return sutProvider; } - private static void SetupManageableLease(SutProvider sutProvider, Guid userId, Lease lease) + private static void SetupManageableLease(SutProvider sutProvider, Guid userId, AccessLease lease) { - sutProvider.GetDependency().GetByIdAsync(lease.Id).Returns(lease); + sutProvider.GetDependency().GetByIdAsync(lease.Id).Returns(lease); sutProvider.GetDependency() .CanManageCollectionAsync(userId, lease.CollectionId).Returns(true); } diff --git a/test/Core.Test/Pam/Commands/RequestAccessCommandTests.cs b/test/Core.Test/Pam/Commands/SubmitAccessRequestCommandTests.cs similarity index 59% rename from test/Core.Test/Pam/Commands/RequestAccessCommandTests.cs rename to test/Core.Test/Pam/Commands/SubmitAccessRequestCommandTests.cs index c119642b7164..3d9c408a83f5 100644 --- a/test/Core.Test/Pam/Commands/RequestAccessCommandTests.cs +++ b/test/Core.Test/Pam/Commands/SubmitAccessRequestCommandTests.cs @@ -3,7 +3,7 @@ using Bit.Core.Pam.Entities; using Bit.Core.Pam.Enums; using Bit.Core.Pam.Models; -using Bit.Core.Pam.Models.Rules; +using Bit.Core.Pam.Models.Conditions; using Bit.Core.Pam.OrganizationFeatures.Commands; using Bit.Core.Pam.Repositories; using Bit.Core.Pam.Services; @@ -18,144 +18,145 @@ namespace Bit.Core.Test.Pam.Commands; [SutProviderCustomize] -public class RequestAccessCommandTests +public class SubmitAccessRequestCommandTests { private static readonly DateTime _now = new(2026, 6, 4, 12, 0, 0, DateTimeKind.Utc); [Theory, BitAutoData] - public async Task RequestAccessAsync_CipherNotAccessible_ThrowsNotFound(Guid userId, Guid cipherId) + public async Task SubmitAsync_CipherNotAccessible_ThrowsNotFound(Guid userId, Guid cipherId) { var sutProvider = Setup(); sutProvider.GetDependency().GetByIdAsync(cipherId, userId).Returns((CipherDetails?)null); await Assert.ThrowsAsync( - () => sutProvider.Sut.RequestAccessAsync(userId, cipherId, new AccessRequestSubmission { DurationSeconds = 3600 })); + () => sutProvider.Sut.SubmitAsync(userId, cipherId, new AccessRequestSubmission { DurationSeconds = 3600 })); } [Theory, BitAutoData] - public async Task RequestAccessAsync_NotLeasingGated_ThrowsBadRequest(Guid userId, Guid cipherId) + public async Task SubmitAsync_NotLeasingGated_ThrowsBadRequest(Guid userId, Guid cipherId) { var sutProvider = Setup(); SetupCipher(sutProvider, userId, cipherId); - sutProvider.GetDependency().ResolveAsync(userId, cipherId) - .Returns((AccessApprovalResolution?)null); + sutProvider.GetDependency().ResolveAsync(userId, cipherId) + .Returns((GoverningRule?)null); var ex = await Assert.ThrowsAsync( - () => sutProvider.Sut.RequestAccessAsync(userId, cipherId, new AccessRequestSubmission { DurationSeconds = 3600 })); + () => sutProvider.Sut.SubmitAsync(userId, cipherId, new AccessRequestSubmission { DurationSeconds = 3600 })); Assert.Contains("does not require a lease", ex.Message); } [Theory, BitAutoData] - public async Task RequestAccessAsync_Automatic_IssuesActiveLease(Guid userId, Guid cipherId, Guid orgId, Guid collectionId) + public async Task SubmitAsync_Automatic_IssuesActiveLease(Guid userId, Guid cipherId, Guid orgId, Guid collectionId) { var sutProvider = Setup(); SetupCipher(sutProvider, userId, cipherId); SetupResolution(sutProvider, userId, cipherId, orgId, collectionId, requiresHuman: false); - SetupPolicyDecision(sutProvider, AccessDecision.Allow); + SetupEvaluation(sutProvider, AccessEvaluation.Allow); - var result = await sutProvider.Sut.RequestAccessAsync(userId, cipherId, + var result = await sutProvider.Sut.SubmitAsync(userId, cipherId, new AccessRequestSubmission { DurationSeconds = 3600, Reason = "deploy" }); - Assert.Equal(AccessApprovalOutcome.Automatic, result.Outcome); + Assert.Equal(AccessApprovalMode.Automatic, result.ApprovalMode); Assert.NotNull(result.Lease); - Assert.Equal(LeaseStatus.Active, result.Lease!.Status); + Assert.Equal(AccessLeaseStatus.Active, result.Lease!.Status); Assert.Equal(_now, result.Lease.NotBefore); Assert.Equal(_now.AddSeconds(3600), result.Lease.NotAfter); - await sutProvider.GetDependency().Received(1) - .CreateAutoApprovedAsync(Arg.Any(), Arg.Any(), Arg.Any(), _now); + await sutProvider.GetDependency().Received(1) + .CreateAutoApprovedAsync(Arg.Any(), Arg.Any(), Arg.Any(), _now); } [Theory, BitAutoData] - public async Task RequestAccessAsync_AutomaticWithWindow_ThrowsBadRequest(Guid userId, Guid cipherId, Guid orgId, Guid collectionId) + public async Task SubmitAsync_AutomaticWithWindow_ThrowsBadRequest(Guid userId, Guid cipherId, Guid orgId, Guid collectionId) { var sutProvider = Setup(); SetupCipher(sutProvider, userId, cipherId); SetupResolution(sutProvider, userId, cipherId, orgId, collectionId, requiresHuman: false); var ex = await Assert.ThrowsAsync( - () => sutProvider.Sut.RequestAccessAsync(userId, cipherId, + () => sutProvider.Sut.SubmitAsync(userId, cipherId, new AccessRequestSubmission { Start = _now, End = _now.AddHours(1) })); Assert.Contains("provide a duration", ex.Message); - await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() .CreateAutoApprovedAsync(default!, default!, default!, default); } [Theory, BitAutoData] - public async Task RequestAccessAsync_AutomaticMissingDuration_ThrowsBadRequest(Guid userId, Guid cipherId, Guid orgId, Guid collectionId) + public async Task SubmitAsync_AutomaticMissingDuration_ThrowsBadRequest(Guid userId, Guid cipherId, Guid orgId, Guid collectionId) { var sutProvider = Setup(); SetupCipher(sutProvider, userId, cipherId); SetupResolution(sutProvider, userId, cipherId, orgId, collectionId, requiresHuman: false); await Assert.ThrowsAsync( - () => sutProvider.Sut.RequestAccessAsync(userId, cipherId, new AccessRequestSubmission())); + () => sutProvider.Sut.SubmitAsync(userId, cipherId, new AccessRequestSubmission())); } [Theory, BitAutoData] - public async Task RequestAccessAsync_AutomaticDurationExceedsMax_ThrowsBadRequest(Guid userId, Guid cipherId, Guid orgId, Guid collectionId) + public async Task SubmitAsync_AutomaticDurationExceedsMax_ThrowsBadRequest(Guid userId, Guid cipherId, Guid orgId, Guid collectionId) { var sutProvider = Setup(); SetupCipher(sutProvider, userId, cipherId); SetupResolution(sutProvider, userId, cipherId, orgId, collectionId, requiresHuman: false); var ex = await Assert.ThrowsAsync( - () => sutProvider.Sut.RequestAccessAsync(userId, cipherId, - new AccessRequestSubmission { DurationSeconds = RequestAccessCommand.MaxDurationSeconds + 1 })); + () => sutProvider.Sut.SubmitAsync(userId, cipherId, + new AccessRequestSubmission { DurationSeconds = SubmitAccessRequestCommand.MaxDurationSeconds + 1 })); Assert.Contains("maximum", ex.Message); } [Theory, BitAutoData] - public async Task RequestAccessAsync_AutomaticPolicyDenied_ThrowsBadRequestAndIssuesNoLease( + public async Task SubmitAsync_AutomaticPolicyDenied_ThrowsBadRequestAndIssuesNoLease( Guid userId, Guid cipherId, Guid orgId, Guid collectionId) { var sutProvider = Setup(); SetupCipher(sutProvider, userId, cipherId); SetupResolution(sutProvider, userId, cipherId, orgId, collectionId, requiresHuman: false); - SetupPolicyDecision(sutProvider, AccessDecision.Deny(DenyReason.NotWithinIpRange)); + SetupEvaluation(sutProvider, AccessEvaluation.Deny(DenyReason.NotWithinIpRange)); var ex = await Assert.ThrowsAsync( - () => sutProvider.Sut.RequestAccessAsync(userId, cipherId, new AccessRequestSubmission { DurationSeconds = 3600 })); + () => sutProvider.Sut.SubmitAsync(userId, cipherId, new AccessRequestSubmission { DurationSeconds = 3600 })); Assert.Contains("network", ex.Message); // A rule the caller fails to satisfy must not produce a lease. - await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() .CreateAutoApprovedAsync(default!, default!, default!, default); } [Theory, BitAutoData] - public async Task RequestAccessAsync_Human_CreatesPendingRequest(Guid userId, Guid cipherId, Guid orgId, Guid collectionId) + public async Task SubmitAsync_Human_CreatesPendingRequest(Guid userId, Guid cipherId, Guid orgId, Guid collectionId) { var sutProvider = Setup(); SetupCipher(sutProvider, userId, cipherId); SetupResolution(sutProvider, userId, cipherId, orgId, collectionId, requiresHuman: true); - sutProvider.GetDependency() - .CreateAsync(Arg.Any()) - .Returns(callInfo => callInfo.Arg()); + sutProvider.GetDependency() + .CreateAsync(Arg.Any()) + .Returns(callInfo => callInfo.Arg()); var start = _now.AddHours(1); var end = _now.AddHours(2); - var result = await sutProvider.Sut.RequestAccessAsync(userId, cipherId, + var result = await sutProvider.Sut.SubmitAsync(userId, cipherId, new AccessRequestSubmission { Start = start, End = end, Reason = "audit" }); - Assert.Equal(AccessApprovalOutcome.Human, result.Outcome); + Assert.Equal(AccessApprovalMode.Human, result.ApprovalMode); Assert.NotNull(result.Request); - Assert.Equal(LeaseRequestStatus.Pending, result.Request!.Status); + Assert.Equal(AccessRequestStatus.Pending, result.Request!.Status); Assert.Equal(start, result.Request.NotBefore); Assert.Equal(end, result.Request.NotAfter); Assert.Equal("audit", result.Request.Reason); - await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() .CreateAutoApprovedAsync(default!, default!, default!, default); await sutProvider.GetDependency().Received(1) .NotifyCollectionApproversAsync(collectionId); } [Theory, BitAutoData] - public async Task RequestAccessAsync_Automatic_DoesNotNotifyApprovers(Guid userId, Guid cipherId, Guid orgId, Guid collectionId) + public async Task SubmitAsync_Automatic_DoesNotNotifyApprovers(Guid userId, Guid cipherId, Guid orgId, Guid collectionId) { var sutProvider = Setup(); SetupCipher(sutProvider, userId, cipherId); SetupResolution(sutProvider, userId, cipherId, orgId, collectionId, requiresHuman: false); + SetupEvaluation(sutProvider, AccessEvaluation.Allow); - await sutProvider.Sut.RequestAccessAsync(userId, cipherId, + await sutProvider.Sut.SubmitAsync(userId, cipherId, new AccessRequestSubmission { DurationSeconds = 3600, Reason = "deploy" }); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() @@ -163,102 +164,102 @@ await sutProvider.GetDependency().DidNotReceiveWithAnyAr } [Theory, BitAutoData] - public async Task RequestAccessAsync_HumanMissingReason_ThrowsBadRequest(Guid userId, Guid cipherId, Guid orgId, Guid collectionId) + public async Task SubmitAsync_HumanMissingReason_ThrowsBadRequest(Guid userId, Guid cipherId, Guid orgId, Guid collectionId) { var sutProvider = Setup(); SetupCipher(sutProvider, userId, cipherId); SetupResolution(sutProvider, userId, cipherId, orgId, collectionId, requiresHuman: true); var ex = await Assert.ThrowsAsync( - () => sutProvider.Sut.RequestAccessAsync(userId, cipherId, + () => sutProvider.Sut.SubmitAsync(userId, cipherId, new AccessRequestSubmission { Start = _now.AddHours(1), End = _now.AddHours(2) })); Assert.Contains("reason is required", ex.Message); } [Theory, BitAutoData] - public async Task RequestAccessAsync_HumanWithDuration_ThrowsBadRequest(Guid userId, Guid cipherId, Guid orgId, Guid collectionId) + public async Task SubmitAsync_HumanWithDuration_ThrowsBadRequest(Guid userId, Guid cipherId, Guid orgId, Guid collectionId) { var sutProvider = Setup(); SetupCipher(sutProvider, userId, cipherId); SetupResolution(sutProvider, userId, cipherId, orgId, collectionId, requiresHuman: true); var ex = await Assert.ThrowsAsync( - () => sutProvider.Sut.RequestAccessAsync(userId, cipherId, + () => sutProvider.Sut.SubmitAsync(userId, cipherId, new AccessRequestSubmission { DurationSeconds = 3600, Reason = "x" })); Assert.Contains("requires human approval", ex.Message); } [Theory, BitAutoData] - public async Task RequestAccessAsync_HumanStartNotBeforeEnd_ThrowsBadRequest(Guid userId, Guid cipherId, Guid orgId, Guid collectionId) + public async Task SubmitAsync_HumanStartNotBeforeEnd_ThrowsBadRequest(Guid userId, Guid cipherId, Guid orgId, Guid collectionId) { var sutProvider = Setup(); SetupCipher(sutProvider, userId, cipherId); SetupResolution(sutProvider, userId, cipherId, orgId, collectionId, requiresHuman: true); var ex = await Assert.ThrowsAsync( - () => sutProvider.Sut.RequestAccessAsync(userId, cipherId, + () => sutProvider.Sut.SubmitAsync(userId, cipherId, new AccessRequestSubmission { Start = _now.AddHours(2), End = _now.AddHours(1), Reason = "x" })); Assert.Contains("before the end date", ex.Message); } [Theory, BitAutoData] - public async Task RequestAccessAsync_ExistingActiveLease_ThrowsBadRequest(Guid userId, Guid cipherId, Guid orgId, Guid collectionId, Lease lease) + public async Task SubmitAsync_ExistingActiveLease_ThrowsBadRequest(Guid userId, Guid cipherId, Guid orgId, Guid collectionId, AccessLease lease) { var sutProvider = Setup(); SetupCipher(sutProvider, userId, cipherId); SetupResolution(sutProvider, userId, cipherId, orgId, collectionId, requiresHuman: false); - sutProvider.GetDependency() + sutProvider.GetDependency() .GetActiveByRequesterIdCipherIdAsync(userId, cipherId, _now) .Returns(lease); var ex = await Assert.ThrowsAsync( - () => sutProvider.Sut.RequestAccessAsync(userId, cipherId, new AccessRequestSubmission { DurationSeconds = 3600 })); + () => sutProvider.Sut.SubmitAsync(userId, cipherId, new AccessRequestSubmission { DurationSeconds = 3600 })); Assert.Contains("already have active access", ex.Message); } [Theory, BitAutoData] - public async Task RequestAccessAsync_ExistingPendingRequest_ThrowsBadRequest(Guid userId, Guid cipherId, Guid orgId, Guid collectionId, LeaseRequest pending) + public async Task SubmitAsync_ExistingPendingRequest_ThrowsBadRequest(Guid userId, Guid cipherId, Guid orgId, Guid collectionId, AccessRequest pending) { var sutProvider = Setup(); SetupCipher(sutProvider, userId, cipherId); SetupResolution(sutProvider, userId, cipherId, orgId, collectionId, requiresHuman: true); - sutProvider.GetDependency() + sutProvider.GetDependency() .GetActivePendingByRequesterIdCipherIdAsync(userId, cipherId) .Returns(pending); var ex = await Assert.ThrowsAsync( - () => sutProvider.Sut.RequestAccessAsync(userId, cipherId, + () => sutProvider.Sut.SubmitAsync(userId, cipherId, new AccessRequestSubmission { Start = _now.AddHours(1), End = _now.AddHours(2), Reason = "x" })); Assert.Contains("already have a pending request", ex.Message); } - private static SutProvider Setup() + private static SutProvider Setup() { - var sutProvider = new SutProvider().WithFakeTimeProvider().Create(); + var sutProvider = new SutProvider().WithFakeTimeProvider().Create(); sutProvider.GetDependency().SetUtcNow(_now); return sutProvider; } - private static void SetupCipher(SutProvider sutProvider, Guid userId, Guid cipherId) + private static void SetupCipher(SutProvider sutProvider, Guid userId, Guid cipherId) { sutProvider.GetDependency() .GetByIdAsync(cipherId, userId) .Returns(new CipherDetails { Id = cipherId }); } - private static void SetupResolution(SutProvider sutProvider, Guid userId, Guid cipherId, + private static void SetupResolution(SutProvider sutProvider, Guid userId, Guid cipherId, Guid orgId, Guid collectionId, bool requiresHuman) { - var rule = requiresHuman ? new HumanApprovalRule() : (Rule)new IpAllowlistRule { Cidrs = ["10.0.0.0/8"] }; - sutProvider.GetDependency() + var condition = requiresHuman ? new HumanApprovalCondition() : (AccessCondition)new IpAllowlistCondition { Cidrs = ["10.0.0.0/8"] }; + sutProvider.GetDependency() .ResolveAsync(userId, cipherId) - .Returns(new AccessApprovalResolution(orgId, collectionId, requiresHuman, rule)); + .Returns(new GoverningRule(orgId, collectionId, requiresHuman, condition)); } - private static void SetupPolicyDecision(SutProvider sutProvider, AccessDecision decision) + private static void SetupEvaluation(SutProvider sutProvider, AccessEvaluation evaluation) { - sutProvider.GetDependency() - .Evaluate(Arg.Any(), Arg.Any()) - .Returns(decision); + sutProvider.GetDependency() + .Evaluate(Arg.Any(), Arg.Any()) + .Returns(evaluation); } } diff --git a/test/Core.Test/Pam/Commands/UpdateAccessRuleCommandTests.cs b/test/Core.Test/Pam/Commands/UpdateAccessRuleCommandTests.cs index 7b434f8c6fed..1d7eaaf5886e 100644 --- a/test/Core.Test/Pam/Commands/UpdateAccessRuleCommandTests.cs +++ b/test/Core.Test/Pam/Commands/UpdateAccessRuleCommandTests.cs @@ -27,12 +27,12 @@ public async Task UpdateAsync_HappyPath_UpdatesFieldsAndBumpsRevision(AccessRule existing.CollectionIds = []; update.Name = "renamed"; update.Description = "new description"; - update.Rule = """{"kind":"human_approval"}"""; + update.Conditions = """{"kind":"human_approval"}"""; sutProvider.GetDependency() .GetDetailsByIdAsync(existing.Id) .Returns(existing); sutProvider.GetDependency() - .Validate(update.Rule) + .Validate(update.Conditions) .Returns(AccessRuleValidationResult.Valid); sutProvider.GetDependency() .GetManyByOrganizationIdAsync(orgId) @@ -42,7 +42,7 @@ public async Task UpdateAsync_HappyPath_UpdatesFieldsAndBumpsRevision(AccessRule Assert.Equal("renamed", result.Name); Assert.Equal("new description", result.Description); - Assert.Equal(update.Rule, result.Rule); + Assert.Equal(update.Conditions, result.Conditions); Assert.Equal(_now, result.RevisionDate); await sutProvider.GetDependency().Received(1) .ReplaceAsync(Arg.Is(r => @@ -56,7 +56,7 @@ public async Task UpdateAsync_ReplacesCollections_AssignsNewAndClearsRemoved(Acc var sutProvider = SetupSutProvider(); var orgId = existing.OrganizationId; update.Name = "renamed"; - update.Rule = """{"kind":"human_approval"}"""; + update.Conditions = """{"kind":"human_approval"}"""; keep.OrganizationId = orgId; keep.AccessRuleId = existing.Id; // already governed by this rule add.OrganizationId = orgId; @@ -68,7 +68,7 @@ public async Task UpdateAsync_ReplacesCollections_AssignsNewAndClearsRemoved(Acc .GetDetailsByIdAsync(existing.Id) .Returns(existing); sutProvider.GetDependency() - .Validate(update.Rule) + .Validate(update.Conditions) .Returns(AccessRuleValidationResult.Valid); sutProvider.GetDependency() .GetManyByOrganizationIdAsync(orgId) @@ -92,14 +92,14 @@ public async Task UpdateAsync_EmptyCollections_ClearsAll(AccessRuleDetails exist var sutProvider = SetupSutProvider(); var orgId = existing.OrganizationId; update.Name = "renamed"; - update.Rule = """{"kind":"human_approval"}"""; + update.Conditions = """{"kind":"human_approval"}"""; var currentId = Guid.NewGuid(); existing.CollectionIds = [currentId]; sutProvider.GetDependency() .GetDetailsByIdAsync(existing.Id) .Returns(existing); sutProvider.GetDependency() - .Validate(update.Rule) + .Validate(update.Conditions) .Returns(AccessRuleValidationResult.Valid); sutProvider.GetDependency() .GetManyByOrganizationIdAsync(orgId) @@ -121,14 +121,14 @@ public async Task UpdateAsync_CollectionGovernedByAnotherRule_ThrowsBadRequest(A var sutProvider = SetupSutProvider(); var orgId = existing.OrganizationId; update.Name = "renamed"; - update.Rule = """{"kind":"human_approval"}"""; + update.Conditions = """{"kind":"human_approval"}"""; collection.OrganizationId = orgId; collection.AccessRuleId = Guid.NewGuid(); // a different rule sutProvider.GetDependency() .GetDetailsByIdAsync(existing.Id) .Returns(existing); sutProvider.GetDependency() - .Validate(update.Rule) + .Validate(update.Conditions) .Returns(AccessRuleValidationResult.Valid); sutProvider.GetDependency() .GetManyByOrganizationIdAsync(orgId) @@ -174,12 +174,12 @@ public async Task UpdateAsync_InvalidRule_ThrowsBadRequest(AccessRuleDetails exi var sutProvider = SetupSutProvider(); var orgId = existing.OrganizationId; update.Name = "ok"; - update.Rule = """{"kind":"bogus"}"""; + update.Conditions = """{"kind":"bogus"}"""; sutProvider.GetDependency() .GetDetailsByIdAsync(existing.Id) .Returns(existing); sutProvider.GetDependency() - .Validate(update.Rule) + .Validate(update.Conditions) .Returns(AccessRuleValidationResult.Invalid("nope")); var ex = await Assert.ThrowsAsync( diff --git a/test/Core.Test/Pam/Engine/AccessPolicyEngineTests.cs b/test/Core.Test/Pam/Engine/AccessPolicyEngineTests.cs deleted file mode 100644 index 02ff497041ab..000000000000 --- a/test/Core.Test/Pam/Engine/AccessPolicyEngineTests.cs +++ /dev/null @@ -1,242 +0,0 @@ -using System.Net; -using Bit.Core.Pam.Engine; -using Bit.Core.Pam.Models.Rules; -using Xunit; - -namespace Bit.Core.Test.Pam.Engine; - -public class AccessPolicyEngineTests -{ - // 2026-06-04T12:00:00Z is a Thursday, so "thu" windows match in UTC. - private static readonly DateTimeOffset _now = new(2026, 6, 4, 12, 0, 0, TimeSpan.Zero); - - private readonly AccessPolicyEngine _sut = new(); - - private static AccessPolicySignals Signals(IPAddress? ip = null, DateTimeOffset? at = null) => new() - { - IpAddress = ip, - Timestamp = at ?? _now, - }; - - [Fact] - public void Evaluate_HumanApproval_RequiresApproval() - { - var decision = _sut.Evaluate(new HumanApprovalRule(), Signals()); - - Assert.Equal(DecisionKind.RequiresApproval, decision.Kind); - } - - [Fact] - public void Evaluate_IpAllowlist_IpInRange_Allows() - { - var rule = new IpAllowlistRule { Cidrs = ["10.0.0.0/8"] }; - - var decision = _sut.Evaluate(rule, Signals(IPAddress.Parse("10.1.2.3"))); - - Assert.Equal(DecisionKind.Allow, decision.Kind); - } - - [Fact] - public void Evaluate_IpAllowlist_IpOutOfRange_Denies() - { - var rule = new IpAllowlistRule { Cidrs = ["10.0.0.0/8"] }; - - var decision = _sut.Evaluate(rule, Signals(IPAddress.Parse("192.168.1.1"))); - - Assert.Equal(DecisionKind.Deny, decision.Kind); - Assert.Equal(DenyReason.NotWithinIpRange, decision.Reason); - } - - [Fact] - public void Evaluate_IpAllowlist_UnknownIp_DeniesClosed() - { - var rule = new IpAllowlistRule { Cidrs = ["10.0.0.0/8"] }; - - var decision = _sut.Evaluate(rule, Signals(ip: null)); - - Assert.Equal(DecisionKind.Deny, decision.Kind); - Assert.Equal(DenyReason.NotWithinIpRange, decision.Reason); - } - - [Fact] - public void Evaluate_IpAllowlist_NoEntries_DeniesClosed() - { - var decision = _sut.Evaluate(new IpAllowlistRule(), Signals(IPAddress.Parse("10.1.2.3"))); - - Assert.Equal(DecisionKind.Deny, decision.Kind); - Assert.Equal(DenyReason.NotWithinIpRange, decision.Reason); - } - - [Fact] - public void Evaluate_TimeOfDay_WithinWindow_Allows() - { - var rule = new TimeOfDayRule - { - Tz = "UTC", - Windows = [new TimeWindow { Days = ["thu"], From = "09:00", To = "17:00" }], - }; - - var decision = _sut.Evaluate(rule, Signals()); - - Assert.Equal(DecisionKind.Allow, decision.Kind); - } - - [Fact] - public void Evaluate_TimeOfDay_OutsideTimeWindow_Denies() - { - var rule = new TimeOfDayRule - { - Tz = "UTC", - Windows = [new TimeWindow { Days = ["thu"], From = "00:00", To = "06:00" }], - }; - - var decision = _sut.Evaluate(rule, Signals()); - - Assert.Equal(DecisionKind.Deny, decision.Kind); - Assert.Equal(DenyReason.NotWithinTimeWindow, decision.Reason); - } - - [Fact] - public void Evaluate_TimeOfDay_DayNotListed_Denies() - { - var rule = new TimeOfDayRule - { - Tz = "UTC", - Windows = [new TimeWindow { Days = ["fri"], From = "00:00", To = "23:59" }], - }; - - var decision = _sut.Evaluate(rule, Signals()); - - Assert.Equal(DecisionKind.Deny, decision.Kind); - Assert.Equal(DenyReason.NotWithinTimeWindow, decision.Reason); - } - - [Fact] - public void Evaluate_TimeOfDay_EvaluatesInConfiguredTimezone() - { - // 23:00 UTC is 19:00 (Thursday) in America/New_York during June DST, inside the window. - var rule = new TimeOfDayRule - { - Tz = "America/New_York", - Windows = [new TimeWindow { Days = ["thu"], From = "18:00", To = "20:00" }], - }; - - var decision = _sut.Evaluate(rule, Signals(at: new DateTimeOffset(2026, 6, 4, 23, 0, 0, TimeSpan.Zero))); - - Assert.Equal(DecisionKind.Allow, decision.Kind); - } - - [Fact] - public void Evaluate_TimeOfDay_UnknownTimezone_DeniesClosed() - { - var rule = new TimeOfDayRule - { - Tz = "Not/AZone", - Windows = [new TimeWindow { Days = ["thu"], From = "00:00", To = "23:59" }], - }; - - var decision = _sut.Evaluate(rule, Signals()); - - Assert.Equal(DecisionKind.Deny, decision.Kind); - Assert.Equal(DenyReason.NotWithinTimeWindow, decision.Reason); - } - - [Fact] - public void Evaluate_AllOf_AllAllow_Allows() - { - var rule = new AllOfRule - { - Rules = - [ - new IpAllowlistRule { Cidrs = ["10.0.0.0/8"] }, - new TimeOfDayRule { Tz = "UTC", Windows = [new TimeWindow { Days = ["thu"], From = "09:00", To = "17:00" }] }, - ], - }; - - var decision = _sut.Evaluate(rule, Signals(IPAddress.Parse("10.1.2.3"))); - - Assert.Equal(DecisionKind.Allow, decision.Kind); - } - - [Fact] - public void Evaluate_AllOf_OneDenies_DeniesWithThatReason() - { - var rule = new AllOfRule - { - Rules = - [ - new IpAllowlistRule { Cidrs = ["10.0.0.0/8"] }, - new TimeOfDayRule { Tz = "UTC", Windows = [new TimeWindow { Days = ["thu"], From = "00:00", To = "06:00" }] }, - ], - }; - - var decision = _sut.Evaluate(rule, Signals(IPAddress.Parse("10.1.2.3"))); - - Assert.Equal(DecisionKind.Deny, decision.Kind); - Assert.Equal(DenyReason.NotWithinTimeWindow, decision.Reason); - } - - [Fact] - public void Evaluate_AllOf_AllowPlusHumanApproval_RequiresApproval() - { - var rule = new AllOfRule - { - Rules = - [ - new IpAllowlistRule { Cidrs = ["10.0.0.0/8"] }, - new HumanApprovalRule(), - ], - }; - - var decision = _sut.Evaluate(rule, Signals(IPAddress.Parse("10.1.2.3"))); - - Assert.Equal(DecisionKind.RequiresApproval, decision.Kind); - } - - [Fact] - public void Evaluate_AllOf_DenyOutranksApproval() - { - // A denying condition beats a pending approval: there is nothing to approve if access is barred outright. - var rule = new AllOfRule - { - Rules = - [ - new HumanApprovalRule(), - new IpAllowlistRule { Cidrs = ["10.0.0.0/8"] }, - ], - }; - - var decision = _sut.Evaluate(rule, Signals(IPAddress.Parse("192.168.1.1"))); - - Assert.Equal(DecisionKind.Deny, decision.Kind); - Assert.Equal(DenyReason.NotWithinIpRange, decision.Reason); - } - - [Fact] - public void Evaluate_NestedAllOf_Allows() - { - var rule = new AllOfRule - { - Rules = - [ - new AllOfRule { Rules = [new IpAllowlistRule { Cidrs = ["10.0.0.0/8"] }] }, - new TimeOfDayRule { Tz = "UTC", Windows = [new TimeWindow { Days = ["thu"], From = "09:00", To = "17:00" }] }, - ], - }; - - var decision = _sut.Evaluate(rule, Signals(IPAddress.Parse("10.1.2.3"))); - - Assert.Equal(DecisionKind.Allow, decision.Kind); - } - - [Fact] - public void Evaluate_UnsupportedRuleKind_DeniesClosed() - { - var decision = _sut.Evaluate(new UnknownRule(), Signals()); - - Assert.Equal(DecisionKind.Deny, decision.Kind); - Assert.Equal(DenyReason.UnsupportedRule, decision.Reason); - } - - private sealed class UnknownRule : Rule; -} diff --git a/test/Core.Test/Pam/Engine/AccessRuleEngineTests.cs b/test/Core.Test/Pam/Engine/AccessRuleEngineTests.cs new file mode 100644 index 000000000000..5bf36f018252 --- /dev/null +++ b/test/Core.Test/Pam/Engine/AccessRuleEngineTests.cs @@ -0,0 +1,242 @@ +using System.Net; +using Bit.Core.Pam.Engine; +using Bit.Core.Pam.Models.Conditions; +using Xunit; + +namespace Bit.Core.Test.Pam.Engine; + +public class AccessRuleEngineTests +{ + // 2026-06-04T12:00:00Z is a Thursday, so "thu" windows match in UTC. + private static readonly DateTimeOffset _now = new(2026, 6, 4, 12, 0, 0, TimeSpan.Zero); + + private readonly AccessRuleEngine _sut = new(); + + private static AccessSignals Signals(IPAddress? ip = null, DateTimeOffset? at = null) => new() + { + IpAddress = ip, + Timestamp = at ?? _now, + }; + + [Fact] + public void Evaluate_HumanApproval_RequiresApproval() + { + var evaluation = _sut.Evaluate(new HumanApprovalCondition(), Signals()); + + Assert.Equal(AccessEvaluationOutcome.RequiresApproval, evaluation.Outcome); + } + + [Fact] + public void Evaluate_IpAllowlist_IpInRange_Allows() + { + var rule = new IpAllowlistCondition { Cidrs = ["10.0.0.0/8"] }; + + var evaluation = _sut.Evaluate(rule, Signals(IPAddress.Parse("10.1.2.3"))); + + Assert.Equal(AccessEvaluationOutcome.Allow, evaluation.Outcome); + } + + [Fact] + public void Evaluate_IpAllowlist_IpOutOfRange_Denies() + { + var rule = new IpAllowlistCondition { Cidrs = ["10.0.0.0/8"] }; + + var evaluation = _sut.Evaluate(rule, Signals(IPAddress.Parse("192.168.1.1"))); + + Assert.Equal(AccessEvaluationOutcome.Deny, evaluation.Outcome); + Assert.Equal(DenyReason.NotWithinIpRange, evaluation.Reason); + } + + [Fact] + public void Evaluate_IpAllowlist_UnknownIp_DeniesClosed() + { + var rule = new IpAllowlistCondition { Cidrs = ["10.0.0.0/8"] }; + + var evaluation = _sut.Evaluate(rule, Signals(ip: null)); + + Assert.Equal(AccessEvaluationOutcome.Deny, evaluation.Outcome); + Assert.Equal(DenyReason.NotWithinIpRange, evaluation.Reason); + } + + [Fact] + public void Evaluate_IpAllowlist_NoEntries_DeniesClosed() + { + var evaluation = _sut.Evaluate(new IpAllowlistCondition(), Signals(IPAddress.Parse("10.1.2.3"))); + + Assert.Equal(AccessEvaluationOutcome.Deny, evaluation.Outcome); + Assert.Equal(DenyReason.NotWithinIpRange, evaluation.Reason); + } + + [Fact] + public void Evaluate_TimeOfDay_WithinWindow_Allows() + { + var rule = new TimeOfDayCondition + { + Tz = "UTC", + Windows = [new TimeWindow { Days = ["thu"], From = "09:00", To = "17:00" }], + }; + + var evaluation = _sut.Evaluate(rule, Signals()); + + Assert.Equal(AccessEvaluationOutcome.Allow, evaluation.Outcome); + } + + [Fact] + public void Evaluate_TimeOfDay_OutsideTimeWindow_Denies() + { + var rule = new TimeOfDayCondition + { + Tz = "UTC", + Windows = [new TimeWindow { Days = ["thu"], From = "00:00", To = "06:00" }], + }; + + var evaluation = _sut.Evaluate(rule, Signals()); + + Assert.Equal(AccessEvaluationOutcome.Deny, evaluation.Outcome); + Assert.Equal(DenyReason.NotWithinTimeWindow, evaluation.Reason); + } + + [Fact] + public void Evaluate_TimeOfDay_DayNotListed_Denies() + { + var rule = new TimeOfDayCondition + { + Tz = "UTC", + Windows = [new TimeWindow { Days = ["fri"], From = "00:00", To = "23:59" }], + }; + + var evaluation = _sut.Evaluate(rule, Signals()); + + Assert.Equal(AccessEvaluationOutcome.Deny, evaluation.Outcome); + Assert.Equal(DenyReason.NotWithinTimeWindow, evaluation.Reason); + } + + [Fact] + public void Evaluate_TimeOfDay_EvaluatesInConfiguredTimezone() + { + // 23:00 UTC is 19:00 (Thursday) in America/New_York during June DST, inside the window. + var rule = new TimeOfDayCondition + { + Tz = "America/New_York", + Windows = [new TimeWindow { Days = ["thu"], From = "18:00", To = "20:00" }], + }; + + var evaluation = _sut.Evaluate(rule, Signals(at: new DateTimeOffset(2026, 6, 4, 23, 0, 0, TimeSpan.Zero))); + + Assert.Equal(AccessEvaluationOutcome.Allow, evaluation.Outcome); + } + + [Fact] + public void Evaluate_TimeOfDay_UnknownTimezone_DeniesClosed() + { + var rule = new TimeOfDayCondition + { + Tz = "Not/AZone", + Windows = [new TimeWindow { Days = ["thu"], From = "00:00", To = "23:59" }], + }; + + var evaluation = _sut.Evaluate(rule, Signals()); + + Assert.Equal(AccessEvaluationOutcome.Deny, evaluation.Outcome); + Assert.Equal(DenyReason.NotWithinTimeWindow, evaluation.Reason); + } + + [Fact] + public void Evaluate_AllOf_AllAllow_Allows() + { + var rule = new AllOfCondition + { + Conditions = + [ + new IpAllowlistCondition { Cidrs = ["10.0.0.0/8"] }, + new TimeOfDayCondition { Tz = "UTC", Windows = [new TimeWindow { Days = ["thu"], From = "09:00", To = "17:00" }] }, + ], + }; + + var evaluation = _sut.Evaluate(rule, Signals(IPAddress.Parse("10.1.2.3"))); + + Assert.Equal(AccessEvaluationOutcome.Allow, evaluation.Outcome); + } + + [Fact] + public void Evaluate_AllOf_OneDenies_DeniesWithThatReason() + { + var rule = new AllOfCondition + { + Conditions = + [ + new IpAllowlistCondition { Cidrs = ["10.0.0.0/8"] }, + new TimeOfDayCondition { Tz = "UTC", Windows = [new TimeWindow { Days = ["thu"], From = "00:00", To = "06:00" }] }, + ], + }; + + var evaluation = _sut.Evaluate(rule, Signals(IPAddress.Parse("10.1.2.3"))); + + Assert.Equal(AccessEvaluationOutcome.Deny, evaluation.Outcome); + Assert.Equal(DenyReason.NotWithinTimeWindow, evaluation.Reason); + } + + [Fact] + public void Evaluate_AllOf_AllowPlusHumanApproval_RequiresApproval() + { + var rule = new AllOfCondition + { + Conditions = + [ + new IpAllowlistCondition { Cidrs = ["10.0.0.0/8"] }, + new HumanApprovalCondition(), + ], + }; + + var evaluation = _sut.Evaluate(rule, Signals(IPAddress.Parse("10.1.2.3"))); + + Assert.Equal(AccessEvaluationOutcome.RequiresApproval, evaluation.Outcome); + } + + [Fact] + public void Evaluate_AllOf_DenyOutranksApproval() + { + // A denying condition beats a pending approval: there is nothing to approve if access is barred outright. + var rule = new AllOfCondition + { + Conditions = + [ + new HumanApprovalCondition(), + new IpAllowlistCondition { Cidrs = ["10.0.0.0/8"] }, + ], + }; + + var evaluation = _sut.Evaluate(rule, Signals(IPAddress.Parse("192.168.1.1"))); + + Assert.Equal(AccessEvaluationOutcome.Deny, evaluation.Outcome); + Assert.Equal(DenyReason.NotWithinIpRange, evaluation.Reason); + } + + [Fact] + public void Evaluate_NestedAllOf_Allows() + { + var rule = new AllOfCondition + { + Conditions = + [ + new AllOfCondition { Conditions = [new IpAllowlistCondition { Cidrs = ["10.0.0.0/8"] }] }, + new TimeOfDayCondition { Tz = "UTC", Windows = [new TimeWindow { Days = ["thu"], From = "09:00", To = "17:00" }] }, + ], + }; + + var evaluation = _sut.Evaluate(rule, Signals(IPAddress.Parse("10.1.2.3"))); + + Assert.Equal(AccessEvaluationOutcome.Allow, evaluation.Outcome); + } + + [Fact] + public void Evaluate_UnsupportedConditionKind_DeniesClosed() + { + var evaluation = _sut.Evaluate(new UnknownCondition(), Signals()); + + Assert.Equal(AccessEvaluationOutcome.Deny, evaluation.Outcome); + Assert.Equal(DenyReason.UnsupportedCondition, evaluation.Reason); + } + + private sealed class UnknownCondition : AccessCondition; +} diff --git a/test/Core.Test/Pam/Models/AccessLeaseStatusNamesTests.cs b/test/Core.Test/Pam/Models/AccessLeaseStatusNamesTests.cs new file mode 100644 index 000000000000..d08b1dcda42e --- /dev/null +++ b/test/Core.Test/Pam/Models/AccessLeaseStatusNamesTests.cs @@ -0,0 +1,17 @@ +using Bit.Core.Pam.Enums; +using Bit.Core.Pam.Models; +using Xunit; + +namespace Bit.Core.Test.Pam.Models; + +public class AccessLeaseStatusNamesTests +{ + [Theory] + [InlineData(AccessLeaseStatus.Active, AccessLeaseStatusNames.Active)] + [InlineData(AccessLeaseStatus.Expired, AccessLeaseStatusNames.Expired)] + [InlineData(AccessLeaseStatus.Revoked, AccessLeaseStatusNames.Revoked)] + public void From_MapsToFrontendVocabulary(AccessLeaseStatus status, string expected) + { + Assert.Equal(expected, AccessLeaseStatusNames.From(status)); + } +} diff --git a/test/Core.Test/Pam/Models/AccessRequestStatusNamesTests.cs b/test/Core.Test/Pam/Models/AccessRequestStatusNamesTests.cs new file mode 100644 index 000000000000..8ef20d29ff0b --- /dev/null +++ b/test/Core.Test/Pam/Models/AccessRequestStatusNamesTests.cs @@ -0,0 +1,20 @@ +using Bit.Core.Pam.Enums; +using Bit.Core.Pam.Models; +using Xunit; + +namespace Bit.Core.Test.Pam.Models; + +public class AccessRequestStatusNamesTests +{ + [Theory] + [InlineData(AccessRequestStatus.Pending, false, AccessRequestStatusNames.Pending)] + [InlineData(AccessRequestStatus.Approved, false, AccessRequestStatusNames.Approved)] + [InlineData(AccessRequestStatus.Approved, true, AccessRequestStatusNames.Activated)] + [InlineData(AccessRequestStatus.Denied, false, AccessRequestStatusNames.Denied)] + [InlineData(AccessRequestStatus.Cancelled, false, AccessRequestStatusNames.Cancelled)] + [InlineData(AccessRequestStatus.ExpiredUnanswered, false, AccessRequestStatusNames.Expired)] + public void From_MapsToFrontendVocabulary(AccessRequestStatus status, bool hasLease, string expected) + { + Assert.Equal(expected, AccessRequestStatusNames.From(status, hasLease)); + } +} diff --git a/test/Core.Test/Pam/Models/InboxRequestStatusTests.cs b/test/Core.Test/Pam/Models/InboxRequestStatusTests.cs deleted file mode 100644 index 75b8cf16ceea..000000000000 --- a/test/Core.Test/Pam/Models/InboxRequestStatusTests.cs +++ /dev/null @@ -1,20 +0,0 @@ -using Bit.Core.Pam.Enums; -using Bit.Core.Pam.Models; -using Xunit; - -namespace Bit.Core.Test.Pam.Models; - -public class InboxRequestStatusTests -{ - [Theory] - [InlineData(LeaseRequestStatus.Pending, false, InboxRequestStatus.Pending)] - [InlineData(LeaseRequestStatus.Approved, false, InboxRequestStatus.Approved)] - [InlineData(LeaseRequestStatus.Approved, true, InboxRequestStatus.Activated)] - [InlineData(LeaseRequestStatus.Denied, false, InboxRequestStatus.Denied)] - [InlineData(LeaseRequestStatus.Cancelled, false, InboxRequestStatus.Cancelled)] - [InlineData(LeaseRequestStatus.ExpiredUnanswered, false, InboxRequestStatus.Expired)] - public void From_MapsToFrontendVocabulary(LeaseRequestStatus status, bool hasLease, string expected) - { - Assert.Equal(expected, InboxRequestStatus.From(status, hasLease)); - } -} diff --git a/test/Core.Test/Pam/Models/LeaseStatusNameTests.cs b/test/Core.Test/Pam/Models/LeaseStatusNameTests.cs deleted file mode 100644 index 93fed456e3ff..000000000000 --- a/test/Core.Test/Pam/Models/LeaseStatusNameTests.cs +++ /dev/null @@ -1,17 +0,0 @@ -using Bit.Core.Pam.Enums; -using Bit.Core.Pam.Models; -using Xunit; - -namespace Bit.Core.Test.Pam.Models; - -public class LeaseStatusNameTests -{ - [Theory] - [InlineData(LeaseStatus.Active, LeaseStatusName.Active)] - [InlineData(LeaseStatus.Expired, LeaseStatusName.Expired)] - [InlineData(LeaseStatus.Revoked, LeaseStatusName.Revoked)] - public void From_MapsToFrontendVocabulary(LeaseStatus status, string expected) - { - Assert.Equal(expected, LeaseStatusName.From(status)); - } -} diff --git a/test/Core.Test/Pam/Queries/AccessPreCheckQueryTests.cs b/test/Core.Test/Pam/Queries/AccessPreCheckQueryTests.cs index 291b78d7e7ce..a206a4e1ff2a 100644 --- a/test/Core.Test/Pam/Queries/AccessPreCheckQueryTests.cs +++ b/test/Core.Test/Pam/Queries/AccessPreCheckQueryTests.cs @@ -2,7 +2,7 @@ using Bit.Core.Pam.Entities; using Bit.Core.Pam.Enums; using Bit.Core.Pam.Models; -using Bit.Core.Pam.Models.Rules; +using Bit.Core.Pam.Models.Conditions; using Bit.Core.Pam.OrganizationFeatures.Queries; using Bit.Core.Pam.Repositories; using Bit.Core.Pam.Services; @@ -30,17 +30,17 @@ public async Task PreCheckAsync_CipherNotAccessible_ThrowsNotFound( } [Theory, BitAutoData] - public async Task PreCheckAsync_HumanApprovalRule_ReturnsHuman( + public async Task PreCheckAsync_HumanApprovalCondition_ReturnsHuman( SutProvider sutProvider, Guid userId, Guid cipherId, Guid orgId, Guid collectionId) { SetupCipher(sutProvider, userId, cipherId); - sutProvider.GetDependency() + sutProvider.GetDependency() .ResolveAsync(userId, cipherId) - .Returns(new AccessApprovalResolution(orgId, collectionId, RequiresHumanApproval: true, new HumanApprovalRule())); + .Returns(new GoverningRule(orgId, collectionId, RequiresHumanApproval: true, new HumanApprovalCondition())); var result = await sutProvider.Sut.PreCheckAsync(userId, cipherId); - Assert.Equal(AccessApprovalOutcome.Human, result.Outcome); + Assert.Equal(AccessApprovalMode.Human, result.ApprovalMode); } [Theory, BitAutoData] @@ -48,21 +48,21 @@ public async Task PreCheckAsync_AutoApproveRule_ReturnsAutomatic( SutProvider sutProvider, Guid userId, Guid cipherId, Guid orgId, Guid collectionId) { SetupCipher(sutProvider, userId, cipherId); - sutProvider.GetDependency() + sutProvider.GetDependency() .ResolveAsync(userId, cipherId) - .Returns(new AccessApprovalResolution(orgId, collectionId, RequiresHumanApproval: false, new IpAllowlistRule { Cidrs = ["10.0.0.0/8"] })); + .Returns(new GoverningRule(orgId, collectionId, RequiresHumanApproval: false, new IpAllowlistCondition { Cidrs = ["10.0.0.0/8"] })); var result = await sutProvider.Sut.PreCheckAsync(userId, cipherId); - Assert.Equal(AccessApprovalOutcome.Automatic, result.Outcome); + Assert.Equal(AccessApprovalMode.Automatic, result.ApprovalMode); } [Theory, BitAutoData] public async Task PreCheckAsync_ExistingActiveLease_ReturnsHasActiveLease( - SutProvider sutProvider, Guid userId, Guid cipherId, Lease activeLease) + SutProvider sutProvider, Guid userId, Guid cipherId, AccessLease activeLease) { SetupCipher(sutProvider, userId, cipherId); - sutProvider.GetDependency() + sutProvider.GetDependency() .GetActiveByRequesterIdCipherIdAsync(userId, cipherId, Arg.Any()) .Returns(activeLease); @@ -70,7 +70,7 @@ public async Task PreCheckAsync_ExistingActiveLease_ReturnsHasActiveLease( Assert.True(result.HasActiveLease); // The approval path is irrelevant once a lease is held, so the rule resolver is never consulted. - await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().ResolveAsync(default, default); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().ResolveAsync(default, default); } [Theory, BitAutoData] @@ -78,13 +78,13 @@ public async Task PreCheckAsync_NotLeasingGated_ReturnsAutomatic( SutProvider sutProvider, Guid userId, Guid cipherId) { SetupCipher(sutProvider, userId, cipherId); - sutProvider.GetDependency() + sutProvider.GetDependency() .ResolveAsync(userId, cipherId) - .Returns((AccessApprovalResolution?)null); + .Returns((GoverningRule?)null); var result = await sutProvider.Sut.PreCheckAsync(userId, cipherId); - Assert.Equal(AccessApprovalOutcome.Automatic, result.Outcome); + Assert.Equal(AccessApprovalMode.Automatic, result.ApprovalMode); } private static void SetupCipher(SutProvider sutProvider, Guid userId, Guid cipherId) diff --git a/test/Core.Test/Pam/Queries/GetCipherLeaseStateQueryTests.cs b/test/Core.Test/Pam/Queries/GetCipherAccessStateQueryTests.cs similarity index 66% rename from test/Core.Test/Pam/Queries/GetCipherLeaseStateQueryTests.cs rename to test/Core.Test/Pam/Queries/GetCipherAccessStateQueryTests.cs index b63291a7cded..a953bdf6163b 100644 --- a/test/Core.Test/Pam/Queries/GetCipherLeaseStateQueryTests.cs +++ b/test/Core.Test/Pam/Queries/GetCipherAccessStateQueryTests.cs @@ -2,7 +2,7 @@ using Bit.Core.Pam.Entities; using Bit.Core.Pam.Enums; using Bit.Core.Pam.Models; -using Bit.Core.Pam.Models.Rules; +using Bit.Core.Pam.Models.Conditions; using Bit.Core.Pam.OrganizationFeatures.Queries; using Bit.Core.Pam.Repositories; using Bit.Core.Pam.Services; @@ -16,11 +16,11 @@ namespace Bit.Core.Test.Pam.Queries; [SutProviderCustomize] -public class GetCipherLeaseStateQueryTests +public class GetCipherAccessStateQueryTests { [Theory, BitAutoData] public async Task GetStateAsync_CipherNotAccessible_ThrowsNotFound( - SutProvider sutProvider, Guid userId, Guid cipherId) + SutProvider sutProvider, Guid userId, Guid cipherId) { sutProvider.GetDependency() .GetByIdAsync(cipherId, userId) @@ -31,23 +31,23 @@ public async Task GetStateAsync_CipherNotAccessible_ThrowsNotFound( [Theory, BitAutoData] public async Task GetStateAsync_NotGatedAndNothingHeld_ThrowsNotFound( - SutProvider sutProvider, Guid userId, Guid cipherId) + SutProvider sutProvider, Guid userId, Guid cipherId) { SetupCipher(sutProvider, userId, cipherId); // No active lease, no pending request, and the resolver finds no governing rule. - sutProvider.GetDependency() + sutProvider.GetDependency() .ResolveAsync(userId, cipherId) - .Returns((AccessApprovalResolution?)null); + .Returns((GoverningRule?)null); await Assert.ThrowsAsync(() => sutProvider.Sut.GetStateAsync(userId, cipherId)); } [Theory, BitAutoData] public async Task GetStateAsync_ActiveLease_ReturnsSnapshotWithLease( - SutProvider sutProvider, Guid userId, Guid cipherId, Lease activeLease) + SutProvider sutProvider, Guid userId, Guid cipherId, AccessLease activeLease) { SetupCipher(sutProvider, userId, cipherId); - sutProvider.GetDependency() + sutProvider.GetDependency() .GetActiveByRequesterIdCipherIdAsync(userId, cipherId, Arg.Any()) .Returns(activeLease); @@ -60,16 +60,16 @@ public async Task GetStateAsync_ActiveLease_ReturnsSnapshotWithLease( [Theory, BitAutoData] public async Task GetStateAsync_LeaseHeldButRuleRemoved_StillReturnsSnapshot( - SutProvider sutProvider, Guid userId, Guid cipherId, Lease activeLease) + SutProvider sutProvider, Guid userId, Guid cipherId, AccessLease activeLease) { SetupCipher(sutProvider, userId, cipherId); - sutProvider.GetDependency() + sutProvider.GetDependency() .GetActiveByRequesterIdCipherIdAsync(userId, cipherId, Arg.Any()) .Returns(activeLease); - // Rule since removed: resolver returns null, but the held lease must not be hidden. - sutProvider.GetDependency() + // Access rule since removed: resolver returns null, but the held lease must not be hidden. + sutProvider.GetDependency() .ResolveAsync(userId, cipherId) - .Returns((AccessApprovalResolution?)null); + .Returns((GoverningRule?)null); var result = await sutProvider.Sut.GetStateAsync(userId, cipherId); @@ -78,13 +78,13 @@ public async Task GetStateAsync_LeaseHeldButRuleRemoved_StillReturnsSnapshot( [Theory, BitAutoData] public async Task GetStateAsync_PendingRequest_MapsToDetails( - SutProvider sutProvider, Guid userId, Guid cipherId, LeaseRequest pending) + SutProvider sutProvider, Guid userId, Guid cipherId, AccessRequest pending) { SetupCipher(sutProvider, userId, cipherId); pending.CipherId = cipherId; pending.RequesterId = userId; - pending.Status = LeaseRequestStatus.Pending; - sutProvider.GetDependency() + pending.Status = AccessRequestStatus.Pending; + sutProvider.GetDependency() .GetActivePendingByRequesterIdCipherIdAsync(userId, cipherId) .Returns(pending); @@ -93,22 +93,22 @@ public async Task GetStateAsync_PendingRequest_MapsToDetails( Assert.Null(result.ActiveLease); Assert.NotNull(result.PendingRequest); Assert.Equal(pending.Id, result.PendingRequest!.Id); - Assert.Equal(pending.LeaseId, result.PendingRequest.ExtensionOfLeaseId); - Assert.Equal(LeaseRequestStatus.Pending, result.PendingRequest.Status); + Assert.Equal(pending.ExtensionOfLeaseId, result.PendingRequest.ExtensionOfLeaseId); + Assert.Equal(AccessRequestStatus.Pending, result.PendingRequest.Status); // Pending has produced no lease and has no resolver yet; display-name fields are not populated. Assert.Null(result.PendingRequest.ProducedLeaseId); - Assert.Null(result.PendingRequest.ResolverId); + Assert.Null(result.PendingRequest.ApproverId); Assert.Null(result.PendingRequest.CipherName); } [Theory, BitAutoData] public async Task GetStateAsync_GatedButEmpty_ReturnsEmptySnapshot( - SutProvider sutProvider, Guid userId, Guid cipherId, Guid orgId, Guid collectionId) + SutProvider sutProvider, Guid userId, Guid cipherId, Guid orgId, Guid collectionId) { SetupCipher(sutProvider, userId, cipherId); - sutProvider.GetDependency() + sutProvider.GetDependency() .ResolveAsync(userId, cipherId) - .Returns(new AccessApprovalResolution(orgId, collectionId, RequiresHumanApproval: true, new HumanApprovalRule())); + .Returns(new GoverningRule(orgId, collectionId, RequiresHumanApproval: true, new HumanApprovalCondition())); var result = await sutProvider.Sut.GetStateAsync(userId, cipherId); @@ -117,7 +117,7 @@ public async Task GetStateAsync_GatedButEmpty_ReturnsEmptySnapshot( Assert.Null(result.PendingRequest); } - private static void SetupCipher(SutProvider sutProvider, Guid userId, Guid cipherId) + private static void SetupCipher(SutProvider sutProvider, Guid userId, Guid cipherId) { sutProvider.GetDependency() .GetByIdAsync(cipherId, userId) diff --git a/test/Core.Test/Pam/Queries/GetLeasedCipherQueryTests.cs b/test/Core.Test/Pam/Queries/GetLeasedCipherQueryTests.cs index 9aef1cee2b28..d11e10e31f49 100644 --- a/test/Core.Test/Pam/Queries/GetLeasedCipherQueryTests.cs +++ b/test/Core.Test/Pam/Queries/GetLeasedCipherQueryTests.cs @@ -1,7 +1,7 @@ using Bit.Core.Pam.Engine; using Bit.Core.Pam.Entities; using Bit.Core.Pam.Models; -using Bit.Core.Pam.Models.Rules; +using Bit.Core.Pam.Models.Conditions; using Bit.Core.Pam.OrganizationFeatures.Queries; using Bit.Core.Pam.Repositories; using Bit.Core.Pam.Services; @@ -24,9 +24,9 @@ public class GetLeasedCipherQueryTests public async Task GetLeasedCipherAsync_NoActiveLease_ReturnsNull(Guid userId, Guid cipherId) { var sutProvider = Setup(); - sutProvider.GetDependency() + sutProvider.GetDependency() .GetActiveByRequesterIdCipherIdAsync(userId, cipherId, _now) - .Returns((Lease?)null); + .Returns((AccessLease?)null); var result = await sutProvider.Sut.GetLeasedCipherAsync(userId, cipherId); @@ -39,10 +39,10 @@ await sutProvider.GetDependency() [Theory, BitAutoData] public async Task GetLeasedCipherAsync_ActiveLeaseButCipherNotAccessible_ReturnsNull( - Guid userId, Guid cipherId, Lease lease) + Guid userId, Guid cipherId, AccessLease lease) { var sutProvider = Setup(); - sutProvider.GetDependency() + sutProvider.GetDependency() .GetActiveByRequesterIdCipherIdAsync(userId, cipherId, _now) .Returns(lease); sutProvider.GetDependency() @@ -56,11 +56,11 @@ public async Task GetLeasedCipherAsync_ActiveLeaseButCipherNotAccessible_Returns [Theory, BitAutoData] public async Task GetLeasedCipherAsync_ActiveLeaseAndAccessible_ReturnsCipher( - Guid userId, Guid cipherId, Lease lease) + Guid userId, Guid cipherId, AccessLease lease) { var sutProvider = Setup(); var cipher = new CipherDetails { Id = cipherId, Data = "2.iv|ct|mac" }; - sutProvider.GetDependency() + sutProvider.GetDependency() .GetActiveByRequesterIdCipherIdAsync(userId, cipherId, _now) .Returns(lease); sutProvider.GetDependency() @@ -73,42 +73,42 @@ public async Task GetLeasedCipherAsync_ActiveLeaseAndAccessible_ReturnsCipher( Assert.Equal(cipherId, result!.Id); Assert.Equal("2.iv|ct|mac", result.Data); // The active-lease lookup uses the TimeProvider's now. - await sutProvider.GetDependency().Received(1) + await sutProvider.GetDependency().Received(1) .GetActiveByRequesterIdCipherIdAsync(userId, cipherId, _now); } [Theory, BitAutoData] - public async Task GetLeasedCipherAsync_PolicyDenied_WithholdsDataAndReturnsNull( - Guid userId, Guid cipherId, Lease lease, Guid orgId, Guid collectionId) + public async Task GetLeasedCipherAsync_ConditionsDeny_WithholdsDataAndReturnsNull( + Guid userId, Guid cipherId, AccessLease lease, Guid orgId, Guid collectionId) { var sutProvider = Setup(); - sutProvider.GetDependency() + sutProvider.GetDependency() .GetActiveByRequesterIdCipherIdAsync(userId, cipherId, _now) .Returns(lease); SetupResolution(sutProvider, userId, cipherId, orgId, collectionId); - SetupPolicyDecision(sutProvider, AccessDecision.Deny(DenyReason.NotWithinIpRange)); + SetupEvaluation(sutProvider, AccessEvaluation.Deny(DenyReason.NotWithinIpRange)); var result = await sutProvider.Sut.GetLeasedCipherAsync(userId, cipherId); Assert.Null(result); - // A denied policy must withhold the data: the cipher is never read. + // A denied evaluation must withhold the data: the cipher is never read. await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() .GetByIdAsync(default, default); } [Theory, BitAutoData] - public async Task GetLeasedCipherAsync_PolicyAllowed_ReturnsCipher( - Guid userId, Guid cipherId, Lease lease, Guid orgId, Guid collectionId) + public async Task GetLeasedCipherAsync_ConditionsAllow_ReturnsCipher( + Guid userId, Guid cipherId, AccessLease lease, Guid orgId, Guid collectionId) { var sutProvider = Setup(); var cipher = new CipherDetails { Id = cipherId }; - sutProvider.GetDependency() + sutProvider.GetDependency() .GetActiveByRequesterIdCipherIdAsync(userId, cipherId, _now) .Returns(lease); sutProvider.GetDependency().GetByIdAsync(cipherId, userId).Returns(cipher); SetupResolution(sutProvider, userId, cipherId, orgId, collectionId); - SetupPolicyDecision(sutProvider, AccessDecision.Allow); + SetupEvaluation(sutProvider, AccessEvaluation.Allow); var result = await sutProvider.Sut.GetLeasedCipherAsync(userId, cipherId); @@ -116,18 +116,18 @@ public async Task GetLeasedCipherAsync_PolicyAllowed_ReturnsCipher( } [Theory, BitAutoData] - public async Task GetLeasedCipherAsync_PolicyRequiresApproval_StillReturnsCipher( - Guid userId, Guid cipherId, Lease lease, Guid orgId, Guid collectionId) + public async Task GetLeasedCipherAsync_ConditionsRequireApproval_StillReturnsCipher( + Guid userId, Guid cipherId, AccessLease lease, Guid orgId, Guid collectionId) { var sutProvider = Setup(); var cipher = new CipherDetails { Id = cipherId }; - sutProvider.GetDependency() + sutProvider.GetDependency() .GetActiveByRequesterIdCipherIdAsync(userId, cipherId, _now) .Returns(lease); sutProvider.GetDependency().GetByIdAsync(cipherId, userId).Returns(cipher); SetupResolution(sutProvider, userId, cipherId, orgId, collectionId); // Holding the lease is proof approval was already granted, so a deferred-approval outcome must not re-gate. - SetupPolicyDecision(sutProvider, AccessDecision.RequiresApproval); + SetupEvaluation(sutProvider, AccessEvaluation.RequiresApproval); var result = await sutProvider.Sut.GetLeasedCipherAsync(userId, cipherId); @@ -144,15 +144,15 @@ private static SutProvider Setup() private static void SetupResolution(SutProvider sutProvider, Guid userId, Guid cipherId, Guid orgId, Guid collectionId) { - sutProvider.GetDependency() + sutProvider.GetDependency() .ResolveAsync(userId, cipherId) - .Returns(new AccessApprovalResolution(orgId, collectionId, false, new IpAllowlistRule { Cidrs = ["10.0.0.0/8"] })); + .Returns(new GoverningRule(orgId, collectionId, false, new IpAllowlistCondition { Cidrs = ["10.0.0.0/8"] })); } - private static void SetupPolicyDecision(SutProvider sutProvider, AccessDecision decision) + private static void SetupEvaluation(SutProvider sutProvider, AccessEvaluation decision) { - sutProvider.GetDependency() - .Evaluate(Arg.Any(), Arg.Any()) + sutProvider.GetDependency() + .Evaluate(Arg.Any(), Arg.Any()) .Returns(decision); } } diff --git a/test/Core.Test/Pam/Queries/GetInboxHistoryQueryTests.cs b/test/Core.Test/Pam/Queries/ListInboxHistoryQueryTests.cs similarity index 74% rename from test/Core.Test/Pam/Queries/GetInboxHistoryQueryTests.cs rename to test/Core.Test/Pam/Queries/ListInboxHistoryQueryTests.cs index 12cd592b7aa7..d01cd86bf2c1 100644 --- a/test/Core.Test/Pam/Queries/GetInboxHistoryQueryTests.cs +++ b/test/Core.Test/Pam/Queries/ListInboxHistoryQueryTests.cs @@ -11,7 +11,7 @@ namespace Bit.Core.Test.Pam.Queries; [SutProviderCustomize] -public class GetInboxHistoryQueryTests +public class ListInboxHistoryQueryTests { private static readonly DateTime _now = new(2026, 6, 5, 12, 0, 0, DateTimeKind.Utc); @@ -25,31 +25,31 @@ public async Task GetHistoryAsync_NoManageableCollections_ReturnsEmptyWithoutQue var result = await sutProvider.Sut.GetHistoryAsync(userId); Assert.Empty(result); - await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() .GetManyInboxHistoryByCollectionIdsAsync(default!, default); } [Theory, BitAutoData] - public async Task GetHistoryAsync_QueriesWithRetentionWindow(Guid userId, Guid collectionId, InboxLeaseRequestDetails row) + public async Task GetHistoryAsync_QueriesWithRetentionWindow(Guid userId, Guid collectionId, AccessRequestDetails row) { var sutProvider = Setup(); var manageable = new HashSet { collectionId }; sutProvider.GetDependency() .GetManageableCollectionIdsAsync(userId).Returns(manageable); - var expectedSince = _now.AddDays(-GetInboxHistoryQuery.HistoryRetentionDays); - sutProvider.GetDependency() + var expectedSince = _now.AddDays(-ListInboxHistoryQuery.HistoryRetentionDays); + sutProvider.GetDependency() .GetManyInboxHistoryByCollectionIdsAsync(manageable, expectedSince).Returns([row]); var result = await sutProvider.Sut.GetHistoryAsync(userId); Assert.Single(result); - await sutProvider.GetDependency().Received(1) + await sutProvider.GetDependency().Received(1) .GetManyInboxHistoryByCollectionIdsAsync(manageable, expectedSince); } - private static SutProvider Setup() + private static SutProvider Setup() { - var sutProvider = new SutProvider().WithFakeTimeProvider().Create(); + var sutProvider = new SutProvider().WithFakeTimeProvider().Create(); sutProvider.GetDependency().SetUtcNow(_now); return sutProvider; } diff --git a/test/Core.Test/Pam/Queries/GetInboxRequestsQueryTests.cs b/test/Core.Test/Pam/Queries/ListInboxRequestsQueryTests.cs similarity index 73% rename from test/Core.Test/Pam/Queries/GetInboxRequestsQueryTests.cs rename to test/Core.Test/Pam/Queries/ListInboxRequestsQueryTests.cs index c6b4e046ea75..e883177d027b 100644 --- a/test/Core.Test/Pam/Queries/GetInboxRequestsQueryTests.cs +++ b/test/Core.Test/Pam/Queries/ListInboxRequestsQueryTests.cs @@ -10,11 +10,11 @@ namespace Bit.Core.Test.Pam.Queries; [SutProviderCustomize] -public class GetInboxRequestsQueryTests +public class ListInboxRequestsQueryTests { [Theory, BitAutoData] public async Task GetPendingAsync_NoManageableCollections_ReturnsEmptyWithoutQuerying( - SutProvider sutProvider, Guid userId) + SutProvider sutProvider, Guid userId) { sutProvider.GetDependency() .GetManageableCollectionIdsAsync(userId).Returns([]); @@ -22,24 +22,24 @@ public async Task GetPendingAsync_NoManageableCollections_ReturnsEmptyWithoutQue var result = await sutProvider.Sut.GetPendingAsync(userId); Assert.Empty(result); - await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() .GetManyInboxPendingByCollectionIdsAsync(default!); } [Theory, BitAutoData] public async Task GetPendingAsync_ManageableCollections_FiltersByThatSet( - SutProvider sutProvider, Guid userId, Guid collectionId, InboxLeaseRequestDetails row) + SutProvider sutProvider, Guid userId, Guid collectionId, AccessRequestDetails row) { var manageable = new HashSet { collectionId }; sutProvider.GetDependency() .GetManageableCollectionIdsAsync(userId).Returns(manageable); - sutProvider.GetDependency() + sutProvider.GetDependency() .GetManyInboxPendingByCollectionIdsAsync(manageable).Returns([row]); var result = await sutProvider.Sut.GetPendingAsync(userId); Assert.Single(result); - await sutProvider.GetDependency().Received(1) + await sutProvider.GetDependency().Received(1) .GetManyInboxPendingByCollectionIdsAsync(manageable); } } diff --git a/test/Core.Test/Pam/Queries/ListMyAccessRequestsQueryTests.cs b/test/Core.Test/Pam/Queries/ListMyAccessRequestsQueryTests.cs index 5dfefdb02ca2..e676be8e47e3 100644 --- a/test/Core.Test/Pam/Queries/ListMyAccessRequestsQueryTests.cs +++ b/test/Core.Test/Pam/Queries/ListMyAccessRequestsQueryTests.cs @@ -13,9 +13,9 @@ public class ListMyAccessRequestsQueryTests { [Theory, BitAutoData] public async Task GetMineAsync_ReturnsRequesterRows( - SutProvider sutProvider, Guid userId, InboxLeaseRequestDetails row) + SutProvider sutProvider, Guid userId, AccessRequestDetails row) { - sutProvider.GetDependency() + sutProvider.GetDependency() .GetManyByRequesterIdAsync(userId) .Returns([row]); @@ -29,7 +29,7 @@ public async Task GetMineAsync_ReturnsRequesterRows( public async Task GetMineAsync_NoRequests_ReturnsEmpty( SutProvider sutProvider, Guid userId) { - sutProvider.GetDependency() + sutProvider.GetDependency() .GetManyByRequesterIdAsync(userId) .Returns([]); diff --git a/test/Core.Test/Pam/Queries/ListMyActiveLeasesQueryTests.cs b/test/Core.Test/Pam/Queries/ListMyActiveAccessLeasesQueryTests.cs similarity index 73% rename from test/Core.Test/Pam/Queries/ListMyActiveLeasesQueryTests.cs rename to test/Core.Test/Pam/Queries/ListMyActiveAccessLeasesQueryTests.cs index 6f0ff91b017a..ab162ef2843b 100644 --- a/test/Core.Test/Pam/Queries/ListMyActiveLeasesQueryTests.cs +++ b/test/Core.Test/Pam/Queries/ListMyActiveAccessLeasesQueryTests.cs @@ -9,13 +9,13 @@ namespace Bit.Core.Test.Pam.Queries; [SutProviderCustomize] -public class ListMyActiveLeasesQueryTests +public class ListMyActiveAccessLeasesQueryTests { [Theory, BitAutoData] public async Task GetMineActiveAsync_ReturnsActiveLeases( - SutProvider sutProvider, Guid userId, Lease lease) + SutProvider sutProvider, Guid userId, AccessLease lease) { - sutProvider.GetDependency() + sutProvider.GetDependency() .GetManyActiveByRequesterIdAsync(userId, Arg.Any()) .Returns([lease]); @@ -27,9 +27,9 @@ public async Task GetMineActiveAsync_ReturnsActiveLeases( [Theory, BitAutoData] public async Task GetMineActiveAsync_NoLeases_ReturnsEmpty( - SutProvider sutProvider, Guid userId) + SutProvider sutProvider, Guid userId) { - sutProvider.GetDependency() + sutProvider.GetDependency() .GetManyActiveByRequesterIdAsync(userId, Arg.Any()) .Returns([]); diff --git a/test/Core.Test/Pam/Services/AccessRuleValidatorTests.cs b/test/Core.Test/Pam/Services/AccessRuleValidatorTests.cs index 14cec8cdb33a..e93f54e2c56d 100644 --- a/test/Core.Test/Pam/Services/AccessRuleValidatorTests.cs +++ b/test/Core.Test/Pam/Services/AccessRuleValidatorTests.cs @@ -8,7 +8,7 @@ public class AccessRuleValidatorTests private readonly AccessRuleValidator _sut = new(); [Fact] - public void Validate_NullRule_IsValid() + public void Validate_NullConditions_IsValid() { var result = _sut.Validate(null); @@ -18,9 +18,9 @@ public void Validate_NullRule_IsValid() [Theory] [InlineData("")] [InlineData(" ")] - public void Validate_EmptyOrWhitespaceRule_IsInvalid(string ruleJson) + public void Validate_EmptyOrWhitespaceConditions_IsInvalid(string conditionsJson) { - var result = _sut.Validate(ruleJson); + var result = _sut.Validate(conditionsJson); Assert.False(result.IsValid); } @@ -53,9 +53,9 @@ public void Validate_HumanApproval_IsValid() [Theory] [InlineData("""{"kind":"ip_allowlist","cidrs":["10.0.0.0/8"]}""")] [InlineData("""{"kind":"ip_allowlist","cidrs":["10.0.0.0/8","192.168.0.0/16","2001:db8::/32"]}""")] - public void Validate_IpAllowlist_ValidCidrs_IsValid(string ruleJson) + public void Validate_IpAllowlist_ValidCidrs_IsValid(string conditionsJson) { - var result = _sut.Validate(ruleJson); + var result = _sut.Validate(conditionsJson); Assert.True(result.IsValid); } @@ -64,9 +64,9 @@ public void Validate_IpAllowlist_ValidCidrs_IsValid(string ruleJson) [InlineData("""{"kind":"ip_allowlist","cidrs":[]}""", "at least one CIDR")] [InlineData("""{"kind":"ip_allowlist","cidrs":["not-a-cidr"]}""", "Invalid CIDR")] [InlineData("""{"kind":"ip_allowlist","cidrs":["10.0.0.0/99"]}""", "Invalid CIDR")] - public void Validate_IpAllowlist_InvalidCidrs_IsInvalid(string ruleJson, string expectedMessageFragment) + public void Validate_IpAllowlist_InvalidCidrs_IsInvalid(string conditionsJson, string expectedMessageFragment) { - var result = _sut.Validate(ruleJson); + var result = _sut.Validate(conditionsJson); Assert.False(result.IsValid); Assert.Contains(expectedMessageFragment, result.Error); @@ -95,9 +95,9 @@ public void Validate_TimeOfDay_Valid_IsValid() [InlineData("""{"kind":"time_of_day","tz":"UTC","windows":[{"days":["funday"],"from":"09:00","to":"17:00"}]}""", "day")] [InlineData("""{"kind":"time_of_day","tz":"UTC","windows":[{"days":["mon"],"from":"9am","to":"5pm"}]}""", "Expected HH:mm")] [InlineData("""{"kind":"time_of_day","tz":"UTC","windows":[{"days":["mon"],"from":"25:00","to":"26:00"}]}""", "Expected HH:mm")] - public void Validate_TimeOfDay_Invalid_IsInvalid(string ruleJson, string expectedMessageFragment) + public void Validate_TimeOfDay_Invalid_IsInvalid(string conditionsJson, string expectedMessageFragment) { - var result = _sut.Validate(ruleJson); + var result = _sut.Validate(conditionsJson); Assert.False(result.IsValid); Assert.Contains(expectedMessageFragment, result.Error); @@ -109,7 +109,7 @@ public void Validate_AllOf_NestedHumanAndIpAllowlist_IsValid() var result = _sut.Validate(""" { "kind": "all_of", - "rules": [ + "conditions": [ { "kind": "human_approval" }, { "kind": "ip_allowlist", "cidrs": ["10.0.0.0/8"] } ] @@ -122,7 +122,7 @@ public void Validate_AllOf_NestedHumanAndIpAllowlist_IsValid() [Fact] public void Validate_AllOf_EmptyChildren_IsInvalid() { - var result = _sut.Validate("""{"kind":"all_of","rules":[]}"""); + var result = _sut.Validate("""{"kind":"all_of","conditions":[]}"""); Assert.False(result.IsValid); Assert.Contains("at least one child", result.Error); @@ -135,13 +135,13 @@ public void Validate_AllOf_ExceedsMaxNestingDepth_IsInvalid() var result = _sut.Validate(""" { "kind": "all_of", - "rules": [{ + "conditions": [{ "kind": "all_of", - "rules": [{ + "conditions": [{ "kind": "all_of", - "rules": [{ + "conditions": [{ "kind": "all_of", - "rules": [{ "kind": "human_approval" }] + "conditions": [{ "kind": "human_approval" }] }] }] }] @@ -155,8 +155,8 @@ public void Validate_AllOf_ExceedsMaxNestingDepth_IsInvalid() [Fact] public void Validate_AllOf_ExceedsMaxChildren_IsInvalid() { - var rules = string.Join(",", Enumerable.Repeat("""{"kind":"human_approval"}""", 11)); - var result = _sut.Validate($$"""{"kind":"all_of","rules":[{{rules}}]}"""); + var conditions = string.Join(",", Enumerable.Repeat("""{"kind":"human_approval"}""", 11)); + var result = _sut.Validate($$"""{"kind":"all_of","conditions":[{{conditions}}]}"""); Assert.False(result.IsValid); Assert.Contains("more than", result.Error); @@ -168,7 +168,7 @@ public void Validate_AllOf_InvalidChild_IsInvalid() var result = _sut.Validate(""" { "kind": "all_of", - "rules": [ + "conditions": [ { "kind": "human_approval" }, { "kind": "ip_allowlist", "cidrs": ["bogus"] } ] diff --git a/test/Core.Test/Pam/Services/AccessApprovalResolverTests.cs b/test/Core.Test/Pam/Services/GoverningRuleResolverTests.cs similarity index 65% rename from test/Core.Test/Pam/Services/AccessApprovalResolverTests.cs rename to test/Core.Test/Pam/Services/GoverningRuleResolverTests.cs index 82d72316a7ce..c2dd2d304354 100644 --- a/test/Core.Test/Pam/Services/AccessApprovalResolverTests.cs +++ b/test/Core.Test/Pam/Services/GoverningRuleResolverTests.cs @@ -1,6 +1,6 @@ using Bit.Core.Entities; using Bit.Core.Pam.Entities; -using Bit.Core.Pam.Models.Rules; +using Bit.Core.Pam.Models.Conditions; using Bit.Core.Pam.Repositories; using Bit.Core.Pam.Services; using Bit.Core.Repositories; @@ -12,11 +12,11 @@ namespace Bit.Core.Test.Pam.Services; [SutProviderCustomize] -public class AccessApprovalResolverTests +public class GoverningRuleResolverTests { [Theory, BitAutoData] public async Task ResolveAsync_NoReachableCollections_ReturnsNull( - SutProvider sutProvider, Guid userId, Guid cipherId) + SutProvider sutProvider, Guid userId, Guid cipherId) { sutProvider.GetDependency() .GetManyByUserIdCipherIdAsync(userId, cipherId) @@ -27,7 +27,7 @@ public async Task ResolveAsync_NoReachableCollections_ReturnsNull( [Theory, BitAutoData] public async Task ResolveAsync_CollectionWithoutAccessRule_ReturnsNull( - SutProvider sutProvider, Guid userId, Guid cipherId, Collection collection) + SutProvider sutProvider, Guid userId, Guid cipherId, Collection collection) { collection.AccessRuleId = null; SetupReachableCollections(sutProvider, userId, cipherId, collection); @@ -36,10 +36,10 @@ public async Task ResolveAsync_CollectionWithoutAccessRule_ReturnsNull( } [Theory, BitAutoData] - public async Task ResolveAsync_HumanApprovalRule_RequiresHumanApproval( - SutProvider sutProvider, Guid userId, Guid cipherId, Collection collection, AccessRule rule) + public async Task ResolveAsync_HumanApprovalCondition_RequiresHumanApproval( + SutProvider sutProvider, Guid userId, Guid cipherId, Collection collection, AccessRule rule) { - rule.Rule = """{"kind":"human_approval"}"""; + rule.Conditions = """{"kind":"human_approval"}"""; SetupGovernedCollection(sutProvider, userId, cipherId, collection, rule); var result = await sutProvider.Sut.ResolveAsync(userId, cipherId); @@ -48,43 +48,43 @@ public async Task ResolveAsync_HumanApprovalRule_RequiresHumanApproval( Assert.True(result!.RequiresHumanApproval); Assert.Equal(collection.Id, result.CollectionId); Assert.Equal(collection.OrganizationId, result.OrganizationId); - Assert.IsType(result.Rule); + Assert.IsType(result.Condition); } [Theory, BitAutoData] - public async Task ResolveAsync_IpAllowlistRule_DoesNotRequireHumanApproval( - SutProvider sutProvider, Guid userId, Guid cipherId, Collection collection, AccessRule rule) + public async Task ResolveAsync_IpAllowlistCondition_DoesNotRequireHumanApproval( + SutProvider sutProvider, Guid userId, Guid cipherId, Collection collection, AccessRule rule) { - rule.Rule = """{"kind":"ip_allowlist","cidrs":["10.0.0.0/8"]}"""; + rule.Conditions = """{"kind":"ip_allowlist","cidrs":["10.0.0.0/8"]}"""; SetupGovernedCollection(sutProvider, userId, cipherId, collection, rule); var result = await sutProvider.Sut.ResolveAsync(userId, cipherId); Assert.NotNull(result); Assert.False(result!.RequiresHumanApproval); - var ip = Assert.IsType(result.Rule); + var ip = Assert.IsType(result.Condition); Assert.Equal("10.0.0.0/8", Assert.Single(ip.Cidrs)); } [Theory, BitAutoData] public async Task ResolveAsync_AllOfContainingHumanApproval_RequiresHumanApproval( - SutProvider sutProvider, Guid userId, Guid cipherId, Collection collection, AccessRule rule) + SutProvider sutProvider, Guid userId, Guid cipherId, Collection collection, AccessRule rule) { - rule.Rule = """{"kind":"all_of","rules":[{"kind":"ip_allowlist","cidrs":["10.0.0.0/8"]},{"kind":"human_approval"}]}"""; + rule.Conditions = """{"kind":"all_of","conditions":[{"kind":"ip_allowlist","cidrs":["10.0.0.0/8"]},{"kind":"human_approval"}]}"""; SetupGovernedCollection(sutProvider, userId, cipherId, collection, rule); var result = await sutProvider.Sut.ResolveAsync(userId, cipherId); Assert.NotNull(result); Assert.True(result!.RequiresHumanApproval); - Assert.IsType(result.Rule); + Assert.IsType(result.Condition); } [Theory, BitAutoData] public async Task ResolveAsync_MalformedRule_FailsSafeToHumanApproval( - SutProvider sutProvider, Guid userId, Guid cipherId, Collection collection, AccessRule rule) + SutProvider sutProvider, Guid userId, Guid cipherId, Collection collection, AccessRule rule) { - rule.Rule = "not json"; + rule.Conditions = "not json"; SetupGovernedCollection(sutProvider, userId, cipherId, collection, rule); var result = await sutProvider.Sut.ResolveAsync(userId, cipherId); @@ -92,11 +92,11 @@ public async Task ResolveAsync_MalformedRule_FailsSafeToHumanApproval( Assert.NotNull(result); Assert.True(result!.RequiresHumanApproval); // An unparseable rule fails safe to human approval rather than surfacing a rule the engine cannot evaluate. - Assert.IsType(result.Rule); + Assert.IsType(result.Condition); } private static void SetupReachableCollections( - SutProvider sutProvider, Guid userId, Guid cipherId, params Collection[] collections) + SutProvider sutProvider, Guid userId, Guid cipherId, params Collection[] collections) { sutProvider.GetDependency() .GetManyByUserIdCipherIdAsync(userId, cipherId) @@ -107,7 +107,7 @@ private static void SetupReachableCollections( } private static void SetupGovernedCollection( - SutProvider sutProvider, Guid userId, Guid cipherId, Collection collection, AccessRule rule) + SutProvider sutProvider, Guid userId, Guid cipherId, Collection collection, AccessRule rule) { collection.AccessRuleId = rule.Id; SetupReachableCollections(sutProvider, userId, cipherId, collection); diff --git a/test/Infrastructure.IntegrationTest/Pam/Repositories/LeaseRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Pam/Repositories/AccessLeaseRepositoryTests.cs similarity index 66% rename from test/Infrastructure.IntegrationTest/Pam/Repositories/LeaseRepositoryTests.cs rename to test/Infrastructure.IntegrationTest/Pam/Repositories/AccessLeaseRepositoryTests.cs index 3af85123b04f..a3698e41c327 100644 --- a/test/Infrastructure.IntegrationTest/Pam/Repositories/LeaseRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/Pam/Repositories/AccessLeaseRepositoryTests.cs @@ -13,8 +13,8 @@ public class LeaseRepositoryTests [DatabaseTheory, DatabaseData] public async Task CreateAutoApprovedAsync_PersistsApprovedRequestDecisionAndActiveLease( IOrganizationRepository organizationRepository, - ILeaseRepository leaseRepository, - ILeaseRequestRepository leaseRequestRepository) + IAccessLeaseRepository accessLeaseRepository, + IAccessRequestRepository accessRequestRepository) { var organization = await organizationRepository.CreateTestOrganizationAsync(); var now = DateTime.UtcNow; @@ -23,23 +23,23 @@ public async Task CreateAutoApprovedAsync_PersistsApprovedRequestDecisionAndActi var (request, decision, lease) = BuildAutoApproved(organization.Id, cipherId, requesterId, now, now.AddHours(1)); - await leaseRepository.CreateAutoApprovedAsync(request, decision, lease, now); + await accessLeaseRepository.CreateAutoApprovedAsync(request, decision, lease, now); - var persistedRequest = await leaseRequestRepository.GetByIdAsync(request.Id); + var persistedRequest = await accessRequestRepository.GetByIdAsync(request.Id); Assert.NotNull(persistedRequest); - Assert.Equal(LeaseRequestStatus.Approved, persistedRequest!.Status); + Assert.Equal(AccessRequestStatus.Approved, persistedRequest!.Status); Assert.NotNull(persistedRequest.ResolvedDate); - var persistedLease = await leaseRepository.GetByIdAsync(lease.Id); + var persistedLease = await accessLeaseRepository.GetByIdAsync(lease.Id); Assert.NotNull(persistedLease); - Assert.Equal(LeaseStatus.Active, persistedLease!.Status); - Assert.Equal(request.Id, persistedLease.LeaseRequestId); + Assert.Equal(AccessLeaseStatus.Active, persistedLease!.Status); + Assert.Equal(request.Id, persistedLease.AccessRequestId); } [DatabaseTheory, DatabaseData] public async Task GetActiveByRequesterIdCipherIdAsync_WithinWindow_ReturnsLease( IOrganizationRepository organizationRepository, - ILeaseRepository leaseRepository) + IAccessLeaseRepository accessLeaseRepository) { var organization = await organizationRepository.CreateTestOrganizationAsync(); var now = DateTime.UtcNow; @@ -48,9 +48,9 @@ public async Task GetActiveByRequesterIdCipherIdAsync_WithinWindow_ReturnsLease( var (request, decision, lease) = BuildAutoApproved( organization.Id, cipherId, requesterId, now.AddMinutes(-5), now.AddHours(1)); - await leaseRepository.CreateAutoApprovedAsync(request, decision, lease, now); + await accessLeaseRepository.CreateAutoApprovedAsync(request, decision, lease, now); - var active = await leaseRepository.GetActiveByRequesterIdCipherIdAsync(requesterId, cipherId, now); + var active = await accessLeaseRepository.GetActiveByRequesterIdCipherIdAsync(requesterId, cipherId, now); Assert.NotNull(active); Assert.Equal(lease.Id, active!.Id); @@ -59,7 +59,7 @@ public async Task GetActiveByRequesterIdCipherIdAsync_WithinWindow_ReturnsLease( [DatabaseTheory, DatabaseData] public async Task GetActiveByRequesterIdCipherIdAsync_OutsideWindow_ReturnsNull( IOrganizationRepository organizationRepository, - ILeaseRepository leaseRepository) + IAccessLeaseRepository accessLeaseRepository) { var organization = await organizationRepository.CreateTestOrganizationAsync(); var now = DateTime.UtcNow; @@ -69,9 +69,9 @@ public async Task GetActiveByRequesterIdCipherIdAsync_OutsideWindow_ReturnsNull( // A lease whose window has already elapsed. var (request, decision, lease) = BuildAutoApproved( organization.Id, cipherId, requesterId, now.AddHours(-2), now.AddHours(-1)); - await leaseRepository.CreateAutoApprovedAsync(request, decision, lease, now.AddHours(-2)); + await accessLeaseRepository.CreateAutoApprovedAsync(request, decision, lease, now.AddHours(-2)); - var active = await leaseRepository.GetActiveByRequesterIdCipherIdAsync(requesterId, cipherId, now); + var active = await accessLeaseRepository.GetActiveByRequesterIdCipherIdAsync(requesterId, cipherId, now); Assert.Null(active); } @@ -79,14 +79,14 @@ public async Task GetActiveByRequesterIdCipherIdAsync_OutsideWindow_ReturnsNull( [DatabaseTheory, DatabaseData] public async Task GetActivePendingByRequesterIdCipherIdAsync_ReturnsPendingRequest( IOrganizationRepository organizationRepository, - ILeaseRequestRepository leaseRequestRepository) + IAccessRequestRepository accessRequestRepository) { var organization = await organizationRepository.CreateTestOrganizationAsync(); var now = DateTime.UtcNow; var cipherId = Guid.NewGuid(); var requesterId = Guid.NewGuid(); - var request = await leaseRequestRepository.CreateAsync(new LeaseRequest + var request = await accessRequestRepository.CreateAsync(new AccessRequest { OrganizationId = organization.Id, CollectionId = Guid.NewGuid(), @@ -95,11 +95,11 @@ public async Task GetActivePendingByRequesterIdCipherIdAsync_ReturnsPendingReque NotBefore = now.AddHours(1), NotAfter = now.AddHours(2), Reason = "audit", - Status = LeaseRequestStatus.Pending, + Status = AccessRequestStatus.Pending, CreationDate = now, }); - var pending = await leaseRequestRepository.GetActivePendingByRequesterIdCipherIdAsync(requesterId, cipherId); + var pending = await accessRequestRepository.GetActivePendingByRequesterIdCipherIdAsync(requesterId, cipherId); Assert.NotNull(pending); Assert.Equal(request.Id, pending!.Id); @@ -109,7 +109,7 @@ public async Task GetActivePendingByRequesterIdCipherIdAsync_ReturnsPendingReque [DatabaseTheory, DatabaseData] public async Task GetManyActiveByRequesterIdAsync_ReturnsOnlyActiveLeasesInWindow( IOrganizationRepository organizationRepository, - ILeaseRepository leaseRepository) + IAccessLeaseRepository accessLeaseRepository) { var organization = await organizationRepository.CreateTestOrganizationAsync(); var now = DateTime.UtcNow; @@ -118,19 +118,19 @@ public async Task GetManyActiveByRequesterIdAsync_ReturnsOnlyActiveLeasesInWindo // Active, in-window lease for the requester. var (activeReq, activeDec, activeLease) = BuildAutoApproved( organization.Id, Guid.NewGuid(), requesterId, now.AddMinutes(-5), now.AddHours(1)); - await leaseRepository.CreateAutoApprovedAsync(activeReq, activeDec, activeLease, now); + await accessLeaseRepository.CreateAutoApprovedAsync(activeReq, activeDec, activeLease, now); // Expired lease for the same requester — must be excluded. var (expiredReq, expiredDec, expiredLease) = BuildAutoApproved( organization.Id, Guid.NewGuid(), requesterId, now.AddHours(-2), now.AddHours(-1)); - await leaseRepository.CreateAutoApprovedAsync(expiredReq, expiredDec, expiredLease, now.AddHours(-2)); + await accessLeaseRepository.CreateAutoApprovedAsync(expiredReq, expiredDec, expiredLease, now.AddHours(-2)); // Active lease for a different requester — must be excluded. var (otherReq, otherDec, otherLease) = BuildAutoApproved( organization.Id, Guid.NewGuid(), Guid.NewGuid(), now.AddMinutes(-5), now.AddHours(1)); - await leaseRepository.CreateAutoApprovedAsync(otherReq, otherDec, otherLease, now); + await accessLeaseRepository.CreateAutoApprovedAsync(otherReq, otherDec, otherLease, now); - var result = await leaseRepository.GetManyActiveByRequesterIdAsync(requesterId, now); + var result = await accessLeaseRepository.GetManyActiveByRequesterIdAsync(requesterId, now); Assert.Single(result); Assert.Equal(activeLease.Id, result.First().Id); @@ -139,7 +139,7 @@ public async Task GetManyActiveByRequesterIdAsync_ReturnsOnlyActiveLeasesInWindo [DatabaseTheory, DatabaseData] public async Task RevokeAsync_RevokesLeaseAndRecordsAuditDecision( IOrganizationRepository organizationRepository, - ILeaseRepository leaseRepository) + IAccessLeaseRepository accessLeaseRepository) { var organization = await organizationRepository.CreateTestOrganizationAsync(); var now = DateTime.UtcNow; @@ -149,33 +149,33 @@ public async Task RevokeAsync_RevokesLeaseAndRecordsAuditDecision( var (request, decision, lease) = BuildAutoApproved( organization.Id, cipherId, requesterId, now.AddMinutes(-5), now.AddHours(1)); - await leaseRepository.CreateAutoApprovedAsync(request, decision, lease, now); + await accessLeaseRepository.CreateAutoApprovedAsync(request, decision, lease, now); - var auditDecision = new LeaseDecision + var auditDecision = new AccessDecision { Id = CoreHelpers.GenerateComb(), - LeaseRequestId = lease.LeaseRequestId, - DeciderKind = LeaseDecisionKind.Human, + AccessRequestId = lease.AccessRequestId, + DeciderKind = AccessDeciderKind.Human, ApproverId = revokerId, - Decision = LeaseDecisionVerdict.Deny, + Verdict = AccessDecisionVerdict.Deny, Comment = "policy change", CreationDate = now, }; - await leaseRepository.RevokeAsync(lease, auditDecision, now); + await accessLeaseRepository.RevokeAsync(lease, auditDecision, now); - var persisted = await leaseRepository.GetByIdAsync(lease.Id); + var persisted = await accessLeaseRepository.GetByIdAsync(lease.Id); Assert.NotNull(persisted); - Assert.Equal(LeaseStatus.Revoked, persisted!.Status); + Assert.Equal(AccessLeaseStatus.Revoked, persisted!.Status); Assert.Equal(revokerId, persisted.RevokedBy); Assert.NotNull(persisted.RevokedDate); } - private static (LeaseRequest, LeaseDecision, Lease) BuildAutoApproved( + private static (AccessRequest, AccessDecision, AccessLease) BuildAutoApproved( Guid organizationId, Guid cipherId, Guid requesterId, DateTime notBefore, DateTime notAfter) { var collectionId = Guid.NewGuid(); - var request = new LeaseRequest + var request = new AccessRequest { Id = CoreHelpers.GenerateComb(), OrganizationId = organizationId, @@ -184,24 +184,24 @@ private static (LeaseRequest, LeaseDecision, Lease) BuildAutoApproved( RequesterId = requesterId, NotBefore = notBefore, NotAfter = notAfter, - Status = LeaseRequestStatus.Approved, + Status = AccessRequestStatus.Approved, }; - var decision = new LeaseDecision + var decision = new AccessDecision { Id = CoreHelpers.GenerateComb(), - LeaseRequestId = request.Id, - DeciderKind = LeaseDecisionKind.Policy, - Decision = LeaseDecisionVerdict.Approve, + AccessRequestId = request.Id, + DeciderKind = AccessDeciderKind.Automatic, + Verdict = AccessDecisionVerdict.Approve, }; - var lease = new Lease + var lease = new AccessLease { Id = CoreHelpers.GenerateComb(), - LeaseRequestId = request.Id, + AccessRequestId = request.Id, OrganizationId = organizationId, CollectionId = collectionId, CipherId = cipherId, RequesterId = requesterId, - Status = LeaseStatus.Active, + Status = AccessLeaseStatus.Active, NotBefore = notBefore, NotAfter = notAfter, }; diff --git a/test/Infrastructure.IntegrationTest/Pam/Repositories/LeaseRequestRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Pam/Repositories/AccessRequestRepositoryTests.cs similarity index 59% rename from test/Infrastructure.IntegrationTest/Pam/Repositories/LeaseRequestRepositoryTests.cs rename to test/Infrastructure.IntegrationTest/Pam/Repositories/AccessRequestRepositoryTests.cs index 146042e81b07..1c3505bf4609 100644 --- a/test/Infrastructure.IntegrationTest/Pam/Repositories/LeaseRequestRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/Pam/Repositories/AccessRequestRepositoryTests.cs @@ -8,31 +8,31 @@ namespace Bit.Infrastructure.IntegrationTest.Pam.Repositories; -public class LeaseRequestRepositoryTests +public class AccessRequestRepositoryTests { [DatabaseTheory, DatabaseData] public async Task GetManyInboxPendingByCollectionIdsAsync_ReturnsPendingWithDenormalizedFields( IUserRepository userRepository, IOrganizationRepository organizationRepository, ICollectionRepository collectionRepository, - ILeaseRequestRepository leaseRequestRepository) + IAccessRequestRepository accessRequestRepository) { var requester = await userRepository.CreateTestUserAsync("requester"); var organization = await organizationRepository.CreateTestOrganizationAsync(); var collection = await collectionRepository.CreateTestCollectionAsync(organization); var now = DateTime.UtcNow; - var pending = await leaseRequestRepository.CreateAsync(BuildRequest( - organization.Id, collection.Id, requester.Id, LeaseRequestStatus.Pending, now)); + var pending = await accessRequestRepository.CreateAsync(BuildRequest( + organization.Id, collection.Id, requester.Id, AccessRequestStatus.Pending, now)); // A resolved request on the same collection must NOT appear in the pending inbox. - await leaseRequestRepository.CreateAsync(BuildRequest( - organization.Id, collection.Id, requester.Id, LeaseRequestStatus.Denied, now)); + await accessRequestRepository.CreateAsync(BuildRequest( + organization.Id, collection.Id, requester.Id, AccessRequestStatus.Denied, now)); - var pendingRows = await leaseRequestRepository.GetManyInboxPendingByCollectionIdsAsync([collection.Id]); + var pendingRows = await accessRequestRepository.GetManyInboxPendingByCollectionIdsAsync([collection.Id]); var row = Assert.Single(pendingRows); Assert.Equal(pending.Id, row.Id); - Assert.Equal(LeaseRequestStatus.Pending, row.Status); + Assert.Equal(AccessRequestStatus.Pending, row.Status); Assert.Equal(collection.Name, row.CollectionName); Assert.Equal(requester.Email, row.RequesterEmail); } @@ -41,15 +41,15 @@ await leaseRequestRepository.CreateAsync(BuildRequest( public async Task GetManyInboxPendingByCollectionIdsAsync_OtherCollection_NotReturned( IOrganizationRepository organizationRepository, ICollectionRepository collectionRepository, - ILeaseRequestRepository leaseRequestRepository) + IAccessRequestRepository accessRequestRepository) { var organization = await organizationRepository.CreateTestOrganizationAsync(); var collection = await collectionRepository.CreateTestCollectionAsync(organization); var now = DateTime.UtcNow; - await leaseRequestRepository.CreateAsync(BuildRequest( - organization.Id, collection.Id, Guid.NewGuid(), LeaseRequestStatus.Pending, now)); + await accessRequestRepository.CreateAsync(BuildRequest( + organization.Id, collection.Id, Guid.NewGuid(), AccessRequestStatus.Pending, now)); - var rows = await leaseRequestRepository.GetManyInboxPendingByCollectionIdsAsync([Guid.NewGuid()]); + var rows = await accessRequestRepository.GetManyInboxPendingByCollectionIdsAsync([Guid.NewGuid()]); Assert.Empty(rows); } @@ -58,22 +58,22 @@ await leaseRequestRepository.CreateAsync(BuildRequest( public async Task GetManyInboxHistoryByCollectionIdsAsync_RespectsStatusAndWindow( IOrganizationRepository organizationRepository, ICollectionRepository collectionRepository, - ILeaseRequestRepository leaseRequestRepository) + IAccessRequestRepository accessRequestRepository) { var organization = await organizationRepository.CreateTestOrganizationAsync(); var collection = await collectionRepository.CreateTestCollectionAsync(organization); var now = DateTime.UtcNow; - var resolved = await leaseRequestRepository.CreateAsync(BuildRequest( - organization.Id, collection.Id, Guid.NewGuid(), LeaseRequestStatus.Approved, now)); + var resolved = await accessRequestRepository.CreateAsync(BuildRequest( + organization.Id, collection.Id, Guid.NewGuid(), AccessRequestStatus.Approved, now)); // Pending requests are excluded from history. - await leaseRequestRepository.CreateAsync(BuildRequest( - organization.Id, collection.Id, Guid.NewGuid(), LeaseRequestStatus.Pending, now)); + await accessRequestRepository.CreateAsync(BuildRequest( + organization.Id, collection.Id, Guid.NewGuid(), AccessRequestStatus.Pending, now)); // A resolved request older than the window is excluded. - await leaseRequestRepository.CreateAsync(BuildRequest( - organization.Id, collection.Id, Guid.NewGuid(), LeaseRequestStatus.Denied, now.AddDays(-120))); + await accessRequestRepository.CreateAsync(BuildRequest( + organization.Id, collection.Id, Guid.NewGuid(), AccessRequestStatus.Denied, now.AddDays(-120))); - var history = await leaseRequestRepository.GetManyInboxHistoryByCollectionIdsAsync( + var history = await accessRequestRepository.GetManyInboxHistoryByCollectionIdsAsync( [collection.Id], now.AddDays(-90)); var row = Assert.Single(history); @@ -84,8 +84,8 @@ await leaseRequestRepository.CreateAsync(BuildRequest( public async Task ResolveWithDecisionAsync_Approve_ResolvesRequestRecordsDecisionAndMintsActiveLease( IOrganizationRepository organizationRepository, ICollectionRepository collectionRepository, - ILeaseRequestRepository leaseRequestRepository, - ILeaseRepository leaseRepository) + IAccessRequestRepository accessRequestRepository, + IAccessLeaseRepository accessLeaseRepository) { var organization = await organizationRepository.CreateTestOrganizationAsync(); var collection = await collectionRepository.CreateTestCollectionAsync(organization); @@ -93,7 +93,7 @@ public async Task ResolveWithDecisionAsync_Approve_ResolvesRequestRecordsDecisio var approverId = Guid.NewGuid(); // Window straddles now so the minted lease is immediately active and findable by the requester. - var request = await leaseRequestRepository.CreateAsync(new LeaseRequest + var request = await accessRequestRepository.CreateAsync(new AccessRequest { OrganizationId = organization.Id, CollectionId = collection.Id, @@ -102,69 +102,69 @@ public async Task ResolveWithDecisionAsync_Approve_ResolvesRequestRecordsDecisio NotBefore = now.AddHours(-1), NotAfter = now.AddHours(1), Reason = "audit", - Status = LeaseRequestStatus.Pending, + Status = AccessRequestStatus.Pending, CreationDate = now, }); - var decision = new LeaseDecision + var decision = new AccessDecision { Id = CoreHelpers.GenerateComb(), - LeaseRequestId = request.Id, - DeciderKind = LeaseDecisionKind.Human, + AccessRequestId = request.Id, + DeciderKind = AccessDeciderKind.Human, ApproverId = approverId, - Decision = LeaseDecisionVerdict.Approve, + Verdict = AccessDecisionVerdict.Approve, Comment = "approved for audit", CreationDate = now, }; - var lease = new Lease + var lease = new AccessLease { Id = CoreHelpers.GenerateComb(), - LeaseRequestId = request.Id, + AccessRequestId = request.Id, OrganizationId = request.OrganizationId, CollectionId = request.CollectionId, CipherId = request.CipherId, RequesterId = request.RequesterId, - Status = LeaseStatus.Active, + Status = AccessLeaseStatus.Active, NotBefore = request.NotBefore, NotAfter = request.NotAfter, CreationDate = now, }; - await leaseRequestRepository.ResolveWithDecisionAsync(request, decision, LeaseRequestStatus.Approved, lease, now); + await accessRequestRepository.ResolveWithDecisionAsync(request, decision, AccessRequestStatus.Approved, lease, now); - var persisted = await leaseRequestRepository.GetByIdAsync(request.Id); + var persisted = await accessRequestRepository.GetByIdAsync(request.Id); Assert.NotNull(persisted); - Assert.Equal(LeaseRequestStatus.Approved, persisted!.Status); + Assert.Equal(AccessRequestStatus.Approved, persisted!.Status); Assert.NotNull(persisted.ResolvedDate); // The human decision surfaces as the resolver in the inbox projection. - var history = await leaseRequestRepository.GetManyInboxHistoryByCollectionIdsAsync( + var history = await accessRequestRepository.GetManyInboxHistoryByCollectionIdsAsync( [collection.Id], now.AddDays(-1)); var row = Assert.Single(history); - Assert.Equal(approverId, row.ResolverId); - Assert.Equal("approved for audit", row.ResolverComment); + Assert.Equal(approverId, row.ApproverId); + Assert.Equal("approved for audit", row.ApproverComment); // The approval minted an active lease spanning the request's window, so the requester now holds access. - var active = await leaseRepository.GetActiveByRequesterIdCipherIdAsync(request.RequesterId, request.CipherId, now); + var active = await accessLeaseRepository.GetActiveByRequesterIdCipherIdAsync(request.RequesterId, request.CipherId, now); Assert.NotNull(active); Assert.Equal(lease.Id, active!.Id); - Assert.Equal(LeaseStatus.Active, active.Status); - Assert.Equal(request.Id, active.LeaseRequestId); + Assert.Equal(AccessLeaseStatus.Active, active.Status); + Assert.Equal(request.Id, active.AccessRequestId); } [DatabaseTheory, DatabaseData] public async Task ResolveWithDecisionAsync_Deny_ResolvesWithoutLease( IOrganizationRepository organizationRepository, ICollectionRepository collectionRepository, - ILeaseRequestRepository leaseRequestRepository, - ILeaseRepository leaseRepository) + IAccessRequestRepository accessRequestRepository, + IAccessLeaseRepository accessLeaseRepository) { var organization = await organizationRepository.CreateTestOrganizationAsync(); var collection = await collectionRepository.CreateTestCollectionAsync(organization); var now = DateTime.UtcNow; - var request = await leaseRequestRepository.CreateAsync(new LeaseRequest + var request = await accessRequestRepository.CreateAsync(new AccessRequest { OrganizationId = organization.Id, CollectionId = collection.Id, @@ -173,27 +173,27 @@ public async Task ResolveWithDecisionAsync_Deny_ResolvesWithoutLease( NotBefore = now.AddHours(-1), NotAfter = now.AddHours(1), Reason = "audit", - Status = LeaseRequestStatus.Pending, + Status = AccessRequestStatus.Pending, CreationDate = now, }); - var decision = new LeaseDecision + var decision = new AccessDecision { Id = CoreHelpers.GenerateComb(), - LeaseRequestId = request.Id, - DeciderKind = LeaseDecisionKind.Human, + AccessRequestId = request.Id, + DeciderKind = AccessDeciderKind.Human, ApproverId = Guid.NewGuid(), - Decision = LeaseDecisionVerdict.Deny, + Verdict = AccessDecisionVerdict.Deny, CreationDate = now, }; - await leaseRequestRepository.ResolveWithDecisionAsync(request, decision, LeaseRequestStatus.Denied, null, now); + await accessRequestRepository.ResolveWithDecisionAsync(request, decision, AccessRequestStatus.Denied, null, now); - var persisted = await leaseRequestRepository.GetByIdAsync(request.Id); - Assert.Equal(LeaseRequestStatus.Denied, persisted!.Status); + var persisted = await accessRequestRepository.GetByIdAsync(request.Id); + Assert.Equal(AccessRequestStatus.Denied, persisted!.Status); // A denial grants nothing: no active lease exists for the requester. - var active = await leaseRepository.GetActiveByRequesterIdCipherIdAsync(request.RequesterId, request.CipherId, now); + var active = await accessLeaseRepository.GetActiveByRequesterIdCipherIdAsync(request.RequesterId, request.CipherId, now); Assert.Null(active); } @@ -201,22 +201,22 @@ public async Task ResolveWithDecisionAsync_Deny_ResolvesWithoutLease( public async Task GetManyByRequesterIdAsync_ReturnsOwnRequestsRegardlessOfStatus( IOrganizationRepository organizationRepository, ICollectionRepository collectionRepository, - ILeaseRequestRepository leaseRequestRepository) + IAccessRequestRepository accessRequestRepository) { var organization = await organizationRepository.CreateTestOrganizationAsync(); var collection = await collectionRepository.CreateTestCollectionAsync(organization); var now = DateTime.UtcNow; var requesterId = Guid.NewGuid(); - var pending = await leaseRequestRepository.CreateAsync(BuildRequest( - organization.Id, collection.Id, requesterId, LeaseRequestStatus.Pending, now)); - var denied = await leaseRequestRepository.CreateAsync(BuildRequest( - organization.Id, collection.Id, requesterId, LeaseRequestStatus.Denied, now.AddMinutes(-1))); + var pending = await accessRequestRepository.CreateAsync(BuildRequest( + organization.Id, collection.Id, requesterId, AccessRequestStatus.Pending, now)); + var denied = await accessRequestRepository.CreateAsync(BuildRequest( + organization.Id, collection.Id, requesterId, AccessRequestStatus.Denied, now.AddMinutes(-1))); // A different user's request on the same collection must not appear. - await leaseRequestRepository.CreateAsync(BuildRequest( - organization.Id, collection.Id, Guid.NewGuid(), LeaseRequestStatus.Pending, now)); + await accessRequestRepository.CreateAsync(BuildRequest( + organization.Id, collection.Id, Guid.NewGuid(), AccessRequestStatus.Pending, now)); - var mine = await leaseRequestRepository.GetManyByRequesterIdAsync(requesterId); + var mine = await accessRequestRepository.GetManyByRequesterIdAsync(requesterId); Assert.Equal(2, mine.Count); Assert.Contains(mine, r => r.Id == pending.Id); @@ -225,8 +225,8 @@ await leaseRequestRepository.CreateAsync(BuildRequest( Assert.All(mine, r => Assert.Null(r.CollectionName)); } - private static LeaseRequest BuildRequest( - Guid organizationId, Guid collectionId, Guid requesterId, LeaseRequestStatus status, DateTime creationDate) + private static AccessRequest BuildRequest( + Guid organizationId, Guid collectionId, Guid requesterId, AccessRequestStatus status, DateTime creationDate) => new() { OrganizationId = organizationId, @@ -238,6 +238,6 @@ private static LeaseRequest BuildRequest( Reason = "audit", Status = status, CreationDate = creationDate, - ResolvedDate = status == LeaseRequestStatus.Pending ? null : creationDate, + ResolvedDate = status == AccessRequestStatus.Pending ? null : creationDate, }; } diff --git a/test/Infrastructure.IntegrationTest/Pam/Repositories/AccessRuleRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Pam/Repositories/AccessRuleRepositoryTests.cs index a5e0f69dbed3..91d524dc093c 100644 --- a/test/Infrastructure.IntegrationTest/Pam/Repositories/AccessRuleRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/Pam/Repositories/AccessRuleRepositoryTests.cs @@ -22,7 +22,7 @@ public async Task DeleteAsync_WithGovernedCollections_ClearsAssociationsAndKeeps { OrganizationId = organization.Id, Name = "Test Rule", - Rule = """{"kind":"human_approval"}""", + Conditions = """{"kind":"human_approval"}""", }); var collection = new Collection diff --git a/util/Migrator/DbScripts/2026-05-21_00_AddAccessRule.sql b/util/Migrator/DbScripts/2026-05-21_00_AddAccessRule.sql index 34e4e003632a..ced250000f83 100644 --- a/util/Migrator/DbScripts/2026-05-21_00_AddAccessRule.sql +++ b/util/Migrator/DbScripts/2026-05-21_00_AddAccessRule.sql @@ -6,7 +6,7 @@ BEGIN [OrganizationId] UNIQUEIDENTIFIER NOT NULL, [Name] NVARCHAR(256) NOT NULL, [Description] NVARCHAR(MAX) NULL, - [Rule] NVARCHAR(MAX) NOT NULL, + [Conditions] NVARCHAR(MAX) NOT NULL, [CreationDate] DATETIME2(7) NOT NULL, [RevisionDate] DATETIME2(7) NOT NULL, CONSTRAINT [PK_AccessRule] PRIMARY KEY CLUSTERED ([Id] ASC), @@ -95,7 +95,7 @@ CREATE OR ALTER PROCEDURE [dbo].[AccessRule_Create] @OrganizationId UNIQUEIDENTIFIER, @Name NVARCHAR(256), @Description NVARCHAR(MAX) = NULL, - @Rule NVARCHAR(MAX), + @Conditions NVARCHAR(MAX), @CreationDate DATETIME2(7), @RevisionDate DATETIME2(7) AS @@ -108,7 +108,7 @@ BEGIN [OrganizationId], [Name], [Description], - [Rule], + [Conditions], [CreationDate], [RevisionDate] ) @@ -118,7 +118,7 @@ BEGIN @OrganizationId, @Name, @Description, - @Rule, + @Conditions, @CreationDate, @RevisionDate ) @@ -130,7 +130,7 @@ CREATE OR ALTER PROCEDURE [dbo].[AccessRule_Update] @OrganizationId UNIQUEIDENTIFIER, @Name NVARCHAR(256), @Description NVARCHAR(MAX) = NULL, - @Rule NVARCHAR(MAX), + @Conditions NVARCHAR(MAX), @CreationDate DATETIME2(7), @RevisionDate DATETIME2(7) AS @@ -143,7 +143,7 @@ BEGIN [OrganizationId] = @OrganizationId, [Name] = @Name, [Description] = @Description, - [Rule] = @Rule, + [Conditions] = @Conditions, [CreationDate] = @CreationDate, [RevisionDate] = @RevisionDate WHERE diff --git a/util/Migrator/DbScripts/2026-06-04_00_AddAccessLeaseTables.sql b/util/Migrator/DbScripts/2026-06-04_00_AddAccessLeaseTables.sql new file mode 100644 index 000000000000..54f3d3e25963 --- /dev/null +++ b/util/Migrator/DbScripts/2026-06-04_00_AddAccessLeaseTables.sql @@ -0,0 +1,244 @@ +-- PAM Credential Leasing: AccessRequest / AccessLease / AccessDecision tables + procedures. + +-- AccessRequest (created first; the FK to AccessLease is added later, once AccessLease exists). +IF OBJECT_ID('[dbo].[AccessRequest]') IS NULL +BEGIN + CREATE TABLE [dbo].[AccessRequest] ( + [Id] UNIQUEIDENTIFIER NOT NULL, + [ExtensionOfLeaseId] UNIQUEIDENTIFIER NULL, + [OrganizationId] UNIQUEIDENTIFIER NOT NULL, + [CollectionId] UNIQUEIDENTIFIER NOT NULL, + [CipherId] UNIQUEIDENTIFIER NOT NULL, + [RequesterId] UNIQUEIDENTIFIER NOT NULL, + [NotBefore] DATETIME2 (7) NOT NULL, + [NotAfter] DATETIME2 (7) NOT NULL, + [Reason] NVARCHAR(MAX) NULL, + [Status] TINYINT NOT NULL, + [CreationDate] DATETIME2 (7) NOT NULL, + [ResolvedDate] DATETIME2 (7) NULL, + CONSTRAINT [PK_AccessRequest] PRIMARY KEY CLUSTERED ([Id] ASC), + CONSTRAINT [FK_AccessRequest_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]) ON DELETE CASCADE + ); +END +GO + +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE [Name] = 'IX_AccessRequest_RequesterId_CipherId_Status' AND object_id = OBJECT_ID('[dbo].[AccessRequest]')) +BEGIN + CREATE NONCLUSTERED INDEX [IX_AccessRequest_RequesterId_CipherId_Status] + ON [dbo].[AccessRequest] ([RequesterId] ASC, [CipherId] ASC, [Status] ASC); +END +GO + +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE [Name] = 'IX_AccessRequest_OrganizationId_Status' AND object_id = OBJECT_ID('[dbo].[AccessRequest]')) +BEGIN + CREATE NONCLUSTERED INDEX [IX_AccessRequest_OrganizationId_Status] + ON [dbo].[AccessRequest] ([OrganizationId] ASC, [Status] ASC); +END +GO + +-- AccessLease +IF OBJECT_ID('[dbo].[AccessLease]') IS NULL +BEGIN + CREATE TABLE [dbo].[AccessLease] ( + [Id] UNIQUEIDENTIFIER NOT NULL, + [AccessRequestId] UNIQUEIDENTIFIER NOT NULL, + [OrganizationId] UNIQUEIDENTIFIER NOT NULL, + [CollectionId] UNIQUEIDENTIFIER NOT NULL, + [CipherId] UNIQUEIDENTIFIER NOT NULL, + [RequesterId] UNIQUEIDENTIFIER NOT NULL, + [Status] TINYINT NOT NULL, + [NotBefore] DATETIME2 (7) NOT NULL, + [NotAfter] DATETIME2 (7) NOT NULL, + [RevokedDate] DATETIME2 (7) NULL, + [RevokedBy] UNIQUEIDENTIFIER NULL, + [CreationDate] DATETIME2 (7) NOT NULL, + CONSTRAINT [PK_AccessLease] PRIMARY KEY CLUSTERED ([Id] ASC), + CONSTRAINT [FK_AccessLease_AccessRequest] FOREIGN KEY ([AccessRequestId]) REFERENCES [dbo].[AccessRequest] ([Id]), + CONSTRAINT [FK_AccessLease_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]) ON DELETE CASCADE + ); +END +GO + +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE [Name] = 'IX_AccessLease_RequesterId_CipherId_Status' AND object_id = OBJECT_ID('[dbo].[AccessLease]')) +BEGIN + CREATE NONCLUSTERED INDEX [IX_AccessLease_RequesterId_CipherId_Status] + ON [dbo].[AccessLease] ([RequesterId] ASC, [CipherId] ASC, [Status] ASC); +END +GO + +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE [Name] = 'IX_AccessLease_NotAfter_Status' AND object_id = OBJECT_ID('[dbo].[AccessLease]')) +BEGIN + CREATE NONCLUSTERED INDEX [IX_AccessLease_NotAfter_Status] + ON [dbo].[AccessLease] ([NotAfter] ASC, [Status] ASC); +END +GO + +-- Now that AccessLease exists, add the reciprocal FK from AccessRequest.ExtensionOfLeaseId (used by future extension requests). +IF OBJECT_ID('[dbo].[FK_AccessRequest_AccessLease]', 'F') IS NULL +BEGIN + ALTER TABLE [dbo].[AccessRequest] + ADD CONSTRAINT [FK_AccessRequest_AccessLease] FOREIGN KEY ([ExtensionOfLeaseId]) REFERENCES [dbo].[AccessLease] ([Id]); +END +GO + +-- AccessDecision +IF OBJECT_ID('[dbo].[AccessDecision]') IS NULL +BEGIN + CREATE TABLE [dbo].[AccessDecision] ( + [Id] UNIQUEIDENTIFIER NOT NULL, + [AccessRequestId] UNIQUEIDENTIFIER NOT NULL, + [DeciderKind] TINYINT NOT NULL, + [ApproverId] UNIQUEIDENTIFIER NULL, + [ConditionKind] NVARCHAR(50) NULL, + [Verdict] TINYINT NOT NULL, + [Comment] NVARCHAR(MAX) NULL, + [EvaluationContext] NVARCHAR(MAX) NULL, + [CreationDate] DATETIME2 (7) NOT NULL, + CONSTRAINT [PK_AccessDecision] PRIMARY KEY CLUSTERED ([Id] ASC), + CONSTRAINT [FK_AccessDecision_AccessRequest] FOREIGN KEY ([AccessRequestId]) REFERENCES [dbo].[AccessRequest] ([Id]) ON DELETE CASCADE + ); +END +GO + +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE [Name] = 'IX_AccessDecision_AccessRequestId' AND object_id = OBJECT_ID('[dbo].[AccessDecision]')) +BEGIN + CREATE NONCLUSTERED INDEX [IX_AccessDecision_AccessRequestId] + ON [dbo].[AccessDecision] ([AccessRequestId] ASC); +END +GO + +-- Stored procedures +CREATE OR ALTER PROCEDURE [dbo].[AccessRequest_Create] + @Id UNIQUEIDENTIFIER OUTPUT, + @ExtensionOfLeaseId UNIQUEIDENTIFIER = NULL, + @OrganizationId UNIQUEIDENTIFIER, + @CollectionId UNIQUEIDENTIFIER, + @CipherId UNIQUEIDENTIFIER, + @RequesterId UNIQUEIDENTIFIER, + @NotBefore DATETIME2(7), + @NotAfter DATETIME2(7), + @Reason NVARCHAR(MAX) = NULL, + @Status TINYINT, + @CreationDate DATETIME2(7), + @ResolvedDate DATETIME2(7) = NULL +AS +BEGIN + SET NOCOUNT ON + + INSERT INTO [dbo].[AccessRequest] + ( + [Id], [ExtensionOfLeaseId], [OrganizationId], [CollectionId], [CipherId], [RequesterId], + [NotBefore], [NotAfter], [Reason], [Status], [CreationDate], [ResolvedDate] + ) + VALUES + ( + @Id, @ExtensionOfLeaseId, @OrganizationId, @CollectionId, @CipherId, @RequesterId, + @NotBefore, @NotAfter, @Reason, @Status, @CreationDate, @ResolvedDate + ) +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[AccessRequest_ReadById] + @Id UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + SELECT * FROM [dbo].[AccessRequest] WHERE [Id] = @Id +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[AccessRequest_ReadActivePendingByRequesterIdCipherId] + @RequesterId UNIQUEIDENTIFIER, + @CipherId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + SELECT TOP 1 * + FROM [dbo].[AccessRequest] + WHERE [RequesterId] = @RequesterId AND [CipherId] = @CipherId AND [Status] = 0 -- Pending + ORDER BY [CreationDate] DESC +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[AccessLease_ReadById] + @Id UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + SELECT * FROM [dbo].[AccessLease] WHERE [Id] = @Id +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[AccessLease_ReadActiveByRequesterIdCipherId] + @RequesterId UNIQUEIDENTIFIER, + @CipherId UNIQUEIDENTIFIER, + @Now DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + SELECT TOP 1 * + FROM [dbo].[AccessLease] + WHERE [RequesterId] = @RequesterId + AND [CipherId] = @CipherId + AND [Status] = 0 -- Active + AND [NotBefore] <= @Now + AND [NotAfter] > @Now + ORDER BY [NotAfter] DESC +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[AccessLease_CreateAutoApproved] + @AccessRequestId UNIQUEIDENTIFIER, + @AccessLeaseId UNIQUEIDENTIFIER, + @AccessDecisionId UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @CollectionId UNIQUEIDENTIFIER, + @CipherId UNIQUEIDENTIFIER, + @RequesterId UNIQUEIDENTIFIER, + @NotBefore DATETIME2(7), + @NotAfter DATETIME2(7), + @Reason NVARCHAR(MAX) = NULL, + @ConditionKind NVARCHAR(50) = NULL, + @Now DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + BEGIN TRANSACTION AccessLease_CreateAutoApproved + + INSERT INTO [dbo].[AccessRequest] + ( + [Id], [ExtensionOfLeaseId], [OrganizationId], [CollectionId], [CipherId], [RequesterId], + [NotBefore], [NotAfter], [Reason], [Status], [CreationDate], [ResolvedDate] + ) + VALUES + ( + @AccessRequestId, NULL, @OrganizationId, @CollectionId, @CipherId, @RequesterId, + @NotBefore, @NotAfter, @Reason, 1 /* Approved */, @Now, @Now + ) + + INSERT INTO [dbo].[AccessDecision] + ( + [Id], [AccessRequestId], [DeciderKind], [ApproverId], [ConditionKind], + [Verdict], [Comment], [EvaluationContext], [CreationDate] + ) + VALUES + ( + @AccessDecisionId, @AccessRequestId, 0 /* Automatic */, NULL, @ConditionKind, + 0 /* Approve */, NULL, NULL, @Now + ) + + INSERT INTO [dbo].[AccessLease] + ( + [Id], [AccessRequestId], [OrganizationId], [CollectionId], [CipherId], [RequesterId], + [Status], [NotBefore], [NotAfter], [RevokedDate], [RevokedBy], [CreationDate] + ) + VALUES + ( + @AccessLeaseId, @AccessRequestId, @OrganizationId, @CollectionId, @CipherId, @RequesterId, + 0 /* Active */, @NotBefore, @NotAfter, NULL, NULL, @Now + ) + + COMMIT TRANSACTION AccessLease_CreateAutoApproved +END +GO diff --git a/util/Migrator/DbScripts/2026-06-04_00_AddLeaseTables.sql b/util/Migrator/DbScripts/2026-06-04_00_AddLeaseTables.sql deleted file mode 100644 index e4459debbb5c..000000000000 --- a/util/Migrator/DbScripts/2026-06-04_00_AddLeaseTables.sql +++ /dev/null @@ -1,244 +0,0 @@ --- PAM Credential Leasing: LeaseRequest / Lease / LeaseDecision tables + procedures. - --- LeaseRequest (created first; the FK to Lease is added later, once Lease exists). -IF OBJECT_ID('[dbo].[LeaseRequest]') IS NULL -BEGIN - CREATE TABLE [dbo].[LeaseRequest] ( - [Id] UNIQUEIDENTIFIER NOT NULL, - [LeaseId] UNIQUEIDENTIFIER NULL, - [OrganizationId] UNIQUEIDENTIFIER NOT NULL, - [CollectionId] UNIQUEIDENTIFIER NOT NULL, - [CipherId] UNIQUEIDENTIFIER NOT NULL, - [RequesterId] UNIQUEIDENTIFIER NOT NULL, - [NotBefore] DATETIME2 (7) NOT NULL, - [NotAfter] DATETIME2 (7) NOT NULL, - [Reason] NVARCHAR(MAX) NULL, - [Status] TINYINT NOT NULL, - [CreationDate] DATETIME2 (7) NOT NULL, - [ResolvedDate] DATETIME2 (7) NULL, - CONSTRAINT [PK_LeaseRequest] PRIMARY KEY CLUSTERED ([Id] ASC), - CONSTRAINT [FK_LeaseRequest_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]) ON DELETE CASCADE - ); -END -GO - -IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE [Name] = 'IX_LeaseRequest_RequesterId_CipherId_Status' AND object_id = OBJECT_ID('[dbo].[LeaseRequest]')) -BEGIN - CREATE NONCLUSTERED INDEX [IX_LeaseRequest_RequesterId_CipherId_Status] - ON [dbo].[LeaseRequest] ([RequesterId] ASC, [CipherId] ASC, [Status] ASC); -END -GO - -IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE [Name] = 'IX_LeaseRequest_OrganizationId_Status' AND object_id = OBJECT_ID('[dbo].[LeaseRequest]')) -BEGIN - CREATE NONCLUSTERED INDEX [IX_LeaseRequest_OrganizationId_Status] - ON [dbo].[LeaseRequest] ([OrganizationId] ASC, [Status] ASC); -END -GO - --- Lease -IF OBJECT_ID('[dbo].[Lease]') IS NULL -BEGIN - CREATE TABLE [dbo].[Lease] ( - [Id] UNIQUEIDENTIFIER NOT NULL, - [LeaseRequestId] UNIQUEIDENTIFIER NOT NULL, - [OrganizationId] UNIQUEIDENTIFIER NOT NULL, - [CollectionId] UNIQUEIDENTIFIER NOT NULL, - [CipherId] UNIQUEIDENTIFIER NOT NULL, - [RequesterId] UNIQUEIDENTIFIER NOT NULL, - [Status] TINYINT NOT NULL, - [NotBefore] DATETIME2 (7) NOT NULL, - [NotAfter] DATETIME2 (7) NOT NULL, - [RevokedDate] DATETIME2 (7) NULL, - [RevokedBy] UNIQUEIDENTIFIER NULL, - [CreationDate] DATETIME2 (7) NOT NULL, - CONSTRAINT [PK_Lease] PRIMARY KEY CLUSTERED ([Id] ASC), - CONSTRAINT [FK_Lease_LeaseRequest] FOREIGN KEY ([LeaseRequestId]) REFERENCES [dbo].[LeaseRequest] ([Id]), - CONSTRAINT [FK_Lease_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]) ON DELETE CASCADE - ); -END -GO - -IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE [Name] = 'IX_Lease_RequesterId_CipherId_Status' AND object_id = OBJECT_ID('[dbo].[Lease]')) -BEGIN - CREATE NONCLUSTERED INDEX [IX_Lease_RequesterId_CipherId_Status] - ON [dbo].[Lease] ([RequesterId] ASC, [CipherId] ASC, [Status] ASC); -END -GO - -IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE [Name] = 'IX_Lease_NotAfter_Status' AND object_id = OBJECT_ID('[dbo].[Lease]')) -BEGIN - CREATE NONCLUSTERED INDEX [IX_Lease_NotAfter_Status] - ON [dbo].[Lease] ([NotAfter] ASC, [Status] ASC); -END -GO - --- Now that Lease exists, add the reciprocal FK from LeaseRequest.LeaseId (used by future extension requests). -IF OBJECT_ID('[dbo].[FK_LeaseRequest_Lease]', 'F') IS NULL -BEGIN - ALTER TABLE [dbo].[LeaseRequest] - ADD CONSTRAINT [FK_LeaseRequest_Lease] FOREIGN KEY ([LeaseId]) REFERENCES [dbo].[Lease] ([Id]); -END -GO - --- LeaseDecision -IF OBJECT_ID('[dbo].[LeaseDecision]') IS NULL -BEGIN - CREATE TABLE [dbo].[LeaseDecision] ( - [Id] UNIQUEIDENTIFIER NOT NULL, - [LeaseRequestId] UNIQUEIDENTIFIER NOT NULL, - [DeciderKind] TINYINT NOT NULL, - [ApproverId] UNIQUEIDENTIFIER NULL, - [PolicyKind] NVARCHAR(50) NULL, - [Decision] TINYINT NOT NULL, - [Comment] NVARCHAR(MAX) NULL, - [EvaluationContext] NVARCHAR(MAX) NULL, - [CreationDate] DATETIME2 (7) NOT NULL, - CONSTRAINT [PK_LeaseDecision] PRIMARY KEY CLUSTERED ([Id] ASC), - CONSTRAINT [FK_LeaseDecision_LeaseRequest] FOREIGN KEY ([LeaseRequestId]) REFERENCES [dbo].[LeaseRequest] ([Id]) ON DELETE CASCADE - ); -END -GO - -IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE [Name] = 'IX_LeaseDecision_LeaseRequestId' AND object_id = OBJECT_ID('[dbo].[LeaseDecision]')) -BEGIN - CREATE NONCLUSTERED INDEX [IX_LeaseDecision_LeaseRequestId] - ON [dbo].[LeaseDecision] ([LeaseRequestId] ASC); -END -GO - --- Stored procedures -CREATE OR ALTER PROCEDURE [dbo].[LeaseRequest_Create] - @Id UNIQUEIDENTIFIER OUTPUT, - @LeaseId UNIQUEIDENTIFIER = NULL, - @OrganizationId UNIQUEIDENTIFIER, - @CollectionId UNIQUEIDENTIFIER, - @CipherId UNIQUEIDENTIFIER, - @RequesterId UNIQUEIDENTIFIER, - @NotBefore DATETIME2(7), - @NotAfter DATETIME2(7), - @Reason NVARCHAR(MAX) = NULL, - @Status TINYINT, - @CreationDate DATETIME2(7), - @ResolvedDate DATETIME2(7) = NULL -AS -BEGIN - SET NOCOUNT ON - - INSERT INTO [dbo].[LeaseRequest] - ( - [Id], [LeaseId], [OrganizationId], [CollectionId], [CipherId], [RequesterId], - [NotBefore], [NotAfter], [Reason], [Status], [CreationDate], [ResolvedDate] - ) - VALUES - ( - @Id, @LeaseId, @OrganizationId, @CollectionId, @CipherId, @RequesterId, - @NotBefore, @NotAfter, @Reason, @Status, @CreationDate, @ResolvedDate - ) -END -GO - -CREATE OR ALTER PROCEDURE [dbo].[LeaseRequest_ReadById] - @Id UNIQUEIDENTIFIER -AS -BEGIN - SET NOCOUNT ON - SELECT * FROM [dbo].[LeaseRequest] WHERE [Id] = @Id -END -GO - -CREATE OR ALTER PROCEDURE [dbo].[LeaseRequest_ReadActivePendingByRequesterIdCipherId] - @RequesterId UNIQUEIDENTIFIER, - @CipherId UNIQUEIDENTIFIER -AS -BEGIN - SET NOCOUNT ON - SELECT TOP 1 * - FROM [dbo].[LeaseRequest] - WHERE [RequesterId] = @RequesterId AND [CipherId] = @CipherId AND [Status] = 0 -- Pending - ORDER BY [CreationDate] DESC -END -GO - -CREATE OR ALTER PROCEDURE [dbo].[Lease_ReadById] - @Id UNIQUEIDENTIFIER -AS -BEGIN - SET NOCOUNT ON - SELECT * FROM [dbo].[Lease] WHERE [Id] = @Id -END -GO - -CREATE OR ALTER PROCEDURE [dbo].[Lease_ReadActiveByRequesterIdCipherId] - @RequesterId UNIQUEIDENTIFIER, - @CipherId UNIQUEIDENTIFIER, - @Now DATETIME2(7) -AS -BEGIN - SET NOCOUNT ON - SELECT TOP 1 * - FROM [dbo].[Lease] - WHERE [RequesterId] = @RequesterId - AND [CipherId] = @CipherId - AND [Status] = 0 -- Active - AND [NotBefore] <= @Now - AND [NotAfter] > @Now - ORDER BY [NotAfter] DESC -END -GO - -CREATE OR ALTER PROCEDURE [dbo].[Lease_CreateAutoApproved] - @LeaseRequestId UNIQUEIDENTIFIER, - @LeaseId UNIQUEIDENTIFIER, - @LeaseDecisionId UNIQUEIDENTIFIER, - @OrganizationId UNIQUEIDENTIFIER, - @CollectionId UNIQUEIDENTIFIER, - @CipherId UNIQUEIDENTIFIER, - @RequesterId UNIQUEIDENTIFIER, - @NotBefore DATETIME2(7), - @NotAfter DATETIME2(7), - @Reason NVARCHAR(MAX) = NULL, - @PolicyKind NVARCHAR(50) = NULL, - @Now DATETIME2(7) -AS -BEGIN - SET NOCOUNT ON - - BEGIN TRANSACTION Lease_CreateAutoApproved - - INSERT INTO [dbo].[LeaseRequest] - ( - [Id], [LeaseId], [OrganizationId], [CollectionId], [CipherId], [RequesterId], - [NotBefore], [NotAfter], [Reason], [Status], [CreationDate], [ResolvedDate] - ) - VALUES - ( - @LeaseRequestId, NULL, @OrganizationId, @CollectionId, @CipherId, @RequesterId, - @NotBefore, @NotAfter, @Reason, 1 /* Approved */, @Now, @Now - ) - - INSERT INTO [dbo].[LeaseDecision] - ( - [Id], [LeaseRequestId], [DeciderKind], [ApproverId], [PolicyKind], - [Decision], [Comment], [EvaluationContext], [CreationDate] - ) - VALUES - ( - @LeaseDecisionId, @LeaseRequestId, 0 /* Policy */, NULL, @PolicyKind, - 0 /* Approve */, NULL, NULL, @Now - ) - - INSERT INTO [dbo].[Lease] - ( - [Id], [LeaseRequestId], [OrganizationId], [CollectionId], [CipherId], [RequesterId], - [Status], [NotBefore], [NotAfter], [RevokedDate], [RevokedBy], [CreationDate] - ) - VALUES - ( - @LeaseId, @LeaseRequestId, @OrganizationId, @CollectionId, @CipherId, @RequesterId, - 0 /* Active */, @NotBefore, @NotAfter, NULL, NULL, @Now - ) - - COMMIT TRANSACTION Lease_CreateAutoApproved -END -GO diff --git a/util/Migrator/DbScripts/2026-06-05_00_AddApproverInboxSprocs.sql b/util/Migrator/DbScripts/2026-06-05_00_AddApproverInboxSprocs.sql index a5586c7a9698..7e9236d26f6b 100644 --- a/util/Migrator/DbScripts/2026-06-05_00_AddApproverInboxSprocs.sql +++ b/util/Migrator/DbScripts/2026-06-05_00_AddApproverInboxSprocs.sql @@ -2,7 +2,7 @@ -- PAM Approver Inbox: read procedures for the pending/history queues, the resolve-with-decision and lease-revoke -- mutations, and Collection_ReadManagingUserIds (the collection managers resolved for the RefreshApproverInbox push). -CREATE OR ALTER PROCEDURE [dbo].[LeaseRequest_ReadInboxPendingByCollectionIds] +CREATE OR ALTER PROCEDURE [dbo].[AccessRequest_ReadInboxPendingByCollectionIds] @CollectionIds [dbo].[GuidIdArray] READONLY AS BEGIN @@ -10,7 +10,7 @@ BEGIN SELECT LR.[Id], - LR.[LeaseId] AS [ExtensionOfLeaseId], + LR.[ExtensionOfLeaseId], LR.[OrganizationId], LR.[CollectionId], LR.[CipherId], @@ -22,34 +22,34 @@ BEGIN LR.[CreationDate], LR.[ResolvedDate], PL.[Id] AS [ProducedLeaseId], - RES.[ApproverId] AS [ResolverId], - RES.[Comment] AS [ResolverComment], + RES.[ApproverId] AS [ApproverId], + RES.[Comment] AS [ApproverComment], JSON_VALUE(C.[Data], '$.Name') AS [CipherName], COL.[Name] AS [CollectionName], U.[Name] AS [RequesterName], U.[Email] AS [RequesterEmail] - FROM [dbo].[LeaseRequest] LR + FROM [dbo].[AccessRequest] LR INNER JOIN @CollectionIds CI ON CI.[Id] = LR.[CollectionId] LEFT JOIN [dbo].[Cipher] C ON C.[Id] = LR.[CipherId] LEFT JOIN [dbo].[Collection] COL ON COL.[Id] = LR.[CollectionId] LEFT JOIN [dbo].[User] U ON U.[Id] = LR.[RequesterId] OUTER APPLY ( SELECT TOP 1 L.[Id] - FROM [dbo].[Lease] L - WHERE L.[LeaseRequestId] = LR.[Id] + FROM [dbo].[AccessLease] L + WHERE L.[AccessRequestId] = LR.[Id] ORDER BY L.[CreationDate] DESC ) PL OUTER APPLY ( SELECT TOP 1 LD.[ApproverId], LD.[Comment] - FROM [dbo].[LeaseDecision] LD - WHERE LD.[LeaseRequestId] = LR.[Id] AND LD.[DeciderKind] = 1 -- Human + FROM [dbo].[AccessDecision] LD + WHERE LD.[AccessRequestId] = LR.[Id] AND LD.[DeciderKind] = 1 -- Human ORDER BY LD.[CreationDate] ASC ) RES WHERE LR.[Status] = 0 -- Pending END GO -CREATE OR ALTER PROCEDURE [dbo].[LeaseRequest_ReadInboxHistoryByCollectionIds] +CREATE OR ALTER PROCEDURE [dbo].[AccessRequest_ReadInboxHistoryByCollectionIds] @CollectionIds [dbo].[GuidIdArray] READONLY, @Since DATETIME2(7) AS @@ -58,7 +58,7 @@ BEGIN SELECT LR.[Id], - LR.[LeaseId] AS [ExtensionOfLeaseId], + LR.[ExtensionOfLeaseId], LR.[OrganizationId], LR.[CollectionId], LR.[CipherId], @@ -70,27 +70,27 @@ BEGIN LR.[CreationDate], LR.[ResolvedDate], PL.[Id] AS [ProducedLeaseId], - RES.[ApproverId] AS [ResolverId], - RES.[Comment] AS [ResolverComment], + RES.[ApproverId] AS [ApproverId], + RES.[Comment] AS [ApproverComment], JSON_VALUE(C.[Data], '$.Name') AS [CipherName], COL.[Name] AS [CollectionName], U.[Name] AS [RequesterName], U.[Email] AS [RequesterEmail] - FROM [dbo].[LeaseRequest] LR + FROM [dbo].[AccessRequest] LR INNER JOIN @CollectionIds CI ON CI.[Id] = LR.[CollectionId] LEFT JOIN [dbo].[Cipher] C ON C.[Id] = LR.[CipherId] LEFT JOIN [dbo].[Collection] COL ON COL.[Id] = LR.[CollectionId] LEFT JOIN [dbo].[User] U ON U.[Id] = LR.[RequesterId] OUTER APPLY ( SELECT TOP 1 L.[Id] - FROM [dbo].[Lease] L - WHERE L.[LeaseRequestId] = LR.[Id] + FROM [dbo].[AccessLease] L + WHERE L.[AccessRequestId] = LR.[Id] ORDER BY L.[CreationDate] DESC ) PL OUTER APPLY ( SELECT TOP 1 LD.[ApproverId], LD.[Comment] - FROM [dbo].[LeaseDecision] LD - WHERE LD.[LeaseRequestId] = LR.[Id] AND LD.[DeciderKind] = 1 -- Human + FROM [dbo].[AccessDecision] LD + WHERE LD.[AccessRequestId] = LR.[Id] AND LD.[DeciderKind] = 1 -- Human ORDER BY LD.[CreationDate] ASC ) RES WHERE LR.[Status] <> 0 -- not Pending @@ -98,71 +98,71 @@ BEGIN END GO -CREATE OR ALTER PROCEDURE [dbo].[LeaseRequest_ResolveWithDecision] - @LeaseRequestId UNIQUEIDENTIFIER, +CREATE OR ALTER PROCEDURE [dbo].[AccessRequest_ResolveWithDecision] + @AccessRequestId UNIQUEIDENTIFIER, @Status TINYINT, - @LeaseDecisionId UNIQUEIDENTIFIER, + @AccessDecisionId UNIQUEIDENTIFIER, @ApproverId UNIQUEIDENTIFIER, - @Decision TINYINT, + @Verdict TINYINT, @Comment NVARCHAR(MAX) = NULL, @Now DATETIME2(7) AS BEGIN SET NOCOUNT ON - BEGIN TRANSACTION LeaseRequest_ResolveWithDecision + BEGIN TRANSACTION AccessRequest_Resolve - UPDATE [dbo].[LeaseRequest] + UPDATE [dbo].[AccessRequest] SET [Status] = @Status, [ResolvedDate] = @Now - WHERE [Id] = @LeaseRequestId AND [Status] = 0 -- Pending + WHERE [Id] = @AccessRequestId AND [Status] = 0 -- Pending - INSERT INTO [dbo].[LeaseDecision] + INSERT INTO [dbo].[AccessDecision] ( - [Id], [LeaseRequestId], [DeciderKind], [ApproverId], [PolicyKind], - [Decision], [Comment], [EvaluationContext], [CreationDate] + [Id], [AccessRequestId], [DeciderKind], [ApproverId], [ConditionKind], + [Verdict], [Comment], [EvaluationContext], [CreationDate] ) VALUES ( - @LeaseDecisionId, @LeaseRequestId, 1 /* Human */, @ApproverId, NULL, - @Decision, @Comment, NULL, @Now + @AccessDecisionId, @AccessRequestId, 1 /* Human */, @ApproverId, NULL, + @Verdict, @Comment, NULL, @Now ) - COMMIT TRANSACTION LeaseRequest_ResolveWithDecision + COMMIT TRANSACTION AccessRequest_Resolve END GO -CREATE OR ALTER PROCEDURE [dbo].[Lease_Revoke] - @LeaseId UNIQUEIDENTIFIER, - @LeaseRequestId UNIQUEIDENTIFIER, +CREATE OR ALTER PROCEDURE [dbo].[AccessLease_Revoke] + @AccessLeaseId UNIQUEIDENTIFIER, + @AccessRequestId UNIQUEIDENTIFIER, @RevokedBy UNIQUEIDENTIFIER, - @LeaseDecisionId UNIQUEIDENTIFIER, + @AccessDecisionId UNIQUEIDENTIFIER, @Reason NVARCHAR(MAX) = NULL, @Now DATETIME2(7) AS BEGIN SET NOCOUNT ON - BEGIN TRANSACTION Lease_Revoke + BEGIN TRANSACTION AccessLease_Revoke - UPDATE [dbo].[Lease] + UPDATE [dbo].[AccessLease] SET [Status] = 2 /* Revoked */, [RevokedDate] = @Now, [RevokedBy] = @RevokedBy - WHERE [Id] = @LeaseId AND [Status] = 0 -- Active + WHERE [Id] = @AccessLeaseId AND [Status] = 0 -- Active - INSERT INTO [dbo].[LeaseDecision] + INSERT INTO [dbo].[AccessDecision] ( - [Id], [LeaseRequestId], [DeciderKind], [ApproverId], [PolicyKind], - [Decision], [Comment], [EvaluationContext], [CreationDate] + [Id], [AccessRequestId], [DeciderKind], [ApproverId], [ConditionKind], + [Verdict], [Comment], [EvaluationContext], [CreationDate] ) VALUES ( - @LeaseDecisionId, @LeaseRequestId, 1 /* Human */, @RevokedBy, NULL, + @AccessDecisionId, @AccessRequestId, 1 /* Human */, @RevokedBy, NULL, 1 /* Deny */, @Reason, NULL, @Now ) - COMMIT TRANSACTION Lease_Revoke + COMMIT TRANSACTION AccessLease_Revoke END GO diff --git a/util/Migrator/DbScripts/2026-06-05_01_CreateAccessLeaseOnApproval.sql b/util/Migrator/DbScripts/2026-06-05_01_CreateAccessLeaseOnApproval.sql new file mode 100644 index 000000000000..9ed76367f7fe --- /dev/null +++ b/util/Migrator/DbScripts/2026-06-05_01_CreateAccessLeaseOnApproval.sql @@ -0,0 +1,60 @@ +-- PAM Credential Leasing: when an approver approves a human request, mint the active lease that authorizes access. +-- Previously [AccessRequest_ResolveWithDecision] only flipped the request to Approved and recorded the decision, so the +-- human path never produced a [AccessLease] and the approved requester could not read the credential. This adds an optional +-- @AccessLeaseId; when supplied (approvals only), the lease is created in the same transaction with the request's approved +-- window, mirroring [AccessLease_CreateAutoApproved] on the automatic path. + +CREATE OR ALTER PROCEDURE [dbo].[AccessRequest_ResolveWithDecision] + @AccessRequestId UNIQUEIDENTIFIER, + @Status TINYINT, + @AccessDecisionId UNIQUEIDENTIFIER, + @ApproverId UNIQUEIDENTIFIER, + @Verdict TINYINT, + @Comment NVARCHAR(MAX) = NULL, + @AccessLeaseId UNIQUEIDENTIFIER = NULL, + @Now DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + -- Atomically resolve a pending request and record the human approver's decision. The caller has already verified + -- (and the application enforces) that the request is still Pending; the WHERE guard keeps the write idempotent + -- under a race so a second approver can't move an already-resolved request. + BEGIN TRANSACTION AccessRequest_Resolve + + UPDATE [dbo].[AccessRequest] + SET [Status] = @Status, + [ResolvedDate] = @Now + WHERE [Id] = @AccessRequestId AND [Status] = 0 -- Pending + + INSERT INTO [dbo].[AccessDecision] + ( + [Id], [AccessRequestId], [DeciderKind], [ApproverId], [ConditionKind], + [Verdict], [Comment], [EvaluationContext], [CreationDate] + ) + VALUES + ( + @AccessDecisionId, @AccessRequestId, 1 /* Human */, @ApproverId, NULL, + @Verdict, @Comment, NULL, @Now + ) + + -- An approval mints the active lease that authorizes access, mirroring [AccessLease_CreateAutoApproved] on the automatic + -- path. @AccessLeaseId is supplied only when approving; the lease window is the request's approved window, so the lease + -- is found by [AccessLease_ReadActiveByRequesterIdCipherId] once @Now falls inside it. + IF @AccessLeaseId IS NOT NULL + BEGIN + INSERT INTO [dbo].[AccessLease] + ( + [Id], [AccessRequestId], [OrganizationId], [CollectionId], [CipherId], [RequesterId], + [Status], [NotBefore], [NotAfter], [RevokedDate], [RevokedBy], [CreationDate] + ) + SELECT + @AccessLeaseId, [Id], [OrganizationId], [CollectionId], [CipherId], [RequesterId], + 0 /* Active */, [NotBefore], [NotAfter], NULL, NULL, @Now + FROM [dbo].[AccessRequest] + WHERE [Id] = @AccessRequestId + END + + COMMIT TRANSACTION AccessRequest_Resolve +END +GO diff --git a/util/Migrator/DbScripts/2026-06-05_01_CreateLeaseOnApproval.sql b/util/Migrator/DbScripts/2026-06-05_01_CreateLeaseOnApproval.sql deleted file mode 100644 index b25130c14a7e..000000000000 --- a/util/Migrator/DbScripts/2026-06-05_01_CreateLeaseOnApproval.sql +++ /dev/null @@ -1,60 +0,0 @@ --- PAM Credential Leasing: when an approver approves a human request, mint the active lease that authorizes access. --- Previously [LeaseRequest_ResolveWithDecision] only flipped the request to Approved and recorded the decision, so the --- human path never produced a [Lease] and the approved requester could not read the credential. This adds an optional --- @LeaseId; when supplied (approvals only), the lease is created in the same transaction with the request's approved --- window, mirroring [Lease_CreateAutoApproved] on the automatic path. - -CREATE OR ALTER PROCEDURE [dbo].[LeaseRequest_ResolveWithDecision] - @LeaseRequestId UNIQUEIDENTIFIER, - @Status TINYINT, - @LeaseDecisionId UNIQUEIDENTIFIER, - @ApproverId UNIQUEIDENTIFIER, - @Decision TINYINT, - @Comment NVARCHAR(MAX) = NULL, - @LeaseId UNIQUEIDENTIFIER = NULL, - @Now DATETIME2(7) -AS -BEGIN - SET NOCOUNT ON - - -- Atomically resolve a pending request and record the human approver's decision. The caller has already verified - -- (and the application enforces) that the request is still Pending; the WHERE guard keeps the write idempotent - -- under a race so a second approver can't move an already-resolved request. - BEGIN TRANSACTION LeaseRequest_ResolveWithDecision - - UPDATE [dbo].[LeaseRequest] - SET [Status] = @Status, - [ResolvedDate] = @Now - WHERE [Id] = @LeaseRequestId AND [Status] = 0 -- Pending - - INSERT INTO [dbo].[LeaseDecision] - ( - [Id], [LeaseRequestId], [DeciderKind], [ApproverId], [PolicyKind], - [Decision], [Comment], [EvaluationContext], [CreationDate] - ) - VALUES - ( - @LeaseDecisionId, @LeaseRequestId, 1 /* Human */, @ApproverId, NULL, - @Decision, @Comment, NULL, @Now - ) - - -- An approval mints the active lease that authorizes access, mirroring [Lease_CreateAutoApproved] on the automatic - -- path. @LeaseId is supplied only when approving; the lease window is the request's approved window, so the lease - -- is found by [Lease_ReadActiveByRequesterIdCipherId] once @Now falls inside it. - IF @LeaseId IS NOT NULL - BEGIN - INSERT INTO [dbo].[Lease] - ( - [Id], [LeaseRequestId], [OrganizationId], [CollectionId], [CipherId], [RequesterId], - [Status], [NotBefore], [NotAfter], [RevokedDate], [RevokedBy], [CreationDate] - ) - SELECT - @LeaseId, [Id], [OrganizationId], [CollectionId], [CipherId], [RequesterId], - 0 /* Active */, [NotBefore], [NotAfter], NULL, NULL, @Now - FROM [dbo].[LeaseRequest] - WHERE [Id] = @LeaseRequestId - END - - COMMIT TRANSACTION LeaseRequest_ResolveWithDecision -END -GO diff --git a/util/Migrator/DbScripts/2026-06-05_02_AddMemberLeaseReadSprocs.sql b/util/Migrator/DbScripts/2026-06-05_02_AddRequesterReadSprocs.sql similarity index 70% rename from util/Migrator/DbScripts/2026-06-05_02_AddMemberLeaseReadSprocs.sql rename to util/Migrator/DbScripts/2026-06-05_02_AddRequesterReadSprocs.sql index a11d09a7f84d..c6310cb95ba2 100644 --- a/util/Migrator/DbScripts/2026-06-05_02_AddMemberLeaseReadSprocs.sql +++ b/util/Migrator/DbScripts/2026-06-05_02_AddRequesterReadSprocs.sql @@ -1,8 +1,8 @@ -- PAM member read endpoints: caller-scoped reads for the cipher-lease banner, vault-row badge, and the -- "My access requests" page. Lease_ReadManyActiveByRequesterId backs "my active leases"; --- LeaseRequest_ReadManyByRequesterId backs "my requests" (all statuses, names omitted — caller-scoped self-read). +-- AccessRequest_ReadManyByRequesterId backs "my requests" (all statuses, names omitted — caller-scoped self-read). -CREATE OR ALTER PROCEDURE [dbo].[Lease_ReadManyActiveByRequesterId] +CREATE OR ALTER PROCEDURE [dbo].[AccessLease_ReadManyActiveByRequesterId] @RequesterId UNIQUEIDENTIFIER, @Now DATETIME2(7) AS @@ -12,7 +12,7 @@ BEGIN SELECT * FROM - [dbo].[Lease] + [dbo].[AccessLease] WHERE [RequesterId] = @RequesterId AND [Status] = 0 -- Active @@ -23,7 +23,7 @@ BEGIN END GO -CREATE OR ALTER PROCEDURE [dbo].[LeaseRequest_ReadManyByRequesterId] +CREATE OR ALTER PROCEDURE [dbo].[AccessRequest_ReadManyByRequesterId] @RequesterId UNIQUEIDENTIFIER AS BEGIN @@ -34,7 +34,7 @@ BEGIN -- (those name fields stay null). Capped at the 250 most recent; the client renders far fewer. SELECT TOP (250) LR.[Id], - LR.[LeaseId] AS [ExtensionOfLeaseId], + LR.[ExtensionOfLeaseId], LR.[OrganizationId], LR.[CollectionId], LR.[CipherId], @@ -46,19 +46,19 @@ BEGIN LR.[CreationDate], LR.[ResolvedDate], PL.[Id] AS [ProducedLeaseId], - RES.[ApproverId] AS [ResolverId], - RES.[Comment] AS [ResolverComment] - FROM [dbo].[LeaseRequest] LR + RES.[ApproverId] AS [ApproverId], + RES.[Comment] AS [ApproverComment] + FROM [dbo].[AccessRequest] LR OUTER APPLY ( SELECT TOP 1 L.[Id] - FROM [dbo].[Lease] L - WHERE L.[LeaseRequestId] = LR.[Id] + FROM [dbo].[AccessLease] L + WHERE L.[AccessRequestId] = LR.[Id] ORDER BY L.[CreationDate] DESC ) PL OUTER APPLY ( SELECT TOP 1 LD.[ApproverId], LD.[Comment] - FROM [dbo].[LeaseDecision] LD - WHERE LD.[LeaseRequestId] = LR.[Id] AND LD.[DeciderKind] = 1 -- Human + FROM [dbo].[AccessDecision] LD + WHERE LD.[AccessRequestId] = LR.[Id] AND LD.[DeciderKind] = 1 -- Human ORDER BY LD.[CreationDate] ASC ) RES WHERE LR.[RequesterId] = @RequesterId diff --git a/util/MySqlMigrations/Migrations/20260526122321_AddAccessRule.Designer.cs b/util/MySqlMigrations/Migrations/20260526122321_AddAccessRule.Designer.cs index 0ee88d7956c7..98519865e331 100644 --- a/util/MySqlMigrations/Migrations/20260526122321_AddAccessRule.Designer.cs +++ b/util/MySqlMigrations/Migrations/20260526122321_AddAccessRule.Designer.cs @@ -2394,7 +2394,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("RevisionDate") .HasColumnType("datetime(6)"); - b.Property("Rule") + b.Property("Conditions") .IsRequired() .HasColumnType("longtext"); diff --git a/util/MySqlMigrations/Migrations/20260526122321_AddAccessRule.cs b/util/MySqlMigrations/Migrations/20260526122321_AddAccessRule.cs index b2742411071d..4b909a234265 100644 --- a/util/MySqlMigrations/Migrations/20260526122321_AddAccessRule.cs +++ b/util/MySqlMigrations/Migrations/20260526122321_AddAccessRule.cs @@ -27,7 +27,7 @@ protected override void Up(MigrationBuilder migrationBuilder) .Annotation("MySql:CharSet", "utf8mb4"), Description = table.Column(type: "longtext", nullable: true) .Annotation("MySql:CharSet", "utf8mb4"), - Rule = table.Column(type: "longtext", nullable: false) + Conditions = table.Column(type: "longtext", nullable: false) .Annotation("MySql:CharSet", "utf8mb4"), CreationDate = table.Column(type: "datetime(6)", nullable: false), RevisionDate = table.Column(type: "datetime(6)", nullable: false) diff --git a/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs b/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs index 3019158d2c35..45dafefe458f 100644 --- a/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs +++ b/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs @@ -2396,7 +2396,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("RevisionDate") .HasColumnType("datetime(6)"); - b.Property("Rule") + b.Property("Conditions") .IsRequired() .HasColumnType("longtext"); diff --git a/util/PostgresMigrations/Migrations/20260526122317_AddAccessRule.Designer.cs b/util/PostgresMigrations/Migrations/20260526122317_AddAccessRule.Designer.cs index 035f3b24d487..e9632299e314 100644 --- a/util/PostgresMigrations/Migrations/20260526122317_AddAccessRule.Designer.cs +++ b/util/PostgresMigrations/Migrations/20260526122317_AddAccessRule.Designer.cs @@ -2400,7 +2400,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("RevisionDate") .HasColumnType("timestamp with time zone"); - b.Property("Rule") + b.Property("Conditions") .IsRequired() .HasColumnType("text"); diff --git a/util/PostgresMigrations/Migrations/20260526122317_AddAccessRule.cs b/util/PostgresMigrations/Migrations/20260526122317_AddAccessRule.cs index 711d81f996f3..5ecdeacbb743 100644 --- a/util/PostgresMigrations/Migrations/20260526122317_AddAccessRule.cs +++ b/util/PostgresMigrations/Migrations/20260526122317_AddAccessRule.cs @@ -24,7 +24,7 @@ protected override void Up(MigrationBuilder migrationBuilder) OrganizationId = table.Column(type: "uuid", nullable: false), Name = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), Description = table.Column(type: "text", nullable: true), - Rule = table.Column(type: "text", nullable: false), + Conditions = table.Column(type: "text", nullable: false), CreationDate = table.Column(type: "timestamp with time zone", nullable: false), RevisionDate = table.Column(type: "timestamp with time zone", nullable: false) }, diff --git a/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs b/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs index 19effc1069df..201da397b823 100644 --- a/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs +++ b/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs @@ -2402,7 +2402,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("RevisionDate") .HasColumnType("timestamp with time zone"); - b.Property("Rule") + b.Property("Conditions") .IsRequired() .HasColumnType("text"); diff --git a/util/SqliteMigrations/Migrations/20260526122325_AddAccessRule.Designer.cs b/util/SqliteMigrations/Migrations/20260526122325_AddAccessRule.Designer.cs index ca7258fdf796..192e8e16a839 100644 --- a/util/SqliteMigrations/Migrations/20260526122325_AddAccessRule.Designer.cs +++ b/util/SqliteMigrations/Migrations/20260526122325_AddAccessRule.Designer.cs @@ -2383,7 +2383,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("RevisionDate") .HasColumnType("TEXT"); - b.Property("Rule") + b.Property("Conditions") .IsRequired() .HasColumnType("TEXT"); diff --git a/util/SqliteMigrations/Migrations/20260526122325_AddAccessRule.cs b/util/SqliteMigrations/Migrations/20260526122325_AddAccessRule.cs index a0bda5678df6..dc621ea9e190 100644 --- a/util/SqliteMigrations/Migrations/20260526122325_AddAccessRule.cs +++ b/util/SqliteMigrations/Migrations/20260526122325_AddAccessRule.cs @@ -24,7 +24,7 @@ protected override void Up(MigrationBuilder migrationBuilder) OrganizationId = table.Column(type: "TEXT", nullable: false), Name = table.Column(type: "TEXT", maxLength: 256, nullable: false), Description = table.Column(type: "TEXT", nullable: true), - Rule = table.Column(type: "TEXT", nullable: false), + Conditions = table.Column(type: "TEXT", nullable: false), CreationDate = table.Column(type: "TEXT", nullable: false), RevisionDate = table.Column(type: "TEXT", nullable: false) }, diff --git a/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs b/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs index 743df2bf70d9..f307e3197a3a 100644 --- a/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs +++ b/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs @@ -2385,7 +2385,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("RevisionDate") .HasColumnType("TEXT"); - b.Property("Rule") + b.Property("Conditions") .IsRequired() .HasColumnType("TEXT"); From 49962b5c025a8c9de0c87cd81133c5c97ed2f714 Mon Sep 17 00:00:00 2001 From: Patrik Svensson Date: Wed, 10 Jun 2026 23:31:03 +0200 Subject: [PATCH 17/54] Defer lease minting from approval to requester activation Approving an access request now records only the verdict; the lease that authorizes access is minted when the requester activates the approved request (POST leasing/requests/{id}/activate), within its window. The automatic path still mints instantly at submit. Activation is idempotent while the produced lease is live, race-safe via a guarded insert plus a unique index on AccessLease(AccessRequestId), and the state endpoint now surfaces the approved-but-not-started request. The caller-scoped list endpoints also wrap in ListResponseModel so clients can parse them. --- .../Pam/Controllers/CipherLeaseController.cs | 5 +- .../Controllers/MemberLeasingController.cs | 42 +++- .../AccessRequestDetailsResponseModel.cs | 8 +- .../CipherAccessStateResponseModel.cs | 12 +- ...OrganizationServiceCollectionExtensions.cs | 1 + src/Core/Pam/Entities/AccessRequest.cs | 4 +- src/Core/Pam/Models/AccessRequestDetails.cs | 2 +- src/Core/Pam/Models/CipherAccessState.cs | 14 +- .../Commands/ActivateAccessRequestCommand.cs | 106 +++++++++ .../Commands/DecideAccessRequestCommand.cs | 33 +-- .../IActivateAccessRequestCommand.cs | 23 ++ .../Interfaces/IDecideAccessRequestCommand.cs | 9 +- .../Commands/SubmitAccessRequestCommand.cs | 7 + .../Queries/GetCipherAccessStateQuery.cs | 18 +- .../Repositories/IAccessLeaseRepository.cs | 15 +- .../Repositories/IAccessRequestRepository.cs | 17 +- .../Pam/Repositories/AccessLeaseRepository.cs | 37 ++++ .../Repositories/AccessRequestRepository.cs | 14 +- .../AccessLease_CreateFromApprovedRequest.sql | 33 +++ .../AccessLease_ReadByAccessRequestId.sql | 16 ++ ...eadActiveApprovedByRequesterIdCipherId.sql | 24 ++ .../AccessRequest_ResolveWithDecision.sql | 22 +- src/Sql/dbo/Pam/Tables/AccessLease.sql | 6 + .../Controllers/CipherLeaseControllerTests.cs | 4 +- .../MemberLeasingControllerTests.cs | 25 ++- .../ActivateAccessRequestCommandTests.cs | 209 ++++++++++++++++++ .../DecideAccessRequestCommandTests.cs | 59 +++-- .../SubmitAccessRequestCommandTests.cs | 20 ++ .../Queries/GetCipherAccessStateQueryTests.cs | 49 ++++ .../AccessLeaseRepositoryTests.cs | 127 +++++++++++ .../AccessRequestRepositoryTests.cs | 109 +++++++-- ...10_00_DeferAccessLeaseMintToActivation.sql | 158 +++++++++++++ 32 files changed, 1101 insertions(+), 127 deletions(-) create mode 100644 src/Core/Pam/OrganizationFeatures/Commands/ActivateAccessRequestCommand.cs create mode 100644 src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IActivateAccessRequestCommand.cs create mode 100644 src/Sql/dbo/Pam/Stored Procedures/AccessLease_CreateFromApprovedRequest.sql create mode 100644 src/Sql/dbo/Pam/Stored Procedures/AccessLease_ReadByAccessRequestId.sql create mode 100644 src/Sql/dbo/Pam/Stored Procedures/AccessRequest_ReadActiveApprovedByRequesterIdCipherId.sql create mode 100644 test/Core.Test/Pam/Commands/ActivateAccessRequestCommandTests.cs create mode 100644 util/Migrator/DbScripts/2026-06-10_00_DeferAccessLeaseMintToActivation.sql diff --git a/src/Api/Pam/Controllers/CipherLeaseController.cs b/src/Api/Pam/Controllers/CipherLeaseController.cs index 7ee7d6ebda04..8b25c940e6c2 100644 --- a/src/Api/Pam/Controllers/CipherLeaseController.cs +++ b/src/Api/Pam/Controllers/CipherLeaseController.cs @@ -41,8 +41,9 @@ public async Task PreCheck(Guid id) } /// - /// Returns a single snapshot of the caller's lease state for this cipher — their active lease and pending request, - /// if any — powering the cipher-view banner and the vault-row badge. Side-effect free. + /// Returns a single snapshot of the caller's lease state for this cipher — their active lease, pending request, + /// and approved-but-not-yet-activated request, if any — powering the cipher-view banner and the vault-row badge. + /// Side-effect free. /// [HttpGet("state")] public async Task State(Guid id) diff --git a/src/Api/Pam/Controllers/MemberLeasingController.cs b/src/Api/Pam/Controllers/MemberLeasingController.cs index 84c7a17e7114..afe49fdaecdd 100644 --- a/src/Api/Pam/Controllers/MemberLeasingController.cs +++ b/src/Api/Pam/Controllers/MemberLeasingController.cs @@ -1,5 +1,7 @@ -using Bit.Api.Pam.Models.Response; +using Bit.Api.Models.Response; +using Bit.Api.Pam.Models.Response; using Bit.Core; +using Bit.Core.Pam.OrganizationFeatures.Commands.Interfaces; using Bit.Core.Pam.OrganizationFeatures.Queries.Interfaces; using Bit.Core.Services; using Bit.Core.Utilities; @@ -9,9 +11,9 @@ namespace Bit.Api.Pam.Controllers; /// -/// Caller-scoped leasing reads: a user's own access requests and active leases, spanning every organization they -/// belong to. Distinct from the approver-facing surface on . Both share the -/// leasing route prefix; the templates don't overlap. +/// Caller-scoped leasing surface: a user's own access requests and active leases, spanning every organization they +/// belong to, plus activation of their approved requests. Distinct from the approver-facing surface on +/// . Both share the leasing route prefix; the templates don't overlap. /// [Route("leasing")] [Authorize("Application")] @@ -19,29 +21,45 @@ namespace Bit.Api.Pam.Controllers; public class MemberLeasingController( IUserService userService, IListMyAccessRequestsQuery listMyAccessRequestsQuery, - IListMyActiveAccessLeasesQuery listMyActiveAccessLeasesQuery) + IListMyActiveAccessLeasesQuery listMyActiveAccessLeasesQuery, + IActivateAccessRequestCommand activateAccessRequestCommand) : Controller { /// - /// Returns the caller's own access requests across all their organizations, regardless of status, as a plain - /// array. The client re-sorts and splits into pending/recent. + /// Returns the caller's own access requests across all their organizations, regardless of status. The client + /// re-sorts and splits into pending/recent. /// [HttpGet("requests/mine")] - public async Task> GetMyRequests() + public async Task> GetMyRequests() { var userId = userService.GetProperUserId(User)!.Value; var requests = await listMyAccessRequestsQuery.GetMineAsync(userId); - return requests.Select(r => new AccessRequestDetailsResponseModel(r)); + return new ListResponseModel( + requests.Select(r => new AccessRequestDetailsResponseModel(r))); } /// - /// Returns the caller's currently-active leases across all their organizations as a plain array. + /// Returns the caller's currently-active leases across all their organizations. /// [HttpGet("leases/mine/active")] - public async Task> GetMyActiveLeases() + public async Task> GetMyActiveLeases() { var userId = userService.GetProperUserId(User)!.Value; var leases = await listMyActiveAccessLeasesQuery.GetMineActiveAsync(userId); - return leases.Select(l => new AccessLeaseResponseModel(l)); + return new ListResponseModel( + leases.Select(l => new AccessLeaseResponseModel(l))); + } + + /// + /// Activates the caller's approved access request: mints the lease that authorizes access, spanning the + /// request's approved window. Only the requester may activate, and only while the window is open. Repeat calls + /// while the produced lease is live return that lease. + /// + [HttpPost("requests/{id:guid}/activate")] + public async Task Activate(Guid id) + { + var userId = userService.GetProperUserId(User)!.Value; + var lease = await activateAccessRequestCommand.ActivateAsync(userId, id); + return new AccessLeaseResponseModel(lease); } } diff --git a/src/Api/Pam/Models/Response/AccessRequestDetailsResponseModel.cs b/src/Api/Pam/Models/Response/AccessRequestDetailsResponseModel.cs index 1ba2fe91d4ba..3bf153fff3c5 100644 --- a/src/Api/Pam/Models/Response/AccessRequestDetailsResponseModel.cs +++ b/src/Api/Pam/Models/Response/AccessRequestDetailsResponseModel.cs @@ -6,7 +6,7 @@ namespace Bit.Api.Pam.Models.Response; /// /// An access request with its denormalized display fields (cipher/collection names, requester identity), serving the /// approver inbox, the caller's own request list, and the cipher access-state snapshot. Fields without a backing -/// store in v1 (, , ) are always null. +/// store in v1 (, ) are always null. /// public class AccessRequestDetailsResponseModel : ResponseModel { @@ -71,7 +71,11 @@ public AccessRequestDetailsResponseModel(AccessRequestDetails details) /// The parent lease if this is an extension request. public Guid? ExtensionOfLeaseId { get; } - /// Only meaningful for approved on-demand requests. Belongs to the out-of-scope activation flow. + /// + /// Always null in v0: human requests are window-bound, so itself bounds when an + /// approved request can be activated. A separate deadline only becomes meaningful with on-demand (duration-only) + /// human requests, which don't exist yet. + /// public DateTime? ActivationDeadline => null; /// The cipher's client-encrypted name. The only cipher attribute exposed by the inbox. diff --git a/src/Api/Pam/Models/Response/CipherAccessStateResponseModel.cs b/src/Api/Pam/Models/Response/CipherAccessStateResponseModel.cs index dcc9e6abf0ee..9bd94504116c 100644 --- a/src/Api/Pam/Models/Response/CipherAccessStateResponseModel.cs +++ b/src/Api/Pam/Models/Response/CipherAccessStateResponseModel.cs @@ -5,8 +5,8 @@ namespace Bit.Api.Pam.Models.Response; /// /// A single-snapshot read of the caller's access state for one cipher, powering the cipher-view banner and the -/// vault-row badge. is always null in v0: approval mints an active lease immediately, -/// so there is no approved-but-not-yet-activated request. The active lease and pending request carry the real state. +/// vault-row badge. At most one of the three branches is meaningfully "next": an active lease authorizes access, a +/// pending request awaits a decision, and an approved request awaits activation by the caller. /// public class CipherAccessStateResponseModel : ResponseModel { @@ -18,6 +18,7 @@ public CipherAccessStateResponseModel(CipherAccessState state) CipherId = state.CipherId; ActiveLease = state.ActiveLease is null ? null : new AccessLeaseResponseModel(state.ActiveLease); PendingRequest = state.PendingRequest is null ? null : new AccessRequestDetailsResponseModel(state.PendingRequest); + ApprovedRequest = state.ApprovedRequest is null ? null : new AccessRequestDetailsResponseModel(state.ApprovedRequest); } public Guid CipherId { get; } @@ -25,6 +26,9 @@ public CipherAccessStateResponseModel(CipherAccessState state) public AccessLeaseResponseModel? ActiveLease { get; } public AccessRequestDetailsResponseModel? PendingRequest { get; } - /// An approved request awaiting activation. Always null in v0 — approval mints the lease immediately. - public AccessRequestDetailsResponseModel? ApprovedRequest => null; + /// + /// An approved request awaiting activation, with a window that can still produce access. The caller activates it + /// to mint the lease; lapsed approvals are never surfaced here. + /// + public AccessRequestDetailsResponseModel? ApprovedRequest { get; } } diff --git a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs index b03ed093aa9d..260dbfe5e230 100644 --- a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs +++ b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs @@ -210,6 +210,7 @@ public static void AddPamServices(this IServiceCollection services) services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/Core/Pam/Entities/AccessRequest.cs b/src/Core/Pam/Entities/AccessRequest.cs index 1ee47ea05ebc..f79448eb26c6 100644 --- a/src/Core/Pam/Entities/AccessRequest.cs +++ b/src/Core/Pam/Entities/AccessRequest.cs @@ -7,7 +7,9 @@ namespace Bit.Core.Pam.Entities; /// /// A request to lease access to a cipher in a leasing-governed collection. Auto-approved requests are created /// already alongside an active ; requests that require -/// human approval are created and resolved later by an approver. +/// human approval are created and resolved later by an approver. A human +/// approval does not mint the lease — the requester activates the approved request within its window, and that +/// activation produces the . /// public class AccessRequest : ITableObject { diff --git a/src/Core/Pam/Models/AccessRequestDetails.cs b/src/Core/Pam/Models/AccessRequestDetails.cs index 35aa9add1192..d5390d356beb 100644 --- a/src/Core/Pam/Models/AccessRequestDetails.cs +++ b/src/Core/Pam/Models/AccessRequestDetails.cs @@ -26,7 +26,7 @@ public class AccessRequestDetails public DateTime CreationDate { get; set; } public DateTime? ResolvedDate { get; set; } - /// The lease this request birthed once redeemed, or null if it has not produced a lease. + /// The lease this request produced once activated, or null if it has not produced a lease. public Guid? ProducedLeaseId { get; set; } /// The human approver who resolved the request, or null (e.g. still pending or auto-resolved). diff --git a/src/Core/Pam/Models/CipherAccessState.cs b/src/Core/Pam/Models/CipherAccessState.cs index c3af393c378f..3813f0e2dd5d 100644 --- a/src/Core/Pam/Models/CipherAccessState.cs +++ b/src/Core/Pam/Models/CipherAccessState.cs @@ -1,10 +1,14 @@ -using Bit.Core.Pam.Entities; +using Bit.Core.Pam.Entities; namespace Bit.Core.Pam.Models; /// -/// The caller's access state for a single cipher: the active lease they hold (if any) and their pending request (if -/// any). The approved-but-not-yet-activated request the client models has no server counterpart in v0 — approval -/// mints an active lease immediately — so it is always absent here. +/// The caller's access state for a single cipher: the active lease they hold (if any), their pending request (if +/// any), and their approved-but-not-yet-activated request (if any). Approval no longer mints the lease, so the +/// approved request is the startable state in between — the caller activates it to produce the active lease. /// -public record CipherAccessState(Guid CipherId, AccessLease? ActiveLease, AccessRequestDetails? PendingRequest); +public record CipherAccessState( + Guid CipherId, + AccessLease? ActiveLease, + AccessRequestDetails? PendingRequest, + AccessRequestDetails? ApprovedRequest); diff --git a/src/Core/Pam/OrganizationFeatures/Commands/ActivateAccessRequestCommand.cs b/src/Core/Pam/OrganizationFeatures/Commands/ActivateAccessRequestCommand.cs new file mode 100644 index 000000000000..e99e6e3b82ed --- /dev/null +++ b/src/Core/Pam/OrganizationFeatures/Commands/ActivateAccessRequestCommand.cs @@ -0,0 +1,106 @@ +using Bit.Core.Exceptions; +using Bit.Core.Pam.Entities; +using Bit.Core.Pam.Enums; +using Bit.Core.Pam.OrganizationFeatures.Commands.Interfaces; +using Bit.Core.Pam.Repositories; +using Bit.Core.Pam.Services; + +namespace Bit.Core.Pam.OrganizationFeatures.Commands; + +public class ActivateAccessRequestCommand : IActivateAccessRequestCommand +{ + private readonly IAccessRequestRepository _accessRequestRepository; + private readonly IAccessLeaseRepository _accessLeaseRepository; + private readonly IApproverInboxNotifier _approverInboxNotifier; + private readonly TimeProvider _timeProvider; + + public ActivateAccessRequestCommand( + IAccessRequestRepository accessRequestRepository, + IAccessLeaseRepository accessLeaseRepository, + IApproverInboxNotifier approverInboxNotifier, + TimeProvider timeProvider) + { + _accessRequestRepository = accessRequestRepository; + _accessLeaseRepository = accessLeaseRepository; + _approverInboxNotifier = approverInboxNotifier; + _timeProvider = timeProvider; + } + + public async Task ActivateAsync(Guid userId, Guid requestId) + { + var request = await _accessRequestRepository.GetByIdAsync(requestId); + + // 404 for both missing and someone else's request, so the caller can't probe for requests they don't own. + if (request is null || request.RequesterId != userId) + { + throw new NotFoundException(); + } + + var now = _timeProvider.GetUtcNow().UtcDateTime; + + // Activation is idempotent while the produced lease is live (double-click, a second tab racing the + // auto-activating open flow); a revoked or lapsed lease is final — a request authorizes access at most once. + var existing = await _accessLeaseRepository.GetByAccessRequestIdAsync(request.Id); + if (existing is not null) + { + if (existing.Status == AccessLeaseStatus.Active && existing.NotAfter > now) + { + return existing; + } + throw new ConflictException("This request's access has already been used and is no longer active."); + } + + if (request.Status != AccessRequestStatus.Approved) + { + throw new ConflictException(request.Status == AccessRequestStatus.Pending + ? "This request has not been approved yet." + : "This request can no longer be activated."); + } + + if (request.NotBefore > now) + { + throw new BadRequestException("The approved access window has not started yet."); + } + + if (request.NotAfter <= now) + { + throw new BadRequestException("The approved access window has already ended."); + } + + var lease = new AccessLease + { + AccessRequestId = request.Id, + OrganizationId = request.OrganizationId, + CollectionId = request.CollectionId, + CipherId = request.CipherId, + RequesterId = request.RequesterId, + Status = AccessLeaseStatus.Active, + // Activation mints the window the approver approved, exactly as the old approval-time path did; the + // creation date is the activation audit timestamp (no decision row is written — approval was the + // decision). + NotBefore = request.NotBefore, + NotAfter = request.NotAfter, + CreationDate = now, + }; + lease.SetNewId(); + + if (!await _accessLeaseRepository.CreateFromApprovedRequestAsync(lease, now)) + { + // Lost a race: the guarded insert re-checks every precondition, so a miss means another activation won + // or the request changed underneath us. If the winner's lease is live, activation still succeeded from + // this caller's point of view. + var winner = await _accessLeaseRepository.GetByAccessRequestIdAsync(request.Id); + if (winner is { Status: AccessLeaseStatus.Active } && winner.NotAfter > now) + { + return winner; + } + throw new ConflictException("This request can no longer be activated."); + } + + // The approver's history row just flipped approved -> activated and gained a revocable lease; tell every + // approver of this collection to re-fetch, mirroring decide and revoke. + await _approverInboxNotifier.NotifyCollectionApproversAsync(request.CollectionId); + + return lease; + } +} diff --git a/src/Core/Pam/OrganizationFeatures/Commands/DecideAccessRequestCommand.cs b/src/Core/Pam/OrganizationFeatures/Commands/DecideAccessRequestCommand.cs index b8648d48341f..d654d648c977 100644 --- a/src/Core/Pam/OrganizationFeatures/Commands/DecideAccessRequestCommand.cs +++ b/src/Core/Pam/OrganizationFeatures/Commands/DecideAccessRequestCommand.cs @@ -53,6 +53,13 @@ public async Task DecideAsync(Guid userId, Guid requestId, var approved = submission.Verdict == AccessDecisionVerdict.Approve; var status = approved ? AccessRequestStatus.Approved : AccessRequestStatus.Denied; + // An approval the requester can never activate (the requested window already ended) would only mint a dead + // "approved" state, so reject it. Denial is still allowed so the audit trail can close the request out. + if (approved && request.NotAfter <= now) + { + throw new BadRequestException("The requested access window has already ended."); + } + var decision = new AccessDecision { AccessRequestId = request.Id, @@ -64,28 +71,10 @@ public async Task DecideAsync(Guid userId, Guid requestId, }; decision.SetNewId(); - // Approval mints the active lease that actually authorizes access, spanning the request's approved window. - // Without it the requester would be Approved but hold no lease, so pre-check and the cipher read would both - // deny them. A denial creates no lease. - AccessLease? lease = null; - if (approved) - { - lease = new AccessLease - { - AccessRequestId = request.Id, - OrganizationId = request.OrganizationId, - CollectionId = request.CollectionId, - CipherId = request.CipherId, - RequesterId = request.RequesterId, - Status = AccessLeaseStatus.Active, - NotBefore = request.NotBefore, - NotAfter = request.NotAfter, - CreationDate = now, - }; - lease.SetNewId(); - } - - await _accessRequestRepository.ResolveWithDecisionAsync(request, decision, status, lease, now); + // Approval records the verdict only. The lease that actually authorizes access is minted when the requester + // activates the approved request (ActivateAccessRequestCommand) — until then they hold a startable approval, + // not access. The automatic path still mints instantly at submit, where the requester is present and asking. + await _accessRequestRepository.ResolveWithDecisionAsync(request, decision, status, now); // The request just left the pending queue; tell every approver of this collection to re-fetch. await _approverInboxNotifier.NotifyCollectionApproversAsync(request.CollectionId); diff --git a/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IActivateAccessRequestCommand.cs b/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IActivateAccessRequestCommand.cs new file mode 100644 index 000000000000..b327a20fd0a5 --- /dev/null +++ b/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IActivateAccessRequestCommand.cs @@ -0,0 +1,23 @@ +using Bit.Core.Pam.Entities; + +namespace Bit.Core.Pam.OrganizationFeatures.Commands.Interfaces; + +public interface IActivateAccessRequestCommand +{ + /// + /// Activates the caller's approved access request: mints the active lease that authorizes access, spanning the + /// request's approved window. Only the requester may activate, and only while the window is open. Activation is + /// idempotent while the produced lease is live — a repeat call returns the existing lease. + /// + /// + /// The request does not exist or the caller is not its requester. + /// + /// + /// The request is not approved (still pending, or denied/cancelled/expired), or it already produced a lease that + /// has since been revoked or has lapsed — a request authorizes access at most once. + /// + /// + /// The approved window has not started yet or has already ended. + /// + Task ActivateAsync(Guid userId, Guid requestId); +} diff --git a/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IDecideAccessRequestCommand.cs b/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IDecideAccessRequestCommand.cs index faa9224deff7..6f50c021911a 100644 --- a/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IDecideAccessRequestCommand.cs +++ b/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IDecideAccessRequestCommand.cs @@ -6,15 +6,18 @@ public interface IDecideAccessRequestCommand { /// /// Approves or denies a pending lease request on behalf of an approver. The caller must be able to Manage the - /// request's collection and must not be the requester. Returns the updated inbox row. + /// request's collection and must not be the requester. An approval does not mint the lease — the requester + /// activates the approved request when they access the item. Returns the updated inbox row. /// /// /// The request does not exist or the caller cannot Manage its collection. /// /// The request is no longer pending. /// - /// The caller is the requester (self-approval). The spec calls for 403 here, but Bitwarden clients treat 403 as a - /// forced logout, so this is surfaced as 400 — matching the existing convention in the Admin Console controllers. + /// The caller is the requester (self-approval), or the verdict is an approval but the requested window has + /// already ended (the requester could never activate it). Self-approval per the spec calls for 403, but + /// Bitwarden clients treat 403 as a forced logout, so this is surfaced as 400 — matching the existing convention + /// in the Admin Console controllers. /// Task DecideAsync(Guid userId, Guid requestId, AccessDecisionSubmission submission); } diff --git a/src/Core/Pam/OrganizationFeatures/Commands/SubmitAccessRequestCommand.cs b/src/Core/Pam/OrganizationFeatures/Commands/SubmitAccessRequestCommand.cs index 407717f38b4d..368838c883e3 100644 --- a/src/Core/Pam/OrganizationFeatures/Commands/SubmitAccessRequestCommand.cs +++ b/src/Core/Pam/OrganizationFeatures/Commands/SubmitAccessRequestCommand.cs @@ -75,6 +75,13 @@ public async Task SubmitAsync(Guid userId, Guid cipherId, A throw new BadRequestException("You already have a pending request for this item."); } + // An approved-but-not-yet-activated request already grants startable access; a second request would let the + // caller stack grants. Lapsed approvals don't match here, so they correctly don't block a fresh request. + if (await _accessRequestRepository.GetActiveApprovedByRequesterIdCipherIdAsync(userId, cipherId, now) is not null) + { + throw new BadRequestException("You already have an approved request for this item."); + } + return governingRule.RequiresHumanApproval ? await RequestHumanApprovalAsync(userId, cipherId, governingRule, submission) : await IssueAutomaticLeaseAsync(userId, cipherId, governingRule, submission, now); diff --git a/src/Core/Pam/OrganizationFeatures/Queries/GetCipherAccessStateQuery.cs b/src/Core/Pam/OrganizationFeatures/Queries/GetCipherAccessStateQuery.cs index 206f0e964968..58dcf7a3e14c 100644 --- a/src/Core/Pam/OrganizationFeatures/Queries/GetCipherAccessStateQuery.cs +++ b/src/Core/Pam/OrganizationFeatures/Queries/GetCipherAccessStateQuery.cs @@ -42,19 +42,27 @@ public async Task GetStateAsync(Guid userId, Guid cipherId) var now = _timeProvider.GetUtcNow().UtcDateTime; var activeLease = await _accessLeaseRepository.GetActiveByRequesterIdCipherIdAsync(userId, cipherId, now); var pending = await _accessRequestRepository.GetActivePendingByRequesterIdCipherIdAsync(userId, cipherId); + var approved = await _accessRequestRepository.GetActiveApprovedByRequesterIdCipherIdAsync(userId, cipherId, now); // 404 when the cipher isn't leasing-gated and there's nothing to report. We still return a snapshot when the - // caller holds a lease or a pending request even if the rule was since removed, so their state isn't hidden. - if (activeLease is null && pending is null && await _resolver.ResolveAsync(userId, cipherId) is null) + // caller holds a lease, a pending request, or a startable approval even if the rule was since removed, so + // their state isn't hidden. + if (activeLease is null && pending is null && approved is null + && await _resolver.ResolveAsync(userId, cipherId) is null) { throw new NotFoundException(); } - return new CipherAccessState(cipherId, activeLease, pending is null ? null : ToDetails(pending)); + return new CipherAccessState( + cipherId, + activeLease, + pending is null ? null : ToDetails(pending), + approved is null ? null : ToDetails(approved)); } - // A pending request has produced no lease and has no resolver yet; the inbox display-name fields aren't needed for - // this caller-scoped snapshot, so they stay null. + // Neither a pending nor an approved-unactivated request has produced a lease (the approved read excludes + // activated rows), and the approver identity/comment and inbox display-name fields aren't needed for this + // caller-scoped snapshot, so they stay null. private static AccessRequestDetails ToDetails(AccessRequest request) => new() { Id = request.Id, diff --git a/src/Core/Pam/Repositories/IAccessLeaseRepository.cs b/src/Core/Pam/Repositories/IAccessLeaseRepository.cs index fca9ada8c0f6..b709b50afe9e 100644 --- a/src/Core/Pam/Repositories/IAccessLeaseRepository.cs +++ b/src/Core/Pam/Repositories/IAccessLeaseRepository.cs @@ -6,6 +6,11 @@ public interface IAccessLeaseRepository { Task GetByIdAsync(Guid id); + /// + /// Returns the lease the request produced (whatever its status), or null if the request has not been activated. + /// + Task GetByAccessRequestIdAsync(Guid accessRequestId); + /// /// Returns the caller's active lease for the cipher whose window contains , or null. /// @@ -20,10 +25,18 @@ public interface IAccessLeaseRepository /// /// Atomically creates an auto-approved , its automatic , and an /// active in a single transaction. The three entities must already have their ids assigned. - /// This is the only way a is created, so the request, decision, and lease never diverge. + /// The automatic path's request, decision, and lease never diverge because they are written together here. /// Task CreateAutoApprovedAsync(AccessRequest request, AccessDecision decision, AccessLease lease, DateTime now); + /// + /// Race-safely mints the active lease for an approved human request, copying the request's window. The insert + /// re-checks ownership, Approved status, an open window, and that the request has not already produced a lease; + /// returns false when any precondition no longer holds (e.g. a concurrent activation won). The lease must + /// already have its id assigned. + /// + Task CreateFromApprovedRequestAsync(AccessLease lease, DateTime now); + /// /// Atomically revokes an active lease (setting its revoked date and revoker) and records the revocation reason as /// a human against the lease's originating request. The decision must already diff --git a/src/Core/Pam/Repositories/IAccessRequestRepository.cs b/src/Core/Pam/Repositories/IAccessRequestRepository.cs index a04d8579b3bb..e6e90b3165ca 100644 --- a/src/Core/Pam/Repositories/IAccessRequestRepository.cs +++ b/src/Core/Pam/Repositories/IAccessRequestRepository.cs @@ -15,6 +15,13 @@ public interface IAccessRequestRepository /// Task GetActivePendingByRequesterIdCipherIdAsync(Guid requesterId, Guid cipherId); + /// + /// Returns the caller's approved-but-not-yet-activated request for the cipher whose window has not lapsed + /// (NotAfter after ), or null. Future windows are included so the client can show the + /// upcoming window; a request that has produced a lease is activated, not approved, and is excluded. + /// + Task GetActiveApprovedByRequesterIdCipherIdAsync(Guid requesterId, Guid cipherId, DateTime now); + /// /// Returns the caller's own lease requests across every organization they belong to, regardless of status, most /// recent first and capped server-side. Display-name fields are not populated for this caller-scoped surface. @@ -35,10 +42,10 @@ public interface IAccessRequestRepository Task> GetManyInboxHistoryByCollectionIdsAsync(IEnumerable collectionIds, DateTime since); /// - /// Atomically transitions a pending request to (setting its resolved date), records the - /// approver's human , and — on approval — creates the active - /// that authorizes access, spanning the request's approved window. Pass as null when - /// denying. Every supplied entity must already have its id assigned. + /// Atomically transitions a pending request to (setting its resolved date) and records + /// the approver's human . No lease is created here: the requester activates an + /// approved request later via . Both supplied + /// entities must already have their ids assigned. /// - Task ResolveWithDecisionAsync(AccessRequest request, AccessDecision decision, AccessRequestStatus status, AccessLease? lease, DateTime now); + Task ResolveWithDecisionAsync(AccessRequest request, AccessDecision decision, AccessRequestStatus status, DateTime now); } diff --git a/src/Infrastructure.Dapper/Pam/Repositories/AccessLeaseRepository.cs b/src/Infrastructure.Dapper/Pam/Repositories/AccessLeaseRepository.cs index d28333c751f2..a461cfa6021f 100644 --- a/src/Infrastructure.Dapper/Pam/Repositories/AccessLeaseRepository.cs +++ b/src/Infrastructure.Dapper/Pam/Repositories/AccessLeaseRepository.cs @@ -20,6 +20,17 @@ public AccessLeaseRepository(string connectionString, string readOnlyConnectionS : base(connectionString, readOnlyConnectionString) { } + public async Task GetByAccessRequestIdAsync(Guid accessRequestId) + { + await using var connection = new SqlConnection(ConnectionString); + var results = await connection.QueryAsync( + $"[{Schema}].[AccessLease_ReadByAccessRequestId]", + new { AccessRequestId = accessRequestId }, + commandType: CommandType.StoredProcedure); + + return results.FirstOrDefault(); + } + public async Task GetActiveByRequesterIdCipherIdAsync(Guid requesterId, Guid cipherId, DateTime now) { await using var connection = new SqlConnection(ConnectionString); @@ -65,6 +76,32 @@ await connection.ExecuteAsync( commandType: CommandType.StoredProcedure); } + public async Task CreateFromApprovedRequestAsync(AccessLease lease, DateTime now) + { + await using var connection = new SqlConnection(ConnectionString); + try + { + var rows = await connection.ExecuteScalarAsync( + $"[{Schema}].[AccessLease_CreateFromApprovedRequest]", + new + { + AccessLeaseId = lease.Id, + lease.AccessRequestId, + lease.RequesterId, + Now = now, + }, + commandType: CommandType.StoredProcedure); + + return rows == 1; + } + catch (SqlException e) when (e.Number is 2601 or 2627) + { + // Unique-index backstop ([IX_AccessLease_AccessRequestId]): a concurrent activation won the race after + // our NOT EXISTS guard passed. Same outcome as the guard catching it — the caller re-reads the winner. + return false; + } + } + public async Task RevokeAsync(AccessLease lease, AccessDecision auditDecision, DateTime now) { await using var connection = new SqlConnection(ConnectionString); diff --git a/src/Infrastructure.Dapper/Pam/Repositories/AccessRequestRepository.cs b/src/Infrastructure.Dapper/Pam/Repositories/AccessRequestRepository.cs index aac27f1f93c8..47381fbf4794 100644 --- a/src/Infrastructure.Dapper/Pam/Repositories/AccessRequestRepository.cs +++ b/src/Infrastructure.Dapper/Pam/Repositories/AccessRequestRepository.cs @@ -33,6 +33,17 @@ public AccessRequestRepository(string connectionString, string readOnlyConnectio return results.FirstOrDefault(); } + public async Task GetActiveApprovedByRequesterIdCipherIdAsync(Guid requesterId, Guid cipherId, DateTime now) + { + await using var connection = new SqlConnection(ConnectionString); + var results = await connection.QueryAsync( + $"[{Schema}].[AccessRequest_ReadActiveApprovedByRequesterIdCipherId]", + new { RequesterId = requesterId, CipherId = cipherId, Now = now }, + commandType: CommandType.StoredProcedure); + + return results.FirstOrDefault(); + } + public async Task> GetManyByRequesterIdAsync(Guid requesterId) { await using var connection = new SqlConnection(ConnectionString); @@ -78,7 +89,7 @@ public async Task> GetManyInboxHistoryByCollec return results.ToList(); } - public async Task ResolveWithDecisionAsync(AccessRequest request, AccessDecision decision, AccessRequestStatus status, AccessLease? lease, DateTime now) + public async Task ResolveWithDecisionAsync(AccessRequest request, AccessDecision decision, AccessRequestStatus status, DateTime now) { await using var connection = new SqlConnection(ConnectionString); await connection.ExecuteAsync( @@ -91,7 +102,6 @@ await connection.ExecuteAsync( ApproverId = decision.ApproverId, Verdict = decision.Verdict, decision.Comment, - AccessLeaseId = lease?.Id, Now = now, }, commandType: CommandType.StoredProcedure); diff --git a/src/Sql/dbo/Pam/Stored Procedures/AccessLease_CreateFromApprovedRequest.sql b/src/Sql/dbo/Pam/Stored Procedures/AccessLease_CreateFromApprovedRequest.sql new file mode 100644 index 000000000000..9ac5042e1c69 --- /dev/null +++ b/src/Sql/dbo/Pam/Stored Procedures/AccessLease_CreateFromApprovedRequest.sql @@ -0,0 +1,33 @@ +CREATE PROCEDURE [dbo].[AccessLease_CreateFromApprovedRequest] + @AccessLeaseId UNIQUEIDENTIFIER, + @AccessRequestId UNIQUEIDENTIFIER, + @RequesterId UNIQUEIDENTIFIER, + @Now DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + -- Activation of an approved request: mints the active lease that authorizes access, spanning the request's + -- approved window. Every application-level precondition is re-checked inside the INSERT so a concurrent + -- activation cannot double-mint; zero rows inserted means a precondition no longer held and the caller decides + -- how to surface that. [IX_AccessLease_AccessRequestId] (unique) is the backstop when two calls pass the + -- NOT EXISTS check simultaneously. + INSERT INTO [dbo].[AccessLease] + ( + [Id], [AccessRequestId], [OrganizationId], [CollectionId], [CipherId], [RequesterId], + [Status], [NotBefore], [NotAfter], [RevokedDate], [RevokedBy], [CreationDate] + ) + SELECT + @AccessLeaseId, AR.[Id], AR.[OrganizationId], AR.[CollectionId], AR.[CipherId], AR.[RequesterId], + 0 /* Active */, AR.[NotBefore], AR.[NotAfter], NULL, NULL, @Now + FROM [dbo].[AccessRequest] AR + WHERE + AR.[Id] = @AccessRequestId + AND AR.[RequesterId] = @RequesterId + AND AR.[Status] = 1 -- Approved + AND AR.[NotBefore] <= @Now + AND AR.[NotAfter] > @Now + AND NOT EXISTS (SELECT 1 FROM [dbo].[AccessLease] AL WHERE AL.[AccessRequestId] = AR.[Id]) + + SELECT @@ROWCOUNT +END diff --git a/src/Sql/dbo/Pam/Stored Procedures/AccessLease_ReadByAccessRequestId.sql b/src/Sql/dbo/Pam/Stored Procedures/AccessLease_ReadByAccessRequestId.sql new file mode 100644 index 000000000000..131b99d22022 --- /dev/null +++ b/src/Sql/dbo/Pam/Stored Procedures/AccessLease_ReadByAccessRequestId.sql @@ -0,0 +1,16 @@ +CREATE PROCEDURE [dbo].[AccessLease_ReadByAccessRequestId] + @AccessRequestId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + -- A request produces at most one lease ([IX_AccessLease_AccessRequestId] is unique); TOP 1 is belt and braces. + SELECT TOP 1 + * + FROM + [dbo].[AccessLease] + WHERE + [AccessRequestId] = @AccessRequestId + ORDER BY + [CreationDate] DESC +END diff --git a/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_ReadActiveApprovedByRequesterIdCipherId.sql b/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_ReadActiveApprovedByRequesterIdCipherId.sql new file mode 100644 index 000000000000..7f6102981a11 --- /dev/null +++ b/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_ReadActiveApprovedByRequesterIdCipherId.sql @@ -0,0 +1,24 @@ +CREATE PROCEDURE [dbo].[AccessRequest_ReadActiveApprovedByRequesterIdCipherId] + @RequesterId UNIQUEIDENTIFIER, + @CipherId UNIQUEIDENTIFIER, + @Now DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + -- The caller's approved-but-not-yet-activated request whose window can still produce access. Future windows are + -- included (the client shows the upcoming window); lapsed windows are excluded so the client never offers an + -- activation that the server would reject. A request that has produced a lease is activated, not approved. + SELECT TOP 1 + AR.* + FROM + [dbo].[AccessRequest] AR + WHERE + AR.[RequesterId] = @RequesterId + AND AR.[CipherId] = @CipherId + AND AR.[Status] = 1 -- Approved + AND AR.[NotAfter] > @Now + AND NOT EXISTS (SELECT 1 FROM [dbo].[AccessLease] AL WHERE AL.[AccessRequestId] = AR.[Id]) + ORDER BY + AR.[CreationDate] DESC +END diff --git a/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_ResolveWithDecision.sql b/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_ResolveWithDecision.sql index d78070c2af7c..52c2e0f7ab4d 100644 --- a/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_ResolveWithDecision.sql +++ b/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_ResolveWithDecision.sql @@ -5,7 +5,6 @@ CREATE PROCEDURE [dbo].[AccessRequest_ResolveWithDecision] @ApproverId UNIQUEIDENTIFIER, @Verdict TINYINT, @Comment NVARCHAR(MAX) = NULL, - @AccessLeaseId UNIQUEIDENTIFIER = NULL, @Now DATETIME2(7) AS BEGIN @@ -14,6 +13,10 @@ BEGIN -- Atomically resolve a pending request and record the human approver's decision. The caller has already verified -- (and the application enforces) that the request is still Pending; the WHERE guard keeps the write idempotent -- under a race so a second approver can't move an already-resolved request. + -- + -- Approval does not mint the lease: the requester activates the approved request later via + -- [AccessLease_CreateFromApprovedRequest]. The automatic path still mints instantly via + -- [AccessLease_CreateAutoApproved], where the requester is online and asking for access now. BEGIN TRANSACTION AccessRequest_Resolve UPDATE [dbo].[AccessRequest] @@ -32,22 +35,5 @@ BEGIN @Verdict, @Comment, NULL, @Now ) - -- An approval mints the active lease that authorizes access, mirroring [AccessLease_CreateAutoApproved] on the automatic - -- path. @AccessLeaseId is supplied only when approving; the lease window is the request's approved window, so the lease - -- is found by [AccessLease_ReadActiveByRequesterIdCipherId] once @Now falls inside it. - IF @AccessLeaseId IS NOT NULL - BEGIN - INSERT INTO [dbo].[AccessLease] - ( - [Id], [AccessRequestId], [OrganizationId], [CollectionId], [CipherId], [RequesterId], - [Status], [NotBefore], [NotAfter], [RevokedDate], [RevokedBy], [CreationDate] - ) - SELECT - @AccessLeaseId, [Id], [OrganizationId], [CollectionId], [CipherId], [RequesterId], - 0 /* Active */, [NotBefore], [NotAfter], NULL, NULL, @Now - FROM [dbo].[AccessRequest] - WHERE [Id] = @AccessRequestId - END - COMMIT TRANSACTION AccessRequest_Resolve END diff --git a/src/Sql/dbo/Pam/Tables/AccessLease.sql b/src/Sql/dbo/Pam/Tables/AccessLease.sql index 9d8f67d40168..ae130d947644 100644 --- a/src/Sql/dbo/Pam/Tables/AccessLease.sql +++ b/src/Sql/dbo/Pam/Tables/AccessLease.sql @@ -24,3 +24,9 @@ GO CREATE NONCLUSTERED INDEX [IX_AccessLease_NotAfter_Status] ON [dbo].[AccessLease] ([NotAfter] ASC, [Status] ASC); GO + +-- A request produces at most one lease, ever: activating an approved request and the automatic path each insert +-- exactly one. Unique to backstop racing activations that pass the application-level checks simultaneously. +CREATE UNIQUE NONCLUSTERED INDEX [IX_AccessLease_AccessRequestId] + ON [dbo].[AccessLease] ([AccessRequestId] ASC); +GO diff --git a/test/Api.Test/Pam/Controllers/CipherLeaseControllerTests.cs b/test/Api.Test/Pam/Controllers/CipherLeaseControllerTests.cs index 4e35cd915e3c..f08667b31cfd 100644 --- a/test/Api.Test/Pam/Controllers/CipherLeaseControllerTests.cs +++ b/test/Api.Test/Pam/Controllers/CipherLeaseControllerTests.cs @@ -30,7 +30,7 @@ public async Task State_ReturnsSnapshotFromQuery( .Returns(userId); sutProvider.GetDependency() .GetStateAsync(userId, id) - .Returns(new Bit.Core.Pam.Models.CipherAccessState(id, activeLease, null)); + .Returns(new Bit.Core.Pam.Models.CipherAccessState(id, activeLease, null, null)); var result = await sutProvider.Sut.State(id); @@ -38,7 +38,7 @@ public async Task State_ReturnsSnapshotFromQuery( Assert.NotNull(result.ActiveLease); Assert.Equal(activeLease.Id, result.ActiveLease!.Id); Assert.Null(result.PendingRequest); - Assert.Null(result.ApprovedRequest); // always null in v0 — no activation flow + Assert.Null(result.ApprovedRequest); } [Theory, BitAutoData] diff --git a/test/Api.Test/Pam/Controllers/MemberLeasingControllerTests.cs b/test/Api.Test/Pam/Controllers/MemberLeasingControllerTests.cs index 8f65aadfd968..09a19804be80 100644 --- a/test/Api.Test/Pam/Controllers/MemberLeasingControllerTests.cs +++ b/test/Api.Test/Pam/Controllers/MemberLeasingControllerTests.cs @@ -1,8 +1,9 @@ -using System.Security.Claims; +using System.Security.Claims; using Bit.Api.Pam.Controllers; using Bit.Core.Pam.Entities; using Bit.Core.Pam.Enums; using Bit.Core.Pam.Models; +using Bit.Core.Pam.OrganizationFeatures.Commands.Interfaces; using Bit.Core.Pam.OrganizationFeatures.Queries.Interfaces; using Bit.Core.Services; using Bit.Test.Common.AutoFixture; @@ -24,7 +25,7 @@ public async Task GetMyRequests_ReturnsMappedRows( row.Status = AccessRequestStatus.Pending; sutProvider.GetDependency().GetMineAsync(userId).Returns([row]); - var result = (await sutProvider.Sut.GetMyRequests()).ToList(); + var result = (await sutProvider.Sut.GetMyRequests()).Data.ToList(); Assert.Single(result); Assert.Equal(row.Id, result[0].Id); @@ -39,7 +40,7 @@ public async Task GetMyActiveLeases_ReturnsMappedLeases( lease.Status = AccessLeaseStatus.Active; sutProvider.GetDependency().GetMineActiveAsync(userId).Returns([lease]); - var result = (await sutProvider.Sut.GetMyActiveLeases()).ToList(); + var result = (await sutProvider.Sut.GetMyActiveLeases()).Data.ToList(); Assert.Single(result); Assert.Equal(lease.Id, result[0].Id); @@ -55,7 +56,23 @@ public async Task GetMyRequests_NoRows_ReturnsEmpty( var result = await sutProvider.Sut.GetMyRequests(); - Assert.Empty(result); + Assert.Empty(result.Data); + } + + [Theory, BitAutoData] + public async Task Activate_ReturnsMintedLease( + Guid userId, Guid requestId, AccessLease lease, SutProvider sutProvider) + { + SetupUser(sutProvider, userId); + lease.Status = AccessLeaseStatus.Active; + sutProvider.GetDependency() + .ActivateAsync(userId, requestId) + .Returns(lease); + + var result = await sutProvider.Sut.Activate(requestId); + + Assert.Equal(lease.Id, result.Id); + Assert.Equal(AccessLeaseStatusNames.Active, result.Status); } private static void SetupUser(SutProvider sutProvider, Guid userId) diff --git a/test/Core.Test/Pam/Commands/ActivateAccessRequestCommandTests.cs b/test/Core.Test/Pam/Commands/ActivateAccessRequestCommandTests.cs new file mode 100644 index 000000000000..a181d611f8d5 --- /dev/null +++ b/test/Core.Test/Pam/Commands/ActivateAccessRequestCommandTests.cs @@ -0,0 +1,209 @@ +using Bit.Core.Exceptions; +using Bit.Core.Pam.Entities; +using Bit.Core.Pam.Enums; +using Bit.Core.Pam.OrganizationFeatures.Commands; +using Bit.Core.Pam.Repositories; +using Bit.Core.Pam.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.Extensions.Time.Testing; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Pam.Commands; + +[SutProviderCustomize] +public class ActivateAccessRequestCommandTests +{ + private static readonly DateTime _now = new(2026, 6, 10, 12, 0, 0, DateTimeKind.Utc); + + [Theory, BitAutoData] + public async Task ActivateAsync_RequestMissing_ThrowsNotFound(Guid userId, Guid requestId) + { + var sutProvider = Setup(); + sutProvider.GetDependency().GetByIdAsync(requestId).Returns((AccessRequest?)null); + + await Assert.ThrowsAsync(() => sutProvider.Sut.ActivateAsync(userId, requestId)); + } + + [Theory, BitAutoData] + public async Task ActivateAsync_NotOwner_ThrowsNotFound(Guid userId, AccessRequest request) + { + var sutProvider = Setup(); + SetupApprovedRequest(sutProvider, request); + + // Someone else's request is indistinguishable from a missing one, so ids can't be probed. + await Assert.ThrowsAsync(() => sutProvider.Sut.ActivateAsync(userId, request.Id)); + } + + [Theory] + [BitAutoData(AccessRequestStatus.Pending)] + [BitAutoData(AccessRequestStatus.Denied)] + [BitAutoData(AccessRequestStatus.Cancelled)] + [BitAutoData(AccessRequestStatus.ExpiredUnanswered)] + public async Task ActivateAsync_NotApproved_ThrowsConflict(AccessRequestStatus status, AccessRequest request) + { + var sutProvider = Setup(); + SetupApprovedRequest(sutProvider, request); + request.Status = status; + + await Assert.ThrowsAsync( + () => sutProvider.Sut.ActivateAsync(request.RequesterId, request.Id)); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .CreateFromApprovedRequestAsync(default!, default); + } + + [Theory, BitAutoData] + public async Task ActivateAsync_AlreadyActivated_LiveLease_ReturnsExistingWithoutMinting( + AccessRequest request, AccessLease existing) + { + var sutProvider = Setup(); + SetupApprovedRequest(sutProvider, request); + existing.Status = AccessLeaseStatus.Active; + existing.NotAfter = _now.AddMinutes(30); + sutProvider.GetDependency().GetByAccessRequestIdAsync(request.Id).Returns(existing); + + var result = await sutProvider.Sut.ActivateAsync(request.RequesterId, request.Id); + + Assert.Same(existing, result); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .CreateFromApprovedRequestAsync(default!, default); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .NotifyCollectionApproversAsync(default); + } + + [Theory] + [BitAutoData(AccessLeaseStatus.Revoked)] + [BitAutoData(AccessLeaseStatus.Expired)] + public async Task ActivateAsync_AlreadyActivated_DeadLease_ThrowsConflict( + AccessLeaseStatus leaseStatus, AccessRequest request, AccessLease existing) + { + var sutProvider = Setup(); + SetupApprovedRequest(sutProvider, request); + existing.Status = leaseStatus; + sutProvider.GetDependency().GetByAccessRequestIdAsync(request.Id).Returns(existing); + + // A request authorizes access at most once; a revoked or lapsed lease is final. + await Assert.ThrowsAsync( + () => sutProvider.Sut.ActivateAsync(request.RequesterId, request.Id)); + } + + [Theory, BitAutoData] + public async Task ActivateAsync_AlreadyActivated_ActiveButLapsedLease_ThrowsConflict( + AccessRequest request, AccessLease existing) + { + var sutProvider = Setup(); + SetupApprovedRequest(sutProvider, request); + existing.Status = AccessLeaseStatus.Active; + existing.NotAfter = _now.AddMinutes(-1); + sutProvider.GetDependency().GetByAccessRequestIdAsync(request.Id).Returns(existing); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.ActivateAsync(request.RequesterId, request.Id)); + } + + [Theory, BitAutoData] + public async Task ActivateAsync_WindowNotStarted_ThrowsBadRequest(AccessRequest request) + { + var sutProvider = Setup(); + SetupApprovedRequest(sutProvider, request); + request.NotBefore = _now.AddHours(1); + request.NotAfter = _now.AddHours(2); + + var ex = await Assert.ThrowsAsync( + () => sutProvider.Sut.ActivateAsync(request.RequesterId, request.Id)); + Assert.Contains("not started", ex.Message); + } + + [Theory, BitAutoData] + public async Task ActivateAsync_WindowEnded_ThrowsBadRequest(AccessRequest request) + { + var sutProvider = Setup(); + SetupApprovedRequest(sutProvider, request); + request.NotBefore = _now.AddHours(-2); + request.NotAfter = _now.AddHours(-1); + + var ex = await Assert.ThrowsAsync( + () => sutProvider.Sut.ActivateAsync(request.RequesterId, request.Id)); + Assert.Contains("already ended", ex.Message); + } + + [Theory, BitAutoData] + public async Task ActivateAsync_Approved_MintsLeaseSpanningRequestWindow(AccessRequest request) + { + var sutProvider = Setup(); + SetupApprovedRequest(sutProvider, request); + sutProvider.GetDependency() + .CreateFromApprovedRequestAsync(Arg.Any(), _now).Returns(true); + + var result = await sutProvider.Sut.ActivateAsync(request.RequesterId, request.Id); + + Assert.Equal(request.Id, result.AccessRequestId); + Assert.Equal(request.OrganizationId, result.OrganizationId); + Assert.Equal(request.CollectionId, result.CollectionId); + Assert.Equal(request.CipherId, result.CipherId); + Assert.Equal(request.RequesterId, result.RequesterId); + Assert.Equal(AccessLeaseStatus.Active, result.Status); + // Activation mints the window the approver approved, not a window anchored at activation time. + Assert.Equal(request.NotBefore, result.NotBefore); + Assert.Equal(request.NotAfter, result.NotAfter); + Assert.Equal(_now, result.CreationDate); + Assert.NotEqual(default, result.Id); + await sutProvider.GetDependency().Received(1) + .CreateFromApprovedRequestAsync(result, _now); + await sutProvider.GetDependency().Received(1) + .NotifyCollectionApproversAsync(request.CollectionId); + } + + [Theory, BitAutoData] + public async Task ActivateAsync_LostRace_WinnerLive_ReturnsWinner(AccessRequest request, AccessLease winner) + { + var sutProvider = Setup(); + SetupApprovedRequest(sutProvider, request); + winner.Status = AccessLeaseStatus.Active; + winner.NotAfter = _now.AddMinutes(30); + sutProvider.GetDependency() + .CreateFromApprovedRequestAsync(Arg.Any(), _now).Returns(false); + sutProvider.GetDependency().GetByAccessRequestIdAsync(request.Id) + .Returns((AccessLease?)null, winner); + + var result = await sutProvider.Sut.ActivateAsync(request.RequesterId, request.Id); + + Assert.Same(winner, result); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .NotifyCollectionApproversAsync(default); + } + + [Theory, BitAutoData] + public async Task ActivateAsync_LostRace_NoLiveLease_ThrowsConflict(AccessRequest request) + { + var sutProvider = Setup(); + SetupApprovedRequest(sutProvider, request); + sutProvider.GetDependency() + .CreateFromApprovedRequestAsync(Arg.Any(), _now).Returns(false); + sutProvider.GetDependency().GetByAccessRequestIdAsync(request.Id) + .Returns((AccessLease?)null); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.ActivateAsync(request.RequesterId, request.Id)); + } + + private static SutProvider Setup() + { + var sutProvider = new SutProvider().WithFakeTimeProvider().Create(); + sutProvider.GetDependency().SetUtcNow(_now); + return sutProvider; + } + + // An approved request owned by its BitAutoData requester, with an open window containing _now and no produced + // lease. Tests override the specific precondition they exercise. + private static void SetupApprovedRequest(SutProvider sutProvider, AccessRequest request) + { + request.Status = AccessRequestStatus.Approved; + request.NotBefore = _now.AddMinutes(-5); + request.NotAfter = _now.AddHours(1); + sutProvider.GetDependency().GetByIdAsync(request.Id).Returns(request); + sutProvider.GetDependency().GetByAccessRequestIdAsync(request.Id) + .Returns((AccessLease?)null); + } +} diff --git a/test/Core.Test/Pam/Commands/DecideAccessRequestCommandTests.cs b/test/Core.Test/Pam/Commands/DecideAccessRequestCommandTests.cs index 8d3692b9397d..f13e21b16b9d 100644 --- a/test/Core.Test/Pam/Commands/DecideAccessRequestCommandTests.cs +++ b/test/Core.Test/Pam/Commands/DecideAccessRequestCommandTests.cs @@ -64,7 +64,40 @@ public async Task DecideAsync_SelfApproval_ThrowsBadRequest(Guid userId, AccessR () => sutProvider.Sut.DecideAsync(userId, request.Id, Approve())); Assert.Contains("your own request", ex.Message); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() - .ResolveWithDecisionAsync(default!, default!, default, default, default); + .ResolveWithDecisionAsync(default!, default!, default, default); + } + + [Theory, BitAutoData] + public async Task DecideAsync_Approve_WindowAlreadyEnded_ThrowsBadRequest(Guid userId, AccessRequest request) + { + var sutProvider = Setup(); + request.Status = AccessRequestStatus.Pending; + request.NotBefore = _now.AddHours(-2); + request.NotAfter = _now.AddHours(-1); + SetupManageableRequest(sutProvider, userId, request); + + var ex = await Assert.ThrowsAsync( + () => sutProvider.Sut.DecideAsync(userId, request.Id, Approve())); + Assert.Contains("already ended", ex.Message); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .ResolveWithDecisionAsync(default!, default!, default, default); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .NotifyCollectionApproversAsync(default); + } + + [Theory, BitAutoData] + public async Task DecideAsync_Deny_WindowAlreadyEnded_Succeeds(Guid userId, AccessRequest request) + { + var sutProvider = Setup(); + request.Status = AccessRequestStatus.Pending; + request.NotBefore = _now.AddHours(-2); + request.NotAfter = _now.AddHours(-1); + SetupManageableRequest(sutProvider, userId, request); + + // A lapsed window only blocks approval (it could never be activated); denial still closes the request out. + var result = await sutProvider.Sut.DecideAsync(userId, request.Id, Deny()); + + Assert.Equal(AccessRequestStatus.Denied, result.Status); } [Theory, BitAutoData] @@ -72,6 +105,7 @@ public async Task DecideAsync_Approve_ResolvesAndWritesHumanDecision(Guid userId { var sutProvider = Setup(); request.Status = AccessRequestStatus.Pending; + SetOpenWindow(request); SetupManageableRequest(sutProvider, userId, request); var result = await sutProvider.Sut.DecideAsync(userId, request.Id, Approve("looks good")); @@ -80,6 +114,7 @@ public async Task DecideAsync_Approve_ResolvesAndWritesHumanDecision(Guid userId Assert.Equal(_now, result.ResolvedDate); Assert.Equal(userId, result.ApproverId); Assert.Equal("looks good", result.ApproverComment); + // Approval records the verdict only; no lease is minted until the requester activates the approved request. await sutProvider.GetDependency().Received(1).ResolveWithDecisionAsync( request, Arg.Is(d => @@ -88,17 +123,6 @@ await sutProvider.GetDependency().Received(1).ResolveW d.Verdict == AccessDecisionVerdict.Approve && d.Comment == "looks good"), AccessRequestStatus.Approved, - // Approval mints an active lease spanning the request's approved window. - Arg.Is(l => - l.AccessRequestId == request.Id && - l.OrganizationId == request.OrganizationId && - l.CollectionId == request.CollectionId && - l.CipherId == request.CipherId && - l.RequesterId == request.RequesterId && - l.Status == AccessLeaseStatus.Active && - l.NotBefore == request.NotBefore && - l.NotAfter == request.NotAfter && - l.Id != default), _now); await sutProvider.GetDependency().Received(1) .NotifyCollectionApproversAsync(request.CollectionId); @@ -109,6 +133,7 @@ public async Task DecideAsync_Deny_ResolvesAsDenied(Guid userId, AccessRequest r { var sutProvider = Setup(); request.Status = AccessRequestStatus.Pending; + SetOpenWindow(request); SetupManageableRequest(sutProvider, userId, request); var result = await sutProvider.Sut.DecideAsync(userId, request.Id, Deny()); @@ -118,8 +143,6 @@ await sutProvider.GetDependency().Received(1).ResolveW request, Arg.Is(d => d.Verdict == AccessDecisionVerdict.Deny), AccessRequestStatus.Denied, - // A denial creates no lease. - null, _now); } @@ -142,4 +165,12 @@ private static void SetupManageableRequest(SutProvider() .CanManageCollectionAsync(userId, request.CollectionId).Returns(true); } + + // BitAutoData generates arbitrary dates; pin a window containing _now so the lapsed-window approve guard + // doesn't trip in tests that aren't about it. + private static void SetOpenWindow(AccessRequest request) + { + request.NotBefore = _now.AddMinutes(-5); + request.NotAfter = _now.AddHours(1); + } } diff --git a/test/Core.Test/Pam/Commands/SubmitAccessRequestCommandTests.cs b/test/Core.Test/Pam/Commands/SubmitAccessRequestCommandTests.cs index 3d9c408a83f5..cd2faa6efc2f 100644 --- a/test/Core.Test/Pam/Commands/SubmitAccessRequestCommandTests.cs +++ b/test/Core.Test/Pam/Commands/SubmitAccessRequestCommandTests.cs @@ -233,6 +233,26 @@ public async Task SubmitAsync_ExistingPendingRequest_ThrowsBadRequest(Guid userI Assert.Contains("already have a pending request", ex.Message); } + [Theory, BitAutoData] + public async Task SubmitAsync_ExistingApprovedUnactivatedRequest_ThrowsBadRequest( + Guid userId, Guid cipherId, Guid orgId, Guid collectionId, AccessRequest approved) + { + var sutProvider = Setup(); + SetupCipher(sutProvider, userId, cipherId); + SetupResolution(sutProvider, userId, cipherId, orgId, collectionId, requiresHuman: true); + sutProvider.GetDependency() + .GetActiveApprovedByRequesterIdCipherIdAsync(userId, cipherId, _now) + .Returns(approved); + + // An approved-but-not-yet-activated request already grants startable access; a second request would stack. + var ex = await Assert.ThrowsAsync( + () => sutProvider.Sut.SubmitAsync(userId, cipherId, + new AccessRequestSubmission { Start = _now.AddHours(1), End = _now.AddHours(2), Reason = "x" })); + Assert.Contains("already have an approved request", ex.Message); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .CreateAsync(default!); + } + private static SutProvider Setup() { var sutProvider = new SutProvider().WithFakeTimeProvider().Create(); diff --git a/test/Core.Test/Pam/Queries/GetCipherAccessStateQueryTests.cs b/test/Core.Test/Pam/Queries/GetCipherAccessStateQueryTests.cs index a953bdf6163b..f16b2812212e 100644 --- a/test/Core.Test/Pam/Queries/GetCipherAccessStateQueryTests.cs +++ b/test/Core.Test/Pam/Queries/GetCipherAccessStateQueryTests.cs @@ -101,6 +101,54 @@ public async Task GetStateAsync_PendingRequest_MapsToDetails( Assert.Null(result.PendingRequest.CipherName); } + [Theory, BitAutoData] + public async Task GetStateAsync_ApprovedRequest_MapsToDetails( + SutProvider sutProvider, Guid userId, Guid cipherId, AccessRequest approved) + { + SetupCipher(sutProvider, userId, cipherId); + approved.CipherId = cipherId; + approved.RequesterId = userId; + approved.Status = AccessRequestStatus.Approved; + sutProvider.GetDependency() + .GetActiveApprovedByRequesterIdCipherIdAsync(userId, cipherId, Arg.Any()) + .Returns(approved); + + var result = await sutProvider.Sut.GetStateAsync(userId, cipherId); + + Assert.Null(result.ActiveLease); + Assert.Null(result.PendingRequest); + Assert.NotNull(result.ApprovedRequest); + Assert.Equal(approved.Id, result.ApprovedRequest!.Id); + Assert.Equal(AccessRequestStatus.Approved, result.ApprovedRequest.Status); + Assert.Equal(approved.NotBefore, result.ApprovedRequest.NotBefore); + Assert.Equal(approved.NotAfter, result.ApprovedRequest.NotAfter); + // The approved read excludes activated rows, so no lease id; the caller-scoped snapshot carries no approver + // identity or display-name fields. + Assert.Null(result.ApprovedRequest.ProducedLeaseId); + Assert.Null(result.ApprovedRequest.ApproverId); + Assert.Null(result.ApprovedRequest.CipherName); + } + + [Theory, BitAutoData] + public async Task GetStateAsync_ApprovedHeldButRuleRemoved_StillReturnsSnapshot( + SutProvider sutProvider, Guid userId, Guid cipherId, AccessRequest approved) + { + SetupCipher(sutProvider, userId, cipherId); + approved.Status = AccessRequestStatus.Approved; + sutProvider.GetDependency() + .GetActiveApprovedByRequesterIdCipherIdAsync(userId, cipherId, Arg.Any()) + .Returns(approved); + // Access rule since removed: resolver returns null, but the startable approval must not be hidden. + sutProvider.GetDependency() + .ResolveAsync(userId, cipherId) + .Returns((GoverningRule?)null); + + var result = await sutProvider.Sut.GetStateAsync(userId, cipherId); + + Assert.NotNull(result.ApprovedRequest); + Assert.Equal(approved.Id, result.ApprovedRequest!.Id); + } + [Theory, BitAutoData] public async Task GetStateAsync_GatedButEmpty_ReturnsEmptySnapshot( SutProvider sutProvider, Guid userId, Guid cipherId, Guid orgId, Guid collectionId) @@ -115,6 +163,7 @@ public async Task GetStateAsync_GatedButEmpty_ReturnsEmptySnapshot( Assert.Equal(cipherId, result.CipherId); Assert.Null(result.ActiveLease); Assert.Null(result.PendingRequest); + Assert.Null(result.ApprovedRequest); } private static void SetupCipher(SutProvider sutProvider, Guid userId, Guid cipherId) diff --git a/test/Infrastructure.IntegrationTest/Pam/Repositories/AccessLeaseRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Pam/Repositories/AccessLeaseRepositoryTests.cs index a3698e41c327..20439d0bca20 100644 --- a/test/Infrastructure.IntegrationTest/Pam/Repositories/AccessLeaseRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/Pam/Repositories/AccessLeaseRepositoryTests.cs @@ -171,6 +171,133 @@ public async Task RevokeAsync_RevokesLeaseAndRecordsAuditDecision( Assert.NotNull(persisted.RevokedDate); } + [DatabaseTheory, DatabaseData] + public async Task CreateFromApprovedRequestAsync_ApprovedOpenWindow_MintsActiveLease( + IOrganizationRepository organizationRepository, + IAccessRequestRepository accessRequestRepository, + IAccessLeaseRepository accessLeaseRepository) + { + var organization = await organizationRepository.CreateTestOrganizationAsync(); + var now = DateTime.UtcNow; + var request = await CreateApprovedRequestAsync( + accessRequestRepository, organization.Id, now.AddHours(-1), now.AddHours(1)); + + // Activation has not happened yet, so the request has produced nothing. + Assert.Null(await accessLeaseRepository.GetByAccessRequestIdAsync(request.Id)); + + var lease = BuildLeaseFor(request, now); + Assert.True(await accessLeaseRepository.CreateFromApprovedRequestAsync(lease, now)); + + var produced = await accessLeaseRepository.GetByAccessRequestIdAsync(request.Id); + Assert.NotNull(produced); + Assert.Equal(lease.Id, produced!.Id); + Assert.Equal(AccessLeaseStatus.Active, produced.Status); + // The minted lease spans the request's approved window exactly — compare against the persisted request, + // since the in-memory entity keeps tick precision the driver's datetime parameters do not. + var persistedRequest = await accessRequestRepository.GetByIdAsync(request.Id); + Assert.Equal(persistedRequest!.NotBefore, produced.NotBefore); + Assert.Equal(persistedRequest.NotAfter, produced.NotAfter); + + // The requester now holds access through the standard active-lease read. + var active = await accessLeaseRepository.GetActiveByRequesterIdCipherIdAsync( + request.RequesterId, request.CipherId, now); + Assert.NotNull(active); + Assert.Equal(lease.Id, active!.Id); + } + + [DatabaseTheory, DatabaseData] + public async Task CreateFromApprovedRequestAsync_SecondActivation_ReturnsFalseAndKeepsFirstLease( + IOrganizationRepository organizationRepository, + IAccessRequestRepository accessRequestRepository, + IAccessLeaseRepository accessLeaseRepository) + { + var organization = await organizationRepository.CreateTestOrganizationAsync(); + var now = DateTime.UtcNow; + var request = await CreateApprovedRequestAsync( + accessRequestRepository, organization.Id, now.AddHours(-1), now.AddHours(1)); + + var first = BuildLeaseFor(request, now); + Assert.True(await accessLeaseRepository.CreateFromApprovedRequestAsync(first, now)); + + // A request authorizes access at most once: the second insert is refused by the guard (and would be by the + // unique index even if the guard raced). + var second = BuildLeaseFor(request, now); + Assert.False(await accessLeaseRepository.CreateFromApprovedRequestAsync(second, now)); + + var produced = await accessLeaseRepository.GetByAccessRequestIdAsync(request.Id); + Assert.Equal(first.Id, produced!.Id); + } + + [DatabaseTheory, DatabaseData] + public async Task CreateFromApprovedRequestAsync_PreconditionNoLongerHolds_ReturnsFalse( + IOrganizationRepository organizationRepository, + IAccessRequestRepository accessRequestRepository, + IAccessLeaseRepository accessLeaseRepository) + { + var organization = await organizationRepository.CreateTestOrganizationAsync(); + var now = DateTime.UtcNow; + + // Still pending: not an approval. + var pending = await CreateApprovedRequestAsync( + accessRequestRepository, organization.Id, now.AddHours(-1), now.AddHours(1), AccessRequestStatus.Pending); + Assert.False(await accessLeaseRepository.CreateFromApprovedRequestAsync(BuildLeaseFor(pending, now), now)); + + // Someone else's request: the requester filter refuses it. + var approved = await CreateApprovedRequestAsync( + accessRequestRepository, organization.Id, now.AddHours(-1), now.AddHours(1)); + var foreign = BuildLeaseFor(approved, now); + foreign.RequesterId = Guid.NewGuid(); + Assert.False(await accessLeaseRepository.CreateFromApprovedRequestAsync(foreign, now)); + + // Window not started yet. + var future = await CreateApprovedRequestAsync( + accessRequestRepository, organization.Id, now.AddHours(1), now.AddHours(2)); + Assert.False(await accessLeaseRepository.CreateFromApprovedRequestAsync(BuildLeaseFor(future, now), now)); + + // Window already ended. + var lapsed = await CreateApprovedRequestAsync( + accessRequestRepository, organization.Id, now.AddHours(-2), now.AddHours(-1)); + Assert.False(await accessLeaseRepository.CreateFromApprovedRequestAsync(BuildLeaseFor(lapsed, now), now)); + + // None of the refused activations left a lease behind. + foreach (var requestId in new[] { pending.Id, approved.Id, future.Id, lapsed.Id }) + { + Assert.Null(await accessLeaseRepository.GetByAccessRequestIdAsync(requestId)); + } + } + + private static async Task CreateApprovedRequestAsync( + IAccessRequestRepository accessRequestRepository, Guid organizationId, DateTime notBefore, DateTime notAfter, + AccessRequestStatus status = AccessRequestStatus.Approved) + => await accessRequestRepository.CreateAsync(new AccessRequest + { + OrganizationId = organizationId, + CollectionId = Guid.NewGuid(), + CipherId = Guid.NewGuid(), + RequesterId = Guid.NewGuid(), + NotBefore = notBefore, + NotAfter = notAfter, + Reason = "audit", + Status = status, + CreationDate = DateTime.UtcNow, + ResolvedDate = status == AccessRequestStatus.Pending ? null : DateTime.UtcNow, + }); + + private static AccessLease BuildLeaseFor(AccessRequest request, DateTime now) + => new() + { + Id = CoreHelpers.GenerateComb(), + AccessRequestId = request.Id, + OrganizationId = request.OrganizationId, + CollectionId = request.CollectionId, + CipherId = request.CipherId, + RequesterId = request.RequesterId, + Status = AccessLeaseStatus.Active, + NotBefore = request.NotBefore, + NotAfter = request.NotAfter, + CreationDate = now, + }; + private static (AccessRequest, AccessDecision, AccessLease) BuildAutoApproved( Guid organizationId, Guid cipherId, Guid requesterId, DateTime notBefore, DateTime notAfter) { diff --git a/test/Infrastructure.IntegrationTest/Pam/Repositories/AccessRequestRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Pam/Repositories/AccessRequestRepositoryTests.cs index 1c3505bf4609..2e080311c1fd 100644 --- a/test/Infrastructure.IntegrationTest/Pam/Repositories/AccessRequestRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/Pam/Repositories/AccessRequestRepositoryTests.cs @@ -81,7 +81,7 @@ await accessRequestRepository.CreateAsync(BuildRequest( } [DatabaseTheory, DatabaseData] - public async Task ResolveWithDecisionAsync_Approve_ResolvesRequestRecordsDecisionAndMintsActiveLease( + public async Task ResolveWithDecisionAsync_Approve_ResolvesRequestAndRecordsDecisionWithoutMintingLease( IOrganizationRepository organizationRepository, ICollectionRepository collectionRepository, IAccessRequestRepository accessRequestRepository, @@ -92,7 +92,6 @@ public async Task ResolveWithDecisionAsync_Approve_ResolvesRequestRecordsDecisio var now = DateTime.UtcNow; var approverId = Guid.NewGuid(); - // Window straddles now so the minted lease is immediately active and findable by the requester. var request = await accessRequestRepository.CreateAsync(new AccessRequest { OrganizationId = organization.Id, @@ -117,21 +116,7 @@ public async Task ResolveWithDecisionAsync_Approve_ResolvesRequestRecordsDecisio CreationDate = now, }; - var lease = new AccessLease - { - Id = CoreHelpers.GenerateComb(), - AccessRequestId = request.Id, - OrganizationId = request.OrganizationId, - CollectionId = request.CollectionId, - CipherId = request.CipherId, - RequesterId = request.RequesterId, - Status = AccessLeaseStatus.Active, - NotBefore = request.NotBefore, - NotAfter = request.NotAfter, - CreationDate = now, - }; - - await accessRequestRepository.ResolveWithDecisionAsync(request, decision, AccessRequestStatus.Approved, lease, now); + await accessRequestRepository.ResolveWithDecisionAsync(request, decision, AccessRequestStatus.Approved, now); var persisted = await accessRequestRepository.GetByIdAsync(request.Id); Assert.NotNull(persisted); @@ -145,12 +130,18 @@ public async Task ResolveWithDecisionAsync_Approve_ResolvesRequestRecordsDecisio Assert.Equal(approverId, row.ApproverId); Assert.Equal("approved for audit", row.ApproverComment); - // The approval minted an active lease spanning the request's window, so the requester now holds access. - var active = await accessLeaseRepository.GetActiveByRequesterIdCipherIdAsync(request.RequesterId, request.CipherId, now); - Assert.NotNull(active); - Assert.Equal(lease.Id, active!.Id); - Assert.Equal(AccessLeaseStatus.Active, active.Status); - Assert.Equal(request.Id, active.AccessRequestId); + // Approval records the verdict only: no lease exists until the requester activates the approved request, + // so the requester does not yet hold access and the inbox row carries no produced lease. + Assert.Null(row.ProducedLeaseId); + Assert.Null(await accessLeaseRepository.GetByAccessRequestIdAsync(request.Id)); + Assert.Null(await accessLeaseRepository.GetActiveByRequesterIdCipherIdAsync( + request.RequesterId, request.CipherId, now)); + + // The approved request is now the requester's startable approval for this cipher. + var approved = await accessRequestRepository.GetActiveApprovedByRequesterIdCipherIdAsync( + request.RequesterId, request.CipherId, now); + Assert.NotNull(approved); + Assert.Equal(request.Id, approved!.Id); } [DatabaseTheory, DatabaseData] @@ -187,7 +178,7 @@ public async Task ResolveWithDecisionAsync_Deny_ResolvesWithoutLease( CreationDate = now, }; - await accessRequestRepository.ResolveWithDecisionAsync(request, decision, AccessRequestStatus.Denied, null, now); + await accessRequestRepository.ResolveWithDecisionAsync(request, decision, AccessRequestStatus.Denied, now); var persisted = await accessRequestRepository.GetByIdAsync(request.Id); Assert.Equal(AccessRequestStatus.Denied, persisted!.Status); @@ -197,6 +188,76 @@ public async Task ResolveWithDecisionAsync_Deny_ResolvesWithoutLease( Assert.Null(active); } + [DatabaseTheory, DatabaseData] + public async Task GetActiveApprovedByRequesterIdCipherIdAsync_ReturnsStartableApprovalsOnly( + IOrganizationRepository organizationRepository, + ICollectionRepository collectionRepository, + IAccessRequestRepository accessRequestRepository, + IAccessLeaseRepository accessLeaseRepository) + { + var organization = await organizationRepository.CreateTestOrganizationAsync(); + var collection = await collectionRepository.CreateTestCollectionAsync(organization); + var now = DateTime.UtcNow; + var requesterId = Guid.NewGuid(); + + // Approved with an open window: the startable approval the query must return. + var openWindow = BuildRequest(organization.Id, collection.Id, requesterId, AccessRequestStatus.Approved, now); + openWindow.NotBefore = now.AddHours(-1); + openWindow.NotAfter = now.AddHours(1); + var startable = await accessRequestRepository.CreateAsync(openWindow); + + var found = await accessRequestRepository.GetActiveApprovedByRequesterIdCipherIdAsync( + requesterId, startable.CipherId, now); + Assert.NotNull(found); + Assert.Equal(startable.Id, found!.Id); + + // Approved with a future window is included — the client shows the upcoming window. + var future = await accessRequestRepository.CreateAsync( + BuildRequest(organization.Id, collection.Id, requesterId, AccessRequestStatus.Approved, now)); + Assert.NotNull(await accessRequestRepository.GetActiveApprovedByRequesterIdCipherIdAsync( + requesterId, future.CipherId, now)); + + // Approved with a lapsed window is excluded — it can never be activated. + var lapsed = BuildRequest(organization.Id, collection.Id, requesterId, AccessRequestStatus.Approved, now); + lapsed.NotBefore = now.AddHours(-2); + lapsed.NotAfter = now.AddHours(-1); + lapsed = await accessRequestRepository.CreateAsync(lapsed); + Assert.Null(await accessRequestRepository.GetActiveApprovedByRequesterIdCipherIdAsync( + requesterId, lapsed.CipherId, now)); + + // Pending and denied requests are not approvals. + var pending = await accessRequestRepository.CreateAsync( + BuildRequest(organization.Id, collection.Id, requesterId, AccessRequestStatus.Pending, now)); + Assert.Null(await accessRequestRepository.GetActiveApprovedByRequesterIdCipherIdAsync( + requesterId, pending.CipherId, now)); + var denied = await accessRequestRepository.CreateAsync( + BuildRequest(organization.Id, collection.Id, requesterId, AccessRequestStatus.Denied, now)); + Assert.Null(await accessRequestRepository.GetActiveApprovedByRequesterIdCipherIdAsync( + requesterId, denied.CipherId, now)); + + // Another user's approval for the same cipher is not the caller's. + Assert.Null(await accessRequestRepository.GetActiveApprovedByRequesterIdCipherIdAsync( + Guid.NewGuid(), startable.CipherId, now)); + + // Once the approval produces a lease it is activated, not approved, and leaves this read. + var lease = new AccessLease + { + Id = CoreHelpers.GenerateComb(), + AccessRequestId = startable.Id, + OrganizationId = startable.OrganizationId, + CollectionId = startable.CollectionId, + CipherId = startable.CipherId, + RequesterId = startable.RequesterId, + Status = AccessLeaseStatus.Active, + NotBefore = startable.NotBefore, + NotAfter = startable.NotAfter, + CreationDate = now, + }; + Assert.True(await accessLeaseRepository.CreateFromApprovedRequestAsync(lease, now)); + Assert.Null(await accessRequestRepository.GetActiveApprovedByRequesterIdCipherIdAsync( + requesterId, startable.CipherId, now)); + } + [DatabaseTheory, DatabaseData] public async Task GetManyByRequesterIdAsync_ReturnsOwnRequestsRegardlessOfStatus( IOrganizationRepository organizationRepository, diff --git a/util/Migrator/DbScripts/2026-06-10_00_DeferAccessLeaseMintToActivation.sql b/util/Migrator/DbScripts/2026-06-10_00_DeferAccessLeaseMintToActivation.sql new file mode 100644 index 000000000000..259fc97c6150 --- /dev/null +++ b/util/Migrator/DbScripts/2026-06-10_00_DeferAccessLeaseMintToActivation.sql @@ -0,0 +1,158 @@ +-- PAM Credential Leasing: approval no longer mints the lease. A human approval now only records the verdict and +-- flips the request to Approved; the requester activates the approved request when they actually access the item +-- ([AccessLease_CreateFromApprovedRequest]), which is when the active lease is minted. The automatic path is +-- unchanged and still mints instantly via [AccessLease_CreateAutoApproved] — there the requester is online and +-- asking for access now. +-- +-- [AccessRequest_ResolveWithDecision] loses its @AccessLeaseId parameter outright instead of keeping an ignored +-- default: during a mixed-binary window an old server passing @AccessLeaseId would error. Acceptable here — the +-- feature is an unshipped POC behind the pm-37044-pam-v-0 flag and server + migration deploy together. + +CREATE OR ALTER PROCEDURE [dbo].[AccessRequest_ResolveWithDecision] + @AccessRequestId UNIQUEIDENTIFIER, + @Status TINYINT, + @AccessDecisionId UNIQUEIDENTIFIER, + @ApproverId UNIQUEIDENTIFIER, + @Verdict TINYINT, + @Comment NVARCHAR(MAX) = NULL, + @Now DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + -- Atomically resolve a pending request and record the human approver's decision. The caller has already verified + -- (and the application enforces) that the request is still Pending; the WHERE guard keeps the write idempotent + -- under a race so a second approver can't move an already-resolved request. + -- + -- Approval does not mint the lease: the requester activates the approved request later via + -- [AccessLease_CreateFromApprovedRequest]. The automatic path still mints instantly via + -- [AccessLease_CreateAutoApproved], where the requester is online and asking for access now. + BEGIN TRANSACTION AccessRequest_Resolve + + UPDATE [dbo].[AccessRequest] + SET [Status] = @Status, + [ResolvedDate] = @Now + WHERE [Id] = @AccessRequestId AND [Status] = 0 -- Pending + + INSERT INTO [dbo].[AccessDecision] + ( + [Id], [AccessRequestId], [DeciderKind], [ApproverId], [ConditionKind], + [Verdict], [Comment], [EvaluationContext], [CreationDate] + ) + VALUES + ( + @AccessDecisionId, @AccessRequestId, 1 /* Human */, @ApproverId, NULL, + @Verdict, @Comment, NULL, @Now + ) + + COMMIT TRANSACTION AccessRequest_Resolve +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[AccessRequest_ReadActiveApprovedByRequesterIdCipherId] + @RequesterId UNIQUEIDENTIFIER, + @CipherId UNIQUEIDENTIFIER, + @Now DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + -- The caller's approved-but-not-yet-activated request whose window can still produce access. Future windows are + -- included (the client shows the upcoming window); lapsed windows are excluded so the client never offers an + -- activation that the server would reject. A request that has produced a lease is activated, not approved. + SELECT TOP 1 + AR.* + FROM + [dbo].[AccessRequest] AR + WHERE + AR.[RequesterId] = @RequesterId + AND AR.[CipherId] = @CipherId + AND AR.[Status] = 1 -- Approved + AND AR.[NotAfter] > @Now + AND NOT EXISTS (SELECT 1 FROM [dbo].[AccessLease] AL WHERE AL.[AccessRequestId] = AR.[Id]) + ORDER BY + AR.[CreationDate] DESC +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[AccessLease_ReadByAccessRequestId] + @AccessRequestId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + -- A request produces at most one lease ([IX_AccessLease_AccessRequestId] is unique); TOP 1 is belt and braces. + SELECT TOP 1 + * + FROM + [dbo].[AccessLease] + WHERE + [AccessRequestId] = @AccessRequestId + ORDER BY + [CreationDate] DESC +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[AccessLease_CreateFromApprovedRequest] + @AccessLeaseId UNIQUEIDENTIFIER, + @AccessRequestId UNIQUEIDENTIFIER, + @RequesterId UNIQUEIDENTIFIER, + @Now DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + -- Activation of an approved request: mints the active lease that authorizes access, spanning the request's + -- approved window. Every application-level precondition is re-checked inside the INSERT so a concurrent + -- activation cannot double-mint; zero rows inserted means a precondition no longer held and the caller decides + -- how to surface that. [IX_AccessLease_AccessRequestId] (unique) is the backstop when two calls pass the + -- NOT EXISTS check simultaneously. + INSERT INTO [dbo].[AccessLease] + ( + [Id], [AccessRequestId], [OrganizationId], [CollectionId], [CipherId], [RequesterId], + [Status], [NotBefore], [NotAfter], [RevokedDate], [RevokedBy], [CreationDate] + ) + SELECT + @AccessLeaseId, AR.[Id], AR.[OrganizationId], AR.[CollectionId], AR.[CipherId], AR.[RequesterId], + 0 /* Active */, AR.[NotBefore], AR.[NotAfter], NULL, NULL, @Now + FROM [dbo].[AccessRequest] AR + WHERE + AR.[Id] = @AccessRequestId + AND AR.[RequesterId] = @RequesterId + AND AR.[Status] = 1 -- Approved + AND AR.[NotBefore] <= @Now + AND AR.[NotAfter] > @Now + AND NOT EXISTS (SELECT 1 FROM [dbo].[AccessLease] AL WHERE AL.[AccessRequestId] = AR.[Id]) + + SELECT @@ROWCOUNT +END +GO + +-- One lease per request, ever. Dev databases may hold duplicates from the earlier approval-mint race +-- ([AccessRequest_ResolveWithDecision] inserted the decision and lease even when the pending-guard UPDATE matched +-- zero rows); keep the earliest lease per request. No production data exists, and the server never writes +-- [AccessRequest].[ExtensionOfLeaseId] yet, so its FK cannot block these deletes. +DELETE AL +FROM [dbo].[AccessLease] AL +WHERE EXISTS ( + SELECT 1 + FROM [dbo].[AccessLease] AL2 + WHERE AL2.[AccessRequestId] = AL.[AccessRequestId] + AND (AL2.[CreationDate] < AL.[CreationDate] + OR (AL2.[CreationDate] = AL.[CreationDate] AND AL2.[Id] < AL.[Id])) +) +GO + +IF NOT EXISTS ( + SELECT 1 + FROM sys.indexes + WHERE [name] = 'IX_AccessLease_AccessRequestId' + AND [object_id] = OBJECT_ID('[dbo].[AccessLease]') +) +BEGIN + -- A request produces at most one lease, ever: activating an approved request and the automatic path each insert + -- exactly one. Unique to backstop racing activations that pass the application-level checks simultaneously. + CREATE UNIQUE NONCLUSTERED INDEX [IX_AccessLease_AccessRequestId] + ON [dbo].[AccessLease] ([AccessRequestId] ASC); +END +GO From bb38f86e5d0c78622b89239d58fd40e54c483911 Mon Sep 17 00:00:00 2001 From: Patrik Svensson Date: Thu, 11 Jun 2026 00:42:20 +0200 Subject: [PATCH 18/54] Surface produced lease status in approver inbox The inbox read procedures returned only the produced lease's id, never its status, so a lease that had ended (revoked/expired) still looked active to the client and offered a Revoke the server then rejected with 409. Select [Status] alongside [Id] in the pending/history inbox procedures and carry it through AccessRequestDetails to the response as ProducedLeaseStatus (active|expired|revoked). --- .../AccessRequestDetailsResponseModel.cs | 11 +- src/Core/Pam/Models/AccessRequestDetails.cs | 7 ++ ...equest_ReadInboxHistoryByCollectionIds.sql | 6 +- ...equest_ReadInboxPendingByCollectionIds.sql | 3 +- .../AccessRequestRepositoryTests.cs | 57 ++++++++++ ...06-11_00_AddProducedLeaseStatusToInbox.sql | 103 ++++++++++++++++++ 6 files changed, 183 insertions(+), 4 deletions(-) create mode 100644 util/Migrator/DbScripts/2026-06-11_00_AddProducedLeaseStatusToInbox.sql diff --git a/src/Api/Pam/Models/Response/AccessRequestDetailsResponseModel.cs b/src/Api/Pam/Models/Response/AccessRequestDetailsResponseModel.cs index 3bf153fff3c5..a2b58a7992aa 100644 --- a/src/Api/Pam/Models/Response/AccessRequestDetailsResponseModel.cs +++ b/src/Api/Pam/Models/Response/AccessRequestDetailsResponseModel.cs @@ -1,4 +1,4 @@ -using Bit.Core.Models.Api; +using Bit.Core.Models.Api; using Bit.Core.Pam.Models; namespace Bit.Api.Pam.Models.Response; @@ -30,6 +30,9 @@ public AccessRequestDetailsResponseModel(AccessRequestDetails details) ApproverId = details.ApproverId; ApproverComment = details.ApproverComment; ProducedLeaseId = details.ProducedLeaseId; + ProducedLeaseStatus = details.ProducedLeaseStatus.HasValue + ? AccessLeaseStatusNames.From(details.ProducedLeaseStatus.Value) + : null; ExtensionOfLeaseId = details.ExtensionOfLeaseId; CipherName = details.CipherName; CollectionName = details.CollectionName; @@ -68,6 +71,12 @@ public AccessRequestDetailsResponseModel(AccessRequestDetails details) /// Set once an approved request has produced a lease. public Guid? ProducedLeaseId { get; } + /// + /// The produced lease's status (active | expired | revoked), or null when no lease exists. The inbox uses + /// this to keep an ended lease out of the "active" group so it is not offered for revocation. + /// + public string? ProducedLeaseStatus { get; } + /// The parent lease if this is an extension request. public Guid? ExtensionOfLeaseId { get; } diff --git a/src/Core/Pam/Models/AccessRequestDetails.cs b/src/Core/Pam/Models/AccessRequestDetails.cs index d5390d356beb..52d0d2d0a974 100644 --- a/src/Core/Pam/Models/AccessRequestDetails.cs +++ b/src/Core/Pam/Models/AccessRequestDetails.cs @@ -29,6 +29,13 @@ public class AccessRequestDetails /// The lease this request produced once activated, or null if it has not produced a lease. public Guid? ProducedLeaseId { get; set; } + /// + /// The produced lease's current status (Active/Expired/Revoked), or null when the request has not produced a + /// lease. Lets the inbox distinguish a still-live lease from one that has ended, so an ended lease is not offered + /// for revocation. + /// + public AccessLeaseStatus? ProducedLeaseStatus { get; set; } + /// The human approver who resolved the request, or null (e.g. still pending or auto-resolved). public Guid? ApproverId { get; set; } diff --git a/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_ReadInboxHistoryByCollectionIds.sql b/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_ReadInboxHistoryByCollectionIds.sql index aab04951e639..a1c07d4d6e60 100644 --- a/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_ReadInboxHistoryByCollectionIds.sql +++ b/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_ReadInboxHistoryByCollectionIds.sql @@ -7,7 +7,8 @@ BEGIN -- The approver history: resolved requests (anything no longer Pending) created on or after @Since, for the -- supplied (caller-manageable) collections. Same projection as the pending inbox. History rows that produced a - -- lease carry ProducedLeaseId so the client can target the Revoke action at the lease. + -- lease carry ProducedLeaseId so the client can target the Revoke action at the lease, plus ProducedLeaseStatus + -- so the client can tell a still-live lease from one that has ended (and not offer Revoke on an ended lease). SELECT LR.[Id], LR.[ExtensionOfLeaseId], @@ -22,6 +23,7 @@ BEGIN LR.[CreationDate], LR.[ResolvedDate], PL.[Id] AS [ProducedLeaseId], + PL.[Status] AS [ProducedLeaseStatus], RES.[ApproverId] AS [ApproverId], RES.[Comment] AS [ApproverComment], JSON_VALUE(C.[Data], '$.Name') AS [CipherName], @@ -34,7 +36,7 @@ BEGIN LEFT JOIN [dbo].[Collection] COL ON COL.[Id] = LR.[CollectionId] LEFT JOIN [dbo].[User] U ON U.[Id] = LR.[RequesterId] OUTER APPLY ( - SELECT TOP 1 L.[Id] + SELECT TOP 1 L.[Id], L.[Status] FROM [dbo].[AccessLease] L WHERE L.[AccessRequestId] = LR.[Id] ORDER BY L.[CreationDate] DESC diff --git a/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_ReadInboxPendingByCollectionIds.sql b/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_ReadInboxPendingByCollectionIds.sql index ce234fe60be8..88ec4e0ecc5d 100644 --- a/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_ReadInboxPendingByCollectionIds.sql +++ b/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_ReadInboxPendingByCollectionIds.sql @@ -23,6 +23,7 @@ BEGIN LR.[CreationDate], LR.[ResolvedDate], PL.[Id] AS [ProducedLeaseId], + PL.[Status] AS [ProducedLeaseStatus], RES.[ApproverId] AS [ApproverId], RES.[Comment] AS [ApproverComment], JSON_VALUE(C.[Data], '$.Name') AS [CipherName], @@ -35,7 +36,7 @@ BEGIN LEFT JOIN [dbo].[Collection] COL ON COL.[Id] = LR.[CollectionId] LEFT JOIN [dbo].[User] U ON U.[Id] = LR.[RequesterId] OUTER APPLY ( - SELECT TOP 1 L.[Id] + SELECT TOP 1 L.[Id], L.[Status] FROM [dbo].[AccessLease] L WHERE L.[AccessRequestId] = LR.[Id] ORDER BY L.[CreationDate] DESC diff --git a/test/Infrastructure.IntegrationTest/Pam/Repositories/AccessRequestRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Pam/Repositories/AccessRequestRepositoryTests.cs index 2e080311c1fd..ef6c778f6901 100644 --- a/test/Infrastructure.IntegrationTest/Pam/Repositories/AccessRequestRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/Pam/Repositories/AccessRequestRepositoryTests.cs @@ -80,6 +80,62 @@ await accessRequestRepository.CreateAsync(BuildRequest( Assert.Equal(resolved.Id, row.Id); } + [DatabaseTheory, DatabaseData] + public async Task GetManyInboxHistoryByCollectionIdsAsync_SurfacesProducedLeaseStatus( + IOrganizationRepository organizationRepository, + ICollectionRepository collectionRepository, + IAccessRequestRepository accessRequestRepository, + IAccessLeaseRepository accessLeaseRepository) + { + var organization = await organizationRepository.CreateTestOrganizationAsync(); + var collection = await collectionRepository.CreateTestCollectionAsync(organization); + var now = DateTime.UtcNow; + + var approved = BuildRequest(organization.Id, collection.Id, Guid.NewGuid(), AccessRequestStatus.Approved, now); + approved.NotBefore = now.AddHours(-1); + approved.NotAfter = now.AddHours(1); + approved = await accessRequestRepository.CreateAsync(approved); + + var lease = new AccessLease + { + Id = CoreHelpers.GenerateComb(), + AccessRequestId = approved.Id, + OrganizationId = approved.OrganizationId, + CollectionId = approved.CollectionId, + CipherId = approved.CipherId, + RequesterId = approved.RequesterId, + Status = AccessLeaseStatus.Active, + NotBefore = approved.NotBefore, + NotAfter = approved.NotAfter, + CreationDate = now, + }; + Assert.True(await accessLeaseRepository.CreateFromApprovedRequestAsync(lease, now)); + + // While the lease is active the inbox sees its Active status, so the client offers Revoke. + var active = Assert.Single(await accessRequestRepository.GetManyInboxHistoryByCollectionIdsAsync( + [collection.Id], now.AddDays(-1))); + Assert.Equal(lease.Id, active.ProducedLeaseId); + Assert.Equal(AccessLeaseStatus.Active, active.ProducedLeaseStatus); + + // After the lease ends the inbox sees the Revoked status (the window is unchanged), so the client can keep + // the row out of the Active group and stop offering a Revoke that the server would now reject. + var auditDecision = new AccessDecision + { + Id = CoreHelpers.GenerateComb(), + AccessRequestId = approved.Id, + DeciderKind = AccessDeciderKind.Human, + ApproverId = Guid.NewGuid(), + Verdict = AccessDecisionVerdict.Deny, + CreationDate = now, + }; + await accessLeaseRepository.RevokeAsync(lease, auditDecision, now); + + var revoked = Assert.Single(await accessRequestRepository.GetManyInboxHistoryByCollectionIdsAsync( + [collection.Id], now.AddDays(-1))); + Assert.Equal(lease.Id, revoked.ProducedLeaseId); + Assert.Equal(AccessLeaseStatus.Revoked, revoked.ProducedLeaseStatus); + } + [DatabaseTheory, DatabaseData] public async Task ResolveWithDecisionAsync_Approve_ResolvesRequestAndRecordsDecisionWithoutMintingLease( IOrganizationRepository organizationRepository, @@ -133,6 +189,7 @@ public async Task ResolveWithDecisionAsync_Approve_ResolvesRequestAndRecordsDeci // Approval records the verdict only: no lease exists until the requester activates the approved request, // so the requester does not yet hold access and the inbox row carries no produced lease. Assert.Null(row.ProducedLeaseId); + Assert.Null(row.ProducedLeaseStatus); Assert.Null(await accessLeaseRepository.GetByAccessRequestIdAsync(request.Id)); Assert.Null(await accessLeaseRepository.GetActiveByRequesterIdCipherIdAsync( request.RequesterId, request.CipherId, now)); diff --git a/util/Migrator/DbScripts/2026-06-11_00_AddProducedLeaseStatusToInbox.sql b/util/Migrator/DbScripts/2026-06-11_00_AddProducedLeaseStatusToInbox.sql new file mode 100644 index 000000000000..ffc3c266871c --- /dev/null +++ b/util/Migrator/DbScripts/2026-06-11_00_AddProducedLeaseStatusToInbox.sql @@ -0,0 +1,103 @@ +-- PAM Approver Inbox: surface the produced lease's status alongside its id. The inbox previously knew only that a +-- request had produced a lease (ProducedLeaseId), not whether that lease was still Active. A lease ended elsewhere +-- (the requester's "End lease", natural expiry, or another approver) still looked active in the inbox, leaving a +-- clickable Revoke that the server then rejects with 409. Selecting [Status] lets the client keep an ended lease out +-- of the "active" group so Revoke is no longer offered for it. + +CREATE OR ALTER PROCEDURE [dbo].[AccessRequest_ReadInboxPendingByCollectionIds] + @CollectionIds [dbo].[GuidIdArray] READONLY +AS +BEGIN + SET NOCOUNT ON + + SELECT + LR.[Id], + LR.[ExtensionOfLeaseId], + LR.[OrganizationId], + LR.[CollectionId], + LR.[CipherId], + LR.[RequesterId], + LR.[NotBefore], + LR.[NotAfter], + LR.[Reason], + LR.[Status], + LR.[CreationDate], + LR.[ResolvedDate], + PL.[Id] AS [ProducedLeaseId], + PL.[Status] AS [ProducedLeaseStatus], + RES.[ApproverId] AS [ApproverId], + RES.[Comment] AS [ApproverComment], + JSON_VALUE(C.[Data], '$.Name') AS [CipherName], + COL.[Name] AS [CollectionName], + U.[Name] AS [RequesterName], + U.[Email] AS [RequesterEmail] + FROM [dbo].[AccessRequest] LR + INNER JOIN @CollectionIds CI ON CI.[Id] = LR.[CollectionId] + LEFT JOIN [dbo].[Cipher] C ON C.[Id] = LR.[CipherId] + LEFT JOIN [dbo].[Collection] COL ON COL.[Id] = LR.[CollectionId] + LEFT JOIN [dbo].[User] U ON U.[Id] = LR.[RequesterId] + OUTER APPLY ( + SELECT TOP 1 L.[Id], L.[Status] + FROM [dbo].[AccessLease] L + WHERE L.[AccessRequestId] = LR.[Id] + ORDER BY L.[CreationDate] DESC + ) PL + OUTER APPLY ( + SELECT TOP 1 LD.[ApproverId], LD.[Comment] + FROM [dbo].[AccessDecision] LD + WHERE LD.[AccessRequestId] = LR.[Id] AND LD.[DeciderKind] = 1 -- Human + ORDER BY LD.[CreationDate] ASC + ) RES + WHERE LR.[Status] = 0 -- Pending +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[AccessRequest_ReadInboxHistoryByCollectionIds] + @CollectionIds [dbo].[GuidIdArray] READONLY, + @Since DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + SELECT + LR.[Id], + LR.[ExtensionOfLeaseId], + LR.[OrganizationId], + LR.[CollectionId], + LR.[CipherId], + LR.[RequesterId], + LR.[NotBefore], + LR.[NotAfter], + LR.[Reason], + LR.[Status], + LR.[CreationDate], + LR.[ResolvedDate], + PL.[Id] AS [ProducedLeaseId], + PL.[Status] AS [ProducedLeaseStatus], + RES.[ApproverId] AS [ApproverId], + RES.[Comment] AS [ApproverComment], + JSON_VALUE(C.[Data], '$.Name') AS [CipherName], + COL.[Name] AS [CollectionName], + U.[Name] AS [RequesterName], + U.[Email] AS [RequesterEmail] + FROM [dbo].[AccessRequest] LR + INNER JOIN @CollectionIds CI ON CI.[Id] = LR.[CollectionId] + LEFT JOIN [dbo].[Cipher] C ON C.[Id] = LR.[CipherId] + LEFT JOIN [dbo].[Collection] COL ON COL.[Id] = LR.[CollectionId] + LEFT JOIN [dbo].[User] U ON U.[Id] = LR.[RequesterId] + OUTER APPLY ( + SELECT TOP 1 L.[Id], L.[Status] + FROM [dbo].[AccessLease] L + WHERE L.[AccessRequestId] = LR.[Id] + ORDER BY L.[CreationDate] DESC + ) PL + OUTER APPLY ( + SELECT TOP 1 LD.[ApproverId], LD.[Comment] + FROM [dbo].[AccessDecision] LD + WHERE LD.[AccessRequestId] = LR.[Id] AND LD.[DeciderKind] = 1 -- Human + ORDER BY LD.[CreationDate] ASC + ) RES + WHERE LR.[Status] <> 0 -- not Pending + AND LR.[CreationDate] >= @Since +END +GO From 7601d7f75f71c23915a4e4a62ed595f8dfe16219 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anders=20=C3=85berg?= Date: Thu, 11 Jun 2026 12:23:43 +0200 Subject: [PATCH 19/54] Enforce per-cipher single-active-lease in PAM leasing Add SingleActiveLease to AccessRule (entity, API request/response, AccessRule_Create/_Update, migration) and enforce it when a lease is minted. Scope is per-cipher with union/OR gating: the singleton binds only when every collection a member reaches the cipher through is governed by a single-active-lease rule; any ungated or non-singleton path is an escape that leaves them unconstrained. The mint procs take @EnforceSingleActiveLease and serialize same-cipher activations under a UPDLOCK/HOLDLOCK range lock inside an explicit transaction; the activate and auto-approve paths surface contention as a retryable conflict. --- .../Models/Request/AccessRuleRequestModel.cs | 6 + .../Response/AccessRuleResponseModel.cs | 2 + ...OrganizationServiceCollectionExtensions.cs | 1 + src/Core/Pam/Entities/AccessRule.cs | 8 + src/Core/Pam/Enums/AccessLeaseMintOutcome.cs | 23 ++ src/Core/Pam/Models/AccessRuleDetails.cs | 1 + .../Commands/ActivateAccessRequestCommand.cs | 16 +- .../Commands/SubmitAccessRequestCommand.cs | 15 +- .../Commands/UpdateAccessRuleCommand.cs | 1 + .../Repositories/IAccessLeaseRepository.cs | 17 +- .../Services/ISingleActiveLeaseEvaluator.cs | 13 + .../Services/SingleActiveLeaseEvaluator.cs | 61 +++++ .../Pam/Repositories/AccessLeaseRepository.cs | 19 +- .../AccessLease_CreateAutoApproved.sql | 27 +- .../AccessLease_CreateFromApprovedRequest.sql | 36 ++- .../Stored Procedures/AccessRule_Create.sql | 3 + .../Stored Procedures/AccessRule_Update.sql | 2 + src/Sql/dbo/Pam/Tables/AccessRule.sql | 1 + .../ActivateAccessRequestCommandTests.cs | 70 +++++- .../SubmitAccessRequestCommandTests.cs | 59 ++++- .../Commands/UpdateAccessRuleCommandTests.cs | 5 +- .../SingleActiveLeaseEvaluatorTests.cs | 99 ++++++++ .../AccessLeaseRepositoryTests.cs | 72 ++++-- .../AccessRequestRepositoryTests.cs | 6 +- .../2026-06-11_01_AddSingleActiveLease.sql | 235 ++++++++++++++++++ 25 files changed, 751 insertions(+), 47 deletions(-) create mode 100644 src/Core/Pam/Enums/AccessLeaseMintOutcome.cs create mode 100644 src/Core/Pam/Services/ISingleActiveLeaseEvaluator.cs create mode 100644 src/Core/Pam/Services/SingleActiveLeaseEvaluator.cs create mode 100644 test/Core.Test/Pam/Services/SingleActiveLeaseEvaluatorTests.cs create mode 100644 util/Migrator/DbScripts/2026-06-11_01_AddSingleActiveLease.sql diff --git a/src/Api/Pam/Models/Request/AccessRuleRequestModel.cs b/src/Api/Pam/Models/Request/AccessRuleRequestModel.cs index ac23ad10486a..7c34554019f5 100644 --- a/src/Api/Pam/Models/Request/AccessRuleRequestModel.cs +++ b/src/Api/Pam/Models/Request/AccessRuleRequestModel.cs @@ -15,6 +15,11 @@ public class AccessRuleRequestModel [Required] public object Conditions { get; set; } = null!; + /// + /// When true, the rule enforces a per-cipher singleton (at most one active lease per cipher across all users). + /// + public bool SingleActiveLease { get; set; } + /// /// The complete set of collections this rule governs. The rule's associations are replaced to match /// exactly this set; an empty array clears all associations. @@ -28,6 +33,7 @@ public class AccessRuleRequestModel Name = Name, Description = Description, Conditions = SerializeConditions(Conditions), + SingleActiveLease = SingleActiveLease, }; private static string SerializeConditions(object conditions) => conditions switch diff --git a/src/Api/Pam/Models/Response/AccessRuleResponseModel.cs b/src/Api/Pam/Models/Response/AccessRuleResponseModel.cs index 420da672aa47..9bff875880c9 100644 --- a/src/Api/Pam/Models/Response/AccessRuleResponseModel.cs +++ b/src/Api/Pam/Models/Response/AccessRuleResponseModel.cs @@ -16,6 +16,7 @@ public AccessRuleResponseModel(AccessRuleDetails rule) Name = rule.Name; Description = rule.Description; Conditions = TryParseConditions(rule.Conditions); + SingleActiveLease = rule.SingleActiveLease; CreationDate = rule.CreationDate; RevisionDate = rule.RevisionDate; Collections = rule.CollectionIds.ToList(); @@ -26,6 +27,7 @@ public AccessRuleResponseModel(AccessRuleDetails rule) public string Name { get; } public string? Description { get; } public JsonElement? Conditions { get; } + public bool SingleActiveLease { get; } public DateTime CreationDate { get; } public DateTime RevisionDate { get; } public IEnumerable Collections { get; } diff --git a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs index 260dbfe5e230..b03b2375e3b0 100644 --- a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs +++ b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs @@ -201,6 +201,7 @@ public static void AddPamServices(this IServiceCollection services) services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddSingleton(); services.AddScoped(); services.AddScoped(); diff --git a/src/Core/Pam/Entities/AccessRule.cs b/src/Core/Pam/Entities/AccessRule.cs index b8fb8d9fd9fb..00b068eaea0c 100644 --- a/src/Core/Pam/Entities/AccessRule.cs +++ b/src/Core/Pam/Entities/AccessRule.cs @@ -24,6 +24,14 @@ public class AccessRule : ITableObject /// public string Conditions { get; set; } = null!; + /// + /// When true, the rule asks for a per-cipher singleton: at most one active lease may exist for a given cipher + /// across all users. The constraint binds for a member only when every collection through which they reach the + /// cipher is governed by a rule with this flag set; any ungated or non-singleton path is an escape that leaves + /// the member unconstrained. + /// + public bool SingleActiveLease { get; set; } + public DateTime CreationDate { get; set; } = DateTime.UtcNow; public DateTime RevisionDate { get; set; } = DateTime.UtcNow; diff --git a/src/Core/Pam/Enums/AccessLeaseMintOutcome.cs b/src/Core/Pam/Enums/AccessLeaseMintOutcome.cs new file mode 100644 index 000000000000..e76a3d72f5d9 --- /dev/null +++ b/src/Core/Pam/Enums/AccessLeaseMintOutcome.cs @@ -0,0 +1,23 @@ +namespace Bit.Core.Pam.Enums; + +/// +/// The result of a race-safe lease mint. The mint stored procedures return a distinct integer code so the caller can +/// tell a lost-the-race precondition failure apart from a per-cipher single-active-lease conflict. +/// +public enum AccessLeaseMintOutcome +{ + /// The active lease was minted (stored proc returned 1). + Minted = 1, + + /// + /// A precondition no longer held when the guarded insert ran (stored proc returned 0, or the unique-index + /// backstop fired). A concurrent activation likely won; the caller re-reads the winner. + /// + PreconditionFailed = 0, + + /// + /// Another active in-window lease already exists for this cipher and the governing rule enforces a per-cipher + /// singleton (stored proc returned -1). Nothing was persisted. + /// + SingleActiveLeaseConflict = -1, +} diff --git a/src/Core/Pam/Models/AccessRuleDetails.cs b/src/Core/Pam/Models/AccessRuleDetails.cs index 10e3d2c16ef0..59d9cefece3c 100644 --- a/src/Core/Pam/Models/AccessRuleDetails.cs +++ b/src/Core/Pam/Models/AccessRuleDetails.cs @@ -16,6 +16,7 @@ public class AccessRuleDetails : AccessRule Name = rule.Name, Description = rule.Description, Conditions = rule.Conditions, + SingleActiveLease = rule.SingleActiveLease, CreationDate = rule.CreationDate, RevisionDate = rule.RevisionDate, CollectionIds = collectionIds, diff --git a/src/Core/Pam/OrganizationFeatures/Commands/ActivateAccessRequestCommand.cs b/src/Core/Pam/OrganizationFeatures/Commands/ActivateAccessRequestCommand.cs index e99e6e3b82ed..a52aebfd117b 100644 --- a/src/Core/Pam/OrganizationFeatures/Commands/ActivateAccessRequestCommand.cs +++ b/src/Core/Pam/OrganizationFeatures/Commands/ActivateAccessRequestCommand.cs @@ -12,17 +12,20 @@ public class ActivateAccessRequestCommand : IActivateAccessRequestCommand private readonly IAccessRequestRepository _accessRequestRepository; private readonly IAccessLeaseRepository _accessLeaseRepository; private readonly IApproverInboxNotifier _approverInboxNotifier; + private readonly ISingleActiveLeaseEvaluator _singleActiveLeaseEvaluator; private readonly TimeProvider _timeProvider; public ActivateAccessRequestCommand( IAccessRequestRepository accessRequestRepository, IAccessLeaseRepository accessLeaseRepository, IApproverInboxNotifier approverInboxNotifier, + ISingleActiveLeaseEvaluator singleActiveLeaseEvaluator, TimeProvider timeProvider) { _accessRequestRepository = accessRequestRepository; _accessLeaseRepository = accessLeaseRepository; _approverInboxNotifier = approverInboxNotifier; + _singleActiveLeaseEvaluator = singleActiveLeaseEvaluator; _timeProvider = timeProvider; } @@ -84,7 +87,18 @@ public async Task ActivateAsync(Guid userId, Guid requestId) }; lease.SetNewId(); - if (!await _accessLeaseRepository.CreateFromApprovedRequestAsync(lease, now)) + // The per-cipher singleton binds only when every path the caller reaches the cipher through is governed by a + // singleton rule; an escape path leaves them unconstrained. The mint proc enforces it under a range lock. + var enforceSingleActiveLease = await _singleActiveLeaseEvaluator.AppliesAsync(userId, request.CipherId); + + var outcome = await _accessLeaseRepository.CreateFromApprovedRequestAsync(lease, now, enforceSingleActiveLease); + + if (outcome == AccessLeaseMintOutcome.SingleActiveLeaseConflict) + { + throw new ConflictException("Another active lease exists for this item. Try again once it ends."); + } + + if (outcome == AccessLeaseMintOutcome.PreconditionFailed) { // Lost a race: the guarded insert re-checks every precondition, so a miss means another activation won // or the request changed underneath us. If the winner's lease is live, activation still succeeded from diff --git a/src/Core/Pam/OrganizationFeatures/Commands/SubmitAccessRequestCommand.cs b/src/Core/Pam/OrganizationFeatures/Commands/SubmitAccessRequestCommand.cs index 368838c883e3..e0a893f92ab7 100644 --- a/src/Core/Pam/OrganizationFeatures/Commands/SubmitAccessRequestCommand.cs +++ b/src/Core/Pam/OrganizationFeatures/Commands/SubmitAccessRequestCommand.cs @@ -27,6 +27,7 @@ public class SubmitAccessRequestCommand : ISubmitAccessRequestCommand private readonly IAccessLeaseRepository _accessLeaseRepository; private readonly IAccessRequestRepository _accessRequestRepository; private readonly IApproverInboxNotifier _approverInboxNotifier; + private readonly ISingleActiveLeaseEvaluator _singleActiveLeaseEvaluator; private readonly TimeProvider _timeProvider; public SubmitAccessRequestCommand( @@ -37,6 +38,7 @@ public SubmitAccessRequestCommand( IAccessLeaseRepository accessLeaseRepository, IAccessRequestRepository accessRequestRepository, IApproverInboxNotifier approverInboxNotifier, + ISingleActiveLeaseEvaluator singleActiveLeaseEvaluator, TimeProvider timeProvider) { _cipherRepository = cipherRepository; @@ -46,6 +48,7 @@ public SubmitAccessRequestCommand( _accessLeaseRepository = accessLeaseRepository; _accessRequestRepository = accessRequestRepository; _approverInboxNotifier = approverInboxNotifier; + _singleActiveLeaseEvaluator = singleActiveLeaseEvaluator; _timeProvider = timeProvider; } @@ -154,7 +157,17 @@ private async Task IssueAutomaticLeaseAsync( }; lease.SetNewId(); - await _accessLeaseRepository.CreateAutoApprovedAsync(request, decision, lease, now); + // The per-cipher singleton binds only when every path the caller reaches the cipher through is governed by a + // singleton rule; an escape path leaves them unconstrained. The mint proc enforces it under a range lock and + // rolls back the whole insert on conflict, so nothing is persisted when this throws. + var enforceSingleActiveLease = await _singleActiveLeaseEvaluator.AppliesAsync(userId, cipherId); + + var outcome = await _accessLeaseRepository.CreateAutoApprovedAsync(request, decision, lease, now, + enforceSingleActiveLease); + if (outcome == AccessLeaseMintOutcome.SingleActiveLeaseConflict) + { + throw new BadRequestException("Another active lease exists for this item. Try again once it ends."); + } return AccessRequestResult.Automatic(lease); } diff --git a/src/Core/Pam/OrganizationFeatures/Commands/UpdateAccessRuleCommand.cs b/src/Core/Pam/OrganizationFeatures/Commands/UpdateAccessRuleCommand.cs index d0a113686855..6a4ac663d343 100644 --- a/src/Core/Pam/OrganizationFeatures/Commands/UpdateAccessRuleCommand.cs +++ b/src/Core/Pam/OrganizationFeatures/Commands/UpdateAccessRuleCommand.cs @@ -64,6 +64,7 @@ public async Task UpdateAsync(Guid organizationId, Guid id, A Name = update.Name, Description = update.Description, Conditions = update.Conditions, + SingleActiveLease = update.SingleActiveLease, CreationDate = existing.CreationDate, RevisionDate = _timeProvider.GetUtcNow().UtcDateTime, }; diff --git a/src/Core/Pam/Repositories/IAccessLeaseRepository.cs b/src/Core/Pam/Repositories/IAccessLeaseRepository.cs index b709b50afe9e..8baf7905b0dc 100644 --- a/src/Core/Pam/Repositories/IAccessLeaseRepository.cs +++ b/src/Core/Pam/Repositories/IAccessLeaseRepository.cs @@ -1,4 +1,5 @@ using Bit.Core.Pam.Entities; +using Bit.Core.Pam.Enums; namespace Bit.Core.Pam.Repositories; @@ -25,17 +26,23 @@ public interface IAccessLeaseRepository /// /// Atomically creates an auto-approved , its automatic , and an /// active in a single transaction. The three entities must already have their ids assigned. - /// The automatic path's request, decision, and lease never diverge because they are written together here. + /// The automatic path's request, decision, and lease never diverge because they are written together here. When + /// is true the transaction first checks the per-cipher singleton and + /// rolls back without persisting anything if another active in-window lease exists for the cipher. /// - Task CreateAutoApprovedAsync(AccessRequest request, AccessDecision decision, AccessLease lease, DateTime now); + Task CreateAutoApprovedAsync(AccessRequest request, AccessDecision decision, + AccessLease lease, DateTime now, bool enforceSingleActiveLease); /// /// Race-safely mints the active lease for an approved human request, copying the request's window. The insert /// re-checks ownership, Approved status, an open window, and that the request has not already produced a lease; - /// returns false when any precondition no longer holds (e.g. a concurrent activation won). The lease must - /// already have its id assigned. + /// returns when any precondition no longer holds (e.g. a + /// concurrent activation won). When is true and another active + /// in-window lease already exists for the cipher, returns + /// without minting. The lease must already have its id assigned. /// - Task CreateFromApprovedRequestAsync(AccessLease lease, DateTime now); + Task CreateFromApprovedRequestAsync(AccessLease lease, DateTime now, + bool enforceSingleActiveLease); /// /// Atomically revokes an active lease (setting its revoked date and revoker) and records the revocation reason as diff --git a/src/Core/Pam/Services/ISingleActiveLeaseEvaluator.cs b/src/Core/Pam/Services/ISingleActiveLeaseEvaluator.cs new file mode 100644 index 000000000000..80005f45f80f --- /dev/null +++ b/src/Core/Pam/Services/ISingleActiveLeaseEvaluator.cs @@ -0,0 +1,13 @@ +namespace Bit.Core.Pam.Services; + +public interface ISingleActiveLeaseEvaluator +{ + /// + /// Decides whether the per-cipher single-active-lease constraint binds for the caller, using the same + /// union-over-paths logic as cipher gating. Returns true only when the caller reaches the cipher through at + /// least one collection and every such collection is governed by a rule whose + /// SingleActiveLease is true. Any ungated path (no access rule) or a path governed by a non-singleton + /// rule is an escape that leaves the caller unconstrained, so the method returns false. + /// + Task AppliesAsync(Guid userId, Guid cipherId); +} diff --git a/src/Core/Pam/Services/SingleActiveLeaseEvaluator.cs b/src/Core/Pam/Services/SingleActiveLeaseEvaluator.cs new file mode 100644 index 000000000000..f7e4cc65beaa --- /dev/null +++ b/src/Core/Pam/Services/SingleActiveLeaseEvaluator.cs @@ -0,0 +1,61 @@ +using Bit.Core.Pam.Repositories; +using Bit.Core.Repositories; + +namespace Bit.Core.Pam.Services; + +public class SingleActiveLeaseEvaluator : ISingleActiveLeaseEvaluator +{ + private readonly ICollectionCipherRepository _collectionCipherRepository; + private readonly ICollectionRepository _collectionRepository; + private readonly IAccessRuleRepository _accessRuleRepository; + + public SingleActiveLeaseEvaluator( + ICollectionCipherRepository collectionCipherRepository, + ICollectionRepository collectionRepository, + IAccessRuleRepository accessRuleRepository) + { + _collectionCipherRepository = collectionCipherRepository; + _collectionRepository = collectionRepository; + _accessRuleRepository = accessRuleRepository; + } + + public async Task AppliesAsync(Guid userId, Guid cipherId) + { + var collectionCiphers = await _collectionCipherRepository.GetManyByUserIdCipherIdAsync(userId, cipherId); + if (collectionCiphers.Count == 0) + { + // No reachable collection: there is no path, so the constraint does not bind. + return false; + } + + var collectionIds = collectionCiphers.Select(cc => cc.CollectionId).ToHashSet(); + var collections = await _collectionRepository.GetManyByManyIdsAsync(collectionIds); + + var paths = collections.Where(c => collectionIds.Contains(c.Id)).ToList(); + if (paths.Count == 0) + { + return false; + } + + foreach (var collection in paths) + { + // An ungated path is an escape: the caller can reach the cipher without any singleton rule, so the + // constraint does not bind for them. + if (!collection.AccessRuleId.HasValue) + { + return false; + } + + var accessRule = await _accessRuleRepository.GetByIdAsync(collection.AccessRuleId.Value); + + // A missing rule, or a rule that does not ask for a singleton, is likewise an escape path. + if (accessRule is null || !accessRule.SingleActiveLease) + { + return false; + } + } + + // Every path is governed by a singleton rule: the constraint binds. + return true; + } +} diff --git a/src/Infrastructure.Dapper/Pam/Repositories/AccessLeaseRepository.cs b/src/Infrastructure.Dapper/Pam/Repositories/AccessLeaseRepository.cs index a461cfa6021f..c6b2a03f9ee4 100644 --- a/src/Infrastructure.Dapper/Pam/Repositories/AccessLeaseRepository.cs +++ b/src/Infrastructure.Dapper/Pam/Repositories/AccessLeaseRepository.cs @@ -1,5 +1,6 @@ using System.Data; using Bit.Core.Pam.Entities; +using Bit.Core.Pam.Enums; using Bit.Core.Pam.Repositories; using Bit.Core.Settings; using Bit.Infrastructure.Dapper.Repositories; @@ -53,10 +54,11 @@ public async Task> GetManyActiveByRequesterIdAsync(Guid return results.ToList(); } - public async Task CreateAutoApprovedAsync(AccessRequest request, AccessDecision decision, AccessLease lease, DateTime now) + public async Task CreateAutoApprovedAsync(AccessRequest request, AccessDecision decision, + AccessLease lease, DateTime now, bool enforceSingleActiveLease) { await using var connection = new SqlConnection(ConnectionString); - await connection.ExecuteAsync( + var result = await connection.ExecuteScalarAsync( $"[{Schema}].[AccessLease_CreateAutoApproved]", new { @@ -72,16 +74,20 @@ await connection.ExecuteAsync( request.Reason, decision.ConditionKind, Now = now, + EnforceSingleActiveLease = enforceSingleActiveLease, }, commandType: CommandType.StoredProcedure); + + return (AccessLeaseMintOutcome)result; } - public async Task CreateFromApprovedRequestAsync(AccessLease lease, DateTime now) + public async Task CreateFromApprovedRequestAsync(AccessLease lease, DateTime now, + bool enforceSingleActiveLease) { await using var connection = new SqlConnection(ConnectionString); try { - var rows = await connection.ExecuteScalarAsync( + var result = await connection.ExecuteScalarAsync( $"[{Schema}].[AccessLease_CreateFromApprovedRequest]", new { @@ -89,16 +95,17 @@ public async Task CreateFromApprovedRequestAsync(AccessLease lease, DateTi lease.AccessRequestId, lease.RequesterId, Now = now, + EnforceSingleActiveLease = enforceSingleActiveLease, }, commandType: CommandType.StoredProcedure); - return rows == 1; + return (AccessLeaseMintOutcome)result; } catch (SqlException e) when (e.Number is 2601 or 2627) { // Unique-index backstop ([IX_AccessLease_AccessRequestId]): a concurrent activation won the race after // our NOT EXISTS guard passed. Same outcome as the guard catching it — the caller re-reads the winner. - return false; + return AccessLeaseMintOutcome.PreconditionFailed; } } diff --git a/src/Sql/dbo/Pam/Stored Procedures/AccessLease_CreateAutoApproved.sql b/src/Sql/dbo/Pam/Stored Procedures/AccessLease_CreateAutoApproved.sql index 691089752135..96db7e6cca11 100644 --- a/src/Sql/dbo/Pam/Stored Procedures/AccessLease_CreateAutoApproved.sql +++ b/src/Sql/dbo/Pam/Stored Procedures/AccessLease_CreateAutoApproved.sql @@ -10,13 +10,35 @@ CREATE PROCEDURE [dbo].[AccessLease_CreateAutoApproved] @NotAfter DATETIME2(7), @Reason NVARCHAR(MAX) = NULL, @ConditionKind NVARCHAR(50) = NULL, - @Now DATETIME2(7) + @Now DATETIME2(7), + @EnforceSingleActiveLease BIT = 0 AS BEGIN SET NOCOUNT ON BEGIN TRANSACTION AccessLease_CreateAutoApproved + -- Per-cipher singleton guard. When the governing rule(s) ask for a single active lease, the auto-approved lease + -- is minted only if no other active in-window lease exists for the same cipher across all users. The check runs + -- inside the transaction before any insert so the UPDLOCK, HOLDLOCK range lock serializes concurrent activations + -- of the same cipher; a conflict rolls back leaving nothing persisted and returns -1. + IF @EnforceSingleActiveLease = 1 + BEGIN + IF EXISTS ( + SELECT 1 + FROM [dbo].[AccessLease] WITH (UPDLOCK, HOLDLOCK) + WHERE [CipherId] = @CipherId + AND [Status] = 0 /* Active */ + AND [NotBefore] <= @Now + AND [NotAfter] > @Now + ) + BEGIN + ROLLBACK TRANSACTION AccessLease_CreateAutoApproved + SELECT -1 + RETURN + END + END + -- The request is created already resolved (Approved). ExtensionOfLeaseId stays NULL: it is reserved for extension -- requests; provenance for an original lease flows the other way, via AccessLease.AccessRequestId. INSERT INTO [dbo].[AccessRequest] @@ -53,4 +75,7 @@ BEGIN ) COMMIT TRANSACTION AccessLease_CreateAutoApproved + + -- 1 = minted (request + decision + lease all written). + SELECT 1 END diff --git a/src/Sql/dbo/Pam/Stored Procedures/AccessLease_CreateFromApprovedRequest.sql b/src/Sql/dbo/Pam/Stored Procedures/AccessLease_CreateFromApprovedRequest.sql index 9ac5042e1c69..760da15a47ee 100644 --- a/src/Sql/dbo/Pam/Stored Procedures/AccessLease_CreateFromApprovedRequest.sql +++ b/src/Sql/dbo/Pam/Stored Procedures/AccessLease_CreateFromApprovedRequest.sql @@ -2,10 +2,37 @@ CREATE PROCEDURE [dbo].[AccessLease_CreateFromApprovedRequest] @AccessLeaseId UNIQUEIDENTIFIER, @AccessRequestId UNIQUEIDENTIFIER, @RequesterId UNIQUEIDENTIFIER, - @Now DATETIME2(7) + @Now DATETIME2(7), + @EnforceSingleActiveLease BIT = 0 AS BEGIN SET NOCOUNT ON + -- An explicit transaction is required so the singleton guard's range lock is held until the INSERT commits; + -- XACT_ABORT guarantees the transaction is rolled back (and the pooled connection left clean) if the + -- unique-index backstop [IX_AccessLease_AccessRequestId] trips on a concurrent activation of the same request. + SET XACT_ABORT ON + + BEGIN TRANSACTION + + -- Per-cipher singleton guard. When the governing rule(s) ask for a single active lease, activation is allowed + -- only if no other active in-window lease exists for the same cipher across all users. The UPDLOCK, HOLDLOCK + -- range lock is held for the life of this transaction, so it serializes against the INSERT below: a concurrent + -- same-cipher activation blocks here until this transaction commits, then sees the new lease and is rejected. + -- Outcome -1 is distinct from the precondition-fail outcome (0) so the caller can surface a 409 conflict. + IF @EnforceSingleActiveLease = 1 + AND EXISTS ( + SELECT 1 + FROM [dbo].[AccessLease] WITH (UPDLOCK, HOLDLOCK) + WHERE [CipherId] = (SELECT [CipherId] FROM [dbo].[AccessRequest] WHERE [Id] = @AccessRequestId) + AND [Status] = 0 /* Active */ + AND [NotBefore] <= @Now + AND [NotAfter] > @Now + ) + BEGIN + ROLLBACK TRANSACTION + SELECT -1 + RETURN + END -- Activation of an approved request: mints the active lease that authorizes access, spanning the request's -- approved window. Every application-level precondition is re-checked inside the INSERT so a concurrent @@ -29,5 +56,10 @@ BEGIN AND AR.[NotAfter] > @Now AND NOT EXISTS (SELECT 1 FROM [dbo].[AccessLease] AL WHERE AL.[AccessRequestId] = AR.[Id]) - SELECT @@ROWCOUNT + DECLARE @Rows INT = @@ROWCOUNT + + COMMIT TRANSACTION + + -- 1 = minted, 0 = precondition no longer held (caller re-reads the winner). + SELECT CASE WHEN @Rows = 1 THEN 1 ELSE 0 END END diff --git a/src/Sql/dbo/Pam/Stored Procedures/AccessRule_Create.sql b/src/Sql/dbo/Pam/Stored Procedures/AccessRule_Create.sql index cf1326af2fe6..25f8cc1b142e 100644 --- a/src/Sql/dbo/Pam/Stored Procedures/AccessRule_Create.sql +++ b/src/Sql/dbo/Pam/Stored Procedures/AccessRule_Create.sql @@ -4,6 +4,7 @@ CREATE PROCEDURE [dbo].[AccessRule_Create] @Name NVARCHAR(256), @Description NVARCHAR(MAX) = NULL, @Conditions NVARCHAR(MAX), + @SingleActiveLease BIT = 0, @CreationDate DATETIME2(7), @RevisionDate DATETIME2(7) AS @@ -17,6 +18,7 @@ BEGIN [Name], [Description], [Conditions], + [SingleActiveLease], [CreationDate], [RevisionDate] ) @@ -27,6 +29,7 @@ BEGIN @Name, @Description, @Conditions, + @SingleActiveLease, @CreationDate, @RevisionDate ) diff --git a/src/Sql/dbo/Pam/Stored Procedures/AccessRule_Update.sql b/src/Sql/dbo/Pam/Stored Procedures/AccessRule_Update.sql index 209557799268..a7ee754efec7 100644 --- a/src/Sql/dbo/Pam/Stored Procedures/AccessRule_Update.sql +++ b/src/Sql/dbo/Pam/Stored Procedures/AccessRule_Update.sql @@ -4,6 +4,7 @@ CREATE PROCEDURE [dbo].[AccessRule_Update] @Name NVARCHAR(256), @Description NVARCHAR(MAX) = NULL, @Conditions NVARCHAR(MAX), + @SingleActiveLease BIT = 0, @CreationDate DATETIME2(7), @RevisionDate DATETIME2(7) AS @@ -17,6 +18,7 @@ BEGIN [Name] = @Name, [Description] = @Description, [Conditions] = @Conditions, + [SingleActiveLease] = @SingleActiveLease, [CreationDate] = @CreationDate, [RevisionDate] = @RevisionDate WHERE diff --git a/src/Sql/dbo/Pam/Tables/AccessRule.sql b/src/Sql/dbo/Pam/Tables/AccessRule.sql index 29db15f0f1d9..6c8f9295c9f1 100644 --- a/src/Sql/dbo/Pam/Tables/AccessRule.sql +++ b/src/Sql/dbo/Pam/Tables/AccessRule.sql @@ -4,6 +4,7 @@ CREATE TABLE [dbo].[AccessRule] ( [Name] NVARCHAR(256) NOT NULL, [Description] NVARCHAR(MAX) NULL, [Conditions] NVARCHAR(MAX) NOT NULL, + [SingleActiveLease] BIT NOT NULL CONSTRAINT [DF_AccessRule_SingleActiveLease] DEFAULT (0), [CreationDate] DATETIME2(7) NOT NULL, [RevisionDate] DATETIME2(7) NOT NULL, CONSTRAINT [PK_AccessRule] PRIMARY KEY CLUSTERED ([Id] ASC), diff --git a/test/Core.Test/Pam/Commands/ActivateAccessRequestCommandTests.cs b/test/Core.Test/Pam/Commands/ActivateAccessRequestCommandTests.cs index a181d611f8d5..e388a09b591b 100644 --- a/test/Core.Test/Pam/Commands/ActivateAccessRequestCommandTests.cs +++ b/test/Core.Test/Pam/Commands/ActivateAccessRequestCommandTests.cs @@ -50,7 +50,7 @@ public async Task ActivateAsync_NotApproved_ThrowsConflict(AccessRequestStatus s await Assert.ThrowsAsync( () => sutProvider.Sut.ActivateAsync(request.RequesterId, request.Id)); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() - .CreateFromApprovedRequestAsync(default!, default); + .CreateFromApprovedRequestAsync(default!, default, default); } [Theory, BitAutoData] @@ -67,7 +67,7 @@ public async Task ActivateAsync_AlreadyActivated_LiveLease_ReturnsExistingWithou Assert.Same(existing, result); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() - .CreateFromApprovedRequestAsync(default!, default); + .CreateFromApprovedRequestAsync(default!, default, default); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() .NotifyCollectionApproversAsync(default); } @@ -134,7 +134,8 @@ public async Task ActivateAsync_Approved_MintsLeaseSpanningRequestWindow(AccessR var sutProvider = Setup(); SetupApprovedRequest(sutProvider, request); sutProvider.GetDependency() - .CreateFromApprovedRequestAsync(Arg.Any(), _now).Returns(true); + .CreateFromApprovedRequestAsync(Arg.Any(), _now, Arg.Any()) + .Returns(AccessLeaseMintOutcome.Minted); var result = await sutProvider.Sut.ActivateAsync(request.RequesterId, request.Id); @@ -150,7 +151,7 @@ public async Task ActivateAsync_Approved_MintsLeaseSpanningRequestWindow(AccessR Assert.Equal(_now, result.CreationDate); Assert.NotEqual(default, result.Id); await sutProvider.GetDependency().Received(1) - .CreateFromApprovedRequestAsync(result, _now); + .CreateFromApprovedRequestAsync(result, _now, Arg.Any()); await sutProvider.GetDependency().Received(1) .NotifyCollectionApproversAsync(request.CollectionId); } @@ -163,7 +164,8 @@ public async Task ActivateAsync_LostRace_WinnerLive_ReturnsWinner(AccessRequest winner.Status = AccessLeaseStatus.Active; winner.NotAfter = _now.AddMinutes(30); sutProvider.GetDependency() - .CreateFromApprovedRequestAsync(Arg.Any(), _now).Returns(false); + .CreateFromApprovedRequestAsync(Arg.Any(), _now, Arg.Any()) + .Returns(AccessLeaseMintOutcome.PreconditionFailed); sutProvider.GetDependency().GetByAccessRequestIdAsync(request.Id) .Returns((AccessLease?)null, winner); @@ -180,7 +182,8 @@ public async Task ActivateAsync_LostRace_NoLiveLease_ThrowsConflict(AccessReques var sutProvider = Setup(); SetupApprovedRequest(sutProvider, request); sutProvider.GetDependency() - .CreateFromApprovedRequestAsync(Arg.Any(), _now).Returns(false); + .CreateFromApprovedRequestAsync(Arg.Any(), _now, Arg.Any()) + .Returns(AccessLeaseMintOutcome.PreconditionFailed); sutProvider.GetDependency().GetByAccessRequestIdAsync(request.Id) .Returns((AccessLease?)null); @@ -188,6 +191,61 @@ await Assert.ThrowsAsync( () => sutProvider.Sut.ActivateAsync(request.RequesterId, request.Id)); } + [Theory, BitAutoData] + public async Task ActivateAsync_SingleActiveLeaseApplies_PassesEnforceTrue_AndMints(AccessRequest request) + { + var sutProvider = Setup(); + SetupApprovedRequest(sutProvider, request); + // The constraint binds for this caller and cipher: enforcement must be passed through to the mint. + sutProvider.GetDependency().AppliesAsync(request.RequesterId, request.CipherId) + .Returns(true); + sutProvider.GetDependency() + .CreateFromApprovedRequestAsync(Arg.Any(), _now, true) + .Returns(AccessLeaseMintOutcome.Minted); + + var result = await sutProvider.Sut.ActivateAsync(request.RequesterId, request.Id); + + Assert.Equal(AccessLeaseStatus.Active, result.Status); + await sutProvider.GetDependency().Received(1) + .CreateFromApprovedRequestAsync(result, _now, true); + } + + [Theory, BitAutoData] + public async Task ActivateAsync_SingleActiveLeaseConflict_ThrowsConflict(AccessRequest request) + { + var sutProvider = Setup(); + SetupApprovedRequest(sutProvider, request); + sutProvider.GetDependency().AppliesAsync(request.RequesterId, request.CipherId) + .Returns(true); + sutProvider.GetDependency() + .CreateFromApprovedRequestAsync(Arg.Any(), _now, true) + .Returns(AccessLeaseMintOutcome.SingleActiveLeaseConflict); + + var ex = await Assert.ThrowsAsync( + () => sutProvider.Sut.ActivateAsync(request.RequesterId, request.Id)); + Assert.Contains("Another active lease exists", ex.Message); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .NotifyCollectionApproversAsync(default); + } + + [Theory, BitAutoData] + public async Task ActivateAsync_EscapePathExists_PassesEnforceFalse(AccessRequest request) + { + var sutProvider = Setup(); + SetupApprovedRequest(sutProvider, request); + // An escape path leaves the caller unconstrained, so enforcement must be passed as false. + sutProvider.GetDependency().AppliesAsync(request.RequesterId, request.CipherId) + .Returns(false); + sutProvider.GetDependency() + .CreateFromApprovedRequestAsync(Arg.Any(), _now, false) + .Returns(AccessLeaseMintOutcome.Minted); + + await sutProvider.Sut.ActivateAsync(request.RequesterId, request.Id); + + await sutProvider.GetDependency().Received(1) + .CreateFromApprovedRequestAsync(Arg.Any(), _now, false); + } + private static SutProvider Setup() { var sutProvider = new SutProvider().WithFakeTimeProvider().Create(); diff --git a/test/Core.Test/Pam/Commands/SubmitAccessRequestCommandTests.cs b/test/Core.Test/Pam/Commands/SubmitAccessRequestCommandTests.cs index cd2faa6efc2f..039e98eb95eb 100644 --- a/test/Core.Test/Pam/Commands/SubmitAccessRequestCommandTests.cs +++ b/test/Core.Test/Pam/Commands/SubmitAccessRequestCommandTests.cs @@ -52,6 +52,7 @@ public async Task SubmitAsync_Automatic_IssuesActiveLease(Guid userId, Guid ciph SetupCipher(sutProvider, userId, cipherId); SetupResolution(sutProvider, userId, cipherId, orgId, collectionId, requiresHuman: false); SetupEvaluation(sutProvider, AccessEvaluation.Allow); + SetupMintOutcome(sutProvider, AccessLeaseMintOutcome.Minted); var result = await sutProvider.Sut.SubmitAsync(userId, cipherId, new AccessRequestSubmission { DurationSeconds = 3600, Reason = "deploy" }); @@ -62,7 +63,8 @@ public async Task SubmitAsync_Automatic_IssuesActiveLease(Guid userId, Guid ciph Assert.Equal(_now, result.Lease.NotBefore); Assert.Equal(_now.AddSeconds(3600), result.Lease.NotAfter); await sutProvider.GetDependency().Received(1) - .CreateAutoApprovedAsync(Arg.Any(), Arg.Any(), Arg.Any(), _now); + .CreateAutoApprovedAsync(Arg.Any(), Arg.Any(), Arg.Any(), _now, + Arg.Any()); } [Theory, BitAutoData] @@ -77,7 +79,7 @@ public async Task SubmitAsync_AutomaticWithWindow_ThrowsBadRequest(Guid userId, new AccessRequestSubmission { Start = _now, End = _now.AddHours(1) })); Assert.Contains("provide a duration", ex.Message); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() - .CreateAutoApprovedAsync(default!, default!, default!, default); + .CreateAutoApprovedAsync(default!, default!, default!, default, default); } [Theory, BitAutoData] @@ -118,7 +120,7 @@ public async Task SubmitAsync_AutomaticPolicyDenied_ThrowsBadRequestAndIssuesNoL Assert.Contains("network", ex.Message); // A rule the caller fails to satisfy must not produce a lease. await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() - .CreateAutoApprovedAsync(default!, default!, default!, default); + .CreateAutoApprovedAsync(default!, default!, default!, default, default); } [Theory, BitAutoData] @@ -143,7 +145,7 @@ public async Task SubmitAsync_Human_CreatesPendingRequest(Guid userId, Guid ciph Assert.Equal(end, result.Request.NotAfter); Assert.Equal("audit", result.Request.Reason); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() - .CreateAutoApprovedAsync(default!, default!, default!, default); + .CreateAutoApprovedAsync(default!, default!, default!, default, default); await sutProvider.GetDependency().Received(1) .NotifyCollectionApproversAsync(collectionId); } @@ -155,6 +157,7 @@ public async Task SubmitAsync_Automatic_DoesNotNotifyApprovers(Guid userId, Guid SetupCipher(sutProvider, userId, cipherId); SetupResolution(sutProvider, userId, cipherId, orgId, collectionId, requiresHuman: false); SetupEvaluation(sutProvider, AccessEvaluation.Allow); + SetupMintOutcome(sutProvider, AccessLeaseMintOutcome.Minted); await sutProvider.Sut.SubmitAsync(userId, cipherId, new AccessRequestSubmission { DurationSeconds = 3600, Reason = "deploy" }); @@ -163,6 +166,46 @@ await sutProvider.GetDependency().DidNotReceiveWithAnyAr .NotifyCollectionApproversAsync(default); } + [Theory, BitAutoData] + public async Task SubmitAsync_AutomaticSingleActiveLeaseApplies_PassesEnforceTrue( + Guid userId, Guid cipherId, Guid orgId, Guid collectionId) + { + var sutProvider = Setup(); + SetupCipher(sutProvider, userId, cipherId); + SetupResolution(sutProvider, userId, cipherId, orgId, collectionId, requiresHuman: false); + SetupEvaluation(sutProvider, AccessEvaluation.Allow); + sutProvider.GetDependency().AppliesAsync(userId, cipherId).Returns(true); + sutProvider.GetDependency() + .CreateAutoApprovedAsync(Arg.Any(), Arg.Any(), Arg.Any(), _now, true) + .Returns(AccessLeaseMintOutcome.Minted); + + await sutProvider.Sut.SubmitAsync(userId, cipherId, + new AccessRequestSubmission { DurationSeconds = 3600, Reason = "deploy" }); + + await sutProvider.GetDependency().Received(1) + .CreateAutoApprovedAsync(Arg.Any(), Arg.Any(), Arg.Any(), _now, true); + } + + [Theory, BitAutoData] + public async Task SubmitAsync_AutomaticSingleActiveLeaseConflict_ThrowsBadRequest( + Guid userId, Guid cipherId, Guid orgId, Guid collectionId) + { + var sutProvider = Setup(); + SetupCipher(sutProvider, userId, cipherId); + SetupResolution(sutProvider, userId, cipherId, orgId, collectionId, requiresHuman: false); + SetupEvaluation(sutProvider, AccessEvaluation.Allow); + sutProvider.GetDependency().AppliesAsync(userId, cipherId).Returns(true); + SetupMintOutcome(sutProvider, AccessLeaseMintOutcome.SingleActiveLeaseConflict); + + // The proc rolled back, so nothing is persisted; the caller sees a 400. + var ex = await Assert.ThrowsAsync( + () => sutProvider.Sut.SubmitAsync(userId, cipherId, + new AccessRequestSubmission { DurationSeconds = 3600, Reason = "deploy" })); + Assert.Contains("Another active lease exists", ex.Message); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .NotifyCollectionApproversAsync(default); + } + [Theory, BitAutoData] public async Task SubmitAsync_HumanMissingReason_ThrowsBadRequest(Guid userId, Guid cipherId, Guid orgId, Guid collectionId) { @@ -282,4 +325,12 @@ private static void SetupEvaluation(SutProvider sutP .Evaluate(Arg.Any(), Arg.Any()) .Returns(evaluation); } + + private static void SetupMintOutcome(SutProvider sutProvider, AccessLeaseMintOutcome outcome) + { + sutProvider.GetDependency() + .CreateAutoApprovedAsync(Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()) + .Returns(outcome); + } } diff --git a/test/Core.Test/Pam/Commands/UpdateAccessRuleCommandTests.cs b/test/Core.Test/Pam/Commands/UpdateAccessRuleCommandTests.cs index 1d7eaaf5886e..435eb8f6d1aa 100644 --- a/test/Core.Test/Pam/Commands/UpdateAccessRuleCommandTests.cs +++ b/test/Core.Test/Pam/Commands/UpdateAccessRuleCommandTests.cs @@ -28,6 +28,7 @@ public async Task UpdateAsync_HappyPath_UpdatesFieldsAndBumpsRevision(AccessRule update.Name = "renamed"; update.Description = "new description"; update.Conditions = """{"kind":"human_approval"}"""; + update.SingleActiveLease = true; sutProvider.GetDependency() .GetDetailsByIdAsync(existing.Id) .Returns(existing); @@ -43,10 +44,12 @@ public async Task UpdateAsync_HappyPath_UpdatesFieldsAndBumpsRevision(AccessRule Assert.Equal("renamed", result.Name); Assert.Equal("new description", result.Description); Assert.Equal(update.Conditions, result.Conditions); + Assert.True(result.SingleActiveLease); Assert.Equal(_now, result.RevisionDate); await sutProvider.GetDependency().Received(1) .ReplaceAsync(Arg.Is(r => - r.Id == existing.Id && r.Name == "renamed" && r.Description == "new description")); + r.Id == existing.Id && r.Name == "renamed" && r.Description == "new description" + && r.SingleActiveLease)); } [Theory, BitAutoData] diff --git a/test/Core.Test/Pam/Services/SingleActiveLeaseEvaluatorTests.cs b/test/Core.Test/Pam/Services/SingleActiveLeaseEvaluatorTests.cs new file mode 100644 index 000000000000..7a0a55c4a1a9 --- /dev/null +++ b/test/Core.Test/Pam/Services/SingleActiveLeaseEvaluatorTests.cs @@ -0,0 +1,99 @@ +using Bit.Core.Entities; +using Bit.Core.Pam.Entities; +using Bit.Core.Pam.Repositories; +using Bit.Core.Pam.Services; +using Bit.Core.Repositories; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Pam.Services; + +[SutProviderCustomize] +public class SingleActiveLeaseEvaluatorTests +{ + [Theory, BitAutoData] + public async Task AppliesAsync_NoReachableCollections_ReturnsFalse( + SutProvider sutProvider, Guid userId, Guid cipherId) + { + sutProvider.GetDependency() + .GetManyByUserIdCipherIdAsync(userId, cipherId) + .Returns(new List()); + + // No path at all: the constraint does not bind. + Assert.False(await sutProvider.Sut.AppliesAsync(userId, cipherId)); + } + + [Theory, BitAutoData] + public async Task AppliesAsync_EveryPathGovernedBySingletonRule_ReturnsTrue( + SutProvider sutProvider, Guid userId, Guid cipherId, + Collection collectionA, Collection collectionB, AccessRule ruleA, AccessRule ruleB) + { + ruleA.SingleActiveLease = true; + ruleB.SingleActiveLease = true; + SetupGovernedCollection(sutProvider, collectionA, ruleA); + SetupGovernedCollection(sutProvider, collectionB, ruleB); + SetupReachableCollections(sutProvider, userId, cipherId, collectionA, collectionB); + + Assert.True(await sutProvider.Sut.AppliesAsync(userId, cipherId)); + } + + [Theory, BitAutoData] + public async Task AppliesAsync_OneUngatedPath_ReturnsFalse( + SutProvider sutProvider, Guid userId, Guid cipherId, + Collection singletonCollection, Collection ungatedCollection, AccessRule singletonRule) + { + singletonRule.SingleActiveLease = true; + SetupGovernedCollection(sutProvider, singletonCollection, singletonRule); + // An ungated path is an escape: the caller can reach the cipher without any singleton rule. + ungatedCollection.AccessRuleId = null; + SetupReachableCollections(sutProvider, userId, cipherId, singletonCollection, ungatedCollection); + + Assert.False(await sutProvider.Sut.AppliesAsync(userId, cipherId)); + } + + [Theory, BitAutoData] + public async Task AppliesAsync_OneNonSingletonRulePath_ReturnsFalse( + SutProvider sutProvider, Guid userId, Guid cipherId, + Collection singletonCollection, Collection plainCollection, AccessRule singletonRule, AccessRule plainRule) + { + singletonRule.SingleActiveLease = true; + plainRule.SingleActiveLease = false; + SetupGovernedCollection(sutProvider, singletonCollection, singletonRule); + SetupGovernedCollection(sutProvider, plainCollection, plainRule); + SetupReachableCollections(sutProvider, userId, cipherId, singletonCollection, plainCollection); + + // A non-singleton rule on any path is an escape. + Assert.False(await sutProvider.Sut.AppliesAsync(userId, cipherId)); + } + + [Theory, BitAutoData] + public async Task AppliesAsync_MissingRuleOnPath_ReturnsFalse( + SutProvider sutProvider, Guid userId, Guid cipherId, Collection collection, Guid ruleId) + { + collection.AccessRuleId = ruleId; + sutProvider.GetDependency().GetByIdAsync(ruleId).Returns((AccessRule?)null); + SetupReachableCollections(sutProvider, userId, cipherId, collection); + + Assert.False(await sutProvider.Sut.AppliesAsync(userId, cipherId)); + } + + private static void SetupReachableCollections( + SutProvider sutProvider, Guid userId, Guid cipherId, params Collection[] collections) + { + sutProvider.GetDependency() + .GetManyByUserIdCipherIdAsync(userId, cipherId) + .Returns(collections.Select(c => new CollectionCipher { CollectionId = c.Id, CipherId = cipherId }).ToList()); + sutProvider.GetDependency() + .GetManyByManyIdsAsync(Arg.Any>()) + .Returns(collections.ToList()); + } + + private static void SetupGovernedCollection( + SutProvider sutProvider, Collection collection, AccessRule rule) + { + collection.AccessRuleId = rule.Id; + sutProvider.GetDependency().GetByIdAsync(rule.Id).Returns(rule); + } +} diff --git a/test/Infrastructure.IntegrationTest/Pam/Repositories/AccessLeaseRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Pam/Repositories/AccessLeaseRepositoryTests.cs index 20439d0bca20..3189a1eeb78f 100644 --- a/test/Infrastructure.IntegrationTest/Pam/Repositories/AccessLeaseRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/Pam/Repositories/AccessLeaseRepositoryTests.cs @@ -23,7 +23,8 @@ public async Task CreateAutoApprovedAsync_PersistsApprovedRequestDecisionAndActi var (request, decision, lease) = BuildAutoApproved(organization.Id, cipherId, requesterId, now, now.AddHours(1)); - await accessLeaseRepository.CreateAutoApprovedAsync(request, decision, lease, now); + var outcome = await accessLeaseRepository.CreateAutoApprovedAsync(request, decision, lease, now, false); + Assert.Equal(AccessLeaseMintOutcome.Minted, outcome); var persistedRequest = await accessRequestRepository.GetByIdAsync(request.Id); Assert.NotNull(persistedRequest); @@ -48,7 +49,7 @@ public async Task GetActiveByRequesterIdCipherIdAsync_WithinWindow_ReturnsLease( var (request, decision, lease) = BuildAutoApproved( organization.Id, cipherId, requesterId, now.AddMinutes(-5), now.AddHours(1)); - await accessLeaseRepository.CreateAutoApprovedAsync(request, decision, lease, now); + await accessLeaseRepository.CreateAutoApprovedAsync(request, decision, lease, now, false); var active = await accessLeaseRepository.GetActiveByRequesterIdCipherIdAsync(requesterId, cipherId, now); @@ -69,7 +70,7 @@ public async Task GetActiveByRequesterIdCipherIdAsync_OutsideWindow_ReturnsNull( // A lease whose window has already elapsed. var (request, decision, lease) = BuildAutoApproved( organization.Id, cipherId, requesterId, now.AddHours(-2), now.AddHours(-1)); - await accessLeaseRepository.CreateAutoApprovedAsync(request, decision, lease, now.AddHours(-2)); + await accessLeaseRepository.CreateAutoApprovedAsync(request, decision, lease, now.AddHours(-2), false); var active = await accessLeaseRepository.GetActiveByRequesterIdCipherIdAsync(requesterId, cipherId, now); @@ -118,17 +119,17 @@ public async Task GetManyActiveByRequesterIdAsync_ReturnsOnlyActiveLeasesInWindo // Active, in-window lease for the requester. var (activeReq, activeDec, activeLease) = BuildAutoApproved( organization.Id, Guid.NewGuid(), requesterId, now.AddMinutes(-5), now.AddHours(1)); - await accessLeaseRepository.CreateAutoApprovedAsync(activeReq, activeDec, activeLease, now); + await accessLeaseRepository.CreateAutoApprovedAsync(activeReq, activeDec, activeLease, now, false); // Expired lease for the same requester — must be excluded. var (expiredReq, expiredDec, expiredLease) = BuildAutoApproved( organization.Id, Guid.NewGuid(), requesterId, now.AddHours(-2), now.AddHours(-1)); - await accessLeaseRepository.CreateAutoApprovedAsync(expiredReq, expiredDec, expiredLease, now.AddHours(-2)); + await accessLeaseRepository.CreateAutoApprovedAsync(expiredReq, expiredDec, expiredLease, now.AddHours(-2), false); // Active lease for a different requester — must be excluded. var (otherReq, otherDec, otherLease) = BuildAutoApproved( organization.Id, Guid.NewGuid(), Guid.NewGuid(), now.AddMinutes(-5), now.AddHours(1)); - await accessLeaseRepository.CreateAutoApprovedAsync(otherReq, otherDec, otherLease, now); + await accessLeaseRepository.CreateAutoApprovedAsync(otherReq, otherDec, otherLease, now, false); var result = await accessLeaseRepository.GetManyActiveByRequesterIdAsync(requesterId, now); @@ -149,7 +150,7 @@ public async Task RevokeAsync_RevokesLeaseAndRecordsAuditDecision( var (request, decision, lease) = BuildAutoApproved( organization.Id, cipherId, requesterId, now.AddMinutes(-5), now.AddHours(1)); - await accessLeaseRepository.CreateAutoApprovedAsync(request, decision, lease, now); + await accessLeaseRepository.CreateAutoApprovedAsync(request, decision, lease, now, false); var auditDecision = new AccessDecision { @@ -186,7 +187,8 @@ public async Task CreateFromApprovedRequestAsync_ApprovedOpenWindow_MintsActiveL Assert.Null(await accessLeaseRepository.GetByAccessRequestIdAsync(request.Id)); var lease = BuildLeaseFor(request, now); - Assert.True(await accessLeaseRepository.CreateFromApprovedRequestAsync(lease, now)); + Assert.Equal(AccessLeaseMintOutcome.Minted, + await accessLeaseRepository.CreateFromApprovedRequestAsync(lease, now, false)); var produced = await accessLeaseRepository.GetByAccessRequestIdAsync(request.Id); Assert.NotNull(produced); @@ -206,7 +208,7 @@ public async Task CreateFromApprovedRequestAsync_ApprovedOpenWindow_MintsActiveL } [DatabaseTheory, DatabaseData] - public async Task CreateFromApprovedRequestAsync_SecondActivation_ReturnsFalseAndKeepsFirstLease( + public async Task CreateFromApprovedRequestAsync_SecondActivation_PreconditionFailedAndKeepsFirstLease( IOrganizationRepository organizationRepository, IAccessRequestRepository accessRequestRepository, IAccessLeaseRepository accessLeaseRepository) @@ -217,19 +219,21 @@ public async Task CreateFromApprovedRequestAsync_SecondActivation_ReturnsFalseAn accessRequestRepository, organization.Id, now.AddHours(-1), now.AddHours(1)); var first = BuildLeaseFor(request, now); - Assert.True(await accessLeaseRepository.CreateFromApprovedRequestAsync(first, now)); + Assert.Equal(AccessLeaseMintOutcome.Minted, + await accessLeaseRepository.CreateFromApprovedRequestAsync(first, now, false)); // A request authorizes access at most once: the second insert is refused by the guard (and would be by the // unique index even if the guard raced). var second = BuildLeaseFor(request, now); - Assert.False(await accessLeaseRepository.CreateFromApprovedRequestAsync(second, now)); + Assert.Equal(AccessLeaseMintOutcome.PreconditionFailed, + await accessLeaseRepository.CreateFromApprovedRequestAsync(second, now, false)); var produced = await accessLeaseRepository.GetByAccessRequestIdAsync(request.Id); Assert.Equal(first.Id, produced!.Id); } [DatabaseTheory, DatabaseData] - public async Task CreateFromApprovedRequestAsync_PreconditionNoLongerHolds_ReturnsFalse( + public async Task CreateFromApprovedRequestAsync_PreconditionNoLongerHolds_PreconditionFailed( IOrganizationRepository organizationRepository, IAccessRequestRepository accessRequestRepository, IAccessLeaseRepository accessLeaseRepository) @@ -240,24 +244,28 @@ public async Task CreateFromApprovedRequestAsync_PreconditionNoLongerHolds_Retur // Still pending: not an approval. var pending = await CreateApprovedRequestAsync( accessRequestRepository, organization.Id, now.AddHours(-1), now.AddHours(1), AccessRequestStatus.Pending); - Assert.False(await accessLeaseRepository.CreateFromApprovedRequestAsync(BuildLeaseFor(pending, now), now)); + Assert.Equal(AccessLeaseMintOutcome.PreconditionFailed, + await accessLeaseRepository.CreateFromApprovedRequestAsync(BuildLeaseFor(pending, now), now, false)); // Someone else's request: the requester filter refuses it. var approved = await CreateApprovedRequestAsync( accessRequestRepository, organization.Id, now.AddHours(-1), now.AddHours(1)); var foreign = BuildLeaseFor(approved, now); foreign.RequesterId = Guid.NewGuid(); - Assert.False(await accessLeaseRepository.CreateFromApprovedRequestAsync(foreign, now)); + Assert.Equal(AccessLeaseMintOutcome.PreconditionFailed, + await accessLeaseRepository.CreateFromApprovedRequestAsync(foreign, now, false)); // Window not started yet. var future = await CreateApprovedRequestAsync( accessRequestRepository, organization.Id, now.AddHours(1), now.AddHours(2)); - Assert.False(await accessLeaseRepository.CreateFromApprovedRequestAsync(BuildLeaseFor(future, now), now)); + Assert.Equal(AccessLeaseMintOutcome.PreconditionFailed, + await accessLeaseRepository.CreateFromApprovedRequestAsync(BuildLeaseFor(future, now), now, false)); // Window already ended. var lapsed = await CreateApprovedRequestAsync( accessRequestRepository, organization.Id, now.AddHours(-2), now.AddHours(-1)); - Assert.False(await accessLeaseRepository.CreateFromApprovedRequestAsync(BuildLeaseFor(lapsed, now), now)); + Assert.Equal(AccessLeaseMintOutcome.PreconditionFailed, + await accessLeaseRepository.CreateFromApprovedRequestAsync(BuildLeaseFor(lapsed, now), now, false)); // None of the refused activations left a lease behind. foreach (var requestId in new[] { pending.Id, approved.Id, future.Id, lapsed.Id }) @@ -266,14 +274,42 @@ public async Task CreateFromApprovedRequestAsync_PreconditionNoLongerHolds_Retur } } + [DatabaseTheory, DatabaseData] + public async Task CreateFromApprovedRequestAsync_EnforceSingleActiveLease_SecondCipherActivationConflicts( + IOrganizationRepository organizationRepository, + IAccessRequestRepository accessRequestRepository, + IAccessLeaseRepository accessLeaseRepository) + { + var organization = await organizationRepository.CreateTestOrganizationAsync(); + var now = DateTime.UtcNow; + var cipherId = Guid.NewGuid(); + + // Two different users each hold an approved request for the SAME cipher. With enforcement on, only one of them + // may mint an active lease — contention is purely per-cipher across all users. + var first = await CreateApprovedRequestAsync( + accessRequestRepository, organization.Id, now.AddHours(-1), now.AddHours(1), cipherId: cipherId); + var second = await CreateApprovedRequestAsync( + accessRequestRepository, organization.Id, now.AddHours(-1), now.AddHours(1), cipherId: cipherId); + + Assert.Equal(AccessLeaseMintOutcome.Minted, + await accessLeaseRepository.CreateFromApprovedRequestAsync(BuildLeaseFor(first, now), now, true)); + + // The cipher already has an active in-window lease, so the second activation is refused as a conflict. + Assert.Equal(AccessLeaseMintOutcome.SingleActiveLeaseConflict, + await accessLeaseRepository.CreateFromApprovedRequestAsync(BuildLeaseFor(second, now), now, true)); + + // The conflict left no lease behind for the second request. + Assert.Null(await accessLeaseRepository.GetByAccessRequestIdAsync(second.Id)); + } + private static async Task CreateApprovedRequestAsync( IAccessRequestRepository accessRequestRepository, Guid organizationId, DateTime notBefore, DateTime notAfter, - AccessRequestStatus status = AccessRequestStatus.Approved) + AccessRequestStatus status = AccessRequestStatus.Approved, Guid? cipherId = null) => await accessRequestRepository.CreateAsync(new AccessRequest { OrganizationId = organizationId, CollectionId = Guid.NewGuid(), - CipherId = Guid.NewGuid(), + CipherId = cipherId ?? Guid.NewGuid(), RequesterId = Guid.NewGuid(), NotBefore = notBefore, NotAfter = notAfter, diff --git a/test/Infrastructure.IntegrationTest/Pam/Repositories/AccessRequestRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Pam/Repositories/AccessRequestRepositoryTests.cs index ef6c778f6901..c88d4a2d8052 100644 --- a/test/Infrastructure.IntegrationTest/Pam/Repositories/AccessRequestRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/Pam/Repositories/AccessRequestRepositoryTests.cs @@ -109,7 +109,8 @@ public async Task GetManyInboxHistoryByCollectionIdsAsync_SurfacesProducedLeaseS NotAfter = approved.NotAfter, CreationDate = now, }; - Assert.True(await accessLeaseRepository.CreateFromApprovedRequestAsync(lease, now)); + Assert.Equal(AccessLeaseMintOutcome.Minted, + await accessLeaseRepository.CreateFromApprovedRequestAsync(lease, now, false)); // While the lease is active the inbox sees its Active status, so the client offers Revoke. var active = Assert.Single(await accessRequestRepository.GetManyInboxHistoryByCollectionIdsAsync( @@ -310,7 +311,8 @@ public async Task GetActiveApprovedByRequesterIdCipherIdAsync_ReturnsStartableAp NotAfter = startable.NotAfter, CreationDate = now, }; - Assert.True(await accessLeaseRepository.CreateFromApprovedRequestAsync(lease, now)); + Assert.Equal(AccessLeaseMintOutcome.Minted, + await accessLeaseRepository.CreateFromApprovedRequestAsync(lease, now, false)); Assert.Null(await accessRequestRepository.GetActiveApprovedByRequesterIdCipherIdAsync( requesterId, startable.CipherId, now)); } diff --git a/util/Migrator/DbScripts/2026-06-11_01_AddSingleActiveLease.sql b/util/Migrator/DbScripts/2026-06-11_01_AddSingleActiveLease.sql new file mode 100644 index 000000000000..adf5dda3c6a7 --- /dev/null +++ b/util/Migrator/DbScripts/2026-06-11_01_AddSingleActiveLease.sql @@ -0,0 +1,235 @@ +-- PAM Credential Leasing: per-cipher single-active-lease. +-- +-- AccessRule gains a [SingleActiveLease] flag. When the governing rule(s) ask for it, at most one active in-window +-- lease may exist for a given cipher across all users. The flag binds for a member only when EVERY collection through +-- which they reach the cipher is governed by a singleton rule (the union/OR gating logic lives in C#); any ungated or +-- non-singleton path is an escape that leaves them unconstrained. The mint procs receive @EnforceSingleActiveLease and +-- serialize concurrent activations of the same cipher with a UPDLOCK, HOLDLOCK range lock, returning a distinct +-- outcome code: 1 = minted, 0 = precondition fail, -1 = single-active conflict. +-- +-- PAM is an unshipped POC behind the pm-37044-pam-v-0 flag with no production data; server + migration deploy +-- together, so the affected procs are altered in place rather than versioned. + +IF COL_LENGTH('[dbo].[AccessRule]', 'SingleActiveLease') IS NULL +BEGIN + ALTER TABLE [dbo].[AccessRule] + ADD [SingleActiveLease] BIT NOT NULL + CONSTRAINT [DF_AccessRule_SingleActiveLease] DEFAULT (0) +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[AccessRule_Create] + @Id UNIQUEIDENTIFIER OUTPUT, + @OrganizationId UNIQUEIDENTIFIER, + @Name NVARCHAR(256), + @Description NVARCHAR(MAX) = NULL, + @Conditions NVARCHAR(MAX), + @SingleActiveLease BIT = 0, + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + INSERT INTO [dbo].[AccessRule] + ( + [Id], + [OrganizationId], + [Name], + [Description], + [Conditions], + [SingleActiveLease], + [CreationDate], + [RevisionDate] + ) + VALUES + ( + @Id, + @OrganizationId, + @Name, + @Description, + @Conditions, + @SingleActiveLease, + @CreationDate, + @RevisionDate + ) +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[AccessRule_Update] + @Id UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @Name NVARCHAR(256), + @Description NVARCHAR(MAX) = NULL, + @Conditions NVARCHAR(MAX), + @SingleActiveLease BIT = 0, + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + UPDATE + [dbo].[AccessRule] + SET + [OrganizationId] = @OrganizationId, + [Name] = @Name, + [Description] = @Description, + [Conditions] = @Conditions, + [SingleActiveLease] = @SingleActiveLease, + [CreationDate] = @CreationDate, + [RevisionDate] = @RevisionDate + WHERE + [Id] = @Id +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[AccessLease_CreateFromApprovedRequest] + @AccessLeaseId UNIQUEIDENTIFIER, + @AccessRequestId UNIQUEIDENTIFIER, + @RequesterId UNIQUEIDENTIFIER, + @Now DATETIME2(7), + @EnforceSingleActiveLease BIT = 0 +AS +BEGIN + SET NOCOUNT ON + -- An explicit transaction is required so the singleton guard's range lock is held until the INSERT commits; + -- XACT_ABORT guarantees the transaction is rolled back (and the pooled connection left clean) if the + -- unique-index backstop [IX_AccessLease_AccessRequestId] trips on a concurrent activation of the same request. + SET XACT_ABORT ON + + BEGIN TRANSACTION + + -- Per-cipher singleton guard. When the governing rule(s) ask for a single active lease, activation is allowed + -- only if no other active in-window lease exists for the same cipher across all users. The UPDLOCK, HOLDLOCK + -- range lock is held for the life of this transaction, so it serializes against the INSERT below: a concurrent + -- same-cipher activation blocks here until this transaction commits, then sees the new lease and is rejected. + -- Outcome -1 is distinct from the precondition-fail outcome (0) so the caller can surface a 409 conflict. + IF @EnforceSingleActiveLease = 1 + AND EXISTS ( + SELECT 1 + FROM [dbo].[AccessLease] WITH (UPDLOCK, HOLDLOCK) + WHERE [CipherId] = (SELECT [CipherId] FROM [dbo].[AccessRequest] WHERE [Id] = @AccessRequestId) + AND [Status] = 0 /* Active */ + AND [NotBefore] <= @Now + AND [NotAfter] > @Now + ) + BEGIN + ROLLBACK TRANSACTION + SELECT -1 + RETURN + END + + -- Activation of an approved request: mints the active lease that authorizes access, spanning the request's + -- approved window. Every application-level precondition is re-checked inside the INSERT so a concurrent + -- activation cannot double-mint; zero rows inserted means a precondition no longer held and the caller decides + -- how to surface that. [IX_AccessLease_AccessRequestId] (unique) is the backstop when two calls pass the + -- NOT EXISTS check simultaneously. + INSERT INTO [dbo].[AccessLease] + ( + [Id], [AccessRequestId], [OrganizationId], [CollectionId], [CipherId], [RequesterId], + [Status], [NotBefore], [NotAfter], [RevokedDate], [RevokedBy], [CreationDate] + ) + SELECT + @AccessLeaseId, AR.[Id], AR.[OrganizationId], AR.[CollectionId], AR.[CipherId], AR.[RequesterId], + 0 /* Active */, AR.[NotBefore], AR.[NotAfter], NULL, NULL, @Now + FROM [dbo].[AccessRequest] AR + WHERE + AR.[Id] = @AccessRequestId + AND AR.[RequesterId] = @RequesterId + AND AR.[Status] = 1 -- Approved + AND AR.[NotBefore] <= @Now + AND AR.[NotAfter] > @Now + AND NOT EXISTS (SELECT 1 FROM [dbo].[AccessLease] AL WHERE AL.[AccessRequestId] = AR.[Id]) + + DECLARE @Rows INT = @@ROWCOUNT + + COMMIT TRANSACTION + + -- 1 = minted, 0 = precondition no longer held (caller re-reads the winner). + SELECT CASE WHEN @Rows = 1 THEN 1 ELSE 0 END +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[AccessLease_CreateAutoApproved] + @AccessRequestId UNIQUEIDENTIFIER, + @AccessLeaseId UNIQUEIDENTIFIER, + @AccessDecisionId UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @CollectionId UNIQUEIDENTIFIER, + @CipherId UNIQUEIDENTIFIER, + @RequesterId UNIQUEIDENTIFIER, + @NotBefore DATETIME2(7), + @NotAfter DATETIME2(7), + @Reason NVARCHAR(MAX) = NULL, + @ConditionKind NVARCHAR(50) = NULL, + @Now DATETIME2(7), + @EnforceSingleActiveLease BIT = 0 +AS +BEGIN + SET NOCOUNT ON + + BEGIN TRANSACTION AccessLease_CreateAutoApproved + + -- Per-cipher singleton guard. When the governing rule(s) ask for a single active lease, the auto-approved lease + -- is minted only if no other active in-window lease exists for the same cipher across all users. The check runs + -- inside the transaction before any insert so the UPDLOCK, HOLDLOCK range lock serializes concurrent activations + -- of the same cipher; a conflict rolls back leaving nothing persisted and returns -1. + IF @EnforceSingleActiveLease = 1 + BEGIN + IF EXISTS ( + SELECT 1 + FROM [dbo].[AccessLease] WITH (UPDLOCK, HOLDLOCK) + WHERE [CipherId] = @CipherId + AND [Status] = 0 /* Active */ + AND [NotBefore] <= @Now + AND [NotAfter] > @Now + ) + BEGIN + ROLLBACK TRANSACTION AccessLease_CreateAutoApproved + SELECT -1 + RETURN + END + END + + -- The request is created already resolved (Approved). ExtensionOfLeaseId stays NULL: it is reserved for extension + -- requests; provenance for an original lease flows the other way, via AccessLease.AccessRequestId. + INSERT INTO [dbo].[AccessRequest] + ( + [Id], [ExtensionOfLeaseId], [OrganizationId], [CollectionId], [CipherId], [RequesterId], + [NotBefore], [NotAfter], [Reason], [Status], [CreationDate], [ResolvedDate] + ) + VALUES + ( + @AccessRequestId, NULL, @OrganizationId, @CollectionId, @CipherId, @RequesterId, + @NotBefore, @NotAfter, @Reason, 1 /* Approved */, @Now, @Now + ) + + INSERT INTO [dbo].[AccessDecision] + ( + [Id], [AccessRequestId], [DeciderKind], [ApproverId], [ConditionKind], + [Verdict], [Comment], [EvaluationContext], [CreationDate] + ) + VALUES + ( + @AccessDecisionId, @AccessRequestId, 0 /* Automatic */, NULL, @ConditionKind, + 0 /* Approve */, NULL, NULL, @Now + ) + + INSERT INTO [dbo].[AccessLease] + ( + [Id], [AccessRequestId], [OrganizationId], [CollectionId], [CipherId], [RequesterId], + [Status], [NotBefore], [NotAfter], [RevokedDate], [RevokedBy], [CreationDate] + ) + VALUES + ( + @AccessLeaseId, @AccessRequestId, @OrganizationId, @CollectionId, @CipherId, @RequesterId, + 0 /* Active */, @NotBefore, @NotAfter, NULL, NULL, @Now + ) + + COMMIT TRANSACTION AccessLease_CreateAutoApproved + + -- 1 = minted (request + decision + lease all written). + SELECT 1 +END +GO From 9fa13dfb3ab95389dcd3b804d14f81b98995547f Mon Sep 17 00:00:00 2001 From: Patrik Svensson Date: Thu, 11 Jun 2026 15:27:44 +0200 Subject: [PATCH 20/54] Defer automatic lease minting to requester activation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The automatic (no-approval) path minted an active lease at submit, so the server granted access the moment the client posted the request on retrieval — never an explicit user choice. Approval was already deferred for the human path; this brings the automatic path in line. Auto-approval now records only the already-Approved AccessRequest and its automatic AccessDecision (no lease) via the new AccessRequest_CreateAutoApproved proc; the requester explicitly activates the approved request to mint the lease (ActivateAccessRequestCommand), exactly like the human path after approval, and the state endpoint surfaces it as the startable ApprovedRequest. The per-cipher single-active-lease guard now runs only at activation, the one remaining mint site, so it drops from the submit path. AccessLease_CreateAutoApproved is dropped; the submit result carries the approved request rather than a lease. --- .../Pam/Controllers/CipherLeaseController.cs | 5 +- .../AccessRequestResultResponseModel.cs | 11 ++- src/Core/Pam/Entities/AccessRequest.cs | 8 +- src/Core/Pam/Models/AccessRequestResult.cs | 17 ++-- .../Commands/SubmitAccessRequestCommand.cs | 45 +++------- .../Repositories/IAccessLeaseRepository.cs | 12 +-- .../Repositories/IAccessRequestRepository.cs | 8 ++ .../Pam/Repositories/AccessLeaseRepository.cs | 27 ------ .../Repositories/AccessRequestRepository.cs | 22 +++++ .../AccessLease_CreateAutoApproved.sql | 81 ----------------- .../AccessRequest_CreateAutoApproved.sql | 47 ++++++++++ .../AccessRequest_ResolveWithDecision.sql | 4 +- .../SubmitAccessRequestCommandTests.cs | 87 +++++-------------- .../AccessLeaseRepositoryTests.cs | 51 +++++++---- ...DeferAutoApprovedLeaseMintToActivation.sql | 62 +++++++++++++ 15 files changed, 230 insertions(+), 257 deletions(-) delete mode 100644 src/Sql/dbo/Pam/Stored Procedures/AccessLease_CreateAutoApproved.sql create mode 100644 src/Sql/dbo/Pam/Stored Procedures/AccessRequest_CreateAutoApproved.sql create mode 100644 util/Migrator/DbScripts/2026-06-11_02_DeferAutoApprovedLeaseMintToActivation.sql diff --git a/src/Api/Pam/Controllers/CipherLeaseController.cs b/src/Api/Pam/Controllers/CipherLeaseController.cs index 8b25c940e6c2..2036027ddd54 100644 --- a/src/Api/Pam/Controllers/CipherLeaseController.cs +++ b/src/Api/Pam/Controllers/CipherLeaseController.cs @@ -54,8 +54,9 @@ public async Task State(Guid id) } /// - /// Submits a request to lease this cipher. The automatic path issues an active lease immediately; the human path - /// creates a pending request for an approver. + /// Submits a request to lease this cipher. The automatic path creates an already-approved request the requester + /// then activates to start the lease; the human path creates a pending request for an approver. Neither mints a + /// lease here — the requester activates the approved request (POST leasing/requests/{id}/activate). /// [HttpPost("")] public async Task Post(Guid id, [FromBody] AccessRequestCreateRequestModel model) diff --git a/src/Api/Pam/Models/Response/AccessRequestResultResponseModel.cs b/src/Api/Pam/Models/Response/AccessRequestResultResponseModel.cs index 5fb3be619294..812fa1172c54 100644 --- a/src/Api/Pam/Models/Response/AccessRequestResultResponseModel.cs +++ b/src/Api/Pam/Models/Response/AccessRequestResultResponseModel.cs @@ -12,16 +12,15 @@ public AccessRequestResultResponseModel(AccessRequestResult result) ArgumentNullException.ThrowIfNull(result); ApprovalMode = result.ApprovalMode == AccessApprovalMode.Human ? "human" : "automatic"; - Lease = result.Lease is null ? null : new AccessLeaseResponseModel(result.Lease); - Request = result.Request is null ? null : new AccessRequestResponseModel(result.Request); + Request = new AccessRequestResponseModel(result.Request); } /// - /// "automatic" when a was issued immediately, "human" when a pending - /// was created. + /// "automatic" when the was approved on submit and is ready to activate (the client + /// shows "Start lease"), "human" when it is pending an approver. No lease is minted at submit on either + /// path; the requester activates the request to start the lease. /// public string ApprovalMode { get; } - public AccessLeaseResponseModel? Lease { get; } - public AccessRequestResponseModel? Request { get; } + public AccessRequestResponseModel Request { get; } } diff --git a/src/Core/Pam/Entities/AccessRequest.cs b/src/Core/Pam/Entities/AccessRequest.cs index f79448eb26c6..041c4ded4761 100644 --- a/src/Core/Pam/Entities/AccessRequest.cs +++ b/src/Core/Pam/Entities/AccessRequest.cs @@ -6,10 +6,10 @@ namespace Bit.Core.Pam.Entities; /// /// A request to lease access to a cipher in a leasing-governed collection. Auto-approved requests are created -/// already alongside an active ; requests that require -/// human approval are created and resolved later by an approver. A human -/// approval does not mint the lease — the requester activates the approved request within its window, and that -/// activation produces the . +/// already ; requests that require human approval are created +/// and resolved later by an approver. Neither approval mints the lease — +/// the requester activates the approved request within its window, and that activation produces the +/// . /// public class AccessRequest : ITableObject { diff --git a/src/Core/Pam/Models/AccessRequestResult.cs b/src/Core/Pam/Models/AccessRequestResult.cs index 5f25008edeed..b58b90c41be7 100644 --- a/src/Core/Pam/Models/AccessRequestResult.cs +++ b/src/Core/Pam/Models/AccessRequestResult.cs @@ -4,18 +4,19 @@ namespace Bit.Core.Pam.Models; /// -/// The result of submitting an access request. On the path an -/// is issued immediately; on the path a pending -/// is created to await an approver. +/// The result of submitting an access request. Neither path mints a lease at submit: the +/// path creates an already- +/// the requester then activates to start the lease, while the +/// path creates a request to await +/// an approver. tells the client which workflow to present. /// public sealed record AccessRequestResult( AccessApprovalMode ApprovalMode, - AccessLease? Lease = null, - AccessRequest? Request = null) + AccessRequest Request) { - public static AccessRequestResult Automatic(AccessLease lease) => - new(AccessApprovalMode.Automatic, Lease: lease); + public static AccessRequestResult Automatic(AccessRequest request) => + new(AccessApprovalMode.Automatic, request); public static AccessRequestResult Human(AccessRequest request) => - new(AccessApprovalMode.Human, Request: request); + new(AccessApprovalMode.Human, request); } diff --git a/src/Core/Pam/OrganizationFeatures/Commands/SubmitAccessRequestCommand.cs b/src/Core/Pam/OrganizationFeatures/Commands/SubmitAccessRequestCommand.cs index e0a893f92ab7..24fc0885400b 100644 --- a/src/Core/Pam/OrganizationFeatures/Commands/SubmitAccessRequestCommand.cs +++ b/src/Core/Pam/OrganizationFeatures/Commands/SubmitAccessRequestCommand.cs @@ -27,7 +27,6 @@ public class SubmitAccessRequestCommand : ISubmitAccessRequestCommand private readonly IAccessLeaseRepository _accessLeaseRepository; private readonly IAccessRequestRepository _accessRequestRepository; private readonly IApproverInboxNotifier _approverInboxNotifier; - private readonly ISingleActiveLeaseEvaluator _singleActiveLeaseEvaluator; private readonly TimeProvider _timeProvider; public SubmitAccessRequestCommand( @@ -38,7 +37,6 @@ public SubmitAccessRequestCommand( IAccessLeaseRepository accessLeaseRepository, IAccessRequestRepository accessRequestRepository, IApproverInboxNotifier approverInboxNotifier, - ISingleActiveLeaseEvaluator singleActiveLeaseEvaluator, TimeProvider timeProvider) { _cipherRepository = cipherRepository; @@ -48,7 +46,6 @@ public SubmitAccessRequestCommand( _accessLeaseRepository = accessLeaseRepository; _accessRequestRepository = accessRequestRepository; _approverInboxNotifier = approverInboxNotifier; - _singleActiveLeaseEvaluator = singleActiveLeaseEvaluator; _timeProvider = timeProvider; } @@ -87,10 +84,10 @@ public async Task SubmitAsync(Guid userId, Guid cipherId, A return governingRule.RequiresHumanApproval ? await RequestHumanApprovalAsync(userId, cipherId, governingRule, submission) - : await IssueAutomaticLeaseAsync(userId, cipherId, governingRule, submission, now); + : await ApproveAutomaticallyAsync(userId, cipherId, governingRule, submission, now); } - private async Task IssueAutomaticLeaseAsync( + private async Task ApproveAutomaticallyAsync( Guid userId, Guid cipherId, GoverningRule governingRule, AccessRequestSubmission submission, DateTime now) { if (submission.Start.HasValue || submission.End.HasValue) @@ -108,9 +105,9 @@ private async Task IssueAutomaticLeaseAsync( throw new BadRequestException($"The requested duration exceeds the maximum of {MaxDurationSeconds} seconds."); } - // The cipher must satisfy its access rule's conditions (source IP, time of day, ...) before an automatic - // lease is issued. The resolver only routes a rule here when it carries no human-approval gate, so the - // engine never asks for approval on this path; any non-allow outcome is a denial we surface to the caller. + // The cipher must satisfy its access rule's conditions (source IP, time of day, ...) before the request is + // auto-approved. The resolver only routes a rule here when it carries no human-approval gate, so the engine + // never asks for approval on this path; any non-allow outcome is a denial we surface to the caller. var evaluation = _ruleEngine.Evaluate(governingRule.Condition, BuildSignals(now)); if (evaluation.Outcome != AccessEvaluationOutcome.Allow) { @@ -143,33 +140,13 @@ private async Task IssueAutomaticLeaseAsync( }; decision.SetNewId(); - var lease = new AccessLease - { - AccessRequestId = request.Id, - OrganizationId = governingRule.OrganizationId, - CollectionId = governingRule.CollectionId, - CipherId = cipherId, - RequesterId = userId, - Status = AccessLeaseStatus.Active, - NotBefore = now, - NotAfter = notAfter, - CreationDate = now, - }; - lease.SetNewId(); - - // The per-cipher singleton binds only when every path the caller reaches the cipher through is governed by a - // singleton rule; an escape path leaves them unconstrained. The mint proc enforces it under a range lock and - // rolls back the whole insert on conflict, so nothing is persisted when this throws. - var enforceSingleActiveLease = await _singleActiveLeaseEvaluator.AppliesAsync(userId, cipherId); - - var outcome = await _accessLeaseRepository.CreateAutoApprovedAsync(request, decision, lease, now, - enforceSingleActiveLease); - if (outcome == AccessLeaseMintOutcome.SingleActiveLeaseConflict) - { - throw new BadRequestException("Another active lease exists for this item. Try again once it ends."); - } + // Auto-approval records only the request and its automatic verdict — no lease. The requester explicitly + // activates the approved request (ActivateAccessRequestCommand) to start the lease, exactly like the human + // path after approval. Deferring the mint means the per-cipher single-active-lease guard runs at activation, + // the one place a lease is now minted, rather than here. + await _accessRequestRepository.CreateAutoApprovedAsync(request, decision); - return AccessRequestResult.Automatic(lease); + return AccessRequestResult.Automatic(request); } private async Task RequestHumanApprovalAsync( diff --git a/src/Core/Pam/Repositories/IAccessLeaseRepository.cs b/src/Core/Pam/Repositories/IAccessLeaseRepository.cs index 8baf7905b0dc..6edc5401a42c 100644 --- a/src/Core/Pam/Repositories/IAccessLeaseRepository.cs +++ b/src/Core/Pam/Repositories/IAccessLeaseRepository.cs @@ -24,17 +24,7 @@ public interface IAccessLeaseRepository Task> GetManyActiveByRequesterIdAsync(Guid requesterId, DateTime now); /// - /// Atomically creates an auto-approved , its automatic , and an - /// active in a single transaction. The three entities must already have their ids assigned. - /// The automatic path's request, decision, and lease never diverge because they are written together here. When - /// is true the transaction first checks the per-cipher singleton and - /// rolls back without persisting anything if another active in-window lease exists for the cipher. - /// - Task CreateAutoApprovedAsync(AccessRequest request, AccessDecision decision, - AccessLease lease, DateTime now, bool enforceSingleActiveLease); - - /// - /// Race-safely mints the active lease for an approved human request, copying the request's window. The insert + /// Race-safely mints the active lease for an approved request, copying the request's window. The insert /// re-checks ownership, Approved status, an open window, and that the request has not already produced a lease; /// returns when any precondition no longer holds (e.g. a /// concurrent activation won). When is true and another active diff --git a/src/Core/Pam/Repositories/IAccessRequestRepository.cs b/src/Core/Pam/Repositories/IAccessRequestRepository.cs index e6e90b3165ca..cd65377a77d6 100644 --- a/src/Core/Pam/Repositories/IAccessRequestRepository.cs +++ b/src/Core/Pam/Repositories/IAccessRequestRepository.cs @@ -8,6 +8,14 @@ public interface IAccessRequestRepository { Task CreateAsync(AccessRequest request); + /// + /// Atomically creates an auto-approved (status , + /// resolved now) and its automatic in a single transaction. No lease is minted: the + /// requester activates the approved request later via , + /// just like the human path after approval. Both supplied entities must already have their ids assigned. + /// + Task CreateAutoApprovedAsync(AccessRequest request, AccessDecision decision); + Task GetByIdAsync(Guid id); /// diff --git a/src/Infrastructure.Dapper/Pam/Repositories/AccessLeaseRepository.cs b/src/Infrastructure.Dapper/Pam/Repositories/AccessLeaseRepository.cs index c6b2a03f9ee4..315e360564cc 100644 --- a/src/Infrastructure.Dapper/Pam/Repositories/AccessLeaseRepository.cs +++ b/src/Infrastructure.Dapper/Pam/Repositories/AccessLeaseRepository.cs @@ -54,33 +54,6 @@ public async Task> GetManyActiveByRequesterIdAsync(Guid return results.ToList(); } - public async Task CreateAutoApprovedAsync(AccessRequest request, AccessDecision decision, - AccessLease lease, DateTime now, bool enforceSingleActiveLease) - { - await using var connection = new SqlConnection(ConnectionString); - var result = await connection.ExecuteScalarAsync( - $"[{Schema}].[AccessLease_CreateAutoApproved]", - new - { - AccessRequestId = request.Id, - AccessLeaseId = lease.Id, - AccessDecisionId = decision.Id, - request.OrganizationId, - request.CollectionId, - request.CipherId, - request.RequesterId, - request.NotBefore, - request.NotAfter, - request.Reason, - decision.ConditionKind, - Now = now, - EnforceSingleActiveLease = enforceSingleActiveLease, - }, - commandType: CommandType.StoredProcedure); - - return (AccessLeaseMintOutcome)result; - } - public async Task CreateFromApprovedRequestAsync(AccessLease lease, DateTime now, bool enforceSingleActiveLease) { diff --git a/src/Infrastructure.Dapper/Pam/Repositories/AccessRequestRepository.cs b/src/Infrastructure.Dapper/Pam/Repositories/AccessRequestRepository.cs index 47381fbf4794..41b545a83a24 100644 --- a/src/Infrastructure.Dapper/Pam/Repositories/AccessRequestRepository.cs +++ b/src/Infrastructure.Dapper/Pam/Repositories/AccessRequestRepository.cs @@ -22,6 +22,28 @@ public AccessRequestRepository(string connectionString, string readOnlyConnectio : base(connectionString, readOnlyConnectionString) { } + public async Task CreateAutoApprovedAsync(AccessRequest request, AccessDecision decision) + { + await using var connection = new SqlConnection(ConnectionString); + await connection.ExecuteAsync( + $"[{Schema}].[AccessRequest_CreateAutoApproved]", + new + { + AccessRequestId = request.Id, + AccessDecisionId = decision.Id, + request.OrganizationId, + request.CollectionId, + request.CipherId, + request.RequesterId, + request.NotBefore, + request.NotAfter, + request.Reason, + decision.ConditionKind, + CreationDate = request.CreationDate, + }, + commandType: CommandType.StoredProcedure); + } + public async Task GetActivePendingByRequesterIdCipherIdAsync(Guid requesterId, Guid cipherId) { await using var connection = new SqlConnection(ConnectionString); diff --git a/src/Sql/dbo/Pam/Stored Procedures/AccessLease_CreateAutoApproved.sql b/src/Sql/dbo/Pam/Stored Procedures/AccessLease_CreateAutoApproved.sql deleted file mode 100644 index 96db7e6cca11..000000000000 --- a/src/Sql/dbo/Pam/Stored Procedures/AccessLease_CreateAutoApproved.sql +++ /dev/null @@ -1,81 +0,0 @@ -CREATE PROCEDURE [dbo].[AccessLease_CreateAutoApproved] - @AccessRequestId UNIQUEIDENTIFIER, - @AccessLeaseId UNIQUEIDENTIFIER, - @AccessDecisionId UNIQUEIDENTIFIER, - @OrganizationId UNIQUEIDENTIFIER, - @CollectionId UNIQUEIDENTIFIER, - @CipherId UNIQUEIDENTIFIER, - @RequesterId UNIQUEIDENTIFIER, - @NotBefore DATETIME2(7), - @NotAfter DATETIME2(7), - @Reason NVARCHAR(MAX) = NULL, - @ConditionKind NVARCHAR(50) = NULL, - @Now DATETIME2(7), - @EnforceSingleActiveLease BIT = 0 -AS -BEGIN - SET NOCOUNT ON - - BEGIN TRANSACTION AccessLease_CreateAutoApproved - - -- Per-cipher singleton guard. When the governing rule(s) ask for a single active lease, the auto-approved lease - -- is minted only if no other active in-window lease exists for the same cipher across all users. The check runs - -- inside the transaction before any insert so the UPDLOCK, HOLDLOCK range lock serializes concurrent activations - -- of the same cipher; a conflict rolls back leaving nothing persisted and returns -1. - IF @EnforceSingleActiveLease = 1 - BEGIN - IF EXISTS ( - SELECT 1 - FROM [dbo].[AccessLease] WITH (UPDLOCK, HOLDLOCK) - WHERE [CipherId] = @CipherId - AND [Status] = 0 /* Active */ - AND [NotBefore] <= @Now - AND [NotAfter] > @Now - ) - BEGIN - ROLLBACK TRANSACTION AccessLease_CreateAutoApproved - SELECT -1 - RETURN - END - END - - -- The request is created already resolved (Approved). ExtensionOfLeaseId stays NULL: it is reserved for extension - -- requests; provenance for an original lease flows the other way, via AccessLease.AccessRequestId. - INSERT INTO [dbo].[AccessRequest] - ( - [Id], [ExtensionOfLeaseId], [OrganizationId], [CollectionId], [CipherId], [RequesterId], - [NotBefore], [NotAfter], [Reason], [Status], [CreationDate], [ResolvedDate] - ) - VALUES - ( - @AccessRequestId, NULL, @OrganizationId, @CollectionId, @CipherId, @RequesterId, - @NotBefore, @NotAfter, @Reason, 1 /* Approved */, @Now, @Now - ) - - INSERT INTO [dbo].[AccessDecision] - ( - [Id], [AccessRequestId], [DeciderKind], [ApproverId], [ConditionKind], - [Verdict], [Comment], [EvaluationContext], [CreationDate] - ) - VALUES - ( - @AccessDecisionId, @AccessRequestId, 0 /* Automatic */, NULL, @ConditionKind, - 0 /* Approve */, NULL, NULL, @Now - ) - - INSERT INTO [dbo].[AccessLease] - ( - [Id], [AccessRequestId], [OrganizationId], [CollectionId], [CipherId], [RequesterId], - [Status], [NotBefore], [NotAfter], [RevokedDate], [RevokedBy], [CreationDate] - ) - VALUES - ( - @AccessLeaseId, @AccessRequestId, @OrganizationId, @CollectionId, @CipherId, @RequesterId, - 0 /* Active */, @NotBefore, @NotAfter, NULL, NULL, @Now - ) - - COMMIT TRANSACTION AccessLease_CreateAutoApproved - - -- 1 = minted (request + decision + lease all written). - SELECT 1 -END diff --git a/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_CreateAutoApproved.sql b/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_CreateAutoApproved.sql new file mode 100644 index 000000000000..f4aa0cab7e79 --- /dev/null +++ b/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_CreateAutoApproved.sql @@ -0,0 +1,47 @@ +CREATE PROCEDURE [dbo].[AccessRequest_CreateAutoApproved] + @AccessRequestId UNIQUEIDENTIFIER, + @AccessDecisionId UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @CollectionId UNIQUEIDENTIFIER, + @CipherId UNIQUEIDENTIFIER, + @RequesterId UNIQUEIDENTIFIER, + @NotBefore DATETIME2(7), + @NotAfter DATETIME2(7), + @Reason NVARCHAR(MAX) = NULL, + @ConditionKind NVARCHAR(50) = NULL, + @CreationDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + -- Atomically record an auto-approved request and its automatic verdict. No lease is minted here: the requester + -- activates the approved request later via [AccessLease_CreateFromApprovedRequest], exactly like the human path + -- after approval. The per-cipher single-active-lease guard therefore lives entirely on that activation path. + BEGIN TRANSACTION AccessRequest_CreateAutoApproved + + -- The request is created already resolved (Approved). ExtensionOfLeaseId stays NULL: it is reserved for extension + -- requests; provenance for an original lease flows the other way, via AccessLease.AccessRequestId. + INSERT INTO [dbo].[AccessRequest] + ( + [Id], [ExtensionOfLeaseId], [OrganizationId], [CollectionId], [CipherId], [RequesterId], + [NotBefore], [NotAfter], [Reason], [Status], [CreationDate], [ResolvedDate] + ) + VALUES + ( + @AccessRequestId, NULL, @OrganizationId, @CollectionId, @CipherId, @RequesterId, + @NotBefore, @NotAfter, @Reason, 1 /* Approved */, @CreationDate, @CreationDate + ) + + INSERT INTO [dbo].[AccessDecision] + ( + [Id], [AccessRequestId], [DeciderKind], [ApproverId], [ConditionKind], + [Verdict], [Comment], [EvaluationContext], [CreationDate] + ) + VALUES + ( + @AccessDecisionId, @AccessRequestId, 0 /* Automatic */, NULL, @ConditionKind, + 0 /* Approve */, NULL, NULL, @CreationDate + ) + + COMMIT TRANSACTION AccessRequest_CreateAutoApproved +END diff --git a/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_ResolveWithDecision.sql b/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_ResolveWithDecision.sql index 52c2e0f7ab4d..28fda8157b9e 100644 --- a/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_ResolveWithDecision.sql +++ b/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_ResolveWithDecision.sql @@ -15,8 +15,8 @@ BEGIN -- under a race so a second approver can't move an already-resolved request. -- -- Approval does not mint the lease: the requester activates the approved request later via - -- [AccessLease_CreateFromApprovedRequest]. The automatic path still mints instantly via - -- [AccessLease_CreateAutoApproved], where the requester is online and asking for access now. + -- [AccessLease_CreateFromApprovedRequest]. The automatic path ([AccessRequest_CreateAutoApproved]) records the + -- approved request the same way and likewise leaves the lease to be minted at activation. BEGIN TRANSACTION AccessRequest_Resolve UPDATE [dbo].[AccessRequest] diff --git a/test/Core.Test/Pam/Commands/SubmitAccessRequestCommandTests.cs b/test/Core.Test/Pam/Commands/SubmitAccessRequestCommandTests.cs index 039e98eb95eb..ebf86612d762 100644 --- a/test/Core.Test/Pam/Commands/SubmitAccessRequestCommandTests.cs +++ b/test/Core.Test/Pam/Commands/SubmitAccessRequestCommandTests.cs @@ -46,25 +46,31 @@ public async Task SubmitAsync_NotLeasingGated_ThrowsBadRequest(Guid userId, Guid } [Theory, BitAutoData] - public async Task SubmitAsync_Automatic_IssuesActiveLease(Guid userId, Guid cipherId, Guid orgId, Guid collectionId) + public async Task SubmitAsync_Automatic_CreatesApprovedRequestWithoutMintingLease( + Guid userId, Guid cipherId, Guid orgId, Guid collectionId) { var sutProvider = Setup(); SetupCipher(sutProvider, userId, cipherId); SetupResolution(sutProvider, userId, cipherId, orgId, collectionId, requiresHuman: false); SetupEvaluation(sutProvider, AccessEvaluation.Allow); - SetupMintOutcome(sutProvider, AccessLeaseMintOutcome.Minted); var result = await sutProvider.Sut.SubmitAsync(userId, cipherId, new AccessRequestSubmission { DurationSeconds = 3600, Reason = "deploy" }); + // The automatic path no longer mints a lease at submit; it produces a startable, already-approved request the + // requester activates explicitly. The window spans the requested duration from now. Assert.Equal(AccessApprovalMode.Automatic, result.ApprovalMode); - Assert.NotNull(result.Lease); - Assert.Equal(AccessLeaseStatus.Active, result.Lease!.Status); - Assert.Equal(_now, result.Lease.NotBefore); - Assert.Equal(_now.AddSeconds(3600), result.Lease.NotAfter); - await sutProvider.GetDependency().Received(1) - .CreateAutoApprovedAsync(Arg.Any(), Arg.Any(), Arg.Any(), _now, - Arg.Any()); + Assert.Equal(AccessRequestStatus.Approved, result.Request.Status); + Assert.Equal(_now, result.Request.NotBefore); + Assert.Equal(_now.AddSeconds(3600), result.Request.NotAfter); + Assert.Equal("deploy", result.Request.Reason); + + await sutProvider.GetDependency().Received(1) + .CreateAutoApprovedAsync( + Arg.Is(r => r.Status == AccessRequestStatus.Approved && r.NotBefore == _now + && r.NotAfter == _now.AddSeconds(3600)), + Arg.Is(d => d.DeciderKind == AccessDeciderKind.Automatic + && d.Verdict == AccessDecisionVerdict.Approve)); } [Theory, BitAutoData] @@ -78,8 +84,8 @@ public async Task SubmitAsync_AutomaticWithWindow_ThrowsBadRequest(Guid userId, () => sutProvider.Sut.SubmitAsync(userId, cipherId, new AccessRequestSubmission { Start = _now, End = _now.AddHours(1) })); Assert.Contains("provide a duration", ex.Message); - await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() - .CreateAutoApprovedAsync(default!, default!, default!, default, default); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .CreateAutoApprovedAsync(default!, default!); } [Theory, BitAutoData] @@ -118,9 +124,9 @@ public async Task SubmitAsync_AutomaticPolicyDenied_ThrowsBadRequestAndIssuesNoL var ex = await Assert.ThrowsAsync( () => sutProvider.Sut.SubmitAsync(userId, cipherId, new AccessRequestSubmission { DurationSeconds = 3600 })); Assert.Contains("network", ex.Message); - // A rule the caller fails to satisfy must not produce a lease. - await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() - .CreateAutoApprovedAsync(default!, default!, default!, default, default); + // A rule the caller fails to satisfy must not produce an approved request. + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .CreateAutoApprovedAsync(default!, default!); } [Theory, BitAutoData] @@ -144,8 +150,8 @@ public async Task SubmitAsync_Human_CreatesPendingRequest(Guid userId, Guid ciph Assert.Equal(start, result.Request.NotBefore); Assert.Equal(end, result.Request.NotAfter); Assert.Equal("audit", result.Request.Reason); - await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() - .CreateAutoApprovedAsync(default!, default!, default!, default, default); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .CreateAutoApprovedAsync(default!, default!); await sutProvider.GetDependency().Received(1) .NotifyCollectionApproversAsync(collectionId); } @@ -157,51 +163,10 @@ public async Task SubmitAsync_Automatic_DoesNotNotifyApprovers(Guid userId, Guid SetupCipher(sutProvider, userId, cipherId); SetupResolution(sutProvider, userId, cipherId, orgId, collectionId, requiresHuman: false); SetupEvaluation(sutProvider, AccessEvaluation.Allow); - SetupMintOutcome(sutProvider, AccessLeaseMintOutcome.Minted); - - await sutProvider.Sut.SubmitAsync(userId, cipherId, - new AccessRequestSubmission { DurationSeconds = 3600, Reason = "deploy" }); - - await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() - .NotifyCollectionApproversAsync(default); - } - - [Theory, BitAutoData] - public async Task SubmitAsync_AutomaticSingleActiveLeaseApplies_PassesEnforceTrue( - Guid userId, Guid cipherId, Guid orgId, Guid collectionId) - { - var sutProvider = Setup(); - SetupCipher(sutProvider, userId, cipherId); - SetupResolution(sutProvider, userId, cipherId, orgId, collectionId, requiresHuman: false); - SetupEvaluation(sutProvider, AccessEvaluation.Allow); - sutProvider.GetDependency().AppliesAsync(userId, cipherId).Returns(true); - sutProvider.GetDependency() - .CreateAutoApprovedAsync(Arg.Any(), Arg.Any(), Arg.Any(), _now, true) - .Returns(AccessLeaseMintOutcome.Minted); await sutProvider.Sut.SubmitAsync(userId, cipherId, new AccessRequestSubmission { DurationSeconds = 3600, Reason = "deploy" }); - await sutProvider.GetDependency().Received(1) - .CreateAutoApprovedAsync(Arg.Any(), Arg.Any(), Arg.Any(), _now, true); - } - - [Theory, BitAutoData] - public async Task SubmitAsync_AutomaticSingleActiveLeaseConflict_ThrowsBadRequest( - Guid userId, Guid cipherId, Guid orgId, Guid collectionId) - { - var sutProvider = Setup(); - SetupCipher(sutProvider, userId, cipherId); - SetupResolution(sutProvider, userId, cipherId, orgId, collectionId, requiresHuman: false); - SetupEvaluation(sutProvider, AccessEvaluation.Allow); - sutProvider.GetDependency().AppliesAsync(userId, cipherId).Returns(true); - SetupMintOutcome(sutProvider, AccessLeaseMintOutcome.SingleActiveLeaseConflict); - - // The proc rolled back, so nothing is persisted; the caller sees a 400. - var ex = await Assert.ThrowsAsync( - () => sutProvider.Sut.SubmitAsync(userId, cipherId, - new AccessRequestSubmission { DurationSeconds = 3600, Reason = "deploy" })); - Assert.Contains("Another active lease exists", ex.Message); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() .NotifyCollectionApproversAsync(default); } @@ -325,12 +290,4 @@ private static void SetupEvaluation(SutProvider sutP .Evaluate(Arg.Any(), Arg.Any()) .Returns(evaluation); } - - private static void SetupMintOutcome(SutProvider sutProvider, AccessLeaseMintOutcome outcome) - { - sutProvider.GetDependency() - .CreateAutoApprovedAsync(Arg.Any(), Arg.Any(), Arg.Any(), - Arg.Any(), Arg.Any()) - .Returns(outcome); - } } diff --git a/test/Infrastructure.IntegrationTest/Pam/Repositories/AccessLeaseRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Pam/Repositories/AccessLeaseRepositoryTests.cs index 3189a1eeb78f..c1c7271bb4a5 100644 --- a/test/Infrastructure.IntegrationTest/Pam/Repositories/AccessLeaseRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/Pam/Repositories/AccessLeaseRepositoryTests.cs @@ -11,35 +11,34 @@ namespace Bit.Infrastructure.IntegrationTest.Pam.Repositories; public class LeaseRepositoryTests { [DatabaseTheory, DatabaseData] - public async Task CreateAutoApprovedAsync_PersistsApprovedRequestDecisionAndActiveLease( + public async Task CreateAutoApprovedAsync_PersistsApprovedRequestAndDecisionWithoutLease( IOrganizationRepository organizationRepository, - IAccessLeaseRepository accessLeaseRepository, - IAccessRequestRepository accessRequestRepository) + IAccessRequestRepository accessRequestRepository, + IAccessLeaseRepository accessLeaseRepository) { var organization = await organizationRepository.CreateTestOrganizationAsync(); var now = DateTime.UtcNow; var cipherId = Guid.NewGuid(); var requesterId = Guid.NewGuid(); - var (request, decision, lease) = BuildAutoApproved(organization.Id, cipherId, requesterId, now, now.AddHours(1)); + var (request, decision, _) = BuildAutoApproved(organization.Id, cipherId, requesterId, now, now.AddHours(1)); - var outcome = await accessLeaseRepository.CreateAutoApprovedAsync(request, decision, lease, now, false); - Assert.Equal(AccessLeaseMintOutcome.Minted, outcome); + await accessRequestRepository.CreateAutoApprovedAsync(request, decision); + // The request is persisted already resolved as Approved... var persistedRequest = await accessRequestRepository.GetByIdAsync(request.Id); Assert.NotNull(persistedRequest); Assert.Equal(AccessRequestStatus.Approved, persistedRequest!.Status); Assert.NotNull(persistedRequest.ResolvedDate); - var persistedLease = await accessLeaseRepository.GetByIdAsync(lease.Id); - Assert.NotNull(persistedLease); - Assert.Equal(AccessLeaseStatus.Active, persistedLease!.Status); - Assert.Equal(request.Id, persistedLease.AccessRequestId); + // ...but no lease is minted at submit: the requester activates the approved request to start one. + Assert.Null(await accessLeaseRepository.GetByAccessRequestIdAsync(request.Id)); } [DatabaseTheory, DatabaseData] public async Task GetActiveByRequesterIdCipherIdAsync_WithinWindow_ReturnsLease( IOrganizationRepository organizationRepository, + IAccessRequestRepository accessRequestRepository, IAccessLeaseRepository accessLeaseRepository) { var organization = await organizationRepository.CreateTestOrganizationAsync(); @@ -49,7 +48,7 @@ public async Task GetActiveByRequesterIdCipherIdAsync_WithinWindow_ReturnsLease( var (request, decision, lease) = BuildAutoApproved( organization.Id, cipherId, requesterId, now.AddMinutes(-5), now.AddHours(1)); - await accessLeaseRepository.CreateAutoApprovedAsync(request, decision, lease, now, false); + await SeedActiveLeaseAsync(accessRequestRepository, accessLeaseRepository, request, decision, lease, now); var active = await accessLeaseRepository.GetActiveByRequesterIdCipherIdAsync(requesterId, cipherId, now); @@ -60,6 +59,7 @@ public async Task GetActiveByRequesterIdCipherIdAsync_WithinWindow_ReturnsLease( [DatabaseTheory, DatabaseData] public async Task GetActiveByRequesterIdCipherIdAsync_OutsideWindow_ReturnsNull( IOrganizationRepository organizationRepository, + IAccessRequestRepository accessRequestRepository, IAccessLeaseRepository accessLeaseRepository) { var organization = await organizationRepository.CreateTestOrganizationAsync(); @@ -67,10 +67,12 @@ public async Task GetActiveByRequesterIdCipherIdAsync_OutsideWindow_ReturnsNull( var cipherId = Guid.NewGuid(); var requesterId = Guid.NewGuid(); - // A lease whose window has already elapsed. + // A lease whose window has already elapsed. It is minted while the window was still open (now - 2h), then + // read back at now, by which point it has expired. var (request, decision, lease) = BuildAutoApproved( organization.Id, cipherId, requesterId, now.AddHours(-2), now.AddHours(-1)); - await accessLeaseRepository.CreateAutoApprovedAsync(request, decision, lease, now.AddHours(-2), false); + await SeedActiveLeaseAsync( + accessRequestRepository, accessLeaseRepository, request, decision, lease, now.AddHours(-2)); var active = await accessLeaseRepository.GetActiveByRequesterIdCipherIdAsync(requesterId, cipherId, now); @@ -110,6 +112,7 @@ public async Task GetActivePendingByRequesterIdCipherIdAsync_ReturnsPendingReque [DatabaseTheory, DatabaseData] public async Task GetManyActiveByRequesterIdAsync_ReturnsOnlyActiveLeasesInWindow( IOrganizationRepository organizationRepository, + IAccessRequestRepository accessRequestRepository, IAccessLeaseRepository accessLeaseRepository) { var organization = await organizationRepository.CreateTestOrganizationAsync(); @@ -119,17 +122,18 @@ public async Task GetManyActiveByRequesterIdAsync_ReturnsOnlyActiveLeasesInWindo // Active, in-window lease for the requester. var (activeReq, activeDec, activeLease) = BuildAutoApproved( organization.Id, Guid.NewGuid(), requesterId, now.AddMinutes(-5), now.AddHours(1)); - await accessLeaseRepository.CreateAutoApprovedAsync(activeReq, activeDec, activeLease, now, false); + await SeedActiveLeaseAsync(accessRequestRepository, accessLeaseRepository, activeReq, activeDec, activeLease, now); // Expired lease for the same requester — must be excluded. var (expiredReq, expiredDec, expiredLease) = BuildAutoApproved( organization.Id, Guid.NewGuid(), requesterId, now.AddHours(-2), now.AddHours(-1)); - await accessLeaseRepository.CreateAutoApprovedAsync(expiredReq, expiredDec, expiredLease, now.AddHours(-2), false); + await SeedActiveLeaseAsync( + accessRequestRepository, accessLeaseRepository, expiredReq, expiredDec, expiredLease, now.AddHours(-2)); // Active lease for a different requester — must be excluded. var (otherReq, otherDec, otherLease) = BuildAutoApproved( organization.Id, Guid.NewGuid(), Guid.NewGuid(), now.AddMinutes(-5), now.AddHours(1)); - await accessLeaseRepository.CreateAutoApprovedAsync(otherReq, otherDec, otherLease, now, false); + await SeedActiveLeaseAsync(accessRequestRepository, accessLeaseRepository, otherReq, otherDec, otherLease, now); var result = await accessLeaseRepository.GetManyActiveByRequesterIdAsync(requesterId, now); @@ -140,6 +144,7 @@ public async Task GetManyActiveByRequesterIdAsync_ReturnsOnlyActiveLeasesInWindo [DatabaseTheory, DatabaseData] public async Task RevokeAsync_RevokesLeaseAndRecordsAuditDecision( IOrganizationRepository organizationRepository, + IAccessRequestRepository accessRequestRepository, IAccessLeaseRepository accessLeaseRepository) { var organization = await organizationRepository.CreateTestOrganizationAsync(); @@ -150,7 +155,7 @@ public async Task RevokeAsync_RevokesLeaseAndRecordsAuditDecision( var (request, decision, lease) = BuildAutoApproved( organization.Id, cipherId, requesterId, now.AddMinutes(-5), now.AddHours(1)); - await accessLeaseRepository.CreateAutoApprovedAsync(request, decision, lease, now, false); + await SeedActiveLeaseAsync(accessRequestRepository, accessLeaseRepository, request, decision, lease, now); var auditDecision = new AccessDecision { @@ -319,6 +324,18 @@ private static async Task CreateApprovedRequestAsync( ResolvedDate = status == AccessRequestStatus.Pending ? null : DateTime.UtcNow, }); + // Seeds an active lease the way production now does: record the approved request, then mint the lease by + // activating it. The mint time sits inside the request's window (it can be in the past), so leases whose windows + // have already elapsed by read time can still be seeded for the read-path tests. + private static async Task SeedActiveLeaseAsync( + IAccessRequestRepository accessRequestRepository, + IAccessLeaseRepository accessLeaseRepository, + AccessRequest request, AccessDecision decision, AccessLease lease, DateTime mintTime) + { + await accessRequestRepository.CreateAutoApprovedAsync(request, decision); + await accessLeaseRepository.CreateFromApprovedRequestAsync(lease, mintTime, false); + } + private static AccessLease BuildLeaseFor(AccessRequest request, DateTime now) => new() { diff --git a/util/Migrator/DbScripts/2026-06-11_02_DeferAutoApprovedLeaseMintToActivation.sql b/util/Migrator/DbScripts/2026-06-11_02_DeferAutoApprovedLeaseMintToActivation.sql new file mode 100644 index 000000000000..09487a0262d1 --- /dev/null +++ b/util/Migrator/DbScripts/2026-06-11_02_DeferAutoApprovedLeaseMintToActivation.sql @@ -0,0 +1,62 @@ +-- PAM Credential Leasing: the automatic (no-approval) path no longer mints a lease at submit. Like the human path, +-- it now only records the request — already Approved, with its automatic verdict — and the requester activates the +-- approved request when they actually want access ([AccessLease_CreateFromApprovedRequest]), which is when the active +-- lease is minted. This makes "start the lease" an explicit caller action on both paths and leaves the per-cipher +-- single-active-lease guard living on the single remaining mint site (activation). +-- +-- [AccessRequest_CreateAutoApproved] replaces [AccessLease_CreateAutoApproved]: it writes the request + automatic +-- decision in one transaction and inserts no lease. The old proc is dropped outright rather than left as a no-longer +-- called shim. Acceptable here — the feature is an unshipped POC behind the pm-37044-pam-v-0 flag and server + +-- migration deploy together. + +CREATE OR ALTER PROCEDURE [dbo].[AccessRequest_CreateAutoApproved] + @AccessRequestId UNIQUEIDENTIFIER, + @AccessDecisionId UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @CollectionId UNIQUEIDENTIFIER, + @CipherId UNIQUEIDENTIFIER, + @RequesterId UNIQUEIDENTIFIER, + @NotBefore DATETIME2(7), + @NotAfter DATETIME2(7), + @Reason NVARCHAR(MAX) = NULL, + @ConditionKind NVARCHAR(50) = NULL, + @CreationDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + -- Atomically record an auto-approved request and its automatic verdict. No lease is minted here: the requester + -- activates the approved request later via [AccessLease_CreateFromApprovedRequest], exactly like the human path + -- after approval. The per-cipher single-active-lease guard therefore lives entirely on that activation path. + BEGIN TRANSACTION AccessRequest_CreateAutoApproved + + -- The request is created already resolved (Approved). ExtensionOfLeaseId stays NULL: it is reserved for extension + -- requests; provenance for an original lease flows the other way, via AccessLease.AccessRequestId. + INSERT INTO [dbo].[AccessRequest] + ( + [Id], [ExtensionOfLeaseId], [OrganizationId], [CollectionId], [CipherId], [RequesterId], + [NotBefore], [NotAfter], [Reason], [Status], [CreationDate], [ResolvedDate] + ) + VALUES + ( + @AccessRequestId, NULL, @OrganizationId, @CollectionId, @CipherId, @RequesterId, + @NotBefore, @NotAfter, @Reason, 1 /* Approved */, @CreationDate, @CreationDate + ) + + INSERT INTO [dbo].[AccessDecision] + ( + [Id], [AccessRequestId], [DeciderKind], [ApproverId], [ConditionKind], + [Verdict], [Comment], [EvaluationContext], [CreationDate] + ) + VALUES + ( + @AccessDecisionId, @AccessRequestId, 0 /* Automatic */, NULL, @ConditionKind, + 0 /* Approve */, NULL, NULL, @CreationDate + ) + + COMMIT TRANSACTION AccessRequest_CreateAutoApproved +END +GO + +DROP PROCEDURE IF EXISTS [dbo].[AccessLease_CreateAutoApproved] +GO From eea48139a1a229c0d11ded81e202dcf818edb990 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anders=20=C3=85berg?= Date: Thu, 11 Jun 2026 16:30:06 +0200 Subject: [PATCH 21/54] Persist default and max lease durations on PAM access rules The access-rule dialog sends defaultLeaseDurationSeconds and maxLeaseDurationSeconds, and the client reads them back from the rule response, but the server had no fields to receive them: AccessRuleRequestModel ignored them, the AccessRule entity had no columns, and the response never returned them. The values were silently dropped on every save, so reopening a rule reset both to defaults. Add DefaultLeaseDurationSeconds and MaxLeaseDurationSeconds (nullable int seconds) to AccessRule end to end: entity, API request/response, AccessRule_Create/_Update procs, table, the field-by-field rebuild in UpdateAccessRuleCommand, and AccessRuleDetails.From, plus a migration. Null default duration means the backend default; null max means no per-rule cap. Matches the SingleActiveLease footprint; EF migrations stay deferred for the POC. --- .../Models/Request/AccessRuleRequestModel.cs | 14 +++ .../Response/AccessRuleResponseModel.cs | 4 + src/Core/Pam/Entities/AccessRule.cs | 12 +++ src/Core/Pam/Models/AccessRuleDetails.cs | 2 + .../Commands/UpdateAccessRuleCommand.cs | 2 + .../Stored Procedures/AccessRule_Create.sql | 6 ++ .../Stored Procedures/AccessRule_Update.sql | 4 + src/Sql/dbo/Pam/Tables/AccessRule.sql | 2 + .../Commands/CreateAccessRuleCommandTests.cs | 8 +- .../Commands/UpdateAccessRuleCommandTests.cs | 7 +- ...6-06-11_02_AddAccessRuleLeaseDurations.sql | 101 ++++++++++++++++++ 11 files changed, 160 insertions(+), 2 deletions(-) create mode 100644 util/Migrator/DbScripts/2026-06-11_02_AddAccessRuleLeaseDurations.sql diff --git a/src/Api/Pam/Models/Request/AccessRuleRequestModel.cs b/src/Api/Pam/Models/Request/AccessRuleRequestModel.cs index 7c34554019f5..5913e45ee9bb 100644 --- a/src/Api/Pam/Models/Request/AccessRuleRequestModel.cs +++ b/src/Api/Pam/Models/Request/AccessRuleRequestModel.cs @@ -20,6 +20,18 @@ public class AccessRuleRequestModel /// public bool SingleActiveLease { get; set; } + /// + /// Default lease duration in seconds, used to pre-fill a request opened under this rule. Null means the + /// backend default applies. + /// + public int? DefaultLeaseDurationSeconds { get; set; } + + /// + /// Hard ceiling on the duration of any single lease granted under this rule, in seconds. Null means no + /// per-rule cap. + /// + public int? MaxLeaseDurationSeconds { get; set; } + /// /// The complete set of collections this rule governs. The rule's associations are replaced to match /// exactly this set; an empty array clears all associations. @@ -34,6 +46,8 @@ public class AccessRuleRequestModel Description = Description, Conditions = SerializeConditions(Conditions), SingleActiveLease = SingleActiveLease, + DefaultLeaseDurationSeconds = DefaultLeaseDurationSeconds, + MaxLeaseDurationSeconds = MaxLeaseDurationSeconds, }; private static string SerializeConditions(object conditions) => conditions switch diff --git a/src/Api/Pam/Models/Response/AccessRuleResponseModel.cs b/src/Api/Pam/Models/Response/AccessRuleResponseModel.cs index 9bff875880c9..c9c8b28e5a5b 100644 --- a/src/Api/Pam/Models/Response/AccessRuleResponseModel.cs +++ b/src/Api/Pam/Models/Response/AccessRuleResponseModel.cs @@ -17,6 +17,8 @@ public AccessRuleResponseModel(AccessRuleDetails rule) Description = rule.Description; Conditions = TryParseConditions(rule.Conditions); SingleActiveLease = rule.SingleActiveLease; + DefaultLeaseDurationSeconds = rule.DefaultLeaseDurationSeconds; + MaxLeaseDurationSeconds = rule.MaxLeaseDurationSeconds; CreationDate = rule.CreationDate; RevisionDate = rule.RevisionDate; Collections = rule.CollectionIds.ToList(); @@ -28,6 +30,8 @@ public AccessRuleResponseModel(AccessRuleDetails rule) public string? Description { get; } public JsonElement? Conditions { get; } public bool SingleActiveLease { get; } + public int? DefaultLeaseDurationSeconds { get; } + public int? MaxLeaseDurationSeconds { get; } public DateTime CreationDate { get; } public DateTime RevisionDate { get; } public IEnumerable Collections { get; } diff --git a/src/Core/Pam/Entities/AccessRule.cs b/src/Core/Pam/Entities/AccessRule.cs index 00b068eaea0c..6282f7dd1c03 100644 --- a/src/Core/Pam/Entities/AccessRule.cs +++ b/src/Core/Pam/Entities/AccessRule.cs @@ -32,6 +32,18 @@ public class AccessRule : ITableObject /// public bool SingleActiveLease { get; set; } + /// + /// Default lease duration in seconds, used to pre-fill a request opened under this rule. Null means no + /// rule-specific default is stored and the backend default applies. + /// + public int? DefaultLeaseDurationSeconds { get; set; } + + /// + /// Hard ceiling on the duration of any single lease granted under this rule, in seconds. Null means no + /// per-rule cap (the global maximum still applies). + /// + public int? MaxLeaseDurationSeconds { get; set; } + public DateTime CreationDate { get; set; } = DateTime.UtcNow; public DateTime RevisionDate { get; set; } = DateTime.UtcNow; diff --git a/src/Core/Pam/Models/AccessRuleDetails.cs b/src/Core/Pam/Models/AccessRuleDetails.cs index 59d9cefece3c..0891e8a6a535 100644 --- a/src/Core/Pam/Models/AccessRuleDetails.cs +++ b/src/Core/Pam/Models/AccessRuleDetails.cs @@ -17,6 +17,8 @@ public class AccessRuleDetails : AccessRule Description = rule.Description, Conditions = rule.Conditions, SingleActiveLease = rule.SingleActiveLease, + DefaultLeaseDurationSeconds = rule.DefaultLeaseDurationSeconds, + MaxLeaseDurationSeconds = rule.MaxLeaseDurationSeconds, CreationDate = rule.CreationDate, RevisionDate = rule.RevisionDate, CollectionIds = collectionIds, diff --git a/src/Core/Pam/OrganizationFeatures/Commands/UpdateAccessRuleCommand.cs b/src/Core/Pam/OrganizationFeatures/Commands/UpdateAccessRuleCommand.cs index 6a4ac663d343..cfeba97634e4 100644 --- a/src/Core/Pam/OrganizationFeatures/Commands/UpdateAccessRuleCommand.cs +++ b/src/Core/Pam/OrganizationFeatures/Commands/UpdateAccessRuleCommand.cs @@ -65,6 +65,8 @@ public async Task UpdateAsync(Guid organizationId, Guid id, A Description = update.Description, Conditions = update.Conditions, SingleActiveLease = update.SingleActiveLease, + DefaultLeaseDurationSeconds = update.DefaultLeaseDurationSeconds, + MaxLeaseDurationSeconds = update.MaxLeaseDurationSeconds, CreationDate = existing.CreationDate, RevisionDate = _timeProvider.GetUtcNow().UtcDateTime, }; diff --git a/src/Sql/dbo/Pam/Stored Procedures/AccessRule_Create.sql b/src/Sql/dbo/Pam/Stored Procedures/AccessRule_Create.sql index 25f8cc1b142e..dbc6baf754ac 100644 --- a/src/Sql/dbo/Pam/Stored Procedures/AccessRule_Create.sql +++ b/src/Sql/dbo/Pam/Stored Procedures/AccessRule_Create.sql @@ -5,6 +5,8 @@ CREATE PROCEDURE [dbo].[AccessRule_Create] @Description NVARCHAR(MAX) = NULL, @Conditions NVARCHAR(MAX), @SingleActiveLease BIT = 0, + @DefaultLeaseDurationSeconds INT = NULL, + @MaxLeaseDurationSeconds INT = NULL, @CreationDate DATETIME2(7), @RevisionDate DATETIME2(7) AS @@ -19,6 +21,8 @@ BEGIN [Description], [Conditions], [SingleActiveLease], + [DefaultLeaseDurationSeconds], + [MaxLeaseDurationSeconds], [CreationDate], [RevisionDate] ) @@ -30,6 +34,8 @@ BEGIN @Description, @Conditions, @SingleActiveLease, + @DefaultLeaseDurationSeconds, + @MaxLeaseDurationSeconds, @CreationDate, @RevisionDate ) diff --git a/src/Sql/dbo/Pam/Stored Procedures/AccessRule_Update.sql b/src/Sql/dbo/Pam/Stored Procedures/AccessRule_Update.sql index a7ee754efec7..e6d8107ceb47 100644 --- a/src/Sql/dbo/Pam/Stored Procedures/AccessRule_Update.sql +++ b/src/Sql/dbo/Pam/Stored Procedures/AccessRule_Update.sql @@ -5,6 +5,8 @@ CREATE PROCEDURE [dbo].[AccessRule_Update] @Description NVARCHAR(MAX) = NULL, @Conditions NVARCHAR(MAX), @SingleActiveLease BIT = 0, + @DefaultLeaseDurationSeconds INT = NULL, + @MaxLeaseDurationSeconds INT = NULL, @CreationDate DATETIME2(7), @RevisionDate DATETIME2(7) AS @@ -19,6 +21,8 @@ BEGIN [Description] = @Description, [Conditions] = @Conditions, [SingleActiveLease] = @SingleActiveLease, + [DefaultLeaseDurationSeconds] = @DefaultLeaseDurationSeconds, + [MaxLeaseDurationSeconds] = @MaxLeaseDurationSeconds, [CreationDate] = @CreationDate, [RevisionDate] = @RevisionDate WHERE diff --git a/src/Sql/dbo/Pam/Tables/AccessRule.sql b/src/Sql/dbo/Pam/Tables/AccessRule.sql index 6c8f9295c9f1..ca9fcaf5b5b0 100644 --- a/src/Sql/dbo/Pam/Tables/AccessRule.sql +++ b/src/Sql/dbo/Pam/Tables/AccessRule.sql @@ -5,6 +5,8 @@ CREATE TABLE [dbo].[AccessRule] ( [Description] NVARCHAR(MAX) NULL, [Conditions] NVARCHAR(MAX) NOT NULL, [SingleActiveLease] BIT NOT NULL CONSTRAINT [DF_AccessRule_SingleActiveLease] DEFAULT (0), + [DefaultLeaseDurationSeconds] INT NULL, + [MaxLeaseDurationSeconds] INT NULL, [CreationDate] DATETIME2(7) NOT NULL, [RevisionDate] DATETIME2(7) NOT NULL, CONSTRAINT [PK_AccessRule] PRIMARY KEY CLUSTERED ([Id] ASC), diff --git a/test/Core.Test/Pam/Commands/CreateAccessRuleCommandTests.cs b/test/Core.Test/Pam/Commands/CreateAccessRuleCommandTests.cs index 11bd6f11c639..47f50bf78459 100644 --- a/test/Core.Test/Pam/Commands/CreateAccessRuleCommandTests.cs +++ b/test/Core.Test/Pam/Commands/CreateAccessRuleCommandTests.cs @@ -24,6 +24,8 @@ public async Task CreateAsync_HappyPath_PersistsWithTimestampsAndValidates(Acces var sutProvider = SetupSutProvider(); rule.Name = "VPN + business hours"; rule.Conditions = """{"kind":"human_approval"}"""; + rule.DefaultLeaseDurationSeconds = 3600; + rule.MaxLeaseDurationSeconds = 28800; sutProvider.GetDependency() .Validate(rule.Conditions) .Returns(AccessRuleValidationResult.Valid); @@ -38,7 +40,11 @@ public async Task CreateAsync_HappyPath_PersistsWithTimestampsAndValidates(Acces Assert.Equal(_now, result.CreationDate); Assert.Equal(_now, result.RevisionDate); - await sutProvider.GetDependency().Received(1).CreateAsync(rule); + Assert.Equal(3600, result.DefaultLeaseDurationSeconds); + Assert.Equal(28800, result.MaxLeaseDurationSeconds); + await sutProvider.GetDependency().Received(1) + .CreateAsync(Arg.Is(r => + r.DefaultLeaseDurationSeconds == 3600 && r.MaxLeaseDurationSeconds == 28800)); } [Theory, BitAutoData] diff --git a/test/Core.Test/Pam/Commands/UpdateAccessRuleCommandTests.cs b/test/Core.Test/Pam/Commands/UpdateAccessRuleCommandTests.cs index 435eb8f6d1aa..456022d350eb 100644 --- a/test/Core.Test/Pam/Commands/UpdateAccessRuleCommandTests.cs +++ b/test/Core.Test/Pam/Commands/UpdateAccessRuleCommandTests.cs @@ -29,6 +29,8 @@ public async Task UpdateAsync_HappyPath_UpdatesFieldsAndBumpsRevision(AccessRule update.Description = "new description"; update.Conditions = """{"kind":"human_approval"}"""; update.SingleActiveLease = true; + update.DefaultLeaseDurationSeconds = 3600; + update.MaxLeaseDurationSeconds = 28800; sutProvider.GetDependency() .GetDetailsByIdAsync(existing.Id) .Returns(existing); @@ -45,11 +47,14 @@ public async Task UpdateAsync_HappyPath_UpdatesFieldsAndBumpsRevision(AccessRule Assert.Equal("new description", result.Description); Assert.Equal(update.Conditions, result.Conditions); Assert.True(result.SingleActiveLease); + Assert.Equal(3600, result.DefaultLeaseDurationSeconds); + Assert.Equal(28800, result.MaxLeaseDurationSeconds); Assert.Equal(_now, result.RevisionDate); await sutProvider.GetDependency().Received(1) .ReplaceAsync(Arg.Is(r => r.Id == existing.Id && r.Name == "renamed" && r.Description == "new description" - && r.SingleActiveLease)); + && r.SingleActiveLease + && r.DefaultLeaseDurationSeconds == 3600 && r.MaxLeaseDurationSeconds == 28800)); } [Theory, BitAutoData] diff --git a/util/Migrator/DbScripts/2026-06-11_02_AddAccessRuleLeaseDurations.sql b/util/Migrator/DbScripts/2026-06-11_02_AddAccessRuleLeaseDurations.sql new file mode 100644 index 000000000000..a4aa3153444d --- /dev/null +++ b/util/Migrator/DbScripts/2026-06-11_02_AddAccessRuleLeaseDurations.sql @@ -0,0 +1,101 @@ +-- PAM Credential Leasing: per-rule lease durations. +-- +-- AccessRule gains [DefaultLeaseDurationSeconds] and [MaxLeaseDurationSeconds] (both nullable INT seconds). +-- The default is used to pre-fill a request opened under the rule; null means the backend default applies. +-- The max is a hard ceiling on any single lease granted under the rule; null means no per-rule cap. The +-- client already sends and reads both fields, but the server had nowhere to persist them, so they were +-- dropped on every save. AccessRule_Create/_Update gain matching parameters (defaulting to NULL so the +-- procs stay backward compatible). The Read procs use SELECT * and pick the new columns up automatically. +-- +-- PAM is an unshipped POC behind the pm-37044-pam-v-0 flag with no production data; server + migration deploy +-- together, so the affected procs are altered in place rather than versioned. + +IF COL_LENGTH('[dbo].[AccessRule]', 'DefaultLeaseDurationSeconds') IS NULL +BEGIN + ALTER TABLE [dbo].[AccessRule] + ADD [DefaultLeaseDurationSeconds] INT NULL +END +GO + +IF COL_LENGTH('[dbo].[AccessRule]', 'MaxLeaseDurationSeconds') IS NULL +BEGIN + ALTER TABLE [dbo].[AccessRule] + ADD [MaxLeaseDurationSeconds] INT NULL +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[AccessRule_Create] + @Id UNIQUEIDENTIFIER OUTPUT, + @OrganizationId UNIQUEIDENTIFIER, + @Name NVARCHAR(256), + @Description NVARCHAR(MAX) = NULL, + @Conditions NVARCHAR(MAX), + @SingleActiveLease BIT = 0, + @DefaultLeaseDurationSeconds INT = NULL, + @MaxLeaseDurationSeconds INT = NULL, + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + INSERT INTO [dbo].[AccessRule] + ( + [Id], + [OrganizationId], + [Name], + [Description], + [Conditions], + [SingleActiveLease], + [DefaultLeaseDurationSeconds], + [MaxLeaseDurationSeconds], + [CreationDate], + [RevisionDate] + ) + VALUES + ( + @Id, + @OrganizationId, + @Name, + @Description, + @Conditions, + @SingleActiveLease, + @DefaultLeaseDurationSeconds, + @MaxLeaseDurationSeconds, + @CreationDate, + @RevisionDate + ) +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[AccessRule_Update] + @Id UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @Name NVARCHAR(256), + @Description NVARCHAR(MAX) = NULL, + @Conditions NVARCHAR(MAX), + @SingleActiveLease BIT = 0, + @DefaultLeaseDurationSeconds INT = NULL, + @MaxLeaseDurationSeconds INT = NULL, + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + UPDATE + [dbo].[AccessRule] + SET + [OrganizationId] = @OrganizationId, + [Name] = @Name, + [Description] = @Description, + [Conditions] = @Conditions, + [SingleActiveLease] = @SingleActiveLease, + [DefaultLeaseDurationSeconds] = @DefaultLeaseDurationSeconds, + [MaxLeaseDurationSeconds] = @MaxLeaseDurationSeconds, + [CreationDate] = @CreationDate, + [RevisionDate] = @RevisionDate + WHERE + [Id] = @Id +END +GO From 2482b37be11e949c5a7d036b76e12e722116eec9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anders=20=C3=85berg?= Date: Thu, 11 Jun 2026 16:30:06 +0200 Subject: [PATCH 22/54] Add endpoint to cancel a pending access request --- .../Controllers/MemberLeasingController.cs | 15 +++- ...OrganizationServiceCollectionExtensions.cs | 1 + .../Commands/CancelAccessRequestCommand.cs | 51 +++++++++++ .../Interfaces/ICancelAccessRequestCommand.cs | 17 ++++ .../Repositories/IAccessRequestRepository.cs | 9 ++ .../Repositories/AccessRequestRepository.cs | 9 ++ .../AccessRequest_Cancel.sql | 17 ++++ .../MemberLeasingControllerTests.cs | 13 +++ .../CancelAccessRequestCommandTests.cs | 84 +++++++++++++++++++ .../AccessRequestRepositoryTests.cs | 43 ++++++++++ .../2026-06-11_03_AddAccessRequestCancel.sql | 22 +++++ 11 files changed, 280 insertions(+), 1 deletion(-) create mode 100644 src/Core/Pam/OrganizationFeatures/Commands/CancelAccessRequestCommand.cs create mode 100644 src/Core/Pam/OrganizationFeatures/Commands/Interfaces/ICancelAccessRequestCommand.cs create mode 100644 src/Sql/dbo/Pam/Stored Procedures/AccessRequest_Cancel.sql create mode 100644 test/Core.Test/Pam/Commands/CancelAccessRequestCommandTests.cs create mode 100644 util/Migrator/DbScripts/2026-06-11_03_AddAccessRequestCancel.sql diff --git a/src/Api/Pam/Controllers/MemberLeasingController.cs b/src/Api/Pam/Controllers/MemberLeasingController.cs index afe49fdaecdd..8710920ad7d4 100644 --- a/src/Api/Pam/Controllers/MemberLeasingController.cs +++ b/src/Api/Pam/Controllers/MemberLeasingController.cs @@ -22,7 +22,8 @@ public class MemberLeasingController( IUserService userService, IListMyAccessRequestsQuery listMyAccessRequestsQuery, IListMyActiveAccessLeasesQuery listMyActiveAccessLeasesQuery, - IActivateAccessRequestCommand activateAccessRequestCommand) + IActivateAccessRequestCommand activateAccessRequestCommand, + ICancelAccessRequestCommand cancelAccessRequestCommand) : Controller { /// @@ -62,4 +63,16 @@ public async Task Activate(Guid id) var lease = await activateAccessRequestCommand.ActivateAsync(userId, id); return new AccessLeaseResponseModel(lease); } + + /// + /// Withdraws the caller's own pending access request. Only the requester may cancel, and only while the request is + /// still pending; a resolved request can no longer be withdrawn. + /// + [HttpDelete("requests/{id:guid}")] + public async Task CancelRequest(Guid id) + { + var userId = userService.GetProperUserId(User)!.Value; + await cancelAccessRequestCommand.CancelAsync(userId, id); + return NoContent(); + } } diff --git a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs index b03b2375e3b0..9c7e1d0ef43e 100644 --- a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs +++ b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs @@ -212,6 +212,7 @@ public static void AddPamServices(this IServiceCollection services) services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/Core/Pam/OrganizationFeatures/Commands/CancelAccessRequestCommand.cs b/src/Core/Pam/OrganizationFeatures/Commands/CancelAccessRequestCommand.cs new file mode 100644 index 000000000000..dec6dd968fc6 --- /dev/null +++ b/src/Core/Pam/OrganizationFeatures/Commands/CancelAccessRequestCommand.cs @@ -0,0 +1,51 @@ +using Bit.Core.Exceptions; +using Bit.Core.Pam.Enums; +using Bit.Core.Pam.OrganizationFeatures.Commands.Interfaces; +using Bit.Core.Pam.Repositories; +using Bit.Core.Pam.Services; + +namespace Bit.Core.Pam.OrganizationFeatures.Commands; + +public class CancelAccessRequestCommand : ICancelAccessRequestCommand +{ + private readonly IAccessRequestRepository _accessRequestRepository; + private readonly IApproverInboxNotifier _approverInboxNotifier; + private readonly TimeProvider _timeProvider; + + public CancelAccessRequestCommand( + IAccessRequestRepository accessRequestRepository, + IApproverInboxNotifier approverInboxNotifier, + TimeProvider timeProvider) + { + _accessRequestRepository = accessRequestRepository; + _approverInboxNotifier = approverInboxNotifier; + _timeProvider = timeProvider; + } + + public async Task CancelAsync(Guid userId, Guid requestId) + { + var request = await _accessRequestRepository.GetByIdAsync(requestId); + + // 404 for both missing and someone else's request, so the caller can't probe for requests they don't own. + // Mirrors ActivateAccessRequestCommand. + if (request is null || request.RequesterId != userId) + { + throw new NotFoundException(); + } + + // Only a still-pending request can be withdrawn. A resolved request (approved/denied/cancelled/expired) is + // terminal; surfaced as a conflict rather than a silent success so the client refreshes its view. The + // repository write is additionally guarded on Status = Pending to stay race-safe. + if (request.Status != AccessRequestStatus.Pending) + { + throw new ConflictException("This request has already been resolved."); + } + + var now = _timeProvider.GetUtcNow().UtcDateTime; + await _accessRequestRepository.CancelAsync(request.Id, now); + + // The request just left the pending queue; tell every approver of this collection to re-fetch so the + // withdrawn request drops out of their inbox. Mirrors decide. + await _approverInboxNotifier.NotifyCollectionApproversAsync(request.CollectionId); + } +} diff --git a/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/ICancelAccessRequestCommand.cs b/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/ICancelAccessRequestCommand.cs new file mode 100644 index 000000000000..051bfc5f32d0 --- /dev/null +++ b/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/ICancelAccessRequestCommand.cs @@ -0,0 +1,17 @@ +namespace Bit.Core.Pam.OrganizationFeatures.Commands.Interfaces; + +public interface ICancelAccessRequestCommand +{ + /// + /// Withdraws the caller's own pending access request: transitions it to + /// and drops it from any approver's inbox. Only the requester + /// may cancel, and only while the request is still pending. + /// + /// + /// The request does not exist or the caller is not its requester. + /// + /// + /// The request is no longer pending (already approved, denied, cancelled, or expired) and cannot be withdrawn. + /// + Task CancelAsync(Guid userId, Guid requestId); +} diff --git a/src/Core/Pam/Repositories/IAccessRequestRepository.cs b/src/Core/Pam/Repositories/IAccessRequestRepository.cs index cd65377a77d6..a2f6a2ef8c10 100644 --- a/src/Core/Pam/Repositories/IAccessRequestRepository.cs +++ b/src/Core/Pam/Repositories/IAccessRequestRepository.cs @@ -56,4 +56,13 @@ public interface IAccessRequestRepository /// entities must already have their ids assigned. /// Task ResolveWithDecisionAsync(AccessRequest request, AccessDecision decision, AccessRequestStatus status, DateTime now); + + /// + /// Withdraws the requester's own pending request: transitions it to + /// and stamps as its resolved date. No is written — a + /// cancellation is the requester acting on their own request, not an approver verdict. The caller enforces + /// ownership and the pending precondition; the write is guarded so a request that has already left + /// is left untouched (race-safe / idempotent). + /// + Task CancelAsync(Guid id, DateTime now); } diff --git a/src/Infrastructure.Dapper/Pam/Repositories/AccessRequestRepository.cs b/src/Infrastructure.Dapper/Pam/Repositories/AccessRequestRepository.cs index 41b545a83a24..e14184ca14ef 100644 --- a/src/Infrastructure.Dapper/Pam/Repositories/AccessRequestRepository.cs +++ b/src/Infrastructure.Dapper/Pam/Repositories/AccessRequestRepository.cs @@ -128,4 +128,13 @@ await connection.ExecuteAsync( }, commandType: CommandType.StoredProcedure); } + + public async Task CancelAsync(Guid id, DateTime now) + { + await using var connection = new SqlConnection(ConnectionString); + await connection.ExecuteAsync( + $"[{Schema}].[AccessRequest_Cancel]", + new { AccessRequestId = id, Now = now }, + commandType: CommandType.StoredProcedure); + } } diff --git a/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_Cancel.sql b/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_Cancel.sql new file mode 100644 index 000000000000..9791e618797d --- /dev/null +++ b/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_Cancel.sql @@ -0,0 +1,17 @@ +CREATE PROCEDURE [dbo].[AccessRequest_Cancel] + @AccessRequestId UNIQUEIDENTIFIER, + @Now DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + -- The requester withdraws their own still-pending request. Unlike [AccessRequest_ResolveWithDecision], no + -- AccessDecision is written: a cancellation is the requester acting on their own request, not an approver verdict. + -- The caller (CancelAccessRequestCommand) has already verified ownership and that the request is Pending; the + -- WHERE guard keeps the write idempotent under a race (double-click, or a concurrent auto/human resolution) so a + -- request that has already left Pending is left untouched. + UPDATE [dbo].[AccessRequest] + SET [Status] = 3, -- Cancelled + [ResolvedDate] = @Now + WHERE [Id] = @AccessRequestId AND [Status] = 0 -- Pending +END diff --git a/test/Api.Test/Pam/Controllers/MemberLeasingControllerTests.cs b/test/Api.Test/Pam/Controllers/MemberLeasingControllerTests.cs index 09a19804be80..d92cda36f1c8 100644 --- a/test/Api.Test/Pam/Controllers/MemberLeasingControllerTests.cs +++ b/test/Api.Test/Pam/Controllers/MemberLeasingControllerTests.cs @@ -8,6 +8,7 @@ using Bit.Core.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.AspNetCore.Mvc; using NSubstitute; using Xunit; @@ -75,6 +76,18 @@ public async Task Activate_ReturnsMintedLease( Assert.Equal(AccessLeaseStatusNames.Active, result.Status); } + [Theory, BitAutoData] + public async Task CancelRequest_CancelsCallersRequest_ReturnsNoContent( + Guid userId, Guid requestId, SutProvider sutProvider) + { + SetupUser(sutProvider, userId); + + var result = await sutProvider.Sut.CancelRequest(requestId); + + Assert.IsType(result); + await sutProvider.GetDependency().Received(1).CancelAsync(userId, requestId); + } + private static void SetupUser(SutProvider sutProvider, Guid userId) { sutProvider.GetDependency() diff --git a/test/Core.Test/Pam/Commands/CancelAccessRequestCommandTests.cs b/test/Core.Test/Pam/Commands/CancelAccessRequestCommandTests.cs new file mode 100644 index 000000000000..2e90036d9931 --- /dev/null +++ b/test/Core.Test/Pam/Commands/CancelAccessRequestCommandTests.cs @@ -0,0 +1,84 @@ +using Bit.Core.Exceptions; +using Bit.Core.Pam.Entities; +using Bit.Core.Pam.Enums; +using Bit.Core.Pam.OrganizationFeatures.Commands; +using Bit.Core.Pam.Repositories; +using Bit.Core.Pam.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.Extensions.Time.Testing; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Pam.Commands; + +[SutProviderCustomize] +public class CancelAccessRequestCommandTests +{ + private static readonly DateTime _now = new(2026, 6, 11, 12, 0, 0, DateTimeKind.Utc); + + [Theory, BitAutoData] + public async Task CancelAsync_RequestMissing_ThrowsNotFound(Guid userId, Guid requestId) + { + var sutProvider = Setup(); + sutProvider.GetDependency().GetByIdAsync(requestId).Returns((AccessRequest?)null); + + await Assert.ThrowsAsync(() => sutProvider.Sut.CancelAsync(userId, requestId)); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .CancelAsync(default, default); + } + + [Theory, BitAutoData] + public async Task CancelAsync_NotOwner_ThrowsNotFound(Guid userId, AccessRequest request) + { + var sutProvider = Setup(); + request.Status = AccessRequestStatus.Pending; + sutProvider.GetDependency().GetByIdAsync(request.Id).Returns(request); + + // Someone else's request is indistinguishable from a missing one, so ids can't be probed. + await Assert.ThrowsAsync(() => sutProvider.Sut.CancelAsync(userId, request.Id)); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .CancelAsync(default, default); + } + + [Theory] + [BitAutoData(AccessRequestStatus.Approved)] + [BitAutoData(AccessRequestStatus.Denied)] + [BitAutoData(AccessRequestStatus.Cancelled)] + [BitAutoData(AccessRequestStatus.ExpiredUnanswered)] + public async Task CancelAsync_NotPending_ThrowsConflict(AccessRequestStatus status, AccessRequest request) + { + var sutProvider = Setup(); + request.Status = status; + sutProvider.GetDependency().GetByIdAsync(request.Id).Returns(request); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.CancelAsync(request.RequesterId, request.Id)); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .CancelAsync(default, default); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .NotifyCollectionApproversAsync(default); + } + + [Theory, BitAutoData] + public async Task CancelAsync_Pending_CancelsAndNotifiesApprovers(AccessRequest request) + { + var sutProvider = Setup(); + request.Status = AccessRequestStatus.Pending; + sutProvider.GetDependency().GetByIdAsync(request.Id).Returns(request); + + await sutProvider.Sut.CancelAsync(request.RequesterId, request.Id); + + await sutProvider.GetDependency().Received(1).CancelAsync(request.Id, _now); + // The request just left the pending queue; approvers must re-fetch so it drops out of their inbox. + await sutProvider.GetDependency().Received(1) + .NotifyCollectionApproversAsync(request.CollectionId); + } + + private static SutProvider Setup() + { + var sutProvider = new SutProvider().WithFakeTimeProvider().Create(); + sutProvider.GetDependency().SetUtcNow(_now); + return sutProvider; + } +} diff --git a/test/Infrastructure.IntegrationTest/Pam/Repositories/AccessRequestRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Pam/Repositories/AccessRequestRepositoryTests.cs index c88d4a2d8052..328a8b5899b5 100644 --- a/test/Infrastructure.IntegrationTest/Pam/Repositories/AccessRequestRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/Pam/Repositories/AccessRequestRepositoryTests.cs @@ -345,6 +345,49 @@ await accessRequestRepository.CreateAsync(BuildRequest( Assert.All(mine, r => Assert.Null(r.CollectionName)); } + [DatabaseTheory, DatabaseData] + public async Task CancelAsync_PendingRequest_TransitionsToCancelledAndStampsResolvedDate( + IOrganizationRepository organizationRepository, + ICollectionRepository collectionRepository, + IAccessRequestRepository accessRequestRepository) + { + var organization = await organizationRepository.CreateTestOrganizationAsync(); + var collection = await collectionRepository.CreateTestCollectionAsync(organization); + var now = DateTime.UtcNow; + + var request = await accessRequestRepository.CreateAsync(BuildRequest( + organization.Id, collection.Id, Guid.NewGuid(), AccessRequestStatus.Pending, now)); + + var resolvedAt = now.AddMinutes(5); + await accessRequestRepository.CancelAsync(request.Id, resolvedAt); + + var persisted = await accessRequestRepository.GetByIdAsync(request.Id); + Assert.NotNull(persisted); + Assert.Equal(AccessRequestStatus.Cancelled, persisted!.Status); + Assert.NotNull(persisted.ResolvedDate); + } + + [DatabaseTheory, DatabaseData] + public async Task CancelAsync_AlreadyResolvedRequest_LeavesItUntouched( + IOrganizationRepository organizationRepository, + ICollectionRepository collectionRepository, + IAccessRequestRepository accessRequestRepository) + { + var organization = await organizationRepository.CreateTestOrganizationAsync(); + var collection = await collectionRepository.CreateTestCollectionAsync(organization); + var now = DateTime.UtcNow; + + // The proc only acts on Pending rows, so a request that already left Pending (e.g. approved) is never + // clobbered into Cancelled by a stray/raced cancel. + var approved = await accessRequestRepository.CreateAsync(BuildRequest( + organization.Id, collection.Id, Guid.NewGuid(), AccessRequestStatus.Approved, now)); + + await accessRequestRepository.CancelAsync(approved.Id, now.AddMinutes(5)); + + var persisted = await accessRequestRepository.GetByIdAsync(approved.Id); + Assert.Equal(AccessRequestStatus.Approved, persisted!.Status); + } + private static AccessRequest BuildRequest( Guid organizationId, Guid collectionId, Guid requesterId, AccessRequestStatus status, DateTime creationDate) => new() diff --git a/util/Migrator/DbScripts/2026-06-11_03_AddAccessRequestCancel.sql b/util/Migrator/DbScripts/2026-06-11_03_AddAccessRequestCancel.sql new file mode 100644 index 000000000000..3c58e008d989 --- /dev/null +++ b/util/Migrator/DbScripts/2026-06-11_03_AddAccessRequestCancel.sql @@ -0,0 +1,22 @@ +-- PAM Credential Leasing: the requester can withdraw their own still-pending access request. The client already +-- issues DELETE /leasing/requests/{id} for this, but the server had no handler — this adds the missing transition. +-- +-- [AccessRequest_Cancel] flips a Pending request to Cancelled (status 3) and stamps its ResolvedDate. No AccessDecision +-- row is written: a cancellation is the requester acting on their own request, not an approver verdict. The WHERE guard +-- keeps the write idempotent under a race so an already-resolved request is left untouched. +-- +-- Feature is behind the pm-37044-pam-v-0 flag (unshipped POC); server + migration deploy together. + +CREATE OR ALTER PROCEDURE [dbo].[AccessRequest_Cancel] + @AccessRequestId UNIQUEIDENTIFIER, + @Now DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + UPDATE [dbo].[AccessRequest] + SET [Status] = 3, -- Cancelled + [ResolvedDate] = @Now + WHERE [Id] = @AccessRequestId AND [Status] = 0 -- Pending +END +GO From e3282e0bf2c01ccb2d0f699582e03f786cc09dab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anders=20=C3=85berg?= Date: Fri, 12 Jun 2026 09:42:51 +0200 Subject: [PATCH 23/54] Persist AccessRule Enabled flag through create/update (MSSQL/Dapper) --- .../Models/Request/AccessRuleRequestModel.cs | 7 ++ .../Response/AccessRuleResponseModel.cs | 2 + src/Core/Pam/Entities/AccessRule.cs | 6 ++ src/Core/Pam/Models/AccessRuleDetails.cs | 1 + .../Commands/UpdateAccessRuleCommand.cs | 1 + .../Stored Procedures/AccessRule_Create.sql | 3 + .../Stored Procedures/AccessRule_Update.sql | 2 + src/Sql/dbo/Pam/Tables/AccessRule.sql | 1 + .../2026-06-12_00_AddAccessRuleEnabled.sql | 98 +++++++++++++++++++ 9 files changed, 121 insertions(+) create mode 100644 util/Migrator/DbScripts/2026-06-12_00_AddAccessRuleEnabled.sql diff --git a/src/Api/Pam/Models/Request/AccessRuleRequestModel.cs b/src/Api/Pam/Models/Request/AccessRuleRequestModel.cs index 5913e45ee9bb..01d8e5d886d6 100644 --- a/src/Api/Pam/Models/Request/AccessRuleRequestModel.cs +++ b/src/Api/Pam/Models/Request/AccessRuleRequestModel.cs @@ -32,6 +32,12 @@ public class AccessRuleRequestModel /// public int? MaxLeaseDurationSeconds { get; set; } + /// + /// When false, the rule is inactive and does not gate access for the collections it governs. Defaults to + /// true so a request that omits the field creates an active rule. + /// + public bool Enabled { get; set; } = true; + /// /// The complete set of collections this rule governs. The rule's associations are replaced to match /// exactly this set; an empty array clears all associations. @@ -48,6 +54,7 @@ public class AccessRuleRequestModel SingleActiveLease = SingleActiveLease, DefaultLeaseDurationSeconds = DefaultLeaseDurationSeconds, MaxLeaseDurationSeconds = MaxLeaseDurationSeconds, + Enabled = Enabled, }; private static string SerializeConditions(object conditions) => conditions switch diff --git a/src/Api/Pam/Models/Response/AccessRuleResponseModel.cs b/src/Api/Pam/Models/Response/AccessRuleResponseModel.cs index c9c8b28e5a5b..ae5a16167f36 100644 --- a/src/Api/Pam/Models/Response/AccessRuleResponseModel.cs +++ b/src/Api/Pam/Models/Response/AccessRuleResponseModel.cs @@ -19,6 +19,7 @@ public AccessRuleResponseModel(AccessRuleDetails rule) SingleActiveLease = rule.SingleActiveLease; DefaultLeaseDurationSeconds = rule.DefaultLeaseDurationSeconds; MaxLeaseDurationSeconds = rule.MaxLeaseDurationSeconds; + Enabled = rule.Enabled; CreationDate = rule.CreationDate; RevisionDate = rule.RevisionDate; Collections = rule.CollectionIds.ToList(); @@ -32,6 +33,7 @@ public AccessRuleResponseModel(AccessRuleDetails rule) public bool SingleActiveLease { get; } public int? DefaultLeaseDurationSeconds { get; } public int? MaxLeaseDurationSeconds { get; } + public bool Enabled { get; } public DateTime CreationDate { get; } public DateTime RevisionDate { get; } public IEnumerable Collections { get; } diff --git a/src/Core/Pam/Entities/AccessRule.cs b/src/Core/Pam/Entities/AccessRule.cs index 6282f7dd1c03..71ae65692d71 100644 --- a/src/Core/Pam/Entities/AccessRule.cs +++ b/src/Core/Pam/Entities/AccessRule.cs @@ -44,6 +44,12 @@ public class AccessRule : ITableObject /// public int? MaxLeaseDurationSeconds { get; set; } + /// + /// When false, the rule is inactive: it does not gate access for the collections it governs. New rules + /// default to enabled. + /// + public bool Enabled { get; set; } = true; + public DateTime CreationDate { get; set; } = DateTime.UtcNow; public DateTime RevisionDate { get; set; } = DateTime.UtcNow; diff --git a/src/Core/Pam/Models/AccessRuleDetails.cs b/src/Core/Pam/Models/AccessRuleDetails.cs index 0891e8a6a535..7608cd58d156 100644 --- a/src/Core/Pam/Models/AccessRuleDetails.cs +++ b/src/Core/Pam/Models/AccessRuleDetails.cs @@ -19,6 +19,7 @@ public class AccessRuleDetails : AccessRule SingleActiveLease = rule.SingleActiveLease, DefaultLeaseDurationSeconds = rule.DefaultLeaseDurationSeconds, MaxLeaseDurationSeconds = rule.MaxLeaseDurationSeconds, + Enabled = rule.Enabled, CreationDate = rule.CreationDate, RevisionDate = rule.RevisionDate, CollectionIds = collectionIds, diff --git a/src/Core/Pam/OrganizationFeatures/Commands/UpdateAccessRuleCommand.cs b/src/Core/Pam/OrganizationFeatures/Commands/UpdateAccessRuleCommand.cs index cfeba97634e4..3e8c697f1983 100644 --- a/src/Core/Pam/OrganizationFeatures/Commands/UpdateAccessRuleCommand.cs +++ b/src/Core/Pam/OrganizationFeatures/Commands/UpdateAccessRuleCommand.cs @@ -67,6 +67,7 @@ public async Task UpdateAsync(Guid organizationId, Guid id, A SingleActiveLease = update.SingleActiveLease, DefaultLeaseDurationSeconds = update.DefaultLeaseDurationSeconds, MaxLeaseDurationSeconds = update.MaxLeaseDurationSeconds, + Enabled = update.Enabled, CreationDate = existing.CreationDate, RevisionDate = _timeProvider.GetUtcNow().UtcDateTime, }; diff --git a/src/Sql/dbo/Pam/Stored Procedures/AccessRule_Create.sql b/src/Sql/dbo/Pam/Stored Procedures/AccessRule_Create.sql index dbc6baf754ac..cc45912124ef 100644 --- a/src/Sql/dbo/Pam/Stored Procedures/AccessRule_Create.sql +++ b/src/Sql/dbo/Pam/Stored Procedures/AccessRule_Create.sql @@ -7,6 +7,7 @@ CREATE PROCEDURE [dbo].[AccessRule_Create] @SingleActiveLease BIT = 0, @DefaultLeaseDurationSeconds INT = NULL, @MaxLeaseDurationSeconds INT = NULL, + @Enabled BIT = 1, @CreationDate DATETIME2(7), @RevisionDate DATETIME2(7) AS @@ -23,6 +24,7 @@ BEGIN [SingleActiveLease], [DefaultLeaseDurationSeconds], [MaxLeaseDurationSeconds], + [Enabled], [CreationDate], [RevisionDate] ) @@ -36,6 +38,7 @@ BEGIN @SingleActiveLease, @DefaultLeaseDurationSeconds, @MaxLeaseDurationSeconds, + @Enabled, @CreationDate, @RevisionDate ) diff --git a/src/Sql/dbo/Pam/Stored Procedures/AccessRule_Update.sql b/src/Sql/dbo/Pam/Stored Procedures/AccessRule_Update.sql index e6d8107ceb47..8b117c966bbd 100644 --- a/src/Sql/dbo/Pam/Stored Procedures/AccessRule_Update.sql +++ b/src/Sql/dbo/Pam/Stored Procedures/AccessRule_Update.sql @@ -7,6 +7,7 @@ CREATE PROCEDURE [dbo].[AccessRule_Update] @SingleActiveLease BIT = 0, @DefaultLeaseDurationSeconds INT = NULL, @MaxLeaseDurationSeconds INT = NULL, + @Enabled BIT = 1, @CreationDate DATETIME2(7), @RevisionDate DATETIME2(7) AS @@ -23,6 +24,7 @@ BEGIN [SingleActiveLease] = @SingleActiveLease, [DefaultLeaseDurationSeconds] = @DefaultLeaseDurationSeconds, [MaxLeaseDurationSeconds] = @MaxLeaseDurationSeconds, + [Enabled] = @Enabled, [CreationDate] = @CreationDate, [RevisionDate] = @RevisionDate WHERE diff --git a/src/Sql/dbo/Pam/Tables/AccessRule.sql b/src/Sql/dbo/Pam/Tables/AccessRule.sql index ca9fcaf5b5b0..ee7ec960de89 100644 --- a/src/Sql/dbo/Pam/Tables/AccessRule.sql +++ b/src/Sql/dbo/Pam/Tables/AccessRule.sql @@ -7,6 +7,7 @@ CREATE TABLE [dbo].[AccessRule] ( [SingleActiveLease] BIT NOT NULL CONSTRAINT [DF_AccessRule_SingleActiveLease] DEFAULT (0), [DefaultLeaseDurationSeconds] INT NULL, [MaxLeaseDurationSeconds] INT NULL, + [Enabled] BIT NOT NULL CONSTRAINT [DF_AccessRule_Enabled] DEFAULT (1), [CreationDate] DATETIME2(7) NOT NULL, [RevisionDate] DATETIME2(7) NOT NULL, CONSTRAINT [PK_AccessRule] PRIMARY KEY CLUSTERED ([Id] ASC), diff --git a/util/Migrator/DbScripts/2026-06-12_00_AddAccessRuleEnabled.sql b/util/Migrator/DbScripts/2026-06-12_00_AddAccessRuleEnabled.sql new file mode 100644 index 000000000000..f27c0b564de4 --- /dev/null +++ b/util/Migrator/DbScripts/2026-06-12_00_AddAccessRuleEnabled.sql @@ -0,0 +1,98 @@ +-- PAM Credential Leasing: per-rule enabled flag. +-- +-- AccessRule gains [Enabled] (BIT NOT NULL, default 1). When false the rule is inactive and does not gate +-- access for the collections it governs. The client already sends and reads the flag (and toggles it from the +-- access-rules list), but the server had nowhere to persist it, so it was dropped on every save and every read +-- defaulted it back to enabled. AccessRule_Create/_Update gain a matching @Enabled parameter (defaulting to 1 +-- so the procs stay backward compatible). The Read procs use SELECT * and pick the new column up automatically. +-- +-- PAM is an unshipped POC behind the pm-37044-pam-v-0 flag with no production data; server + migration deploy +-- together, so the affected procs are altered in place rather than versioned. + +IF COL_LENGTH('[dbo].[AccessRule]', 'Enabled') IS NULL +BEGIN + ALTER TABLE [dbo].[AccessRule] + ADD [Enabled] BIT NOT NULL CONSTRAINT [DF_AccessRule_Enabled] DEFAULT (1) +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[AccessRule_Create] + @Id UNIQUEIDENTIFIER OUTPUT, + @OrganizationId UNIQUEIDENTIFIER, + @Name NVARCHAR(256), + @Description NVARCHAR(MAX) = NULL, + @Conditions NVARCHAR(MAX), + @SingleActiveLease BIT = 0, + @DefaultLeaseDurationSeconds INT = NULL, + @MaxLeaseDurationSeconds INT = NULL, + @Enabled BIT = 1, + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + INSERT INTO [dbo].[AccessRule] + ( + [Id], + [OrganizationId], + [Name], + [Description], + [Conditions], + [SingleActiveLease], + [DefaultLeaseDurationSeconds], + [MaxLeaseDurationSeconds], + [Enabled], + [CreationDate], + [RevisionDate] + ) + VALUES + ( + @Id, + @OrganizationId, + @Name, + @Description, + @Conditions, + @SingleActiveLease, + @DefaultLeaseDurationSeconds, + @MaxLeaseDurationSeconds, + @Enabled, + @CreationDate, + @RevisionDate + ) +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[AccessRule_Update] + @Id UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @Name NVARCHAR(256), + @Description NVARCHAR(MAX) = NULL, + @Conditions NVARCHAR(MAX), + @SingleActiveLease BIT = 0, + @DefaultLeaseDurationSeconds INT = NULL, + @MaxLeaseDurationSeconds INT = NULL, + @Enabled BIT = 1, + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + UPDATE + [dbo].[AccessRule] + SET + [OrganizationId] = @OrganizationId, + [Name] = @Name, + [Description] = @Description, + [Conditions] = @Conditions, + [SingleActiveLease] = @SingleActiveLease, + [DefaultLeaseDurationSeconds] = @DefaultLeaseDurationSeconds, + [MaxLeaseDurationSeconds] = @MaxLeaseDurationSeconds, + [Enabled] = @Enabled, + [CreationDate] = @CreationDate, + [RevisionDate] = @RevisionDate + WHERE + [Id] = @Id +END +GO From 1bc89de012ea7e87d2f449eb31ca8b1adcb92d1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anders=20=C3=85berg?= Date: Mon, 15 Jun 2026 10:02:36 +0200 Subject: [PATCH 24/54] Serialize PAM API response timestamps as UTC --- .../Response/AccessLeaseResponseModel.cs | 6 +-- .../AccessRequestDetailsResponseModel.cs | 8 +-- .../Response/AccessRequestResponseModel.cs | 6 +-- .../Response/AccessRuleResponseModel.cs | 4 +- .../Models/Response/PamDateTimeExtensions.cs | 23 ++++++++ .../Models/AccessLeaseResponseModelTests.cs | 39 ++++++++++++++ .../AccessRequestDetailsResponseModelTests.cs | 53 +++++++++++++++++++ 7 files changed, 127 insertions(+), 12 deletions(-) create mode 100644 src/Api/Pam/Models/Response/PamDateTimeExtensions.cs create mode 100644 test/Api.Test/Pam/Models/AccessRequestDetailsResponseModelTests.cs diff --git a/src/Api/Pam/Models/Response/AccessLeaseResponseModel.cs b/src/Api/Pam/Models/Response/AccessLeaseResponseModel.cs index 8a619d245ab9..491a0132f1c7 100644 --- a/src/Api/Pam/Models/Response/AccessLeaseResponseModel.cs +++ b/src/Api/Pam/Models/Response/AccessLeaseResponseModel.cs @@ -24,9 +24,9 @@ public AccessLeaseResponseModel(AccessLease lease) OrganizationId = lease.OrganizationId; RequesterId = lease.RequesterId; Status = AccessLeaseStatusNames.From(lease.Status); - NotBefore = lease.NotBefore; - NotAfter = lease.NotAfter; - RevokedAt = lease.RevokedDate; + NotBefore = lease.NotBefore.AsUtc(); + NotAfter = lease.NotAfter.AsUtc(); + RevokedAt = lease.RevokedDate.AsUtc(); RevokedByUserId = lease.RevokedBy; } diff --git a/src/Api/Pam/Models/Response/AccessRequestDetailsResponseModel.cs b/src/Api/Pam/Models/Response/AccessRequestDetailsResponseModel.cs index a2b58a7992aa..fbd4bd677b14 100644 --- a/src/Api/Pam/Models/Response/AccessRequestDetailsResponseModel.cs +++ b/src/Api/Pam/Models/Response/AccessRequestDetailsResponseModel.cs @@ -21,12 +21,12 @@ public AccessRequestDetailsResponseModel(AccessRequestDetails details) OrganizationId = details.OrganizationId; RequesterId = details.RequesterId; Status = AccessRequestStatusNames.From(details.Status, details.ProducedLeaseId.HasValue); - RequestedNotBefore = details.NotBefore; - RequestedNotAfter = details.NotAfter; + RequestedNotBefore = details.NotBefore.AsUtc(); + RequestedNotAfter = details.NotAfter.AsUtc(); RequestedTtlSeconds = (int)(details.NotAfter - details.NotBefore).TotalSeconds; Reason = details.Reason; - SubmittedAt = details.CreationDate; - ResolvedAt = details.ResolvedDate; + SubmittedAt = details.CreationDate.AsUtc(); + ResolvedAt = details.ResolvedDate.AsUtc(); ApproverId = details.ApproverId; ApproverComment = details.ApproverComment; ProducedLeaseId = details.ProducedLeaseId; diff --git a/src/Api/Pam/Models/Response/AccessRequestResponseModel.cs b/src/Api/Pam/Models/Response/AccessRequestResponseModel.cs index ca7f7cca298c..41bbd002e532 100644 --- a/src/Api/Pam/Models/Response/AccessRequestResponseModel.cs +++ b/src/Api/Pam/Models/Response/AccessRequestResponseModel.cs @@ -16,10 +16,10 @@ public AccessRequestResponseModel(AccessRequest request) CollectionId = request.CollectionId; OrganizationId = request.OrganizationId; Status = AccessRequestStatusNames.From(request.Status, hasLease: false); - NotBefore = request.NotBefore; - NotAfter = request.NotAfter; + NotBefore = request.NotBefore.AsUtc(); + NotAfter = request.NotAfter.AsUtc(); Reason = request.Reason; - CreationDate = request.CreationDate; + CreationDate = request.CreationDate.AsUtc(); } public Guid Id { get; } diff --git a/src/Api/Pam/Models/Response/AccessRuleResponseModel.cs b/src/Api/Pam/Models/Response/AccessRuleResponseModel.cs index ae5a16167f36..880fc3803a35 100644 --- a/src/Api/Pam/Models/Response/AccessRuleResponseModel.cs +++ b/src/Api/Pam/Models/Response/AccessRuleResponseModel.cs @@ -20,8 +20,8 @@ public AccessRuleResponseModel(AccessRuleDetails rule) DefaultLeaseDurationSeconds = rule.DefaultLeaseDurationSeconds; MaxLeaseDurationSeconds = rule.MaxLeaseDurationSeconds; Enabled = rule.Enabled; - CreationDate = rule.CreationDate; - RevisionDate = rule.RevisionDate; + CreationDate = rule.CreationDate.AsUtc(); + RevisionDate = rule.RevisionDate.AsUtc(); Collections = rule.CollectionIds.ToList(); } diff --git a/src/Api/Pam/Models/Response/PamDateTimeExtensions.cs b/src/Api/Pam/Models/Response/PamDateTimeExtensions.cs new file mode 100644 index 000000000000..6d44aefbb455 --- /dev/null +++ b/src/Api/Pam/Models/Response/PamDateTimeExtensions.cs @@ -0,0 +1,23 @@ +namespace Bit.Api.Pam.Models.Response; + +/// +/// Marks PAM response timestamps as UTC for serialization. +/// +/// PAM entities and read models are materialised by Dapper, which leaves their as +/// . System.Text.Json then writes an unspecified-kind value with no timezone +/// designator (e.g. "2026-06-15T13:00:00"), which a JavaScript client parses as local time. For any +/// client east/west of UTC the instant shifts — and in the approver inbox that shift drops still-valid requests whose +/// requested window only appears to have lapsed. +/// +/// The stored values are already UTC instants (the commands stamp them from UtcNow), so we relabel the kind +/// with . We deliberately do not use ToUniversalTime(), which treats an +/// unspecified value as local and would shift the clock. This mirrors the convention in CipherRepository, which +/// specifies UTC on the dates it returns. +/// +internal static class PamDateTimeExtensions +{ + public static DateTime AsUtc(this DateTime value) => DateTime.SpecifyKind(value, DateTimeKind.Utc); + + public static DateTime? AsUtc(this DateTime? value) => + value.HasValue ? DateTime.SpecifyKind(value.Value, DateTimeKind.Utc) : null; +} diff --git a/test/Api.Test/Pam/Models/AccessLeaseResponseModelTests.cs b/test/Api.Test/Pam/Models/AccessLeaseResponseModelTests.cs index b3837419bb21..df5528b966fa 100644 --- a/test/Api.Test/Pam/Models/AccessLeaseResponseModelTests.cs +++ b/test/Api.Test/Pam/Models/AccessLeaseResponseModelTests.cs @@ -30,4 +30,43 @@ public void Ctor_MapsLeaseToClientShape(AccessLease lease) Assert.Null(model.RuleId); Assert.Null(model.RevocationReason); } + + [Fact] + public void Ctor_MarksTimestampsAsUtc() + { + // Dapper materialises stored UTC instants with Kind=Unspecified; the model must relabel them UTC so the + // serialised JSON carries a 'Z' and clients don't reparse them as local time. + var unspecified = new DateTime(2026, 6, 15, 13, 0, 0, DateTimeKind.Unspecified); + var lease = new AccessLease + { + Status = AccessLeaseStatus.Active, + NotBefore = unspecified, + NotAfter = unspecified.AddHours(1), + RevokedDate = unspecified.AddMinutes(30), + }; + + var model = new AccessLeaseResponseModel(lease); + + Assert.Equal(DateTimeKind.Utc, model.NotBefore.Kind); + Assert.Equal(DateTimeKind.Utc, model.NotAfter.Kind); + Assert.Equal(DateTimeKind.Utc, model.RevokedAt!.Value.Kind); + // SpecifyKind relabels without shifting the wall clock. + Assert.Equal(unspecified.Ticks, model.NotBefore.Ticks); + } + + [Fact] + public void Ctor_LeavesNullRevokedDateNull() + { + var lease = new AccessLease + { + Status = AccessLeaseStatus.Active, + NotBefore = new DateTime(2026, 6, 15, 13, 0, 0, DateTimeKind.Unspecified), + NotAfter = new DateTime(2026, 6, 15, 14, 0, 0, DateTimeKind.Unspecified), + RevokedDate = null, + }; + + var model = new AccessLeaseResponseModel(lease); + + Assert.Null(model.RevokedAt); + } } diff --git a/test/Api.Test/Pam/Models/AccessRequestDetailsResponseModelTests.cs b/test/Api.Test/Pam/Models/AccessRequestDetailsResponseModelTests.cs new file mode 100644 index 000000000000..68a140e770de --- /dev/null +++ b/test/Api.Test/Pam/Models/AccessRequestDetailsResponseModelTests.cs @@ -0,0 +1,53 @@ +using Bit.Api.Pam.Models.Response; +using Bit.Core.Pam.Enums; +using Bit.Core.Pam.Models; +using Xunit; + +namespace Bit.Api.Test.Pam.Models; + +public class AccessRequestDetailsResponseModelTests +{ + [Fact] + public void Ctor_MarksTimestampsAsUtc() + { + // Regression guard: the approver inbox drops a request whose requested window has lapsed. When the stored + // UTC instants are serialised without a 'Z' (Kind=Unspecified), a client east of UTC reparses them as local + // time and the shift hides still-valid requests. The model must relabel the kind as UTC. + var unspecified = new DateTime(2026, 6, 15, 13, 0, 0, DateTimeKind.Unspecified); + var details = new AccessRequestDetails + { + Status = AccessRequestStatus.Pending, + NotBefore = unspecified, + NotAfter = unspecified.AddHours(1), + CreationDate = unspecified.AddMinutes(-5), + ResolvedDate = unspecified.AddMinutes(10), + }; + + var model = new AccessRequestDetailsResponseModel(details); + + Assert.Equal(DateTimeKind.Utc, model.RequestedNotBefore.Kind); + Assert.Equal(DateTimeKind.Utc, model.RequestedNotAfter.Kind); + Assert.Equal(DateTimeKind.Utc, model.SubmittedAt.Kind); + Assert.Equal(DateTimeKind.Utc, model.ResolvedAt!.Value.Kind); + // SpecifyKind relabels without shifting the wall clock. + Assert.Equal(unspecified.Ticks, model.RequestedNotBefore.Ticks); + } + + [Fact] + public void Ctor_LeavesNullResolvedDateNull() + { + var unspecified = new DateTime(2026, 6, 15, 13, 0, 0, DateTimeKind.Unspecified); + var details = new AccessRequestDetails + { + Status = AccessRequestStatus.Pending, + NotBefore = unspecified, + NotAfter = unspecified.AddHours(1), + CreationDate = unspecified, + ResolvedDate = null, + }; + + var model = new AccessRequestDetailsResponseModel(details); + + Assert.Null(model.ResolvedAt); + } +} From 94c7b5dc74ab2f11513b14128866a94c28cf2846 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anders=20=C3=85berg?= Date: Mon, 15 Jun 2026 10:02:36 +0200 Subject: [PATCH 25/54] PAM: allow requester or approver to cancel non-activated access requests --- .../Controllers/MemberLeasingController.cs | 6 +- .../Commands/CancelAccessRequestCommand.cs | 67 ++++++++++++--- .../Repositories/IAccessRequestRepository.cs | 21 +++-- .../Repositories/AccessRequestRepository.cs | 17 ++++ .../AccessRequest_Cancel.sql | 14 +-- .../AccessRequest_CancelWithDecision.sql | 41 +++++++++ .../CancelAccessRequestCommandTests.cs | 85 +++++++++++++++++-- ...026-06-15_00_PamCancelApprovedRequests.sql | 66 ++++++++++++++ 8 files changed, 286 insertions(+), 31 deletions(-) create mode 100644 src/Sql/dbo/Pam/Stored Procedures/AccessRequest_CancelWithDecision.sql create mode 100644 util/Migrator/DbScripts/2026-06-15_00_PamCancelApprovedRequests.sql diff --git a/src/Api/Pam/Controllers/MemberLeasingController.cs b/src/Api/Pam/Controllers/MemberLeasingController.cs index 8710920ad7d4..6d7e40758978 100644 --- a/src/Api/Pam/Controllers/MemberLeasingController.cs +++ b/src/Api/Pam/Controllers/MemberLeasingController.cs @@ -65,8 +65,10 @@ public async Task Activate(Guid id) } /// - /// Withdraws the caller's own pending access request. Only the requester may cancel, and only while the request is - /// still pending; a resolved request can no longer be withdrawn. + /// Cancels an access request that has not produced a lease. The requester may cancel their own request, and a + /// managing approver may cancel any request on a collection they manage; either way the request must still be + /// pending or an unactivated approved request. A request that has produced a lease (revoke the lease + /// instead) or is otherwise resolved can no longer be cancelled. /// [HttpDelete("requests/{id:guid}")] public async Task CancelRequest(Guid id) diff --git a/src/Core/Pam/OrganizationFeatures/Commands/CancelAccessRequestCommand.cs b/src/Core/Pam/OrganizationFeatures/Commands/CancelAccessRequestCommand.cs index dec6dd968fc6..5e4da33f17b6 100644 --- a/src/Core/Pam/OrganizationFeatures/Commands/CancelAccessRequestCommand.cs +++ b/src/Core/Pam/OrganizationFeatures/Commands/CancelAccessRequestCommand.cs @@ -1,4 +1,5 @@ using Bit.Core.Exceptions; +using Bit.Core.Pam.Entities; using Bit.Core.Pam.Enums; using Bit.Core.Pam.OrganizationFeatures.Commands.Interfaces; using Bit.Core.Pam.Repositories; @@ -9,15 +10,21 @@ namespace Bit.Core.Pam.OrganizationFeatures.Commands; public class CancelAccessRequestCommand : ICancelAccessRequestCommand { private readonly IAccessRequestRepository _accessRequestRepository; + private readonly IAccessLeaseRepository _accessLeaseRepository; + private readonly IApproverCollectionAccessQuery _approverCollectionAccessQuery; private readonly IApproverInboxNotifier _approverInboxNotifier; private readonly TimeProvider _timeProvider; public CancelAccessRequestCommand( IAccessRequestRepository accessRequestRepository, + IAccessLeaseRepository accessLeaseRepository, + IApproverCollectionAccessQuery approverCollectionAccessQuery, IApproverInboxNotifier approverInboxNotifier, TimeProvider timeProvider) { _accessRequestRepository = accessRequestRepository; + _accessLeaseRepository = accessLeaseRepository; + _approverCollectionAccessQuery = approverCollectionAccessQuery; _approverInboxNotifier = approverInboxNotifier; _timeProvider = timeProvider; } @@ -26,26 +33,66 @@ public async Task CancelAsync(Guid userId, Guid requestId) { var request = await _accessRequestRepository.GetByIdAsync(requestId); - // 404 for both missing and someone else's request, so the caller can't probe for requests they don't own. - // Mirrors ActivateAccessRequestCommand. - if (request is null || request.RequesterId != userId) + // 404 when the request is missing or the caller is neither its requester nor a managing approver, so the + // caller can't probe for requests they have no business seeing. Mirrors the inbox/decide surfaces. + if (request is null) { throw new NotFoundException(); } - // Only a still-pending request can be withdrawn. A resolved request (approved/denied/cancelled/expired) is - // terminal; surfaced as a conflict rather than a silent success so the client refreshes its view. The - // repository write is additionally guarded on Status = Pending to stay race-safe. - if (request.Status != AccessRequestStatus.Pending) + var isRequester = request.RequesterId == userId; + var isManager = !isRequester + && await _approverCollectionAccessQuery.CanManageCollectionAsync(userId, request.CollectionId); + if (!isRequester && !isManager) + { + throw new NotFoundException(); + } + + // Only a request that has not produced a lease can be cancelled: still Pending, or Approved that the requester + // has not yet activated. Anything else (denied/cancelled/expired) is terminal; surfaced as a conflict so the + // client refreshes. The stored procs additionally guard the transition to stay race-safe. + if (request.Status is not (AccessRequestStatus.Pending or AccessRequestStatus.Approved)) { throw new ConflictException("This request has already been resolved."); } + // An approved request that has minted a lease is governed by that lease, not the request: end it via lease + // revoke while active, and once the lease has ended the request is terminal history. + var lease = await _accessLeaseRepository.GetByAccessRequestIdAsync(requestId); + if (lease is not null) + { + throw lease.Status == AccessLeaseStatus.Active + ? new ConflictException("This request has an active lease; revoke the lease instead.") + : new ConflictException("This request has already been resolved."); + } + var now = _timeProvider.GetUtcNow().UtcDateTime; - await _accessRequestRepository.CancelAsync(request.Id, now); - // The request just left the pending queue; tell every approver of this collection to re-fetch so the - // withdrawn request drops out of their inbox. Mirrors decide. + if (isRequester) + { + // The requester withdraws their own request: Cancelled, no decision recorded. A user who is both the + // requester and a manager takes this branch when cancelling their own request. + await _accessRequestRepository.CancelAsync(request.Id, now); + } + else + { + // A managing approver retracts the request: Denied, recorded as a human Deny decision so the audit trail + // names the approver — mirrors RevokeAccessLeaseCommand. + var decision = new AccessDecision + { + AccessRequestId = request.Id, + DeciderKind = AccessDeciderKind.Human, + ApproverId = userId, + Verdict = AccessDecisionVerdict.Deny, + Comment = null, + CreationDate = now, + }; + decision.SetNewId(); + await _accessRequestRepository.CancelWithDecisionAsync(request, decision, now); + } + + // The request just left the pending/approved set; tell every approver of this collection to re-fetch so it + // drops out of their inbox. Mirrors decide. await _approverInboxNotifier.NotifyCollectionApproversAsync(request.CollectionId); } } diff --git a/src/Core/Pam/Repositories/IAccessRequestRepository.cs b/src/Core/Pam/Repositories/IAccessRequestRepository.cs index a2f6a2ef8c10..8ea21956b5ee 100644 --- a/src/Core/Pam/Repositories/IAccessRequestRepository.cs +++ b/src/Core/Pam/Repositories/IAccessRequestRepository.cs @@ -58,11 +58,22 @@ public interface IAccessRequestRepository Task ResolveWithDecisionAsync(AccessRequest request, AccessDecision decision, AccessRequestStatus status, DateTime now); /// - /// Withdraws the requester's own pending request: transitions it to - /// and stamps as its resolved date. No is written — a - /// cancellation is the requester acting on their own request, not an approver verdict. The caller enforces - /// ownership and the pending precondition; the write is guarded so a request that has already left - /// is left untouched (race-safe / idempotent). + /// Withdraws a not-yet-activated request on the requester's behalf: transitions it to + /// (from or an + /// request the requester has not activated) and stamps + /// as its resolved date. No is written — a cancellation is the + /// requester acting on their own request, not an approver verdict. The write is guarded so a request that has + /// already left the cancellable set or produced a lease is left untouched (race-safe / idempotent). /// Task CancelAsync(Guid id, DateTime now); + + /// + /// Retracts a not-yet-activated request on a managing approver's behalf: transitions it to + /// (from or an unactivated + /// request), stamps the resolved date, and records the approver's human + /// Deny so the audit trail names them. The write is guarded so a request that has + /// already left the cancellable set or produced a lease is left untouched (race-safe); the decision is recorded + /// only when the transition happens. Both supplied entities must already have their ids assigned. + /// + Task CancelWithDecisionAsync(AccessRequest request, AccessDecision decision, DateTime now); } diff --git a/src/Infrastructure.Dapper/Pam/Repositories/AccessRequestRepository.cs b/src/Infrastructure.Dapper/Pam/Repositories/AccessRequestRepository.cs index e14184ca14ef..688340081a9f 100644 --- a/src/Infrastructure.Dapper/Pam/Repositories/AccessRequestRepository.cs +++ b/src/Infrastructure.Dapper/Pam/Repositories/AccessRequestRepository.cs @@ -137,4 +137,21 @@ await connection.ExecuteAsync( new { AccessRequestId = id, Now = now }, commandType: CommandType.StoredProcedure); } + + public async Task CancelWithDecisionAsync(AccessRequest request, AccessDecision decision, DateTime now) + { + await using var connection = new SqlConnection(ConnectionString); + await connection.ExecuteAsync( + $"[{Schema}].[AccessRequest_CancelWithDecision]", + new + { + AccessRequestId = request.Id, + AccessDecisionId = decision.Id, + ApproverId = decision.ApproverId, + Verdict = decision.Verdict, + decision.Comment, + Now = now, + }, + commandType: CommandType.StoredProcedure); + } } diff --git a/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_Cancel.sql b/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_Cancel.sql index 9791e618797d..26f65fa8c95a 100644 --- a/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_Cancel.sql +++ b/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_Cancel.sql @@ -5,13 +5,15 @@ AS BEGIN SET NOCOUNT ON - -- The requester withdraws their own still-pending request. Unlike [AccessRequest_ResolveWithDecision], no - -- AccessDecision is written: a cancellation is the requester acting on their own request, not an approver verdict. - -- The caller (CancelAccessRequestCommand) has already verified ownership and that the request is Pending; the - -- WHERE guard keeps the write idempotent under a race (double-click, or a concurrent auto/human resolution) so a - -- request that has already left Pending is left untouched. + -- The requester withdraws their own not-yet-activated request (Pending, or an Approved request they have not + -- activated). Unlike [AccessRequest_CancelWithDecision], no AccessDecision is written: a cancellation is the + -- requester acting on their own request, not an approver verdict. The WHERE guard keeps the write idempotent under + -- a race and refuses a request that has already produced a lease (that access is governed by the lease, which must + -- be revoked instead). UPDATE [dbo].[AccessRequest] SET [Status] = 3, -- Cancelled [ResolvedDate] = @Now - WHERE [Id] = @AccessRequestId AND [Status] = 0 -- Pending + WHERE [Id] = @AccessRequestId + AND [Status] IN (0, 1) -- Pending or Approved + AND NOT EXISTS (SELECT 1 FROM [dbo].[AccessLease] L WHERE L.[AccessRequestId] = @AccessRequestId) END diff --git a/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_CancelWithDecision.sql b/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_CancelWithDecision.sql new file mode 100644 index 000000000000..7560a8ccc398 --- /dev/null +++ b/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_CancelWithDecision.sql @@ -0,0 +1,41 @@ +CREATE PROCEDURE [dbo].[AccessRequest_CancelWithDecision] + @AccessRequestId UNIQUEIDENTIFIER, + @AccessDecisionId UNIQUEIDENTIFIER, + @ApproverId UNIQUEIDENTIFIER, + @Verdict TINYINT, + @Comment NVARCHAR(MAX) = NULL, + @Now DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + -- A managing approver retracts a not-yet-activated request (Pending, or an Approved request the requester has not + -- activated): transition it to Denied and record the approver's human decision, mirroring + -- [AccessRequest_ResolveWithDecision] but over the broader cancellable set. The WHERE guard is race-safe and + -- refuses a request that has produced a lease (governed by the lease — revoke instead). The decision is inserted + -- only when the transition actually happened (@@ROWCOUNT > 0), so a no-op never orphans an AccessDecision. + BEGIN TRANSACTION AccessRequest_CancelWithDecision + + UPDATE [dbo].[AccessRequest] + SET [Status] = 2, -- Denied + [ResolvedDate] = @Now + WHERE [Id] = @AccessRequestId + AND [Status] IN (0, 1) -- Pending or Approved + AND NOT EXISTS (SELECT 1 FROM [dbo].[AccessLease] L WHERE L.[AccessRequestId] = @AccessRequestId) + + IF @@ROWCOUNT > 0 + BEGIN + INSERT INTO [dbo].[AccessDecision] + ( + [Id], [AccessRequestId], [DeciderKind], [ApproverId], [ConditionKind], + [Verdict], [Comment], [EvaluationContext], [CreationDate] + ) + VALUES + ( + @AccessDecisionId, @AccessRequestId, 1 /* Human */, @ApproverId, NULL, + @Verdict, @Comment, NULL, @Now + ) + END + + COMMIT TRANSACTION AccessRequest_CancelWithDecision +END diff --git a/test/Core.Test/Pam/Commands/CancelAccessRequestCommandTests.cs b/test/Core.Test/Pam/Commands/CancelAccessRequestCommandTests.cs index 2e90036d9931..7592b511fe9b 100644 --- a/test/Core.Test/Pam/Commands/CancelAccessRequestCommandTests.cs +++ b/test/Core.Test/Pam/Commands/CancelAccessRequestCommandTests.cs @@ -29,24 +29,26 @@ await sutProvider.GetDependency().DidNotReceiveWithAny } [Theory, BitAutoData] - public async Task CancelAsync_NotOwner_ThrowsNotFound(Guid userId, AccessRequest request) + public async Task CancelAsync_NeitherRequesterNorManager_ThrowsNotFound(Guid userId, AccessRequest request) { var sutProvider = Setup(); request.Status = AccessRequestStatus.Pending; sutProvider.GetDependency().GetByIdAsync(request.Id).Returns(request); + // userId is neither the requester nor a manager (CanManageCollectionAsync defaults to false). - // Someone else's request is indistinguishable from a missing one, so ids can't be probed. + // A request the caller can't act on is indistinguishable from a missing one, so ids can't be probed. await Assert.ThrowsAsync(() => sutProvider.Sut.CancelAsync(userId, request.Id)); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() .CancelAsync(default, default); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .CancelWithDecisionAsync(default!, default!, default); } [Theory] - [BitAutoData(AccessRequestStatus.Approved)] [BitAutoData(AccessRequestStatus.Denied)] [BitAutoData(AccessRequestStatus.Cancelled)] [BitAutoData(AccessRequestStatus.ExpiredUnanswered)] - public async Task CancelAsync_NotPending_ThrowsConflict(AccessRequestStatus status, AccessRequest request) + public async Task CancelAsync_TerminalStatus_ThrowsConflict(AccessRequestStatus status, AccessRequest request) { var sutProvider = Setup(); request.Status = status; @@ -60,21 +62,88 @@ await sutProvider.GetDependency().DidNotReceiveWithAnyAr .NotifyCollectionApproversAsync(default); } - [Theory, BitAutoData] - public async Task CancelAsync_Pending_CancelsAndNotifiesApprovers(AccessRequest request) + [Theory] + [BitAutoData(AccessRequestStatus.Pending)] + [BitAutoData(AccessRequestStatus.Approved)] + public async Task CancelAsync_RequesterNoLease_CancelsAndNotifies(AccessRequestStatus status, AccessRequest request) { var sutProvider = Setup(); - request.Status = AccessRequestStatus.Pending; + request.Status = status; sutProvider.GetDependency().GetByIdAsync(request.Id).Returns(request); + // No lease produced (GetByAccessRequestIdAsync defaults to null). await sutProvider.Sut.CancelAsync(request.RequesterId, request.Id); await sutProvider.GetDependency().Received(1).CancelAsync(request.Id, _now); - // The request just left the pending queue; approvers must re-fetch so it drops out of their inbox. + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .CancelWithDecisionAsync(default!, default!, default); await sutProvider.GetDependency().Received(1) .NotifyCollectionApproversAsync(request.CollectionId); } + [Theory] + [BitAutoData(AccessRequestStatus.Pending)] + [BitAutoData(AccessRequestStatus.Approved)] + public async Task CancelAsync_ManagerNoLease_DeniesWithDecisionAndNotifies( + AccessRequestStatus status, Guid managerId, AccessRequest request) + { + var sutProvider = Setup(); + request.Status = status; + sutProvider.GetDependency().GetByIdAsync(request.Id).Returns(request); + sutProvider.GetDependency() + .CanManageCollectionAsync(managerId, request.CollectionId).Returns(true); + + await sutProvider.Sut.CancelAsync(managerId, request.Id); + + await sutProvider.GetDependency().Received(1).CancelWithDecisionAsync( + request, + Arg.Is(d => + d.AccessRequestId == request.Id + && d.ApproverId == managerId + && d.Verdict == AccessDecisionVerdict.Deny + && d.DeciderKind == AccessDeciderKind.Human), + _now); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .CancelAsync(default, default); + await sutProvider.GetDependency().Received(1) + .NotifyCollectionApproversAsync(request.CollectionId); + } + + [Theory, BitAutoData] + public async Task CancelAsync_ApprovedWithActiveLease_ThrowsConflict(AccessRequest request, AccessLease lease) + { + var sutProvider = Setup(); + request.Status = AccessRequestStatus.Approved; + lease.Status = AccessLeaseStatus.Active; + sutProvider.GetDependency().GetByIdAsync(request.Id).Returns(request); + sutProvider.GetDependency().GetByAccessRequestIdAsync(request.Id).Returns(lease); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.CancelAsync(request.RequesterId, request.Id)); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .CancelAsync(default, default); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .CancelWithDecisionAsync(default!, default!, default); + } + + [Theory] + [BitAutoData(AccessLeaseStatus.Revoked)] + [BitAutoData(AccessLeaseStatus.Expired)] + public async Task CancelAsync_ApprovedWithEndedLease_ThrowsConflict( + AccessLeaseStatus leaseStatus, AccessRequest request, AccessLease lease) + { + var sutProvider = Setup(); + request.Status = AccessRequestStatus.Approved; + lease.Status = leaseStatus; + sutProvider.GetDependency().GetByIdAsync(request.Id).Returns(request); + sutProvider.GetDependency().GetByAccessRequestIdAsync(request.Id).Returns(lease); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.CancelAsync(request.RequesterId, request.Id)); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .CancelAsync(default, default); + } + private static SutProvider Setup() { var sutProvider = new SutProvider().WithFakeTimeProvider().Create(); diff --git a/util/Migrator/DbScripts/2026-06-15_00_PamCancelApprovedRequests.sql b/util/Migrator/DbScripts/2026-06-15_00_PamCancelApprovedRequests.sql new file mode 100644 index 000000000000..68a97c3a2d37 --- /dev/null +++ b/util/Migrator/DbScripts/2026-06-15_00_PamCancelApprovedRequests.sql @@ -0,0 +1,66 @@ +-- PAM Credential Leasing: broaden cancellation so a not-yet-activated access request can be ended by either the +-- requester or a managing approver. A request is cancellable while Pending or Approved-without-a-lease; once it has +-- produced a lease it is governed by that lease (revoke instead). +-- +-- [AccessRequest_Cancel] (requester path): flips a Pending/Approved request with no lease to Cancelled (status 3), +-- stamps ResolvedDate. No AccessDecision is written. +-- [AccessRequest_CancelWithDecision] (approver path): flips it to Denied (status 2) and records the approver's human +-- Deny decision, mirroring [AccessRequest_ResolveWithDecision]. The decision is inserted only when the transition +-- happens (@@ROWCOUNT > 0), so a no-op never orphans a decision. +-- Both procs guard on Status IN (0,1) AND no AccessLease for the request, keeping the write race-safe. +-- +-- Feature is behind the pm-37044-pam-v-0 flag (unshipped POC); server + migration deploy together. + +CREATE OR ALTER PROCEDURE [dbo].[AccessRequest_Cancel] + @AccessRequestId UNIQUEIDENTIFIER, + @Now DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + UPDATE [dbo].[AccessRequest] + SET [Status] = 3, -- Cancelled + [ResolvedDate] = @Now + WHERE [Id] = @AccessRequestId + AND [Status] IN (0, 1) -- Pending or Approved + AND NOT EXISTS (SELECT 1 FROM [dbo].[AccessLease] L WHERE L.[AccessRequestId] = @AccessRequestId) +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[AccessRequest_CancelWithDecision] + @AccessRequestId UNIQUEIDENTIFIER, + @AccessDecisionId UNIQUEIDENTIFIER, + @ApproverId UNIQUEIDENTIFIER, + @Verdict TINYINT, + @Comment NVARCHAR(MAX) = NULL, + @Now DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + BEGIN TRANSACTION AccessRequest_CancelWithDecision + + UPDATE [dbo].[AccessRequest] + SET [Status] = 2, -- Denied + [ResolvedDate] = @Now + WHERE [Id] = @AccessRequestId + AND [Status] IN (0, 1) -- Pending or Approved + AND NOT EXISTS (SELECT 1 FROM [dbo].[AccessLease] L WHERE L.[AccessRequestId] = @AccessRequestId) + + IF @@ROWCOUNT > 0 + BEGIN + INSERT INTO [dbo].[AccessDecision] + ( + [Id], [AccessRequestId], [DeciderKind], [ApproverId], [ConditionKind], + [Verdict], [Comment], [EvaluationContext], [CreationDate] + ) + VALUES + ( + @AccessDecisionId, @AccessRequestId, 1 /* Human */, @ApproverId, NULL, + @Verdict, @Comment, NULL, @Now + ) + END + + COMMIT TRANSACTION AccessRequest_CancelWithDecision +END +GO From cf7ee9b6e9454e016913f6dd219e71e6453dbdee Mon Sep 17 00:00:00 2001 From: Patrik Svensson Date: Mon, 15 Jun 2026 13:17:37 +0200 Subject: [PATCH 26/54] Add PAM lease extension with per-rule extension settings AccessRule gains AllowsExtensions + MaxExtensions, threaded through the entity, SSDT table/procs, API request/response models, and create/update validation. Extensions are always auto-approved (never routed to an approver): a new POST /leasing/requests/extension runs RequestLeaseExtensionCommand, which validates the active lease, the rule opt-in, the duration, a required justification, and the per-lease cap, then atomically records an approved extension request and pushes the parent lease's NotAfter out in place (AccessRequest_CreateApprovedExtension, under a per-lease lock) without minting a new lease. Also surface ExtensionsAllowed/ExtensionsRemaining on the cipher access-state snapshot, and exclude extension requests from the startable-approved read so an applied extension never shows as activatable. --- .../Controllers/MemberLeasingController.cs | 17 +- .../AccessLeaseExtensionRequestModel.cs | 24 ++ .../Models/Request/AccessRuleRequestModel.cs | 14 + .../Response/AccessRuleResponseModel.cs | 4 + .../CipherAccessStateResponseModel.cs | 8 + ...OrganizationServiceCollectionExtensions.cs | 1 + src/Core/Pam/Entities/AccessRule.cs | 12 + .../Pam/Enums/AccessLeaseExtendOutcome.cs | 23 ++ .../Models/AccessLeaseExtensionSubmission.cs | 13 + src/Core/Pam/Models/AccessRuleDetails.cs | 2 + src/Core/Pam/Models/CipherAccessState.cs | 8 +- src/Core/Pam/Models/GoverningRule.cs | 15 +- .../Commands/CreateAccessRuleCommand.cs | 5 + .../IRequestLeaseExtensionCommand.cs | 24 ++ .../Commands/RequestLeaseExtensionCommand.cs | 142 ++++++++++ .../Commands/UpdateAccessRuleCommand.cs | 7 + .../Queries/GetCipherAccessStateQuery.cs | 26 +- .../Repositories/IAccessRequestRepository.cs | 18 ++ .../Pam/Services/GoverningRuleResolver.cs | 14 +- .../Repositories/AccessRequestRepository.cs | 35 +++ ...AccessRequest_CountExtensionsByLeaseId.sql | 12 + .../AccessRequest_CreateApprovedExtension.sql | 82 ++++++ ...eadActiveApprovedByRequesterIdCipherId.sql | 3 + .../Stored Procedures/AccessRule_Create.sql | 6 + .../Stored Procedures/AccessRule_Update.sql | 4 + src/Sql/dbo/Pam/Tables/AccessRule.sql | 2 + .../MemberLeasingControllerTests.cs | 26 +- .../Commands/CreateAccessRuleCommandTests.cs | 54 ++++ .../RequestLeaseExtensionCommandTests.cs | 245 ++++++++++++++++++ .../Commands/UpdateAccessRuleCommandTests.cs | 37 ++- .../Queries/GetCipherAccessStateQueryTests.cs | 75 ++++++ .../AccessRequestExtensionRepositoryTests.cs | 209 +++++++++++++++ ...6-12_01_AddAccessRuleExtensionSettings.sql | 114 ++++++++ .../2026-06-12_02_AddLeaseExtension.sql | 125 +++++++++ 34 files changed, 1391 insertions(+), 15 deletions(-) create mode 100644 src/Api/Pam/Models/Request/AccessLeaseExtensionRequestModel.cs create mode 100644 src/Core/Pam/Enums/AccessLeaseExtendOutcome.cs create mode 100644 src/Core/Pam/Models/AccessLeaseExtensionSubmission.cs create mode 100644 src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IRequestLeaseExtensionCommand.cs create mode 100644 src/Core/Pam/OrganizationFeatures/Commands/RequestLeaseExtensionCommand.cs create mode 100644 src/Sql/dbo/Pam/Stored Procedures/AccessRequest_CountExtensionsByLeaseId.sql create mode 100644 src/Sql/dbo/Pam/Stored Procedures/AccessRequest_CreateApprovedExtension.sql create mode 100644 test/Core.Test/Pam/Commands/RequestLeaseExtensionCommandTests.cs create mode 100644 test/Infrastructure.IntegrationTest/Pam/Repositories/AccessRequestExtensionRepositoryTests.cs create mode 100644 util/Migrator/DbScripts/2026-06-12_01_AddAccessRuleExtensionSettings.sql create mode 100644 util/Migrator/DbScripts/2026-06-12_02_AddLeaseExtension.sql diff --git a/src/Api/Pam/Controllers/MemberLeasingController.cs b/src/Api/Pam/Controllers/MemberLeasingController.cs index 6d7e40758978..e04363f115e2 100644 --- a/src/Api/Pam/Controllers/MemberLeasingController.cs +++ b/src/Api/Pam/Controllers/MemberLeasingController.cs @@ -1,4 +1,5 @@ using Bit.Api.Models.Response; +using Bit.Api.Pam.Models.Request; using Bit.Api.Pam.Models.Response; using Bit.Core; using Bit.Core.Pam.OrganizationFeatures.Commands.Interfaces; @@ -23,7 +24,8 @@ public class MemberLeasingController( IListMyAccessRequestsQuery listMyAccessRequestsQuery, IListMyActiveAccessLeasesQuery listMyActiveAccessLeasesQuery, IActivateAccessRequestCommand activateAccessRequestCommand, - ICancelAccessRequestCommand cancelAccessRequestCommand) + ICancelAccessRequestCommand cancelAccessRequestCommand, + IRequestLeaseExtensionCommand requestLeaseExtensionCommand) : Controller { /// @@ -77,4 +79,17 @@ public async Task CancelRequest(Guid id) await cancelAccessRequestCommand.CancelAsync(userId, id); return NoContent(); } + + /// + /// Extends one of the caller's active leases by the requested duration. Extensions are always auto-approved, + /// subject to the governing rule allowing them and the per-lease maximum not being reached; the lease's end is + /// pushed out in place rather than minting a new lease. Only the lease's requester may extend it. + /// + [HttpPost("requests/extension")] + public async Task RequestExtension([FromBody] AccessLeaseExtensionRequestModel model) + { + var userId = userService.GetProperUserId(User)!.Value; + var details = await requestLeaseExtensionCommand.ExtendAsync(userId, model.ToSubmission()); + return new AccessRequestDetailsResponseModel(details); + } } diff --git a/src/Api/Pam/Models/Request/AccessLeaseExtensionRequestModel.cs b/src/Api/Pam/Models/Request/AccessLeaseExtensionRequestModel.cs new file mode 100644 index 000000000000..d5643da3574a --- /dev/null +++ b/src/Api/Pam/Models/Request/AccessLeaseExtensionRequestModel.cs @@ -0,0 +1,24 @@ +using Bit.Core.Pam.Models; + +namespace Bit.Api.Pam.Models.Request; + +/// +/// A request to extend an active lease, identified by . The lease's end is pushed out by +/// ; a justifying is required. Extensions are always auto-approved, +/// subject to the governing rule allowing extensions and the per-lease maximum not being reached. +/// +public class AccessLeaseExtensionRequestModel +{ + public Guid LeaseId { get; set; } + + public int DurationSeconds { get; set; } + + public string? Reason { get; set; } + + public AccessLeaseExtensionSubmission ToSubmission() => new() + { + LeaseId = LeaseId, + DurationSeconds = DurationSeconds, + Reason = Reason, + }; +} diff --git a/src/Api/Pam/Models/Request/AccessRuleRequestModel.cs b/src/Api/Pam/Models/Request/AccessRuleRequestModel.cs index 01d8e5d886d6..926c9b9469fc 100644 --- a/src/Api/Pam/Models/Request/AccessRuleRequestModel.cs +++ b/src/Api/Pam/Models/Request/AccessRuleRequestModel.cs @@ -38,6 +38,18 @@ public class AccessRuleRequestModel /// public bool Enabled { get; set; } = true; + /// + /// When true, a member holding an active lease under this rule may extend it (always auto-approved), up to + /// times. + /// + public bool AllowsExtensions { get; set; } + + /// + /// The maximum number of times a single lease may be extended. Required to be positive when + /// is true. + /// + public int? MaxExtensions { get; set; } + /// /// The complete set of collections this rule governs. The rule's associations are replaced to match /// exactly this set; an empty array clears all associations. @@ -55,6 +67,8 @@ public class AccessRuleRequestModel DefaultLeaseDurationSeconds = DefaultLeaseDurationSeconds, MaxLeaseDurationSeconds = MaxLeaseDurationSeconds, Enabled = Enabled, + AllowsExtensions = AllowsExtensions, + MaxExtensions = MaxExtensions, }; private static string SerializeConditions(object conditions) => conditions switch diff --git a/src/Api/Pam/Models/Response/AccessRuleResponseModel.cs b/src/Api/Pam/Models/Response/AccessRuleResponseModel.cs index 880fc3803a35..df36a90be8ca 100644 --- a/src/Api/Pam/Models/Response/AccessRuleResponseModel.cs +++ b/src/Api/Pam/Models/Response/AccessRuleResponseModel.cs @@ -20,6 +20,8 @@ public AccessRuleResponseModel(AccessRuleDetails rule) DefaultLeaseDurationSeconds = rule.DefaultLeaseDurationSeconds; MaxLeaseDurationSeconds = rule.MaxLeaseDurationSeconds; Enabled = rule.Enabled; + AllowsExtensions = rule.AllowsExtensions; + MaxExtensions = rule.MaxExtensions; CreationDate = rule.CreationDate.AsUtc(); RevisionDate = rule.RevisionDate.AsUtc(); Collections = rule.CollectionIds.ToList(); @@ -34,6 +36,8 @@ public AccessRuleResponseModel(AccessRuleDetails rule) public int? DefaultLeaseDurationSeconds { get; } public int? MaxLeaseDurationSeconds { get; } public bool Enabled { get; } + public bool AllowsExtensions { get; } + public int? MaxExtensions { get; } public DateTime CreationDate { get; } public DateTime RevisionDate { get; } public IEnumerable Collections { get; } diff --git a/src/Api/Pam/Models/Response/CipherAccessStateResponseModel.cs b/src/Api/Pam/Models/Response/CipherAccessStateResponseModel.cs index 9bd94504116c..acb611b5df6c 100644 --- a/src/Api/Pam/Models/Response/CipherAccessStateResponseModel.cs +++ b/src/Api/Pam/Models/Response/CipherAccessStateResponseModel.cs @@ -19,6 +19,8 @@ public CipherAccessStateResponseModel(CipherAccessState state) ActiveLease = state.ActiveLease is null ? null : new AccessLeaseResponseModel(state.ActiveLease); PendingRequest = state.PendingRequest is null ? null : new AccessRequestDetailsResponseModel(state.PendingRequest); ApprovedRequest = state.ApprovedRequest is null ? null : new AccessRequestDetailsResponseModel(state.ApprovedRequest); + ExtensionsAllowed = state.ExtensionsAllowed; + ExtensionsRemaining = state.ExtensionsRemaining; } public Guid CipherId { get; } @@ -31,4 +33,10 @@ public CipherAccessStateResponseModel(CipherAccessState state) /// to mint the lease; lapsed approvals are never surfaced here. /// public AccessRequestDetailsResponseModel? ApprovedRequest { get; } + + /// Whether the active lease's governing rule permits extending it. + public bool ExtensionsAllowed { get; } + + /// How many extensions remain for the active lease (0 when none remain or there is no active lease). + public int ExtensionsRemaining { get; } } diff --git a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs index 9c7e1d0ef43e..169593adfaee 100644 --- a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs +++ b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs @@ -213,6 +213,7 @@ public static void AddPamServices(this IServiceCollection services) services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/Core/Pam/Entities/AccessRule.cs b/src/Core/Pam/Entities/AccessRule.cs index 71ae65692d71..6c1cdcd7a7ab 100644 --- a/src/Core/Pam/Entities/AccessRule.cs +++ b/src/Core/Pam/Entities/AccessRule.cs @@ -50,6 +50,18 @@ public class AccessRule : ITableObject /// public bool Enabled { get; set; } = true; + /// + /// When true, a member holding an active lease under this rule may extend it. Extensions are always + /// auto-approved (regardless of the rule's approval conditions), up to times. + /// + public bool AllowsExtensions { get; set; } + + /// + /// The maximum number of times a single lease granted under this rule may be extended. Required to be a + /// positive value when is true; ignored otherwise. + /// + public int? MaxExtensions { get; set; } + public DateTime CreationDate { get; set; } = DateTime.UtcNow; public DateTime RevisionDate { get; set; } = DateTime.UtcNow; diff --git a/src/Core/Pam/Enums/AccessLeaseExtendOutcome.cs b/src/Core/Pam/Enums/AccessLeaseExtendOutcome.cs new file mode 100644 index 000000000000..5c07c9b563a2 --- /dev/null +++ b/src/Core/Pam/Enums/AccessLeaseExtendOutcome.cs @@ -0,0 +1,23 @@ +namespace Bit.Core.Pam.Enums; + +/// +/// The result of a race-safe lease extension. The extension stored procedure returns a distinct integer code so the +/// caller can tell a lease that is no longer extendable apart from a per-lease max-extensions conflict. +/// +public enum AccessLeaseExtendOutcome +{ + /// The lease's window was extended (stored proc returned 1). + Extended = 1, + + /// + /// The lease was no longer active, or its window had already ended, when the guarded update ran (stored proc + /// returned 0). A concurrent revoke or expiry likely won. + /// + LeaseNotActive = 0, + + /// + /// The lease has already been extended the maximum number of times permitted by its governing rule (stored proc + /// returned -1). Nothing was persisted. + /// + MaxExtensionsReached = -1, +} diff --git a/src/Core/Pam/Models/AccessLeaseExtensionSubmission.cs b/src/Core/Pam/Models/AccessLeaseExtensionSubmission.cs new file mode 100644 index 000000000000..39765b82dee0 --- /dev/null +++ b/src/Core/Pam/Models/AccessLeaseExtensionSubmission.cs @@ -0,0 +1,13 @@ +namespace Bit.Core.Pam.Models; + +/// +/// A request to extend an active lease. Extensions are always auto-approved, subject to the governing rule's +/// AllowsExtensions / MaxExtensions settings: the lease's end is pushed out by +/// in place (no new lease is minted), and a justifying is required. +/// +public sealed class AccessLeaseExtensionSubmission +{ + public Guid LeaseId { get; init; } + public int DurationSeconds { get; init; } + public string? Reason { get; init; } +} diff --git a/src/Core/Pam/Models/AccessRuleDetails.cs b/src/Core/Pam/Models/AccessRuleDetails.cs index 7608cd58d156..a2bebd5fdad2 100644 --- a/src/Core/Pam/Models/AccessRuleDetails.cs +++ b/src/Core/Pam/Models/AccessRuleDetails.cs @@ -20,6 +20,8 @@ public class AccessRuleDetails : AccessRule DefaultLeaseDurationSeconds = rule.DefaultLeaseDurationSeconds, MaxLeaseDurationSeconds = rule.MaxLeaseDurationSeconds, Enabled = rule.Enabled, + AllowsExtensions = rule.AllowsExtensions, + MaxExtensions = rule.MaxExtensions, CreationDate = rule.CreationDate, RevisionDate = rule.RevisionDate, CollectionIds = collectionIds, diff --git a/src/Core/Pam/Models/CipherAccessState.cs b/src/Core/Pam/Models/CipherAccessState.cs index 3813f0e2dd5d..dfd8a7fcec93 100644 --- a/src/Core/Pam/Models/CipherAccessState.cs +++ b/src/Core/Pam/Models/CipherAccessState.cs @@ -1,4 +1,4 @@ -using Bit.Core.Pam.Entities; +using Bit.Core.Pam.Entities; namespace Bit.Core.Pam.Models; @@ -6,9 +6,13 @@ namespace Bit.Core.Pam.Models; /// The caller's access state for a single cipher: the active lease they hold (if any), their pending request (if /// any), and their approved-but-not-yet-activated request (if any). Approval no longer mints the lease, so the /// approved request is the startable state in between — the caller activates it to produce the active lease. +/// ExtensionsAllowed / ExtensionsRemaining describe whether the active lease can be extended and how many extensions +/// remain, so the banner can gate its "Extend" control. /// public record CipherAccessState( Guid CipherId, AccessLease? ActiveLease, AccessRequestDetails? PendingRequest, - AccessRequestDetails? ApprovedRequest); + AccessRequestDetails? ApprovedRequest, + bool ExtensionsAllowed = false, + int ExtensionsRemaining = 0); diff --git a/src/Core/Pam/Models/GoverningRule.cs b/src/Core/Pam/Models/GoverningRule.cs index 9f352ae47007..9a6f0d1354be 100644 --- a/src/Core/Pam/Models/GoverningRule.cs +++ b/src/Core/Pam/Models/GoverningRule.cs @@ -12,4 +12,17 @@ public sealed record GoverningRule( Guid OrganizationId, Guid CollectionId, bool RequiresHumanApproval, - AccessCondition Condition); + AccessCondition Condition) +{ + /// + /// When true, a member holding an active lease under this rule may extend it (always auto-approved), up to + /// times. + /// + public bool AllowsExtensions { get; init; } + + /// + /// The maximum number of times a single lease under this rule may be extended; meaningful only when + /// is true. + /// + public int? MaxExtensions { get; init; } +} diff --git a/src/Core/Pam/OrganizationFeatures/Commands/CreateAccessRuleCommand.cs b/src/Core/Pam/OrganizationFeatures/Commands/CreateAccessRuleCommand.cs index 496edb2fb07d..60c78e930219 100644 --- a/src/Core/Pam/OrganizationFeatures/Commands/CreateAccessRuleCommand.cs +++ b/src/Core/Pam/OrganizationFeatures/Commands/CreateAccessRuleCommand.cs @@ -35,6 +35,11 @@ public async Task CreateAsync(AccessRule rule, IEnumerable 0) + { + throw new BadRequestException("Maximum extensions must be a positive number when extensions are allowed."); + } + var validation = _validator.Validate(rule.Conditions); if (!validation.IsValid) { diff --git a/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IRequestLeaseExtensionCommand.cs b/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IRequestLeaseExtensionCommand.cs new file mode 100644 index 000000000000..4dd48dc5ef69 --- /dev/null +++ b/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IRequestLeaseExtensionCommand.cs @@ -0,0 +1,24 @@ +using Bit.Core.Pam.Models; + +namespace Bit.Core.Pam.OrganizationFeatures.Commands.Interfaces; + +public interface IRequestLeaseExtensionCommand +{ + /// + /// Extends the caller's active lease by the requested duration. Extensions are always auto-approved, subject to + /// the governing rule's AllowsExtensions / MaxExtensions settings: the lease's end is pushed out in + /// place (no new lease is minted) and an auto-approved extension request is recorded. Only the lease's requester + /// may extend it. + /// + /// + /// The lease does not exist or the caller is not its requester. + /// + /// + /// The lease is no longer active (revoked or expired). + /// + /// + /// The item is not lease-gated or does not allow extensions, the per-lease maximum has been reached, the duration + /// is non-positive or exceeds the maximum, or no justification was supplied. + /// + Task ExtendAsync(Guid userId, AccessLeaseExtensionSubmission submission); +} diff --git a/src/Core/Pam/OrganizationFeatures/Commands/RequestLeaseExtensionCommand.cs b/src/Core/Pam/OrganizationFeatures/Commands/RequestLeaseExtensionCommand.cs new file mode 100644 index 000000000000..98a6485c2cbb --- /dev/null +++ b/src/Core/Pam/OrganizationFeatures/Commands/RequestLeaseExtensionCommand.cs @@ -0,0 +1,142 @@ +using Bit.Core.Exceptions; +using Bit.Core.Pam.Entities; +using Bit.Core.Pam.Enums; +using Bit.Core.Pam.Models; +using Bit.Core.Pam.OrganizationFeatures.Commands.Interfaces; +using Bit.Core.Pam.Repositories; +using Bit.Core.Pam.Services; + +namespace Bit.Core.Pam.OrganizationFeatures.Commands; + +public class RequestLeaseExtensionCommand : IRequestLeaseExtensionCommand +{ + private readonly IAccessLeaseRepository _accessLeaseRepository; + private readonly IGoverningRuleResolver _resolver; + private readonly IAccessRequestRepository _accessRequestRepository; + private readonly TimeProvider _timeProvider; + + public RequestLeaseExtensionCommand( + IAccessLeaseRepository accessLeaseRepository, + IGoverningRuleResolver resolver, + IAccessRequestRepository accessRequestRepository, + TimeProvider timeProvider) + { + _accessLeaseRepository = accessLeaseRepository; + _resolver = resolver; + _accessRequestRepository = accessRequestRepository; + _timeProvider = timeProvider; + } + + public async Task ExtendAsync(Guid userId, AccessLeaseExtensionSubmission submission) + { + var lease = await _accessLeaseRepository.GetByIdAsync(submission.LeaseId); + + // 404 for both missing and someone else's lease, so the caller can't probe for leases they don't own. + if (lease is null || lease.RequesterId != userId) + { + throw new NotFoundException(); + } + + var now = _timeProvider.GetUtcNow().UtcDateTime; + + if (lease.Status != AccessLeaseStatus.Active || lease.NotAfter <= now) + { + throw new ConflictException("This lease is no longer active."); + } + + // Extensions reuse the cipher's governing rule, but never its approval gate: they are always auto-approved, + // gated only by the rule opting in and the per-lease maximum. + var governingRule = await _resolver.ResolveAsync(userId, lease.CipherId); + if (governingRule is null) + { + throw new BadRequestException("This item does not require a lease."); + } + + if (!governingRule.AllowsExtensions) + { + throw new BadRequestException("This item does not allow extending a lease."); + } + + if (submission.DurationSeconds <= 0) + { + throw new BadRequestException("A positive duration is required."); + } + + if (submission.DurationSeconds > SubmitAccessRequestCommand.MaxDurationSeconds) + { + throw new BadRequestException( + $"The requested duration exceeds the maximum of {SubmitAccessRequestCommand.MaxDurationSeconds} seconds."); + } + + if (string.IsNullOrWhiteSpace(submission.Reason)) + { + throw new BadRequestException("A justification is required to extend a lease."); + } + + // MaxExtensions is guaranteed positive when AllowsExtensions is true (enforced on rule create/update); a + // missing cap is treated as zero so a misconfigured rule denies rather than grants unbounded extensions. + var maxExtensions = governingRule.MaxExtensions ?? 0; + + // Friendly early check; the mint proc re-counts under a per-lease lock and is the race-safe authority. + var priorExtensions = await _accessRequestRepository.CountExtensionsByLeaseIdAsync(lease.Id); + if (priorExtensions >= maxExtensions) + { + throw new BadRequestException("This lease has reached the maximum number of extensions."); + } + + // The extension window spans from the lease's current end to its new end; NotAfter is the lease's new end. + var request = new AccessRequest + { + ExtensionOfLeaseId = lease.Id, + OrganizationId = lease.OrganizationId, + CollectionId = lease.CollectionId, + CipherId = lease.CipherId, + RequesterId = userId, + NotBefore = lease.NotAfter, + NotAfter = lease.NotAfter.AddSeconds(submission.DurationSeconds), + Reason = submission.Reason, + Status = AccessRequestStatus.Approved, + CreationDate = now, + ResolvedDate = now, + }; + request.SetNewId(); + + var decision = new AccessDecision + { + AccessRequestId = request.Id, + DeciderKind = AccessDeciderKind.Automatic, + Verdict = AccessDecisionVerdict.Approve, + CreationDate = now, + }; + decision.SetNewId(); + + var outcome = await _accessRequestRepository.CreateApprovedExtensionAsync(request, decision, maxExtensions, now); + + switch (outcome) + { + case AccessLeaseExtendOutcome.LeaseNotActive: + throw new ConflictException("This lease is no longer active."); + case AccessLeaseExtendOutcome.MaxExtensionsReached: + throw new BadRequestException("This lease has reached the maximum number of extensions."); + } + + // Project the approved-extension state the client renders (Status approved + ExtensionOfLeaseId set) from + // what we just wrote. The parent lease's end has already been pushed out, so the next access-state snapshot + // re-emits the longer countdown. + return new AccessRequestDetails + { + Id = request.Id, + ExtensionOfLeaseId = request.ExtensionOfLeaseId, + OrganizationId = request.OrganizationId, + CollectionId = request.CollectionId, + CipherId = request.CipherId, + RequesterId = request.RequesterId, + NotBefore = request.NotBefore, + NotAfter = request.NotAfter, + Reason = request.Reason, + Status = AccessRequestStatus.Approved, + CreationDate = request.CreationDate, + ResolvedDate = request.ResolvedDate, + }; + } +} diff --git a/src/Core/Pam/OrganizationFeatures/Commands/UpdateAccessRuleCommand.cs b/src/Core/Pam/OrganizationFeatures/Commands/UpdateAccessRuleCommand.cs index 3e8c697f1983..af619f2f957a 100644 --- a/src/Core/Pam/OrganizationFeatures/Commands/UpdateAccessRuleCommand.cs +++ b/src/Core/Pam/OrganizationFeatures/Commands/UpdateAccessRuleCommand.cs @@ -35,6 +35,11 @@ public async Task UpdateAsync(Guid organizationId, Guid id, A throw new BadRequestException("Name is required."); } + if (update.AllowsExtensions && update.MaxExtensions is not > 0) + { + throw new BadRequestException("Maximum extensions must be a positive number when extensions are allowed."); + } + var existing = await _repository.GetDetailsByIdAsync(id); if (existing is null || existing.OrganizationId != organizationId) { @@ -68,6 +73,8 @@ public async Task UpdateAsync(Guid organizationId, Guid id, A DefaultLeaseDurationSeconds = update.DefaultLeaseDurationSeconds, MaxLeaseDurationSeconds = update.MaxLeaseDurationSeconds, Enabled = update.Enabled, + AllowsExtensions = update.AllowsExtensions, + MaxExtensions = update.MaxExtensions, CreationDate = existing.CreationDate, RevisionDate = _timeProvider.GetUtcNow().UtcDateTime, }; diff --git a/src/Core/Pam/OrganizationFeatures/Queries/GetCipherAccessStateQuery.cs b/src/Core/Pam/OrganizationFeatures/Queries/GetCipherAccessStateQuery.cs index 58dcf7a3e14c..759353f1b9d5 100644 --- a/src/Core/Pam/OrganizationFeatures/Queries/GetCipherAccessStateQuery.cs +++ b/src/Core/Pam/OrganizationFeatures/Queries/GetCipherAccessStateQuery.cs @@ -44,12 +44,24 @@ public async Task GetStateAsync(Guid userId, Guid cipherId) var pending = await _accessRequestRepository.GetActivePendingByRequesterIdCipherIdAsync(userId, cipherId); var approved = await _accessRequestRepository.GetActiveApprovedByRequesterIdCipherIdAsync(userId, cipherId, now); - // 404 when the cipher isn't leasing-gated and there's nothing to report. We still return a snapshot when the - // caller holds a lease, a pending request, or a startable approval even if the rule was since removed, so - // their state isn't hidden. - if (activeLease is null && pending is null && approved is null - && await _resolver.ResolveAsync(userId, cipherId) is null) + var extensionsAllowed = false; + var extensionsRemaining = 0; + if (activeLease is not null) { + // Extension eligibility drives the banner's "Extend" control. Resolve the governing rule for the active + // lease and, when it opts in, report how many of its per-lease extensions remain. + var rule = await _resolver.ResolveAsync(userId, cipherId); + if (rule?.AllowsExtensions == true) + { + extensionsAllowed = true; + var used = await _accessRequestRepository.CountExtensionsByLeaseIdAsync(activeLease.Id); + extensionsRemaining = Math.Max(0, (rule.MaxExtensions ?? 0) - used); + } + } + else if (pending is null && approved is null && await _resolver.ResolveAsync(userId, cipherId) is null) + { + // Nothing to report and the cipher isn't leasing-gated. (When a lease or request exists we still return a + // snapshot even if the rule was since removed, so the caller's state isn't hidden.) throw new NotFoundException(); } @@ -57,7 +69,9 @@ public async Task GetStateAsync(Guid userId, Guid cipherId) cipherId, activeLease, pending is null ? null : ToDetails(pending), - approved is null ? null : ToDetails(approved)); + approved is null ? null : ToDetails(approved), + extensionsAllowed, + extensionsRemaining); } // Neither a pending nor an approved-unactivated request has produced a lease (the approved read excludes diff --git a/src/Core/Pam/Repositories/IAccessRequestRepository.cs b/src/Core/Pam/Repositories/IAccessRequestRepository.cs index 8ea21956b5ee..001d1300914d 100644 --- a/src/Core/Pam/Repositories/IAccessRequestRepository.cs +++ b/src/Core/Pam/Repositories/IAccessRequestRepository.cs @@ -76,4 +76,22 @@ public interface IAccessRequestRepository /// only when the transition happens. Both supplied entities must already have their ids assigned. /// Task CancelWithDecisionAsync(AccessRequest request, AccessDecision decision, DateTime now); + + /// + /// Returns the number of extension requests recorded against the lease. Extensions are always auto-approved, so + /// every such request counts toward the governing rule's per-lease maximum. + /// + Task CountExtensionsByLeaseIdAsync(Guid leaseId); + + /// + /// Atomically records an auto-approved extension request (with its automatic decision) and pushes the parent + /// lease's end out to the request's NotAfter, all under a per-lease lock. Returns + /// when the lease is no longer active or its window has + /// ended, or when + /// has already been reached; otherwise . Both supplied entities + /// must already have their ids assigned, and the request's ExtensionOfLeaseId identifies the lease being + /// extended. + /// + Task CreateApprovedExtensionAsync(AccessRequest request, AccessDecision decision, + int maxExtensions, DateTime now); } diff --git a/src/Core/Pam/Services/GoverningRuleResolver.cs b/src/Core/Pam/Services/GoverningRuleResolver.cs index 79e69c4eb480..00ea698d8b5e 100644 --- a/src/Core/Pam/Services/GoverningRuleResolver.cs +++ b/src/Core/Pam/Services/GoverningRuleResolver.cs @@ -1,4 +1,4 @@ -using System.Text.Json; +using System.Text.Json; using Bit.Core.Pam.Models; using Bit.Core.Pam.Models.Conditions; using Bit.Core.Pam.Repositories; @@ -57,10 +57,18 @@ public GoverningRuleResolver( if (ContainsHumanApproval(condition)) { // Most restrictive wins — return as soon as a human-approval condition is found. - return new GoverningRule(collection.OrganizationId, collection.Id, true, condition); + return new GoverningRule(collection.OrganizationId, collection.Id, true, condition) + { + AllowsExtensions = accessRule.AllowsExtensions, + MaxExtensions = accessRule.MaxExtensions, + }; } - automatic ??= new GoverningRule(collection.OrganizationId, collection.Id, false, condition); + automatic ??= new GoverningRule(collection.OrganizationId, collection.Id, false, condition) + { + AllowsExtensions = accessRule.AllowsExtensions, + MaxExtensions = accessRule.MaxExtensions, + }; } return automatic; diff --git a/src/Infrastructure.Dapper/Pam/Repositories/AccessRequestRepository.cs b/src/Infrastructure.Dapper/Pam/Repositories/AccessRequestRepository.cs index 688340081a9f..929afc68669c 100644 --- a/src/Infrastructure.Dapper/Pam/Repositories/AccessRequestRepository.cs +++ b/src/Infrastructure.Dapper/Pam/Repositories/AccessRequestRepository.cs @@ -154,4 +154,39 @@ await connection.ExecuteAsync( }, commandType: CommandType.StoredProcedure); } + + public async Task CountExtensionsByLeaseIdAsync(Guid leaseId) + { + await using var connection = new SqlConnection(ConnectionString); + return await connection.ExecuteScalarAsync( + $"[{Schema}].[AccessRequest_CountExtensionsByLeaseId]", + new { LeaseId = leaseId }, + commandType: CommandType.StoredProcedure); + } + + public async Task CreateApprovedExtensionAsync(AccessRequest request, + AccessDecision decision, int maxExtensions, DateTime now) + { + await using var connection = new SqlConnection(ConnectionString); + var result = await connection.ExecuteScalarAsync( + $"[{Schema}].[AccessRequest_CreateApprovedExtension]", + new + { + AccessRequestId = request.Id, + AccessDecisionId = decision.Id, + request.ExtensionOfLeaseId, + request.OrganizationId, + request.CollectionId, + request.CipherId, + request.RequesterId, + request.NotBefore, + request.NotAfter, + request.Reason, + MaxExtensions = maxExtensions, + Now = now, + }, + commandType: CommandType.StoredProcedure); + + return (AccessLeaseExtendOutcome)result; + } } diff --git a/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_CountExtensionsByLeaseId.sql b/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_CountExtensionsByLeaseId.sql new file mode 100644 index 000000000000..ae70378082de --- /dev/null +++ b/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_CountExtensionsByLeaseId.sql @@ -0,0 +1,12 @@ +CREATE PROCEDURE [dbo].[AccessRequest_CountExtensionsByLeaseId] + @LeaseId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + -- Number of extension requests recorded against the lease. Extensions are always auto-approved, so every such + -- request counts toward the governing rule's per-lease maximum. + SELECT COUNT(*) + FROM [dbo].[AccessRequest] + WHERE [ExtensionOfLeaseId] = @LeaseId +END diff --git a/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_CreateApprovedExtension.sql b/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_CreateApprovedExtension.sql new file mode 100644 index 000000000000..7780c9aa6634 --- /dev/null +++ b/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_CreateApprovedExtension.sql @@ -0,0 +1,82 @@ +CREATE PROCEDURE [dbo].[AccessRequest_CreateApprovedExtension] + @AccessRequestId UNIQUEIDENTIFIER, + @AccessDecisionId UNIQUEIDENTIFIER, + @ExtensionOfLeaseId UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @CollectionId UNIQUEIDENTIFIER, + @CipherId UNIQUEIDENTIFIER, + @RequesterId UNIQUEIDENTIFIER, + @NotBefore DATETIME2(7), + @NotAfter DATETIME2(7), + @Reason NVARCHAR(MAX) = NULL, + @MaxExtensions INT, + @Now DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + -- An explicit transaction holds the per-lease range lock until the writes commit, so concurrent extensions of + -- the same lease serialize. XACT_ABORT guarantees rollback (and a clean pooled connection) on any error. + SET XACT_ABORT ON + + BEGIN TRANSACTION + + -- Lock the parent lease row for the life of the transaction. A second concurrent extension of the same lease + -- blocks here until this transaction commits, then re-counts below and sees this extension. The lease must + -- still be active and in-window to be extendable; outcome 0 is distinct from the cap conflict (-1). + IF NOT EXISTS ( + SELECT 1 + FROM [dbo].[AccessLease] WITH (UPDLOCK, HOLDLOCK) + WHERE [Id] = @ExtensionOfLeaseId + AND [RequesterId] = @RequesterId + AND [Status] = 0 /* Active */ + AND [NotAfter] > @Now + ) + BEGIN + ROLLBACK TRANSACTION + SELECT 0 -- LeaseNotActive + RETURN + END + + -- Per-lease extension cap. Every extension request against this lease is auto-approved, so every one counts. + -- Counted under the lease lock, so it is race-safe against a concurrent extension of the same lease. + IF (SELECT COUNT(*) FROM [dbo].[AccessRequest] WHERE [ExtensionOfLeaseId] = @ExtensionOfLeaseId) >= @MaxExtensions + BEGIN + ROLLBACK TRANSACTION + SELECT -1 -- MaxExtensionsReached + RETURN + END + + -- Record the auto-approved extension request and its automatic verdict, then push the parent lease's end out in + -- place. No new lease is minted — extending reuses the existing lease, preserving the single-active-lease + -- invariant. The request's window spans the extension ([old lease end] .. [new lease end]); its NotAfter is the + -- lease's new end. + INSERT INTO [dbo].[AccessRequest] + ( + [Id], [ExtensionOfLeaseId], [OrganizationId], [CollectionId], [CipherId], [RequesterId], + [NotBefore], [NotAfter], [Reason], [Status], [CreationDate], [ResolvedDate] + ) + VALUES + ( + @AccessRequestId, @ExtensionOfLeaseId, @OrganizationId, @CollectionId, @CipherId, @RequesterId, + @NotBefore, @NotAfter, @Reason, 1 /* Approved */, @Now, @Now + ) + + INSERT INTO [dbo].[AccessDecision] + ( + [Id], [AccessRequestId], [DeciderKind], [ApproverId], [ConditionKind], + [Verdict], [Comment], [EvaluationContext], [CreationDate] + ) + VALUES + ( + @AccessDecisionId, @AccessRequestId, 0 /* Automatic */, NULL, NULL, + 0 /* Approve */, NULL, NULL, @Now + ) + + UPDATE [dbo].[AccessLease] + SET [NotAfter] = @NotAfter + WHERE [Id] = @ExtensionOfLeaseId + + COMMIT TRANSACTION + + SELECT 1 -- Extended +END diff --git a/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_ReadActiveApprovedByRequesterIdCipherId.sql b/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_ReadActiveApprovedByRequesterIdCipherId.sql index 7f6102981a11..a937f33b6275 100644 --- a/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_ReadActiveApprovedByRequesterIdCipherId.sql +++ b/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_ReadActiveApprovedByRequesterIdCipherId.sql @@ -9,6 +9,8 @@ BEGIN -- The caller's approved-but-not-yet-activated request whose window can still produce access. Future windows are -- included (the client shows the upcoming window); lapsed windows are excluded so the client never offers an -- activation that the server would reject. A request that has produced a lease is activated, not approved. + -- Extension requests are excluded: an approved extension pushes its parent lease's end out in place and never + -- produces a lease of its own, so it must not surface here as an activatable "Start access" request. SELECT TOP 1 AR.* FROM @@ -18,6 +20,7 @@ BEGIN AND AR.[CipherId] = @CipherId AND AR.[Status] = 1 -- Approved AND AR.[NotAfter] > @Now + AND AR.[ExtensionOfLeaseId] IS NULL AND NOT EXISTS (SELECT 1 FROM [dbo].[AccessLease] AL WHERE AL.[AccessRequestId] = AR.[Id]) ORDER BY AR.[CreationDate] DESC diff --git a/src/Sql/dbo/Pam/Stored Procedures/AccessRule_Create.sql b/src/Sql/dbo/Pam/Stored Procedures/AccessRule_Create.sql index cc45912124ef..e5d627821f2b 100644 --- a/src/Sql/dbo/Pam/Stored Procedures/AccessRule_Create.sql +++ b/src/Sql/dbo/Pam/Stored Procedures/AccessRule_Create.sql @@ -8,6 +8,8 @@ CREATE PROCEDURE [dbo].[AccessRule_Create] @DefaultLeaseDurationSeconds INT = NULL, @MaxLeaseDurationSeconds INT = NULL, @Enabled BIT = 1, + @AllowsExtensions BIT = 0, + @MaxExtensions INT = NULL, @CreationDate DATETIME2(7), @RevisionDate DATETIME2(7) AS @@ -25,6 +27,8 @@ BEGIN [DefaultLeaseDurationSeconds], [MaxLeaseDurationSeconds], [Enabled], + [AllowsExtensions], + [MaxExtensions], [CreationDate], [RevisionDate] ) @@ -39,6 +43,8 @@ BEGIN @DefaultLeaseDurationSeconds, @MaxLeaseDurationSeconds, @Enabled, + @AllowsExtensions, + @MaxExtensions, @CreationDate, @RevisionDate ) diff --git a/src/Sql/dbo/Pam/Stored Procedures/AccessRule_Update.sql b/src/Sql/dbo/Pam/Stored Procedures/AccessRule_Update.sql index 8b117c966bbd..b8fdeb312637 100644 --- a/src/Sql/dbo/Pam/Stored Procedures/AccessRule_Update.sql +++ b/src/Sql/dbo/Pam/Stored Procedures/AccessRule_Update.sql @@ -8,6 +8,8 @@ CREATE PROCEDURE [dbo].[AccessRule_Update] @DefaultLeaseDurationSeconds INT = NULL, @MaxLeaseDurationSeconds INT = NULL, @Enabled BIT = 1, + @AllowsExtensions BIT = 0, + @MaxExtensions INT = NULL, @CreationDate DATETIME2(7), @RevisionDate DATETIME2(7) AS @@ -25,6 +27,8 @@ BEGIN [DefaultLeaseDurationSeconds] = @DefaultLeaseDurationSeconds, [MaxLeaseDurationSeconds] = @MaxLeaseDurationSeconds, [Enabled] = @Enabled, + [AllowsExtensions] = @AllowsExtensions, + [MaxExtensions] = @MaxExtensions, [CreationDate] = @CreationDate, [RevisionDate] = @RevisionDate WHERE diff --git a/src/Sql/dbo/Pam/Tables/AccessRule.sql b/src/Sql/dbo/Pam/Tables/AccessRule.sql index ee7ec960de89..e07d79f79193 100644 --- a/src/Sql/dbo/Pam/Tables/AccessRule.sql +++ b/src/Sql/dbo/Pam/Tables/AccessRule.sql @@ -8,6 +8,8 @@ CREATE TABLE [dbo].[AccessRule] ( [DefaultLeaseDurationSeconds] INT NULL, [MaxLeaseDurationSeconds] INT NULL, [Enabled] BIT NOT NULL CONSTRAINT [DF_AccessRule_Enabled] DEFAULT (1), + [AllowsExtensions] BIT NOT NULL CONSTRAINT [DF_AccessRule_AllowsExtensions] DEFAULT (0), + [MaxExtensions] INT NULL, [CreationDate] DATETIME2(7) NOT NULL, [RevisionDate] DATETIME2(7) NOT NULL, CONSTRAINT [PK_AccessRule] PRIMARY KEY CLUSTERED ([Id] ASC), diff --git a/test/Api.Test/Pam/Controllers/MemberLeasingControllerTests.cs b/test/Api.Test/Pam/Controllers/MemberLeasingControllerTests.cs index d92cda36f1c8..451512c4b1ec 100644 --- a/test/Api.Test/Pam/Controllers/MemberLeasingControllerTests.cs +++ b/test/Api.Test/Pam/Controllers/MemberLeasingControllerTests.cs @@ -1,5 +1,6 @@ -using System.Security.Claims; +using System.Security.Claims; using Bit.Api.Pam.Controllers; +using Bit.Api.Pam.Models.Request; using Bit.Core.Pam.Entities; using Bit.Core.Pam.Enums; using Bit.Core.Pam.Models; @@ -88,6 +89,29 @@ public async Task CancelRequest_CancelsCallersRequest_ReturnsNoContent( await sutProvider.GetDependency().Received(1).CancelAsync(userId, requestId); } + [Theory, BitAutoData] + public async Task RequestExtension_ForwardsSubmission_ReturnsApprovedExtensionDetails( + Guid userId, AccessLeaseExtensionRequestModel model, AccessRequestDetails details, + SutProvider sutProvider) + { + SetupUser(sutProvider, userId); + details.Status = AccessRequestStatus.Approved; + details.ProducedLeaseId = null; // an extension produces no lease of its own, so the status stays "approved" + sutProvider.GetDependency() + .ExtendAsync(userId, Arg.Any()) + .Returns(details); + + var result = await sutProvider.Sut.RequestExtension(model); + + Assert.Equal(details.Id, result.Id); + Assert.Equal(AccessRequestStatusNames.Approved, result.Status); + Assert.Equal(details.ExtensionOfLeaseId, result.ExtensionOfLeaseId); + await sutProvider.GetDependency().Received(1).ExtendAsync( + userId, + Arg.Is(s => + s.LeaseId == model.LeaseId && s.DurationSeconds == model.DurationSeconds && s.Reason == model.Reason)); + } + private static void SetupUser(SutProvider sutProvider, Guid userId) { sutProvider.GetDependency() diff --git a/test/Core.Test/Pam/Commands/CreateAccessRuleCommandTests.cs b/test/Core.Test/Pam/Commands/CreateAccessRuleCommandTests.cs index 47f50bf78459..ace342843edf 100644 --- a/test/Core.Test/Pam/Commands/CreateAccessRuleCommandTests.cs +++ b/test/Core.Test/Pam/Commands/CreateAccessRuleCommandTests.cs @@ -195,6 +195,60 @@ public async Task CreateAsync_DuplicateName_ThrowsBadRequest(AccessRule rule, Ac await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().CreateAsync(default!); } + [Theory, BitAutoData] + public async Task CreateAsync_AllowsExtensionsWithoutMax_ThrowsBadRequest(AccessRule rule) + { + var sutProvider = SetupSutProvider(); + rule.Name = "extendable"; + rule.AllowsExtensions = true; + rule.MaxExtensions = null; + + var ex = await Assert.ThrowsAsync(() => sutProvider.Sut.CreateAsync(rule, [])); + Assert.Contains("Maximum extensions", ex.Message); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().CreateAsync(default!); + } + + [Theory] + [BitAutoData(0)] + [BitAutoData(-1)] + public async Task CreateAsync_AllowsExtensionsWithNonPositiveMax_ThrowsBadRequest(int maxExtensions, AccessRule rule) + { + var sutProvider = SetupSutProvider(); + rule.Name = "extendable"; + rule.AllowsExtensions = true; + rule.MaxExtensions = maxExtensions; + + var ex = await Assert.ThrowsAsync(() => sutProvider.Sut.CreateAsync(rule, [])); + Assert.Contains("Maximum extensions", ex.Message); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().CreateAsync(default!); + } + + [Theory, BitAutoData] + public async Task CreateAsync_AllowsExtensionsWithPositiveMax_Persists(AccessRule rule) + { + var sutProvider = SetupSutProvider(); + rule.Name = "extendable"; + rule.Conditions = """{"kind":"human_approval"}"""; + rule.AllowsExtensions = true; + rule.MaxExtensions = 3; + sutProvider.GetDependency() + .Validate(rule.Conditions) + .Returns(AccessRuleValidationResult.Valid); + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(rule.OrganizationId) + .Returns(new List()); + sutProvider.GetDependency() + .CreateAsync(rule) + .Returns(rule); + + var result = await sutProvider.Sut.CreateAsync(rule, []); + + Assert.True(result.AllowsExtensions); + Assert.Equal(3, result.MaxExtensions); + await sutProvider.GetDependency().Received(1) + .CreateAsync(Arg.Is(r => r.AllowsExtensions && r.MaxExtensions == 3)); + } + private static SutProvider SetupSutProvider() { var sutProvider = new SutProvider() diff --git a/test/Core.Test/Pam/Commands/RequestLeaseExtensionCommandTests.cs b/test/Core.Test/Pam/Commands/RequestLeaseExtensionCommandTests.cs new file mode 100644 index 000000000000..dccc0a52ed22 --- /dev/null +++ b/test/Core.Test/Pam/Commands/RequestLeaseExtensionCommandTests.cs @@ -0,0 +1,245 @@ +using Bit.Core.Exceptions; +using Bit.Core.Pam.Entities; +using Bit.Core.Pam.Enums; +using Bit.Core.Pam.Models; +using Bit.Core.Pam.Models.Conditions; +using Bit.Core.Pam.OrganizationFeatures.Commands; +using Bit.Core.Pam.Repositories; +using Bit.Core.Pam.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.Extensions.Time.Testing; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Pam.Commands; + +[SutProviderCustomize] +public class RequestLeaseExtensionCommandTests +{ + private static readonly DateTime _now = new(2026, 6, 12, 12, 0, 0, DateTimeKind.Utc); + private const int _maxExtensions = 3; + + [Theory, BitAutoData] + public async Task ExtendAsync_LeaseMissing_ThrowsNotFound(Guid userId, Guid leaseId) + { + var sutProvider = Setup(); + sutProvider.GetDependency().GetByIdAsync(leaseId).Returns((AccessLease?)null); + + await Assert.ThrowsAsync(() => sutProvider.Sut.ExtendAsync(userId, Submission(leaseId))); + } + + [Theory, BitAutoData] + public async Task ExtendAsync_NotOwner_ThrowsNotFound(Guid userId, AccessLease lease) + { + var sutProvider = Setup(); + SetupExtendableLease(sutProvider, lease); + + // Someone else's lease is indistinguishable from a missing one, so ids can't be probed. + await Assert.ThrowsAsync(() => sutProvider.Sut.ExtendAsync(userId, Submission(lease.Id))); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .CreateApprovedExtensionAsync(default!, default!, default, default); + } + + [Theory] + [BitAutoData(AccessLeaseStatus.Revoked)] + [BitAutoData(AccessLeaseStatus.Expired)] + public async Task ExtendAsync_LeaseNotActive_ThrowsConflict(AccessLeaseStatus status, AccessLease lease) + { + var sutProvider = Setup(); + SetupExtendableLease(sutProvider, lease); + lease.Status = status; + + await Assert.ThrowsAsync( + () => sutProvider.Sut.ExtendAsync(lease.RequesterId, Submission(lease.Id))); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .CreateApprovedExtensionAsync(default!, default!, default, default); + } + + [Theory, BitAutoData] + public async Task ExtendAsync_LeaseWindowEnded_ThrowsConflict(AccessLease lease) + { + var sutProvider = Setup(); + SetupExtendableLease(sutProvider, lease); + lease.NotAfter = _now.AddMinutes(-1); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.ExtendAsync(lease.RequesterId, Submission(lease.Id))); + } + + [Theory, BitAutoData] + public async Task ExtendAsync_ItemNotGated_ThrowsBadRequest(AccessLease lease) + { + var sutProvider = Setup(); + SetupExtendableLease(sutProvider, lease); + sutProvider.GetDependency() + .ResolveAsync(lease.RequesterId, lease.CipherId).Returns((GoverningRule?)null); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.ExtendAsync(lease.RequesterId, Submission(lease.Id))); + } + + [Theory, BitAutoData] + public async Task ExtendAsync_ExtensionsNotAllowed_ThrowsBadRequest(AccessLease lease) + { + var sutProvider = Setup(); + SetupExtendableLease(sutProvider, lease, allowsExtensions: false); + + var ex = await Assert.ThrowsAsync( + () => sutProvider.Sut.ExtendAsync(lease.RequesterId, Submission(lease.Id))); + Assert.Contains("does not allow extending", ex.Message); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .CreateApprovedExtensionAsync(default!, default!, default, default); + } + + [Theory] + [BitAutoData(0)] + [BitAutoData(-60)] + public async Task ExtendAsync_NonPositiveDuration_ThrowsBadRequest(int durationSeconds, AccessLease lease) + { + var sutProvider = Setup(); + SetupExtendableLease(sutProvider, lease); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.ExtendAsync(lease.RequesterId, Submission(lease.Id, durationSeconds))); + } + + [Theory, BitAutoData] + public async Task ExtendAsync_DurationExceedsMax_ThrowsBadRequest(AccessLease lease) + { + var sutProvider = Setup(); + SetupExtendableLease(sutProvider, lease); + + var ex = await Assert.ThrowsAsync( + () => sutProvider.Sut.ExtendAsync(lease.RequesterId, + Submission(lease.Id, SubmitAccessRequestCommand.MaxDurationSeconds + 1))); + Assert.Contains("exceeds the maximum", ex.Message); + } + + [Theory] + [BitAutoData("")] + [BitAutoData(" ")] + public async Task ExtendAsync_BlankReason_ThrowsBadRequest(string reason, AccessLease lease) + { + var sutProvider = Setup(); + SetupExtendableLease(sutProvider, lease); + + var ex = await Assert.ThrowsAsync( + () => sutProvider.Sut.ExtendAsync(lease.RequesterId, Submission(lease.Id, reason: reason))); + Assert.Contains("justification", ex.Message); + } + + [Theory, BitAutoData] + public async Task ExtendAsync_MaxExtensionsAlreadyReached_ThrowsBadRequest(AccessLease lease) + { + var sutProvider = Setup(); + SetupExtendableLease(sutProvider, lease); + sutProvider.GetDependency() + .CountExtensionsByLeaseIdAsync(lease.Id).Returns(_maxExtensions); + + var ex = await Assert.ThrowsAsync( + () => sutProvider.Sut.ExtendAsync(lease.RequesterId, Submission(lease.Id))); + Assert.Contains("maximum number of extensions", ex.Message); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .CreateApprovedExtensionAsync(default!, default!, default, default); + } + + [Theory, BitAutoData] + public async Task ExtendAsync_Valid_RecordsApprovedExtensionAndExtendsLeaseInPlace(AccessLease lease) + { + var sutProvider = Setup(); + SetupExtendableLease(sutProvider, lease); + const int duration = 2 * 60 * 60; + var expectedNotAfter = lease.NotAfter.AddSeconds(duration); + + var result = await sutProvider.Sut.ExtendAsync(lease.RequesterId, Submission(lease.Id, duration, "incident")); + + // Auto-approved extension request, pointing at the parent lease, spanning [old end .. new end]. + Assert.Equal(AccessRequestStatus.Approved, result.Status); + Assert.Equal(lease.Id, result.ExtensionOfLeaseId); + Assert.Equal(lease.CipherId, result.CipherId); + Assert.Equal(lease.OrganizationId, result.OrganizationId); + Assert.Equal(lease.CollectionId, result.CollectionId); + Assert.Equal(lease.RequesterId, result.RequesterId); + Assert.Equal(lease.NotAfter, result.NotBefore); + Assert.Equal(expectedNotAfter, result.NotAfter); + Assert.Equal("incident", result.Reason); + Assert.Equal(_now, result.ResolvedDate); + + // The repo applies the request + decision + lease bump atomically; the per-rule cap is passed through. + await sutProvider.GetDependency().Received(1).CreateApprovedExtensionAsync( + Arg.Is(r => + r.ExtensionOfLeaseId == lease.Id + && r.Status == AccessRequestStatus.Approved + && r.NotBefore == lease.NotAfter + && r.NotAfter == expectedNotAfter), + Arg.Is(d => + d.DeciderKind == AccessDeciderKind.Automatic && d.Verdict == AccessDecisionVerdict.Approve), + _maxExtensions, + _now); + } + + [Theory, BitAutoData] + public async Task ExtendAsync_RepoReportsLeaseNotActive_ThrowsConflict(AccessLease lease) + { + var sutProvider = Setup(); + SetupExtendableLease(sutProvider, lease); + sutProvider.GetDependency() + .CreateApprovedExtensionAsync(Arg.Any(), Arg.Any(), _maxExtensions, _now) + .Returns(AccessLeaseExtendOutcome.LeaseNotActive); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.ExtendAsync(lease.RequesterId, Submission(lease.Id))); + } + + [Theory, BitAutoData] + public async Task ExtendAsync_RepoReportsMaxExtensionsReached_ThrowsBadRequest(AccessLease lease) + { + var sutProvider = Setup(); + SetupExtendableLease(sutProvider, lease); + // Lost a race: another extension filled the last slot between the pre-check and the guarded write. + sutProvider.GetDependency() + .CreateApprovedExtensionAsync(Arg.Any(), Arg.Any(), _maxExtensions, _now) + .Returns(AccessLeaseExtendOutcome.MaxExtensionsReached); + + var ex = await Assert.ThrowsAsync( + () => sutProvider.Sut.ExtendAsync(lease.RequesterId, Submission(lease.Id))); + Assert.Contains("maximum number of extensions", ex.Message); + } + + private static AccessLeaseExtensionSubmission Submission( + Guid leaseId, int durationSeconds = 3600, string? reason = "Investigating an incident") => + new() { LeaseId = leaseId, DurationSeconds = durationSeconds, Reason = reason }; + + private static SutProvider Setup() + { + var sutProvider = new SutProvider().WithFakeTimeProvider().Create(); + sutProvider.GetDependency().SetUtcNow(_now); + return sutProvider; + } + + // An active, in-window lease owned by its BitAutoData requester, governed by an extension-enabled rule with no + // extensions used yet, and a repo that extends successfully. Tests override the precondition they exercise. + private static void SetupExtendableLease( + SutProvider sutProvider, AccessLease lease, bool allowsExtensions = true) + { + lease.Status = AccessLeaseStatus.Active; + lease.NotAfter = _now.AddHours(1); + sutProvider.GetDependency().GetByIdAsync(lease.Id).Returns(lease); + + // A human-approval rule still yields automatic extensions — the approval gate never applies to extensions. + sutProvider.GetDependency() + .ResolveAsync(lease.RequesterId, lease.CipherId) + .Returns(new GoverningRule(lease.OrganizationId, lease.CollectionId, RequiresHumanApproval: true, + new HumanApprovalCondition()) + { + AllowsExtensions = allowsExtensions, + MaxExtensions = _maxExtensions, + }); + + sutProvider.GetDependency().CountExtensionsByLeaseIdAsync(lease.Id).Returns(0); + sutProvider.GetDependency() + .CreateApprovedExtensionAsync(Arg.Any(), Arg.Any(), Arg.Any(), _now) + .Returns(AccessLeaseExtendOutcome.Extended); + } +} diff --git a/test/Core.Test/Pam/Commands/UpdateAccessRuleCommandTests.cs b/test/Core.Test/Pam/Commands/UpdateAccessRuleCommandTests.cs index 456022d350eb..0f23ce759931 100644 --- a/test/Core.Test/Pam/Commands/UpdateAccessRuleCommandTests.cs +++ b/test/Core.Test/Pam/Commands/UpdateAccessRuleCommandTests.cs @@ -31,6 +31,8 @@ public async Task UpdateAsync_HappyPath_UpdatesFieldsAndBumpsRevision(AccessRule update.SingleActiveLease = true; update.DefaultLeaseDurationSeconds = 3600; update.MaxLeaseDurationSeconds = 28800; + update.AllowsExtensions = true; + update.MaxExtensions = 2; sutProvider.GetDependency() .GetDetailsByIdAsync(existing.Id) .Returns(existing); @@ -49,12 +51,45 @@ public async Task UpdateAsync_HappyPath_UpdatesFieldsAndBumpsRevision(AccessRule Assert.True(result.SingleActiveLease); Assert.Equal(3600, result.DefaultLeaseDurationSeconds); Assert.Equal(28800, result.MaxLeaseDurationSeconds); + Assert.True(result.AllowsExtensions); + Assert.Equal(2, result.MaxExtensions); Assert.Equal(_now, result.RevisionDate); await sutProvider.GetDependency().Received(1) .ReplaceAsync(Arg.Is(r => r.Id == existing.Id && r.Name == "renamed" && r.Description == "new description" && r.SingleActiveLease - && r.DefaultLeaseDurationSeconds == 3600 && r.MaxLeaseDurationSeconds == 28800)); + && r.DefaultLeaseDurationSeconds == 3600 && r.MaxLeaseDurationSeconds == 28800 + && r.AllowsExtensions && r.MaxExtensions == 2)); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_AllowsExtensionsWithoutMax_ThrowsBadRequest(AccessRule update) + { + var sutProvider = SetupSutProvider(); + update.Name = "renamed"; + update.AllowsExtensions = true; + update.MaxExtensions = null; + + var ex = await Assert.ThrowsAsync( + () => sutProvider.Sut.UpdateAsync(update.OrganizationId, update.Id, update, [])); + Assert.Contains("Maximum extensions", ex.Message); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().ReplaceAsync(default!); + } + + [Theory] + [BitAutoData(0)] + [BitAutoData(-1)] + public async Task UpdateAsync_AllowsExtensionsWithNonPositiveMax_ThrowsBadRequest(int maxExtensions, AccessRule update) + { + var sutProvider = SetupSutProvider(); + update.Name = "renamed"; + update.AllowsExtensions = true; + update.MaxExtensions = maxExtensions; + + var ex = await Assert.ThrowsAsync( + () => sutProvider.Sut.UpdateAsync(update.OrganizationId, update.Id, update, [])); + Assert.Contains("Maximum extensions", ex.Message); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().ReplaceAsync(default!); } [Theory, BitAutoData] diff --git a/test/Core.Test/Pam/Queries/GetCipherAccessStateQueryTests.cs b/test/Core.Test/Pam/Queries/GetCipherAccessStateQueryTests.cs index f16b2812212e..a2557de78d6f 100644 --- a/test/Core.Test/Pam/Queries/GetCipherAccessStateQueryTests.cs +++ b/test/Core.Test/Pam/Queries/GetCipherAccessStateQueryTests.cs @@ -166,6 +166,81 @@ public async Task GetStateAsync_GatedButEmpty_ReturnsEmptySnapshot( Assert.Null(result.ApprovedRequest); } + [Theory, BitAutoData] + public async Task GetStateAsync_ActiveLease_ExtensionsAllowed_ReportsRemaining( + SutProvider sutProvider, Guid userId, Guid cipherId, Guid orgId, Guid collectionId, + AccessLease activeLease) + { + SetupCipher(sutProvider, userId, cipherId); + sutProvider.GetDependency() + .GetActiveByRequesterIdCipherIdAsync(userId, cipherId, Arg.Any()) + .Returns(activeLease); + sutProvider.GetDependency() + .ResolveAsync(userId, cipherId) + .Returns(new GoverningRule(orgId, collectionId, RequiresHumanApproval: false, new HumanApprovalCondition()) + { + AllowsExtensions = true, + MaxExtensions = 3, + }); + sutProvider.GetDependency() + .CountExtensionsByLeaseIdAsync(activeLease.Id).Returns(1); + + var result = await sutProvider.Sut.GetStateAsync(userId, cipherId); + + Assert.True(result.ExtensionsAllowed); + Assert.Equal(2, result.ExtensionsRemaining); + } + + [Theory, BitAutoData] + public async Task GetStateAsync_ActiveLease_ExtensionsExhausted_ReportsZeroRemaining( + SutProvider sutProvider, Guid userId, Guid cipherId, Guid orgId, Guid collectionId, + AccessLease activeLease) + { + SetupCipher(sutProvider, userId, cipherId); + sutProvider.GetDependency() + .GetActiveByRequesterIdCipherIdAsync(userId, cipherId, Arg.Any()) + .Returns(activeLease); + sutProvider.GetDependency() + .ResolveAsync(userId, cipherId) + .Returns(new GoverningRule(orgId, collectionId, RequiresHumanApproval: false, new HumanApprovalCondition()) + { + AllowsExtensions = true, + MaxExtensions = 2, + }); + // More used than the cap (a rule whose max was lowered after the fact) must clamp to zero, never negative. + sutProvider.GetDependency() + .CountExtensionsByLeaseIdAsync(activeLease.Id).Returns(5); + + var result = await sutProvider.Sut.GetStateAsync(userId, cipherId); + + Assert.True(result.ExtensionsAllowed); + Assert.Equal(0, result.ExtensionsRemaining); + } + + [Theory, BitAutoData] + public async Task GetStateAsync_ActiveLease_ExtensionsDisallowed_ReportsNotAllowed( + SutProvider sutProvider, Guid userId, Guid cipherId, Guid orgId, Guid collectionId, + AccessLease activeLease) + { + SetupCipher(sutProvider, userId, cipherId); + sutProvider.GetDependency() + .GetActiveByRequesterIdCipherIdAsync(userId, cipherId, Arg.Any()) + .Returns(activeLease); + sutProvider.GetDependency() + .ResolveAsync(userId, cipherId) + .Returns(new GoverningRule(orgId, collectionId, RequiresHumanApproval: false, new HumanApprovalCondition()) + { + AllowsExtensions = false, + }); + + var result = await sutProvider.Sut.GetStateAsync(userId, cipherId); + + Assert.False(result.ExtensionsAllowed); + Assert.Equal(0, result.ExtensionsRemaining); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .CountExtensionsByLeaseIdAsync(default); + } + private static void SetupCipher(SutProvider sutProvider, Guid userId, Guid cipherId) { sutProvider.GetDependency() diff --git a/test/Infrastructure.IntegrationTest/Pam/Repositories/AccessRequestExtensionRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Pam/Repositories/AccessRequestExtensionRepositoryTests.cs new file mode 100644 index 000000000000..aecffabab7e6 --- /dev/null +++ b/test/Infrastructure.IntegrationTest/Pam/Repositories/AccessRequestExtensionRepositoryTests.cs @@ -0,0 +1,209 @@ +using Bit.Core.Pam.Entities; +using Bit.Core.Pam.Enums; +using Bit.Core.Pam.Repositories; +using Bit.Core.Repositories; +using Bit.Core.Utilities; +using Bit.Infrastructure.IntegrationTest.AdminConsole; +using Xunit; + +namespace Bit.Infrastructure.IntegrationTest.Pam.Repositories; + +public class AccessRequestExtensionRepositoryTests +{ + [DatabaseTheory, DatabaseData] + public async Task CreateApprovedExtensionAsync_ExtendsLeaseInPlaceAndRecordsRequest( + IOrganizationRepository organizationRepository, + ICollectionRepository collectionRepository, + IAccessRequestRepository accessRequestRepository, + IAccessLeaseRepository accessLeaseRepository) + { + var organization = await organizationRepository.CreateTestOrganizationAsync(); + var collection = await collectionRepository.CreateTestCollectionAsync(organization); + var now = DateTime.UtcNow; + var requesterId = Guid.NewGuid(); + + var lease = await CreateActiveLeaseAsync( + accessRequestRepository, accessLeaseRepository, organization.Id, collection.Id, requesterId, now); + var newNotAfter = lease.NotAfter.AddHours(1); + + var outcome = await accessRequestRepository.CreateApprovedExtensionAsync( + BuildExtension(lease, newNotAfter, now), BuildAutoDecision(now), maxExtensions: 3, now); + + Assert.Equal(AccessLeaseExtendOutcome.Extended, outcome); + + // The parent lease's end is pushed out in place; no new lease is minted. + var updatedLease = await accessLeaseRepository.GetByIdAsync(lease.Id); + Assert.NotNull(updatedLease); + Assert.Equal(newNotAfter, updatedLease!.NotAfter); + Assert.Equal(AccessLeaseStatus.Active, updatedLease.Status); + + // The extension is recorded as an approved request pointing at the parent lease, and counts toward the cap. + Assert.Equal(1, await accessRequestRepository.CountExtensionsByLeaseIdAsync(lease.Id)); + + // An approved extension produces no lease of its own, so it must not surface as a startable approval. + Assert.Null(await accessRequestRepository.GetActiveApprovedByRequesterIdCipherIdAsync( + requesterId, lease.CipherId, now)); + } + + [DatabaseTheory, DatabaseData] + public async Task CreateApprovedExtensionAsync_MaxReached_ReturnsMaxExtensionsReached( + IOrganizationRepository organizationRepository, + ICollectionRepository collectionRepository, + IAccessRequestRepository accessRequestRepository, + IAccessLeaseRepository accessLeaseRepository) + { + var organization = await organizationRepository.CreateTestOrganizationAsync(); + var collection = await collectionRepository.CreateTestCollectionAsync(organization); + var now = DateTime.UtcNow; + var requesterId = Guid.NewGuid(); + + var lease = await CreateActiveLeaseAsync( + accessRequestRepository, accessLeaseRepository, organization.Id, collection.Id, requesterId, now); + + var firstNotAfter = lease.NotAfter.AddHours(1); + Assert.Equal(AccessLeaseExtendOutcome.Extended, await accessRequestRepository.CreateApprovedExtensionAsync( + BuildExtension(lease, firstNotAfter, now), BuildAutoDecision(now), maxExtensions: 1, now)); + + // The cap is 1 and one extension already exists, so a second is rejected and nothing is written. + var rejected = await accessRequestRepository.CreateApprovedExtensionAsync( + BuildExtension(lease, firstNotAfter.AddHours(1), now), BuildAutoDecision(now), maxExtensions: 1, now); + + Assert.Equal(AccessLeaseExtendOutcome.MaxExtensionsReached, rejected); + Assert.Equal(1, await accessRequestRepository.CountExtensionsByLeaseIdAsync(lease.Id)); + var updatedLease = await accessLeaseRepository.GetByIdAsync(lease.Id); + Assert.Equal(firstNotAfter, updatedLease!.NotAfter); + } + + [DatabaseTheory, DatabaseData] + public async Task CreateApprovedExtensionAsync_LeaseNotActive_ReturnsLeaseNotActiveAndWritesNothing( + IOrganizationRepository organizationRepository, + ICollectionRepository collectionRepository, + IAccessRequestRepository accessRequestRepository, + IAccessLeaseRepository accessLeaseRepository) + { + var organization = await organizationRepository.CreateTestOrganizationAsync(); + var collection = await collectionRepository.CreateTestCollectionAsync(organization); + var now = DateTime.UtcNow; + var requesterId = Guid.NewGuid(); + + var lease = await CreateActiveLeaseAsync( + accessRequestRepository, accessLeaseRepository, organization.Id, collection.Id, requesterId, now); + // Revoke the lease so it is no longer active. + await accessLeaseRepository.RevokeAsync(lease, BuildHumanDecision(lease.AccessRequestId, now), now); + + var extension = BuildExtension(lease, lease.NotAfter.AddHours(1), now); + var outcome = await accessRequestRepository.CreateApprovedExtensionAsync( + extension, BuildAutoDecision(now), maxExtensions: 3, now); + + Assert.Equal(AccessLeaseExtendOutcome.LeaseNotActive, outcome); + Assert.Equal(0, await accessRequestRepository.CountExtensionsByLeaseIdAsync(lease.Id)); + Assert.Null(await accessRequestRepository.GetByIdAsync(extension.Id)); + } + + [DatabaseTheory, DatabaseData] + public async Task CountExtensionsByLeaseIdAsync_CountsOnlyThatLease( + IOrganizationRepository organizationRepository, + ICollectionRepository collectionRepository, + IAccessRequestRepository accessRequestRepository, + IAccessLeaseRepository accessLeaseRepository) + { + var organization = await organizationRepository.CreateTestOrganizationAsync(); + var collection = await collectionRepository.CreateTestCollectionAsync(organization); + var now = DateTime.UtcNow; + + var leaseA = await CreateActiveLeaseAsync( + accessRequestRepository, accessLeaseRepository, organization.Id, collection.Id, Guid.NewGuid(), now); + var leaseB = await CreateActiveLeaseAsync( + accessRequestRepository, accessLeaseRepository, organization.Id, collection.Id, Guid.NewGuid(), now); + + await accessRequestRepository.CreateApprovedExtensionAsync( + BuildExtension(leaseA, leaseA.NotAfter.AddHours(1), now), BuildAutoDecision(now), maxExtensions: 5, now); + await accessRequestRepository.CreateApprovedExtensionAsync( + BuildExtension(leaseA, leaseA.NotAfter.AddHours(2), now), BuildAutoDecision(now), maxExtensions: 5, now); + + Assert.Equal(2, await accessRequestRepository.CountExtensionsByLeaseIdAsync(leaseA.Id)); + Assert.Equal(0, await accessRequestRepository.CountExtensionsByLeaseIdAsync(leaseB.Id)); + } + + private static async Task CreateActiveLeaseAsync( + IAccessRequestRepository accessRequestRepository, IAccessLeaseRepository accessLeaseRepository, + Guid organizationId, Guid collectionId, Guid requesterId, DateTime now) + { + var approved = await accessRequestRepository.CreateAsync(new AccessRequest + { + OrganizationId = organizationId, + CollectionId = collectionId, + CipherId = Guid.NewGuid(), + RequesterId = requesterId, + NotBefore = now.AddMinutes(-5), + NotAfter = now.AddHours(1), + Reason = "audit", + Status = AccessRequestStatus.Approved, + CreationDate = now, + ResolvedDate = now, + }); + + var lease = new AccessLease + { + Id = CoreHelpers.GenerateComb(), + AccessRequestId = approved.Id, + OrganizationId = approved.OrganizationId, + CollectionId = approved.CollectionId, + CipherId = approved.CipherId, + RequesterId = approved.RequesterId, + Status = AccessLeaseStatus.Active, + NotBefore = approved.NotBefore, + NotAfter = approved.NotAfter, + CreationDate = now, + }; + Assert.Equal(AccessLeaseMintOutcome.Minted, + await accessLeaseRepository.CreateFromApprovedRequestAsync(lease, now, false)); + return lease; + } + + private static AccessRequest BuildExtension(AccessLease lease, DateTime newNotAfter, DateTime now) + { + var extension = new AccessRequest + { + ExtensionOfLeaseId = lease.Id, + OrganizationId = lease.OrganizationId, + CollectionId = lease.CollectionId, + CipherId = lease.CipherId, + RequesterId = lease.RequesterId, + NotBefore = lease.NotAfter, + NotAfter = newNotAfter, + Reason = "need more time", + Status = AccessRequestStatus.Approved, + CreationDate = now, + ResolvedDate = now, + }; + extension.SetNewId(); + return extension; + } + + private static AccessDecision BuildAutoDecision(DateTime now) + { + var decision = new AccessDecision + { + DeciderKind = AccessDeciderKind.Automatic, + Verdict = AccessDecisionVerdict.Approve, + CreationDate = now, + }; + decision.SetNewId(); + return decision; + } + + private static AccessDecision BuildHumanDecision(Guid accessRequestId, DateTime now) + { + var decision = new AccessDecision + { + AccessRequestId = accessRequestId, + DeciderKind = AccessDeciderKind.Human, + ApproverId = Guid.NewGuid(), + Verdict = AccessDecisionVerdict.Deny, + CreationDate = now, + }; + decision.SetNewId(); + return decision; + } +} diff --git a/util/Migrator/DbScripts/2026-06-12_01_AddAccessRuleExtensionSettings.sql b/util/Migrator/DbScripts/2026-06-12_01_AddAccessRuleExtensionSettings.sql new file mode 100644 index 000000000000..b1343aff9f18 --- /dev/null +++ b/util/Migrator/DbScripts/2026-06-12_01_AddAccessRuleExtensionSettings.sql @@ -0,0 +1,114 @@ +-- PAM Credential Leasing: per-rule extension settings. +-- +-- AccessRule gains [AllowsExtensions] (BIT NOT NULL, default 0) and [MaxExtensions] (INT NULL). When +-- AllowsExtensions is true, a member holding an active lease under the rule may extend it (always auto-approved), +-- up to MaxExtensions times. AccessRule_Create/_Update gain matching parameters (defaulting so the procs stay +-- backward compatible). The Read procs use SELECT * and pick the new columns up automatically. +-- +-- PAM is an unshipped POC behind the pm-37044-pam-v-0 flag with no production data; server + migration deploy +-- together, so the affected procs are altered in place rather than versioned. + +IF COL_LENGTH('[dbo].[AccessRule]', 'AllowsExtensions') IS NULL +BEGIN + ALTER TABLE [dbo].[AccessRule] + ADD [AllowsExtensions] BIT NOT NULL CONSTRAINT [DF_AccessRule_AllowsExtensions] DEFAULT (0) +END +GO + +IF COL_LENGTH('[dbo].[AccessRule]', 'MaxExtensions') IS NULL +BEGIN + ALTER TABLE [dbo].[AccessRule] + ADD [MaxExtensions] INT NULL +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[AccessRule_Create] + @Id UNIQUEIDENTIFIER OUTPUT, + @OrganizationId UNIQUEIDENTIFIER, + @Name NVARCHAR(256), + @Description NVARCHAR(MAX) = NULL, + @Conditions NVARCHAR(MAX), + @SingleActiveLease BIT = 0, + @DefaultLeaseDurationSeconds INT = NULL, + @MaxLeaseDurationSeconds INT = NULL, + @Enabled BIT = 1, + @AllowsExtensions BIT = 0, + @MaxExtensions INT = NULL, + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + INSERT INTO [dbo].[AccessRule] + ( + [Id], + [OrganizationId], + [Name], + [Description], + [Conditions], + [SingleActiveLease], + [DefaultLeaseDurationSeconds], + [MaxLeaseDurationSeconds], + [Enabled], + [AllowsExtensions], + [MaxExtensions], + [CreationDate], + [RevisionDate] + ) + VALUES + ( + @Id, + @OrganizationId, + @Name, + @Description, + @Conditions, + @SingleActiveLease, + @DefaultLeaseDurationSeconds, + @MaxLeaseDurationSeconds, + @Enabled, + @AllowsExtensions, + @MaxExtensions, + @CreationDate, + @RevisionDate + ) +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[AccessRule_Update] + @Id UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @Name NVARCHAR(256), + @Description NVARCHAR(MAX) = NULL, + @Conditions NVARCHAR(MAX), + @SingleActiveLease BIT = 0, + @DefaultLeaseDurationSeconds INT = NULL, + @MaxLeaseDurationSeconds INT = NULL, + @Enabled BIT = 1, + @AllowsExtensions BIT = 0, + @MaxExtensions INT = NULL, + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + UPDATE + [dbo].[AccessRule] + SET + [OrganizationId] = @OrganizationId, + [Name] = @Name, + [Description] = @Description, + [Conditions] = @Conditions, + [SingleActiveLease] = @SingleActiveLease, + [DefaultLeaseDurationSeconds] = @DefaultLeaseDurationSeconds, + [MaxLeaseDurationSeconds] = @MaxLeaseDurationSeconds, + [Enabled] = @Enabled, + [AllowsExtensions] = @AllowsExtensions, + [MaxExtensions] = @MaxExtensions, + [CreationDate] = @CreationDate, + [RevisionDate] = @RevisionDate + WHERE + [Id] = @Id +END +GO diff --git a/util/Migrator/DbScripts/2026-06-12_02_AddLeaseExtension.sql b/util/Migrator/DbScripts/2026-06-12_02_AddLeaseExtension.sql new file mode 100644 index 000000000000..d120ee1e5210 --- /dev/null +++ b/util/Migrator/DbScripts/2026-06-12_02_AddLeaseExtension.sql @@ -0,0 +1,125 @@ +-- PAM Credential Leasing: lease extension. +-- +-- A member holding an active lease on an extension-enabled item (see AccessRule.AllowsExtensions / MaxExtensions) +-- may extend it. Extensions are always auto-approved: an already-Approved AccessRequest with a non-NULL +-- ExtensionOfLeaseId is recorded together with its automatic AccessDecision, and the parent lease's NotAfter is +-- pushed out in place — no new lease is minted, so the single-active-lease invariant is preserved. +-- +-- * AccessRequest_CreateApprovedExtension - atomic, per-lease-locked: guards the lease is active and the +-- per-rule max has not been reached, then writes the request + +-- decision and extends the lease. +-- * AccessRequest_CountExtensionsByLeaseId - extension count for a lease (cap pre-check + UI "remaining"). +-- * AccessRequest_ReadActiveApprovedByRequesterIdCipherId - now excludes extension requests, which extend the +-- parent lease in place and must never surface as activatable. +-- +-- PAM is an unshipped POC behind the pm-37044-pam-v-0 flag with no production data; server + migration deploy +-- together, so the affected proc is altered in place rather than versioned. + +CREATE OR ALTER PROCEDURE [dbo].[AccessRequest_CreateApprovedExtension] + @AccessRequestId UNIQUEIDENTIFIER, + @AccessDecisionId UNIQUEIDENTIFIER, + @ExtensionOfLeaseId UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @CollectionId UNIQUEIDENTIFIER, + @CipherId UNIQUEIDENTIFIER, + @RequesterId UNIQUEIDENTIFIER, + @NotBefore DATETIME2(7), + @NotAfter DATETIME2(7), + @Reason NVARCHAR(MAX) = NULL, + @MaxExtensions INT, + @Now DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + SET XACT_ABORT ON + + BEGIN TRANSACTION + + IF NOT EXISTS ( + SELECT 1 + FROM [dbo].[AccessLease] WITH (UPDLOCK, HOLDLOCK) + WHERE [Id] = @ExtensionOfLeaseId + AND [RequesterId] = @RequesterId + AND [Status] = 0 /* Active */ + AND [NotAfter] > @Now + ) + BEGIN + ROLLBACK TRANSACTION + SELECT 0 -- LeaseNotActive + RETURN + END + + IF (SELECT COUNT(*) FROM [dbo].[AccessRequest] WHERE [ExtensionOfLeaseId] = @ExtensionOfLeaseId) >= @MaxExtensions + BEGIN + ROLLBACK TRANSACTION + SELECT -1 -- MaxExtensionsReached + RETURN + END + + INSERT INTO [dbo].[AccessRequest] + ( + [Id], [ExtensionOfLeaseId], [OrganizationId], [CollectionId], [CipherId], [RequesterId], + [NotBefore], [NotAfter], [Reason], [Status], [CreationDate], [ResolvedDate] + ) + VALUES + ( + @AccessRequestId, @ExtensionOfLeaseId, @OrganizationId, @CollectionId, @CipherId, @RequesterId, + @NotBefore, @NotAfter, @Reason, 1 /* Approved */, @Now, @Now + ) + + INSERT INTO [dbo].[AccessDecision] + ( + [Id], [AccessRequestId], [DeciderKind], [ApproverId], [ConditionKind], + [Verdict], [Comment], [EvaluationContext], [CreationDate] + ) + VALUES + ( + @AccessDecisionId, @AccessRequestId, 0 /* Automatic */, NULL, NULL, + 0 /* Approve */, NULL, NULL, @Now + ) + + UPDATE [dbo].[AccessLease] + SET [NotAfter] = @NotAfter + WHERE [Id] = @ExtensionOfLeaseId + + COMMIT TRANSACTION + + SELECT 1 -- Extended +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[AccessRequest_CountExtensionsByLeaseId] + @LeaseId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT COUNT(*) + FROM [dbo].[AccessRequest] + WHERE [ExtensionOfLeaseId] = @LeaseId +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[AccessRequest_ReadActiveApprovedByRequesterIdCipherId] + @RequesterId UNIQUEIDENTIFIER, + @CipherId UNIQUEIDENTIFIER, + @Now DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + SELECT TOP 1 + AR.* + FROM + [dbo].[AccessRequest] AR + WHERE + AR.[RequesterId] = @RequesterId + AND AR.[CipherId] = @CipherId + AND AR.[Status] = 1 -- Approved + AND AR.[NotAfter] > @Now + AND AR.[ExtensionOfLeaseId] IS NULL + AND NOT EXISTS (SELECT 1 FROM [dbo].[AccessLease] AL WHERE AL.[AccessRequestId] = AR.[Id]) + ORDER BY + AR.[CreationDate] DESC +END +GO From 390b856b1cff3faa63d17ad8a26311eb147c6199 Mon Sep 17 00:00:00 2001 From: Patrik Svensson Date: Mon, 15 Jun 2026 14:58:06 +0200 Subject: [PATCH 27/54] Refine PAM lease extension: single extension + admin-set max length Per updated product direction, a lease may now be extended exactly once, and the admin caps the length of that extension instead of a count. The AccessRule setting MaxExtensions (a count) becomes MaxExtensionDurationSeconds (the longest a single extension may run); the member picks any duration up to that maximum. RequestLeaseExtensionCommand caps the requested duration at the rule's MaxExtensionDurationSeconds and rejects a second extension (the outcome MaxExtensionsReached becomes AlreadyExtended; AccessRequest_CreateApprovedExtension drops its @MaxExtensions parameter and guards on EXISTS instead of a count). The cipher access-state snapshot now reports ExtensionsAllowed (the rule opts in and the lease has not been extended yet) plus MaxExtensionDurationSeconds so the client can cap its duration picker. Migration 2026-06-15_00 drops MaxExtensions, adds MaxExtensionDurationSeconds, and recreates the affected procs, leaving 2026-06-12_01/02 intact so existing dev databases roll forward without a reset. --- .../Models/Request/AccessRuleRequestModel.cs | 10 +- .../Response/AccessRuleResponseModel.cs | 4 +- .../CipherAccessStateResponseModel.cs | 8 +- src/Core/Pam/Entities/AccessRule.cs | 10 +- .../Pam/Enums/AccessLeaseExtendOutcome.cs | 8 +- .../Models/AccessLeaseExtensionSubmission.cs | 2 +- src/Core/Pam/Models/AccessRuleDetails.cs | 2 +- src/Core/Pam/Models/CipherAccessState.cs | 7 +- src/Core/Pam/Models/GoverningRule.cs | 8 +- .../Commands/CreateAccessRuleCommand.cs | 4 +- .../IRequestLeaseExtensionCommand.cs | 10 +- .../Commands/RequestLeaseExtensionCommand.cs | 25 ++- .../Commands/UpdateAccessRuleCommand.cs | 6 +- .../Queries/GetCipherAccessStateQuery.cs | 13 +- .../Repositories/IAccessRequestRepository.cs | 10 +- .../Pam/Services/GoverningRuleResolver.cs | 4 +- .../Repositories/AccessRequestRepository.cs | 3 +- .../AccessRequest_CreateApprovedExtension.sql | 9 +- .../Stored Procedures/AccessRule_Create.sql | 6 +- .../Stored Procedures/AccessRule_Update.sql | 4 +- src/Sql/dbo/Pam/Tables/AccessRule.sql | 2 +- .../Commands/CreateAccessRuleCommandTests.cs | 16 +- .../RequestLeaseExtensionCommandTests.cs | 46 ++--- .../Commands/UpdateAccessRuleCommandTests.cs | 16 +- .../Queries/GetCipherAccessStateQueryTests.cs | 22 +-- .../AccessRequestExtensionRepositoryTests.cs | 23 ++- ...ameMaxExtensionsToMaxExtensionDuration.sql | 187 ++++++++++++++++++ 27 files changed, 325 insertions(+), 140 deletions(-) create mode 100644 util/Migrator/DbScripts/2026-06-15_00_RenameMaxExtensionsToMaxExtensionDuration.sql diff --git a/src/Api/Pam/Models/Request/AccessRuleRequestModel.cs b/src/Api/Pam/Models/Request/AccessRuleRequestModel.cs index 926c9b9469fc..91ed87d40a81 100644 --- a/src/Api/Pam/Models/Request/AccessRuleRequestModel.cs +++ b/src/Api/Pam/Models/Request/AccessRuleRequestModel.cs @@ -39,16 +39,16 @@ public class AccessRuleRequestModel public bool Enabled { get; set; } = true; /// - /// When true, a member holding an active lease under this rule may extend it (always auto-approved), up to - /// times. + /// When true, a member holding an active lease under this rule may extend it once (always auto-approved), by up + /// to . /// public bool AllowsExtensions { get; set; } /// - /// The maximum number of times a single lease may be extended. Required to be positive when + /// The longest a single extension may run, in seconds. Required to be positive when /// is true. /// - public int? MaxExtensions { get; set; } + public int? MaxExtensionDurationSeconds { get; set; } /// /// The complete set of collections this rule governs. The rule's associations are replaced to match @@ -68,7 +68,7 @@ public class AccessRuleRequestModel MaxLeaseDurationSeconds = MaxLeaseDurationSeconds, Enabled = Enabled, AllowsExtensions = AllowsExtensions, - MaxExtensions = MaxExtensions, + MaxExtensionDurationSeconds = MaxExtensionDurationSeconds, }; private static string SerializeConditions(object conditions) => conditions switch diff --git a/src/Api/Pam/Models/Response/AccessRuleResponseModel.cs b/src/Api/Pam/Models/Response/AccessRuleResponseModel.cs index df36a90be8ca..f5e50bb3e2a8 100644 --- a/src/Api/Pam/Models/Response/AccessRuleResponseModel.cs +++ b/src/Api/Pam/Models/Response/AccessRuleResponseModel.cs @@ -21,7 +21,7 @@ public AccessRuleResponseModel(AccessRuleDetails rule) MaxLeaseDurationSeconds = rule.MaxLeaseDurationSeconds; Enabled = rule.Enabled; AllowsExtensions = rule.AllowsExtensions; - MaxExtensions = rule.MaxExtensions; + MaxExtensionDurationSeconds = rule.MaxExtensionDurationSeconds; CreationDate = rule.CreationDate.AsUtc(); RevisionDate = rule.RevisionDate.AsUtc(); Collections = rule.CollectionIds.ToList(); @@ -37,7 +37,7 @@ public AccessRuleResponseModel(AccessRuleDetails rule) public int? MaxLeaseDurationSeconds { get; } public bool Enabled { get; } public bool AllowsExtensions { get; } - public int? MaxExtensions { get; } + public int? MaxExtensionDurationSeconds { get; } public DateTime CreationDate { get; } public DateTime RevisionDate { get; } public IEnumerable Collections { get; } diff --git a/src/Api/Pam/Models/Response/CipherAccessStateResponseModel.cs b/src/Api/Pam/Models/Response/CipherAccessStateResponseModel.cs index acb611b5df6c..bf66ca6d7927 100644 --- a/src/Api/Pam/Models/Response/CipherAccessStateResponseModel.cs +++ b/src/Api/Pam/Models/Response/CipherAccessStateResponseModel.cs @@ -20,7 +20,7 @@ public CipherAccessStateResponseModel(CipherAccessState state) PendingRequest = state.PendingRequest is null ? null : new AccessRequestDetailsResponseModel(state.PendingRequest); ApprovedRequest = state.ApprovedRequest is null ? null : new AccessRequestDetailsResponseModel(state.ApprovedRequest); ExtensionsAllowed = state.ExtensionsAllowed; - ExtensionsRemaining = state.ExtensionsRemaining; + MaxExtensionDurationSeconds = state.MaxExtensionDurationSeconds; } public Guid CipherId { get; } @@ -34,9 +34,9 @@ public CipherAccessStateResponseModel(CipherAccessState state) /// public AccessRequestDetailsResponseModel? ApprovedRequest { get; } - /// Whether the active lease's governing rule permits extending it. + /// Whether the active lease can still be extended (the rule opts in and it has not been extended yet). public bool ExtensionsAllowed { get; } - /// How many extensions remain for the active lease (0 when none remain or there is no active lease). - public int ExtensionsRemaining { get; } + /// The longest a single extension of the active lease may run, in seconds; null when there is no cap or no active lease. + public int? MaxExtensionDurationSeconds { get; } } diff --git a/src/Core/Pam/Entities/AccessRule.cs b/src/Core/Pam/Entities/AccessRule.cs index 6c1cdcd7a7ab..8fec55209de0 100644 --- a/src/Core/Pam/Entities/AccessRule.cs +++ b/src/Core/Pam/Entities/AccessRule.cs @@ -51,16 +51,16 @@ public class AccessRule : ITableObject public bool Enabled { get; set; } = true; /// - /// When true, a member holding an active lease under this rule may extend it. Extensions are always - /// auto-approved (regardless of the rule's approval conditions), up to times. + /// When true, a member holding an active lease under this rule may extend it once. Extensions are always + /// auto-approved (regardless of the rule's approval conditions). /// public bool AllowsExtensions { get; set; } /// - /// The maximum number of times a single lease granted under this rule may be extended. Required to be a - /// positive value when is true; ignored otherwise. + /// The longest a single extension granted under this rule may run, in seconds. Required to be a positive value + /// when is true; ignored otherwise. /// - public int? MaxExtensions { get; set; } + public int? MaxExtensionDurationSeconds { get; set; } public DateTime CreationDate { get; set; } = DateTime.UtcNow; public DateTime RevisionDate { get; set; } = DateTime.UtcNow; diff --git a/src/Core/Pam/Enums/AccessLeaseExtendOutcome.cs b/src/Core/Pam/Enums/AccessLeaseExtendOutcome.cs index 5c07c9b563a2..545d97b9743b 100644 --- a/src/Core/Pam/Enums/AccessLeaseExtendOutcome.cs +++ b/src/Core/Pam/Enums/AccessLeaseExtendOutcome.cs @@ -2,7 +2,7 @@ /// /// The result of a race-safe lease extension. The extension stored procedure returns a distinct integer code so the -/// caller can tell a lease that is no longer extendable apart from a per-lease max-extensions conflict. +/// caller can tell a lease that is no longer extendable apart from one that has already been extended. /// public enum AccessLeaseExtendOutcome { @@ -16,8 +16,8 @@ public enum AccessLeaseExtendOutcome LeaseNotActive = 0, /// - /// The lease has already been extended the maximum number of times permitted by its governing rule (stored proc - /// returned -1). Nothing was persisted. + /// The lease has already been extended (a lease may be extended once; stored proc returned -1). Nothing was + /// persisted. /// - MaxExtensionsReached = -1, + AlreadyExtended = -1, } diff --git a/src/Core/Pam/Models/AccessLeaseExtensionSubmission.cs b/src/Core/Pam/Models/AccessLeaseExtensionSubmission.cs index 39765b82dee0..47e0199cbea7 100644 --- a/src/Core/Pam/Models/AccessLeaseExtensionSubmission.cs +++ b/src/Core/Pam/Models/AccessLeaseExtensionSubmission.cs @@ -2,7 +2,7 @@ /// /// A request to extend an active lease. Extensions are always auto-approved, subject to the governing rule's -/// AllowsExtensions / MaxExtensions settings: the lease's end is pushed out by +/// AllowsExtensions / MaxExtensionDurationSeconds settings: the lease's end is pushed out by /// in place (no new lease is minted), and a justifying is required. /// public sealed class AccessLeaseExtensionSubmission diff --git a/src/Core/Pam/Models/AccessRuleDetails.cs b/src/Core/Pam/Models/AccessRuleDetails.cs index a2bebd5fdad2..c178e475ad8e 100644 --- a/src/Core/Pam/Models/AccessRuleDetails.cs +++ b/src/Core/Pam/Models/AccessRuleDetails.cs @@ -21,7 +21,7 @@ public class AccessRuleDetails : AccessRule MaxLeaseDurationSeconds = rule.MaxLeaseDurationSeconds, Enabled = rule.Enabled, AllowsExtensions = rule.AllowsExtensions, - MaxExtensions = rule.MaxExtensions, + MaxExtensionDurationSeconds = rule.MaxExtensionDurationSeconds, CreationDate = rule.CreationDate, RevisionDate = rule.RevisionDate, CollectionIds = collectionIds, diff --git a/src/Core/Pam/Models/CipherAccessState.cs b/src/Core/Pam/Models/CipherAccessState.cs index dfd8a7fcec93..90bc9281e91b 100644 --- a/src/Core/Pam/Models/CipherAccessState.cs +++ b/src/Core/Pam/Models/CipherAccessState.cs @@ -6,8 +6,9 @@ namespace Bit.Core.Pam.Models; /// The caller's access state for a single cipher: the active lease they hold (if any), their pending request (if /// any), and their approved-but-not-yet-activated request (if any). Approval no longer mints the lease, so the /// approved request is the startable state in between — the caller activates it to produce the active lease. -/// ExtensionsAllowed / ExtensionsRemaining describe whether the active lease can be extended and how many extensions -/// remain, so the banner can gate its "Extend" control. +/// ExtensionsAllowed says whether the active lease can still be extended (the rule opts in and it has not been +/// extended yet — a lease may be extended once); MaxExtensionDurationSeconds is the longest a single extension may +/// run, so the banner can gate its "Extend" control and cap the duration picker. /// public record CipherAccessState( Guid CipherId, @@ -15,4 +16,4 @@ public record CipherAccessState( AccessRequestDetails? PendingRequest, AccessRequestDetails? ApprovedRequest, bool ExtensionsAllowed = false, - int ExtensionsRemaining = 0); + int? MaxExtensionDurationSeconds = null); diff --git a/src/Core/Pam/Models/GoverningRule.cs b/src/Core/Pam/Models/GoverningRule.cs index 9a6f0d1354be..c25de2b26aee 100644 --- a/src/Core/Pam/Models/GoverningRule.cs +++ b/src/Core/Pam/Models/GoverningRule.cs @@ -15,14 +15,14 @@ public sealed record GoverningRule( AccessCondition Condition) { /// - /// When true, a member holding an active lease under this rule may extend it (always auto-approved), up to - /// times. + /// When true, a member holding an active lease under this rule may extend it once (always auto-approved), by up + /// to . /// public bool AllowsExtensions { get; init; } /// - /// The maximum number of times a single lease under this rule may be extended; meaningful only when + /// The longest a single extension under this rule may run, in seconds; meaningful only when /// is true. /// - public int? MaxExtensions { get; init; } + public int? MaxExtensionDurationSeconds { get; init; } } diff --git a/src/Core/Pam/OrganizationFeatures/Commands/CreateAccessRuleCommand.cs b/src/Core/Pam/OrganizationFeatures/Commands/CreateAccessRuleCommand.cs index 60c78e930219..6b634a8f45e8 100644 --- a/src/Core/Pam/OrganizationFeatures/Commands/CreateAccessRuleCommand.cs +++ b/src/Core/Pam/OrganizationFeatures/Commands/CreateAccessRuleCommand.cs @@ -35,9 +35,9 @@ public async Task CreateAsync(AccessRule rule, IEnumerable 0) + if (rule.AllowsExtensions && rule.MaxExtensionDurationSeconds is not > 0) { - throw new BadRequestException("Maximum extensions must be a positive number when extensions are allowed."); + throw new BadRequestException("A maximum extension length is required when extensions are allowed."); } var validation = _validator.Validate(rule.Conditions); diff --git a/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IRequestLeaseExtensionCommand.cs b/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IRequestLeaseExtensionCommand.cs index 4dd48dc5ef69..9e05b42d0d69 100644 --- a/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IRequestLeaseExtensionCommand.cs +++ b/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IRequestLeaseExtensionCommand.cs @@ -6,9 +6,9 @@ public interface IRequestLeaseExtensionCommand { /// /// Extends the caller's active lease by the requested duration. Extensions are always auto-approved, subject to - /// the governing rule's AllowsExtensions / MaxExtensions settings: the lease's end is pushed out in - /// place (no new lease is minted) and an auto-approved extension request is recorded. Only the lease's requester - /// may extend it. + /// the governing rule's AllowsExtensions / MaxExtensionDurationSeconds settings: the lease's end is + /// pushed out in place (no new lease is minted) and an auto-approved extension request is recorded. Only the + /// lease's requester may extend it. /// /// /// The lease does not exist or the caller is not its requester. @@ -17,8 +17,8 @@ public interface IRequestLeaseExtensionCommand /// The lease is no longer active (revoked or expired). /// /// - /// The item is not lease-gated or does not allow extensions, the per-lease maximum has been reached, the duration - /// is non-positive or exceeds the maximum, or no justification was supplied. + /// The item is not lease-gated or does not allow extensions, the lease has already been extended, the duration is + /// non-positive or exceeds the maximum extension length, or no justification was supplied. /// Task ExtendAsync(Guid userId, AccessLeaseExtensionSubmission submission); } diff --git a/src/Core/Pam/OrganizationFeatures/Commands/RequestLeaseExtensionCommand.cs b/src/Core/Pam/OrganizationFeatures/Commands/RequestLeaseExtensionCommand.cs index 98a6485c2cbb..23549f54b712 100644 --- a/src/Core/Pam/OrganizationFeatures/Commands/RequestLeaseExtensionCommand.cs +++ b/src/Core/Pam/OrganizationFeatures/Commands/RequestLeaseExtensionCommand.cs @@ -62,10 +62,11 @@ public async Task ExtendAsync(Guid userId, AccessLeaseExte throw new BadRequestException("A positive duration is required."); } - if (submission.DurationSeconds > SubmitAccessRequestCommand.MaxDurationSeconds) + // The rule's max extension length is the cap (the admin picks it from presets); it is always set when + // AllowsExtensions is true. A missing cap is treated as zero so a misconfigured rule denies. + if (submission.DurationSeconds > (governingRule.MaxExtensionDurationSeconds ?? 0)) { - throw new BadRequestException( - $"The requested duration exceeds the maximum of {SubmitAccessRequestCommand.MaxDurationSeconds} seconds."); + throw new BadRequestException("The requested duration exceeds the maximum extension length for this item."); } if (string.IsNullOrWhiteSpace(submission.Reason)) @@ -73,15 +74,11 @@ public async Task ExtendAsync(Guid userId, AccessLeaseExte throw new BadRequestException("A justification is required to extend a lease."); } - // MaxExtensions is guaranteed positive when AllowsExtensions is true (enforced on rule create/update); a - // missing cap is treated as zero so a misconfigured rule denies rather than grants unbounded extensions. - var maxExtensions = governingRule.MaxExtensions ?? 0; - - // Friendly early check; the mint proc re-counts under a per-lease lock and is the race-safe authority. - var priorExtensions = await _accessRequestRepository.CountExtensionsByLeaseIdAsync(lease.Id); - if (priorExtensions >= maxExtensions) + // A lease may be extended exactly once. Friendly early check; the mint proc re-counts under a per-lease lock + // and is the race-safe authority. + if (await _accessRequestRepository.CountExtensionsByLeaseIdAsync(lease.Id) >= 1) { - throw new BadRequestException("This lease has reached the maximum number of extensions."); + throw new BadRequestException("This lease has already been extended."); } // The extension window spans from the lease's current end to its new end; NotAfter is the lease's new end. @@ -110,14 +107,14 @@ public async Task ExtendAsync(Guid userId, AccessLeaseExte }; decision.SetNewId(); - var outcome = await _accessRequestRepository.CreateApprovedExtensionAsync(request, decision, maxExtensions, now); + var outcome = await _accessRequestRepository.CreateApprovedExtensionAsync(request, decision, now); switch (outcome) { case AccessLeaseExtendOutcome.LeaseNotActive: throw new ConflictException("This lease is no longer active."); - case AccessLeaseExtendOutcome.MaxExtensionsReached: - throw new BadRequestException("This lease has reached the maximum number of extensions."); + case AccessLeaseExtendOutcome.AlreadyExtended: + throw new BadRequestException("This lease has already been extended."); } // Project the approved-extension state the client renders (Status approved + ExtensionOfLeaseId set) from diff --git a/src/Core/Pam/OrganizationFeatures/Commands/UpdateAccessRuleCommand.cs b/src/Core/Pam/OrganizationFeatures/Commands/UpdateAccessRuleCommand.cs index af619f2f957a..709390693347 100644 --- a/src/Core/Pam/OrganizationFeatures/Commands/UpdateAccessRuleCommand.cs +++ b/src/Core/Pam/OrganizationFeatures/Commands/UpdateAccessRuleCommand.cs @@ -35,9 +35,9 @@ public async Task UpdateAsync(Guid organizationId, Guid id, A throw new BadRequestException("Name is required."); } - if (update.AllowsExtensions && update.MaxExtensions is not > 0) + if (update.AllowsExtensions && update.MaxExtensionDurationSeconds is not > 0) { - throw new BadRequestException("Maximum extensions must be a positive number when extensions are allowed."); + throw new BadRequestException("A maximum extension length is required when extensions are allowed."); } var existing = await _repository.GetDetailsByIdAsync(id); @@ -74,7 +74,7 @@ public async Task UpdateAsync(Guid organizationId, Guid id, A MaxLeaseDurationSeconds = update.MaxLeaseDurationSeconds, Enabled = update.Enabled, AllowsExtensions = update.AllowsExtensions, - MaxExtensions = update.MaxExtensions, + MaxExtensionDurationSeconds = update.MaxExtensionDurationSeconds, CreationDate = existing.CreationDate, RevisionDate = _timeProvider.GetUtcNow().UtcDateTime, }; diff --git a/src/Core/Pam/OrganizationFeatures/Queries/GetCipherAccessStateQuery.cs b/src/Core/Pam/OrganizationFeatures/Queries/GetCipherAccessStateQuery.cs index 759353f1b9d5..296f25ea8c91 100644 --- a/src/Core/Pam/OrganizationFeatures/Queries/GetCipherAccessStateQuery.cs +++ b/src/Core/Pam/OrganizationFeatures/Queries/GetCipherAccessStateQuery.cs @@ -45,17 +45,18 @@ public async Task GetStateAsync(Guid userId, Guid cipherId) var approved = await _accessRequestRepository.GetActiveApprovedByRequesterIdCipherIdAsync(userId, cipherId, now); var extensionsAllowed = false; - var extensionsRemaining = 0; + int? maxExtensionDurationSeconds = null; if (activeLease is not null) { - // Extension eligibility drives the banner's "Extend" control. Resolve the governing rule for the active - // lease and, when it opts in, report how many of its per-lease extensions remain. + // Extension eligibility drives the banner's "Extend" control. A lease may be extended once, so it is + // extendable only while the rule opts in and no extension has been recorded yet; surface the rule's max + // length so the client can cap its duration picker. var rule = await _resolver.ResolveAsync(userId, cipherId); if (rule?.AllowsExtensions == true) { - extensionsAllowed = true; var used = await _accessRequestRepository.CountExtensionsByLeaseIdAsync(activeLease.Id); - extensionsRemaining = Math.Max(0, (rule.MaxExtensions ?? 0) - used); + extensionsAllowed = used == 0; + maxExtensionDurationSeconds = rule.MaxExtensionDurationSeconds; } } else if (pending is null && approved is null && await _resolver.ResolveAsync(userId, cipherId) is null) @@ -71,7 +72,7 @@ public async Task GetStateAsync(Guid userId, Guid cipherId) pending is null ? null : ToDetails(pending), approved is null ? null : ToDetails(approved), extensionsAllowed, - extensionsRemaining); + maxExtensionDurationSeconds); } // Neither a pending nor an approved-unactivated request has produced a lease (the approved read excludes diff --git a/src/Core/Pam/Repositories/IAccessRequestRepository.cs b/src/Core/Pam/Repositories/IAccessRequestRepository.cs index 001d1300914d..10167ef65ddc 100644 --- a/src/Core/Pam/Repositories/IAccessRequestRepository.cs +++ b/src/Core/Pam/Repositories/IAccessRequestRepository.cs @@ -78,8 +78,8 @@ public interface IAccessRequestRepository Task CancelWithDecisionAsync(AccessRequest request, AccessDecision decision, DateTime now); /// - /// Returns the number of extension requests recorded against the lease. Extensions are always auto-approved, so - /// every such request counts toward the governing rule's per-lease maximum. + /// Returns the number of extension requests recorded against the lease (a lease may be extended once, so this is + /// 0 or 1). Used to gate whether another extension is allowed. /// Task CountExtensionsByLeaseIdAsync(Guid leaseId); @@ -87,11 +87,11 @@ public interface IAccessRequestRepository /// Atomically records an auto-approved extension request (with its automatic decision) and pushes the parent /// lease's end out to the request's NotAfter, all under a per-lease lock. Returns /// when the lease is no longer active or its window has - /// ended, or when - /// has already been reached; otherwise . Both supplied entities + /// ended, or when the lease has already been extended (a + /// lease may be extended once); otherwise . Both supplied entities /// must already have their ids assigned, and the request's ExtensionOfLeaseId identifies the lease being /// extended. /// Task CreateApprovedExtensionAsync(AccessRequest request, AccessDecision decision, - int maxExtensions, DateTime now); + DateTime now); } diff --git a/src/Core/Pam/Services/GoverningRuleResolver.cs b/src/Core/Pam/Services/GoverningRuleResolver.cs index 00ea698d8b5e..1dd2e82dd27f 100644 --- a/src/Core/Pam/Services/GoverningRuleResolver.cs +++ b/src/Core/Pam/Services/GoverningRuleResolver.cs @@ -60,14 +60,14 @@ public GoverningRuleResolver( return new GoverningRule(collection.OrganizationId, collection.Id, true, condition) { AllowsExtensions = accessRule.AllowsExtensions, - MaxExtensions = accessRule.MaxExtensions, + MaxExtensionDurationSeconds = accessRule.MaxExtensionDurationSeconds, }; } automatic ??= new GoverningRule(collection.OrganizationId, collection.Id, false, condition) { AllowsExtensions = accessRule.AllowsExtensions, - MaxExtensions = accessRule.MaxExtensions, + MaxExtensionDurationSeconds = accessRule.MaxExtensionDurationSeconds, }; } diff --git a/src/Infrastructure.Dapper/Pam/Repositories/AccessRequestRepository.cs b/src/Infrastructure.Dapper/Pam/Repositories/AccessRequestRepository.cs index 929afc68669c..cae7cd7b1d96 100644 --- a/src/Infrastructure.Dapper/Pam/Repositories/AccessRequestRepository.cs +++ b/src/Infrastructure.Dapper/Pam/Repositories/AccessRequestRepository.cs @@ -165,7 +165,7 @@ public async Task CountExtensionsByLeaseIdAsync(Guid leaseId) } public async Task CreateApprovedExtensionAsync(AccessRequest request, - AccessDecision decision, int maxExtensions, DateTime now) + AccessDecision decision, DateTime now) { await using var connection = new SqlConnection(ConnectionString); var result = await connection.ExecuteScalarAsync( @@ -182,7 +182,6 @@ public async Task CreateApprovedExtensionAsync(AccessR request.NotBefore, request.NotAfter, request.Reason, - MaxExtensions = maxExtensions, Now = now, }, commandType: CommandType.StoredProcedure); diff --git a/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_CreateApprovedExtension.sql b/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_CreateApprovedExtension.sql index 7780c9aa6634..c1eb4f821e52 100644 --- a/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_CreateApprovedExtension.sql +++ b/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_CreateApprovedExtension.sql @@ -9,7 +9,6 @@ CREATE PROCEDURE [dbo].[AccessRequest_CreateApprovedExtension] @NotBefore DATETIME2(7), @NotAfter DATETIME2(7), @Reason NVARCHAR(MAX) = NULL, - @MaxExtensions INT, @Now DATETIME2(7) AS BEGIN @@ -37,12 +36,12 @@ BEGIN RETURN END - -- Per-lease extension cap. Every extension request against this lease is auto-approved, so every one counts. - -- Counted under the lease lock, so it is race-safe against a concurrent extension of the same lease. - IF (SELECT COUNT(*) FROM [dbo].[AccessRequest] WHERE [ExtensionOfLeaseId] = @ExtensionOfLeaseId) >= @MaxExtensions + -- A lease may be extended exactly once. Counted under the lease lock, so it is race-safe against a concurrent + -- extension of the same lease. + IF EXISTS (SELECT 1 FROM [dbo].[AccessRequest] WHERE [ExtensionOfLeaseId] = @ExtensionOfLeaseId) BEGIN ROLLBACK TRANSACTION - SELECT -1 -- MaxExtensionsReached + SELECT -1 -- AlreadyExtended RETURN END diff --git a/src/Sql/dbo/Pam/Stored Procedures/AccessRule_Create.sql b/src/Sql/dbo/Pam/Stored Procedures/AccessRule_Create.sql index e5d627821f2b..ef3f8bf7a6de 100644 --- a/src/Sql/dbo/Pam/Stored Procedures/AccessRule_Create.sql +++ b/src/Sql/dbo/Pam/Stored Procedures/AccessRule_Create.sql @@ -9,7 +9,7 @@ CREATE PROCEDURE [dbo].[AccessRule_Create] @MaxLeaseDurationSeconds INT = NULL, @Enabled BIT = 1, @AllowsExtensions BIT = 0, - @MaxExtensions INT = NULL, + @MaxExtensionDurationSeconds INT = NULL, @CreationDate DATETIME2(7), @RevisionDate DATETIME2(7) AS @@ -28,7 +28,7 @@ BEGIN [MaxLeaseDurationSeconds], [Enabled], [AllowsExtensions], - [MaxExtensions], + [MaxExtensionDurationSeconds], [CreationDate], [RevisionDate] ) @@ -44,7 +44,7 @@ BEGIN @MaxLeaseDurationSeconds, @Enabled, @AllowsExtensions, - @MaxExtensions, + @MaxExtensionDurationSeconds, @CreationDate, @RevisionDate ) diff --git a/src/Sql/dbo/Pam/Stored Procedures/AccessRule_Update.sql b/src/Sql/dbo/Pam/Stored Procedures/AccessRule_Update.sql index b8fdeb312637..787d0edac456 100644 --- a/src/Sql/dbo/Pam/Stored Procedures/AccessRule_Update.sql +++ b/src/Sql/dbo/Pam/Stored Procedures/AccessRule_Update.sql @@ -9,7 +9,7 @@ CREATE PROCEDURE [dbo].[AccessRule_Update] @MaxLeaseDurationSeconds INT = NULL, @Enabled BIT = 1, @AllowsExtensions BIT = 0, - @MaxExtensions INT = NULL, + @MaxExtensionDurationSeconds INT = NULL, @CreationDate DATETIME2(7), @RevisionDate DATETIME2(7) AS @@ -28,7 +28,7 @@ BEGIN [MaxLeaseDurationSeconds] = @MaxLeaseDurationSeconds, [Enabled] = @Enabled, [AllowsExtensions] = @AllowsExtensions, - [MaxExtensions] = @MaxExtensions, + [MaxExtensionDurationSeconds] = @MaxExtensionDurationSeconds, [CreationDate] = @CreationDate, [RevisionDate] = @RevisionDate WHERE diff --git a/src/Sql/dbo/Pam/Tables/AccessRule.sql b/src/Sql/dbo/Pam/Tables/AccessRule.sql index e07d79f79193..4c636e86df32 100644 --- a/src/Sql/dbo/Pam/Tables/AccessRule.sql +++ b/src/Sql/dbo/Pam/Tables/AccessRule.sql @@ -9,7 +9,7 @@ CREATE TABLE [dbo].[AccessRule] ( [MaxLeaseDurationSeconds] INT NULL, [Enabled] BIT NOT NULL CONSTRAINT [DF_AccessRule_Enabled] DEFAULT (1), [AllowsExtensions] BIT NOT NULL CONSTRAINT [DF_AccessRule_AllowsExtensions] DEFAULT (0), - [MaxExtensions] INT NULL, + [MaxExtensionDurationSeconds] INT NULL, [CreationDate] DATETIME2(7) NOT NULL, [RevisionDate] DATETIME2(7) NOT NULL, CONSTRAINT [PK_AccessRule] PRIMARY KEY CLUSTERED ([Id] ASC), diff --git a/test/Core.Test/Pam/Commands/CreateAccessRuleCommandTests.cs b/test/Core.Test/Pam/Commands/CreateAccessRuleCommandTests.cs index ace342843edf..c2e3c3375d87 100644 --- a/test/Core.Test/Pam/Commands/CreateAccessRuleCommandTests.cs +++ b/test/Core.Test/Pam/Commands/CreateAccessRuleCommandTests.cs @@ -201,25 +201,25 @@ public async Task CreateAsync_AllowsExtensionsWithoutMax_ThrowsBadRequest(Access var sutProvider = SetupSutProvider(); rule.Name = "extendable"; rule.AllowsExtensions = true; - rule.MaxExtensions = null; + rule.MaxExtensionDurationSeconds = null; var ex = await Assert.ThrowsAsync(() => sutProvider.Sut.CreateAsync(rule, [])); - Assert.Contains("Maximum extensions", ex.Message); + Assert.Contains("maximum extension length", ex.Message); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().CreateAsync(default!); } [Theory] [BitAutoData(0)] [BitAutoData(-1)] - public async Task CreateAsync_AllowsExtensionsWithNonPositiveMax_ThrowsBadRequest(int maxExtensions, AccessRule rule) + public async Task CreateAsync_AllowsExtensionsWithNonPositiveMax_ThrowsBadRequest(int maxExtensionDurationSeconds, AccessRule rule) { var sutProvider = SetupSutProvider(); rule.Name = "extendable"; rule.AllowsExtensions = true; - rule.MaxExtensions = maxExtensions; + rule.MaxExtensionDurationSeconds = maxExtensionDurationSeconds; var ex = await Assert.ThrowsAsync(() => sutProvider.Sut.CreateAsync(rule, [])); - Assert.Contains("Maximum extensions", ex.Message); + Assert.Contains("maximum extension length", ex.Message); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().CreateAsync(default!); } @@ -230,7 +230,7 @@ public async Task CreateAsync_AllowsExtensionsWithPositiveMax_Persists(AccessRul rule.Name = "extendable"; rule.Conditions = """{"kind":"human_approval"}"""; rule.AllowsExtensions = true; - rule.MaxExtensions = 3; + rule.MaxExtensionDurationSeconds = 3600; sutProvider.GetDependency() .Validate(rule.Conditions) .Returns(AccessRuleValidationResult.Valid); @@ -244,9 +244,9 @@ public async Task CreateAsync_AllowsExtensionsWithPositiveMax_Persists(AccessRul var result = await sutProvider.Sut.CreateAsync(rule, []); Assert.True(result.AllowsExtensions); - Assert.Equal(3, result.MaxExtensions); + Assert.Equal(3600, result.MaxExtensionDurationSeconds); await sutProvider.GetDependency().Received(1) - .CreateAsync(Arg.Is(r => r.AllowsExtensions && r.MaxExtensions == 3)); + .CreateAsync(Arg.Is(r => r.AllowsExtensions && r.MaxExtensionDurationSeconds == 3600)); } private static SutProvider SetupSutProvider() diff --git a/test/Core.Test/Pam/Commands/RequestLeaseExtensionCommandTests.cs b/test/Core.Test/Pam/Commands/RequestLeaseExtensionCommandTests.cs index dccc0a52ed22..0f75af04474e 100644 --- a/test/Core.Test/Pam/Commands/RequestLeaseExtensionCommandTests.cs +++ b/test/Core.Test/Pam/Commands/RequestLeaseExtensionCommandTests.cs @@ -18,7 +18,7 @@ namespace Bit.Core.Test.Pam.Commands; public class RequestLeaseExtensionCommandTests { private static readonly DateTime _now = new(2026, 6, 12, 12, 0, 0, DateTimeKind.Utc); - private const int _maxExtensions = 3; + private const int _maxExtensionDurationSeconds = 4 * 60 * 60; [Theory, BitAutoData] public async Task ExtendAsync_LeaseMissing_ThrowsNotFound(Guid userId, Guid leaseId) @@ -38,7 +38,7 @@ public async Task ExtendAsync_NotOwner_ThrowsNotFound(Guid userId, AccessLease l // Someone else's lease is indistinguishable from a missing one, so ids can't be probed. await Assert.ThrowsAsync(() => sutProvider.Sut.ExtendAsync(userId, Submission(lease.Id))); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() - .CreateApprovedExtensionAsync(default!, default!, default, default); + .CreateApprovedExtensionAsync(default!, default!, default); } [Theory] @@ -53,7 +53,7 @@ public async Task ExtendAsync_LeaseNotActive_ThrowsConflict(AccessLeaseStatus st await Assert.ThrowsAsync( () => sutProvider.Sut.ExtendAsync(lease.RequesterId, Submission(lease.Id))); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() - .CreateApprovedExtensionAsync(default!, default!, default, default); + .CreateApprovedExtensionAsync(default!, default!, default); } [Theory, BitAutoData] @@ -89,7 +89,7 @@ public async Task ExtendAsync_ExtensionsNotAllowed_ThrowsBadRequest(AccessLease () => sutProvider.Sut.ExtendAsync(lease.RequesterId, Submission(lease.Id))); Assert.Contains("does not allow extending", ex.Message); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() - .CreateApprovedExtensionAsync(default!, default!, default, default); + .CreateApprovedExtensionAsync(default!, default!, default); } [Theory] @@ -105,15 +105,17 @@ await Assert.ThrowsAsync( } [Theory, BitAutoData] - public async Task ExtendAsync_DurationExceedsMax_ThrowsBadRequest(AccessLease lease) + public async Task ExtendAsync_DurationExceedsRuleMax_ThrowsBadRequest(AccessLease lease) { var sutProvider = Setup(); SetupExtendableLease(sutProvider, lease); var ex = await Assert.ThrowsAsync( () => sutProvider.Sut.ExtendAsync(lease.RequesterId, - Submission(lease.Id, SubmitAccessRequestCommand.MaxDurationSeconds + 1))); - Assert.Contains("exceeds the maximum", ex.Message); + Submission(lease.Id, _maxExtensionDurationSeconds + 1))); + Assert.Contains("maximum extension length", ex.Message); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .CreateApprovedExtensionAsync(default!, default!, default); } [Theory] @@ -130,18 +132,19 @@ public async Task ExtendAsync_BlankReason_ThrowsBadRequest(string reason, Access } [Theory, BitAutoData] - public async Task ExtendAsync_MaxExtensionsAlreadyReached_ThrowsBadRequest(AccessLease lease) + public async Task ExtendAsync_AlreadyExtended_ThrowsBadRequest(AccessLease lease) { var sutProvider = Setup(); SetupExtendableLease(sutProvider, lease); + // A lease may be extended once; an existing extension request blocks another. sutProvider.GetDependency() - .CountExtensionsByLeaseIdAsync(lease.Id).Returns(_maxExtensions); + .CountExtensionsByLeaseIdAsync(lease.Id).Returns(1); var ex = await Assert.ThrowsAsync( () => sutProvider.Sut.ExtendAsync(lease.RequesterId, Submission(lease.Id))); - Assert.Contains("maximum number of extensions", ex.Message); + Assert.Contains("already been extended", ex.Message); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() - .CreateApprovedExtensionAsync(default!, default!, default, default); + .CreateApprovedExtensionAsync(default!, default!, default); } [Theory, BitAutoData] @@ -166,7 +169,7 @@ public async Task ExtendAsync_Valid_RecordsApprovedExtensionAndExtendsLeaseInPla Assert.Equal("incident", result.Reason); Assert.Equal(_now, result.ResolvedDate); - // The repo applies the request + decision + lease bump atomically; the per-rule cap is passed through. + // The repo applies the request + decision + lease bump atomically. await sutProvider.GetDependency().Received(1).CreateApprovedExtensionAsync( Arg.Is(r => r.ExtensionOfLeaseId == lease.Id @@ -175,7 +178,6 @@ await sutProvider.GetDependency().Received(1).CreateAp && r.NotAfter == expectedNotAfter), Arg.Is(d => d.DeciderKind == AccessDeciderKind.Automatic && d.Verdict == AccessDecisionVerdict.Approve), - _maxExtensions, _now); } @@ -185,7 +187,7 @@ public async Task ExtendAsync_RepoReportsLeaseNotActive_ThrowsConflict(AccessLea var sutProvider = Setup(); SetupExtendableLease(sutProvider, lease); sutProvider.GetDependency() - .CreateApprovedExtensionAsync(Arg.Any(), Arg.Any(), _maxExtensions, _now) + .CreateApprovedExtensionAsync(Arg.Any(), Arg.Any(), _now) .Returns(AccessLeaseExtendOutcome.LeaseNotActive); await Assert.ThrowsAsync( @@ -193,18 +195,18 @@ await Assert.ThrowsAsync( } [Theory, BitAutoData] - public async Task ExtendAsync_RepoReportsMaxExtensionsReached_ThrowsBadRequest(AccessLease lease) + public async Task ExtendAsync_RepoReportsAlreadyExtended_ThrowsBadRequest(AccessLease lease) { var sutProvider = Setup(); SetupExtendableLease(sutProvider, lease); - // Lost a race: another extension filled the last slot between the pre-check and the guarded write. + // Lost a race: another extension landed between the pre-check and the guarded write. sutProvider.GetDependency() - .CreateApprovedExtensionAsync(Arg.Any(), Arg.Any(), _maxExtensions, _now) - .Returns(AccessLeaseExtendOutcome.MaxExtensionsReached); + .CreateApprovedExtensionAsync(Arg.Any(), Arg.Any(), _now) + .Returns(AccessLeaseExtendOutcome.AlreadyExtended); var ex = await Assert.ThrowsAsync( () => sutProvider.Sut.ExtendAsync(lease.RequesterId, Submission(lease.Id))); - Assert.Contains("maximum number of extensions", ex.Message); + Assert.Contains("already been extended", ex.Message); } private static AccessLeaseExtensionSubmission Submission( @@ -219,7 +221,7 @@ private static SutProvider Setup() } // An active, in-window lease owned by its BitAutoData requester, governed by an extension-enabled rule with no - // extensions used yet, and a repo that extends successfully. Tests override the precondition they exercise. + // extension used yet, and a repo that extends successfully. Tests override the precondition they exercise. private static void SetupExtendableLease( SutProvider sutProvider, AccessLease lease, bool allowsExtensions = true) { @@ -234,12 +236,12 @@ private static void SetupExtendableLease( new HumanApprovalCondition()) { AllowsExtensions = allowsExtensions, - MaxExtensions = _maxExtensions, + MaxExtensionDurationSeconds = _maxExtensionDurationSeconds, }); sutProvider.GetDependency().CountExtensionsByLeaseIdAsync(lease.Id).Returns(0); sutProvider.GetDependency() - .CreateApprovedExtensionAsync(Arg.Any(), Arg.Any(), Arg.Any(), _now) + .CreateApprovedExtensionAsync(Arg.Any(), Arg.Any(), _now) .Returns(AccessLeaseExtendOutcome.Extended); } } diff --git a/test/Core.Test/Pam/Commands/UpdateAccessRuleCommandTests.cs b/test/Core.Test/Pam/Commands/UpdateAccessRuleCommandTests.cs index 0f23ce759931..ffb312b08f89 100644 --- a/test/Core.Test/Pam/Commands/UpdateAccessRuleCommandTests.cs +++ b/test/Core.Test/Pam/Commands/UpdateAccessRuleCommandTests.cs @@ -32,7 +32,7 @@ public async Task UpdateAsync_HappyPath_UpdatesFieldsAndBumpsRevision(AccessRule update.DefaultLeaseDurationSeconds = 3600; update.MaxLeaseDurationSeconds = 28800; update.AllowsExtensions = true; - update.MaxExtensions = 2; + update.MaxExtensionDurationSeconds = 7200; sutProvider.GetDependency() .GetDetailsByIdAsync(existing.Id) .Returns(existing); @@ -52,14 +52,14 @@ public async Task UpdateAsync_HappyPath_UpdatesFieldsAndBumpsRevision(AccessRule Assert.Equal(3600, result.DefaultLeaseDurationSeconds); Assert.Equal(28800, result.MaxLeaseDurationSeconds); Assert.True(result.AllowsExtensions); - Assert.Equal(2, result.MaxExtensions); + Assert.Equal(7200, result.MaxExtensionDurationSeconds); Assert.Equal(_now, result.RevisionDate); await sutProvider.GetDependency().Received(1) .ReplaceAsync(Arg.Is(r => r.Id == existing.Id && r.Name == "renamed" && r.Description == "new description" && r.SingleActiveLease && r.DefaultLeaseDurationSeconds == 3600 && r.MaxLeaseDurationSeconds == 28800 - && r.AllowsExtensions && r.MaxExtensions == 2)); + && r.AllowsExtensions && r.MaxExtensionDurationSeconds == 7200)); } [Theory, BitAutoData] @@ -68,27 +68,27 @@ public async Task UpdateAsync_AllowsExtensionsWithoutMax_ThrowsBadRequest(Access var sutProvider = SetupSutProvider(); update.Name = "renamed"; update.AllowsExtensions = true; - update.MaxExtensions = null; + update.MaxExtensionDurationSeconds = null; var ex = await Assert.ThrowsAsync( () => sutProvider.Sut.UpdateAsync(update.OrganizationId, update.Id, update, [])); - Assert.Contains("Maximum extensions", ex.Message); + Assert.Contains("maximum extension length", ex.Message); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().ReplaceAsync(default!); } [Theory] [BitAutoData(0)] [BitAutoData(-1)] - public async Task UpdateAsync_AllowsExtensionsWithNonPositiveMax_ThrowsBadRequest(int maxExtensions, AccessRule update) + public async Task UpdateAsync_AllowsExtensionsWithNonPositiveMax_ThrowsBadRequest(int maxExtensionDurationSeconds, AccessRule update) { var sutProvider = SetupSutProvider(); update.Name = "renamed"; update.AllowsExtensions = true; - update.MaxExtensions = maxExtensions; + update.MaxExtensionDurationSeconds = maxExtensionDurationSeconds; var ex = await Assert.ThrowsAsync( () => sutProvider.Sut.UpdateAsync(update.OrganizationId, update.Id, update, [])); - Assert.Contains("Maximum extensions", ex.Message); + Assert.Contains("maximum extension length", ex.Message); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().ReplaceAsync(default!); } diff --git a/test/Core.Test/Pam/Queries/GetCipherAccessStateQueryTests.cs b/test/Core.Test/Pam/Queries/GetCipherAccessStateQueryTests.cs index a2557de78d6f..836dfe7445a8 100644 --- a/test/Core.Test/Pam/Queries/GetCipherAccessStateQueryTests.cs +++ b/test/Core.Test/Pam/Queries/GetCipherAccessStateQueryTests.cs @@ -167,7 +167,7 @@ public async Task GetStateAsync_GatedButEmpty_ReturnsEmptySnapshot( } [Theory, BitAutoData] - public async Task GetStateAsync_ActiveLease_ExtensionsAllowed_ReportsRemaining( + public async Task GetStateAsync_ActiveLease_NotYetExtended_AllowedWithMaxLength( SutProvider sutProvider, Guid userId, Guid cipherId, Guid orgId, Guid collectionId, AccessLease activeLease) { @@ -180,19 +180,19 @@ public async Task GetStateAsync_ActiveLease_ExtensionsAllowed_ReportsRemaining( .Returns(new GoverningRule(orgId, collectionId, RequiresHumanApproval: false, new HumanApprovalCondition()) { AllowsExtensions = true, - MaxExtensions = 3, + MaxExtensionDurationSeconds = 4 * 60 * 60, }); sutProvider.GetDependency() - .CountExtensionsByLeaseIdAsync(activeLease.Id).Returns(1); + .CountExtensionsByLeaseIdAsync(activeLease.Id).Returns(0); var result = await sutProvider.Sut.GetStateAsync(userId, cipherId); Assert.True(result.ExtensionsAllowed); - Assert.Equal(2, result.ExtensionsRemaining); + Assert.Equal(4 * 60 * 60, result.MaxExtensionDurationSeconds); } [Theory, BitAutoData] - public async Task GetStateAsync_ActiveLease_ExtensionsExhausted_ReportsZeroRemaining( + public async Task GetStateAsync_ActiveLease_AlreadyExtended_NotAllowed( SutProvider sutProvider, Guid userId, Guid cipherId, Guid orgId, Guid collectionId, AccessLease activeLease) { @@ -205,16 +205,16 @@ public async Task GetStateAsync_ActiveLease_ExtensionsExhausted_ReportsZeroRemai .Returns(new GoverningRule(orgId, collectionId, RequiresHumanApproval: false, new HumanApprovalCondition()) { AllowsExtensions = true, - MaxExtensions = 2, + MaxExtensionDurationSeconds = 2 * 60 * 60, }); - // More used than the cap (a rule whose max was lowered after the fact) must clamp to zero, never negative. + // A lease may be extended once; an existing extension means no more are allowed. sutProvider.GetDependency() - .CountExtensionsByLeaseIdAsync(activeLease.Id).Returns(5); + .CountExtensionsByLeaseIdAsync(activeLease.Id).Returns(1); var result = await sutProvider.Sut.GetStateAsync(userId, cipherId); - Assert.True(result.ExtensionsAllowed); - Assert.Equal(0, result.ExtensionsRemaining); + Assert.False(result.ExtensionsAllowed); + Assert.Equal(2 * 60 * 60, result.MaxExtensionDurationSeconds); } [Theory, BitAutoData] @@ -236,7 +236,7 @@ public async Task GetStateAsync_ActiveLease_ExtensionsDisallowed_ReportsNotAllow var result = await sutProvider.Sut.GetStateAsync(userId, cipherId); Assert.False(result.ExtensionsAllowed); - Assert.Equal(0, result.ExtensionsRemaining); + Assert.Null(result.MaxExtensionDurationSeconds); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() .CountExtensionsByLeaseIdAsync(default); } diff --git a/test/Infrastructure.IntegrationTest/Pam/Repositories/AccessRequestExtensionRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Pam/Repositories/AccessRequestExtensionRepositoryTests.cs index aecffabab7e6..ef5c3c32df02 100644 --- a/test/Infrastructure.IntegrationTest/Pam/Repositories/AccessRequestExtensionRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/Pam/Repositories/AccessRequestExtensionRepositoryTests.cs @@ -27,7 +27,7 @@ public async Task CreateApprovedExtensionAsync_ExtendsLeaseInPlaceAndRecordsRequ var newNotAfter = lease.NotAfter.AddHours(1); var outcome = await accessRequestRepository.CreateApprovedExtensionAsync( - BuildExtension(lease, newNotAfter, now), BuildAutoDecision(now), maxExtensions: 3, now); + BuildExtension(lease, newNotAfter, now), BuildAutoDecision(now), now); Assert.Equal(AccessLeaseExtendOutcome.Extended, outcome); @@ -37,7 +37,7 @@ public async Task CreateApprovedExtensionAsync_ExtendsLeaseInPlaceAndRecordsRequ Assert.Equal(newNotAfter, updatedLease!.NotAfter); Assert.Equal(AccessLeaseStatus.Active, updatedLease.Status); - // The extension is recorded as an approved request pointing at the parent lease, and counts toward the cap. + // The extension is recorded as an approved request pointing at the parent lease. Assert.Equal(1, await accessRequestRepository.CountExtensionsByLeaseIdAsync(lease.Id)); // An approved extension produces no lease of its own, so it must not surface as a startable approval. @@ -46,7 +46,7 @@ public async Task CreateApprovedExtensionAsync_ExtendsLeaseInPlaceAndRecordsRequ } [DatabaseTheory, DatabaseData] - public async Task CreateApprovedExtensionAsync_MaxReached_ReturnsMaxExtensionsReached( + public async Task CreateApprovedExtensionAsync_SecondExtension_ReturnsAlreadyExtended( IOrganizationRepository organizationRepository, ICollectionRepository collectionRepository, IAccessRequestRepository accessRequestRepository, @@ -62,13 +62,13 @@ public async Task CreateApprovedExtensionAsync_MaxReached_ReturnsMaxExtensionsRe var firstNotAfter = lease.NotAfter.AddHours(1); Assert.Equal(AccessLeaseExtendOutcome.Extended, await accessRequestRepository.CreateApprovedExtensionAsync( - BuildExtension(lease, firstNotAfter, now), BuildAutoDecision(now), maxExtensions: 1, now)); + BuildExtension(lease, firstNotAfter, now), BuildAutoDecision(now), now)); - // The cap is 1 and one extension already exists, so a second is rejected and nothing is written. + // A lease may be extended exactly once, so a second extension is rejected and nothing is written. var rejected = await accessRequestRepository.CreateApprovedExtensionAsync( - BuildExtension(lease, firstNotAfter.AddHours(1), now), BuildAutoDecision(now), maxExtensions: 1, now); + BuildExtension(lease, firstNotAfter.AddHours(1), now), BuildAutoDecision(now), now); - Assert.Equal(AccessLeaseExtendOutcome.MaxExtensionsReached, rejected); + Assert.Equal(AccessLeaseExtendOutcome.AlreadyExtended, rejected); Assert.Equal(1, await accessRequestRepository.CountExtensionsByLeaseIdAsync(lease.Id)); var updatedLease = await accessLeaseRepository.GetByIdAsync(lease.Id); Assert.Equal(firstNotAfter, updatedLease!.NotAfter); @@ -93,7 +93,7 @@ public async Task CreateApprovedExtensionAsync_LeaseNotActive_ReturnsLeaseNotAct var extension = BuildExtension(lease, lease.NotAfter.AddHours(1), now); var outcome = await accessRequestRepository.CreateApprovedExtensionAsync( - extension, BuildAutoDecision(now), maxExtensions: 3, now); + extension, BuildAutoDecision(now), now); Assert.Equal(AccessLeaseExtendOutcome.LeaseNotActive, outcome); Assert.Equal(0, await accessRequestRepository.CountExtensionsByLeaseIdAsync(lease.Id)); @@ -116,12 +116,11 @@ public async Task CountExtensionsByLeaseIdAsync_CountsOnlyThatLease( var leaseB = await CreateActiveLeaseAsync( accessRequestRepository, accessLeaseRepository, organization.Id, collection.Id, Guid.NewGuid(), now); + // Extend only leaseA (a lease may be extended once); the count is scoped to its own lease. await accessRequestRepository.CreateApprovedExtensionAsync( - BuildExtension(leaseA, leaseA.NotAfter.AddHours(1), now), BuildAutoDecision(now), maxExtensions: 5, now); - await accessRequestRepository.CreateApprovedExtensionAsync( - BuildExtension(leaseA, leaseA.NotAfter.AddHours(2), now), BuildAutoDecision(now), maxExtensions: 5, now); + BuildExtension(leaseA, leaseA.NotAfter.AddHours(1), now), BuildAutoDecision(now), now); - Assert.Equal(2, await accessRequestRepository.CountExtensionsByLeaseIdAsync(leaseA.Id)); + Assert.Equal(1, await accessRequestRepository.CountExtensionsByLeaseIdAsync(leaseA.Id)); Assert.Equal(0, await accessRequestRepository.CountExtensionsByLeaseIdAsync(leaseB.Id)); } diff --git a/util/Migrator/DbScripts/2026-06-15_00_RenameMaxExtensionsToMaxExtensionDuration.sql b/util/Migrator/DbScripts/2026-06-15_00_RenameMaxExtensionsToMaxExtensionDuration.sql new file mode 100644 index 000000000000..dd5a23e72aa9 --- /dev/null +++ b/util/Migrator/DbScripts/2026-06-15_00_RenameMaxExtensionsToMaxExtensionDuration.sql @@ -0,0 +1,187 @@ +-- PAM Credential Leasing: rework extension settings. +-- +-- Product direction changed: a lease may now be extended exactly once, and the admin instead caps the *length* of +-- that extension. So AccessRule.[MaxExtensions] (a count) is dropped in favour of [MaxExtensionDurationSeconds] (the +-- longest a single extension may run), and AccessRequest_CreateApprovedExtension no longer takes @MaxExtensions — +-- it rejects when any extension request already exists for the lease. +-- +-- Supersedes the column/proc shapes added in 2026-06-12_01/02 (kept intact so already-migrated dev DBs roll forward +-- cleanly). PAM is an unshipped POC behind the pm-37044-pam-v-0 flag with no production data; the affected procs are +-- altered in place. + +IF COL_LENGTH('[dbo].[AccessRule]', 'MaxExtensions') IS NOT NULL +BEGIN + ALTER TABLE [dbo].[AccessRule] DROP COLUMN [MaxExtensions] +END +GO + +IF COL_LENGTH('[dbo].[AccessRule]', 'MaxExtensionDurationSeconds') IS NULL +BEGIN + ALTER TABLE [dbo].[AccessRule] ADD [MaxExtensionDurationSeconds] INT NULL +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[AccessRule_Create] + @Id UNIQUEIDENTIFIER OUTPUT, + @OrganizationId UNIQUEIDENTIFIER, + @Name NVARCHAR(256), + @Description NVARCHAR(MAX) = NULL, + @Conditions NVARCHAR(MAX), + @SingleActiveLease BIT = 0, + @DefaultLeaseDurationSeconds INT = NULL, + @MaxLeaseDurationSeconds INT = NULL, + @Enabled BIT = 1, + @AllowsExtensions BIT = 0, + @MaxExtensionDurationSeconds INT = NULL, + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + INSERT INTO [dbo].[AccessRule] + ( + [Id], + [OrganizationId], + [Name], + [Description], + [Conditions], + [SingleActiveLease], + [DefaultLeaseDurationSeconds], + [MaxLeaseDurationSeconds], + [Enabled], + [AllowsExtensions], + [MaxExtensionDurationSeconds], + [CreationDate], + [RevisionDate] + ) + VALUES + ( + @Id, + @OrganizationId, + @Name, + @Description, + @Conditions, + @SingleActiveLease, + @DefaultLeaseDurationSeconds, + @MaxLeaseDurationSeconds, + @Enabled, + @AllowsExtensions, + @MaxExtensionDurationSeconds, + @CreationDate, + @RevisionDate + ) +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[AccessRule_Update] + @Id UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @Name NVARCHAR(256), + @Description NVARCHAR(MAX) = NULL, + @Conditions NVARCHAR(MAX), + @SingleActiveLease BIT = 0, + @DefaultLeaseDurationSeconds INT = NULL, + @MaxLeaseDurationSeconds INT = NULL, + @Enabled BIT = 1, + @AllowsExtensions BIT = 0, + @MaxExtensionDurationSeconds INT = NULL, + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + UPDATE + [dbo].[AccessRule] + SET + [OrganizationId] = @OrganizationId, + [Name] = @Name, + [Description] = @Description, + [Conditions] = @Conditions, + [SingleActiveLease] = @SingleActiveLease, + [DefaultLeaseDurationSeconds] = @DefaultLeaseDurationSeconds, + [MaxLeaseDurationSeconds] = @MaxLeaseDurationSeconds, + [Enabled] = @Enabled, + [AllowsExtensions] = @AllowsExtensions, + [MaxExtensionDurationSeconds] = @MaxExtensionDurationSeconds, + [CreationDate] = @CreationDate, + [RevisionDate] = @RevisionDate + WHERE + [Id] = @Id +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[AccessRequest_CreateApprovedExtension] + @AccessRequestId UNIQUEIDENTIFIER, + @AccessDecisionId UNIQUEIDENTIFIER, + @ExtensionOfLeaseId UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @CollectionId UNIQUEIDENTIFIER, + @CipherId UNIQUEIDENTIFIER, + @RequesterId UNIQUEIDENTIFIER, + @NotBefore DATETIME2(7), + @NotAfter DATETIME2(7), + @Reason NVARCHAR(MAX) = NULL, + @Now DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + SET XACT_ABORT ON + + BEGIN TRANSACTION + + IF NOT EXISTS ( + SELECT 1 + FROM [dbo].[AccessLease] WITH (UPDLOCK, HOLDLOCK) + WHERE [Id] = @ExtensionOfLeaseId + AND [RequesterId] = @RequesterId + AND [Status] = 0 /* Active */ + AND [NotAfter] > @Now + ) + BEGIN + ROLLBACK TRANSACTION + SELECT 0 -- LeaseNotActive + RETURN + END + + -- A lease may be extended exactly once. Counted under the lease lock, so it is race-safe against a concurrent + -- extension of the same lease. + IF EXISTS (SELECT 1 FROM [dbo].[AccessRequest] WHERE [ExtensionOfLeaseId] = @ExtensionOfLeaseId) + BEGIN + ROLLBACK TRANSACTION + SELECT -1 -- AlreadyExtended + RETURN + END + + INSERT INTO [dbo].[AccessRequest] + ( + [Id], [ExtensionOfLeaseId], [OrganizationId], [CollectionId], [CipherId], [RequesterId], + [NotBefore], [NotAfter], [Reason], [Status], [CreationDate], [ResolvedDate] + ) + VALUES + ( + @AccessRequestId, @ExtensionOfLeaseId, @OrganizationId, @CollectionId, @CipherId, @RequesterId, + @NotBefore, @NotAfter, @Reason, 1 /* Approved */, @Now, @Now + ) + + INSERT INTO [dbo].[AccessDecision] + ( + [Id], [AccessRequestId], [DeciderKind], [ApproverId], [ConditionKind], + [Verdict], [Comment], [EvaluationContext], [CreationDate] + ) + VALUES + ( + @AccessDecisionId, @AccessRequestId, 0 /* Automatic */, NULL, NULL, + 0 /* Approve */, NULL, NULL, @Now + ) + + UPDATE [dbo].[AccessLease] + SET [NotAfter] = @NotAfter + WHERE [Id] = @ExtensionOfLeaseId + + COMMIT TRANSACTION + + SELECT 1 -- Extended +END +GO From 38b88a2ac39eb4aaaf9440d1c51be75c46931dcf Mon Sep 17 00:00:00 2001 From: Patrik Svensson Date: Mon, 15 Jun 2026 15:15:52 +0200 Subject: [PATCH 28/54] PAM: allow a conditionless access rule (empty all_of) A rule with no conditions is a deliberate way to route a collection's access through the PAM flow purely for audit logging. The engine and governing-rule resolver already treat an empty all_of as vacuously satisfied (auto-allow, no human approval), but AccessRuleValidator rejected it outright, blocking the create/update path. Permit an empty all_of in the validator (depth and max-children bounds are unchanged); the resolver's fail-closed-on-malformed behavior is untouched. Documents the semantics on AllOfCondition and adds validator, engine, and resolver test coverage for the conditionless case. --- src/Core/Pam/Models/Conditions/AllOfCondition.cs | 4 +++- src/Core/Pam/Services/AccessRuleValidator.cs | 8 +++----- .../Core.Test/Pam/Engine/AccessRuleEngineTests.cs | 10 ++++++++++ .../Pam/Services/AccessRuleValidatorTests.cs | 7 ++++--- .../Pam/Services/GoverningRuleResolverTests.cs | 15 +++++++++++++++ 5 files changed, 35 insertions(+), 9 deletions(-) diff --git a/src/Core/Pam/Models/Conditions/AllOfCondition.cs b/src/Core/Pam/Models/Conditions/AllOfCondition.cs index a5c077c1d8da..448851db4473 100644 --- a/src/Core/Pam/Models/Conditions/AllOfCondition.cs +++ b/src/Core/Pam/Models/Conditions/AllOfCondition.cs @@ -1,7 +1,9 @@ namespace Bit.Core.Pam.Models.Conditions; /// -/// Composite condition that approves only when every child condition approves. +/// Composite condition that approves only when every child condition approves. An empty set of children is +/// vacuously satisfied (always allow), representing a rule with no gating conditions that exists only to route +/// access through the PAM flow for audit logging. /// public sealed class AllOfCondition : AccessCondition { diff --git a/src/Core/Pam/Services/AccessRuleValidator.cs b/src/Core/Pam/Services/AccessRuleValidator.cs index 525c9cc2eec8..b796ea292daa 100644 --- a/src/Core/Pam/Services/AccessRuleValidator.cs +++ b/src/Core/Pam/Services/AccessRuleValidator.cs @@ -143,11 +143,9 @@ private static AccessRuleValidationResult ValidateAllOf(AllOfCondition condition return AccessRuleValidationResult.Invalid($"all_of nesting exceeds maximum depth of {MaxCompositeDepth}."); } - if (condition.Conditions.Count == 0) - { - return AccessRuleValidationResult.Invalid("all_of requires at least one child condition."); - } - + // An empty all_of is allowed: it is vacuously satisfied, so the rule governs its collections — routing + // access through the PAM flow for audit logging — without imposing any gating condition. The engine + // evaluates it to Allow. if (condition.Conditions.Count > MaxCompositeChildren) { return AccessRuleValidationResult.Invalid($"all_of cannot contain more than {MaxCompositeChildren} child conditions."); diff --git a/test/Core.Test/Pam/Engine/AccessRuleEngineTests.cs b/test/Core.Test/Pam/Engine/AccessRuleEngineTests.cs index 5bf36f018252..82bda85d532c 100644 --- a/test/Core.Test/Pam/Engine/AccessRuleEngineTests.cs +++ b/test/Core.Test/Pam/Engine/AccessRuleEngineTests.cs @@ -212,6 +212,16 @@ public void Evaluate_AllOf_DenyOutranksApproval() Assert.Equal(DenyReason.NotWithinIpRange, evaluation.Reason); } + [Fact] + public void Evaluate_AllOf_NoChildren_Allows() + { + // A rule with no conditions is vacuously satisfied: access is auto-granted while still flowing through + // PAM for audit logging. + var evaluation = _sut.Evaluate(new AllOfCondition(), Signals()); + + Assert.Equal(AccessEvaluationOutcome.Allow, evaluation.Outcome); + } + [Fact] public void Evaluate_NestedAllOf_Allows() { diff --git a/test/Core.Test/Pam/Services/AccessRuleValidatorTests.cs b/test/Core.Test/Pam/Services/AccessRuleValidatorTests.cs index e93f54e2c56d..983ea3f367ab 100644 --- a/test/Core.Test/Pam/Services/AccessRuleValidatorTests.cs +++ b/test/Core.Test/Pam/Services/AccessRuleValidatorTests.cs @@ -120,12 +120,13 @@ public void Validate_AllOf_NestedHumanAndIpAllowlist_IsValid() } [Fact] - public void Validate_AllOf_EmptyChildren_IsInvalid() + public void Validate_AllOf_EmptyChildren_IsValid() { + // A rule with no conditions (an empty all_of) is allowed: it gates nothing and exists to route access + // through the PAM flow for audit logging. var result = _sut.Validate("""{"kind":"all_of","conditions":[]}"""); - Assert.False(result.IsValid); - Assert.Contains("at least one child", result.Error); + Assert.True(result.IsValid); } [Fact] diff --git a/test/Core.Test/Pam/Services/GoverningRuleResolverTests.cs b/test/Core.Test/Pam/Services/GoverningRuleResolverTests.cs index c2dd2d304354..9c4994ce5058 100644 --- a/test/Core.Test/Pam/Services/GoverningRuleResolverTests.cs +++ b/test/Core.Test/Pam/Services/GoverningRuleResolverTests.cs @@ -80,6 +80,21 @@ public async Task ResolveAsync_AllOfContainingHumanApproval_RequiresHumanApprova Assert.IsType(result.Condition); } + [Theory, BitAutoData] + public async Task ResolveAsync_EmptyAllOf_DoesNotRequireHumanApproval( + SutProvider sutProvider, Guid userId, Guid cipherId, Collection collection, AccessRule rule) + { + // A conditionless rule (empty all_of) governs the collection for audit logging but auto-approves access. + rule.Conditions = """{"kind":"all_of","conditions":[]}"""; + SetupGovernedCollection(sutProvider, userId, cipherId, collection, rule); + + var result = await sutProvider.Sut.ResolveAsync(userId, cipherId); + + Assert.NotNull(result); + Assert.False(result!.RequiresHumanApproval); + Assert.IsType(result.Condition); + } + [Theory, BitAutoData] public async Task ResolveAsync_MalformedRule_FailsSafeToHumanApproval( SutProvider sutProvider, Guid userId, Guid cipherId, Collection collection, AccessRule rule) From 4ea701e61bf8700a3a04f79385221a05d030e734 Mon Sep 17 00:00:00 2001 From: Patrik Svensson Date: Mon, 15 Jun 2026 17:53:27 +0200 Subject: [PATCH 29/54] PAM: flatten access rule conditions from a tree to a flat list AccessRule.Conditions was a polymorphic AccessCondition tree rooted at an all_of node. Replace it with a flat, implicitly-ANDed array of leaf conditions, stored and serialized as a bare JSON array. Remove AllOfCondition; the engine, validator, resolver, and GoverningRule now operate over IReadOnlyList. An empty list is vacuously allowed. Unshipped PoC, so a clean break with no data migration. A future grouping condition kind can be reintroduced within the array, so a bare array does not preclude trees later. --- src/Core/Pam/Engine/AccessRuleEngine.cs | 14 +- src/Core/Pam/Engine/IAccessRuleEngine.cs | 10 +- .../Pam/Models/Conditions/AccessCondition.cs | 5 +- .../Pam/Models/Conditions/AllOfCondition.cs | 11 -- src/Core/Pam/Models/GoverningRule.cs | 6 +- .../Commands/SubmitAccessRequestCommand.cs | 2 +- .../Queries/GetLeasedCipherQuery.cs | 2 +- src/Core/Pam/Services/AccessRuleValidator.cs | 61 ++++---- .../Pam/Services/GoverningRuleResolver.cs | 32 ++--- .../RequestLeaseExtensionCommandTests.cs | 2 +- .../SubmitAccessRequestCommandTests.cs | 4 +- .../Pam/Engine/AccessRuleEngineTests.cs | 131 +++++++----------- .../Pam/Queries/AccessPreCheckQueryTests.cs | 6 +- .../Queries/GetCipherAccessStateQueryTests.cs | 12 +- .../Pam/Queries/GetLeasedCipherQueryTests.cs | 5 +- .../Pam/Services/AccessRuleValidatorTests.cs | 123 ++++++++-------- .../Services/GoverningRuleResolverTests.cs | 25 ++-- 17 files changed, 196 insertions(+), 255 deletions(-) delete mode 100644 src/Core/Pam/Models/Conditions/AllOfCondition.cs diff --git a/src/Core/Pam/Engine/AccessRuleEngine.cs b/src/Core/Pam/Engine/AccessRuleEngine.cs index f22b8d8130c7..69debcd9bf95 100644 --- a/src/Core/Pam/Engine/AccessRuleEngine.cs +++ b/src/Core/Pam/Engine/AccessRuleEngine.cs @@ -5,10 +5,10 @@ namespace Bit.Core.Pam.Engine; /// -/// Recursively evaluates the polymorphic tree against the caller's signals. Each leaf -/// condition yields an ; combines its children with deny -/// taking precedence over a pending approval, which in turn takes precedence over allow. Unparseable inputs fail -/// closed. +/// Evaluates the access rule's flat list of s against the caller's signals. Each +/// condition yields an ; the results combine with deny taking precedence over a +/// pending approval, which in turn takes precedence over allow. An empty list is vacuously satisfied (allow). +/// Unparseable inputs fail closed before they reach the engine. /// public sealed class AccessRuleEngine : IAccessRuleEngine { @@ -24,12 +24,14 @@ public sealed class AccessRuleEngine : IAccessRuleEngine ["sat"] = DayOfWeek.Saturday, }; - public AccessEvaluation Evaluate(AccessCondition condition, AccessSignals signals) => condition switch + public AccessEvaluation Evaluate(IReadOnlyList conditions, AccessSignals signals) => + AccessEvaluation.Combine(conditions.Select(condition => EvaluateCondition(condition, signals))); + + private static AccessEvaluation EvaluateCondition(AccessCondition condition, AccessSignals signals) => condition switch { HumanApprovalCondition => AccessEvaluation.RequiresApproval, IpAllowlistCondition ip => EvaluateIpAllowlist(ip, signals), TimeOfDayCondition time => EvaluateTimeOfDay(time, signals), - AllOfCondition all => AccessEvaluation.Combine(all.Conditions.Select(child => Evaluate(child, signals))), // A condition kind the engine does not understand cannot be shown to be satisfied, so deny. _ => AccessEvaluation.Deny(DenyReason.UnsupportedCondition), }; diff --git a/src/Core/Pam/Engine/IAccessRuleEngine.cs b/src/Core/Pam/Engine/IAccessRuleEngine.cs index d256afe1a45f..80b5f5513d48 100644 --- a/src/Core/Pam/Engine/IAccessRuleEngine.cs +++ b/src/Core/Pam/Engine/IAccessRuleEngine.cs @@ -3,12 +3,12 @@ namespace Bit.Core.Pam.Engine; /// -/// Evaluates an access rule's tree against the request-time -/// , deciding whether access is allowed, denied, or gated on human approval. -/// The engine is pure: it reads no state and issues no leases. Lease lifecycle is owned by the lease commands and -/// queries, which call the engine to decide whether a lease may be issued or its data handed over. +/// Evaluates an access rule's conditions — a flat list of ANDed together — against +/// the request-time , deciding whether access is allowed, denied, or gated on human +/// approval. The engine is pure: it reads no state and issues no leases. Lease lifecycle is owned by the lease +/// commands and queries, which call the engine to decide whether a lease may be issued or its data handed over. /// public interface IAccessRuleEngine { - AccessEvaluation Evaluate(AccessCondition condition, AccessSignals signals); + AccessEvaluation Evaluate(IReadOnlyList conditions, AccessSignals signals); } diff --git a/src/Core/Pam/Models/Conditions/AccessCondition.cs b/src/Core/Pam/Models/Conditions/AccessCondition.cs index 127ce293a5a5..55ec2f771445 100644 --- a/src/Core/Pam/Models/Conditions/AccessCondition.cs +++ b/src/Core/Pam/Models/Conditions/AccessCondition.cs @@ -3,12 +3,11 @@ namespace Bit.Core.Pam.Models.Conditions; /// -/// Base type for the structured conditions document stored on AccessRule.Conditions. -/// Polymorphic deserialization is keyed by the JSON kind property. +/// Base type for a single leaf condition in an access rule's flat conditions list. Polymorphic deserialization is +/// keyed by the JSON kind property. /// [JsonPolymorphic(TypeDiscriminatorPropertyName = "kind", UnknownDerivedTypeHandling = JsonUnknownDerivedTypeHandling.FailSerialization)] [JsonDerivedType(typeof(HumanApprovalCondition), "human_approval")] [JsonDerivedType(typeof(IpAllowlistCondition), "ip_allowlist")] [JsonDerivedType(typeof(TimeOfDayCondition), "time_of_day")] -[JsonDerivedType(typeof(AllOfCondition), "all_of")] public abstract class AccessCondition; diff --git a/src/Core/Pam/Models/Conditions/AllOfCondition.cs b/src/Core/Pam/Models/Conditions/AllOfCondition.cs deleted file mode 100644 index 448851db4473..000000000000 --- a/src/Core/Pam/Models/Conditions/AllOfCondition.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace Bit.Core.Pam.Models.Conditions; - -/// -/// Composite condition that approves only when every child condition approves. An empty set of children is -/// vacuously satisfied (always allow), representing a rule with no gating conditions that exists only to route -/// access through the PAM flow for audit logging. -/// -public sealed class AllOfCondition : AccessCondition -{ - public IReadOnlyList Conditions { get; init; } = []; -} diff --git a/src/Core/Pam/Models/GoverningRule.cs b/src/Core/Pam/Models/GoverningRule.cs index c25de2b26aee..7c9261e456f7 100644 --- a/src/Core/Pam/Models/GoverningRule.cs +++ b/src/Core/Pam/Models/GoverningRule.cs @@ -4,15 +4,15 @@ namespace Bit.Core.Pam.Models; /// /// The access rule that governs a cipher for a particular caller: which collection's rule applies, the owning -/// organization, whether the rule requires human approval, and the parsed tree so the -/// rule engine can evaluate it against the caller's signals. A null governing rule means the cipher is not +/// organization, whether the rule requires human approval, and the parsed flat list of s +/// so the rule engine can evaluate them against the caller's signals. A null governing rule means the cipher is not /// leasing-gated for the caller. /// public sealed record GoverningRule( Guid OrganizationId, Guid CollectionId, bool RequiresHumanApproval, - AccessCondition Condition) + IReadOnlyList Conditions) { /// /// When true, a member holding an active lease under this rule may extend it once (always auto-approved), by up diff --git a/src/Core/Pam/OrganizationFeatures/Commands/SubmitAccessRequestCommand.cs b/src/Core/Pam/OrganizationFeatures/Commands/SubmitAccessRequestCommand.cs index 24fc0885400b..8e6727b619e4 100644 --- a/src/Core/Pam/OrganizationFeatures/Commands/SubmitAccessRequestCommand.cs +++ b/src/Core/Pam/OrganizationFeatures/Commands/SubmitAccessRequestCommand.cs @@ -108,7 +108,7 @@ private async Task ApproveAutomaticallyAsync( // The cipher must satisfy its access rule's conditions (source IP, time of day, ...) before the request is // auto-approved. The resolver only routes a rule here when it carries no human-approval gate, so the engine // never asks for approval on this path; any non-allow outcome is a denial we surface to the caller. - var evaluation = _ruleEngine.Evaluate(governingRule.Condition, BuildSignals(now)); + var evaluation = _ruleEngine.Evaluate(governingRule.Conditions, BuildSignals(now)); if (evaluation.Outcome != AccessEvaluationOutcome.Allow) { throw new BadRequestException(DenyMessage(evaluation)); diff --git a/src/Core/Pam/OrganizationFeatures/Queries/GetLeasedCipherQuery.cs b/src/Core/Pam/OrganizationFeatures/Queries/GetLeasedCipherQuery.cs index 2f299eea258d..2c9325bc3059 100644 --- a/src/Core/Pam/OrganizationFeatures/Queries/GetLeasedCipherQuery.cs +++ b/src/Core/Pam/OrganizationFeatures/Queries/GetLeasedCipherQuery.cs @@ -57,7 +57,7 @@ public GetLeasedCipherQuery( Timestamp = now, }; - if (_ruleEngine.Evaluate(governingRule.Condition, signals).Outcome == AccessEvaluationOutcome.Deny) + if (_ruleEngine.Evaluate(governingRule.Conditions, signals).Outcome == AccessEvaluationOutcome.Deny) { return null; } diff --git a/src/Core/Pam/Services/AccessRuleValidator.cs b/src/Core/Pam/Services/AccessRuleValidator.cs index b796ea292daa..a7b69a80bb74 100644 --- a/src/Core/Pam/Services/AccessRuleValidator.cs +++ b/src/Core/Pam/Services/AccessRuleValidator.cs @@ -7,8 +7,7 @@ namespace Bit.Core.Pam.Services; public sealed partial class AccessRuleValidator : IAccessRuleValidator { - private const int MaxCompositeDepth = 3; - private const int MaxCompositeChildren = 10; + private const int MaxConditions = 10; private static readonly HashSet AllowedDays = new(StringComparer.OrdinalIgnoreCase) { "mon", "tue", "wed", "thu", "fri", "sat", "sun" }; @@ -34,32 +33,49 @@ public AccessRuleValidationResult Validate(string? conditionsJson) return AccessRuleValidationResult.Invalid("Conditions JSON cannot be empty."); } - AccessCondition? condition; + List? conditions; try { - condition = JsonSerializer.Deserialize(conditionsJson, JsonOptions); + conditions = JsonSerializer.Deserialize>(conditionsJson, JsonOptions); } catch (JsonException ex) { return AccessRuleValidationResult.Invalid($"Conditions JSON is malformed: {ex.Message}"); } - if (condition is null) + if (conditions is null) { - return AccessRuleValidationResult.Invalid("Conditions must be an object."); + return AccessRuleValidationResult.Invalid("Conditions must be an array."); } - return ValidateCondition(condition, depth: 0); + // An empty list is allowed: it is vacuously satisfied, so the rule governs its collections — routing access + // through the PAM flow for audit logging — without imposing any gating condition. The engine evaluates it + // to Allow. + if (conditions.Count > MaxConditions) + { + return AccessRuleValidationResult.Invalid($"Conditions cannot contain more than {MaxConditions} conditions."); + } + + foreach (var condition in conditions) + { + var result = ValidateCondition(condition); + if (!result.IsValid) + { + return result; + } + } + + return AccessRuleValidationResult.Valid; } - private static AccessRuleValidationResult ValidateCondition(AccessCondition condition, int depth) + private static AccessRuleValidationResult ValidateCondition(AccessCondition? condition) { return condition switch { HumanApprovalCondition => AccessRuleValidationResult.Valid, IpAllowlistCondition ip => ValidateIpAllowlist(ip), TimeOfDayCondition tod => ValidateTimeOfDay(tod), - AllOfCondition all => ValidateAllOf(all, depth), + null => AccessRuleValidationResult.Invalid("Conditions cannot contain a null entry."), _ => AccessRuleValidationResult.Invalid($"Unsupported condition kind: {condition.GetType().Name}."), }; } @@ -135,31 +151,4 @@ private static AccessRuleValidationResult ValidateTimeOfDay(TimeOfDayCondition c return AccessRuleValidationResult.Valid; } - - private static AccessRuleValidationResult ValidateAllOf(AllOfCondition condition, int depth) - { - if (depth >= MaxCompositeDepth) - { - return AccessRuleValidationResult.Invalid($"all_of nesting exceeds maximum depth of {MaxCompositeDepth}."); - } - - // An empty all_of is allowed: it is vacuously satisfied, so the rule governs its collections — routing - // access through the PAM flow for audit logging — without imposing any gating condition. The engine - // evaluates it to Allow. - if (condition.Conditions.Count > MaxCompositeChildren) - { - return AccessRuleValidationResult.Invalid($"all_of cannot contain more than {MaxCompositeChildren} child conditions."); - } - - foreach (var child in condition.Conditions) - { - var childResult = ValidateCondition(child, depth + 1); - if (!childResult.IsValid) - { - return childResult; - } - } - - return AccessRuleValidationResult.Valid; - } } diff --git a/src/Core/Pam/Services/GoverningRuleResolver.cs b/src/Core/Pam/Services/GoverningRuleResolver.cs index 1dd2e82dd27f..c9a6309f69f5 100644 --- a/src/Core/Pam/Services/GoverningRuleResolver.cs +++ b/src/Core/Pam/Services/GoverningRuleResolver.cs @@ -53,18 +53,18 @@ public GoverningRuleResolver( continue; } - var condition = Parse(accessRule.Conditions); - if (ContainsHumanApproval(condition)) + var conditions = Parse(accessRule.Conditions); + if (ContainsHumanApproval(conditions)) { // Most restrictive wins — return as soon as a human-approval condition is found. - return new GoverningRule(collection.OrganizationId, collection.Id, true, condition) + return new GoverningRule(collection.OrganizationId, collection.Id, true, conditions) { AllowsExtensions = accessRule.AllowsExtensions, MaxExtensionDurationSeconds = accessRule.MaxExtensionDurationSeconds, }; } - automatic ??= new GoverningRule(collection.OrganizationId, collection.Id, false, condition) + automatic ??= new GoverningRule(collection.OrganizationId, collection.Id, false, conditions) { AllowsExtensions = accessRule.AllowsExtensions, MaxExtensionDurationSeconds = accessRule.MaxExtensionDurationSeconds, @@ -75,27 +75,25 @@ public GoverningRuleResolver( } /// - /// Parses the stored conditions JSON into an . A malformed or unparseable document - /// fails safe to a so access is never silently auto-approved on conditions - /// the server could not understand; the human-approval path then routes it to an approver rather than issuing an - /// automatic lease. + /// Parses the stored conditions JSON into a flat list of . A malformed or + /// unparseable document fails safe to a single human-approval condition so access is never silently auto-approved + /// on conditions the server could not understand; the human-approval path then routes it to an approver rather + /// than issuing an automatic lease. /// - private static AccessCondition Parse(string conditionsJson) + private static IReadOnlyList Parse(string conditionsJson) { try { - return JsonSerializer.Deserialize(conditionsJson, _jsonOptions) ?? new HumanApprovalCondition(); + return JsonSerializer.Deserialize>(conditionsJson, _jsonOptions) ?? FailSafe(); } catch (JsonException) { - return new HumanApprovalCondition(); + return FailSafe(); } } - private static bool ContainsHumanApproval(AccessCondition condition) => condition switch - { - HumanApprovalCondition => true, - AllOfCondition all => all.Conditions.Any(ContainsHumanApproval), - _ => false, - }; + private static IReadOnlyList FailSafe() => [new HumanApprovalCondition()]; + + private static bool ContainsHumanApproval(IReadOnlyList conditions) => + conditions.Any(condition => condition is HumanApprovalCondition); } diff --git a/test/Core.Test/Pam/Commands/RequestLeaseExtensionCommandTests.cs b/test/Core.Test/Pam/Commands/RequestLeaseExtensionCommandTests.cs index 0f75af04474e..fa1d34d71c99 100644 --- a/test/Core.Test/Pam/Commands/RequestLeaseExtensionCommandTests.cs +++ b/test/Core.Test/Pam/Commands/RequestLeaseExtensionCommandTests.cs @@ -233,7 +233,7 @@ private static void SetupExtendableLease( sutProvider.GetDependency() .ResolveAsync(lease.RequesterId, lease.CipherId) .Returns(new GoverningRule(lease.OrganizationId, lease.CollectionId, RequiresHumanApproval: true, - new HumanApprovalCondition()) + [new HumanApprovalCondition()]) { AllowsExtensions = allowsExtensions, MaxExtensionDurationSeconds = _maxExtensionDurationSeconds, diff --git a/test/Core.Test/Pam/Commands/SubmitAccessRequestCommandTests.cs b/test/Core.Test/Pam/Commands/SubmitAccessRequestCommandTests.cs index ebf86612d762..7ace09e04ec8 100644 --- a/test/Core.Test/Pam/Commands/SubmitAccessRequestCommandTests.cs +++ b/test/Core.Test/Pam/Commands/SubmitAccessRequestCommandTests.cs @@ -281,13 +281,13 @@ private static void SetupResolution(SutProvider sutP var condition = requiresHuman ? new HumanApprovalCondition() : (AccessCondition)new IpAllowlistCondition { Cidrs = ["10.0.0.0/8"] }; sutProvider.GetDependency() .ResolveAsync(userId, cipherId) - .Returns(new GoverningRule(orgId, collectionId, requiresHuman, condition)); + .Returns(new GoverningRule(orgId, collectionId, requiresHuman, [condition])); } private static void SetupEvaluation(SutProvider sutProvider, AccessEvaluation evaluation) { sutProvider.GetDependency() - .Evaluate(Arg.Any(), Arg.Any()) + .Evaluate(Arg.Any>(), Arg.Any()) .Returns(evaluation); } } diff --git a/test/Core.Test/Pam/Engine/AccessRuleEngineTests.cs b/test/Core.Test/Pam/Engine/AccessRuleEngineTests.cs index 82bda85d532c..e70018af24a8 100644 --- a/test/Core.Test/Pam/Engine/AccessRuleEngineTests.cs +++ b/test/Core.Test/Pam/Engine/AccessRuleEngineTests.cs @@ -18,10 +18,12 @@ public class AccessRuleEngineTests Timestamp = at ?? _now, }; + private static AccessCondition[] Set(params AccessCondition[] conditions) => conditions; + [Fact] public void Evaluate_HumanApproval_RequiresApproval() { - var evaluation = _sut.Evaluate(new HumanApprovalCondition(), Signals()); + var evaluation = _sut.Evaluate(Set(new HumanApprovalCondition()), Signals()); Assert.Equal(AccessEvaluationOutcome.RequiresApproval, evaluation.Outcome); } @@ -29,9 +31,9 @@ public void Evaluate_HumanApproval_RequiresApproval() [Fact] public void Evaluate_IpAllowlist_IpInRange_Allows() { - var rule = new IpAllowlistCondition { Cidrs = ["10.0.0.0/8"] }; + var conditions = Set(new IpAllowlistCondition { Cidrs = ["10.0.0.0/8"] }); - var evaluation = _sut.Evaluate(rule, Signals(IPAddress.Parse("10.1.2.3"))); + var evaluation = _sut.Evaluate(conditions, Signals(IPAddress.Parse("10.1.2.3"))); Assert.Equal(AccessEvaluationOutcome.Allow, evaluation.Outcome); } @@ -39,9 +41,9 @@ public void Evaluate_IpAllowlist_IpInRange_Allows() [Fact] public void Evaluate_IpAllowlist_IpOutOfRange_Denies() { - var rule = new IpAllowlistCondition { Cidrs = ["10.0.0.0/8"] }; + var conditions = Set(new IpAllowlistCondition { Cidrs = ["10.0.0.0/8"] }); - var evaluation = _sut.Evaluate(rule, Signals(IPAddress.Parse("192.168.1.1"))); + var evaluation = _sut.Evaluate(conditions, Signals(IPAddress.Parse("192.168.1.1"))); Assert.Equal(AccessEvaluationOutcome.Deny, evaluation.Outcome); Assert.Equal(DenyReason.NotWithinIpRange, evaluation.Reason); @@ -50,9 +52,9 @@ public void Evaluate_IpAllowlist_IpOutOfRange_Denies() [Fact] public void Evaluate_IpAllowlist_UnknownIp_DeniesClosed() { - var rule = new IpAllowlistCondition { Cidrs = ["10.0.0.0/8"] }; + var conditions = Set(new IpAllowlistCondition { Cidrs = ["10.0.0.0/8"] }); - var evaluation = _sut.Evaluate(rule, Signals(ip: null)); + var evaluation = _sut.Evaluate(conditions, Signals(ip: null)); Assert.Equal(AccessEvaluationOutcome.Deny, evaluation.Outcome); Assert.Equal(DenyReason.NotWithinIpRange, evaluation.Reason); @@ -61,7 +63,7 @@ public void Evaluate_IpAllowlist_UnknownIp_DeniesClosed() [Fact] public void Evaluate_IpAllowlist_NoEntries_DeniesClosed() { - var evaluation = _sut.Evaluate(new IpAllowlistCondition(), Signals(IPAddress.Parse("10.1.2.3"))); + var evaluation = _sut.Evaluate(Set(new IpAllowlistCondition()), Signals(IPAddress.Parse("10.1.2.3"))); Assert.Equal(AccessEvaluationOutcome.Deny, evaluation.Outcome); Assert.Equal(DenyReason.NotWithinIpRange, evaluation.Reason); @@ -70,13 +72,13 @@ public void Evaluate_IpAllowlist_NoEntries_DeniesClosed() [Fact] public void Evaluate_TimeOfDay_WithinWindow_Allows() { - var rule = new TimeOfDayCondition + var conditions = Set(new TimeOfDayCondition { Tz = "UTC", Windows = [new TimeWindow { Days = ["thu"], From = "09:00", To = "17:00" }], - }; + }); - var evaluation = _sut.Evaluate(rule, Signals()); + var evaluation = _sut.Evaluate(conditions, Signals()); Assert.Equal(AccessEvaluationOutcome.Allow, evaluation.Outcome); } @@ -84,13 +86,13 @@ public void Evaluate_TimeOfDay_WithinWindow_Allows() [Fact] public void Evaluate_TimeOfDay_OutsideTimeWindow_Denies() { - var rule = new TimeOfDayCondition + var conditions = Set(new TimeOfDayCondition { Tz = "UTC", Windows = [new TimeWindow { Days = ["thu"], From = "00:00", To = "06:00" }], - }; + }); - var evaluation = _sut.Evaluate(rule, Signals()); + var evaluation = _sut.Evaluate(conditions, Signals()); Assert.Equal(AccessEvaluationOutcome.Deny, evaluation.Outcome); Assert.Equal(DenyReason.NotWithinTimeWindow, evaluation.Reason); @@ -99,13 +101,13 @@ public void Evaluate_TimeOfDay_OutsideTimeWindow_Denies() [Fact] public void Evaluate_TimeOfDay_DayNotListed_Denies() { - var rule = new TimeOfDayCondition + var conditions = Set(new TimeOfDayCondition { Tz = "UTC", Windows = [new TimeWindow { Days = ["fri"], From = "00:00", To = "23:59" }], - }; + }); - var evaluation = _sut.Evaluate(rule, Signals()); + var evaluation = _sut.Evaluate(conditions, Signals()); Assert.Equal(AccessEvaluationOutcome.Deny, evaluation.Outcome); Assert.Equal(DenyReason.NotWithinTimeWindow, evaluation.Reason); @@ -115,13 +117,13 @@ public void Evaluate_TimeOfDay_DayNotListed_Denies() public void Evaluate_TimeOfDay_EvaluatesInConfiguredTimezone() { // 23:00 UTC is 19:00 (Thursday) in America/New_York during June DST, inside the window. - var rule = new TimeOfDayCondition + var conditions = Set(new TimeOfDayCondition { Tz = "America/New_York", Windows = [new TimeWindow { Days = ["thu"], From = "18:00", To = "20:00" }], - }; + }); - var evaluation = _sut.Evaluate(rule, Signals(at: new DateTimeOffset(2026, 6, 4, 23, 0, 0, TimeSpan.Zero))); + var evaluation = _sut.Evaluate(conditions, Signals(at: new DateTimeOffset(2026, 6, 4, 23, 0, 0, TimeSpan.Zero))); Assert.Equal(AccessEvaluationOutcome.Allow, evaluation.Outcome); } @@ -129,112 +131,75 @@ public void Evaluate_TimeOfDay_EvaluatesInConfiguredTimezone() [Fact] public void Evaluate_TimeOfDay_UnknownTimezone_DeniesClosed() { - var rule = new TimeOfDayCondition + var conditions = Set(new TimeOfDayCondition { Tz = "Not/AZone", Windows = [new TimeWindow { Days = ["thu"], From = "00:00", To = "23:59" }], - }; + }); - var evaluation = _sut.Evaluate(rule, Signals()); + var evaluation = _sut.Evaluate(conditions, Signals()); Assert.Equal(AccessEvaluationOutcome.Deny, evaluation.Outcome); Assert.Equal(DenyReason.NotWithinTimeWindow, evaluation.Reason); } [Fact] - public void Evaluate_AllOf_AllAllow_Allows() + public void Evaluate_AllConditionsAllow_Allows() { - var rule = new AllOfCondition - { - Conditions = - [ - new IpAllowlistCondition { Cidrs = ["10.0.0.0/8"] }, - new TimeOfDayCondition { Tz = "UTC", Windows = [new TimeWindow { Days = ["thu"], From = "09:00", To = "17:00" }] }, - ], - }; + var conditions = Set( + new IpAllowlistCondition { Cidrs = ["10.0.0.0/8"] }, + new TimeOfDayCondition { Tz = "UTC", Windows = [new TimeWindow { Days = ["thu"], From = "09:00", To = "17:00" }] }); - var evaluation = _sut.Evaluate(rule, Signals(IPAddress.Parse("10.1.2.3"))); + var evaluation = _sut.Evaluate(conditions, Signals(IPAddress.Parse("10.1.2.3"))); Assert.Equal(AccessEvaluationOutcome.Allow, evaluation.Outcome); } [Fact] - public void Evaluate_AllOf_OneDenies_DeniesWithThatReason() + public void Evaluate_OneConditionDenies_DeniesWithThatReason() { - var rule = new AllOfCondition - { - Conditions = - [ - new IpAllowlistCondition { Cidrs = ["10.0.0.0/8"] }, - new TimeOfDayCondition { Tz = "UTC", Windows = [new TimeWindow { Days = ["thu"], From = "00:00", To = "06:00" }] }, - ], - }; + var conditions = Set( + new IpAllowlistCondition { Cidrs = ["10.0.0.0/8"] }, + new TimeOfDayCondition { Tz = "UTC", Windows = [new TimeWindow { Days = ["thu"], From = "00:00", To = "06:00" }] }); - var evaluation = _sut.Evaluate(rule, Signals(IPAddress.Parse("10.1.2.3"))); + var evaluation = _sut.Evaluate(conditions, Signals(IPAddress.Parse("10.1.2.3"))); Assert.Equal(AccessEvaluationOutcome.Deny, evaluation.Outcome); Assert.Equal(DenyReason.NotWithinTimeWindow, evaluation.Reason); } [Fact] - public void Evaluate_AllOf_AllowPlusHumanApproval_RequiresApproval() + public void Evaluate_AllowPlusHumanApproval_RequiresApproval() { - var rule = new AllOfCondition - { - Conditions = - [ - new IpAllowlistCondition { Cidrs = ["10.0.0.0/8"] }, - new HumanApprovalCondition(), - ], - }; + var conditions = Set( + new IpAllowlistCondition { Cidrs = ["10.0.0.0/8"] }, + new HumanApprovalCondition()); - var evaluation = _sut.Evaluate(rule, Signals(IPAddress.Parse("10.1.2.3"))); + var evaluation = _sut.Evaluate(conditions, Signals(IPAddress.Parse("10.1.2.3"))); Assert.Equal(AccessEvaluationOutcome.RequiresApproval, evaluation.Outcome); } [Fact] - public void Evaluate_AllOf_DenyOutranksApproval() + public void Evaluate_DenyOutranksApproval() { // A denying condition beats a pending approval: there is nothing to approve if access is barred outright. - var rule = new AllOfCondition - { - Conditions = - [ - new HumanApprovalCondition(), - new IpAllowlistCondition { Cidrs = ["10.0.0.0/8"] }, - ], - }; + var conditions = Set( + new HumanApprovalCondition(), + new IpAllowlistCondition { Cidrs = ["10.0.0.0/8"] }); - var evaluation = _sut.Evaluate(rule, Signals(IPAddress.Parse("192.168.1.1"))); + var evaluation = _sut.Evaluate(conditions, Signals(IPAddress.Parse("192.168.1.1"))); Assert.Equal(AccessEvaluationOutcome.Deny, evaluation.Outcome); Assert.Equal(DenyReason.NotWithinIpRange, evaluation.Reason); } [Fact] - public void Evaluate_AllOf_NoChildren_Allows() + public void Evaluate_NoConditions_Allows() { // A rule with no conditions is vacuously satisfied: access is auto-granted while still flowing through // PAM for audit logging. - var evaluation = _sut.Evaluate(new AllOfCondition(), Signals()); - - Assert.Equal(AccessEvaluationOutcome.Allow, evaluation.Outcome); - } - - [Fact] - public void Evaluate_NestedAllOf_Allows() - { - var rule = new AllOfCondition - { - Conditions = - [ - new AllOfCondition { Conditions = [new IpAllowlistCondition { Cidrs = ["10.0.0.0/8"] }] }, - new TimeOfDayCondition { Tz = "UTC", Windows = [new TimeWindow { Days = ["thu"], From = "09:00", To = "17:00" }] }, - ], - }; - - var evaluation = _sut.Evaluate(rule, Signals(IPAddress.Parse("10.1.2.3"))); + var evaluation = _sut.Evaluate(Set(), Signals()); Assert.Equal(AccessEvaluationOutcome.Allow, evaluation.Outcome); } @@ -242,7 +207,7 @@ public void Evaluate_NestedAllOf_Allows() [Fact] public void Evaluate_UnsupportedConditionKind_DeniesClosed() { - var evaluation = _sut.Evaluate(new UnknownCondition(), Signals()); + var evaluation = _sut.Evaluate(Set(new UnknownCondition()), Signals()); Assert.Equal(AccessEvaluationOutcome.Deny, evaluation.Outcome); Assert.Equal(DenyReason.UnsupportedCondition, evaluation.Reason); diff --git a/test/Core.Test/Pam/Queries/AccessPreCheckQueryTests.cs b/test/Core.Test/Pam/Queries/AccessPreCheckQueryTests.cs index a206a4e1ff2a..447d566cd29e 100644 --- a/test/Core.Test/Pam/Queries/AccessPreCheckQueryTests.cs +++ b/test/Core.Test/Pam/Queries/AccessPreCheckQueryTests.cs @@ -36,7 +36,8 @@ public async Task PreCheckAsync_HumanApprovalCondition_ReturnsHuman( SetupCipher(sutProvider, userId, cipherId); sutProvider.GetDependency() .ResolveAsync(userId, cipherId) - .Returns(new GoverningRule(orgId, collectionId, RequiresHumanApproval: true, new HumanApprovalCondition())); + .Returns(new GoverningRule(orgId, collectionId, RequiresHumanApproval: true, + [new HumanApprovalCondition()])); var result = await sutProvider.Sut.PreCheckAsync(userId, cipherId); @@ -50,7 +51,8 @@ public async Task PreCheckAsync_AutoApproveRule_ReturnsAutomatic( SetupCipher(sutProvider, userId, cipherId); sutProvider.GetDependency() .ResolveAsync(userId, cipherId) - .Returns(new GoverningRule(orgId, collectionId, RequiresHumanApproval: false, new IpAllowlistCondition { Cidrs = ["10.0.0.0/8"] })); + .Returns(new GoverningRule(orgId, collectionId, RequiresHumanApproval: false, + [new IpAllowlistCondition { Cidrs = ["10.0.0.0/8"] }])); var result = await sutProvider.Sut.PreCheckAsync(userId, cipherId); diff --git a/test/Core.Test/Pam/Queries/GetCipherAccessStateQueryTests.cs b/test/Core.Test/Pam/Queries/GetCipherAccessStateQueryTests.cs index 836dfe7445a8..ddfb0c14ad92 100644 --- a/test/Core.Test/Pam/Queries/GetCipherAccessStateQueryTests.cs +++ b/test/Core.Test/Pam/Queries/GetCipherAccessStateQueryTests.cs @@ -156,7 +156,8 @@ public async Task GetStateAsync_GatedButEmpty_ReturnsEmptySnapshot( SetupCipher(sutProvider, userId, cipherId); sutProvider.GetDependency() .ResolveAsync(userId, cipherId) - .Returns(new GoverningRule(orgId, collectionId, RequiresHumanApproval: true, new HumanApprovalCondition())); + .Returns(new GoverningRule(orgId, collectionId, RequiresHumanApproval: true, + [new HumanApprovalCondition()])); var result = await sutProvider.Sut.GetStateAsync(userId, cipherId); @@ -177,7 +178,8 @@ public async Task GetStateAsync_ActiveLease_NotYetExtended_AllowedWithMaxLength( .Returns(activeLease); sutProvider.GetDependency() .ResolveAsync(userId, cipherId) - .Returns(new GoverningRule(orgId, collectionId, RequiresHumanApproval: false, new HumanApprovalCondition()) + .Returns(new GoverningRule(orgId, collectionId, RequiresHumanApproval: false, + [new HumanApprovalCondition()]) { AllowsExtensions = true, MaxExtensionDurationSeconds = 4 * 60 * 60, @@ -202,7 +204,8 @@ public async Task GetStateAsync_ActiveLease_AlreadyExtended_NotAllowed( .Returns(activeLease); sutProvider.GetDependency() .ResolveAsync(userId, cipherId) - .Returns(new GoverningRule(orgId, collectionId, RequiresHumanApproval: false, new HumanApprovalCondition()) + .Returns(new GoverningRule(orgId, collectionId, RequiresHumanApproval: false, + [new HumanApprovalCondition()]) { AllowsExtensions = true, MaxExtensionDurationSeconds = 2 * 60 * 60, @@ -228,7 +231,8 @@ public async Task GetStateAsync_ActiveLease_ExtensionsDisallowed_ReportsNotAllow .Returns(activeLease); sutProvider.GetDependency() .ResolveAsync(userId, cipherId) - .Returns(new GoverningRule(orgId, collectionId, RequiresHumanApproval: false, new HumanApprovalCondition()) + .Returns(new GoverningRule(orgId, collectionId, RequiresHumanApproval: false, + [new HumanApprovalCondition()]) { AllowsExtensions = false, }); diff --git a/test/Core.Test/Pam/Queries/GetLeasedCipherQueryTests.cs b/test/Core.Test/Pam/Queries/GetLeasedCipherQueryTests.cs index d11e10e31f49..ae3e51eda450 100644 --- a/test/Core.Test/Pam/Queries/GetLeasedCipherQueryTests.cs +++ b/test/Core.Test/Pam/Queries/GetLeasedCipherQueryTests.cs @@ -146,13 +146,14 @@ private static void SetupResolution(SutProvider sutProvide { sutProvider.GetDependency() .ResolveAsync(userId, cipherId) - .Returns(new GoverningRule(orgId, collectionId, false, new IpAllowlistCondition { Cidrs = ["10.0.0.0/8"] })); + .Returns(new GoverningRule(orgId, collectionId, false, + [new IpAllowlistCondition { Cidrs = ["10.0.0.0/8"] }])); } private static void SetupEvaluation(SutProvider sutProvider, AccessEvaluation decision) { sutProvider.GetDependency() - .Evaluate(Arg.Any(), Arg.Any()) + .Evaluate(Arg.Any>(), Arg.Any()) .Returns(decision); } } diff --git a/test/Core.Test/Pam/Services/AccessRuleValidatorTests.cs b/test/Core.Test/Pam/Services/AccessRuleValidatorTests.cs index 983ea3f367ab..df0d84be50c3 100644 --- a/test/Core.Test/Pam/Services/AccessRuleValidatorTests.cs +++ b/test/Core.Test/Pam/Services/AccessRuleValidatorTests.cs @@ -34,10 +34,29 @@ public void Validate_MalformedJson_IsInvalid() Assert.Contains("malformed", result.Error); } + [Fact] + public void Validate_NonArrayDocument_IsInvalid() + { + // The conditions document is a flat array; a bare object is rejected. + var result = _sut.Validate("""{"kind":"human_approval"}"""); + + Assert.False(result.IsValid); + } + [Fact] public void Validate_UnknownKind_IsInvalid() { - var result = _sut.Validate("""{"kind":"bogus"}"""); + var result = _sut.Validate("""[{"kind":"bogus"}]"""); + + Assert.False(result.IsValid); + } + + [Fact] + public void Validate_LegacyAllOfKind_IsInvalid() + { + // The flattened model dropped the all_of composite; a document that still nests one is rejected rather than + // silently accepted. + var result = _sut.Validate("""[{"kind":"all_of","conditions":[]}]"""); Assert.False(result.IsValid); } @@ -45,14 +64,14 @@ public void Validate_UnknownKind_IsInvalid() [Fact] public void Validate_HumanApproval_IsValid() { - var result = _sut.Validate("""{"kind":"human_approval"}"""); + var result = _sut.Validate("""[{"kind":"human_approval"}]"""); Assert.True(result.IsValid); } [Theory] - [InlineData("""{"kind":"ip_allowlist","cidrs":["10.0.0.0/8"]}""")] - [InlineData("""{"kind":"ip_allowlist","cidrs":["10.0.0.0/8","192.168.0.0/16","2001:db8::/32"]}""")] + [InlineData("""[{"kind":"ip_allowlist","cidrs":["10.0.0.0/8"]}]""")] + [InlineData("""[{"kind":"ip_allowlist","cidrs":["10.0.0.0/8","192.168.0.0/16","2001:db8::/32"]}]""")] public void Validate_IpAllowlist_ValidCidrs_IsValid(string conditionsJson) { var result = _sut.Validate(conditionsJson); @@ -61,9 +80,9 @@ public void Validate_IpAllowlist_ValidCidrs_IsValid(string conditionsJson) } [Theory] - [InlineData("""{"kind":"ip_allowlist","cidrs":[]}""", "at least one CIDR")] - [InlineData("""{"kind":"ip_allowlist","cidrs":["not-a-cidr"]}""", "Invalid CIDR")] - [InlineData("""{"kind":"ip_allowlist","cidrs":["10.0.0.0/99"]}""", "Invalid CIDR")] + [InlineData("""[{"kind":"ip_allowlist","cidrs":[]}]""", "at least one CIDR")] + [InlineData("""[{"kind":"ip_allowlist","cidrs":["not-a-cidr"]}]""", "Invalid CIDR")] + [InlineData("""[{"kind":"ip_allowlist","cidrs":["10.0.0.0/99"]}]""", "Invalid CIDR")] public void Validate_IpAllowlist_InvalidCidrs_IsInvalid(string conditionsJson, string expectedMessageFragment) { var result = _sut.Validate(conditionsJson); @@ -76,25 +95,27 @@ public void Validate_IpAllowlist_InvalidCidrs_IsInvalid(string conditionsJson, s public void Validate_TimeOfDay_Valid_IsValid() { var result = _sut.Validate(""" - { - "kind": "time_of_day", - "tz": "UTC", - "windows": [ - { "days": ["mon","tue","wed","thu","fri"], "from": "09:00", "to": "18:00" } - ] - } + [ + { + "kind": "time_of_day", + "tz": "UTC", + "windows": [ + { "days": ["mon","tue","wed","thu","fri"], "from": "09:00", "to": "18:00" } + ] + } + ] """); Assert.True(result.IsValid); } [Theory] - [InlineData("""{"kind":"time_of_day","tz":"Invalid/Zone","windows":[{"days":["mon"],"from":"09:00","to":"17:00"}]}""", "timezone")] - [InlineData("""{"kind":"time_of_day","tz":"UTC","windows":[]}""", "at least one window")] - [InlineData("""{"kind":"time_of_day","tz":"UTC","windows":[{"days":[],"from":"09:00","to":"17:00"}]}""", "at least one day")] - [InlineData("""{"kind":"time_of_day","tz":"UTC","windows":[{"days":["funday"],"from":"09:00","to":"17:00"}]}""", "day")] - [InlineData("""{"kind":"time_of_day","tz":"UTC","windows":[{"days":["mon"],"from":"9am","to":"5pm"}]}""", "Expected HH:mm")] - [InlineData("""{"kind":"time_of_day","tz":"UTC","windows":[{"days":["mon"],"from":"25:00","to":"26:00"}]}""", "Expected HH:mm")] + [InlineData("""[{"kind":"time_of_day","tz":"Invalid/Zone","windows":[{"days":["mon"],"from":"09:00","to":"17:00"}]}]""", "timezone")] + [InlineData("""[{"kind":"time_of_day","tz":"UTC","windows":[]}]""", "at least one window")] + [InlineData("""[{"kind":"time_of_day","tz":"UTC","windows":[{"days":[],"from":"09:00","to":"17:00"}]}]""", "at least one day")] + [InlineData("""[{"kind":"time_of_day","tz":"UTC","windows":[{"days":["funday"],"from":"09:00","to":"17:00"}]}]""", "day")] + [InlineData("""[{"kind":"time_of_day","tz":"UTC","windows":[{"days":["mon"],"from":"9am","to":"5pm"}]}]""", "Expected HH:mm")] + [InlineData("""[{"kind":"time_of_day","tz":"UTC","windows":[{"days":["mon"],"from":"25:00","to":"26:00"}]}]""", "Expected HH:mm")] public void Validate_TimeOfDay_Invalid_IsInvalid(string conditionsJson, string expectedMessageFragment) { var result = _sut.Validate(conditionsJson); @@ -104,76 +125,46 @@ public void Validate_TimeOfDay_Invalid_IsInvalid(string conditionsJson, string e } [Fact] - public void Validate_AllOf_NestedHumanAndIpAllowlist_IsValid() + public void Validate_MultipleConditions_IsValid() { var result = _sut.Validate(""" - { - "kind": "all_of", - "conditions": [ - { "kind": "human_approval" }, - { "kind": "ip_allowlist", "cidrs": ["10.0.0.0/8"] } - ] - } + [ + { "kind": "human_approval" }, + { "kind": "ip_allowlist", "cidrs": ["10.0.0.0/8"] } + ] """); Assert.True(result.IsValid); } [Fact] - public void Validate_AllOf_EmptyChildren_IsValid() + public void Validate_EmptyConditions_IsValid() { - // A rule with no conditions (an empty all_of) is allowed: it gates nothing and exists to route access - // through the PAM flow for audit logging. - var result = _sut.Validate("""{"kind":"all_of","conditions":[]}"""); + // A rule with no conditions is allowed: it gates nothing and exists to route access through the PAM flow + // for audit logging. + var result = _sut.Validate("[]"); Assert.True(result.IsValid); } [Fact] - public void Validate_AllOf_ExceedsMaxNestingDepth_IsInvalid() - { - // Depth 4: all_of > all_of > all_of > all_of > human_approval; limit is 3 - var result = _sut.Validate(""" - { - "kind": "all_of", - "conditions": [{ - "kind": "all_of", - "conditions": [{ - "kind": "all_of", - "conditions": [{ - "kind": "all_of", - "conditions": [{ "kind": "human_approval" }] - }] - }] - }] - } - """); - - Assert.False(result.IsValid); - Assert.Contains("nesting", result.Error); - } - - [Fact] - public void Validate_AllOf_ExceedsMaxChildren_IsInvalid() + public void Validate_ExceedsMaxConditions_IsInvalid() { var conditions = string.Join(",", Enumerable.Repeat("""{"kind":"human_approval"}""", 11)); - var result = _sut.Validate($$"""{"kind":"all_of","conditions":[{{conditions}}]}"""); + var result = _sut.Validate($$"""[{{conditions}}]"""); Assert.False(result.IsValid); Assert.Contains("more than", result.Error); } [Fact] - public void Validate_AllOf_InvalidChild_IsInvalid() + public void Validate_InvalidCondition_IsInvalid() { var result = _sut.Validate(""" - { - "kind": "all_of", - "conditions": [ - { "kind": "human_approval" }, - { "kind": "ip_allowlist", "cidrs": ["bogus"] } - ] - } + [ + { "kind": "human_approval" }, + { "kind": "ip_allowlist", "cidrs": ["bogus"] } + ] """); Assert.False(result.IsValid); diff --git a/test/Core.Test/Pam/Services/GoverningRuleResolverTests.cs b/test/Core.Test/Pam/Services/GoverningRuleResolverTests.cs index 9c4994ce5058..4afa521a67bb 100644 --- a/test/Core.Test/Pam/Services/GoverningRuleResolverTests.cs +++ b/test/Core.Test/Pam/Services/GoverningRuleResolverTests.cs @@ -39,7 +39,7 @@ public async Task ResolveAsync_CollectionWithoutAccessRule_ReturnsNull( public async Task ResolveAsync_HumanApprovalCondition_RequiresHumanApproval( SutProvider sutProvider, Guid userId, Guid cipherId, Collection collection, AccessRule rule) { - rule.Conditions = """{"kind":"human_approval"}"""; + rule.Conditions = """[{"kind":"human_approval"}]"""; SetupGovernedCollection(sutProvider, userId, cipherId, collection, rule); var result = await sutProvider.Sut.ResolveAsync(userId, cipherId); @@ -48,51 +48,52 @@ public async Task ResolveAsync_HumanApprovalCondition_RequiresHumanApproval( Assert.True(result!.RequiresHumanApproval); Assert.Equal(collection.Id, result.CollectionId); Assert.Equal(collection.OrganizationId, result.OrganizationId); - Assert.IsType(result.Condition); + Assert.IsType(Assert.Single(result.Conditions)); } [Theory, BitAutoData] public async Task ResolveAsync_IpAllowlistCondition_DoesNotRequireHumanApproval( SutProvider sutProvider, Guid userId, Guid cipherId, Collection collection, AccessRule rule) { - rule.Conditions = """{"kind":"ip_allowlist","cidrs":["10.0.0.0/8"]}"""; + rule.Conditions = """[{"kind":"ip_allowlist","cidrs":["10.0.0.0/8"]}]"""; SetupGovernedCollection(sutProvider, userId, cipherId, collection, rule); var result = await sutProvider.Sut.ResolveAsync(userId, cipherId); Assert.NotNull(result); Assert.False(result!.RequiresHumanApproval); - var ip = Assert.IsType(result.Condition); + var ip = Assert.IsType(Assert.Single(result.Conditions)); Assert.Equal("10.0.0.0/8", Assert.Single(ip.Cidrs)); } [Theory, BitAutoData] - public async Task ResolveAsync_AllOfContainingHumanApproval_RequiresHumanApproval( + public async Task ResolveAsync_ConditionsContainingHumanApproval_RequiresHumanApproval( SutProvider sutProvider, Guid userId, Guid cipherId, Collection collection, AccessRule rule) { - rule.Conditions = """{"kind":"all_of","conditions":[{"kind":"ip_allowlist","cidrs":["10.0.0.0/8"]},{"kind":"human_approval"}]}"""; + rule.Conditions = """[{"kind":"ip_allowlist","cidrs":["10.0.0.0/8"]},{"kind":"human_approval"}]"""; SetupGovernedCollection(sutProvider, userId, cipherId, collection, rule); var result = await sutProvider.Sut.ResolveAsync(userId, cipherId); Assert.NotNull(result); Assert.True(result!.RequiresHumanApproval); - Assert.IsType(result.Condition); + Assert.Equal(2, result.Conditions.Count); + Assert.Contains(result.Conditions, condition => condition is HumanApprovalCondition); } [Theory, BitAutoData] - public async Task ResolveAsync_EmptyAllOf_DoesNotRequireHumanApproval( + public async Task ResolveAsync_EmptyConditions_DoesNotRequireHumanApproval( SutProvider sutProvider, Guid userId, Guid cipherId, Collection collection, AccessRule rule) { - // A conditionless rule (empty all_of) governs the collection for audit logging but auto-approves access. - rule.Conditions = """{"kind":"all_of","conditions":[]}"""; + // A conditionless rule governs the collection for audit logging but auto-approves access. + rule.Conditions = "[]"; SetupGovernedCollection(sutProvider, userId, cipherId, collection, rule); var result = await sutProvider.Sut.ResolveAsync(userId, cipherId); Assert.NotNull(result); Assert.False(result!.RequiresHumanApproval); - Assert.IsType(result.Condition); + Assert.Empty(result.Conditions); } [Theory, BitAutoData] @@ -107,7 +108,7 @@ public async Task ResolveAsync_MalformedRule_FailsSafeToHumanApproval( Assert.NotNull(result); Assert.True(result!.RequiresHumanApproval); // An unparseable rule fails safe to human approval rather than surfacing a rule the engine cannot evaluate. - Assert.IsType(result.Condition); + Assert.IsType(Assert.Single(result.Conditions)); } private static void SetupReachableCollections( From 82c75c157c71b1032003cc568f11e2ac22d99759 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anders=20=C3=85berg?= Date: Mon, 15 Jun 2026 14:43:11 +0200 Subject: [PATCH 30/54] Restructure PAM leasing API to /access-requests and /leases (v2 routes) --- .../Controllers/ApproverInboxController.cs | 7 +++--- .../Pam/Controllers/CipherLeaseController.cs | 2 +- .../Controllers/MemberLeasingController.cs | 23 ++++++++++--------- .../MemberLeasingControllerTests.cs | 4 ++-- 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/Api/Pam/Controllers/ApproverInboxController.cs b/src/Api/Pam/Controllers/ApproverInboxController.cs index bddf4d14df87..4c5ad47b7e35 100644 --- a/src/Api/Pam/Controllers/ApproverInboxController.cs +++ b/src/Api/Pam/Controllers/ApproverInboxController.cs @@ -11,7 +11,6 @@ namespace Bit.Api.Pam.Controllers; -[Route("leasing")] [Authorize("Application")] [RequireFeature(FeatureFlagKeys.Pam)] public class ApproverInboxController( @@ -26,7 +25,7 @@ public class ApproverInboxController( /// Returns the caller's pending approver queue: requests on collections the caller can Manage that are still /// awaiting a decision. /// - [HttpGet("inbox/requests")] + [HttpGet("access-requests/inbox")] public async Task> GetRequests() { var userId = userService.GetProperUserId(User)!.Value; @@ -38,7 +37,7 @@ public async Task> GetReque /// /// Returns the caller's resolved approver queue (decision history and lease outcomes) within the retention window. /// - [HttpGet("inbox/history")] + [HttpGet("access-requests/history")] public async Task> GetHistory() { var userId = userService.GetProperUserId(User)!.Value; @@ -51,7 +50,7 @@ public async Task> GetHisto /// Approves or denies a pending lease request. The caller must be able to Manage the request's collection and may /// not decide their own request. /// - [HttpPost("requests/{id:guid}/decision")] + [HttpPost("access-requests/{id:guid}/decision")] public async Task Decide(Guid id, [FromBody] AccessDecisionRequestModel model) { var userId = userService.GetProperUserId(User)!.Value; diff --git a/src/Api/Pam/Controllers/CipherLeaseController.cs b/src/Api/Pam/Controllers/CipherLeaseController.cs index 2036027ddd54..e011ee324cfb 100644 --- a/src/Api/Pam/Controllers/CipherLeaseController.cs +++ b/src/Api/Pam/Controllers/CipherLeaseController.cs @@ -56,7 +56,7 @@ public async Task State(Guid id) /// /// Submits a request to lease this cipher. The automatic path creates an already-approved request the requester /// then activates to start the lease; the human path creates a pending request for an approver. Neither mints a - /// lease here — the requester activates the approved request (POST leasing/requests/{id}/activate). + /// lease here — the requester activates the approved request (POST access-requests/{id}/activate). /// [HttpPost("")] public async Task Post(Guid id, [FromBody] AccessRequestCreateRequestModel model) diff --git a/src/Api/Pam/Controllers/MemberLeasingController.cs b/src/Api/Pam/Controllers/MemberLeasingController.cs index e04363f115e2..8b78f5eff2f7 100644 --- a/src/Api/Pam/Controllers/MemberLeasingController.cs +++ b/src/Api/Pam/Controllers/MemberLeasingController.cs @@ -12,11 +12,11 @@ namespace Bit.Api.Pam.Controllers; /// -/// Caller-scoped leasing surface: a user's own access requests and active leases, spanning every organization they +/// Caller-scoped surface: a user's own access requests and active leases, spanning every organization they /// belong to, plus activation of their approved requests. Distinct from the approver-facing surface on -/// . Both share the leasing route prefix; the templates don't overlap. +/// ; both expose actions under the top-level access-requests and +/// leases resources. /// -[Route("leasing")] [Authorize("Application")] [RequireFeature(FeatureFlagKeys.Pam)] public class MemberLeasingController( @@ -32,7 +32,7 @@ public class MemberLeasingController( /// Returns the caller's own access requests across all their organizations, regardless of status. The client /// re-sorts and splits into pending/recent. /// - [HttpGet("requests/mine")] + [HttpGet("access-requests/mine")] public async Task> GetMyRequests() { var userId = userService.GetProperUserId(User)!.Value; @@ -44,7 +44,7 @@ public async Task> GetMyReq /// /// Returns the caller's currently-active leases across all their organizations. /// - [HttpGet("leases/mine/active")] + [HttpGet("leases/mine")] public async Task> GetMyActiveLeases() { var userId = userService.GetProperUserId(User)!.Value; @@ -58,7 +58,7 @@ public async Task> GetMyActiveLeases /// request's approved window. Only the requester may activate, and only while the window is open. Repeat calls /// while the produced lease is live return that lease. /// - [HttpPost("requests/{id:guid}/activate")] + [HttpPost("access-requests/{id:guid}/activate")] public async Task Activate(Guid id) { var userId = userService.GetProperUserId(User)!.Value; @@ -67,13 +67,14 @@ public async Task Activate(Guid id) } /// - /// Cancels an access request that has not produced a lease. The requester may cancel their own request, and a - /// managing approver may cancel any request on a collection they manage; either way the request must still be + /// Revokes an access request that has not produced a lease, ending it without minting access. Caller-dependent: + /// the requester withdrawing their own request ends it as cancelled; a managing approver retracting a + /// request on a collection they manage ends it as denied. Either way the request must still be /// pending or an unactivated approved request. A request that has produced a lease (revoke the lease - /// instead) or is otherwise resolved can no longer be cancelled. + /// instead) or is otherwise resolved can no longer be revoked. /// - [HttpDelete("requests/{id:guid}")] - public async Task CancelRequest(Guid id) + [HttpPost("access-requests/{id:guid}/revoke")] + public async Task RevokeRequest(Guid id) { var userId = userService.GetProperUserId(User)!.Value; await cancelAccessRequestCommand.CancelAsync(userId, id); diff --git a/test/Api.Test/Pam/Controllers/MemberLeasingControllerTests.cs b/test/Api.Test/Pam/Controllers/MemberLeasingControllerTests.cs index 451512c4b1ec..3d571b7ec220 100644 --- a/test/Api.Test/Pam/Controllers/MemberLeasingControllerTests.cs +++ b/test/Api.Test/Pam/Controllers/MemberLeasingControllerTests.cs @@ -78,12 +78,12 @@ public async Task Activate_ReturnsMintedLease( } [Theory, BitAutoData] - public async Task CancelRequest_CancelsCallersRequest_ReturnsNoContent( + public async Task RevokeRequest_RevokesCallersRequest_ReturnsNoContent( Guid userId, Guid requestId, SutProvider sutProvider) { SetupUser(sutProvider, userId); - var result = await sutProvider.Sut.CancelRequest(requestId); + var result = await sutProvider.Sut.RevokeRequest(requestId); Assert.IsType(result); await sutProvider.GetDependency().Received(1).CancelAsync(userId, requestId); From 552afc3d63774f0ebdd5187b503d5dcb0ce29df2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anders=20=C3=85berg?= Date: Mon, 15 Jun 2026 14:52:36 +0200 Subject: [PATCH 31/54] Add governance lease list endpoints: GET /leases/active and /leases/history --- .../Controllers/LeaseGovernanceController.cs | 49 +++++++++++ ...OrganizationServiceCollectionExtensions.cs | 2 + .../Interfaces/IListActiveLeasesQuery.cs | 14 ++++ .../Interfaces/IListLeaseHistoryQuery.cs | 14 ++++ .../Queries/ListActiveLeasesQuery.cs | 35 ++++++++ .../Queries/ListLeaseHistoryQuery.cs | 36 ++++++++ .../Repositories/IAccessLeaseRepository.cs | 15 ++++ .../Pam/Repositories/AccessLeaseRepository.cs | 34 ++++++++ ...essLease_ReadManyActiveByCollectionIds.sql | 21 +++++ ...cessLease_ReadManyEndedByCollectionIds.sql | 24 ++++++ .../LeaseGovernanceControllerTests.cs | 67 +++++++++++++++ .../Pam/Queries/ListActiveLeasesQueryTests.cs | 56 +++++++++++++ .../Pam/Queries/ListLeaseHistoryQueryTests.cs | 58 +++++++++++++ .../AccessLeaseRepositoryTests.cs | 83 +++++++++++++++++++ ...-15_01_AddPamGovernanceLeaseListSprocs.sql | 56 +++++++++++++ 15 files changed, 564 insertions(+) create mode 100644 src/Api/Pam/Controllers/LeaseGovernanceController.cs create mode 100644 src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IListActiveLeasesQuery.cs create mode 100644 src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IListLeaseHistoryQuery.cs create mode 100644 src/Core/Pam/OrganizationFeatures/Queries/ListActiveLeasesQuery.cs create mode 100644 src/Core/Pam/OrganizationFeatures/Queries/ListLeaseHistoryQuery.cs create mode 100644 src/Sql/dbo/Pam/Stored Procedures/AccessLease_ReadManyActiveByCollectionIds.sql create mode 100644 src/Sql/dbo/Pam/Stored Procedures/AccessLease_ReadManyEndedByCollectionIds.sql create mode 100644 test/Api.Test/Pam/Controllers/LeaseGovernanceControllerTests.cs create mode 100644 test/Core.Test/Pam/Queries/ListActiveLeasesQueryTests.cs create mode 100644 test/Core.Test/Pam/Queries/ListLeaseHistoryQueryTests.cs create mode 100644 util/Migrator/DbScripts/2026-06-15_01_AddPamGovernanceLeaseListSprocs.sql diff --git a/src/Api/Pam/Controllers/LeaseGovernanceController.cs b/src/Api/Pam/Controllers/LeaseGovernanceController.cs new file mode 100644 index 000000000000..d025e7d4d06e --- /dev/null +++ b/src/Api/Pam/Controllers/LeaseGovernanceController.cs @@ -0,0 +1,49 @@ +using Bit.Api.Models.Response; +using Bit.Api.Pam.Models.Response; +using Bit.Core; +using Bit.Core.Pam.OrganizationFeatures.Queries.Interfaces; +using Bit.Core.Services; +using Bit.Core.Utilities; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Bit.Api.Pam.Controllers; + +/// +/// Governance surface over leases: read models of all active and recently-ended leases the caller can Manage, +/// powering the governance dashboard. Unlike the member surface on (the caller's +/// own leases), these span every member. Scope is the caller's manageable collections — the same resolution as the +/// approver inbox — so an org admin or collection manager sees all access in their scope. +/// +[Authorize("Application")] +[RequireFeature(FeatureFlagKeys.Pam)] +public class LeaseGovernanceController( + IUserService userService, + IListActiveLeasesQuery listActiveLeasesQuery, + IListLeaseHistoryQuery listLeaseHistoryQuery) + : Controller +{ + /// + /// Returns every currently-active lease on collections the caller can Manage. + /// + [HttpGet("leases/active")] + public async Task> GetActive() + { + var userId = userService.GetProperUserId(User)!.Value; + var leases = await listActiveLeasesQuery.GetActiveAsync(userId); + return new ListResponseModel( + leases.Select(l => new AccessLeaseResponseModel(l))); + } + + /// + /// Returns the ended leases (expired or revoked) on collections the caller can Manage, within the history window. + /// + [HttpGet("leases/history")] + public async Task> GetHistory() + { + var userId = userService.GetProperUserId(User)!.Value; + var leases = await listLeaseHistoryQuery.GetHistoryAsync(userId); + return new ListResponseModel( + leases.Select(l => new AccessLeaseResponseModel(l))); + } +} diff --git a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs index 169593adfaee..ba950b949872 100644 --- a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs +++ b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs @@ -218,6 +218,8 @@ public static void AddPamServices(this IServiceCollection services) services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); } private static void AddOrganizationGroupCommands(this IServiceCollection services) diff --git a/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IListActiveLeasesQuery.cs b/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IListActiveLeasesQuery.cs new file mode 100644 index 000000000000..878427b0adf2 --- /dev/null +++ b/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IListActiveLeasesQuery.cs @@ -0,0 +1,14 @@ +using Bit.Core.Pam.Entities; + +namespace Bit.Core.Pam.OrganizationFeatures.Queries.Interfaces; + +public interface IListActiveLeasesQuery +{ + /// + /// Returns every currently-active lease on the collections the caller can Manage — the governance view of all + /// active access in the caller's scope, not just their own leases. Scope is resolved the same way as the approver + /// inbox (): the caller's manageable collections across every organization. + /// Returns an empty collection when the caller manages none. + /// + Task> GetActiveAsync(Guid userId); +} diff --git a/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IListLeaseHistoryQuery.cs b/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IListLeaseHistoryQuery.cs new file mode 100644 index 000000000000..8d1f59e94195 --- /dev/null +++ b/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IListLeaseHistoryQuery.cs @@ -0,0 +1,14 @@ +using Bit.Core.Pam.Entities; + +namespace Bit.Core.Pam.OrganizationFeatures.Queries.Interfaces; + +public interface IListLeaseHistoryQuery +{ + /// + /// Returns the ended leases (expired or revoked) on the collections the caller can Manage, within the shared + /// history window — the governance history view of recently-ended access in the caller's scope. Scope is resolved + /// the same way as the approver inbox (): the caller's manageable collections + /// across every organization. Returns an empty collection when the caller manages none. + /// + Task> GetHistoryAsync(Guid userId); +} diff --git a/src/Core/Pam/OrganizationFeatures/Queries/ListActiveLeasesQuery.cs b/src/Core/Pam/OrganizationFeatures/Queries/ListActiveLeasesQuery.cs new file mode 100644 index 000000000000..35575b1be6a4 --- /dev/null +++ b/src/Core/Pam/OrganizationFeatures/Queries/ListActiveLeasesQuery.cs @@ -0,0 +1,35 @@ +using Bit.Core.Pam.Entities; +using Bit.Core.Pam.OrganizationFeatures.Queries.Interfaces; +using Bit.Core.Pam.Repositories; +using Bit.Core.Pam.Services; + +namespace Bit.Core.Pam.OrganizationFeatures.Queries; + +public class ListActiveLeasesQuery : IListActiveLeasesQuery +{ + private readonly IApproverCollectionAccessQuery _approverCollectionAccessQuery; + private readonly IAccessLeaseRepository _accessLeaseRepository; + private readonly TimeProvider _timeProvider; + + public ListActiveLeasesQuery( + IApproverCollectionAccessQuery approverCollectionAccessQuery, + IAccessLeaseRepository accessLeaseRepository, + TimeProvider timeProvider) + { + _approverCollectionAccessQuery = approverCollectionAccessQuery; + _accessLeaseRepository = accessLeaseRepository; + _timeProvider = timeProvider; + } + + public async Task> GetActiveAsync(Guid userId) + { + var manageableCollectionIds = await _approverCollectionAccessQuery.GetManageableCollectionIdsAsync(userId); + if (manageableCollectionIds.Count == 0) + { + return new List(); + } + + return await _accessLeaseRepository.GetManyActiveByCollectionIdsAsync( + manageableCollectionIds, _timeProvider.GetUtcNow().UtcDateTime); + } +} diff --git a/src/Core/Pam/OrganizationFeatures/Queries/ListLeaseHistoryQuery.cs b/src/Core/Pam/OrganizationFeatures/Queries/ListLeaseHistoryQuery.cs new file mode 100644 index 000000000000..ea49f2d1092f --- /dev/null +++ b/src/Core/Pam/OrganizationFeatures/Queries/ListLeaseHistoryQuery.cs @@ -0,0 +1,36 @@ +using Bit.Core.Pam.Entities; +using Bit.Core.Pam.OrganizationFeatures.Queries.Interfaces; +using Bit.Core.Pam.Repositories; +using Bit.Core.Pam.Services; + +namespace Bit.Core.Pam.OrganizationFeatures.Queries; + +public class ListLeaseHistoryQuery : IListLeaseHistoryQuery +{ + private readonly IApproverCollectionAccessQuery _approverCollectionAccessQuery; + private readonly IAccessLeaseRepository _accessLeaseRepository; + private readonly TimeProvider _timeProvider; + + public ListLeaseHistoryQuery( + IApproverCollectionAccessQuery approverCollectionAccessQuery, + IAccessLeaseRepository accessLeaseRepository, + TimeProvider timeProvider) + { + _approverCollectionAccessQuery = approverCollectionAccessQuery; + _accessLeaseRepository = accessLeaseRepository; + _timeProvider = timeProvider; + } + + public async Task> GetHistoryAsync(Guid userId) + { + var manageableCollectionIds = await _approverCollectionAccessQuery.GetManageableCollectionIdsAsync(userId); + if (manageableCollectionIds.Count == 0) + { + return new List(); + } + + // Shares the approver inbox's history window so request history and lease history reach equally far back. + var since = _timeProvider.GetUtcNow().UtcDateTime.AddDays(-ListInboxHistoryQuery.HistoryRetentionDays); + return await _accessLeaseRepository.GetManyEndedByCollectionIdsAsync(manageableCollectionIds, since); + } +} diff --git a/src/Core/Pam/Repositories/IAccessLeaseRepository.cs b/src/Core/Pam/Repositories/IAccessLeaseRepository.cs index 6edc5401a42c..7a99f36570c0 100644 --- a/src/Core/Pam/Repositories/IAccessLeaseRepository.cs +++ b/src/Core/Pam/Repositories/IAccessLeaseRepository.cs @@ -23,6 +23,21 @@ public interface IAccessLeaseRepository /// Task> GetManyActiveByRequesterIdAsync(Guid requesterId, DateTime now); + /// + /// Returns every currently-active lease (status Active, window containing ) on the given + /// collections, across all members — the governance view over a set of caller-manageable collections. Returns an + /// empty collection when none are active. + /// + Task> GetManyActiveByCollectionIdsAsync(IEnumerable collectionIds, DateTime now); + + /// + /// Returns the ended leases (status Expired or Revoked) on the given collections that ended on or after + /// — the governance history view over a set of caller-manageable collections. A revoked + /// lease's end is its revoked date; an expired lease's end is its not-after. Returns an empty collection when none + /// qualify. + /// + Task> GetManyEndedByCollectionIdsAsync(IEnumerable collectionIds, DateTime since); + /// /// Race-safely mints the active lease for an approved request, copying the request's window. The insert /// re-checks ownership, Approved status, an open window, and that the request has not already produced a lease; diff --git a/src/Infrastructure.Dapper/Pam/Repositories/AccessLeaseRepository.cs b/src/Infrastructure.Dapper/Pam/Repositories/AccessLeaseRepository.cs index 315e360564cc..bff21842d547 100644 --- a/src/Infrastructure.Dapper/Pam/Repositories/AccessLeaseRepository.cs +++ b/src/Infrastructure.Dapper/Pam/Repositories/AccessLeaseRepository.cs @@ -54,6 +54,40 @@ public async Task> GetManyActiveByRequesterIdAsync(Guid return results.ToList(); } + public async Task> GetManyActiveByCollectionIdsAsync(IEnumerable collectionIds, DateTime now) + { + var ids = collectionIds.ToList(); + if (ids.Count == 0) + { + return new List(); + } + + await using var connection = new SqlConnection(ConnectionString); + var results = await connection.QueryAsync( + $"[{Schema}].[AccessLease_ReadManyActiveByCollectionIds]", + new { CollectionIds = ids.ToGuidIdArrayTVP(), Now = now }, + commandType: CommandType.StoredProcedure); + + return results.ToList(); + } + + public async Task> GetManyEndedByCollectionIdsAsync(IEnumerable collectionIds, DateTime since) + { + var ids = collectionIds.ToList(); + if (ids.Count == 0) + { + return new List(); + } + + await using var connection = new SqlConnection(ConnectionString); + var results = await connection.QueryAsync( + $"[{Schema}].[AccessLease_ReadManyEndedByCollectionIds]", + new { CollectionIds = ids.ToGuidIdArrayTVP(), Since = since }, + commandType: CommandType.StoredProcedure); + + return results.ToList(); + } + public async Task CreateFromApprovedRequestAsync(AccessLease lease, DateTime now, bool enforceSingleActiveLease) { diff --git a/src/Sql/dbo/Pam/Stored Procedures/AccessLease_ReadManyActiveByCollectionIds.sql b/src/Sql/dbo/Pam/Stored Procedures/AccessLease_ReadManyActiveByCollectionIds.sql new file mode 100644 index 000000000000..dc8bd84c25f9 --- /dev/null +++ b/src/Sql/dbo/Pam/Stored Procedures/AccessLease_ReadManyActiveByCollectionIds.sql @@ -0,0 +1,21 @@ +CREATE PROCEDURE [dbo].[AccessLease_ReadManyActiveByCollectionIds] + @CollectionIds [dbo].[GuidIdArray] READONLY, + @Now DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + -- Governance view: every currently-active lease (Active, window containing @Now) on the supplied + -- (caller-manageable) collections, across all members -- not just the caller's own. + SELECT + L.* + FROM + [dbo].[AccessLease] L + INNER JOIN @CollectionIds CI ON CI.[Id] = L.[CollectionId] + WHERE + L.[Status] = 0 -- Active + AND L.[NotBefore] <= @Now + AND L.[NotAfter] > @Now + ORDER BY + L.[NotAfter] ASC +END diff --git a/src/Sql/dbo/Pam/Stored Procedures/AccessLease_ReadManyEndedByCollectionIds.sql b/src/Sql/dbo/Pam/Stored Procedures/AccessLease_ReadManyEndedByCollectionIds.sql new file mode 100644 index 000000000000..52c0b1e2245b --- /dev/null +++ b/src/Sql/dbo/Pam/Stored Procedures/AccessLease_ReadManyEndedByCollectionIds.sql @@ -0,0 +1,24 @@ +CREATE PROCEDURE [dbo].[AccessLease_ReadManyEndedByCollectionIds] + @CollectionIds [dbo].[GuidIdArray] READONLY, + @Since DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + -- Governance history: leases that have ended (Expired or Revoked) on the supplied (caller-manageable) + -- collections, that ended on or after @Since. A revoked lease's end is its RevokedDate; an expired lease's end + -- is its NotAfter. Most recently ended first. + SELECT + L.* + FROM + [dbo].[AccessLease] L + INNER JOIN @CollectionIds CI ON CI.[Id] = L.[CollectionId] + WHERE + L.[Status] IN (1, 2) -- Expired, Revoked + AND ( + (L.[Status] = 2 AND L.[RevokedDate] >= @Since) -- Revoked + OR (L.[Status] = 1 AND L.[NotAfter] >= @Since) -- Expired + ) + ORDER BY + CASE WHEN L.[Status] = 2 THEN L.[RevokedDate] ELSE L.[NotAfter] END DESC +END diff --git a/test/Api.Test/Pam/Controllers/LeaseGovernanceControllerTests.cs b/test/Api.Test/Pam/Controllers/LeaseGovernanceControllerTests.cs new file mode 100644 index 000000000000..c1c66b5d1ea4 --- /dev/null +++ b/test/Api.Test/Pam/Controllers/LeaseGovernanceControllerTests.cs @@ -0,0 +1,67 @@ +using System.Security.Claims; +using Bit.Api.Pam.Controllers; +using Bit.Core.Pam.Entities; +using Bit.Core.Pam.Enums; +using Bit.Core.Pam.Models; +using Bit.Core.Pam.OrganizationFeatures.Queries.Interfaces; +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Api.Test.Pam.Controllers; + +[ControllerCustomize(typeof(LeaseGovernanceController))] +[SutProviderCustomize] +public class LeaseGovernanceControllerTests +{ + [Theory, BitAutoData] + public async Task GetActive_ReturnsMappedLeases( + Guid userId, AccessLease lease, SutProvider sutProvider) + { + SetupUser(sutProvider, userId); + lease.Status = AccessLeaseStatus.Active; + sutProvider.GetDependency().GetActiveAsync(userId).Returns([lease]); + + var result = (await sutProvider.Sut.GetActive()).Data.ToList(); + + Assert.Single(result); + Assert.Equal(lease.Id, result[0].Id); + Assert.Equal(AccessLeaseStatusNames.Active, result[0].Status); + } + + [Theory, BitAutoData] + public async Task GetActive_NoLeases_ReturnsEmpty( + Guid userId, SutProvider sutProvider) + { + SetupUser(sutProvider, userId); + sutProvider.GetDependency().GetActiveAsync(userId).Returns([]); + + var result = await sutProvider.Sut.GetActive(); + + Assert.Empty(result.Data); + } + + [Theory, BitAutoData] + public async Task GetHistory_ReturnsMappedLeases( + Guid userId, AccessLease lease, SutProvider sutProvider) + { + SetupUser(sutProvider, userId); + lease.Status = AccessLeaseStatus.Revoked; + sutProvider.GetDependency().GetHistoryAsync(userId).Returns([lease]); + + var result = (await sutProvider.Sut.GetHistory()).Data.ToList(); + + Assert.Single(result); + Assert.Equal(lease.Id, result[0].Id); + Assert.Equal(AccessLeaseStatusNames.Revoked, result[0].Status); + } + + private static void SetupUser(SutProvider sutProvider, Guid userId) + { + sutProvider.GetDependency() + .GetProperUserId(Arg.Any()) + .Returns(userId); + } +} diff --git a/test/Core.Test/Pam/Queries/ListActiveLeasesQueryTests.cs b/test/Core.Test/Pam/Queries/ListActiveLeasesQueryTests.cs new file mode 100644 index 000000000000..25dbc806e8a5 --- /dev/null +++ b/test/Core.Test/Pam/Queries/ListActiveLeasesQueryTests.cs @@ -0,0 +1,56 @@ +using Bit.Core.Pam.Entities; +using Bit.Core.Pam.OrganizationFeatures.Queries; +using Bit.Core.Pam.Repositories; +using Bit.Core.Pam.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.Extensions.Time.Testing; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Pam.Queries; + +[SutProviderCustomize] +public class ListActiveLeasesQueryTests +{ + private static readonly DateTime _now = new(2026, 6, 5, 12, 0, 0, DateTimeKind.Utc); + + [Theory, BitAutoData] + public async Task GetActiveAsync_NoManageableCollections_ReturnsEmptyWithoutQuerying(Guid userId) + { + var sutProvider = Setup(); + sutProvider.GetDependency() + .GetManageableCollectionIdsAsync(userId).Returns([]); + + var result = await sutProvider.Sut.GetActiveAsync(userId); + + Assert.Empty(result); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .GetManyActiveByCollectionIdsAsync(default!, default); + } + + [Theory, BitAutoData] + public async Task GetActiveAsync_ManageableCollections_FiltersByThatSetAtNow( + Guid userId, Guid collectionId, AccessLease lease) + { + var sutProvider = Setup(); + var manageable = new HashSet { collectionId }; + sutProvider.GetDependency() + .GetManageableCollectionIdsAsync(userId).Returns(manageable); + sutProvider.GetDependency() + .GetManyActiveByCollectionIdsAsync(manageable, _now).Returns([lease]); + + var result = await sutProvider.Sut.GetActiveAsync(userId); + + Assert.Single(result); + await sutProvider.GetDependency().Received(1) + .GetManyActiveByCollectionIdsAsync(manageable, _now); + } + + private static SutProvider Setup() + { + var sutProvider = new SutProvider().WithFakeTimeProvider().Create(); + sutProvider.GetDependency().SetUtcNow(_now); + return sutProvider; + } +} diff --git a/test/Core.Test/Pam/Queries/ListLeaseHistoryQueryTests.cs b/test/Core.Test/Pam/Queries/ListLeaseHistoryQueryTests.cs new file mode 100644 index 000000000000..69bc27c3db9c --- /dev/null +++ b/test/Core.Test/Pam/Queries/ListLeaseHistoryQueryTests.cs @@ -0,0 +1,58 @@ +using Bit.Core.Pam.Entities; +using Bit.Core.Pam.OrganizationFeatures.Queries; +using Bit.Core.Pam.Repositories; +using Bit.Core.Pam.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.Extensions.Time.Testing; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Pam.Queries; + +[SutProviderCustomize] +public class ListLeaseHistoryQueryTests +{ + private static readonly DateTime _now = new(2026, 6, 5, 12, 0, 0, DateTimeKind.Utc); + + [Theory, BitAutoData] + public async Task GetHistoryAsync_NoManageableCollections_ReturnsEmptyWithoutQuerying(Guid userId) + { + var sutProvider = Setup(); + sutProvider.GetDependency() + .GetManageableCollectionIdsAsync(userId).Returns([]); + + var result = await sutProvider.Sut.GetHistoryAsync(userId); + + Assert.Empty(result); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .GetManyEndedByCollectionIdsAsync(default!, default); + } + + [Theory, BitAutoData] + public async Task GetHistoryAsync_QueriesWithSharedRetentionWindow( + Guid userId, Guid collectionId, AccessLease lease) + { + var sutProvider = Setup(); + var manageable = new HashSet { collectionId }; + sutProvider.GetDependency() + .GetManageableCollectionIdsAsync(userId).Returns(manageable); + // Shares the approver inbox's history window. + var expectedSince = _now.AddDays(-ListInboxHistoryQuery.HistoryRetentionDays); + sutProvider.GetDependency() + .GetManyEndedByCollectionIdsAsync(manageable, expectedSince).Returns([lease]); + + var result = await sutProvider.Sut.GetHistoryAsync(userId); + + Assert.Single(result); + await sutProvider.GetDependency().Received(1) + .GetManyEndedByCollectionIdsAsync(manageable, expectedSince); + } + + private static SutProvider Setup() + { + var sutProvider = new SutProvider().WithFakeTimeProvider().Create(); + sutProvider.GetDependency().SetUtcNow(_now); + return sutProvider; + } +} diff --git a/test/Infrastructure.IntegrationTest/Pam/Repositories/AccessLeaseRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Pam/Repositories/AccessLeaseRepositoryTests.cs index c1c7271bb4a5..519337fbbf3e 100644 --- a/test/Infrastructure.IntegrationTest/Pam/Repositories/AccessLeaseRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/Pam/Repositories/AccessLeaseRepositoryTests.cs @@ -307,6 +307,89 @@ public async Task CreateFromApprovedRequestAsync_EnforceSingleActiveLease_Second Assert.Null(await accessLeaseRepository.GetByAccessRequestIdAsync(second.Id)); } + [DatabaseTheory, DatabaseData] + public async Task GetManyActiveByCollectionIdsAsync_ReturnsActiveInWindowLeasesOnGivenCollections( + IOrganizationRepository organizationRepository, + IAccessRequestRepository accessRequestRepository, + IAccessLeaseRepository accessLeaseRepository) + { + var organization = await organizationRepository.CreateTestOrganizationAsync(); + var now = DateTime.UtcNow; + + // Two active, in-window leases on distinct collections — both visible to a manager of those collections. + var (req1, dec1, lease1) = BuildAutoApproved( + organization.Id, Guid.NewGuid(), Guid.NewGuid(), now.AddMinutes(-5), now.AddHours(1)); + await SeedActiveLeaseAsync(accessRequestRepository, accessLeaseRepository, req1, dec1, lease1, now); + var (req2, dec2, lease2) = BuildAutoApproved( + organization.Id, Guid.NewGuid(), Guid.NewGuid(), now.AddMinutes(-5), now.AddHours(1)); + await SeedActiveLeaseAsync(accessRequestRepository, accessLeaseRepository, req2, dec2, lease2, now); + + // Active but already out of window (minted in a past window) on a third collection — excluded by the window. + var (req3, dec3, lease3) = BuildAutoApproved( + organization.Id, Guid.NewGuid(), Guid.NewGuid(), now.AddHours(-2), now.AddHours(-1)); + await SeedActiveLeaseAsync(accessRequestRepository, accessLeaseRepository, req3, dec3, lease3, now.AddHours(-2)); + + var all = await accessLeaseRepository.GetManyActiveByCollectionIdsAsync( + new[] { lease1.CollectionId, lease2.CollectionId, lease3.CollectionId }, now); + + Assert.Equal(2, all.Count); + Assert.Contains(all, l => l.Id == lease1.Id); + Assert.Contains(all, l => l.Id == lease2.Id); + + // Collection scoping: querying a subset returns only that collection's leases. + var scoped = await accessLeaseRepository.GetManyActiveByCollectionIdsAsync(new[] { lease1.CollectionId }, now); + Assert.Single(scoped); + Assert.Equal(lease1.Id, scoped.First().Id); + } + + [DatabaseTheory, DatabaseData] + public async Task GetManyEndedByCollectionIdsAsync_ReturnsRecentlyEndedLeasesOnGivenCollections( + IOrganizationRepository organizationRepository, + IAccessRequestRepository accessRequestRepository, + IAccessLeaseRepository accessLeaseRepository) + { + var organization = await organizationRepository.CreateTestOrganizationAsync(); + var now = DateTime.UtcNow; + var since = now.AddDays(-90); + + // Active lease — not ended, excluded. + var (activeReq, activeDec, activeLease) = BuildAutoApproved( + organization.Id, Guid.NewGuid(), Guid.NewGuid(), now.AddMinutes(-5), now.AddHours(1)); + await SeedActiveLeaseAsync(accessRequestRepository, accessLeaseRepository, activeReq, activeDec, activeLease, now); + + // Revoked within the window — included. + var (revReq, revDec, revLease) = BuildAutoApproved( + organization.Id, Guid.NewGuid(), Guid.NewGuid(), now.AddMinutes(-5), now.AddHours(1)); + await SeedActiveLeaseAsync(accessRequestRepository, accessLeaseRepository, revReq, revDec, revLease, now); + await accessLeaseRepository.RevokeAsync(revLease, BuildAuditDecision(revLease, now), now); + + // Revoked long before the window — excluded by @Since. + var (oldReq, oldDec, oldLease) = BuildAutoApproved( + organization.Id, Guid.NewGuid(), Guid.NewGuid(), now.AddDays(-200), now.AddDays(-100)); + await SeedActiveLeaseAsync( + accessRequestRepository, accessLeaseRepository, oldReq, oldDec, oldLease, now.AddDays(-200)); + await accessLeaseRepository.RevokeAsync(oldLease, BuildAuditDecision(oldLease, now.AddDays(-150)), now.AddDays(-150)); + + var result = await accessLeaseRepository.GetManyEndedByCollectionIdsAsync( + new[] { activeLease.CollectionId, revLease.CollectionId, oldLease.CollectionId }, since); + + Assert.Single(result); + Assert.Equal(revLease.Id, result.First().Id); + Assert.Equal(AccessLeaseStatus.Revoked, result.First().Status); + } + + private static AccessDecision BuildAuditDecision(AccessLease lease, DateTime now) + => new() + { + Id = CoreHelpers.GenerateComb(), + AccessRequestId = lease.AccessRequestId, + DeciderKind = AccessDeciderKind.Human, + ApproverId = Guid.NewGuid(), + Verdict = AccessDecisionVerdict.Deny, + Comment = "ended for test", + CreationDate = now, + }; + private static async Task CreateApprovedRequestAsync( IAccessRequestRepository accessRequestRepository, Guid organizationId, DateTime notBefore, DateTime notAfter, AccessRequestStatus status = AccessRequestStatus.Approved, Guid? cipherId = null) diff --git a/util/Migrator/DbScripts/2026-06-15_01_AddPamGovernanceLeaseListSprocs.sql b/util/Migrator/DbScripts/2026-06-15_01_AddPamGovernanceLeaseListSprocs.sql new file mode 100644 index 000000000000..7750e8988fdf --- /dev/null +++ b/util/Migrator/DbScripts/2026-06-15_01_AddPamGovernanceLeaseListSprocs.sql @@ -0,0 +1,56 @@ +-- PAM Credential Leasing: governance lease read models. Two new sprocs back GET /leases/active and +-- GET /leases/history, which list all active / all recently-ended leases on the collections the caller can Manage +-- (resolved the same way as the approver inbox), powering the governance dashboard. Both take the caller's +-- manageable collection ids as a GuidIdArray TVP, mirroring [AccessRequest_ReadInboxPendingByCollectionIds] / +-- [AccessRequest_ReadInboxHistoryByCollectionIds]. +-- +-- Feature is behind the pm-37044-pam-v-0 flag (unshipped POC); server + migration deploy together. + +CREATE OR ALTER PROCEDURE [dbo].[AccessLease_ReadManyActiveByCollectionIds] + @CollectionIds [dbo].[GuidIdArray] READONLY, + @Now DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + -- Every currently-active lease (Active, window containing @Now) on the supplied (caller-manageable) collections, + -- across all members -- not just the caller's own. + SELECT + L.* + FROM + [dbo].[AccessLease] L + INNER JOIN @CollectionIds CI ON CI.[Id] = L.[CollectionId] + WHERE + L.[Status] = 0 -- Active + AND L.[NotBefore] <= @Now + AND L.[NotAfter] > @Now + ORDER BY + L.[NotAfter] ASC +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[AccessLease_ReadManyEndedByCollectionIds] + @CollectionIds [dbo].[GuidIdArray] READONLY, + @Since DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + -- Leases that have ended (Expired or Revoked) on the supplied (caller-manageable) collections, that ended on or + -- after @Since. A revoked lease's end is its RevokedDate; an expired lease's end is its NotAfter. Most recently + -- ended first. + SELECT + L.* + FROM + [dbo].[AccessLease] L + INNER JOIN @CollectionIds CI ON CI.[Id] = L.[CollectionId] + WHERE + L.[Status] IN (1, 2) -- Expired, Revoked + AND ( + (L.[Status] = 2 AND L.[RevokedDate] >= @Since) -- Revoked + OR (L.[Status] = 1 AND L.[NotAfter] >= @Since) -- Expired + ) + ORDER BY + CASE WHEN L.[Status] = 2 THEN L.[RevokedDate] ELSE L.[NotAfter] END DESC +END +GO From e8a4878b79b6edbcb102558672e7c6910b8f02d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anders=20=C3=85berg?= Date: Mon, 15 Jun 2026 15:07:17 +0200 Subject: [PATCH 32/54] Mark GET /ciphers/{id}/lease/cipher deprecated (scheduled for removal) --- src/Api/Pam/Controllers/CipherLeaseController.cs | 4 ++++ test/Api.Test/Pam/Controllers/CipherLeaseControllerTests.cs | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/src/Api/Pam/Controllers/CipherLeaseController.cs b/src/Api/Pam/Controllers/CipherLeaseController.cs index e011ee324cfb..4b76dfa42afd 100644 --- a/src/Api/Pam/Controllers/CipherLeaseController.cs +++ b/src/Api/Pam/Controllers/CipherLeaseController.cs @@ -71,6 +71,10 @@ public async Task Post(Guid id, [FromBody] Acc /// This is the read-back counterpart to the partial data sync returns for leasing-gated ciphers. The data is /// still client-encrypted; the lease only gates whether the server hands it over. /// + // DEPRECATED: scheduled for removal; the full leased cipher will be served through the standard cipher read path + // rather than this dedicated endpoint. Kept fully functional during the PAM pre-release. Removal is a later task. + [Obsolete("Deprecated and scheduled for removal; the full leased cipher will be served through the standard " + + "cipher read path instead of this dedicated endpoint. Kept functional for the PAM pre-release.")] [HttpGet("cipher")] public async Task GetCipher(Guid id) { diff --git a/test/Api.Test/Pam/Controllers/CipherLeaseControllerTests.cs b/test/Api.Test/Pam/Controllers/CipherLeaseControllerTests.cs index f08667b31cfd..75b6332b7146 100644 --- a/test/Api.Test/Pam/Controllers/CipherLeaseControllerTests.cs +++ b/test/Api.Test/Pam/Controllers/CipherLeaseControllerTests.cs @@ -41,6 +41,9 @@ public async Task State_ReturnsSnapshotFromQuery( Assert.Null(result.ApprovedRequest); } + // GET /ciphers/{id}/lease/cipher is [Obsolete] (deprecated, scheduled for removal) but still fully functional, so + // its behaviour stays under test; suppress the obsolete-usage warning for these deliberate calls. +#pragma warning disable CS0618 [Theory, BitAutoData] public async Task GetCipher_NoLeasedCipher_ThrowsNotFound( Guid id, User user, SutProvider sutProvider) @@ -86,4 +89,5 @@ public async Task GetCipher_LeasedCipher_ReturnsFullData( Assert.Equal("2.iv|ct|mac", result.Data); // full data present Assert.Null(result.PartialData); // isPartial == false } +#pragma warning restore CS0618 } From e4c176d9f6a52c25b677f6f42e349fb0647a363e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anders=20=C3=85berg?= Date: Mon, 15 Jun 2026 15:14:45 +0200 Subject: [PATCH 33/54] simplify: post-refactor cleanup of PAM access-requests / leases --- src/Sql/dbo/Pam/Tables/AccessLease.sql | 6 ++++++ ...026-06-15_02_AddAccessLeaseCollectionIdIndex.sql | 13 +++++++++++++ 2 files changed, 19 insertions(+) create mode 100644 util/Migrator/DbScripts/2026-06-15_02_AddAccessLeaseCollectionIdIndex.sql diff --git a/src/Sql/dbo/Pam/Tables/AccessLease.sql b/src/Sql/dbo/Pam/Tables/AccessLease.sql index ae130d947644..0b81fe226296 100644 --- a/src/Sql/dbo/Pam/Tables/AccessLease.sql +++ b/src/Sql/dbo/Pam/Tables/AccessLease.sql @@ -25,6 +25,12 @@ CREATE NONCLUSTERED INDEX [IX_AccessLease_NotAfter_Status] ON [dbo].[AccessLease] ([NotAfter] ASC, [Status] ASC); GO +-- Supports the governance lease lists (AccessLease_ReadManyActiveByCollectionIds / +-- AccessLease_ReadManyEndedByCollectionIds), which filter by the caller's manageable collection ids. +CREATE NONCLUSTERED INDEX [IX_AccessLease_CollectionId_Status] + ON [dbo].[AccessLease] ([CollectionId] ASC, [Status] ASC); +GO + -- A request produces at most one lease, ever: activating an approved request and the automatic path each insert -- exactly one. Unique to backstop racing activations that pass the application-level checks simultaneously. CREATE UNIQUE NONCLUSTERED INDEX [IX_AccessLease_AccessRequestId] diff --git a/util/Migrator/DbScripts/2026-06-15_02_AddAccessLeaseCollectionIdIndex.sql b/util/Migrator/DbScripts/2026-06-15_02_AddAccessLeaseCollectionIdIndex.sql new file mode 100644 index 000000000000..dd1516f96943 --- /dev/null +++ b/util/Migrator/DbScripts/2026-06-15_02_AddAccessLeaseCollectionIdIndex.sql @@ -0,0 +1,13 @@ +-- PAM Credential Leasing: supporting index for the governance lease lists. AccessLease_ReadManyActiveByCollectionIds +-- and AccessLease_ReadManyEndedByCollectionIds (GET /leases/active, /leases/history) filter AccessLease by the +-- caller's manageable collection ids; without a CollectionId index those scans fall back to the table. This adds the +-- seekable index. No behaviour change. +-- +-- Feature is behind the pm-37044-pam-v-0 flag (unshipped POC); server + migration deploy together. + +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE [Name] = 'IX_AccessLease_CollectionId_Status' AND object_id = OBJECT_ID('[dbo].[AccessLease]')) +BEGIN + CREATE NONCLUSTERED INDEX [IX_AccessLease_CollectionId_Status] + ON [dbo].[AccessLease] ([CollectionId] ASC, [Status] ASC); +END +GO From a7c390371928b70ca59616899543a1db834e9197 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anders=20=C3=85berg?= Date: Mon, 15 Jun 2026 19:31:21 +0200 Subject: [PATCH 34/54] Allow a lease holder to end (revoke) their own active lease --- .../Controllers/ApproverInboxController.cs | 3 +- .../Interfaces/IRevokeAccessLeaseCommand.cs | 7 +++-- .../Commands/RevokeAccessLeaseCommand.cs | 9 ++++-- .../Commands/RevokeAccessLeaseCommandTests.cs | 29 ++++++++++++++++++- 4 files changed, 41 insertions(+), 7 deletions(-) diff --git a/src/Api/Pam/Controllers/ApproverInboxController.cs b/src/Api/Pam/Controllers/ApproverInboxController.cs index 4c5ad47b7e35..35714d7e5104 100644 --- a/src/Api/Pam/Controllers/ApproverInboxController.cs +++ b/src/Api/Pam/Controllers/ApproverInboxController.cs @@ -59,7 +59,8 @@ public async Task Decide(Guid id, [FromBody] } /// - /// Revokes an active lease early. The caller must be able to Manage the lease's collection. + /// Ends an active lease early. The caller must be either the lease's holder (ending their own access) or able to + /// Manage the lease's collection. /// [HttpPost("leases/{id:guid}/revoke")] public async Task Revoke(Guid id, [FromBody] AccessLeaseRevokeRequestModel model) diff --git a/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IRevokeAccessLeaseCommand.cs b/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IRevokeAccessLeaseCommand.cs index f9d44664b86b..c76ff99caeae 100644 --- a/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IRevokeAccessLeaseCommand.cs +++ b/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IRevokeAccessLeaseCommand.cs @@ -3,11 +3,12 @@ public interface IRevokeAccessLeaseCommand { /// - /// Revokes an active lease early. The caller must be able to Manage the lease's collection. The optional reason is - /// retained for the audit trail. + /// Ends an active lease early, settling it to revoked. The caller must be either the lease's holder (ending their + /// own access) or able to Manage the lease's collection (a managing approver or org admin); the actor is recorded + /// as the revoker. The optional reason is retained for the audit trail. /// /// - /// The lease does not exist or the caller cannot Manage its collection. + /// The lease does not exist, or the caller is neither its holder nor able to Manage its collection. /// /// The lease is not active. Task RevokeAsync(Guid userId, Guid leaseId, string? reason); diff --git a/src/Core/Pam/OrganizationFeatures/Commands/RevokeAccessLeaseCommand.cs b/src/Core/Pam/OrganizationFeatures/Commands/RevokeAccessLeaseCommand.cs index d7210e637e80..7649d16f7365 100644 --- a/src/Core/Pam/OrganizationFeatures/Commands/RevokeAccessLeaseCommand.cs +++ b/src/Core/Pam/OrganizationFeatures/Commands/RevokeAccessLeaseCommand.cs @@ -30,8 +30,13 @@ public async Task RevokeAsync(Guid userId, Guid leaseId, string? reason) { var lease = await _accessLeaseRepository.GetByIdAsync(leaseId); - // 404 for both missing and not-visible, so the caller can't probe for leases they don't manage. - if (lease is null || !await _approverCollectionAccessQuery.CanManageCollectionAsync(userId, lease.CollectionId)) + // Who may end a lease early: the lease's own holder (ending their own access), or anyone who can Manage its + // collection (a managing approver or org admin). Either way the lease settles to revoked, recording the actor + // as RevokedBy — RevokedBy == RequesterId distinguishes a self-end from an operator revoke. 404 covers both + // missing and not-authorized, so a caller can't probe for leases they can't touch. + var isHolder = lease is not null && lease.RequesterId == userId; + if (lease is null || + (!isHolder && !await _approverCollectionAccessQuery.CanManageCollectionAsync(userId, lease.CollectionId))) { throw new NotFoundException(); } diff --git a/test/Core.Test/Pam/Commands/RevokeAccessLeaseCommandTests.cs b/test/Core.Test/Pam/Commands/RevokeAccessLeaseCommandTests.cs index ab624d1f5d46..8511c5853112 100644 --- a/test/Core.Test/Pam/Commands/RevokeAccessLeaseCommandTests.cs +++ b/test/Core.Test/Pam/Commands/RevokeAccessLeaseCommandTests.cs @@ -27,10 +27,11 @@ public async Task RevokeAsync_LeaseMissing_ThrowsNotFound(Guid userId, Guid leas } [Theory, BitAutoData] - public async Task RevokeAsync_NotManageable_ThrowsNotFound(Guid userId, AccessLease lease) + public async Task RevokeAsync_NeitherHolderNorManageable_ThrowsNotFound(Guid userId, AccessLease lease) { var sutProvider = Setup(); lease.Status = AccessLeaseStatus.Active; + // userId is neither the lease holder (lease.RequesterId is a different AutoFixture Guid) nor a manager. sutProvider.GetDependency().GetByIdAsync(lease.Id).Returns(lease); sutProvider.GetDependency() .CanManageCollectionAsync(userId, lease.CollectionId).Returns(false); @@ -38,6 +39,32 @@ public async Task RevokeAsync_NotManageable_ThrowsNotFound(Guid userId, AccessLe await Assert.ThrowsAsync(() => sutProvider.Sut.RevokeAsync(userId, lease.Id, null)); } + [Theory, BitAutoData] + public async Task RevokeAsync_HolderEndsOwnLease_RevokesWithoutManageRights(AccessLease lease) + { + var sutProvider = Setup(); + lease.Status = AccessLeaseStatus.Active; + // The caller IS the lease's own holder, but cannot Manage the collection — they may still end their own access. + sutProvider.GetDependency().GetByIdAsync(lease.Id).Returns(lease); + sutProvider.GetDependency() + .CanManageCollectionAsync(lease.RequesterId, lease.CollectionId).Returns(false); + + await sutProvider.Sut.RevokeAsync(lease.RequesterId, lease.Id, "done with it"); + + // Settles to revoked with the holder recorded as the revoker (RevokedBy via the audit decision's ApproverId). + await sutProvider.GetDependency().Received(1).RevokeAsync( + lease, + Arg.Is(d => + d.AccessRequestId == lease.AccessRequestId && + d.DeciderKind == AccessDeciderKind.Human && + d.ApproverId == lease.RequesterId && + d.Verdict == AccessDecisionVerdict.Deny && + d.Comment == "done with it"), + _now); + await sutProvider.GetDependency().Received(1) + .NotifyCollectionApproversAsync(lease.CollectionId); + } + [Theory, BitAutoData] public async Task RevokeAsync_NotActive_ThrowsConflict(Guid userId, AccessLease lease) { From 456500d42b2284d91cfa6b54e11d0d0e8c843864 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anders=20=C3=85berg?= Date: Tue, 16 Jun 2026 00:38:13 +0200 Subject: [PATCH 35/54] PAM: push requester-scoped lease lifecycle notifications --- ...OrganizationServiceCollectionExtensions.cs | 1 + .../Commands/ActivateAccessRequestCommand.cs | 7 ++++++ .../Commands/CancelAccessRequestCommand.cs | 7 ++++++ .../Commands/DecideAccessRequestCommand.cs | 7 ++++++ .../Commands/RequestLeaseExtensionCommand.cs | 12 ++++++++++ .../Commands/RevokeAccessLeaseCommand.cs | 7 ++++++ .../Commands/SubmitAccessRequestCommand.cs | 11 ++++++++++ src/Core/Pam/Services/IRequesterNotifier.cs | 13 +++++++++++ src/Core/Pam/Services/RequesterNotifier.cs | 18 +++++++++++++++ .../Platform/Push/IPushNotificationService.cs | 16 ++++++++++++++ src/Core/Platform/Push/PushType.cs | 3 +++ src/Notifications/HubHelpers.cs | 12 ++++++++++ .../ActivateAccessRequestCommandTests.cs | 6 +++++ .../CancelAccessRequestCommandTests.cs | 4 ++++ .../DecideAccessRequestCommandTests.cs | 17 ++++++++++++++ .../RequestLeaseExtensionCommandTests.cs | 7 ++++++ .../Commands/RevokeAccessLeaseCommandTests.cs | 4 ++++ .../SubmitAccessRequestCommandTests.cs | 6 +++++ .../Pam/Services/RequesterNotifierTests.cs | 22 +++++++++++++++++++ 19 files changed, 180 insertions(+) create mode 100644 src/Core/Pam/Services/IRequesterNotifier.cs create mode 100644 src/Core/Pam/Services/RequesterNotifier.cs create mode 100644 test/Core.Test/Pam/Services/RequesterNotifierTests.cs diff --git a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs index ba950b949872..f66edc3cb273 100644 --- a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs +++ b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs @@ -208,6 +208,7 @@ public static void AddPamServices(this IServiceCollection services) services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/Core/Pam/OrganizationFeatures/Commands/ActivateAccessRequestCommand.cs b/src/Core/Pam/OrganizationFeatures/Commands/ActivateAccessRequestCommand.cs index a52aebfd117b..2d76780dd104 100644 --- a/src/Core/Pam/OrganizationFeatures/Commands/ActivateAccessRequestCommand.cs +++ b/src/Core/Pam/OrganizationFeatures/Commands/ActivateAccessRequestCommand.cs @@ -12,6 +12,7 @@ public class ActivateAccessRequestCommand : IActivateAccessRequestCommand private readonly IAccessRequestRepository _accessRequestRepository; private readonly IAccessLeaseRepository _accessLeaseRepository; private readonly IApproverInboxNotifier _approverInboxNotifier; + private readonly IRequesterNotifier _requesterNotifier; private readonly ISingleActiveLeaseEvaluator _singleActiveLeaseEvaluator; private readonly TimeProvider _timeProvider; @@ -19,12 +20,14 @@ public ActivateAccessRequestCommand( IAccessRequestRepository accessRequestRepository, IAccessLeaseRepository accessLeaseRepository, IApproverInboxNotifier approverInboxNotifier, + IRequesterNotifier requesterNotifier, ISingleActiveLeaseEvaluator singleActiveLeaseEvaluator, TimeProvider timeProvider) { _accessRequestRepository = accessRequestRepository; _accessLeaseRepository = accessLeaseRepository; _approverInboxNotifier = approverInboxNotifier; + _requesterNotifier = requesterNotifier; _singleActiveLeaseEvaluator = singleActiveLeaseEvaluator; _timeProvider = timeProvider; } @@ -115,6 +118,10 @@ public async Task ActivateAsync(Guid userId, Guid requestId) // approver of this collection to re-fetch, mirroring decide and revoke. await _approverInboxNotifier.NotifyCollectionApproversAsync(request.CollectionId); + // Tell the requester's other devices the approved request just minted a lease, so their "My requests" view + // and any open partial cipher pick up the live lease without a manual refresh. + await _requesterNotifier.NotifyRequesterAsync(request.RequesterId); + return lease; } } diff --git a/src/Core/Pam/OrganizationFeatures/Commands/CancelAccessRequestCommand.cs b/src/Core/Pam/OrganizationFeatures/Commands/CancelAccessRequestCommand.cs index 5e4da33f17b6..213ebe26b59d 100644 --- a/src/Core/Pam/OrganizationFeatures/Commands/CancelAccessRequestCommand.cs +++ b/src/Core/Pam/OrganizationFeatures/Commands/CancelAccessRequestCommand.cs @@ -13,6 +13,7 @@ public class CancelAccessRequestCommand : ICancelAccessRequestCommand private readonly IAccessLeaseRepository _accessLeaseRepository; private readonly IApproverCollectionAccessQuery _approverCollectionAccessQuery; private readonly IApproverInboxNotifier _approverInboxNotifier; + private readonly IRequesterNotifier _requesterNotifier; private readonly TimeProvider _timeProvider; public CancelAccessRequestCommand( @@ -20,12 +21,14 @@ public CancelAccessRequestCommand( IAccessLeaseRepository accessLeaseRepository, IApproverCollectionAccessQuery approverCollectionAccessQuery, IApproverInboxNotifier approverInboxNotifier, + IRequesterNotifier requesterNotifier, TimeProvider timeProvider) { _accessRequestRepository = accessRequestRepository; _accessLeaseRepository = accessLeaseRepository; _approverCollectionAccessQuery = approverCollectionAccessQuery; _approverInboxNotifier = approverInboxNotifier; + _requesterNotifier = requesterNotifier; _timeProvider = timeProvider; } @@ -94,5 +97,9 @@ public async Task CancelAsync(Guid userId, Guid requestId) // The request just left the pending/approved set; tell every approver of this collection to re-fetch so it // drops out of their inbox. Mirrors decide. await _approverInboxNotifier.NotifyCollectionApproversAsync(request.CollectionId); + + // Tell the requester their request is gone, so a manager's retraction reaches them and their other devices + // drop the request from "My requests" without a manual refresh. + await _requesterNotifier.NotifyRequesterAsync(request.RequesterId); } } diff --git a/src/Core/Pam/OrganizationFeatures/Commands/DecideAccessRequestCommand.cs b/src/Core/Pam/OrganizationFeatures/Commands/DecideAccessRequestCommand.cs index d654d648c977..c20b17935f88 100644 --- a/src/Core/Pam/OrganizationFeatures/Commands/DecideAccessRequestCommand.cs +++ b/src/Core/Pam/OrganizationFeatures/Commands/DecideAccessRequestCommand.cs @@ -13,17 +13,20 @@ public class DecideAccessRequestCommand : IDecideAccessRequestCommand private readonly IAccessRequestRepository _accessRequestRepository; private readonly IApproverCollectionAccessQuery _approverCollectionAccessQuery; private readonly IApproverInboxNotifier _approverInboxNotifier; + private readonly IRequesterNotifier _requesterNotifier; private readonly TimeProvider _timeProvider; public DecideAccessRequestCommand( IAccessRequestRepository accessRequestRepository, IApproverCollectionAccessQuery approverCollectionAccessQuery, IApproverInboxNotifier approverInboxNotifier, + IRequesterNotifier requesterNotifier, TimeProvider timeProvider) { _accessRequestRepository = accessRequestRepository; _approverCollectionAccessQuery = approverCollectionAccessQuery; _approverInboxNotifier = approverInboxNotifier; + _requesterNotifier = requesterNotifier; _timeProvider = timeProvider; } @@ -79,6 +82,10 @@ public async Task DecideAsync(Guid userId, Guid requestId, // The request just left the pending queue; tell every approver of this collection to re-fetch. await _approverInboxNotifier.NotifyCollectionApproversAsync(request.CollectionId); + // Tell the requester their request was resolved, so their "My requests" view flips to approved/denied and + // an approval becomes activatable without a manual refresh. + await _requesterNotifier.NotifyRequesterAsync(request.RequesterId); + // The client repaints the row from Status, ResolvedAt, and ApproverComment, so those must be accurate; the // denormalized display fields already live on the client's existing row. Project from what we just wrote // rather than re-reading. diff --git a/src/Core/Pam/OrganizationFeatures/Commands/RequestLeaseExtensionCommand.cs b/src/Core/Pam/OrganizationFeatures/Commands/RequestLeaseExtensionCommand.cs index 23549f54b712..e41b46fad2a5 100644 --- a/src/Core/Pam/OrganizationFeatures/Commands/RequestLeaseExtensionCommand.cs +++ b/src/Core/Pam/OrganizationFeatures/Commands/RequestLeaseExtensionCommand.cs @@ -13,17 +13,23 @@ public class RequestLeaseExtensionCommand : IRequestLeaseExtensionCommand private readonly IAccessLeaseRepository _accessLeaseRepository; private readonly IGoverningRuleResolver _resolver; private readonly IAccessRequestRepository _accessRequestRepository; + private readonly IApproverInboxNotifier _approverInboxNotifier; + private readonly IRequesterNotifier _requesterNotifier; private readonly TimeProvider _timeProvider; public RequestLeaseExtensionCommand( IAccessLeaseRepository accessLeaseRepository, IGoverningRuleResolver resolver, IAccessRequestRepository accessRequestRepository, + IApproverInboxNotifier approverInboxNotifier, + IRequesterNotifier requesterNotifier, TimeProvider timeProvider) { _accessLeaseRepository = accessLeaseRepository; _resolver = resolver; _accessRequestRepository = accessRequestRepository; + _approverInboxNotifier = approverInboxNotifier; + _requesterNotifier = requesterNotifier; _timeProvider = timeProvider; } @@ -117,6 +123,12 @@ public async Task ExtendAsync(Guid userId, AccessLeaseExte throw new BadRequestException("This lease has already been extended."); } + // The parent lease's window just grew. Tell every approver of the collection to re-fetch (their active-leases + // and history views show the new end), and tell the requester's other devices so the banner/badge countdown + // reflects the longer window without a manual refresh. + await _approverInboxNotifier.NotifyCollectionApproversAsync(lease.CollectionId); + await _requesterNotifier.NotifyRequesterAsync(lease.RequesterId); + // Project the approved-extension state the client renders (Status approved + ExtensionOfLeaseId set) from // what we just wrote. The parent lease's end has already been pushed out, so the next access-state snapshot // re-emits the longer countdown. diff --git a/src/Core/Pam/OrganizationFeatures/Commands/RevokeAccessLeaseCommand.cs b/src/Core/Pam/OrganizationFeatures/Commands/RevokeAccessLeaseCommand.cs index 7649d16f7365..178b58b07d94 100644 --- a/src/Core/Pam/OrganizationFeatures/Commands/RevokeAccessLeaseCommand.cs +++ b/src/Core/Pam/OrganizationFeatures/Commands/RevokeAccessLeaseCommand.cs @@ -12,17 +12,20 @@ public class RevokeAccessLeaseCommand : IRevokeAccessLeaseCommand private readonly IAccessLeaseRepository _accessLeaseRepository; private readonly IApproverCollectionAccessQuery _approverCollectionAccessQuery; private readonly IApproverInboxNotifier _approverInboxNotifier; + private readonly IRequesterNotifier _requesterNotifier; private readonly TimeProvider _timeProvider; public RevokeAccessLeaseCommand( IAccessLeaseRepository accessLeaseRepository, IApproverCollectionAccessQuery approverCollectionAccessQuery, IApproverInboxNotifier approverInboxNotifier, + IRequesterNotifier requesterNotifier, TimeProvider timeProvider) { _accessLeaseRepository = accessLeaseRepository; _approverCollectionAccessQuery = approverCollectionAccessQuery; _approverInboxNotifier = approverInboxNotifier; + _requesterNotifier = requesterNotifier; _timeProvider = timeProvider; } @@ -64,5 +67,9 @@ public async Task RevokeAsync(Guid userId, Guid leaseId, string? reason) // The active lease just drained; tell every approver of this collection to re-fetch. await _approverInboxNotifier.NotifyCollectionApproversAsync(lease.CollectionId); + + // Tell the lease holder their access ended, so an open cipher re-locks and the banner/badges drop the lease + // — whether an operator revoked it or the holder ended it from another device. + await _requesterNotifier.NotifyRequesterAsync(lease.RequesterId); } } diff --git a/src/Core/Pam/OrganizationFeatures/Commands/SubmitAccessRequestCommand.cs b/src/Core/Pam/OrganizationFeatures/Commands/SubmitAccessRequestCommand.cs index 8e6727b619e4..f0d6f474f8c6 100644 --- a/src/Core/Pam/OrganizationFeatures/Commands/SubmitAccessRequestCommand.cs +++ b/src/Core/Pam/OrganizationFeatures/Commands/SubmitAccessRequestCommand.cs @@ -27,6 +27,7 @@ public class SubmitAccessRequestCommand : ISubmitAccessRequestCommand private readonly IAccessLeaseRepository _accessLeaseRepository; private readonly IAccessRequestRepository _accessRequestRepository; private readonly IApproverInboxNotifier _approverInboxNotifier; + private readonly IRequesterNotifier _requesterNotifier; private readonly TimeProvider _timeProvider; public SubmitAccessRequestCommand( @@ -37,6 +38,7 @@ public SubmitAccessRequestCommand( IAccessLeaseRepository accessLeaseRepository, IAccessRequestRepository accessRequestRepository, IApproverInboxNotifier approverInboxNotifier, + IRequesterNotifier requesterNotifier, TimeProvider timeProvider) { _cipherRepository = cipherRepository; @@ -46,6 +48,7 @@ public SubmitAccessRequestCommand( _accessLeaseRepository = accessLeaseRepository; _accessRequestRepository = accessRequestRepository; _approverInboxNotifier = approverInboxNotifier; + _requesterNotifier = requesterNotifier; _timeProvider = timeProvider; } @@ -146,6 +149,10 @@ private async Task ApproveAutomaticallyAsync( // the one place a lease is now minted, rather than here. await _accessRequestRepository.CreateAutoApprovedAsync(request, decision); + // Tell the requester's other devices a new approved request exists, so "My requests" can offer to activate it + // without a manual refresh. + await _requesterNotifier.NotifyRequesterAsync(userId); + return AccessRequestResult.Automatic(request); } @@ -196,6 +203,10 @@ private async Task RequestHumanApprovalAsync( // A new request just entered the pending queue; tell every approver of this collection to re-fetch. await _approverInboxNotifier.NotifyCollectionApproversAsync(created.CollectionId); + // Tell the requester's other devices a new pending request exists, so "My requests" reflects it without a + // manual refresh. + await _requesterNotifier.NotifyRequesterAsync(userId); + return AccessRequestResult.Human(created); } diff --git a/src/Core/Pam/Services/IRequesterNotifier.cs b/src/Core/Pam/Services/IRequesterNotifier.cs new file mode 100644 index 000000000000..bc819e650f75 --- /dev/null +++ b/src/Core/Pam/Services/IRequesterNotifier.cs @@ -0,0 +1,13 @@ +namespace Bit.Core.Pam.Services; + +/// +/// Pushes the RefreshAccessRequest signal to a single requester, telling their clients to re-fetch their own +/// access requests and active leases. Fired whenever something the requester's view renders changes for a reason +/// other than their own local action — a pending request being decided, a held lease being revoked or extended, a +/// request being cancelled — so their "My requests" list, lease banner, and row badges stay live, and an open cipher +/// re-locks the moment its lease ends. +/// +public interface IRequesterNotifier +{ + Task NotifyRequesterAsync(Guid requesterId); +} diff --git a/src/Core/Pam/Services/RequesterNotifier.cs b/src/Core/Pam/Services/RequesterNotifier.cs new file mode 100644 index 000000000000..9a50f5b1b6ca --- /dev/null +++ b/src/Core/Pam/Services/RequesterNotifier.cs @@ -0,0 +1,18 @@ +using Bit.Core.Platform.Push; + +namespace Bit.Core.Pam.Services; + +public class RequesterNotifier : IRequesterNotifier +{ + private readonly IPushNotificationService _pushNotificationService; + + public RequesterNotifier(IPushNotificationService pushNotificationService) + { + _pushNotificationService = pushNotificationService; + } + + public Task NotifyRequesterAsync(Guid requesterId) + { + return _pushNotificationService.PushRefreshAccessRequestAsync(requesterId); + } +} diff --git a/src/Core/Platform/Push/IPushNotificationService.cs b/src/Core/Platform/Push/IPushNotificationService.cs index 7be20ae5ed40..181a4a340a75 100644 --- a/src/Core/Platform/Push/IPushNotificationService.cs +++ b/src/Core/Platform/Push/IPushNotificationService.cs @@ -441,6 +441,22 @@ Task PushRefreshApproverInboxAsync(Guid userId) UserId = userId, #pragma warning disable BWP0001 // Type or member is obsolete Date = TimeProvider.GetUtcNow().UtcDateTime, +#pragma warning restore BWP0001 // Type or member is obsolete + }, + ExcludeCurrentContext = false, + }); + + Task PushRefreshAccessRequestAsync(Guid userId) + => PushAsync(new PushNotification + { + Type = PushType.RefreshAccessRequest, + Target = NotificationTarget.User, + TargetId = userId, + Payload = new UserPushNotification + { + UserId = userId, +#pragma warning disable BWP0001 // Type or member is obsolete + Date = TimeProvider.GetUtcNow().UtcDateTime, #pragma warning restore BWP0001 // Type or member is obsolete }, ExcludeCurrentContext = false, diff --git a/src/Core/Platform/Push/PushType.cs b/src/Core/Platform/Push/PushType.cs index c271abaee169..59dec637fe22 100644 --- a/src/Core/Platform/Push/PushType.cs +++ b/src/Core/Platform/Push/PushType.cs @@ -108,4 +108,7 @@ public enum PushType : byte [NotificationInfo("@bitwarden/team-vault-dev", typeof(Models.UserPushNotification))] RefreshApproverInbox = 28, + + [NotificationInfo("@bitwarden/team-vault-dev", typeof(Models.UserPushNotification))] + RefreshAccessRequest = 29, } diff --git a/src/Notifications/HubHelpers.cs b/src/Notifications/HubHelpers.cs index 714ce894433d..f2a9a29ae580 100644 --- a/src/Notifications/HubHelpers.cs +++ b/src/Notifications/HubHelpers.cs @@ -244,6 +244,18 @@ await _hubContext.Clients.User(pendingTasksData.Payload.UserId.ToString()) await _hubContext.Clients.User(approverInboxData.Payload.UserId.ToString()) .SendAsync(_receiveMessageMethod, approverInboxData, cancellationToken); break; + case PushType.RefreshAccessRequest: + var accessRequestData = + JsonSerializer.Deserialize>(notificationJson, + _deserializerOptions); + if (accessRequestData is null) + { + break; + } + + await _hubContext.Clients.User(accessRequestData.Payload.UserId.ToString()) + .SendAsync(_receiveMessageMethod, accessRequestData, cancellationToken); + break; case PushType.PolicyChanged: await policyChangedNotificationHandler(notificationJson, cancellationToken); break; diff --git a/test/Core.Test/Pam/Commands/ActivateAccessRequestCommandTests.cs b/test/Core.Test/Pam/Commands/ActivateAccessRequestCommandTests.cs index e388a09b591b..697267be604e 100644 --- a/test/Core.Test/Pam/Commands/ActivateAccessRequestCommandTests.cs +++ b/test/Core.Test/Pam/Commands/ActivateAccessRequestCommandTests.cs @@ -154,6 +154,8 @@ await sutProvider.GetDependency().Received(1) .CreateFromApprovedRequestAsync(result, _now, Arg.Any()); await sutProvider.GetDependency().Received(1) .NotifyCollectionApproversAsync(request.CollectionId); + await sutProvider.GetDependency().Received(1) + .NotifyRequesterAsync(request.RequesterId); } [Theory, BitAutoData] @@ -174,6 +176,8 @@ public async Task ActivateAsync_LostRace_WinnerLive_ReturnsWinner(AccessRequest Assert.Same(winner, result); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() .NotifyCollectionApproversAsync(default); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .NotifyRequesterAsync(default); } [Theory, BitAutoData] @@ -226,6 +230,8 @@ public async Task ActivateAsync_SingleActiveLeaseConflict_ThrowsConflict(AccessR Assert.Contains("Another active lease exists", ex.Message); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() .NotifyCollectionApproversAsync(default); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .NotifyRequesterAsync(default); } [Theory, BitAutoData] diff --git a/test/Core.Test/Pam/Commands/CancelAccessRequestCommandTests.cs b/test/Core.Test/Pam/Commands/CancelAccessRequestCommandTests.cs index 7592b511fe9b..58ecbd08c126 100644 --- a/test/Core.Test/Pam/Commands/CancelAccessRequestCommandTests.cs +++ b/test/Core.Test/Pam/Commands/CancelAccessRequestCommandTests.cs @@ -79,6 +79,8 @@ await sutProvider.GetDependency().DidNotReceiveWithAny .CancelWithDecisionAsync(default!, default!, default); await sutProvider.GetDependency().Received(1) .NotifyCollectionApproversAsync(request.CollectionId); + await sutProvider.GetDependency().Received(1) + .NotifyRequesterAsync(request.RequesterId); } [Theory] @@ -107,6 +109,8 @@ await sutProvider.GetDependency().DidNotReceiveWithAny .CancelAsync(default, default); await sutProvider.GetDependency().Received(1) .NotifyCollectionApproversAsync(request.CollectionId); + await sutProvider.GetDependency().Received(1) + .NotifyRequesterAsync(request.RequesterId); } [Theory, BitAutoData] diff --git a/test/Core.Test/Pam/Commands/DecideAccessRequestCommandTests.cs b/test/Core.Test/Pam/Commands/DecideAccessRequestCommandTests.cs index f13e21b16b9d..b21a66d0e674 100644 --- a/test/Core.Test/Pam/Commands/DecideAccessRequestCommandTests.cs +++ b/test/Core.Test/Pam/Commands/DecideAccessRequestCommandTests.cs @@ -39,6 +39,10 @@ public async Task DecideAsync_NotManageable_ThrowsNotFound(Guid userId, AccessRe await Assert.ThrowsAsync( () => sutProvider.Sut.DecideAsync(userId, request.Id, Approve())); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .NotifyCollectionApproversAsync(default); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .NotifyRequesterAsync(default); } [Theory, BitAutoData] @@ -50,6 +54,10 @@ public async Task DecideAsync_NotPending_ThrowsConflict(Guid userId, AccessReque await Assert.ThrowsAsync( () => sutProvider.Sut.DecideAsync(userId, request.Id, Approve())); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .NotifyCollectionApproversAsync(default); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .NotifyRequesterAsync(default); } [Theory, BitAutoData] @@ -65,6 +73,8 @@ public async Task DecideAsync_SelfApproval_ThrowsBadRequest(Guid userId, AccessR Assert.Contains("your own request", ex.Message); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() .ResolveWithDecisionAsync(default!, default!, default, default); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .NotifyRequesterAsync(default); } [Theory, BitAutoData] @@ -83,6 +93,8 @@ await sutProvider.GetDependency().DidNotReceiveWithAny .ResolveWithDecisionAsync(default!, default!, default, default); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() .NotifyCollectionApproversAsync(default); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .NotifyRequesterAsync(default); } [Theory, BitAutoData] @@ -126,6 +138,8 @@ await sutProvider.GetDependency().Received(1).ResolveW _now); await sutProvider.GetDependency().Received(1) .NotifyCollectionApproversAsync(request.CollectionId); + await sutProvider.GetDependency().Received(1) + .NotifyRequesterAsync(request.RequesterId); } [Theory, BitAutoData] @@ -144,6 +158,9 @@ await sutProvider.GetDependency().Received(1).ResolveW Arg.Is(d => d.Verdict == AccessDecisionVerdict.Deny), AccessRequestStatus.Denied, _now); + // A denial reaches the requester too (their "My requests" view flips to denied). + await sutProvider.GetDependency().Received(1) + .NotifyRequesterAsync(request.RequesterId); } private static AccessDecisionSubmission Approve(string? comment = null) => diff --git a/test/Core.Test/Pam/Commands/RequestLeaseExtensionCommandTests.cs b/test/Core.Test/Pam/Commands/RequestLeaseExtensionCommandTests.cs index fa1d34d71c99..0300f7965ec6 100644 --- a/test/Core.Test/Pam/Commands/RequestLeaseExtensionCommandTests.cs +++ b/test/Core.Test/Pam/Commands/RequestLeaseExtensionCommandTests.cs @@ -179,6 +179,13 @@ await sutProvider.GetDependency().Received(1).CreateAp Arg.Is(d => d.DeciderKind == AccessDeciderKind.Automatic && d.Verdict == AccessDecisionVerdict.Approve), _now); + + // The widened lease window must reach both the approvers (active-leases / history views) and the requester's + // other devices (banner / badge countdown). + await sutProvider.GetDependency().Received(1) + .NotifyCollectionApproversAsync(lease.CollectionId); + await sutProvider.GetDependency().Received(1) + .NotifyRequesterAsync(lease.RequesterId); } [Theory, BitAutoData] diff --git a/test/Core.Test/Pam/Commands/RevokeAccessLeaseCommandTests.cs b/test/Core.Test/Pam/Commands/RevokeAccessLeaseCommandTests.cs index 8511c5853112..77296dd8cc92 100644 --- a/test/Core.Test/Pam/Commands/RevokeAccessLeaseCommandTests.cs +++ b/test/Core.Test/Pam/Commands/RevokeAccessLeaseCommandTests.cs @@ -63,6 +63,8 @@ await sutProvider.GetDependency().Received(1).RevokeAsyn _now); await sutProvider.GetDependency().Received(1) .NotifyCollectionApproversAsync(lease.CollectionId); + await sutProvider.GetDependency().Received(1) + .NotifyRequesterAsync(lease.RequesterId); } [Theory, BitAutoData] @@ -95,6 +97,8 @@ await sutProvider.GetDependency().Received(1).RevokeAsyn _now); await sutProvider.GetDependency().Received(1) .NotifyCollectionApproversAsync(lease.CollectionId); + await sutProvider.GetDependency().Received(1) + .NotifyRequesterAsync(lease.RequesterId); } private static SutProvider Setup() diff --git a/test/Core.Test/Pam/Commands/SubmitAccessRequestCommandTests.cs b/test/Core.Test/Pam/Commands/SubmitAccessRequestCommandTests.cs index 7ace09e04ec8..8864db84daaa 100644 --- a/test/Core.Test/Pam/Commands/SubmitAccessRequestCommandTests.cs +++ b/test/Core.Test/Pam/Commands/SubmitAccessRequestCommandTests.cs @@ -154,6 +154,8 @@ await sutProvider.GetDependency().DidNotReceiveWithAny .CreateAutoApprovedAsync(default!, default!); await sutProvider.GetDependency().Received(1) .NotifyCollectionApproversAsync(collectionId); + await sutProvider.GetDependency().Received(1) + .NotifyRequesterAsync(userId); } [Theory, BitAutoData] @@ -169,6 +171,10 @@ await sutProvider.Sut.SubmitAsync(userId, cipherId, await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() .NotifyCollectionApproversAsync(default); + // The auto path mints no approval gate, but the requester's other devices still learn of the new approved + // request. + await sutProvider.GetDependency().Received(1) + .NotifyRequesterAsync(userId); } [Theory, BitAutoData] diff --git a/test/Core.Test/Pam/Services/RequesterNotifierTests.cs b/test/Core.Test/Pam/Services/RequesterNotifierTests.cs new file mode 100644 index 000000000000..2997a5cb08ee --- /dev/null +++ b/test/Core.Test/Pam/Services/RequesterNotifierTests.cs @@ -0,0 +1,22 @@ +using Bit.Core.Pam.Services; +using Bit.Core.Platform.Push; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Pam.Services; + +[SutProviderCustomize] +public class RequesterNotifierTests +{ + [Theory, BitAutoData] + public async Task NotifyRequesterAsync_PushesToRequester( + SutProvider sutProvider, Guid requesterId) + { + await sutProvider.Sut.NotifyRequesterAsync(requesterId); + + await sutProvider.GetDependency().Received(1) + .PushRefreshAccessRequestAsync(requesterId); + } +} From 43415f8a73a9d3a1b064e4208d7afe3883e2992b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anders=20=C3=85berg?= Date: Tue, 16 Jun 2026 09:33:03 +0200 Subject: [PATCH 36/54] simplify: collapse identical user-refresh push handlers in HubHelpers into one fall-through case --- src/Notifications/HubHelpers.cs | 30 ++++-------------------------- 1 file changed, 4 insertions(+), 26 deletions(-) diff --git a/src/Notifications/HubHelpers.cs b/src/Notifications/HubHelpers.cs index f2a9a29ae580..9cad096aba35 100644 --- a/src/Notifications/HubHelpers.cs +++ b/src/Notifications/HubHelpers.cs @@ -221,40 +221,18 @@ await _hubContext.Clients.Group(NotificationsHub.GetOrganizationGroup( break; case PushType.RefreshSecurityTasks: - var pendingTasksData = - JsonSerializer.Deserialize>(notificationJson, - _deserializerOptions); - if (pendingTasksData is null) - { - break; - } - - await _hubContext.Clients.User(pendingTasksData.Payload.UserId.ToString()) - .SendAsync(_receiveMessageMethod, pendingTasksData, cancellationToken); - break; case PushType.RefreshApproverInbox: - var approverInboxData = - JsonSerializer.Deserialize>(notificationJson, - _deserializerOptions); - if (approverInboxData is null) - { - break; - } - - await _hubContext.Clients.User(approverInboxData.Payload.UserId.ToString()) - .SendAsync(_receiveMessageMethod, approverInboxData, cancellationToken); - break; case PushType.RefreshAccessRequest: - var accessRequestData = + var userRefreshData = JsonSerializer.Deserialize>(notificationJson, _deserializerOptions); - if (accessRequestData is null) + if (userRefreshData is null) { break; } - await _hubContext.Clients.User(accessRequestData.Payload.UserId.ToString()) - .SendAsync(_receiveMessageMethod, accessRequestData, cancellationToken); + await _hubContext.Clients.User(userRefreshData.Payload.UserId.ToString()) + .SendAsync(_receiveMessageMethod, userRefreshData, cancellationToken); break; case PushType.PolicyChanged: await policyChangedNotificationHandler(notificationJson, cancellationToken); From fcc219826852bf411199ef1d9f7bb94ea5c588ed Mon Sep 17 00:00:00 2001 From: Hinton Date: Tue, 16 Jun 2026 10:37:33 +0200 Subject: [PATCH 37/54] Add email --- .../Pam/PendingAccessRequest.html.hbs | 26 ++++ .../Pam/PendingAccessRequest.text.hbs | 9 ++ .../Mail/PamPendingAccessRequestViewModel.cs | 15 +++ .../Commands/SubmitAccessRequestCommand.cs | 85 +++++++++++++ .../Platform/Mail/HandlebarsMailService.cs | 35 ++++++ src/Core/Platform/Mail/IMailService.cs | 14 +++ src/Core/Platform/Mail/NoopMailService.cs | 6 + .../SubmitAccessRequestCommandTests.cs | 119 +++++++++++++++++- .../Services/HandlebarsMailServiceTests.cs | 37 ++++++ 9 files changed, 345 insertions(+), 1 deletion(-) create mode 100644 src/Core/MailTemplates/Handlebars/Pam/PendingAccessRequest.html.hbs create mode 100644 src/Core/MailTemplates/Handlebars/Pam/PendingAccessRequest.text.hbs create mode 100644 src/Core/Models/Mail/PamPendingAccessRequestViewModel.cs diff --git a/src/Core/MailTemplates/Handlebars/Pam/PendingAccessRequest.html.hbs b/src/Core/MailTemplates/Handlebars/Pam/PendingAccessRequest.html.hbs new file mode 100644 index 000000000000..a9e3024510b3 --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/Pam/PendingAccessRequest.html.hbs @@ -0,0 +1,26 @@ +{{#>FullHtmlLayout}} + + + + + + + + + + +
+ A member of {{OrganizationName}} has requested time-limited access to a protected item and is waiting for an approver. Log in to your approver inbox to approve or deny the request. +
+ Requested by: {{RequesterName}} ({{RequesterEmail}}) +
+ Access window: {{NotBefore}} – {{NotAfter}} +
+ Reason: {{Reason}} +
+ + Review the request + +
+
+{{/FullHtmlLayout}} diff --git a/src/Core/MailTemplates/Handlebars/Pam/PendingAccessRequest.text.hbs b/src/Core/MailTemplates/Handlebars/Pam/PendingAccessRequest.text.hbs new file mode 100644 index 000000000000..1b7fbd4bcbd5 --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/Pam/PendingAccessRequest.text.hbs @@ -0,0 +1,9 @@ +{{#>BasicTextLayout}} +A member of {{OrganizationName}} has requested time-limited access to a protected item and is waiting for an approver. + +Requested by: {{RequesterName}} ({{RequesterEmail}}) +Access window: {{NotBefore}} - {{NotAfter}} +Reason: {{Reason}} + +Log in to your approver inbox to approve or deny the request: {{ApproverInboxUrl}} +{{/BasicTextLayout}} diff --git a/src/Core/Models/Mail/PamPendingAccessRequestViewModel.cs b/src/Core/Models/Mail/PamPendingAccessRequestViewModel.cs new file mode 100644 index 000000000000..f80ffc11ea13 --- /dev/null +++ b/src/Core/Models/Mail/PamPendingAccessRequestViewModel.cs @@ -0,0 +1,15 @@ +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Models.Mail; + +public class PamPendingAccessRequestViewModel : BaseMailModel +{ + public string OrganizationName { get; set; } + public string RequesterName { get; set; } + public string RequesterEmail { get; set; } + public string NotBefore { get; set; } + public string NotAfter { get; set; } + public string Reason { get; set; } + public string ApproverInboxUrl => $"{WebVaultUrl}/pam/approver-inbox/approvals"; +} diff --git a/src/Core/Pam/OrganizationFeatures/Commands/SubmitAccessRequestCommand.cs b/src/Core/Pam/OrganizationFeatures/Commands/SubmitAccessRequestCommand.cs index f0d6f474f8c6..8ff9358dceef 100644 --- a/src/Core/Pam/OrganizationFeatures/Commands/SubmitAccessRequestCommand.cs +++ b/src/Core/Pam/OrganizationFeatures/Commands/SubmitAccessRequestCommand.cs @@ -8,7 +8,10 @@ using Bit.Core.Pam.OrganizationFeatures.Commands.Interfaces; using Bit.Core.Pam.Repositories; using Bit.Core.Pam.Services; +using Bit.Core.Repositories; +using Bit.Core.Services; using Bit.Core.Vault.Repositories; +using Microsoft.Extensions.Logging; namespace Bit.Core.Pam.OrganizationFeatures.Commands; @@ -28,6 +31,11 @@ public class SubmitAccessRequestCommand : ISubmitAccessRequestCommand private readonly IAccessRequestRepository _accessRequestRepository; private readonly IApproverInboxNotifier _approverInboxNotifier; private readonly IRequesterNotifier _requesterNotifier; + private readonly ICollectionRepository _collectionRepository; + private readonly IUserRepository _userRepository; + private readonly IOrganizationRepository _organizationRepository; + private readonly IMailService _mailService; + private readonly ILogger _logger; private readonly TimeProvider _timeProvider; public SubmitAccessRequestCommand( @@ -39,6 +47,11 @@ public SubmitAccessRequestCommand( IAccessRequestRepository accessRequestRepository, IApproverInboxNotifier approverInboxNotifier, IRequesterNotifier requesterNotifier, + ICollectionRepository collectionRepository, + IUserRepository userRepository, + IOrganizationRepository organizationRepository, + IMailService mailService, + ILogger logger, TimeProvider timeProvider) { _cipherRepository = cipherRepository; @@ -49,6 +62,11 @@ public SubmitAccessRequestCommand( _accessRequestRepository = accessRequestRepository; _approverInboxNotifier = approverInboxNotifier; _requesterNotifier = requesterNotifier; + _collectionRepository = collectionRepository; + _userRepository = userRepository; + _organizationRepository = organizationRepository; + _mailService = mailService; + _logger = logger; _timeProvider = timeProvider; } @@ -207,9 +225,76 @@ private async Task RequestHumanApprovalAsync( // manual refresh. await _requesterNotifier.NotifyRequesterAsync(userId); + // Also email the collection's approvers so they learn about the request outside of an open session. + await NotifyApproversByEmailAsync(created); + return AccessRequestResult.Human(created); } + /// + /// Emails the managers (approvers) of the request's collection that a new request is pending their review. + /// Best-effort: failures are logged and swallowed so they never fail the request submission. + /// + private async Task NotifyApproversByEmailAsync(AccessRequest request) + { + try + { + var managerIds = await _collectionRepository.GetManagingUserIdsAsync(request.CollectionId); + + // The requester may manage the collection themselves; never notify them of their own request. + var recipientIds = managerIds.Where(id => id != request.RequesterId).ToList(); + if (recipientIds.Count == 0) + { + return; + } + + var managers = await _userRepository.GetManyAsync(recipientIds); + var managerEmails = managers + .Select(u => u.Email) + .Where(email => !string.IsNullOrWhiteSpace(email)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + if (managerEmails.Count == 0) + { + return; + } + + var requester = await _userRepository.GetByIdAsync(request.RequesterId); + if (requester is null || string.IsNullOrWhiteSpace(requester.Email)) + { + _logger.LogWarning( + "Skipping PAM approver email for access request {AccessRequestId}; requester not found or has no email.", + request.Id); + return; + } + + var organization = await _organizationRepository.GetByIdAsync(request.OrganizationId); + if (organization is null) + { + _logger.LogWarning( + "Skipping PAM approver email for access request {AccessRequestId}; organization not found.", + request.Id); + return; + } + + await _mailService.SendPamPendingAccessRequestEmailsAsync( + managerEmails, + organization.Name, + requester.Name, + requester.Email, + request.NotBefore, + request.NotAfter, + request.Reason); + } + catch (Exception ex) + { + // Best effort: the request is already persisted and the inbox push already fired. An email failure + // must never fail the submission, so log and move on. + _logger.LogError(ex, + "Failed to send PAM approver emails for access request {AccessRequestId}.", request.Id); + } + } + private AccessSignals BuildSignals(DateTime now) => new() { IpAddress = IPAddress.TryParse(_currentContext.IpAddress, out var ip) ? ip : null, diff --git a/src/Core/Platform/Mail/HandlebarsMailService.cs b/src/Core/Platform/Mail/HandlebarsMailService.cs index 7e97f3648212..0ffd460af323 100644 --- a/src/Core/Platform/Mail/HandlebarsMailService.cs +++ b/src/Core/Platform/Mail/HandlebarsMailService.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using System.Globalization; using System.Net; using System.Reflection; using System.Text.Json; @@ -342,6 +343,40 @@ public async Task SendOrganizationAcceptedEmailAsync(Organization organization, await _mailDeliveryService.SendEmailAsync(message); } + public async Task SendPamPendingAccessRequestEmailsAsync(IEnumerable managerEmails, string organizationName, + string? requesterName, string requesterEmail, DateTime notBefore, DateTime notAfter, string? reason) + { + // The body is identical for every approver, so build the view model once and reuse it. + var model = new PamPendingAccessRequestViewModel + { + // Only server-readable request metadata is rendered. The requested item and collection names are + // encrypted vault data and are intentionally never passed to, or shown by, this email. The requester + // controls their name, email, and reason, so each is anti-phishing sanitized to neutralize links and + // spoofed addresses smuggled into the notice. + OrganizationName = CoreHelpers.SanitizeForEmail(organizationName, false), + RequesterName = CoreHelpers.SanitizeForEmail( + string.IsNullOrWhiteSpace(requesterName) ? requesterEmail : requesterName, false), + RequesterEmail = CoreHelpers.SanitizeForEmail(requesterEmail, false), + NotBefore = notBefore.ToString("MMMM d, yyyy h:mm tt", CultureInfo.InvariantCulture) + " " + _utcTimeZoneDisplay, + NotAfter = notAfter.ToString("MMMM d, yyyy h:mm tt", CultureInfo.InvariantCulture) + " " + _utcTimeZoneDisplay, + Reason = CoreHelpers.SanitizeForEmail(string.IsNullOrWhiteSpace(reason) ? "(no reason provided)" : reason, false), + WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash, + SiteName = _globalSettings.SiteName, + }; + + // One message per recipient so approvers are not disclosed to one another. + var queueMessages = managerEmails.Select(email => + { + var message = CreateDefaultMessage("New access request awaiting your approval", email); + return new MailQueueMessage(message, "Pam.PendingAccessRequest", model) + { + Category = "PamPendingAccessRequest", + }; + }); + + await EnqueueMailAsync(queueMessages); + } + public async Task SendOrganizationConfirmedEmailAsync(string organizationName, string email, bool hasAccessSecretsManager = false) { var message = CreateDefaultMessage($"You Have Been Confirmed To {organizationName}", email); diff --git a/src/Core/Platform/Mail/IMailService.cs b/src/Core/Platform/Mail/IMailService.cs index 6c91943e59fe..9787539939f6 100644 --- a/src/Core/Platform/Mail/IMailService.cs +++ b/src/Core/Platform/Mail/IMailService.cs @@ -77,6 +77,20 @@ Task SendTrialInitiationSignupEmailAsync( Task SendOrganizationConfirmedEmailAsync(string organizationName, string email, bool hasAccessSecretsManager = false); Task SendUpdatedOrganizationConfirmedEmailAsync(Organization organization, string userEmail, bool accessSecretsManager = false); Task SendOrganizationUserRevokedForTwoFactorPolicyEmailAsync(string organizationName, string email); + /// + /// Notifies a collection's managers (the approvers) that a new PAM access request is pending their review. + /// Sent as a single bulk message to all manager emails. Carries only server-readable request metadata; it + /// must never include the requested item or collection name, which are encrypted vault data. + /// + /// Email addresses of the collection's managers/approvers. + /// The organization the request belongs to. + /// Display name of the requester (falls back to email when blank). + /// Email address of the requester. + /// Start of the requested access window (UTC). + /// End of the requested access window (UTC). + /// The requester's stated reason for access. + Task SendPamPendingAccessRequestEmailsAsync(IEnumerable managerEmails, string organizationName, + string? requesterName, string requesterEmail, DateTime notBefore, DateTime notAfter, string? reason); Task SendOrganizationUserRevokedForPolicySingleOrgEmailAsync(string organizationName, string email); Task SendPasswordlessSignInAsync(string returnUrl, string token, string email); Task SendInvoiceUpcoming( diff --git a/src/Core/Platform/Mail/NoopMailService.cs b/src/Core/Platform/Mail/NoopMailService.cs index ef8b5f9c78f8..26f43dba3c91 100644 --- a/src/Core/Platform/Mail/NoopMailService.cs +++ b/src/Core/Platform/Mail/NoopMailService.cs @@ -68,6 +68,12 @@ public Task SendOrganizationAutoscaledEmailAsync(Organization organization, int return Task.FromResult(0); } + public Task SendPamPendingAccessRequestEmailsAsync(IEnumerable managerEmails, string organizationName, + string? requesterName, string requesterEmail, DateTime notBefore, DateTime notAfter, string? reason) + { + return Task.FromResult(0); + } + public Task SendOrganizationAcceptedEmailAsync(Organization organization, string userIdentifier, IEnumerable adminEmails, bool hasAccessSecretsManager = false) { diff --git a/test/Core.Test/Pam/Commands/SubmitAccessRequestCommandTests.cs b/test/Core.Test/Pam/Commands/SubmitAccessRequestCommandTests.cs index 8864db84daaa..69c474be99fa 100644 --- a/test/Core.Test/Pam/Commands/SubmitAccessRequestCommandTests.cs +++ b/test/Core.Test/Pam/Commands/SubmitAccessRequestCommandTests.cs @@ -1,4 +1,6 @@ -using Bit.Core.Exceptions; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Entities; +using Bit.Core.Exceptions; using Bit.Core.Pam.Engine; using Bit.Core.Pam.Entities; using Bit.Core.Pam.Enums; @@ -7,6 +9,8 @@ using Bit.Core.Pam.OrganizationFeatures.Commands; using Bit.Core.Pam.Repositories; using Bit.Core.Pam.Services; +using Bit.Core.Repositories; +using Bit.Core.Services; using Bit.Core.Vault.Models.Data; using Bit.Core.Vault.Repositories; using Bit.Test.Common.AutoFixture; @@ -158,6 +162,90 @@ await sutProvider.GetDependency().Received(1) .NotifyRequesterAsync(userId); } + [Theory, BitAutoData] + public async Task SubmitAsync_Human_EmailsApproversExcludingRequester( + Guid userId, Guid cipherId, Guid orgId, Guid collectionId) + { + var sutProvider = Setup(); + SetupCipher(sutProvider, userId, cipherId); + SetupResolution(sutProvider, userId, cipherId, orgId, collectionId, requiresHuman: true); + SetupHumanCreate(sutProvider); + SetupApproverEmails(sutProvider, collectionId, userId, orgId); + + var start = _now.AddHours(1); + var end = _now.AddHours(2); + await sutProvider.Sut.SubmitAsync(userId, cipherId, + new AccessRequestSubmission { Start = start, End = end, Reason = "audit" }); + + await sutProvider.GetDependency().Received(1) + .SendPamPendingAccessRequestEmailsAsync( + Arg.Is>(e => + e.Contains("a@example.com") && e.Contains("b@example.com") && !e.Contains("requester@example.com")), + "Acme Corp", "Reqi", "requester@example.com", start, end, "audit"); + } + + [Theory, BitAutoData] + public async Task SubmitAsync_Human_NoApprovers_DoesNotEmail( + Guid userId, Guid cipherId, Guid orgId, Guid collectionId) + { + var sutProvider = Setup(); + SetupCipher(sutProvider, userId, cipherId); + SetupResolution(sutProvider, userId, cipherId, orgId, collectionId, requiresHuman: true); + SetupHumanCreate(sutProvider); + // Setup() defaults the collection to no managers, so there is nobody to email. + + var result = await sutProvider.Sut.SubmitAsync(userId, cipherId, + new AccessRequestSubmission { Start = _now.AddHours(1), End = _now.AddHours(2), Reason = "audit" }); + + Assert.Equal(AccessApprovalMode.Human, result.ApprovalMode); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .SendPamPendingAccessRequestEmailsAsync(default!, default!, default, default!, default, default, default); + } + + [Theory, BitAutoData] + public async Task SubmitAsync_Human_RequesterNotFound_DoesNotEmail( + Guid userId, Guid cipherId, Guid orgId, Guid collectionId) + { + var sutProvider = Setup(); + SetupCipher(sutProvider, userId, cipherId); + SetupResolution(sutProvider, userId, cipherId, orgId, collectionId, requiresHuman: true); + SetupHumanCreate(sutProvider); + var manager = new User { Id = Guid.NewGuid(), Email = "manager@example.com" }; + sutProvider.GetDependency().GetManagingUserIdsAsync(collectionId) + .Returns(new List { manager.Id }); + sutProvider.GetDependency().GetManyAsync(Arg.Any>()) + .Returns(new List { manager }); + sutProvider.GetDependency().GetByIdAsync(userId).Returns((User?)null); + + await sutProvider.Sut.SubmitAsync(userId, cipherId, + new AccessRequestSubmission { Start = _now.AddHours(1), End = _now.AddHours(2), Reason = "audit" }); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .SendPamPendingAccessRequestEmailsAsync(default!, default!, default, default!, default, default, default); + } + + [Theory, BitAutoData] + public async Task SubmitAsync_Human_MailServiceThrows_StillReturnsResult( + Guid userId, Guid cipherId, Guid orgId, Guid collectionId) + { + var sutProvider = Setup(); + SetupCipher(sutProvider, userId, cipherId); + SetupResolution(sutProvider, userId, cipherId, orgId, collectionId, requiresHuman: true); + SetupHumanCreate(sutProvider); + SetupApproverEmails(sutProvider, collectionId, userId, orgId); + sutProvider.GetDependency() + .SendPamPendingAccessRequestEmailsAsync(Arg.Any>(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromException(new Exception("smtp down"))); + + // An email failure must not fail the submission — the request is already persisted. + var result = await sutProvider.Sut.SubmitAsync(userId, cipherId, + new AccessRequestSubmission { Start = _now.AddHours(1), End = _now.AddHours(2), Reason = "audit" }); + + Assert.Equal(AccessApprovalMode.Human, result.ApprovalMode); + Assert.Equal(AccessRequestStatus.Pending, result.Request!.Status); + } + [Theory, BitAutoData] public async Task SubmitAsync_Automatic_DoesNotNotifyApprovers(Guid userId, Guid cipherId, Guid orgId, Guid collectionId) { @@ -175,6 +263,8 @@ await sutProvider.GetDependency().DidNotReceiveWithAnyAr // request. await sutProvider.GetDependency().Received(1) .NotifyRequesterAsync(userId); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .SendPamPendingAccessRequestEmailsAsync(default!, default!, default, default!, default, default, default); } [Theory, BitAutoData] @@ -271,9 +361,36 @@ private static SutProvider Setup() { var sutProvider = new SutProvider().WithFakeTimeProvider().Create(); sutProvider.GetDependency().SetUtcNow(_now); + // Default to a collection with no managers so tests that don't exercise the approver email skip it cleanly. + sutProvider.GetDependency().GetManagingUserIdsAsync(Arg.Any()) + .Returns(new List()); return sutProvider; } + private static void SetupHumanCreate(SutProvider sutProvider) => + sutProvider.GetDependency() + .CreateAsync(Arg.Any()) + .Returns(callInfo => callInfo.Arg()); + + /// + /// Wires the collection managers, requester, and organization so the approver email resolves and sends. + /// The requester () is intentionally also a manager, to assert they're excluded. + /// + private static void SetupApproverEmails(SutProvider sutProvider, + Guid collectionId, Guid userId, Guid orgId) + { + var managerA = new User { Id = Guid.NewGuid(), Email = "a@example.com", Name = "A" }; + var managerB = new User { Id = Guid.NewGuid(), Email = "b@example.com", Name = "B" }; + sutProvider.GetDependency().GetManagingUserIdsAsync(collectionId) + .Returns(new List { managerA.Id, managerB.Id, userId }); + sutProvider.GetDependency().GetManyAsync(Arg.Any>()) + .Returns(new List { managerA, managerB }); + sutProvider.GetDependency().GetByIdAsync(userId) + .Returns(new User { Id = userId, Email = "requester@example.com", Name = "Reqi" }); + sutProvider.GetDependency().GetByIdAsync(orgId) + .Returns(new Organization { Id = orgId, Name = "Acme Corp" }); + } + private static void SetupCipher(SutProvider sutProvider, Guid userId, Guid cipherId) { sutProvider.GetDependency() diff --git a/test/Core.Test/Services/HandlebarsMailServiceTests.cs b/test/Core.Test/Services/HandlebarsMailServiceTests.cs index 5dae4f5d17d4..3e96f8ac26f9 100644 --- a/test/Core.Test/Services/HandlebarsMailServiceTests.cs +++ b/test/Core.Test/Services/HandlebarsMailServiceTests.cs @@ -275,6 +275,43 @@ await _mailDeliveryService.Received(1).SendEmailAsync(Arg.Is(m => m.Category == "Welcome")); } + [Fact] + public async Task SendPamPendingAccessRequestEmailsAsync_SendsOneRenderedEmailPerRecipient() + { + // Arrange + var managerEmails = new[] { "approver1@example.com", "approver2@example.com" }; + var notBefore = new DateTime(2026, 6, 16, 9, 0, 0, DateTimeKind.Utc); + var notAfter = new DateTime(2026, 6, 16, 17, 0, 0, DateTimeKind.Utc); + // The messages are enqueued; run the fallback inline for each so the template renders and emails are delivered. + _mailEnqueuingService + .EnqueueManyAsync(Arg.Any>(), Arg.Any>()) + .Returns(call => Task.WhenAll( + call.Arg>().Select(call.Arg>()))); + + // Act + await _sut.SendPamPendingAccessRequestEmailsAsync(managerEmails, "Acme Corp", + "Alice Smith", "alice@example.com", notBefore, notAfter, "Rotate prod DB password"); + + // Assert — one message per recipient (approvers are not disclosed to one another), each carrying the + // server-readable request fields with the requester's email anti-phishing sanitized. + await _mailDeliveryService.Received(2).SendEmailAsync(Arg.Any()); + foreach (var recipient in managerEmails) + { + await _mailDeliveryService.Received(1).SendEmailAsync(Arg.Is(m => + m.ToEmails.Count() == 1 && + m.ToEmails.Contains(recipient) && + m.Subject == "New access request awaiting your approval" && + m.Category == "PamPendingAccessRequest" && + m.HtmlContent.Contains("Acme Corp") && + m.HtmlContent.Contains("Alice Smith") && + m.HtmlContent.Contains("alice[at]example[dot]com") && + m.HtmlContent.Contains("Rotate prod DB password") && + m.HtmlContent.Contains("2026") && + m.HtmlContent.Contains("UTC") && + m.HtmlContent.Contains("/pam/approver-inbox/approvals"))); + } + } + [Fact] public async Task SendOrganizationUserWelcomeEmailAsync_SendsCorrectEmailWithOrganizationName() { From 6afa266ea0a4dabf04f0a1b4ae1bbd9d75992da8 Mon Sep 17 00:00:00 2001 From: Hinton Date: Tue, 16 Jun 2026 10:59:42 +0200 Subject: [PATCH 38/54] Organize endpoinnts by resource --- ...troller.cs => AccessRequestsController.cs} | 82 ++++++----- .../Controllers/ApproverInboxController.cs | 72 --------- .../Controllers/LeaseGovernanceController.cs | 49 ------- src/Api/Pam/Controllers/LeasesController.cs | 93 ++++++++++++ .../AccessLeaseExtensionRequestModel.cs | 8 +- .../AccessRequestDetailsResponseModel.cs | 2 +- .../AccessRequestsControllerTests.cs | 138 ++++++++++++++++++ .../ApproverInboxControllerTests.cs | 94 ------------ .../LeaseGovernanceControllerTests.cs | 67 --------- .../Pam/Controllers/LeasesControllerTests.cs | 120 +++++++++++++++ .../MemberLeasingControllerTests.cs | 121 --------------- 11 files changed, 403 insertions(+), 443 deletions(-) rename src/Api/Pam/Controllers/{MemberLeasingController.cs => AccessRequestsController.cs} (52%) delete mode 100644 src/Api/Pam/Controllers/ApproverInboxController.cs delete mode 100644 src/Api/Pam/Controllers/LeaseGovernanceController.cs create mode 100644 src/Api/Pam/Controllers/LeasesController.cs create mode 100644 test/Api.Test/Pam/Controllers/AccessRequestsControllerTests.cs delete mode 100644 test/Api.Test/Pam/Controllers/ApproverInboxControllerTests.cs delete mode 100644 test/Api.Test/Pam/Controllers/LeaseGovernanceControllerTests.cs create mode 100644 test/Api.Test/Pam/Controllers/LeasesControllerTests.cs delete mode 100644 test/Api.Test/Pam/Controllers/MemberLeasingControllerTests.cs diff --git a/src/Api/Pam/Controllers/MemberLeasingController.cs b/src/Api/Pam/Controllers/AccessRequestsController.cs similarity index 52% rename from src/Api/Pam/Controllers/MemberLeasingController.cs rename to src/Api/Pam/Controllers/AccessRequestsController.cs index 8b78f5eff2f7..cda269a00a56 100644 --- a/src/Api/Pam/Controllers/MemberLeasingController.cs +++ b/src/Api/Pam/Controllers/AccessRequestsController.cs @@ -1,4 +1,4 @@ -using Bit.Api.Models.Response; +using Bit.Api.Models.Response; using Bit.Api.Pam.Models.Request; using Bit.Api.Pam.Models.Response; using Bit.Core; @@ -12,28 +12,55 @@ namespace Bit.Api.Pam.Controllers; /// -/// Caller-scoped surface: a user's own access requests and active leases, spanning every organization they -/// belong to, plus activation of their approved requests. Distinct from the approver-facing surface on -/// ; both expose actions under the top-level access-requests and -/// leases resources. +/// The access-requests resource: lease requests through their lifecycle, before any lease is minted. Covers +/// both the requester's own queue (their requests across every organization, plus activation and withdrawal) and the +/// approver's queue (requests on collections the caller can Manage, plus the decision). Activating an approved request +/// mints a lease, which from then on lives under the leases resource. /// +[Route("access-requests")] [Authorize("Application")] [RequireFeature(FeatureFlagKeys.Pam)] -public class MemberLeasingController( +public class AccessRequestsController( IUserService userService, + IListInboxRequestsQuery listInboxRequestsQuery, + IListInboxHistoryQuery listInboxHistoryQuery, + IDecideAccessRequestCommand decideAccessRequestCommand, IListMyAccessRequestsQuery listMyAccessRequestsQuery, - IListMyActiveAccessLeasesQuery listMyActiveAccessLeasesQuery, IActivateAccessRequestCommand activateAccessRequestCommand, - ICancelAccessRequestCommand cancelAccessRequestCommand, - IRequestLeaseExtensionCommand requestLeaseExtensionCommand) + ICancelAccessRequestCommand cancelAccessRequestCommand) : Controller { + /// + /// Returns the caller's pending approver queue: requests on collections the caller can Manage that are still + /// awaiting a decision. + /// + [HttpGet("inbox")] + public async Task> GetInbox() + { + var userId = userService.GetProperUserId(User)!.Value; + var requests = await listInboxRequestsQuery.GetPendingAsync(userId); + return new ListResponseModel( + requests.Select(r => new AccessRequestDetailsResponseModel(r))); + } + + /// + /// Returns the caller's resolved approver queue (decision history and lease outcomes) within the retention window. + /// + [HttpGet("history")] + public async Task> GetHistory() + { + var userId = userService.GetProperUserId(User)!.Value; + var history = await listInboxHistoryQuery.GetHistoryAsync(userId); + return new ListResponseModel( + history.Select(r => new AccessRequestDetailsResponseModel(r))); + } + /// /// Returns the caller's own access requests across all their organizations, regardless of status. The client /// re-sorts and splits into pending/recent. /// - [HttpGet("access-requests/mine")] - public async Task> GetMyRequests() + [HttpGet("mine")] + public async Task> GetMine() { var userId = userService.GetProperUserId(User)!.Value; var requests = await listMyAccessRequestsQuery.GetMineAsync(userId); @@ -42,15 +69,15 @@ public async Task> GetMyReq } /// - /// Returns the caller's currently-active leases across all their organizations. + /// Approves or denies a pending lease request. The caller must be able to Manage the request's collection and may + /// not decide their own request. /// - [HttpGet("leases/mine")] - public async Task> GetMyActiveLeases() + [HttpPost("{id:guid}/decision")] + public async Task Decide(Guid id, [FromBody] AccessDecisionRequestModel model) { var userId = userService.GetProperUserId(User)!.Value; - var leases = await listMyActiveAccessLeasesQuery.GetMineActiveAsync(userId); - return new ListResponseModel( - leases.Select(l => new AccessLeaseResponseModel(l))); + var result = await decideAccessRequestCommand.DecideAsync(userId, id, model.ToSubmission()); + return new AccessRequestDetailsResponseModel(result); } /// @@ -58,7 +85,7 @@ public async Task> GetMyActiveLeases /// request's approved window. Only the requester may activate, and only while the window is open. Repeat calls /// while the produced lease is live return that lease. /// - [HttpPost("access-requests/{id:guid}/activate")] + [HttpPost("{id:guid}/activate")] public async Task Activate(Guid id) { var userId = userService.GetProperUserId(User)!.Value; @@ -73,24 +100,11 @@ public async Task Activate(Guid id) /// pending or an unactivated approved request. A request that has produced a lease (revoke the lease /// instead) or is otherwise resolved can no longer be revoked. ///
- [HttpPost("access-requests/{id:guid}/revoke")] - public async Task RevokeRequest(Guid id) + [HttpPost("{id:guid}/revoke")] + public async Task Revoke(Guid id) { var userId = userService.GetProperUserId(User)!.Value; await cancelAccessRequestCommand.CancelAsync(userId, id); return NoContent(); } - - /// - /// Extends one of the caller's active leases by the requested duration. Extensions are always auto-approved, - /// subject to the governing rule allowing them and the per-lease maximum not being reached; the lease's end is - /// pushed out in place rather than minting a new lease. Only the lease's requester may extend it. - /// - [HttpPost("requests/extension")] - public async Task RequestExtension([FromBody] AccessLeaseExtensionRequestModel model) - { - var userId = userService.GetProperUserId(User)!.Value; - var details = await requestLeaseExtensionCommand.ExtendAsync(userId, model.ToSubmission()); - return new AccessRequestDetailsResponseModel(details); - } -} +} \ No newline at end of file diff --git a/src/Api/Pam/Controllers/ApproverInboxController.cs b/src/Api/Pam/Controllers/ApproverInboxController.cs deleted file mode 100644 index 35714d7e5104..000000000000 --- a/src/Api/Pam/Controllers/ApproverInboxController.cs +++ /dev/null @@ -1,72 +0,0 @@ -using Bit.Api.Models.Response; -using Bit.Api.Pam.Models.Request; -using Bit.Api.Pam.Models.Response; -using Bit.Core; -using Bit.Core.Pam.OrganizationFeatures.Commands.Interfaces; -using Bit.Core.Pam.OrganizationFeatures.Queries.Interfaces; -using Bit.Core.Services; -using Bit.Core.Utilities; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; - -namespace Bit.Api.Pam.Controllers; - -[Authorize("Application")] -[RequireFeature(FeatureFlagKeys.Pam)] -public class ApproverInboxController( - IUserService userService, - IListInboxRequestsQuery listInboxRequestsQuery, - IListInboxHistoryQuery listInboxHistoryQuery, - IDecideAccessRequestCommand decideAccessRequestCommand, - IRevokeAccessLeaseCommand revokeAccessLeaseCommand) - : Controller -{ - /// - /// Returns the caller's pending approver queue: requests on collections the caller can Manage that are still - /// awaiting a decision. - /// - [HttpGet("access-requests/inbox")] - public async Task> GetRequests() - { - var userId = userService.GetProperUserId(User)!.Value; - var requests = await listInboxRequestsQuery.GetPendingAsync(userId); - return new ListResponseModel( - requests.Select(r => new AccessRequestDetailsResponseModel(r))); - } - - /// - /// Returns the caller's resolved approver queue (decision history and lease outcomes) within the retention window. - /// - [HttpGet("access-requests/history")] - public async Task> GetHistory() - { - var userId = userService.GetProperUserId(User)!.Value; - var history = await listInboxHistoryQuery.GetHistoryAsync(userId); - return new ListResponseModel( - history.Select(r => new AccessRequestDetailsResponseModel(r))); - } - - /// - /// Approves or denies a pending lease request. The caller must be able to Manage the request's collection and may - /// not decide their own request. - /// - [HttpPost("access-requests/{id:guid}/decision")] - public async Task Decide(Guid id, [FromBody] AccessDecisionRequestModel model) - { - var userId = userService.GetProperUserId(User)!.Value; - var result = await decideAccessRequestCommand.DecideAsync(userId, id, model.ToSubmission()); - return new AccessRequestDetailsResponseModel(result); - } - - /// - /// Ends an active lease early. The caller must be either the lease's holder (ending their own access) or able to - /// Manage the lease's collection. - /// - [HttpPost("leases/{id:guid}/revoke")] - public async Task Revoke(Guid id, [FromBody] AccessLeaseRevokeRequestModel model) - { - var userId = userService.GetProperUserId(User)!.Value; - await revokeAccessLeaseCommand.RevokeAsync(userId, id, model.Reason); - return NoContent(); - } -} diff --git a/src/Api/Pam/Controllers/LeaseGovernanceController.cs b/src/Api/Pam/Controllers/LeaseGovernanceController.cs deleted file mode 100644 index d025e7d4d06e..000000000000 --- a/src/Api/Pam/Controllers/LeaseGovernanceController.cs +++ /dev/null @@ -1,49 +0,0 @@ -using Bit.Api.Models.Response; -using Bit.Api.Pam.Models.Response; -using Bit.Core; -using Bit.Core.Pam.OrganizationFeatures.Queries.Interfaces; -using Bit.Core.Services; -using Bit.Core.Utilities; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; - -namespace Bit.Api.Pam.Controllers; - -/// -/// Governance surface over leases: read models of all active and recently-ended leases the caller can Manage, -/// powering the governance dashboard. Unlike the member surface on (the caller's -/// own leases), these span every member. Scope is the caller's manageable collections — the same resolution as the -/// approver inbox — so an org admin or collection manager sees all access in their scope. -/// -[Authorize("Application")] -[RequireFeature(FeatureFlagKeys.Pam)] -public class LeaseGovernanceController( - IUserService userService, - IListActiveLeasesQuery listActiveLeasesQuery, - IListLeaseHistoryQuery listLeaseHistoryQuery) - : Controller -{ - /// - /// Returns every currently-active lease on collections the caller can Manage. - /// - [HttpGet("leases/active")] - public async Task> GetActive() - { - var userId = userService.GetProperUserId(User)!.Value; - var leases = await listActiveLeasesQuery.GetActiveAsync(userId); - return new ListResponseModel( - leases.Select(l => new AccessLeaseResponseModel(l))); - } - - /// - /// Returns the ended leases (expired or revoked) on collections the caller can Manage, within the history window. - /// - [HttpGet("leases/history")] - public async Task> GetHistory() - { - var userId = userService.GetProperUserId(User)!.Value; - var leases = await listLeaseHistoryQuery.GetHistoryAsync(userId); - return new ListResponseModel( - leases.Select(l => new AccessLeaseResponseModel(l))); - } -} diff --git a/src/Api/Pam/Controllers/LeasesController.cs b/src/Api/Pam/Controllers/LeasesController.cs new file mode 100644 index 000000000000..44b6914530df --- /dev/null +++ b/src/Api/Pam/Controllers/LeasesController.cs @@ -0,0 +1,93 @@ +using Bit.Api.Models.Response; +using Bit.Api.Pam.Models.Request; +using Bit.Api.Pam.Models.Response; +using Bit.Core; +using Bit.Core.Pam.OrganizationFeatures.Commands.Interfaces; +using Bit.Core.Pam.OrganizationFeatures.Queries.Interfaces; +using Bit.Core.Services; +using Bit.Core.Utilities; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Bit.Api.Pam.Controllers; + +/// +/// The leases resource: active and ended leases — the access a lease authorizes once an approved request is +/// activated. Covers the caller's own leases (across every organization), the governance surface over every lease on +/// collections the caller can Manage, and the actions on a single lease: ending it early (revoke) and extending it. +/// The governance scope mirrors the approver inbox — the caller's manageable collections — so an org admin or +/// collection manager sees all access in their scope. +/// +[Route("leases")] +[Authorize("Application")] +[RequireFeature(FeatureFlagKeys.Pam)] +public class LeasesController( + IUserService userService, + IListActiveLeasesQuery listActiveLeasesQuery, + IListLeaseHistoryQuery listLeaseHistoryQuery, + IListMyActiveAccessLeasesQuery listMyActiveAccessLeasesQuery, + IRevokeAccessLeaseCommand revokeAccessLeaseCommand, + IRequestLeaseExtensionCommand requestLeaseExtensionCommand) + : Controller +{ + /// + /// Returns every currently-active lease on collections the caller can Manage. + /// + [HttpGet("active")] + public async Task> GetActive() + { + var userId = userService.GetProperUserId(User)!.Value; + var leases = await listActiveLeasesQuery.GetActiveAsync(userId); + return new ListResponseModel( + leases.Select(l => new AccessLeaseResponseModel(l))); + } + + /// + /// Returns the ended leases (expired or revoked) on collections the caller can Manage, within the history window. + /// + [HttpGet("history")] + public async Task> GetHistory() + { + var userId = userService.GetProperUserId(User)!.Value; + var leases = await listLeaseHistoryQuery.GetHistoryAsync(userId); + return new ListResponseModel( + leases.Select(l => new AccessLeaseResponseModel(l))); + } + + /// + /// Returns the caller's currently-active leases across all their organizations. + /// + [HttpGet("mine")] + public async Task> GetMine() + { + var userId = userService.GetProperUserId(User)!.Value; + var leases = await listMyActiveAccessLeasesQuery.GetMineActiveAsync(userId); + return new ListResponseModel( + leases.Select(l => new AccessLeaseResponseModel(l))); + } + + /// + /// Ends an active lease early. The caller must be either the lease's holder (ending their own access) or able to + /// Manage the lease's collection. + /// + [HttpPost("{id:guid}/revoke")] + public async Task Revoke(Guid id, [FromBody] AccessLeaseRevokeRequestModel model) + { + var userId = userService.GetProperUserId(User)!.Value; + await revokeAccessLeaseCommand.RevokeAsync(userId, id, model.Reason); + return NoContent(); + } + + /// + /// Extends one of the caller's active leases by the requested duration. Extensions are always auto-approved, + /// subject to the governing rule allowing them and the per-lease maximum not being reached; the lease's end is + /// pushed out in place rather than minting a new lease. Only the lease's requester may extend it. + /// + [HttpPost("{id:guid}/extend")] + public async Task Extend(Guid id, [FromBody] AccessLeaseExtensionRequestModel model) + { + var userId = userService.GetProperUserId(User)!.Value; + var details = await requestLeaseExtensionCommand.ExtendAsync(userId, model.ToSubmission(id)); + return new AccessRequestDetailsResponseModel(details); + } +} diff --git a/src/Api/Pam/Models/Request/AccessLeaseExtensionRequestModel.cs b/src/Api/Pam/Models/Request/AccessLeaseExtensionRequestModel.cs index d5643da3574a..a26e063b5428 100644 --- a/src/Api/Pam/Models/Request/AccessLeaseExtensionRequestModel.cs +++ b/src/Api/Pam/Models/Request/AccessLeaseExtensionRequestModel.cs @@ -3,21 +3,19 @@ namespace Bit.Api.Pam.Models.Request; /// -/// A request to extend an active lease, identified by . The lease's end is pushed out by +/// A request to extend an active lease, identified by the route's lease id. The lease's end is pushed out by /// ; a justifying is required. Extensions are always auto-approved, /// subject to the governing rule allowing extensions and the per-lease maximum not being reached. /// public class AccessLeaseExtensionRequestModel { - public Guid LeaseId { get; set; } - public int DurationSeconds { get; set; } public string? Reason { get; set; } - public AccessLeaseExtensionSubmission ToSubmission() => new() + public AccessLeaseExtensionSubmission ToSubmission(Guid leaseId) => new() { - LeaseId = LeaseId, + LeaseId = leaseId, DurationSeconds = DurationSeconds, Reason = Reason, }; diff --git a/src/Api/Pam/Models/Response/AccessRequestDetailsResponseModel.cs b/src/Api/Pam/Models/Response/AccessRequestDetailsResponseModel.cs index fbd4bd677b14..eb709dbdd0c0 100644 --- a/src/Api/Pam/Models/Response/AccessRequestDetailsResponseModel.cs +++ b/src/Api/Pam/Models/Response/AccessRequestDetailsResponseModel.cs @@ -50,7 +50,7 @@ public AccessRequestDetailsResponseModel(AccessRequestDetails details) public Guid OrganizationId { get; } public Guid RequesterId { get; } - /// pending | approved | activated | denied | cancelled | expired. + /// pending | approved | activated | denied | canceled | expired. public string Status { get; } public DateTime RequestedNotBefore { get; } diff --git a/test/Api.Test/Pam/Controllers/AccessRequestsControllerTests.cs b/test/Api.Test/Pam/Controllers/AccessRequestsControllerTests.cs new file mode 100644 index 000000000000..f7bbbc9dee66 --- /dev/null +++ b/test/Api.Test/Pam/Controllers/AccessRequestsControllerTests.cs @@ -0,0 +1,138 @@ +using System.Security.Claims; +using Bit.Api.Pam.Controllers; +using Bit.Api.Pam.Models.Request; +using Bit.Core.Exceptions; +using Bit.Core.Pam.Entities; +using Bit.Core.Pam.Enums; +using Bit.Core.Pam.Models; +using Bit.Core.Pam.OrganizationFeatures.Commands.Interfaces; +using Bit.Core.Pam.OrganizationFeatures.Queries.Interfaces; +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.AspNetCore.Mvc; +using NSubstitute; +using Xunit; + +namespace Bit.Api.Test.Pam.Controllers; + +[ControllerCustomize(typeof(AccessRequestsController))] +[SutProviderCustomize] +public class AccessRequestsControllerTests +{ + [Theory, BitAutoData] + public async Task GetInbox_ReturnsMappedPendingRows( + Guid userId, AccessRequestDetails row, SutProvider sutProvider) + { + SetupUser(sutProvider, userId); + row.Status = AccessRequestStatus.Pending; + sutProvider.GetDependency().GetPendingAsync(userId).Returns([row]); + + var result = await sutProvider.Sut.GetInbox(); + + Assert.Single(result.Data); + Assert.Equal(row.Id, result.Data.First().Id); + } + + [Theory, BitAutoData] + public async Task GetHistory_ReturnsMappedHistoryRows( + Guid userId, AccessRequestDetails row, SutProvider sutProvider) + { + SetupUser(sutProvider, userId); + row.Status = AccessRequestStatus.Approved; + sutProvider.GetDependency().GetHistoryAsync(userId).Returns([row]); + + var result = await sutProvider.Sut.GetHistory(); + + Assert.Single(result.Data); + } + + [Theory, BitAutoData] + public async Task GetMine_ReturnsMappedRows( + Guid userId, AccessRequestDetails row, SutProvider sutProvider) + { + SetupUser(sutProvider, userId); + row.Status = AccessRequestStatus.Pending; + sutProvider.GetDependency().GetMineAsync(userId).Returns([row]); + + var result = (await sutProvider.Sut.GetMine()).Data.ToList(); + + Assert.Single(result); + Assert.Equal(row.Id, result[0].Id); + Assert.Equal(AccessRequestStatusNames.Pending, result[0].Status); + } + + [Theory, BitAutoData] + public async Task GetMine_NoRows_ReturnsEmpty( + Guid userId, SutProvider sutProvider) + { + SetupUser(sutProvider, userId); + sutProvider.GetDependency().GetMineAsync(userId).Returns([]); + + var result = await sutProvider.Sut.GetMine(); + + Assert.Empty(result.Data); + } + + [Theory, BitAutoData] + public async Task Decide_ReturnsUpdatedRow( + Guid userId, Guid requestId, AccessRequestDetails updated, SutProvider sutProvider) + { + SetupUser(sutProvider, userId); + updated.Status = AccessRequestStatus.Approved; + updated.ProducedLeaseId = null; + sutProvider.GetDependency() + .DecideAsync(userId, requestId, Arg.Any()) + .Returns(updated); + + var result = await sutProvider.Sut.Decide(requestId, new AccessDecisionRequestModel { Verdict = "approve" }); + + Assert.Equal(updated.Id, result.Id); + Assert.Equal(AccessRequestStatusNames.Approved, result.Status); + } + + [Theory, BitAutoData] + public async Task Decide_InvalidDecision_ThrowsBadRequest( + Guid userId, Guid requestId, SutProvider sutProvider) + { + SetupUser(sutProvider, userId); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.Decide(requestId, new AccessDecisionRequestModel { Verdict = "maybe" })); + } + + [Theory, BitAutoData] + public async Task Activate_ReturnsMintedLease( + Guid userId, Guid requestId, AccessLease lease, SutProvider sutProvider) + { + SetupUser(sutProvider, userId); + lease.Status = AccessLeaseStatus.Active; + sutProvider.GetDependency() + .ActivateAsync(userId, requestId) + .Returns(lease); + + var result = await sutProvider.Sut.Activate(requestId); + + Assert.Equal(lease.Id, result.Id); + Assert.Equal(AccessLeaseStatusNames.Active, result.Status); + } + + [Theory, BitAutoData] + public async Task Revoke_RevokesCallersRequest_ReturnsNoContent( + Guid userId, Guid requestId, SutProvider sutProvider) + { + SetupUser(sutProvider, userId); + + var result = await sutProvider.Sut.Revoke(requestId); + + Assert.IsType(result); + await sutProvider.GetDependency().Received(1).CancelAsync(userId, requestId); + } + + private static void SetupUser(SutProvider sutProvider, Guid userId) + { + sutProvider.GetDependency() + .GetProperUserId(Arg.Any()) + .Returns(userId); + } +} diff --git a/test/Api.Test/Pam/Controllers/ApproverInboxControllerTests.cs b/test/Api.Test/Pam/Controllers/ApproverInboxControllerTests.cs deleted file mode 100644 index 3b5eb4e8be8d..000000000000 --- a/test/Api.Test/Pam/Controllers/ApproverInboxControllerTests.cs +++ /dev/null @@ -1,94 +0,0 @@ -using System.Security.Claims; -using Bit.Api.Pam.Controllers; -using Bit.Api.Pam.Models.Request; -using Bit.Core.Exceptions; -using Bit.Core.Pam.Enums; -using Bit.Core.Pam.Models; -using Bit.Core.Pam.OrganizationFeatures.Commands.Interfaces; -using Bit.Core.Pam.OrganizationFeatures.Queries.Interfaces; -using Bit.Core.Services; -using Bit.Test.Common.AutoFixture; -using Bit.Test.Common.AutoFixture.Attributes; -using Microsoft.AspNetCore.Mvc; -using NSubstitute; -using Xunit; - -namespace Bit.Api.Test.Pam.Controllers; - -[ControllerCustomize(typeof(ApproverInboxController))] -[SutProviderCustomize] -public class ApproverInboxControllerTests -{ - [Theory, BitAutoData] - public async Task GetRequests_ReturnsMappedPendingRows( - Guid userId, AccessRequestDetails row, SutProvider sutProvider) - { - SetupUser(sutProvider, userId); - row.Status = AccessRequestStatus.Pending; - sutProvider.GetDependency().GetPendingAsync(userId).Returns([row]); - - var result = await sutProvider.Sut.GetRequests(); - - Assert.Single(result.Data); - Assert.Equal(row.Id, result.Data.First().Id); - } - - [Theory, BitAutoData] - public async Task GetHistory_ReturnsMappedHistoryRows( - Guid userId, AccessRequestDetails row, SutProvider sutProvider) - { - SetupUser(sutProvider, userId); - row.Status = AccessRequestStatus.Approved; - sutProvider.GetDependency().GetHistoryAsync(userId).Returns([row]); - - var result = await sutProvider.Sut.GetHistory(); - - Assert.Single(result.Data); - } - - [Theory, BitAutoData] - public async Task Decide_ReturnsUpdatedRow( - Guid userId, Guid requestId, AccessRequestDetails updated, SutProvider sutProvider) - { - SetupUser(sutProvider, userId); - updated.Status = AccessRequestStatus.Approved; - updated.ProducedLeaseId = null; - sutProvider.GetDependency() - .DecideAsync(userId, requestId, Arg.Any()) - .Returns(updated); - - var result = await sutProvider.Sut.Decide(requestId, new AccessDecisionRequestModel { Verdict = "approve" }); - - Assert.Equal(updated.Id, result.Id); - Assert.Equal(AccessRequestStatusNames.Approved, result.Status); - } - - [Theory, BitAutoData] - public async Task Decide_InvalidDecision_ThrowsBadRequest( - Guid userId, Guid requestId, SutProvider sutProvider) - { - SetupUser(sutProvider, userId); - - await Assert.ThrowsAsync( - () => sutProvider.Sut.Decide(requestId, new AccessDecisionRequestModel { Verdict = "maybe" })); - } - - [Theory, BitAutoData] - public async Task Revoke_ReturnsNoContent( - Guid userId, Guid leaseId, SutProvider sutProvider) - { - SetupUser(sutProvider, userId); - - var result = await sutProvider.Sut.Revoke(leaseId, new AccessLeaseRevokeRequestModel { Reason = "policy" }); - - Assert.IsType(result); - await sutProvider.GetDependency().Received(1).RevokeAsync(userId, leaseId, "policy"); - } - - private static void SetupUser(SutProvider sutProvider, Guid userId) - { - sutProvider.GetDependency() - .GetProperUserId(Arg.Any()) - .Returns(userId); - } -} diff --git a/test/Api.Test/Pam/Controllers/LeaseGovernanceControllerTests.cs b/test/Api.Test/Pam/Controllers/LeaseGovernanceControllerTests.cs deleted file mode 100644 index c1c66b5d1ea4..000000000000 --- a/test/Api.Test/Pam/Controllers/LeaseGovernanceControllerTests.cs +++ /dev/null @@ -1,67 +0,0 @@ -using System.Security.Claims; -using Bit.Api.Pam.Controllers; -using Bit.Core.Pam.Entities; -using Bit.Core.Pam.Enums; -using Bit.Core.Pam.Models; -using Bit.Core.Pam.OrganizationFeatures.Queries.Interfaces; -using Bit.Core.Services; -using Bit.Test.Common.AutoFixture; -using Bit.Test.Common.AutoFixture.Attributes; -using NSubstitute; -using Xunit; - -namespace Bit.Api.Test.Pam.Controllers; - -[ControllerCustomize(typeof(LeaseGovernanceController))] -[SutProviderCustomize] -public class LeaseGovernanceControllerTests -{ - [Theory, BitAutoData] - public async Task GetActive_ReturnsMappedLeases( - Guid userId, AccessLease lease, SutProvider sutProvider) - { - SetupUser(sutProvider, userId); - lease.Status = AccessLeaseStatus.Active; - sutProvider.GetDependency().GetActiveAsync(userId).Returns([lease]); - - var result = (await sutProvider.Sut.GetActive()).Data.ToList(); - - Assert.Single(result); - Assert.Equal(lease.Id, result[0].Id); - Assert.Equal(AccessLeaseStatusNames.Active, result[0].Status); - } - - [Theory, BitAutoData] - public async Task GetActive_NoLeases_ReturnsEmpty( - Guid userId, SutProvider sutProvider) - { - SetupUser(sutProvider, userId); - sutProvider.GetDependency().GetActiveAsync(userId).Returns([]); - - var result = await sutProvider.Sut.GetActive(); - - Assert.Empty(result.Data); - } - - [Theory, BitAutoData] - public async Task GetHistory_ReturnsMappedLeases( - Guid userId, AccessLease lease, SutProvider sutProvider) - { - SetupUser(sutProvider, userId); - lease.Status = AccessLeaseStatus.Revoked; - sutProvider.GetDependency().GetHistoryAsync(userId).Returns([lease]); - - var result = (await sutProvider.Sut.GetHistory()).Data.ToList(); - - Assert.Single(result); - Assert.Equal(lease.Id, result[0].Id); - Assert.Equal(AccessLeaseStatusNames.Revoked, result[0].Status); - } - - private static void SetupUser(SutProvider sutProvider, Guid userId) - { - sutProvider.GetDependency() - .GetProperUserId(Arg.Any()) - .Returns(userId); - } -} diff --git a/test/Api.Test/Pam/Controllers/LeasesControllerTests.cs b/test/Api.Test/Pam/Controllers/LeasesControllerTests.cs new file mode 100644 index 000000000000..916852ed3987 --- /dev/null +++ b/test/Api.Test/Pam/Controllers/LeasesControllerTests.cs @@ -0,0 +1,120 @@ +using System.Security.Claims; +using Bit.Api.Pam.Controllers; +using Bit.Api.Pam.Models.Request; +using Bit.Core.Pam.Entities; +using Bit.Core.Pam.Enums; +using Bit.Core.Pam.Models; +using Bit.Core.Pam.OrganizationFeatures.Commands.Interfaces; +using Bit.Core.Pam.OrganizationFeatures.Queries.Interfaces; +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.AspNetCore.Mvc; +using NSubstitute; +using Xunit; + +namespace Bit.Api.Test.Pam.Controllers; + +[ControllerCustomize(typeof(LeasesController))] +[SutProviderCustomize] +public class LeasesControllerTests +{ + [Theory, BitAutoData] + public async Task GetActive_ReturnsMappedLeases( + Guid userId, AccessLease lease, SutProvider sutProvider) + { + SetupUser(sutProvider, userId); + lease.Status = AccessLeaseStatus.Active; + sutProvider.GetDependency().GetActiveAsync(userId).Returns([lease]); + + var result = (await sutProvider.Sut.GetActive()).Data.ToList(); + + Assert.Single(result); + Assert.Equal(lease.Id, result[0].Id); + Assert.Equal(AccessLeaseStatusNames.Active, result[0].Status); + } + + [Theory, BitAutoData] + public async Task GetActive_NoLeases_ReturnsEmpty( + Guid userId, SutProvider sutProvider) + { + SetupUser(sutProvider, userId); + sutProvider.GetDependency().GetActiveAsync(userId).Returns([]); + + var result = await sutProvider.Sut.GetActive(); + + Assert.Empty(result.Data); + } + + [Theory, BitAutoData] + public async Task GetHistory_ReturnsMappedLeases( + Guid userId, AccessLease lease, SutProvider sutProvider) + { + SetupUser(sutProvider, userId); + lease.Status = AccessLeaseStatus.Revoked; + sutProvider.GetDependency().GetHistoryAsync(userId).Returns([lease]); + + var result = (await sutProvider.Sut.GetHistory()).Data.ToList(); + + Assert.Single(result); + Assert.Equal(lease.Id, result[0].Id); + Assert.Equal(AccessLeaseStatusNames.Revoked, result[0].Status); + } + + [Theory, BitAutoData] + public async Task GetMine_ReturnsMappedLeases( + Guid userId, AccessLease lease, SutProvider sutProvider) + { + SetupUser(sutProvider, userId); + lease.Status = AccessLeaseStatus.Active; + sutProvider.GetDependency().GetMineActiveAsync(userId).Returns([lease]); + + var result = (await sutProvider.Sut.GetMine()).Data.ToList(); + + Assert.Single(result); + Assert.Equal(lease.Id, result[0].Id); + Assert.Equal(AccessLeaseStatusNames.Active, result[0].Status); + } + + [Theory, BitAutoData] + public async Task Revoke_ReturnsNoContent( + Guid userId, Guid leaseId, SutProvider sutProvider) + { + SetupUser(sutProvider, userId); + + var result = await sutProvider.Sut.Revoke(leaseId, new AccessLeaseRevokeRequestModel { Reason = "policy" }); + + Assert.IsType(result); + await sutProvider.GetDependency().Received(1).RevokeAsync(userId, leaseId, "policy"); + } + + [Theory, BitAutoData] + public async Task Extend_ForwardsRouteLeaseId_ReturnsApprovedExtensionDetails( + Guid userId, Guid leaseId, AccessLeaseExtensionRequestModel model, AccessRequestDetails details, + SutProvider sutProvider) + { + SetupUser(sutProvider, userId); + details.Status = AccessRequestStatus.Approved; + details.ProducedLeaseId = null; // an extension produces no lease of its own, so the status stays "approved" + sutProvider.GetDependency() + .ExtendAsync(userId, Arg.Any()) + .Returns(details); + + var result = await sutProvider.Sut.Extend(leaseId, model); + + Assert.Equal(details.Id, result.Id); + Assert.Equal(AccessRequestStatusNames.Approved, result.Status); + Assert.Equal(details.ExtensionOfLeaseId, result.ExtensionOfLeaseId); + await sutProvider.GetDependency().Received(1).ExtendAsync( + userId, + Arg.Is(s => + s.LeaseId == leaseId && s.DurationSeconds == model.DurationSeconds && s.Reason == model.Reason)); + } + + private static void SetupUser(SutProvider sutProvider, Guid userId) + { + sutProvider.GetDependency() + .GetProperUserId(Arg.Any()) + .Returns(userId); + } +} diff --git a/test/Api.Test/Pam/Controllers/MemberLeasingControllerTests.cs b/test/Api.Test/Pam/Controllers/MemberLeasingControllerTests.cs deleted file mode 100644 index 3d571b7ec220..000000000000 --- a/test/Api.Test/Pam/Controllers/MemberLeasingControllerTests.cs +++ /dev/null @@ -1,121 +0,0 @@ -using System.Security.Claims; -using Bit.Api.Pam.Controllers; -using Bit.Api.Pam.Models.Request; -using Bit.Core.Pam.Entities; -using Bit.Core.Pam.Enums; -using Bit.Core.Pam.Models; -using Bit.Core.Pam.OrganizationFeatures.Commands.Interfaces; -using Bit.Core.Pam.OrganizationFeatures.Queries.Interfaces; -using Bit.Core.Services; -using Bit.Test.Common.AutoFixture; -using Bit.Test.Common.AutoFixture.Attributes; -using Microsoft.AspNetCore.Mvc; -using NSubstitute; -using Xunit; - -namespace Bit.Api.Test.Pam.Controllers; - -[ControllerCustomize(typeof(MemberLeasingController))] -[SutProviderCustomize] -public class MemberLeasingControllerTests -{ - [Theory, BitAutoData] - public async Task GetMyRequests_ReturnsMappedRows( - Guid userId, AccessRequestDetails row, SutProvider sutProvider) - { - SetupUser(sutProvider, userId); - row.Status = AccessRequestStatus.Pending; - sutProvider.GetDependency().GetMineAsync(userId).Returns([row]); - - var result = (await sutProvider.Sut.GetMyRequests()).Data.ToList(); - - Assert.Single(result); - Assert.Equal(row.Id, result[0].Id); - Assert.Equal(AccessRequestStatusNames.Pending, result[0].Status); - } - - [Theory, BitAutoData] - public async Task GetMyActiveLeases_ReturnsMappedLeases( - Guid userId, AccessLease lease, SutProvider sutProvider) - { - SetupUser(sutProvider, userId); - lease.Status = AccessLeaseStatus.Active; - sutProvider.GetDependency().GetMineActiveAsync(userId).Returns([lease]); - - var result = (await sutProvider.Sut.GetMyActiveLeases()).Data.ToList(); - - Assert.Single(result); - Assert.Equal(lease.Id, result[0].Id); - Assert.Equal(AccessLeaseStatusNames.Active, result[0].Status); - } - - [Theory, BitAutoData] - public async Task GetMyRequests_NoRows_ReturnsEmpty( - Guid userId, SutProvider sutProvider) - { - SetupUser(sutProvider, userId); - sutProvider.GetDependency().GetMineAsync(userId).Returns([]); - - var result = await sutProvider.Sut.GetMyRequests(); - - Assert.Empty(result.Data); - } - - [Theory, BitAutoData] - public async Task Activate_ReturnsMintedLease( - Guid userId, Guid requestId, AccessLease lease, SutProvider sutProvider) - { - SetupUser(sutProvider, userId); - lease.Status = AccessLeaseStatus.Active; - sutProvider.GetDependency() - .ActivateAsync(userId, requestId) - .Returns(lease); - - var result = await sutProvider.Sut.Activate(requestId); - - Assert.Equal(lease.Id, result.Id); - Assert.Equal(AccessLeaseStatusNames.Active, result.Status); - } - - [Theory, BitAutoData] - public async Task RevokeRequest_RevokesCallersRequest_ReturnsNoContent( - Guid userId, Guid requestId, SutProvider sutProvider) - { - SetupUser(sutProvider, userId); - - var result = await sutProvider.Sut.RevokeRequest(requestId); - - Assert.IsType(result); - await sutProvider.GetDependency().Received(1).CancelAsync(userId, requestId); - } - - [Theory, BitAutoData] - public async Task RequestExtension_ForwardsSubmission_ReturnsApprovedExtensionDetails( - Guid userId, AccessLeaseExtensionRequestModel model, AccessRequestDetails details, - SutProvider sutProvider) - { - SetupUser(sutProvider, userId); - details.Status = AccessRequestStatus.Approved; - details.ProducedLeaseId = null; // an extension produces no lease of its own, so the status stays "approved" - sutProvider.GetDependency() - .ExtendAsync(userId, Arg.Any()) - .Returns(details); - - var result = await sutProvider.Sut.RequestExtension(model); - - Assert.Equal(details.Id, result.Id); - Assert.Equal(AccessRequestStatusNames.Approved, result.Status); - Assert.Equal(details.ExtensionOfLeaseId, result.ExtensionOfLeaseId); - await sutProvider.GetDependency().Received(1).ExtendAsync( - userId, - Arg.Is(s => - s.LeaseId == model.LeaseId && s.DurationSeconds == model.DurationSeconds && s.Reason == model.Reason)); - } - - private static void SetupUser(SutProvider sutProvider, Guid userId) - { - sutProvider.GetDependency() - .GetProperUserId(Arg.Any()) - .Returns(userId); - } -} From 40ba1a57f0d9d59226abeff44804a21b31ecb928 Mon Sep 17 00:00:00 2001 From: Hinton Date: Tue, 16 Jun 2026 13:41:46 +0200 Subject: [PATCH 39/54] PAM: add UsePam organization capability Add the UsePam organization ability across the stack, modeled on UseInviteLinks/UseMyItems: - Entity + OrganizationAbility + profile/provider detail models - Licensing: claim emission and claims-based VerifyData using the conditional HasClaim check (PM-33980) so pre-existing license files still validate - API response models and an Admin portal toggle - MSSQL schema + migration; EF migrations for Postgres/MySQL/SQLite Defaults off for all organizations. Plan/pricing wiring is deliberately deferred until a PAM plan tier exists, so the Admin toggle is currently the only way to enable it. --- .../Controllers/OrganizationsController.cs | 1 + .../Models/OrganizationEditModel.cs | 4 + .../Views/Shared/_OrganizationForm.cshtml | 74 +- .../BaseProfileOrganizationResponseModel.cs | 2 + .../OrganizationResponseModel.cs | 2 + .../AdminConsole/Entities/Organization.cs | 6 + .../Data/IProfileOrganizationDetails.cs | 1 + .../OrganizationUserOrganizationDetails.cs | 1 + .../SelfHostedOrganizationDetails.cs | 1 + .../ProviderUserOrganizationDetails.cs | 1 + .../OrganizationAbility.cs | 2 + src/Core/Billing/Licenses/LicenseConstants.cs | 1 + .../OrganizationLicenseClaimsFactory.cs | 1 + .../UpdateOrganizationLicenseCommand.cs | 1 + .../Models/OrganizationLicense.cs | 9 +- .../Repositories/OrganizationRepository.cs | 3 +- ...izationUserOrganizationDetailsViewQuery.cs | 1 + ...roviderUserOrganizationDetailsViewQuery.cs | 3 +- .../Stored Procedures/Organization_Create.sql | 9 +- .../Organization_ReadAbilities.sql | 3 +- .../Organization_ReadManyByIds.sql | 3 +- .../Stored Procedures/Organization_Update.sql | 6 +- src/Sql/dbo/Tables/Organization.sql | 1 + src/Sql/dbo/Views/OrganizationAbilityView.sql | 3 +- ...rganizationUserOrganizationDetailsView.sql | 1 + src/Sql/dbo/Views/OrganizationView.sql | 3 +- ...derUserProviderOrganizationDetailsView.sql | 3 +- .../UpdateOrganizationLicenseCommandTests.cs | 6 +- .../AdminConsole/OrganizationTestHelpers.cs | 1 + .../OrganizationUserRepositoryTests.cs | 1 + .../2026-06-16_00_AddUsePamToOrganization.sql | 749 ++++ ...ddAccessRuleLeaseConfiguration.Designer.cs | 3839 ++++++++++++++++ ...6102519_AddAccessRuleLeaseConfiguration.cs | 80 + ...102606_AddUsePamToOrganization.Designer.cs | 3842 ++++++++++++++++ .../20260616102606_AddUsePamToOrganization.cs | 28 + .../DatabaseContextModelSnapshot.cs | 73 +- ...ddAccessRuleLeaseConfiguration.Designer.cs | 3845 ++++++++++++++++ ...6102515_AddAccessRuleLeaseConfiguration.cs | 80 + ...102601_AddUsePamToOrganization.Designer.cs | 3848 +++++++++++++++++ .../20260616102601_AddUsePamToOrganization.cs | 28 + .../DatabaseContextModelSnapshot.cs | 73 +- ...ddAccessRuleLeaseConfiguration.Designer.cs | 3828 ++++++++++++++++ ...6102508_AddAccessRuleLeaseConfiguration.cs | 80 + ...102557_AddUsePamToOrganization.Designer.cs | 3831 ++++++++++++++++ .../20260616102557_AddUsePamToOrganization.cs | 28 + .../DatabaseContextModelSnapshot.cs | 65 +- 46 files changed, 24351 insertions(+), 120 deletions(-) create mode 100644 util/Migrator/DbScripts/2026-06-16_00_AddUsePamToOrganization.sql create mode 100644 util/MySqlMigrations/Migrations/20260616102519_AddAccessRuleLeaseConfiguration.Designer.cs create mode 100644 util/MySqlMigrations/Migrations/20260616102519_AddAccessRuleLeaseConfiguration.cs create mode 100644 util/MySqlMigrations/Migrations/20260616102606_AddUsePamToOrganization.Designer.cs create mode 100644 util/MySqlMigrations/Migrations/20260616102606_AddUsePamToOrganization.cs create mode 100644 util/PostgresMigrations/Migrations/20260616102515_AddAccessRuleLeaseConfiguration.Designer.cs create mode 100644 util/PostgresMigrations/Migrations/20260616102515_AddAccessRuleLeaseConfiguration.cs create mode 100644 util/PostgresMigrations/Migrations/20260616102601_AddUsePamToOrganization.Designer.cs create mode 100644 util/PostgresMigrations/Migrations/20260616102601_AddUsePamToOrganization.cs create mode 100644 util/SqliteMigrations/Migrations/20260616102508_AddAccessRuleLeaseConfiguration.Designer.cs create mode 100644 util/SqliteMigrations/Migrations/20260616102508_AddAccessRuleLeaseConfiguration.cs create mode 100644 util/SqliteMigrations/Migrations/20260616102557_AddUsePamToOrganization.Designer.cs create mode 100644 util/SqliteMigrations/Migrations/20260616102557_AddUsePamToOrganization.cs diff --git a/src/Admin/AdminConsole/Controllers/OrganizationsController.cs b/src/Admin/AdminConsole/Controllers/OrganizationsController.cs index c6c301da03cc..b5e6139c658e 100644 --- a/src/Admin/AdminConsole/Controllers/OrganizationsController.cs +++ b/src/Admin/AdminConsole/Controllers/OrganizationsController.cs @@ -754,6 +754,7 @@ private void UpdateOrganization(Organization organization, OrganizationEditModel organization.UsePhishingBlocker = model.UsePhishingBlocker; organization.UseMyItems = model.UseMyItems; organization.UseInviteLinks = model.UseInviteLinks; + organization.UsePam = model.UsePam; //secrets organization.SmSeats = model.SmSeats; diff --git a/src/Admin/AdminConsole/Models/OrganizationEditModel.cs b/src/Admin/AdminConsole/Models/OrganizationEditModel.cs index 5924f96ca7d3..9cf76ff6fe6f 100644 --- a/src/Admin/AdminConsole/Models/OrganizationEditModel.cs +++ b/src/Admin/AdminConsole/Models/OrganizationEditModel.cs @@ -112,6 +112,7 @@ public OrganizationEditModel( UsePhishingBlocker = org.UsePhishingBlocker; UseMyItems = org.UseMyItems; UseInviteLinks = org.UseInviteLinks; + UsePam = org.UsePam; ExemptFromBillingAutomation = org.ExemptFromBillingAutomation; _plans = plans; @@ -210,6 +211,8 @@ public OrganizationEditModel( public bool UseMyItems { get; set; } [Display(Name = "Invite Links")] public bool UseInviteLinks { get; set; } + [Display(Name = "Use PAM")] + public bool UsePam { get; set; } [Display(Name = "Exempt From Billing Automation")] public bool ExemptFromBillingAutomation { get; set; } @@ -362,6 +365,7 @@ public Organization ToOrganization(Organization existingOrganization) existingOrganization.UsePhishingBlocker = UsePhishingBlocker; existingOrganization.UseMyItems = UseMyItems; existingOrganization.UseInviteLinks = UseInviteLinks; + existingOrganization.UsePam = UsePam; existingOrganization.ExemptFromBillingAutomation = ExemptFromBillingAutomation; return existingOrganization; } diff --git a/src/Admin/AdminConsole/Views/Shared/_OrganizationForm.cshtml b/src/Admin/AdminConsole/Views/Shared/_OrganizationForm.cshtml index 19416d66cee1..89f61d57483e 100644 --- a/src/Admin/AdminConsole/Views/Shared/_OrganizationForm.cshtml +++ b/src/Admin/AdminConsole/Views/Shared/_OrganizationForm.cshtml @@ -206,37 +206,51 @@ -
-

Password Manager

-
- - -
-
- - -
-
- - -
-
-
-

Secrets Manager

-
- - -
-
- - +
+
+
+

Password Manager

+
+ + +
+
+ + +
+
+ + +
+
+ +
+

Secrets Manager

+
+ + +
+
+ + +
+
-
-
-

Access Intelligence

-
- - +
+
+

Access Intelligence

+
+ + +
+
+
+

Privileged Access Management

+
+ + +
+
diff --git a/src/Api/AdminConsole/Models/Response/BaseProfileOrganizationResponseModel.cs b/src/Api/AdminConsole/Models/Response/BaseProfileOrganizationResponseModel.cs index 7031c5946283..6741419cc855 100644 --- a/src/Api/AdminConsole/Models/Response/BaseProfileOrganizationResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/BaseProfileOrganizationResponseModel.cs @@ -53,6 +53,7 @@ protected BaseProfileOrganizationResponseModel( UsePasswordManager = organizationDetails.UsePasswordManager; UseMyItems = organizationDetails.UseMyItems; UseInviteLinks = organizationDetails.UseInviteLinks; + UsePam = organizationDetails.UsePam; SelfHost = organizationDetails.SelfHost; Seats = organizationDetails.Seats; MaxCollections = organizationDetails.MaxCollections; @@ -108,6 +109,7 @@ protected BaseProfileOrganizationResponseModel( public bool UsePhishingBlocker { get; set; } public bool UseMyItems { get; set; } public bool UseInviteLinks { get; set; } + public bool UsePam { get; set; } public bool SelfHost { get; set; } public int? Seats { get; set; } public short? MaxCollections { get; set; } diff --git a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationResponseModel.cs b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationResponseModel.cs index ca3ae7671f60..efdd40302017 100644 --- a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationResponseModel.cs @@ -78,6 +78,7 @@ public OrganizationResponseModel( UsePhishingBlocker = organization.UsePhishingBlocker; UseMyItems = organization.UseMyItems; UseInviteLinks = organization.UseInviteLinks; + UsePam = organization.UsePam; } public Guid Id { get; set; } @@ -131,6 +132,7 @@ public OrganizationResponseModel( public bool UsePhishingBlocker { get; set; } public bool UseMyItems { get; set; } public bool UseInviteLinks { get; set; } + public bool UsePam { get; set; } } public class OrganizationSubscriptionResponseModel : OrganizationResponseModel diff --git a/src/Core/AdminConsole/Entities/Organization.cs b/src/Core/AdminConsole/Entities/Organization.cs index d9ed1c3159d4..94ce88ae0f38 100644 --- a/src/Core/AdminConsole/Entities/Organization.cs +++ b/src/Core/AdminConsole/Entities/Organization.cs @@ -327,6 +327,11 @@ public class Organization : ITableObject, IStorableSubscriber, IRevisable ///
public bool UseInviteLinks { get; set; } + /// + /// If true, the organization is subscribed to the Privileged Access Management (PAM) product. + /// + public bool UsePam { get; set; } + /// /// When set to true, the organization is excluded from automated billing /// lifecycle operations such as subscription cancellation and disabling for non-payment. @@ -559,6 +564,7 @@ public void UpdateFromLicense(OrganizationLicense license, IFeatureService featu UsePolicies = license.UsePolicies; UseMyItems = license.UseMyItems; UseInviteLinks = license.UseInviteLinks; + UsePam = license.UsePam; UseSso = license.UseSso; UseKeyConnector = license.UseKeyConnector; UseScim = license.UseScim; diff --git a/src/Core/AdminConsole/Models/Data/IProfileOrganizationDetails.cs b/src/Core/AdminConsole/Models/Data/IProfileOrganizationDetails.cs index 3b22feb058ce..59a2f50a555c 100644 --- a/src/Core/AdminConsole/Models/Data/IProfileOrganizationDetails.cs +++ b/src/Core/AdminConsole/Models/Data/IProfileOrganizationDetails.cs @@ -58,4 +58,5 @@ public interface IProfileOrganizationDetails bool UsePhishingBlocker { get; set; } bool UseMyItems { get; set; } bool UseInviteLinks { get; set; } + bool UsePam { get; set; } } diff --git a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserOrganizationDetails.cs b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserOrganizationDetails.cs index 094290fe483d..e5c01adfb0ba 100644 --- a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserOrganizationDetails.cs +++ b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserOrganizationDetails.cs @@ -74,4 +74,5 @@ public class OrganizationUserOrganizationDetails : IProfileOrganizationDetails public bool UsePhishingBlocker { get; set; } public bool UseMyItems { get; set; } public bool UseInviteLinks { get; set; } + public bool UsePam { get; set; } } diff --git a/src/Core/AdminConsole/Models/Data/Organizations/SelfHostedOrganizationDetails.cs b/src/Core/AdminConsole/Models/Data/Organizations/SelfHostedOrganizationDetails.cs index 96197c0ed1d7..93620652d328 100644 --- a/src/Core/AdminConsole/Models/Data/Organizations/SelfHostedOrganizationDetails.cs +++ b/src/Core/AdminConsole/Models/Data/Organizations/SelfHostedOrganizationDetails.cs @@ -161,6 +161,7 @@ public Organization ToOrganization() UseAutomaticUserConfirmation = UseAutomaticUserConfirmation, UseMyItems = UseMyItems, UseInviteLinks = UseInviteLinks, + UsePam = UsePam, }; } } diff --git a/src/Core/AdminConsole/Models/Data/Provider/ProviderUserOrganizationDetails.cs b/src/Core/AdminConsole/Models/Data/Provider/ProviderUserOrganizationDetails.cs index c92e6ec2aeae..67d080be3df6 100644 --- a/src/Core/AdminConsole/Models/Data/Provider/ProviderUserOrganizationDetails.cs +++ b/src/Core/AdminConsole/Models/Data/Provider/ProviderUserOrganizationDetails.cs @@ -60,4 +60,5 @@ public class ProviderUserOrganizationDetails : IProfileOrganizationDetails public bool UsePhishingBlocker { get; set; } public bool UseMyItems { get; set; } public bool UseInviteLinks { get; set; } + public bool UsePam { get; set; } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationAbility/OrganizationAbility.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationAbility/OrganizationAbility.cs index df6e6122ddb0..92da021bdb7f 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationAbility/OrganizationAbility.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationAbility/OrganizationAbility.cs @@ -33,6 +33,7 @@ public OrganizationAbility(Organization organization) UsePhishingBlocker = organization.UsePhishingBlocker; UseMyItems = organization.UseMyItems; UseInviteLinks = organization.UseInviteLinks; + UsePam = organization.UsePam; } public Guid Id { get; set; } @@ -59,4 +60,5 @@ public OrganizationAbility(Organization organization) public bool UsePhishingBlocker { get; set; } public bool UseMyItems { get; set; } public bool UseInviteLinks { get; set; } + public bool UsePam { get; set; } } diff --git a/src/Core/Billing/Licenses/LicenseConstants.cs b/src/Core/Billing/Licenses/LicenseConstants.cs index 069dcde07158..7f2586c152b2 100644 --- a/src/Core/Billing/Licenses/LicenseConstants.cs +++ b/src/Core/Billing/Licenses/LicenseConstants.cs @@ -48,6 +48,7 @@ public static class OrganizationLicenseConstants public const string UsePhishingBlocker = nameof(UsePhishingBlocker); public const string UseMyItems = nameof(UseMyItems); public const string UseInviteLinks = nameof(UseInviteLinks); + public const string UsePam = nameof(UsePam); } public static class UserLicenseConstants diff --git a/src/Core/Billing/Licenses/Services/Implementations/OrganizationLicenseClaimsFactory.cs b/src/Core/Billing/Licenses/Services/Implementations/OrganizationLicenseClaimsFactory.cs index 3d48f24440ae..1bf8a3327040 100644 --- a/src/Core/Billing/Licenses/Services/Implementations/OrganizationLicenseClaimsFactory.cs +++ b/src/Core/Billing/Licenses/Services/Implementations/OrganizationLicenseClaimsFactory.cs @@ -61,6 +61,7 @@ public Task> GenerateClaims(Organization entity, LicenseContext lice new(nameof(OrganizationLicenseConstants.UsePhishingBlocker), entity.UsePhishingBlocker.ToString()), new(nameof(OrganizationLicenseConstants.UseMyItems), entity.UseMyItems.ToString()), new(nameof(OrganizationLicenseConstants.UseInviteLinks), entity.UseInviteLinks.ToString()), + new(nameof(OrganizationLicenseConstants.UsePam), entity.UsePam.ToString()), }; if (entity.Name is not null) diff --git a/src/Core/Billing/Organizations/Commands/UpdateOrganizationLicenseCommand.cs b/src/Core/Billing/Organizations/Commands/UpdateOrganizationLicenseCommand.cs index 21178a602d16..c67271515700 100644 --- a/src/Core/Billing/Organizations/Commands/UpdateOrganizationLicenseCommand.cs +++ b/src/Core/Billing/Organizations/Commands/UpdateOrganizationLicenseCommand.cs @@ -90,6 +90,7 @@ public async Task UpdateLicenseAsync(SelfHostedOrganizationDetails selfHostedOrg license.UsePhishingBlocker = claimsPrincipal.GetValue(OrganizationLicenseConstants.UsePhishingBlocker); license.UseMyItems = claimsPrincipal.GetValue(OrganizationLicenseConstants.UseMyItems); license.UseInviteLinks = claimsPrincipal.GetValue(OrganizationLicenseConstants.UseInviteLinks); + license.UsePam = claimsPrincipal.GetValue(OrganizationLicenseConstants.UsePam); license.MaxStorageGb = claimsPrincipal.GetValue(OrganizationLicenseConstants.MaxStorageGb); license.InstallationId = claimsPrincipal.GetValue(OrganizationLicenseConstants.InstallationId); license.LicenseType = claimsPrincipal.GetValue(OrganizationLicenseConstants.LicenseType); diff --git a/src/Core/Billing/Organizations/Models/OrganizationLicense.cs b/src/Core/Billing/Organizations/Models/OrganizationLicense.cs index 97ff75c6822d..355dbbc419e9 100644 --- a/src/Core/Billing/Organizations/Models/OrganizationLicense.cs +++ b/src/Core/Billing/Organizations/Models/OrganizationLicense.cs @@ -161,6 +161,7 @@ public OrganizationLicense(Organization org, SubscriptionInfo subscriptionInfo, public bool UseDisableSmAdsForUsers { get; set; } public bool UseMyItems { get; set; } public bool UseInviteLinks { get; set; } + public bool UsePam { get; set; } public string Hash { get; set; } public string Signature { get; set; } public string Token { get; set; } @@ -239,7 +240,8 @@ public byte[] GetDataBytes(bool forHash = false) !p.Name.Equals(nameof(UseDisableSmAdsForUsers)) && !p.Name.Equals(nameof(UsePhishingBlocker)) && !p.Name.Equals(nameof(UseMyItems)) && - !p.Name.Equals(nameof(UseInviteLinks))) + !p.Name.Equals(nameof(UseInviteLinks)) && + !p.Name.Equals(nameof(UsePam))) .OrderBy(p => p.Name) .Select(p => $"{p.Name}:{Core.Utilities.CoreHelpers.FormatLicenseSignatureValue(p.GetValue(this, null))}") .Aggregate((c, n) => $"{c}|{n}"); @@ -437,6 +439,7 @@ public bool VerifyData( var useDisableSmAdsForUsers = claimsPrincipal.GetValue(nameof(UseDisableSmAdsForUsers)); var useMyItems = claimsPrincipal.GetValue(nameof(UseMyItems)); var useInviteLinks = claimsPrincipal.GetValue(nameof(UseInviteLinks)); + var usePam = claimsPrincipal.GetValue(nameof(UsePam)); var claimedPlanType = claimsPrincipal.GetValue(nameof(PlanType)); @@ -483,7 +486,9 @@ public bool VerifyData( (!claimsPrincipal.HasClaim(c => c.Type == nameof(UseMyItems)) || useMyItems == organization.UseMyItems) && (!claimsPrincipal.HasClaim(c => c.Type == nameof(UseInviteLinks)) - || useInviteLinks == organization.UseInviteLinks); + || useInviteLinks == organization.UseInviteLinks) && + (!claimsPrincipal.HasClaim(c => c.Type == nameof(UsePam)) + || usePam == organization.UsePam); } diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs index 7a7de65207c4..0ecfce4f7dbf 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs @@ -141,7 +141,8 @@ public async Task> GetManyAbilitiesAsync() UseDisableSmAdsForUsers = e.UseDisableSmAdsForUsers, UsePhishingBlocker = e.UsePhishingBlocker, UseMyItems = e.UseMyItems, - UseInviteLinks = e.UseInviteLinks + UseInviteLinks = e.UseInviteLinks, + UsePam = e.UsePam }).ToListAsync(); } } diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/OrganizationUserOrganizationDetailsViewQuery.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/OrganizationUserOrganizationDetailsViewQuery.cs index 9f9fbc0aaaa4..96ef158d7f6b 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/OrganizationUserOrganizationDetailsViewQuery.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/OrganizationUserOrganizationDetailsViewQuery.cs @@ -79,6 +79,7 @@ from os in os_g.DefaultIfEmpty() UsePhishingBlocker = o.UsePhishingBlocker, UseMyItems = o.UseMyItems, UseInviteLinks = o.UseInviteLinks, + UsePam = o.UsePam, RevocationReason = ou.RevocationReason }; return query; diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/ProviderUserOrganizationDetailsViewQuery.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/ProviderUserOrganizationDetailsViewQuery.cs index ea9b5c08d64e..e4c787dc25cb 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/ProviderUserOrganizationDetailsViewQuery.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/ProviderUserOrganizationDetailsViewQuery.cs @@ -64,7 +64,8 @@ from ss in ss_g.DefaultIfEmpty() UseDisableSMAdsForUsers = x.o.UseDisableSmAdsForUsers, UsePhishingBlocker = x.o.UsePhishingBlocker, UseMyItems = x.o.UseMyItems, - UseInviteLinks = x.o.UseInviteLinks + UseInviteLinks = x.o.UseInviteLinks, + UsePam = x.o.UsePam }); } } diff --git a/src/Sql/dbo/Stored Procedures/Organization_Create.sql b/src/Sql/dbo/Stored Procedures/Organization_Create.sql index 9478fe1acc63..be15e53a0bdb 100644 --- a/src/Sql/dbo/Stored Procedures/Organization_Create.sql +++ b/src/Sql/dbo/Stored Procedures/Organization_Create.sql @@ -64,7 +64,8 @@ CREATE PROCEDURE [dbo].[Organization_Create] @UseDisableSmAdsForUsers BIT = 0, @UseMyItems BIT = 0, @ExemptFromBillingAutomation BIT = 0, - @UseInviteLinks BIT = 0 + @UseInviteLinks BIT = 0, + @UsePam BIT = 0 AS BEGIN SET NOCOUNT ON @@ -137,7 +138,8 @@ BEGIN [UseDisableSmAdsForUsers], [UseMyItems], [ExemptFromBillingAutomation], - [UseInviteLinks] + [UseInviteLinks], + [UsePam] ) VALUES ( @@ -207,6 +209,7 @@ BEGIN @UseDisableSmAdsForUsers, @UseMyItems, @ExemptFromBillingAutomation, - @UseInviteLinks + @UseInviteLinks, + @UsePam ); END diff --git a/src/Sql/dbo/Stored Procedures/Organization_ReadAbilities.sql b/src/Sql/dbo/Stored Procedures/Organization_ReadAbilities.sql index 49c48e194577..4bb7ec68894d 100644 --- a/src/Sql/dbo/Stored Procedures/Organization_ReadAbilities.sql +++ b/src/Sql/dbo/Stored Procedures/Organization_ReadAbilities.sql @@ -32,7 +32,8 @@ BEGIN [UsePhishingBlocker], [UseDisableSmAdsForUsers], [UseMyItems], - [UseInviteLinks] + [UseInviteLinks], + [UsePam] FROM [dbo].[Organization] END diff --git a/src/Sql/dbo/Stored Procedures/Organization_ReadManyByIds.sql b/src/Sql/dbo/Stored Procedures/Organization_ReadManyByIds.sql index 6656f71af82d..aa5e1e3857aa 100644 --- a/src/Sql/dbo/Stored Procedures/Organization_ReadManyByIds.sql +++ b/src/Sql/dbo/Stored Procedures/Organization_ReadManyByIds.sql @@ -60,7 +60,8 @@ BEGIN o.[LimitItemDeletion], o.[AllowAdminAccessToAllCollectionItems], o.[UseRiskInsights], - o.[UseInviteLinks] + o.[UseInviteLinks], + o.[UsePam] FROM [dbo].[OrganizationView] o INNER JOIN @OrganizationIds ids ON o.[Id] = ids.[Id] diff --git a/src/Sql/dbo/Stored Procedures/Organization_Update.sql b/src/Sql/dbo/Stored Procedures/Organization_Update.sql index 0b1ce40cfdbe..8b46fbeb1e33 100644 --- a/src/Sql/dbo/Stored Procedures/Organization_Update.sql +++ b/src/Sql/dbo/Stored Procedures/Organization_Update.sql @@ -64,7 +64,8 @@ CREATE PROCEDURE [dbo].[Organization_Update] @UseDisableSmAdsForUsers BIT = 0, @UseMyItems BIT = 0, @ExemptFromBillingAutomation BIT = 0, - @UseInviteLinks BIT = 0 + @UseInviteLinks BIT = 0, + @UsePam BIT = 0 AS BEGIN SET NOCOUNT ON @@ -137,7 +138,8 @@ BEGIN [UseDisableSmAdsForUsers] = @UseDisableSmAdsForUsers, [UseMyItems] = @UseMyItems, [ExemptFromBillingAutomation] = @ExemptFromBillingAutomation, - [UseInviteLinks] = @UseInviteLinks + [UseInviteLinks] = @UseInviteLinks, + [UsePam] = @UsePam WHERE [Id] = @Id; END diff --git a/src/Sql/dbo/Tables/Organization.sql b/src/Sql/dbo/Tables/Organization.sql index 41bcb2f74e8b..04b2e0fa6182 100644 --- a/src/Sql/dbo/Tables/Organization.sql +++ b/src/Sql/dbo/Tables/Organization.sql @@ -66,6 +66,7 @@ CREATE TABLE [dbo].[Organization] ( [UseMyItems] BIT NOT NULL CONSTRAINT [DF_Organization_UseMyItems] DEFAULT (0), [ExemptFromBillingAutomation] BIT NOT NULL CONSTRAINT [DF_Organization_ExemptFromBillingAutomation] DEFAULT (0), [UseInviteLinks] BIT NOT NULL CONSTRAINT [DF_Organization_UseInviteLinks] DEFAULT (0), + [UsePam] BIT NOT NULL CONSTRAINT [DF_Organization_UsePam] DEFAULT (0), CONSTRAINT [PK_Organization] PRIMARY KEY CLUSTERED ([Id] ASC) ); diff --git a/src/Sql/dbo/Views/OrganizationAbilityView.sql b/src/Sql/dbo/Views/OrganizationAbilityView.sql index 5dde7a2e9570..066086492e71 100644 --- a/src/Sql/dbo/Views/OrganizationAbilityView.sql +++ b/src/Sql/dbo/Views/OrganizationAbilityView.sql @@ -24,6 +24,7 @@ SELECT [UseDisableSmAdsForUsers], [UsePhishingBlocker], [UseMyItems], - [UseInviteLinks] + [UseInviteLinks], + [UsePam] FROM [dbo].[Organization] diff --git a/src/Sql/dbo/Views/OrganizationUserOrganizationDetailsView.sql b/src/Sql/dbo/Views/OrganizationUserOrganizationDetailsView.sql index 9dea90113ea6..029da30339f3 100644 --- a/src/Sql/dbo/Views/OrganizationUserOrganizationDetailsView.sql +++ b/src/Sql/dbo/Views/OrganizationUserOrganizationDetailsView.sql @@ -60,6 +60,7 @@ SELECT O.[UseDisableSmAdsForUsers], O.[UseMyItems], O.[UseInviteLinks], + O.[UsePam], OU.[RevocationReason] FROM [dbo].[OrganizationUser] OU diff --git a/src/Sql/dbo/Views/OrganizationView.sql b/src/Sql/dbo/Views/OrganizationView.sql index a60ff9c921ff..7a4291795e14 100644 --- a/src/Sql/dbo/Views/OrganizationView.sql +++ b/src/Sql/dbo/Views/OrganizationView.sql @@ -66,6 +66,7 @@ SELECT [UseDisableSmAdsForUsers], [UseMyItems], [ExemptFromBillingAutomation], - [UseInviteLinks] + [UseInviteLinks], + [UsePam] FROM [dbo].[Organization] diff --git a/src/Sql/dbo/Views/ProviderUserProviderOrganizationDetailsView.sql b/src/Sql/dbo/Views/ProviderUserProviderOrganizationDetailsView.sql index 8d638f391c35..2a3c273be91d 100644 --- a/src/Sql/dbo/Views/ProviderUserProviderOrganizationDetailsView.sql +++ b/src/Sql/dbo/Views/ProviderUserProviderOrganizationDetailsView.sql @@ -48,7 +48,8 @@ SELECT O.[UsePhishingBlocker], O.[UseDisableSmAdsForUsers], O.[UseMyItems], - O.[UseInviteLinks] + O.[UseInviteLinks], + O.[UsePam] FROM [dbo].[ProviderUser] PU INNER JOIN diff --git a/test/Core.Test/Billing/Organizations/Commands/UpdateOrganizationLicenseCommandTests.cs b/test/Core.Test/Billing/Organizations/Commands/UpdateOrganizationLicenseCommandTests.cs index d5283b01a394..55a0ade7a005 100644 --- a/test/Core.Test/Billing/Organizations/Commands/UpdateOrganizationLicenseCommandTests.cs +++ b/test/Core.Test/Billing/Organizations/Commands/UpdateOrganizationLicenseCommandTests.cs @@ -170,7 +170,8 @@ public async Task UpdateLicenseAsync_WithClaimsPrincipal_ExtractsAllPropertiesFr new(OrganizationLicenseConstants.LimitCollectionCreationDeletion, "true"), new(OrganizationLicenseConstants.AllowAdminAccessToAllCollectionItems, "true"), new(OrganizationLicenseConstants.UseMyItems, "true"), - new(OrganizationLicenseConstants.UseInviteLinks, "true") + new(OrganizationLicenseConstants.UseInviteLinks, "true"), + new(OrganizationLicenseConstants.UsePam, "true") }; var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity(claims)); @@ -239,7 +240,8 @@ await sutProvider.GetDependency() org.UseDisableSmAdsForUsers == true && org.UsePhishingBlocker == true && org.UseMyItems && - org.UseInviteLinks)); + org.UseInviteLinks && + org.UsePam)); } finally { diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/OrganizationTestHelpers.cs b/test/Infrastructure.IntegrationTest/AdminConsole/OrganizationTestHelpers.cs index fdb601921427..0742598f7909 100644 --- a/test/Infrastructure.IntegrationTest/AdminConsole/OrganizationTestHelpers.cs +++ b/test/Infrastructure.IntegrationTest/AdminConsole/OrganizationTestHelpers.cs @@ -98,6 +98,7 @@ public static Task CreateTestOrganizationAsync(this IOrganizationR UseDisableSmAdsForUsers = true, UseMyItems = true, UseInviteLinks = true, + UsePam = true, }); } diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepository/OrganizationUserRepositoryTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepository/OrganizationUserRepositoryTests.cs index f843093a0ccb..fc8a35808bad 100644 --- a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepository/OrganizationUserRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepository/OrganizationUserRepositoryTests.cs @@ -792,6 +792,7 @@ public async Task GetManyDetailsByUserAsync_Works(IUserRepository userRepository Assert.Equal(organization.UseAdminSponsoredFamilies, result.UseAdminSponsoredFamilies); Assert.Equal(organization.UseAutomaticUserConfirmation, result.UseAutomaticUserConfirmation); Assert.Equal(organization.UseInviteLinks, result.UseInviteLinks); + Assert.Equal(organization.UsePam, result.UsePam); Assert.Equal(orgUser1.RevocationReason, result.RevocationReason); } diff --git a/util/Migrator/DbScripts/2026-06-16_00_AddUsePamToOrganization.sql b/util/Migrator/DbScripts/2026-06-16_00_AddUsePamToOrganization.sql new file mode 100644 index 000000000000..cad2fb4fcd0c --- /dev/null +++ b/util/Migrator/DbScripts/2026-06-16_00_AddUsePamToOrganization.sql @@ -0,0 +1,749 @@ +-- Add UsePam column to Organization table +IF COL_LENGTH('[dbo].[Organization]', 'UsePam') IS NULL +BEGIN + ALTER TABLE [dbo].[Organization] + ADD [UsePam] BIT NOT NULL CONSTRAINT [DF_Organization_UsePam] DEFAULT (0); +END +GO + +-- Update Organization_Create stored procedure to include UsePam +CREATE OR ALTER PROCEDURE [dbo].[Organization_Create] + @Id UNIQUEIDENTIFIER OUTPUT, + @Identifier NVARCHAR(50), + @Name NVARCHAR(50), + @BusinessName NVARCHAR(50), + @BusinessAddress1 NVARCHAR(50), + @BusinessAddress2 NVARCHAR(50), + @BusinessAddress3 NVARCHAR(50), + @BusinessCountry VARCHAR(2), + @BusinessTaxNumber NVARCHAR(30), + @BillingEmail NVARCHAR(256), + @Plan NVARCHAR(50), + @PlanType TINYINT, + @Seats INT, + @MaxCollections SMALLINT, + @UsePolicies BIT, + @UseSso BIT, + @UseGroups BIT, + @UseDirectory BIT, + @UseEvents BIT, + @UseTotp BIT, + @Use2fa BIT, + @UseApi BIT, + @UseResetPassword BIT, + @SelfHost BIT, + @UsersGetPremium BIT, + @Storage BIGINT, + @MaxStorageGb SMALLINT, + @Gateway TINYINT, + @GatewayCustomerId VARCHAR(50), + @GatewaySubscriptionId VARCHAR(50), + @ReferenceData VARCHAR(MAX), + @Enabled BIT, + @LicenseKey VARCHAR(100), + @PublicKey VARCHAR(MAX), + @PrivateKey VARCHAR(MAX), + @TwoFactorProviders NVARCHAR(MAX), + @ExpirationDate DATETIME2(7), + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7), + @OwnersNotifiedOfAutoscaling DATETIME2(7), + @MaxAutoscaleSeats INT, + @UseKeyConnector BIT = 0, + @UseScim BIT = 0, + @UseCustomPermissions BIT = 0, + @UseSecretsManager BIT = 0, + @Status TINYINT = 0, + @UsePasswordManager BIT = 1, + @SmSeats INT = null, + @SmServiceAccounts INT = null, + @MaxAutoscaleSmSeats INT= null, + @MaxAutoscaleSmServiceAccounts INT = null, + @SecretsManagerBeta BIT = 0, + @LimitCollectionCreation BIT = NULL, + @LimitCollectionDeletion BIT = NULL, + @AllowAdminAccessToAllCollectionItems BIT = 0, + @UseRiskInsights BIT = 0, + @LimitItemDeletion BIT = 0, + @UseOrganizationDomains BIT = 0, + @UseAdminSponsoredFamilies BIT = 0, + @SyncSeats BIT = 0, + @UseAutomaticUserConfirmation BIT = 0, + @UsePhishingBlocker BIT = 0, + @UseDisableSmAdsForUsers BIT = 0, + @UseMyItems BIT = 0, + @ExemptFromBillingAutomation BIT = 0, + @UseInviteLinks BIT = 0, + @UsePam BIT = 0 +AS +BEGIN + SET NOCOUNT ON + + INSERT INTO [dbo].[Organization] + ( + [Id], + [Identifier], + [Name], + [BusinessName], + [BusinessAddress1], + [BusinessAddress2], + [BusinessAddress3], + [BusinessCountry], + [BusinessTaxNumber], + [BillingEmail], + [Plan], + [PlanType], + [Seats], + [MaxCollections], + [UsePolicies], + [UseSso], + [UseGroups], + [UseDirectory], + [UseEvents], + [UseTotp], + [Use2fa], + [UseApi], + [UseResetPassword], + [SelfHost], + [UsersGetPremium], + [Storage], + [MaxStorageGb], + [Gateway], + [GatewayCustomerId], + [GatewaySubscriptionId], + [ReferenceData], + [Enabled], + [LicenseKey], + [PublicKey], + [PrivateKey], + [TwoFactorProviders], + [ExpirationDate], + [CreationDate], + [RevisionDate], + [OwnersNotifiedOfAutoscaling], + [MaxAutoscaleSeats], + [UseKeyConnector], + [UseScim], + [UseCustomPermissions], + [UseSecretsManager], + [Status], + [UsePasswordManager], + [SmSeats], + [SmServiceAccounts], + [MaxAutoscaleSmSeats], + [MaxAutoscaleSmServiceAccounts], + [SecretsManagerBeta], + [LimitCollectionCreation], + [LimitCollectionDeletion], + [AllowAdminAccessToAllCollectionItems], + [UseRiskInsights], + [LimitItemDeletion], + [UseOrganizationDomains], + [UseAdminSponsoredFamilies], + [SyncSeats], + [UseAutomaticUserConfirmation], + [UsePhishingBlocker], + [MaxStorageGbIncreased], + [UseDisableSmAdsForUsers], + [UseMyItems], + [ExemptFromBillingAutomation], + [UseInviteLinks], + [UsePam] + ) + VALUES + ( + @Id, + @Identifier, + @Name, + @BusinessName, + @BusinessAddress1, + @BusinessAddress2, + @BusinessAddress3, + @BusinessCountry, + @BusinessTaxNumber, + @BillingEmail, + @Plan, + @PlanType, + @Seats, + @MaxCollections, + @UsePolicies, + @UseSso, + @UseGroups, + @UseDirectory, + @UseEvents, + @UseTotp, + @Use2fa, + @UseApi, + @UseResetPassword, + @SelfHost, + @UsersGetPremium, + @Storage, + @MaxStorageGb, + @Gateway, + @GatewayCustomerId, + @GatewaySubscriptionId, + @ReferenceData, + @Enabled, + @LicenseKey, + @PublicKey, + @PrivateKey, + @TwoFactorProviders, + @ExpirationDate, + @CreationDate, + @RevisionDate, + @OwnersNotifiedOfAutoscaling, + @MaxAutoscaleSeats, + @UseKeyConnector, + @UseScim, + @UseCustomPermissions, + @UseSecretsManager, + @Status, + @UsePasswordManager, + @SmSeats, + @SmServiceAccounts, + @MaxAutoscaleSmSeats, + @MaxAutoscaleSmServiceAccounts, + @SecretsManagerBeta, + @LimitCollectionCreation, + @LimitCollectionDeletion, + @AllowAdminAccessToAllCollectionItems, + @UseRiskInsights, + @LimitItemDeletion, + @UseOrganizationDomains, + @UseAdminSponsoredFamilies, + @SyncSeats, + @UseAutomaticUserConfirmation, + @UsePhishingBlocker, + @MaxStorageGb, + @UseDisableSmAdsForUsers, + @UseMyItems, + @ExemptFromBillingAutomation, + @UseInviteLinks, + @UsePam + ); +END +GO + +-- Update Organization_Update stored procedure to include UsePam +CREATE OR ALTER PROCEDURE [dbo].[Organization_Update] + @Id UNIQUEIDENTIFIER, + @Identifier NVARCHAR(50), + @Name NVARCHAR(50), + @BusinessName NVARCHAR(50), + @BusinessAddress1 NVARCHAR(50), + @BusinessAddress2 NVARCHAR(50), + @BusinessAddress3 NVARCHAR(50), + @BusinessCountry VARCHAR(2), + @BusinessTaxNumber NVARCHAR(30), + @BillingEmail NVARCHAR(256), + @Plan NVARCHAR(50), + @PlanType TINYINT, + @Seats INT, + @MaxCollections SMALLINT, + @UsePolicies BIT, + @UseSso BIT, + @UseGroups BIT, + @UseDirectory BIT, + @UseEvents BIT, + @UseTotp BIT, + @Use2fa BIT, + @UseApi BIT, + @UseResetPassword BIT, + @SelfHost BIT, + @UsersGetPremium BIT, + @Storage BIGINT, + @MaxStorageGb SMALLINT, + @Gateway TINYINT, + @GatewayCustomerId VARCHAR(50), + @GatewaySubscriptionId VARCHAR(50), + @ReferenceData VARCHAR(MAX), + @Enabled BIT, + @LicenseKey VARCHAR(100), + @PublicKey VARCHAR(MAX), + @PrivateKey VARCHAR(MAX), + @TwoFactorProviders NVARCHAR(MAX), + @ExpirationDate DATETIME2(7), + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7), + @OwnersNotifiedOfAutoscaling DATETIME2(7), + @MaxAutoscaleSeats INT, + @UseKeyConnector BIT = 0, + @UseScim BIT = 0, + @UseCustomPermissions BIT = 0, + @UseSecretsManager BIT = 0, + @Status TINYINT = 0, + @UsePasswordManager BIT = 1, + @SmSeats INT = null, + @SmServiceAccounts INT = null, + @MaxAutoscaleSmSeats INT = null, + @MaxAutoscaleSmServiceAccounts INT = null, + @SecretsManagerBeta BIT = 0, + @LimitCollectionCreation BIT = null, + @LimitCollectionDeletion BIT = null, + @AllowAdminAccessToAllCollectionItems BIT = 0, + @UseRiskInsights BIT = 0, + @LimitItemDeletion BIT = 0, + @UseOrganizationDomains BIT = 0, + @UseAdminSponsoredFamilies BIT = 0, + @SyncSeats BIT = 0, + @UseAutomaticUserConfirmation BIT = 0, + @UsePhishingBlocker BIT = 0, + @UseDisableSmAdsForUsers BIT = 0, + @UseMyItems BIT = 0, + @ExemptFromBillingAutomation BIT = 0, + @UseInviteLinks BIT = 0, + @UsePam BIT = 0 +AS +BEGIN + SET NOCOUNT ON + + UPDATE + [dbo].[Organization] + SET + [Identifier] = @Identifier, + [Name] = @Name, + [BusinessName] = @BusinessName, + [BusinessAddress1] = @BusinessAddress1, + [BusinessAddress2] = @BusinessAddress2, + [BusinessAddress3] = @BusinessAddress3, + [BusinessCountry] = @BusinessCountry, + [BusinessTaxNumber] = @BusinessTaxNumber, + [BillingEmail] = @BillingEmail, + [Plan] = @Plan, + [PlanType] = @PlanType, + [Seats] = @Seats, + [MaxCollections] = @MaxCollections, + [UsePolicies] = @UsePolicies, + [UseSso] = @UseSso, + [UseGroups] = @UseGroups, + [UseDirectory] = @UseDirectory, + [UseEvents] = @UseEvents, + [UseTotp] = @UseTotp, + [Use2fa] = @Use2fa, + [UseApi] = @UseApi, + [UseResetPassword] = @UseResetPassword, + [SelfHost] = @SelfHost, + [UsersGetPremium] = @UsersGetPremium, + [Storage] = @Storage, + [MaxStorageGb] = @MaxStorageGb, + [Gateway] = @Gateway, + [GatewayCustomerId] = @GatewayCustomerId, + [GatewaySubscriptionId] = @GatewaySubscriptionId, + [ReferenceData] = @ReferenceData, + [Enabled] = @Enabled, + [LicenseKey] = @LicenseKey, + [PublicKey] = @PublicKey, + [PrivateKey] = @PrivateKey, + [TwoFactorProviders] = @TwoFactorProviders, + [ExpirationDate] = @ExpirationDate, + [CreationDate] = @CreationDate, + [RevisionDate] = @RevisionDate, + [OwnersNotifiedOfAutoscaling] = @OwnersNotifiedOfAutoscaling, + [MaxAutoscaleSeats] = @MaxAutoscaleSeats, + [UseKeyConnector] = @UseKeyConnector, + [UseScim] = @UseScim, + [UseCustomPermissions] = @UseCustomPermissions, + [UseSecretsManager] = @UseSecretsManager, + [Status] = @Status, + [UsePasswordManager] = @UsePasswordManager, + [SmSeats] = @SmSeats, + [SmServiceAccounts] = @SmServiceAccounts, + [MaxAutoscaleSmSeats] = @MaxAutoscaleSmSeats, + [MaxAutoscaleSmServiceAccounts] = @MaxAutoscaleSmServiceAccounts, + [SecretsManagerBeta] = @SecretsManagerBeta, + [LimitCollectionCreation] = @LimitCollectionCreation, + [LimitCollectionDeletion] = @LimitCollectionDeletion, + [AllowAdminAccessToAllCollectionItems] = @AllowAdminAccessToAllCollectionItems, + [UseRiskInsights] = @UseRiskInsights, + [LimitItemDeletion] = @LimitItemDeletion, + [UseOrganizationDomains] = @UseOrganizationDomains, + [UseAdminSponsoredFamilies] = @UseAdminSponsoredFamilies, + [SyncSeats] = @SyncSeats, + [UseAutomaticUserConfirmation] = @UseAutomaticUserConfirmation, + [UsePhishingBlocker] = @UsePhishingBlocker, + [MaxStorageGbIncreased] = @MaxStorageGb, + [UseDisableSmAdsForUsers] = @UseDisableSmAdsForUsers, + [UseMyItems] = @UseMyItems, + [ExemptFromBillingAutomation] = @ExemptFromBillingAutomation, + [UseInviteLinks] = @UseInviteLinks, + [UsePam] = @UsePam + WHERE + [Id] = @Id; +END +GO + +-- Update Organization_ReadAbilities stored procedure to include UsePam +CREATE OR ALTER PROCEDURE [dbo].[Organization_ReadAbilities] +AS +BEGIN + SET NOCOUNT ON + + SELECT + [Id], + [UseEvents], + [Use2fa], + CASE + WHEN [Use2fa] = 1 AND [TwoFactorProviders] IS NOT NULL AND [TwoFactorProviders] != '{}' THEN + 1 + ELSE + 0 + END AS [Using2fa], + [UsersGetPremium], + [UseCustomPermissions], + [UseSso], + [UseKeyConnector], + [UseScim], + [UseResetPassword], + [UsePolicies], + [Enabled], + [LimitCollectionCreation], + [LimitCollectionDeletion], + [AllowAdminAccessToAllCollectionItems], + [UseRiskInsights], + [LimitItemDeletion], + [UseOrganizationDomains], + [UseAdminSponsoredFamilies], + [UseAutomaticUserConfirmation], + [UsePhishingBlocker], + [UseDisableSmAdsForUsers], + [UseMyItems], + [UseInviteLinks], + [UsePam] + FROM + [dbo].[Organization] +END +GO + +-- Update OrganizationView to include UsePam +CREATE OR ALTER VIEW [dbo].[OrganizationView] +AS +SELECT + [Id], + [Identifier], + [Name], + [BusinessName], + [BusinessAddress1], + [BusinessAddress2], + [BusinessAddress3], + [BusinessCountry], + [BusinessTaxNumber], + [BillingEmail], + [Plan], + [PlanType], + [Seats], + [MaxCollections], + [UsePolicies], + [UseSso], + [UseGroups], + [UseDirectory], + [UseEvents], + [UseTotp], + [Use2fa], + [UseApi], + [UseResetPassword], + [SelfHost], + [UsersGetPremium], + [Storage], + COALESCE([MaxStorageGbIncreased], [MaxStorageGb]) AS [MaxStorageGb], + [Gateway], + [GatewayCustomerId], + [GatewaySubscriptionId], + [ReferenceData], + [Enabled], + [LicenseKey], + [PublicKey], + [PrivateKey], + [TwoFactorProviders], + [ExpirationDate], + [CreationDate], + [RevisionDate], + [OwnersNotifiedOfAutoscaling], + [MaxAutoscaleSeats], + [UseKeyConnector], + [UseScim], + [UseCustomPermissions], + [UseSecretsManager], + [Status], + [UsePasswordManager], + [SmSeats], + [SmServiceAccounts], + [MaxAutoscaleSmSeats], + [MaxAutoscaleSmServiceAccounts], + [SecretsManagerBeta], + [LimitCollectionCreation], + [LimitCollectionDeletion], + [LimitItemDeletion], + [AllowAdminAccessToAllCollectionItems], + [UseRiskInsights], + [UseOrganizationDomains], + [UseAdminSponsoredFamilies], + [SyncSeats], + [UseAutomaticUserConfirmation], + [UsePhishingBlocker], + [UseDisableSmAdsForUsers], + [UseMyItems], + [ExemptFromBillingAutomation], + [UseInviteLinks], + [UsePam] +FROM + [dbo].[Organization] +GO + +-- Update OrganizationAbilityView to include UsePam +CREATE OR ALTER VIEW [dbo].[OrganizationAbilityView] +AS +SELECT + [Id], + [UseEvents], + [Use2fa], + IIF([Use2fa] = 1 AND [TwoFactorProviders] IS NOT NULL AND [TwoFactorProviders] != '{}', 1, 0) AS [Using2fa], + [UsersGetPremium], + [Enabled], + [UseSso], + [UseKeyConnector], + [UseScim], + [UseResetPassword], + [UseCustomPermissions], + [UsePolicies], + [LimitCollectionCreation], + [LimitCollectionDeletion], + [LimitItemDeletion], + [AllowAdminAccessToAllCollectionItems], + [UseRiskInsights], + [UseOrganizationDomains], + [UseAdminSponsoredFamilies], + [UseAutomaticUserConfirmation], + [UseDisableSmAdsForUsers], + [UsePhishingBlocker], + [UseMyItems], + [UseInviteLinks], + [UsePam] +FROM + [dbo].[Organization] +GO + +-- Update OrganizationUserOrganizationDetailsView to include UsePam +CREATE OR ALTER VIEW [dbo].[OrganizationUserOrganizationDetailsView] +AS +SELECT + OU.[UserId], + OU.[OrganizationId], + OU.[Id] OrganizationUserId, + O.[Name], + O.[Enabled], + O.[PlanType], + O.[UsePolicies], + O.[UseSso], + O.[UseKeyConnector], + O.[UseScim], + O.[UseGroups], + O.[UseDirectory], + O.[UseEvents], + O.[UseTotp], + O.[Use2fa], + O.[UseApi], + O.[UseResetPassword], + O.[SelfHost], + O.[UsersGetPremium], + O.[UseCustomPermissions], + O.[UseSecretsManager], + O.[Seats], + O.[MaxCollections], + COALESCE(O.[MaxStorageGbIncreased], O.[MaxStorageGb]) AS [MaxStorageGb], + O.[Identifier], + OU.[Key], + OU.[ResetPasswordKey], + O.[PublicKey], + O.[PrivateKey], + OU.[Status], + OU.[Type], + SU.[ExternalId] SsoExternalId, + OU.[Permissions], + PO.[ProviderId], + P.[Name] ProviderName, + P.[Type] ProviderType, + SS.[Enabled] SsoEnabled, + SS.[Data] SsoConfig, + OS.[FriendlyName] FamilySponsorshipFriendlyName, + OS.[LastSyncDate] FamilySponsorshipLastSyncDate, + OS.[ToDelete] FamilySponsorshipToDelete, + OS.[ValidUntil] FamilySponsorshipValidUntil, + OU.[AccessSecretsManager], + O.[UsePasswordManager], + O.[SmSeats], + O.[SmServiceAccounts], + O.[LimitCollectionCreation], + O.[LimitCollectionDeletion], + O.[AllowAdminAccessToAllCollectionItems], + O.[UseRiskInsights], + O.[LimitItemDeletion], + O.[UseAdminSponsoredFamilies], + O.[UseOrganizationDomains], + OS.[IsAdminInitiated], + O.[UseAutomaticUserConfirmation], + O.[UsePhishingBlocker], + O.[UseDisableSmAdsForUsers], + O.[UseMyItems], + O.[UseInviteLinks], + O.[UsePam], + OU.[RevocationReason] +FROM + [dbo].[OrganizationUser] OU +LEFT JOIN + [dbo].[Organization] O ON O.[Id] = OU.[OrganizationId] +LEFT JOIN + [dbo].[SsoUser] SU ON SU.[UserId] = OU.[UserId] AND SU.[OrganizationId] = OU.[OrganizationId] +LEFT JOIN + [dbo].[ProviderOrganization] PO ON PO.[OrganizationId] = O.[Id] +LEFT JOIN + [dbo].[Provider] P ON P.[Id] = PO.[ProviderId] +LEFT JOIN + [dbo].[SsoConfig] SS ON SS.[OrganizationId] = OU.[OrganizationId] +LEFT JOIN + [dbo].[OrganizationSponsorship] OS ON OS.[SponsoringOrganizationUserID] = OU.[Id] +GO + +-- Update ProviderUserProviderOrganizationDetailsView to include UsePam +CREATE OR ALTER VIEW [dbo].[ProviderUserProviderOrganizationDetailsView] +AS +SELECT + PU.[UserId], + PO.[OrganizationId], + O.[Name], + O.[Enabled], + O.[UsePolicies], + O.[UseSso], + O.[UseKeyConnector], + O.[UseScim], + O.[UseGroups], + O.[UseDirectory], + O.[UseEvents], + O.[UseTotp], + O.[Use2fa], + O.[UseApi], + O.[UseResetPassword], + O.[UseSecretsManager], + O.[UsePasswordManager], + O.[SelfHost], + O.[UsersGetPremium], + O.[UseCustomPermissions], + O.[Seats], + O.[MaxCollections], + COALESCE(O.[MaxStorageGbIncreased], O.[MaxStorageGb]) AS [MaxStorageGb], + O.[Identifier], + PO.[Key], + O.[PublicKey], + O.[PrivateKey], + PU.[Status], + PU.[Type], + PO.[ProviderId], + PU.[Id] ProviderUserId, + P.[Name] ProviderName, + O.[PlanType], + O.[LimitCollectionCreation], + O.[LimitCollectionDeletion], + O.[AllowAdminAccessToAllCollectionItems], + O.[UseRiskInsights], + O.[UseAdminSponsoredFamilies], + P.[Type] ProviderType, + O.[LimitItemDeletion], + O.[UseOrganizationDomains], + O.[UseAutomaticUserConfirmation], + SS.[Enabled] SsoEnabled, + SS.[Data] SsoConfig, + O.[UsePhishingBlocker], + O.[UseDisableSmAdsForUsers], + O.[UseMyItems], + O.[UseInviteLinks], + O.[UsePam] +FROM + [dbo].[ProviderUser] PU +INNER JOIN + [dbo].[ProviderOrganization] PO ON PO.[ProviderId] = PU.[ProviderId] +INNER JOIN + [dbo].[Organization] O ON O.[Id] = PO.[OrganizationId] +INNER JOIN + [dbo].[Provider] P ON P.[Id] = PU.[ProviderId] +LEFT JOIN + [dbo].[SsoConfig] SS ON SS.[OrganizationId] = O.[Id] +GO + +-- Refresh dependent views +EXEC sp_refreshview '[dbo].[OrganizationCipherDetailsCollectionsView]'; +GO + +EXEC sp_refreshview '[dbo].[ProviderOrganizationOrganizationDetailsView]'; +GO + +EXEC sp_refreshview '[dbo].[UserPremiumAccessView]'; +GO + +-- Update Organization_ReadManyByIds to include UsePam +CREATE OR ALTER PROCEDURE [dbo].[Organization_ReadManyByIds] @OrganizationIds AS [dbo].[GuidIdArray] READONLY +AS +BEGIN + SET NOCOUNT ON + + SELECT o.[Id], + o.[Identifier], + o.[Name], + o.[BusinessName], + o.[BusinessAddress1], + o.[BusinessAddress2], + o.[BusinessAddress3], + o.[BusinessCountry], + o.[BusinessTaxNumber], + o.[BillingEmail], + o.[Plan], + o.[PlanType], + o.[Seats], + o.[MaxCollections], + o.[UsePolicies], + o.[UseSso], + o.[UseGroups], + o.[UseDirectory], + o.[UseEvents], + o.[UseTotp], + o.[Use2fa], + o.[UseApi], + o.[UseResetPassword], + o.[SelfHost], + o.[UsersGetPremium], + o.[Storage], + o.[MaxStorageGb], + o.[Gateway], + o.[GatewayCustomerId], + o.[GatewaySubscriptionId], + o.[ReferenceData], + o.[Enabled], + o.[LicenseKey], + o.[PublicKey], + o.[PrivateKey], + o.[TwoFactorProviders], + o.[ExpirationDate], + o.[CreationDate], + o.[RevisionDate], + o.[OwnersNotifiedOfAutoscaling], + o.[MaxAutoscaleSeats], + o.[UseKeyConnector], + o.[UseScim], + o.[UseCustomPermissions], + o.[UseSecretsManager], + o.[Status], + o.[UsePasswordManager], + o.[SmSeats], + o.[SmServiceAccounts], + o.[MaxAutoscaleSmSeats], + o.[MaxAutoscaleSmServiceAccounts], + o.[SecretsManagerBeta], + o.[LimitCollectionCreation], + o.[LimitCollectionDeletion], + o.[LimitItemDeletion], + o.[AllowAdminAccessToAllCollectionItems], + o.[UseRiskInsights], + o.[UseInviteLinks], + o.[UsePam] + FROM [dbo].[OrganizationView] o + INNER JOIN @OrganizationIds ids ON o.[Id] = ids.[Id] +END +GO diff --git a/util/MySqlMigrations/Migrations/20260616102519_AddAccessRuleLeaseConfiguration.Designer.cs b/util/MySqlMigrations/Migrations/20260616102519_AddAccessRuleLeaseConfiguration.Designer.cs new file mode 100644 index 000000000000..68e99b901e8a --- /dev/null +++ b/util/MySqlMigrations/Migrations/20260616102519_AddAccessRuleLeaseConfiguration.Designer.cs @@ -0,0 +1,3839 @@ +// +using System; +using Bit.Infrastructure.EntityFramework.Repositories; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Bit.MySqlMigrations.Migrations +{ + [DbContext(typeof(DatabaseContext))] + [Migration("20260616102519_AddAccessRuleLeaseConfiguration")] + partial class AddAccessRuleLeaseConfiguration + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); + + modelBuilder.Entity("Bit.Core.Dirt.Reports.Models.Data.OrganizationMemberBaseDetail", b => + { + b.Property("CipherId") + .HasColumnType("char(36)"); + + b.Property("CollectionId") + .HasColumnType("char(36)"); + + b.Property("CollectionName") + .HasColumnType("longtext"); + + b.Property("Email") + .HasColumnType("longtext"); + + b.Property("GroupId") + .HasColumnType("char(36)"); + + b.Property("GroupName") + .HasColumnType("longtext"); + + b.Property("HidePasswords") + .HasColumnType("tinyint(1)"); + + b.Property("Manage") + .HasColumnType("tinyint(1)"); + + b.Property("ReadOnly") + .HasColumnType("tinyint(1)"); + + b.Property("ResetPasswordKey") + .HasColumnType("longtext"); + + b.Property("TwoFactorProviders") + .HasColumnType("longtext"); + + b.Property("UserGuid") + .HasColumnType("char(36)"); + + b.Property("UserName") + .HasColumnType("longtext"); + + b.Property("UsesKeyConnector") + .HasColumnType("tinyint(1)"); + + b.ToTable("OrganizationMemberBaseDetails"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Collection", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AccessRuleId") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("DefaultUserCollectionEmail") + .HasColumnType("longtext"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("AccessRuleId"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Collection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.CollectionGroup", b => + { + b.Property("CollectionId") + .HasColumnType("char(36)"); + + b.Property("GroupId") + .HasColumnType("char(36)"); + + b.Property("HidePasswords") + .HasColumnType("tinyint(1)"); + + b.Property("Manage") + .HasColumnType("tinyint(1)"); + + b.Property("ReadOnly") + .HasColumnType("tinyint(1)"); + + b.HasKey("CollectionId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("CollectionGroups"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.CollectionUser", b => + { + b.Property("CollectionId") + .HasColumnType("char(36)"); + + b.Property("OrganizationUserId") + .HasColumnType("char(36)"); + + b.Property("HidePasswords") + .HasColumnType("tinyint(1)"); + + b.Property("Manage") + .HasColumnType("tinyint(1)"); + + b.Property("ReadOnly") + .HasColumnType("tinyint(1)"); + + b.HasKey("CollectionId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AllowAdminAccessToAllCollectionItems") + .HasColumnType("tinyint(1)") + .HasDefaultValue(true); + + b.Property("BillingEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("BusinessAddress1") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("BusinessAddress2") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("BusinessAddress3") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("BusinessCountry") + .HasMaxLength(2) + .HasColumnType("varchar(2)"); + + b.Property("BusinessName") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("BusinessTaxNumber") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property("ExemptFromBillingAutomation") + .HasColumnType("tinyint(1)"); + + b.Property("ExpirationDate") + .HasColumnType("datetime(6)"); + + b.Property("Gateway") + .HasColumnType("tinyint unsigned"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Identifier") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("LimitCollectionCreation") + .HasColumnType("tinyint(1)"); + + b.Property("LimitCollectionDeletion") + .HasColumnType("tinyint(1)"); + + b.Property("LimitItemDeletion") + .HasColumnType("tinyint(1)"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("int"); + + b.Property("MaxAutoscaleSmSeats") + .HasColumnType("int"); + + b.Property("MaxAutoscaleSmServiceAccounts") + .HasColumnType("int"); + + b.Property("MaxCollections") + .HasColumnType("smallint"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("OwnersNotifiedOfAutoscaling") + .HasColumnType("datetime(6)"); + + b.Property("Plan") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("PlanType") + .HasColumnType("tinyint unsigned"); + + b.Property("PrivateKey") + .HasColumnType("longtext"); + + b.Property("PublicKey") + .HasColumnType("longtext"); + + b.Property("ReferenceData") + .HasColumnType("longtext"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Seats") + .HasColumnType("int"); + + b.Property("SelfHost") + .HasColumnType("tinyint(1)"); + + b.Property("SmSeats") + .HasColumnType("int"); + + b.Property("SmServiceAccounts") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("tinyint unsigned"); + + b.Property("Storage") + .HasColumnType("bigint"); + + b.Property("SyncSeats") + .HasColumnType("tinyint(1)"); + + b.Property("TwoFactorProviders") + .HasColumnType("longtext"); + + b.Property("Use2fa") + .HasColumnType("tinyint(1)"); + + b.Property("UseAdminSponsoredFamilies") + .HasColumnType("tinyint(1)"); + + b.Property("UseApi") + .HasColumnType("tinyint(1)"); + + b.Property("UseAutomaticUserConfirmation") + .HasColumnType("tinyint(1)"); + + b.Property("UseCustomPermissions") + .HasColumnType("tinyint(1)"); + + b.Property("UseDirectory") + .HasColumnType("tinyint(1)"); + + b.Property("UseDisableSmAdsForUsers") + .HasColumnType("tinyint(1)"); + + b.Property("UseEvents") + .HasColumnType("tinyint(1)"); + + b.Property("UseGroups") + .HasColumnType("tinyint(1)"); + + b.Property("UseInviteLinks") + .HasColumnType("tinyint(1)"); + + b.Property("UseKeyConnector") + .HasColumnType("tinyint(1)"); + + b.Property("UseMyItems") + .HasColumnType("tinyint(1)"); + + b.Property("UseOrganizationDomains") + .HasColumnType("tinyint(1)"); + + b.Property("UsePasswordManager") + .HasColumnType("tinyint(1)"); + + b.Property("UsePhishingBlocker") + .HasColumnType("tinyint(1)"); + + b.Property("UsePolicies") + .HasColumnType("tinyint(1)"); + + b.Property("UseResetPassword") + .HasColumnType("tinyint(1)"); + + b.Property("UseRiskInsights") + .HasColumnType("tinyint(1)"); + + b.Property("UseScim") + .HasColumnType("tinyint(1)"); + + b.Property("UseSecretsManager") + .HasColumnType("tinyint(1)"); + + b.Property("UseSso") + .HasColumnType("tinyint(1)"); + + b.Property("UseTotp") + .HasColumnType("tinyint(1)"); + + b.Property("UsersGetPremium") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + b.HasIndex("GatewayCustomerId"); + + b.HasIndex("GatewaySubscriptionId"); + + b.HasIndex("Id", "Enabled") + .HasAnnotation("Npgsql:IndexInclude", new[] { "UseTotp", "UsersGetPremium" }); + + b.ToTable("Organization", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationInviteLink", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AllowedDomains") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Code") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("EncryptedInviteKey") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("EncryptedOrgKey") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationInviteLink", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Data") + .HasColumnType("longtext"); + + b.Property("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "Type") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Policy", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("BillingEmail") + .HasColumnType("longtext"); + + b.Property("BillingPhone") + .HasColumnType("longtext"); + + b.Property("BusinessAddress1") + .HasColumnType("longtext"); + + b.Property("BusinessAddress2") + .HasColumnType("longtext"); + + b.Property("BusinessAddress3") + .HasColumnType("longtext"); + + b.Property("BusinessCountry") + .HasColumnType("longtext"); + + b.Property("BusinessName") + .HasColumnType("longtext"); + + b.Property("BusinessTaxNumber") + .HasColumnType("longtext"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("DiscountId") + .HasColumnType("longtext"); + + b.Property("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property("Gateway") + .HasColumnType("tinyint unsigned"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Name") + .HasColumnType("longtext"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("tinyint unsigned"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UseEvents") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + b.HasIndex("GatewayCustomerId"); + + b.HasIndex("GatewaySubscriptionId"); + + b.ToTable("Provider", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Settings") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ProviderId"); + + b.ToTable("ProviderOrganization", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderUser", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Email") + .HasColumnType("longtext"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("Permissions") + .HasColumnType("longtext"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("tinyint unsigned"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.HasIndex("UserId"); + + b.ToTable("ProviderUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.AuthRequest", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AccessCode") + .HasMaxLength(25) + .HasColumnType("varchar(25)"); + + b.Property("Approved") + .HasColumnType("tinyint(1)"); + + b.Property("AuthenticationDate") + .HasColumnType("datetime(6)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("MasterPasswordHash") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("PublicKey") + .HasColumnType("longtext"); + + b.Property("RequestCountryName") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("RequestDeviceIdentifier") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("RequestDeviceType") + .HasColumnType("tinyint unsigned"); + + b.Property("RequestIpAddress") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("ResponseDate") + .HasColumnType("datetime(6)"); + + b.Property("ResponseDeviceId") + .HasColumnType("char(36)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ResponseDeviceId"); + + b.HasIndex("UserId"); + + b.ToTable("AuthRequest", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("GranteeId") + .HasColumnType("char(36)"); + + b.Property("GrantorId") + .HasColumnType("char(36)"); + + b.Property("KeyEncrypted") + .HasColumnType("longtext"); + + b.Property("LastNotificationDate") + .HasColumnType("datetime(6)"); + + b.Property("RecoveryInitiatedDate") + .HasColumnType("datetime(6)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("tinyint unsigned"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("WaitTimeDays") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("GranteeId"); + + b.HasIndex("GrantorId"); + + b.ToTable("EmergencyAccess", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.Grant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ClientId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("ConsumedDate") + .HasColumnType("datetime(6)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Data") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Description") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("ExpirationDate") + .HasColumnType("datetime(6)"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("SessionId") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("SubjectId") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.HasKey("Id") + .HasName("PK_Grant") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpirationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("Grant", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Data") + .HasColumnType("longtext"); + + b.Property("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("SsoConfig", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId"); + + b.HasIndex("OrganizationId", "ExternalId") + .IsUnique() + .HasAnnotation("Npgsql:IndexInclude", new[] { "UserId" }) + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "UserId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SsoUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AaGuid") + .HasColumnType("char(36)"); + + b.Property("Counter") + .HasColumnType("int"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("CredentialId") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("EncryptedPrivateKey") + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("EncryptedPublicKey") + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("EncryptedUserKey") + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("PublicKey") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("SupportsPrf") + .HasColumnType("tinyint(1)"); + + b.Property("Type") + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("WebAuthnCredential", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ClientOrganizationMigrationRecord", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ExpirationDate") + .HasColumnType("datetime(6)"); + + b.Property("GatewayCustomerId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("GatewaySubscriptionId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("int"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("PlanType") + .HasColumnType("tinyint unsigned"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("Seats") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("tinyint unsigned"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId", "OrganizationId") + .IsUnique(); + + b.ToTable("ClientOrganizationMigrationRecord", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("InstallationId") + .HasColumnType("char(36)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("InstallationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationInstallation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationPlanMigrationCohort", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ChurnDiscountCouponCode") + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("MigrationPathId") + .HasColumnType("tinyint unsigned"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("ProactiveDiscountCouponCode") + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("Name") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationPlanMigrationCohort", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationPlanMigrationCohortAssignment", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ChurnDiscountAppliedDate") + .HasColumnType("datetime(6)"); + + b.Property("CohortId") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("MigratedDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("ScheduledDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("CohortId", "ScheduledDate", "MigratedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationPlanMigrationCohortAssignment", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AssignedSeats") + .HasColumnType("int"); + + b.Property("ClientId") + .HasColumnType("char(36)"); + + b.Property("ClientName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Created") + .HasColumnType("datetime(6)"); + + b.Property("InvoiceId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("InvoiceNumber") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("PlanName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("Total") + .HasColumnType("decimal(65,30)"); + + b.Property("UsedSeats") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.ToTable("ProviderInvoiceItem", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AllocatedSeats") + .HasColumnType("int"); + + b.Property("PlanType") + .HasColumnType("tinyint unsigned"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("PurchasedSeats") + .HasColumnType("int"); + + b.Property("SeatMinimum") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.HasIndex("Id", "PlanType") + .IsUnique(); + + b.ToTable("ProviderPlan", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.SubscriptionDiscount", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AmountOff") + .HasColumnType("bigint"); + + b.Property("AudienceType") + .HasColumnType("int"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Currency") + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("Duration") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("DurationInMonths") + .HasColumnType("int"); + + b.Property("EndDate") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("PercentOff") + .HasPrecision(5, 2) + .HasColumnType("decimal(5,2)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("StartDate") + .HasColumnType("datetime(6)"); + + b.Property("StripeCouponId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("StripeProductIds") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("StripeCouponId") + .IsUnique(); + + b.HasIndex("StartDate", "EndDate") + .HasDatabaseName("IX_SubscriptionDiscount_DateRange") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SubscriptionDiscount", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationApplication", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Applications") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("ContentEncryptionKey") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationApplication", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegration", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Configuration") + .HasColumnType("longtext"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "Type") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationIntegration", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegrationConfiguration", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Configuration") + .HasColumnType("longtext"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("EventType") + .HasColumnType("int"); + + b.Property("Filters") + .HasColumnType("longtext"); + + b.Property("OrganizationIntegrationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Template") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationIntegrationId"); + + b.ToTable("OrganizationIntegrationConfiguration", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ApplicationAtRiskCount") + .HasColumnType("int"); + + b.Property("ApplicationCount") + .HasColumnType("int"); + + b.Property("ApplicationData") + .HasColumnType("longtext"); + + b.Property("ContentEncryptionKey") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("CriticalApplicationAtRiskCount") + .HasColumnType("int"); + + b.Property("CriticalApplicationCount") + .HasColumnType("int"); + + b.Property("CriticalMemberAtRiskCount") + .HasColumnType("int"); + + b.Property("CriticalMemberCount") + .HasColumnType("int"); + + b.Property("CriticalPasswordAtRiskCount") + .HasColumnType("int"); + + b.Property("CriticalPasswordCount") + .HasColumnType("int"); + + b.Property("MemberAtRiskCount") + .HasColumnType("int"); + + b.Property("MemberCount") + .HasColumnType("int"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("PasswordAtRiskCount") + .HasColumnType("int"); + + b.Property("PasswordCount") + .HasColumnType("int"); + + b.Property("ReportData") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("ReportFile") + .HasColumnType("longtext"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("SummaryData") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationReport", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.PasswordHealthReportApplication", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Uri") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("PasswordHealthReportApplication", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Cache", b => + { + b.Property("Id") + .HasMaxLength(449) + .HasColumnType("varchar(449)"); + + b.Property("AbsoluteExpiration") + .HasColumnType("datetime(6)"); + + b.Property("ExpiresAtTime") + .HasColumnType("datetime(6)"); + + b.Property("SlidingExpirationInSeconds") + .HasColumnType("bigint"); + + b.Property("Value") + .IsRequired() + .HasColumnType("longblob"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpiresAtTime") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Cache", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.Property("CollectionId") + .HasColumnType("char(36)"); + + b.Property("CipherId") + .HasColumnType("char(36)"); + + b.HasKey("CollectionId", "CipherId"); + + b.HasIndex("CipherId"); + + b.ToTable("CollectionCipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Active") + .HasColumnType("tinyint(1)") + .HasDefaultValue(true); + + b.Property("ClientVersion") + .HasMaxLength(43) + .HasColumnType("varchar(43)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("EncryptedPrivateKey") + .HasColumnType("longtext"); + + b.Property("EncryptedPublicKey") + .HasColumnType("longtext"); + + b.Property("EncryptedUserKey") + .HasColumnType("longtext"); + + b.Property("Identifier") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("LastActivityDate") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("PushToken") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("Identifier") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "Identifier") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Device", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Event", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ActingUserId") + .HasColumnType("char(36)"); + + b.Property("CipherId") + .HasColumnType("char(36)"); + + b.Property("CollectionId") + .HasColumnType("char(36)"); + + b.Property("Date") + .HasColumnType("datetime(6)"); + + b.Property("DeviceType") + .HasColumnType("tinyint unsigned"); + + b.Property("DomainName") + .HasColumnType("longtext"); + + b.Property("GrantedServiceAccountId") + .HasColumnType("char(36)"); + + b.Property("GroupId") + .HasColumnType("char(36)"); + + b.Property("InstallationId") + .HasColumnType("char(36)"); + + b.Property("IpAddress") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("OrganizationUserId") + .HasColumnType("char(36)"); + + b.Property("PolicyId") + .HasColumnType("char(36)"); + + b.Property("ProjectId") + .HasColumnType("char(36)"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("ProviderOrganizationId") + .HasColumnType("char(36)"); + + b.Property("ProviderUserId") + .HasColumnType("char(36)"); + + b.Property("SecretId") + .HasColumnType("char(36)"); + + b.Property("ServiceAccountId") + .HasColumnType("char(36)"); + + b.Property("SystemUser") + .HasColumnType("tinyint unsigned"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("Date", "OrganizationId", "ActingUserId", "CipherId") + .HasDatabaseName("IX_Event_DateOrganizationIdUserId") + .HasAnnotation("SqlServer:Clustered", false) + .HasAnnotation("SqlServer:Include", new[] { "ServiceAccountId", "GrantedServiceAccountId" }); + + b.ToTable("Event", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Group", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.Property("GroupId") + .HasColumnType("char(36)"); + + b.Property("OrganizationUserId") + .HasColumnType("char(36)"); + + b.HasKey("GroupId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("GroupUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Config") + .HasColumnType("longtext"); + + b.Property("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationConnection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("DomainName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("JobRunCount") + .HasColumnType("int"); + + b.Property("LastCheckedDate") + .HasColumnType("datetime(6)"); + + b.Property("NextRunDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Txt") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("VerifiedDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationDomain", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("FriendlyName") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("IsAdminInitiated") + .HasColumnType("tinyint(1)"); + + b.Property("LastSyncDate") + .HasColumnType("datetime(6)"); + + b.Property("Notes") + .HasColumnType("longtext"); + + b.Property("OfferedToEmail") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("PlanSponsorshipType") + .HasColumnType("tinyint unsigned"); + + b.Property("SponsoredOrganizationId") + .HasColumnType("char(36)"); + + b.Property("SponsoringOrganizationId") + .HasColumnType("char(36)"); + + b.Property("SponsoringOrganizationUserId") + .HasColumnType("char(36)"); + + b.Property("ToDelete") + .HasColumnType("tinyint(1)"); + + b.Property("ValidUntil") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("SponsoredOrganizationId"); + + b.HasIndex("SponsoringOrganizationId"); + + b.HasIndex("SponsoringOrganizationUserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationSponsorship", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AccessSecretsManager") + .HasColumnType("tinyint(1)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Permissions") + .HasColumnType("longtext"); + + b.Property("ResetPasswordKey") + .HasColumnType("longtext"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("RevocationReason") + .HasColumnType("tinyint unsigned"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("StatusNew") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.PlayItem", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("PlayId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("PlayId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("PlayItem", null, t => + { + t.HasCheckConstraint("CK_PlayItem_UserOrOrganization", "(\"UserId\" IS NOT NULL AND \"OrganizationId\" IS NULL) OR (\"UserId\" IS NULL AND \"OrganizationId\" IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AccessCount") + .HasColumnType("int"); + + b.Property("AuthType") + .HasColumnType("tinyint unsigned"); + + b.Property("CipherId") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Data") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("DeletionDate") + .HasColumnType("datetime(6)"); + + b.Property("Disabled") + .HasColumnType("tinyint(1)"); + + b.Property("Emails") + .HasMaxLength(4000) + .HasColumnType("varchar(4000)"); + + b.Property("ExpirationDate") + .HasColumnType("datetime(6)"); + + b.Property("HideEmail") + .HasColumnType("tinyint(1)"); + + b.Property("Key") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("MaxAccessCount") + .HasColumnType("int"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Password") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("DeletionDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Send", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.TaxRate", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("Active") + .HasColumnType("tinyint(1)"); + + b.Property("Country") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("PostalCode") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("Rate") + .HasColumnType("decimal(65,30)"); + + b.Property("State") + .HasMaxLength(2) + .HasColumnType("varchar(2)"); + + b.HasKey("Id"); + + b.ToTable("TaxRate", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Amount") + .HasColumnType("decimal(65,30)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Details") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Gateway") + .HasColumnType("tinyint unsigned"); + + b.Property("GatewayId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("PaymentMethodType") + .HasColumnType("tinyint unsigned"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("Refunded") + .HasColumnType("tinyint(1)"); + + b.Property("RefundedAmount") + .HasColumnType("decimal(65,30)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ProviderId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId", "CreationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Transaction", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AccountRevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("AvatarColor") + .HasMaxLength(7) + .HasColumnType("varchar(7)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Culture") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("EmailVerified") + .HasColumnType("tinyint(1)"); + + b.Property("EquivalentDomains") + .HasColumnType("longtext"); + + b.Property("ExcludedGlobalEquivalentDomains") + .HasColumnType("longtext"); + + b.Property("FailedLoginCount") + .HasColumnType("int"); + + b.Property("ForcePasswordReset") + .HasColumnType("tinyint(1)"); + + b.Property("Gateway") + .HasColumnType("tinyint unsigned"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Kdf") + .HasColumnType("tinyint unsigned"); + + b.Property("KdfIterations") + .HasColumnType("int"); + + b.Property("KdfMemory") + .HasColumnType("int"); + + b.Property("KdfParallelism") + .HasColumnType("int"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("LastApiKeyRotationDate") + .HasColumnType("datetime(6)"); + + b.Property("LastEmailChangeDate") + .HasColumnType("datetime(6)"); + + b.Property("LastFailedLoginDate") + .HasColumnType("datetime(6)"); + + b.Property("LastKdfChangeDate") + .HasColumnType("datetime(6)"); + + b.Property("LastKeyRotationDate") + .HasColumnType("datetime(6)"); + + b.Property("LastPasswordChangeDate") + .HasColumnType("datetime(6)"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("MasterPassword") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("MasterPasswordHint") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("MasterPasswordSalt") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Premium") + .HasColumnType("tinyint(1)"); + + b.Property("PremiumExpirationDate") + .HasColumnType("datetime(6)"); + + b.Property("PrivateKey") + .HasColumnType("longtext"); + + b.Property("PublicKey") + .HasColumnType("longtext"); + + b.Property("ReferenceData") + .HasColumnType("longtext"); + + b.Property("RenewalReminderDate") + .HasColumnType("datetime(6)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("SecurityStamp") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("SecurityState") + .HasColumnType("longtext"); + + b.Property("SecurityVersion") + .HasColumnType("int"); + + b.Property("SignedPublicKey") + .HasColumnType("longtext"); + + b.Property("Storage") + .HasColumnType("bigint"); + + b.Property("TwoFactorProviders") + .HasColumnType("longtext"); + + b.Property("TwoFactorRecoveryCode") + .HasMaxLength(32) + .HasColumnType("varchar(32)"); + + b.Property("UsesKeyConnector") + .HasColumnType("tinyint(1)"); + + b.Property("V2UpgradeToken") + .HasColumnType("longtext"); + + b.Property("VerifyDevices") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("GatewayCustomerId"); + + b.HasIndex("GatewaySubscriptionId"); + + b.HasIndex("Premium", "PremiumExpirationDate", "RenewalReminderDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("User", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.UserSignatureKeyPair", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("SignatureAlgorithm") + .HasColumnType("tinyint unsigned"); + + b.Property("SigningKey") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.Property("VerifyingKey") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("UserSignatureKeyPair", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Body") + .HasMaxLength(3000) + .HasColumnType("varchar(3000)"); + + b.Property("ClientType") + .HasColumnType("tinyint unsigned"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Global") + .HasColumnType("tinyint(1)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Priority") + .HasColumnType("tinyint unsigned"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("TaskId") + .HasColumnType("char(36)"); + + b.Property("Title") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("TaskId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("ClientType", "Global", "UserId", "OrganizationId", "Priority", "CreationDate") + .IsDescending(false, false, false, false, true, true) + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Notification", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.Property("UserId") + .HasColumnType("char(36)"); + + b.Property("NotificationId") + .HasColumnType("char(36)"); + + b.Property("DeletedDate") + .HasColumnType("datetime(6)"); + + b.Property("ReadDate") + .HasColumnType("datetime(6)"); + + b.HasKey("UserId", "NotificationId") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("NotificationId"); + + b.ToTable("NotificationStatus", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Pam.Models.AccessRule", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AllowsExtensions") + .HasColumnType("tinyint(1)"); + + b.Property("Conditions") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("DefaultLeaseDurationSeconds") + .HasColumnType("int"); + + b.Property("Description") + .HasColumnType("longtext"); + + b.Property("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property("MaxExtensionDurationSeconds") + .HasColumnType("int"); + + b.Property("MaxLeaseDurationSeconds") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("SingleActiveLease") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId", "Name") + .IsUnique(); + + b.ToTable("AccessRule", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Platform.Installation", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("varchar(150)"); + + b.Property("LastActivityDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.ToTable("Installation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Discriminator") + .IsRequired() + .HasMaxLength(34) + .HasColumnType("varchar(34)"); + + b.Property("Read") + .HasColumnType("tinyint(1)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Write") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.ToTable("AccessPolicy", (string)null); + + b.HasDiscriminator().HasValue("AccessPolicy"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ClientSecretHash") + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("EncryptedPayload") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("varchar(4000)"); + + b.Property("ExpireAt") + .HasColumnType("datetime(6)"); + + b.Property("Key") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("varchar(4000)"); + + b.Property("ServiceAccountId") + .HasColumnType("char(36)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ServiceAccountId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("DeletedDate") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Project", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("DeletedDate") + .HasColumnType("datetime(6)"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("Note") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Value") + .HasColumnType("longtext"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Secret", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.SecretVersion", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("EditorOrganizationUserId") + .HasColumnType("char(36)"); + + b.Property("EditorServiceAccountId") + .HasColumnType("char(36)"); + + b.Property("SecretId") + .HasColumnType("char(36)"); + + b.Property("Value") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("VersionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("EditorOrganizationUserId") + .HasDatabaseName("IX_SecretVersion_EditorOrganizationUserId"); + + b.HasIndex("EditorServiceAccountId") + .HasDatabaseName("IX_SecretVersion_EditorServiceAccountId"); + + b.HasIndex("SecretId") + .HasDatabaseName("IX_SecretVersion_SecretId"); + + b.ToTable("SecretVersion"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ServiceAccount", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Archives") + .HasColumnType("longtext"); + + b.Property("Attachments") + .HasColumnType("longtext"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Data") + .HasColumnType("longtext"); + + b.Property("DeletedDate") + .HasColumnType("datetime(6)"); + + b.Property("Favorites") + .HasColumnType("longtext"); + + b.Property("Folders") + .HasColumnType("longtext"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Reprompt") + .HasColumnType("tinyint unsigned"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId"); + + b.ToTable("Cipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .HasColumnType("longtext"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Folder", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CipherId") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("tinyint unsigned"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("CipherId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SecurityTask", (string)null); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.Property("ProjectsId") + .HasColumnType("char(36)"); + + b.Property("SecretsId") + .HasColumnType("char(36)"); + + b.HasKey("ProjectsId", "SecretsId"); + + b.HasIndex("SecretsId"); + + b.ToTable("ProjectSecret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedProjectId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedSecretId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedProjectId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedSecretId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedProjectId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedSecretId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Collection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Pam.Models.AccessRule", null) + .WithMany() + .HasForeignKey("AccessRuleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Collections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.CollectionGroup", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Collection", "Collection") + .WithMany("CollectionGroups") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.CollectionUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Collection", "Collection") + .WithMany("CollectionUsers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("CollectionUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationInviteLink", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Policies") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.AuthRequest", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Device", "ResponseDevice") + .WithMany() + .HasForeignKey("ResponseDeviceId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("ResponseDevice"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantee") + .WithMany() + .HasForeignKey("GranteeId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantor") + .WithMany() + .HasForeignKey("GrantorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Grantee"); + + b.Navigation("Grantor"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoConfigs") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoUsers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("SsoUsers") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Platform.Installation", "Installation") + .WithMany() + .HasForeignKey("InstallationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Installation"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationPlanMigrationCohortAssignment", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationPlanMigrationCohort", "Cohort") + .WithMany() + .HasForeignKey("CohortId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cohort"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationApplication", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegrationConfiguration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegration", "OrganizationIntegration") + .WithMany() + .HasForeignKey("OrganizationIntegrationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("OrganizationIntegration"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.PasswordHealthReportApplication", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany("CollectionCiphers") + .HasForeignKey("CipherId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Collection", "Collection") + .WithMany("CollectionCiphers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Collection"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Groups") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany("GroupUsers") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("GroupUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("ApiKeys") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Connections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Domains") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoredOrganization") + .WithMany() + .HasForeignKey("SponsoredOrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoringOrganization") + .WithMany() + .HasForeignKey("SponsoringOrganizationId"); + + b.Navigation("SponsoredOrganization"); + + b.Navigation("SponsoringOrganization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("OrganizationUsers") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("OrganizationUsers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.PlayItem", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Transactions") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Transactions") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.UserSignatureKeyPair", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", "Task") + .WithMany() + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Task"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", "Notification") + .WithMany() + .HasForeignKey("NotificationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Notification"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Pam.Models.AccessRule", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ApiKeys") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.SecretVersion", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "EditorOrganizationUser") + .WithMany() + .HasForeignKey("EditorOrganizationUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "EditorServiceAccount") + .WithMany() + .HasForeignKey("EditorServiceAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "Secret") + .WithMany("SecretVersions") + .HasForeignKey("SecretId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("EditorOrganizationUser"); + + b.Navigation("EditorServiceAccount"); + + b.Navigation("Secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Ciphers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Ciphers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Folders") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany() + .HasForeignKey("CipherId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", null) + .WithMany() + .HasForeignKey("ProjectsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", null) + .WithMany() + .HasForeignKey("SecretsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedProject"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedSecret"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ProjectAccessPolicies") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedProject"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany() + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedProject"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Collection", b => + { + b.Navigation("CollectionCiphers"); + + b.Navigation("CollectionGroups"); + + b.Navigation("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("Ciphers"); + + b.Navigation("Collections"); + + b.Navigation("Connections"); + + b.Navigation("Domains"); + + b.Navigation("Groups"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("Policies"); + + b.Navigation("SsoConfigs"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Navigation("CollectionUsers"); + + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Navigation("Ciphers"); + + b.Navigation("Folders"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("SecretVersions"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ProjectAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Navigation("CollectionCiphers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/util/MySqlMigrations/Migrations/20260616102519_AddAccessRuleLeaseConfiguration.cs b/util/MySqlMigrations/Migrations/20260616102519_AddAccessRuleLeaseConfiguration.cs new file mode 100644 index 000000000000..e7f8ba13dc04 --- /dev/null +++ b/util/MySqlMigrations/Migrations/20260616102519_AddAccessRuleLeaseConfiguration.cs @@ -0,0 +1,80 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bit.MySqlMigrations.Migrations; + +/// +public partial class AddAccessRuleLeaseConfiguration : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "AllowsExtensions", + table: "AccessRule", + type: "tinyint(1)", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "DefaultLeaseDurationSeconds", + table: "AccessRule", + type: "int", + nullable: true); + + migrationBuilder.AddColumn( + name: "Enabled", + table: "AccessRule", + type: "tinyint(1)", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "MaxExtensionDurationSeconds", + table: "AccessRule", + type: "int", + nullable: true); + + migrationBuilder.AddColumn( + name: "MaxLeaseDurationSeconds", + table: "AccessRule", + type: "int", + nullable: true); + + migrationBuilder.AddColumn( + name: "SingleActiveLease", + table: "AccessRule", + type: "tinyint(1)", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "AllowsExtensions", + table: "AccessRule"); + + migrationBuilder.DropColumn( + name: "DefaultLeaseDurationSeconds", + table: "AccessRule"); + + migrationBuilder.DropColumn( + name: "Enabled", + table: "AccessRule"); + + migrationBuilder.DropColumn( + name: "MaxExtensionDurationSeconds", + table: "AccessRule"); + + migrationBuilder.DropColumn( + name: "MaxLeaseDurationSeconds", + table: "AccessRule"); + + migrationBuilder.DropColumn( + name: "SingleActiveLease", + table: "AccessRule"); + } +} diff --git a/util/MySqlMigrations/Migrations/20260616102606_AddUsePamToOrganization.Designer.cs b/util/MySqlMigrations/Migrations/20260616102606_AddUsePamToOrganization.Designer.cs new file mode 100644 index 000000000000..4e33c65d3d6d --- /dev/null +++ b/util/MySqlMigrations/Migrations/20260616102606_AddUsePamToOrganization.Designer.cs @@ -0,0 +1,3842 @@ +// +using System; +using Bit.Infrastructure.EntityFramework.Repositories; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Bit.MySqlMigrations.Migrations +{ + [DbContext(typeof(DatabaseContext))] + [Migration("20260616102606_AddUsePamToOrganization")] + partial class AddUsePamToOrganization + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); + + modelBuilder.Entity("Bit.Core.Dirt.Reports.Models.Data.OrganizationMemberBaseDetail", b => + { + b.Property("CipherId") + .HasColumnType("char(36)"); + + b.Property("CollectionId") + .HasColumnType("char(36)"); + + b.Property("CollectionName") + .HasColumnType("longtext"); + + b.Property("Email") + .HasColumnType("longtext"); + + b.Property("GroupId") + .HasColumnType("char(36)"); + + b.Property("GroupName") + .HasColumnType("longtext"); + + b.Property("HidePasswords") + .HasColumnType("tinyint(1)"); + + b.Property("Manage") + .HasColumnType("tinyint(1)"); + + b.Property("ReadOnly") + .HasColumnType("tinyint(1)"); + + b.Property("ResetPasswordKey") + .HasColumnType("longtext"); + + b.Property("TwoFactorProviders") + .HasColumnType("longtext"); + + b.Property("UserGuid") + .HasColumnType("char(36)"); + + b.Property("UserName") + .HasColumnType("longtext"); + + b.Property("UsesKeyConnector") + .HasColumnType("tinyint(1)"); + + b.ToTable("OrganizationMemberBaseDetails"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Collection", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AccessRuleId") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("DefaultUserCollectionEmail") + .HasColumnType("longtext"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("AccessRuleId"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Collection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.CollectionGroup", b => + { + b.Property("CollectionId") + .HasColumnType("char(36)"); + + b.Property("GroupId") + .HasColumnType("char(36)"); + + b.Property("HidePasswords") + .HasColumnType("tinyint(1)"); + + b.Property("Manage") + .HasColumnType("tinyint(1)"); + + b.Property("ReadOnly") + .HasColumnType("tinyint(1)"); + + b.HasKey("CollectionId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("CollectionGroups"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.CollectionUser", b => + { + b.Property("CollectionId") + .HasColumnType("char(36)"); + + b.Property("OrganizationUserId") + .HasColumnType("char(36)"); + + b.Property("HidePasswords") + .HasColumnType("tinyint(1)"); + + b.Property("Manage") + .HasColumnType("tinyint(1)"); + + b.Property("ReadOnly") + .HasColumnType("tinyint(1)"); + + b.HasKey("CollectionId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AllowAdminAccessToAllCollectionItems") + .HasColumnType("tinyint(1)") + .HasDefaultValue(true); + + b.Property("BillingEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("BusinessAddress1") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("BusinessAddress2") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("BusinessAddress3") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("BusinessCountry") + .HasMaxLength(2) + .HasColumnType("varchar(2)"); + + b.Property("BusinessName") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("BusinessTaxNumber") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property("ExemptFromBillingAutomation") + .HasColumnType("tinyint(1)"); + + b.Property("ExpirationDate") + .HasColumnType("datetime(6)"); + + b.Property("Gateway") + .HasColumnType("tinyint unsigned"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Identifier") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("LimitCollectionCreation") + .HasColumnType("tinyint(1)"); + + b.Property("LimitCollectionDeletion") + .HasColumnType("tinyint(1)"); + + b.Property("LimitItemDeletion") + .HasColumnType("tinyint(1)"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("int"); + + b.Property("MaxAutoscaleSmSeats") + .HasColumnType("int"); + + b.Property("MaxAutoscaleSmServiceAccounts") + .HasColumnType("int"); + + b.Property("MaxCollections") + .HasColumnType("smallint"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("OwnersNotifiedOfAutoscaling") + .HasColumnType("datetime(6)"); + + b.Property("Plan") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("PlanType") + .HasColumnType("tinyint unsigned"); + + b.Property("PrivateKey") + .HasColumnType("longtext"); + + b.Property("PublicKey") + .HasColumnType("longtext"); + + b.Property("ReferenceData") + .HasColumnType("longtext"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Seats") + .HasColumnType("int"); + + b.Property("SelfHost") + .HasColumnType("tinyint(1)"); + + b.Property("SmSeats") + .HasColumnType("int"); + + b.Property("SmServiceAccounts") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("tinyint unsigned"); + + b.Property("Storage") + .HasColumnType("bigint"); + + b.Property("SyncSeats") + .HasColumnType("tinyint(1)"); + + b.Property("TwoFactorProviders") + .HasColumnType("longtext"); + + b.Property("Use2fa") + .HasColumnType("tinyint(1)"); + + b.Property("UseAdminSponsoredFamilies") + .HasColumnType("tinyint(1)"); + + b.Property("UseApi") + .HasColumnType("tinyint(1)"); + + b.Property("UseAutomaticUserConfirmation") + .HasColumnType("tinyint(1)"); + + b.Property("UseCustomPermissions") + .HasColumnType("tinyint(1)"); + + b.Property("UseDirectory") + .HasColumnType("tinyint(1)"); + + b.Property("UseDisableSmAdsForUsers") + .HasColumnType("tinyint(1)"); + + b.Property("UseEvents") + .HasColumnType("tinyint(1)"); + + b.Property("UseGroups") + .HasColumnType("tinyint(1)"); + + b.Property("UseInviteLinks") + .HasColumnType("tinyint(1)"); + + b.Property("UseKeyConnector") + .HasColumnType("tinyint(1)"); + + b.Property("UseMyItems") + .HasColumnType("tinyint(1)"); + + b.Property("UseOrganizationDomains") + .HasColumnType("tinyint(1)"); + + b.Property("UsePam") + .HasColumnType("tinyint(1)"); + + b.Property("UsePasswordManager") + .HasColumnType("tinyint(1)"); + + b.Property("UsePhishingBlocker") + .HasColumnType("tinyint(1)"); + + b.Property("UsePolicies") + .HasColumnType("tinyint(1)"); + + b.Property("UseResetPassword") + .HasColumnType("tinyint(1)"); + + b.Property("UseRiskInsights") + .HasColumnType("tinyint(1)"); + + b.Property("UseScim") + .HasColumnType("tinyint(1)"); + + b.Property("UseSecretsManager") + .HasColumnType("tinyint(1)"); + + b.Property("UseSso") + .HasColumnType("tinyint(1)"); + + b.Property("UseTotp") + .HasColumnType("tinyint(1)"); + + b.Property("UsersGetPremium") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + b.HasIndex("GatewayCustomerId"); + + b.HasIndex("GatewaySubscriptionId"); + + b.HasIndex("Id", "Enabled") + .HasAnnotation("Npgsql:IndexInclude", new[] { "UseTotp", "UsersGetPremium" }); + + b.ToTable("Organization", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationInviteLink", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AllowedDomains") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Code") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("EncryptedInviteKey") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("EncryptedOrgKey") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationInviteLink", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Data") + .HasColumnType("longtext"); + + b.Property("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "Type") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Policy", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("BillingEmail") + .HasColumnType("longtext"); + + b.Property("BillingPhone") + .HasColumnType("longtext"); + + b.Property("BusinessAddress1") + .HasColumnType("longtext"); + + b.Property("BusinessAddress2") + .HasColumnType("longtext"); + + b.Property("BusinessAddress3") + .HasColumnType("longtext"); + + b.Property("BusinessCountry") + .HasColumnType("longtext"); + + b.Property("BusinessName") + .HasColumnType("longtext"); + + b.Property("BusinessTaxNumber") + .HasColumnType("longtext"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("DiscountId") + .HasColumnType("longtext"); + + b.Property("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property("Gateway") + .HasColumnType("tinyint unsigned"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Name") + .HasColumnType("longtext"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("tinyint unsigned"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UseEvents") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + b.HasIndex("GatewayCustomerId"); + + b.HasIndex("GatewaySubscriptionId"); + + b.ToTable("Provider", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Settings") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ProviderId"); + + b.ToTable("ProviderOrganization", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderUser", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Email") + .HasColumnType("longtext"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("Permissions") + .HasColumnType("longtext"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("tinyint unsigned"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.HasIndex("UserId"); + + b.ToTable("ProviderUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.AuthRequest", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AccessCode") + .HasMaxLength(25) + .HasColumnType("varchar(25)"); + + b.Property("Approved") + .HasColumnType("tinyint(1)"); + + b.Property("AuthenticationDate") + .HasColumnType("datetime(6)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("MasterPasswordHash") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("PublicKey") + .HasColumnType("longtext"); + + b.Property("RequestCountryName") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("RequestDeviceIdentifier") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("RequestDeviceType") + .HasColumnType("tinyint unsigned"); + + b.Property("RequestIpAddress") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("ResponseDate") + .HasColumnType("datetime(6)"); + + b.Property("ResponseDeviceId") + .HasColumnType("char(36)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ResponseDeviceId"); + + b.HasIndex("UserId"); + + b.ToTable("AuthRequest", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("GranteeId") + .HasColumnType("char(36)"); + + b.Property("GrantorId") + .HasColumnType("char(36)"); + + b.Property("KeyEncrypted") + .HasColumnType("longtext"); + + b.Property("LastNotificationDate") + .HasColumnType("datetime(6)"); + + b.Property("RecoveryInitiatedDate") + .HasColumnType("datetime(6)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("tinyint unsigned"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("WaitTimeDays") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("GranteeId"); + + b.HasIndex("GrantorId"); + + b.ToTable("EmergencyAccess", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.Grant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ClientId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("ConsumedDate") + .HasColumnType("datetime(6)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Data") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Description") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("ExpirationDate") + .HasColumnType("datetime(6)"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("SessionId") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("SubjectId") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.HasKey("Id") + .HasName("PK_Grant") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpirationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("Grant", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Data") + .HasColumnType("longtext"); + + b.Property("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("SsoConfig", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId"); + + b.HasIndex("OrganizationId", "ExternalId") + .IsUnique() + .HasAnnotation("Npgsql:IndexInclude", new[] { "UserId" }) + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "UserId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SsoUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AaGuid") + .HasColumnType("char(36)"); + + b.Property("Counter") + .HasColumnType("int"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("CredentialId") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("EncryptedPrivateKey") + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("EncryptedPublicKey") + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("EncryptedUserKey") + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("PublicKey") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("SupportsPrf") + .HasColumnType("tinyint(1)"); + + b.Property("Type") + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("WebAuthnCredential", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ClientOrganizationMigrationRecord", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ExpirationDate") + .HasColumnType("datetime(6)"); + + b.Property("GatewayCustomerId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("GatewaySubscriptionId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("int"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("PlanType") + .HasColumnType("tinyint unsigned"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("Seats") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("tinyint unsigned"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId", "OrganizationId") + .IsUnique(); + + b.ToTable("ClientOrganizationMigrationRecord", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("InstallationId") + .HasColumnType("char(36)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("InstallationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationInstallation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationPlanMigrationCohort", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ChurnDiscountCouponCode") + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("MigrationPathId") + .HasColumnType("tinyint unsigned"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("ProactiveDiscountCouponCode") + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("Name") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationPlanMigrationCohort", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationPlanMigrationCohortAssignment", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ChurnDiscountAppliedDate") + .HasColumnType("datetime(6)"); + + b.Property("CohortId") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("MigratedDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("ScheduledDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("CohortId", "ScheduledDate", "MigratedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationPlanMigrationCohortAssignment", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AssignedSeats") + .HasColumnType("int"); + + b.Property("ClientId") + .HasColumnType("char(36)"); + + b.Property("ClientName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Created") + .HasColumnType("datetime(6)"); + + b.Property("InvoiceId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("InvoiceNumber") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("PlanName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("Total") + .HasColumnType("decimal(65,30)"); + + b.Property("UsedSeats") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.ToTable("ProviderInvoiceItem", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AllocatedSeats") + .HasColumnType("int"); + + b.Property("PlanType") + .HasColumnType("tinyint unsigned"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("PurchasedSeats") + .HasColumnType("int"); + + b.Property("SeatMinimum") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.HasIndex("Id", "PlanType") + .IsUnique(); + + b.ToTable("ProviderPlan", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.SubscriptionDiscount", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AmountOff") + .HasColumnType("bigint"); + + b.Property("AudienceType") + .HasColumnType("int"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Currency") + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("Duration") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("DurationInMonths") + .HasColumnType("int"); + + b.Property("EndDate") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("PercentOff") + .HasPrecision(5, 2) + .HasColumnType("decimal(5,2)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("StartDate") + .HasColumnType("datetime(6)"); + + b.Property("StripeCouponId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("StripeProductIds") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("StripeCouponId") + .IsUnique(); + + b.HasIndex("StartDate", "EndDate") + .HasDatabaseName("IX_SubscriptionDiscount_DateRange") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SubscriptionDiscount", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationApplication", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Applications") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("ContentEncryptionKey") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationApplication", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegration", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Configuration") + .HasColumnType("longtext"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "Type") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationIntegration", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegrationConfiguration", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Configuration") + .HasColumnType("longtext"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("EventType") + .HasColumnType("int"); + + b.Property("Filters") + .HasColumnType("longtext"); + + b.Property("OrganizationIntegrationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Template") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationIntegrationId"); + + b.ToTable("OrganizationIntegrationConfiguration", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ApplicationAtRiskCount") + .HasColumnType("int"); + + b.Property("ApplicationCount") + .HasColumnType("int"); + + b.Property("ApplicationData") + .HasColumnType("longtext"); + + b.Property("ContentEncryptionKey") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("CriticalApplicationAtRiskCount") + .HasColumnType("int"); + + b.Property("CriticalApplicationCount") + .HasColumnType("int"); + + b.Property("CriticalMemberAtRiskCount") + .HasColumnType("int"); + + b.Property("CriticalMemberCount") + .HasColumnType("int"); + + b.Property("CriticalPasswordAtRiskCount") + .HasColumnType("int"); + + b.Property("CriticalPasswordCount") + .HasColumnType("int"); + + b.Property("MemberAtRiskCount") + .HasColumnType("int"); + + b.Property("MemberCount") + .HasColumnType("int"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("PasswordAtRiskCount") + .HasColumnType("int"); + + b.Property("PasswordCount") + .HasColumnType("int"); + + b.Property("ReportData") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("ReportFile") + .HasColumnType("longtext"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("SummaryData") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationReport", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.PasswordHealthReportApplication", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Uri") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("PasswordHealthReportApplication", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Cache", b => + { + b.Property("Id") + .HasMaxLength(449) + .HasColumnType("varchar(449)"); + + b.Property("AbsoluteExpiration") + .HasColumnType("datetime(6)"); + + b.Property("ExpiresAtTime") + .HasColumnType("datetime(6)"); + + b.Property("SlidingExpirationInSeconds") + .HasColumnType("bigint"); + + b.Property("Value") + .IsRequired() + .HasColumnType("longblob"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpiresAtTime") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Cache", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.Property("CollectionId") + .HasColumnType("char(36)"); + + b.Property("CipherId") + .HasColumnType("char(36)"); + + b.HasKey("CollectionId", "CipherId"); + + b.HasIndex("CipherId"); + + b.ToTable("CollectionCipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Active") + .HasColumnType("tinyint(1)") + .HasDefaultValue(true); + + b.Property("ClientVersion") + .HasMaxLength(43) + .HasColumnType("varchar(43)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("EncryptedPrivateKey") + .HasColumnType("longtext"); + + b.Property("EncryptedPublicKey") + .HasColumnType("longtext"); + + b.Property("EncryptedUserKey") + .HasColumnType("longtext"); + + b.Property("Identifier") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("LastActivityDate") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("PushToken") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("Identifier") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "Identifier") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Device", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Event", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ActingUserId") + .HasColumnType("char(36)"); + + b.Property("CipherId") + .HasColumnType("char(36)"); + + b.Property("CollectionId") + .HasColumnType("char(36)"); + + b.Property("Date") + .HasColumnType("datetime(6)"); + + b.Property("DeviceType") + .HasColumnType("tinyint unsigned"); + + b.Property("DomainName") + .HasColumnType("longtext"); + + b.Property("GrantedServiceAccountId") + .HasColumnType("char(36)"); + + b.Property("GroupId") + .HasColumnType("char(36)"); + + b.Property("InstallationId") + .HasColumnType("char(36)"); + + b.Property("IpAddress") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("OrganizationUserId") + .HasColumnType("char(36)"); + + b.Property("PolicyId") + .HasColumnType("char(36)"); + + b.Property("ProjectId") + .HasColumnType("char(36)"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("ProviderOrganizationId") + .HasColumnType("char(36)"); + + b.Property("ProviderUserId") + .HasColumnType("char(36)"); + + b.Property("SecretId") + .HasColumnType("char(36)"); + + b.Property("ServiceAccountId") + .HasColumnType("char(36)"); + + b.Property("SystemUser") + .HasColumnType("tinyint unsigned"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("Date", "OrganizationId", "ActingUserId", "CipherId") + .HasDatabaseName("IX_Event_DateOrganizationIdUserId") + .HasAnnotation("SqlServer:Clustered", false) + .HasAnnotation("SqlServer:Include", new[] { "ServiceAccountId", "GrantedServiceAccountId" }); + + b.ToTable("Event", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Group", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.Property("GroupId") + .HasColumnType("char(36)"); + + b.Property("OrganizationUserId") + .HasColumnType("char(36)"); + + b.HasKey("GroupId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("GroupUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Config") + .HasColumnType("longtext"); + + b.Property("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationConnection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("DomainName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("JobRunCount") + .HasColumnType("int"); + + b.Property("LastCheckedDate") + .HasColumnType("datetime(6)"); + + b.Property("NextRunDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Txt") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("VerifiedDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationDomain", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("FriendlyName") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("IsAdminInitiated") + .HasColumnType("tinyint(1)"); + + b.Property("LastSyncDate") + .HasColumnType("datetime(6)"); + + b.Property("Notes") + .HasColumnType("longtext"); + + b.Property("OfferedToEmail") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("PlanSponsorshipType") + .HasColumnType("tinyint unsigned"); + + b.Property("SponsoredOrganizationId") + .HasColumnType("char(36)"); + + b.Property("SponsoringOrganizationId") + .HasColumnType("char(36)"); + + b.Property("SponsoringOrganizationUserId") + .HasColumnType("char(36)"); + + b.Property("ToDelete") + .HasColumnType("tinyint(1)"); + + b.Property("ValidUntil") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("SponsoredOrganizationId"); + + b.HasIndex("SponsoringOrganizationId"); + + b.HasIndex("SponsoringOrganizationUserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationSponsorship", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AccessSecretsManager") + .HasColumnType("tinyint(1)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Permissions") + .HasColumnType("longtext"); + + b.Property("ResetPasswordKey") + .HasColumnType("longtext"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("RevocationReason") + .HasColumnType("tinyint unsigned"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("StatusNew") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.PlayItem", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("PlayId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("PlayId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("PlayItem", null, t => + { + t.HasCheckConstraint("CK_PlayItem_UserOrOrganization", "(\"UserId\" IS NOT NULL AND \"OrganizationId\" IS NULL) OR (\"UserId\" IS NULL AND \"OrganizationId\" IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AccessCount") + .HasColumnType("int"); + + b.Property("AuthType") + .HasColumnType("tinyint unsigned"); + + b.Property("CipherId") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Data") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("DeletionDate") + .HasColumnType("datetime(6)"); + + b.Property("Disabled") + .HasColumnType("tinyint(1)"); + + b.Property("Emails") + .HasMaxLength(4000) + .HasColumnType("varchar(4000)"); + + b.Property("ExpirationDate") + .HasColumnType("datetime(6)"); + + b.Property("HideEmail") + .HasColumnType("tinyint(1)"); + + b.Property("Key") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("MaxAccessCount") + .HasColumnType("int"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Password") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("DeletionDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Send", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.TaxRate", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("Active") + .HasColumnType("tinyint(1)"); + + b.Property("Country") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("PostalCode") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("Rate") + .HasColumnType("decimal(65,30)"); + + b.Property("State") + .HasMaxLength(2) + .HasColumnType("varchar(2)"); + + b.HasKey("Id"); + + b.ToTable("TaxRate", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Amount") + .HasColumnType("decimal(65,30)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Details") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Gateway") + .HasColumnType("tinyint unsigned"); + + b.Property("GatewayId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("PaymentMethodType") + .HasColumnType("tinyint unsigned"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("Refunded") + .HasColumnType("tinyint(1)"); + + b.Property("RefundedAmount") + .HasColumnType("decimal(65,30)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ProviderId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId", "CreationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Transaction", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AccountRevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("AvatarColor") + .HasMaxLength(7) + .HasColumnType("varchar(7)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Culture") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("EmailVerified") + .HasColumnType("tinyint(1)"); + + b.Property("EquivalentDomains") + .HasColumnType("longtext"); + + b.Property("ExcludedGlobalEquivalentDomains") + .HasColumnType("longtext"); + + b.Property("FailedLoginCount") + .HasColumnType("int"); + + b.Property("ForcePasswordReset") + .HasColumnType("tinyint(1)"); + + b.Property("Gateway") + .HasColumnType("tinyint unsigned"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Kdf") + .HasColumnType("tinyint unsigned"); + + b.Property("KdfIterations") + .HasColumnType("int"); + + b.Property("KdfMemory") + .HasColumnType("int"); + + b.Property("KdfParallelism") + .HasColumnType("int"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("LastApiKeyRotationDate") + .HasColumnType("datetime(6)"); + + b.Property("LastEmailChangeDate") + .HasColumnType("datetime(6)"); + + b.Property("LastFailedLoginDate") + .HasColumnType("datetime(6)"); + + b.Property("LastKdfChangeDate") + .HasColumnType("datetime(6)"); + + b.Property("LastKeyRotationDate") + .HasColumnType("datetime(6)"); + + b.Property("LastPasswordChangeDate") + .HasColumnType("datetime(6)"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("MasterPassword") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("MasterPasswordHint") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("MasterPasswordSalt") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Premium") + .HasColumnType("tinyint(1)"); + + b.Property("PremiumExpirationDate") + .HasColumnType("datetime(6)"); + + b.Property("PrivateKey") + .HasColumnType("longtext"); + + b.Property("PublicKey") + .HasColumnType("longtext"); + + b.Property("ReferenceData") + .HasColumnType("longtext"); + + b.Property("RenewalReminderDate") + .HasColumnType("datetime(6)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("SecurityStamp") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("SecurityState") + .HasColumnType("longtext"); + + b.Property("SecurityVersion") + .HasColumnType("int"); + + b.Property("SignedPublicKey") + .HasColumnType("longtext"); + + b.Property("Storage") + .HasColumnType("bigint"); + + b.Property("TwoFactorProviders") + .HasColumnType("longtext"); + + b.Property("TwoFactorRecoveryCode") + .HasMaxLength(32) + .HasColumnType("varchar(32)"); + + b.Property("UsesKeyConnector") + .HasColumnType("tinyint(1)"); + + b.Property("V2UpgradeToken") + .HasColumnType("longtext"); + + b.Property("VerifyDevices") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("GatewayCustomerId"); + + b.HasIndex("GatewaySubscriptionId"); + + b.HasIndex("Premium", "PremiumExpirationDate", "RenewalReminderDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("User", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.UserSignatureKeyPair", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("SignatureAlgorithm") + .HasColumnType("tinyint unsigned"); + + b.Property("SigningKey") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.Property("VerifyingKey") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("UserSignatureKeyPair", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Body") + .HasMaxLength(3000) + .HasColumnType("varchar(3000)"); + + b.Property("ClientType") + .HasColumnType("tinyint unsigned"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Global") + .HasColumnType("tinyint(1)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Priority") + .HasColumnType("tinyint unsigned"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("TaskId") + .HasColumnType("char(36)"); + + b.Property("Title") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("TaskId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("ClientType", "Global", "UserId", "OrganizationId", "Priority", "CreationDate") + .IsDescending(false, false, false, false, true, true) + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Notification", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.Property("UserId") + .HasColumnType("char(36)"); + + b.Property("NotificationId") + .HasColumnType("char(36)"); + + b.Property("DeletedDate") + .HasColumnType("datetime(6)"); + + b.Property("ReadDate") + .HasColumnType("datetime(6)"); + + b.HasKey("UserId", "NotificationId") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("NotificationId"); + + b.ToTable("NotificationStatus", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Pam.Models.AccessRule", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AllowsExtensions") + .HasColumnType("tinyint(1)"); + + b.Property("Conditions") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("DefaultLeaseDurationSeconds") + .HasColumnType("int"); + + b.Property("Description") + .HasColumnType("longtext"); + + b.Property("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property("MaxExtensionDurationSeconds") + .HasColumnType("int"); + + b.Property("MaxLeaseDurationSeconds") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("SingleActiveLease") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId", "Name") + .IsUnique(); + + b.ToTable("AccessRule", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Platform.Installation", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("varchar(150)"); + + b.Property("LastActivityDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.ToTable("Installation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Discriminator") + .IsRequired() + .HasMaxLength(34) + .HasColumnType("varchar(34)"); + + b.Property("Read") + .HasColumnType("tinyint(1)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Write") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.ToTable("AccessPolicy", (string)null); + + b.HasDiscriminator().HasValue("AccessPolicy"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ClientSecretHash") + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("EncryptedPayload") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("varchar(4000)"); + + b.Property("ExpireAt") + .HasColumnType("datetime(6)"); + + b.Property("Key") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("varchar(4000)"); + + b.Property("ServiceAccountId") + .HasColumnType("char(36)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ServiceAccountId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("DeletedDate") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Project", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("DeletedDate") + .HasColumnType("datetime(6)"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("Note") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Value") + .HasColumnType("longtext"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Secret", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.SecretVersion", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("EditorOrganizationUserId") + .HasColumnType("char(36)"); + + b.Property("EditorServiceAccountId") + .HasColumnType("char(36)"); + + b.Property("SecretId") + .HasColumnType("char(36)"); + + b.Property("Value") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("VersionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("EditorOrganizationUserId") + .HasDatabaseName("IX_SecretVersion_EditorOrganizationUserId"); + + b.HasIndex("EditorServiceAccountId") + .HasDatabaseName("IX_SecretVersion_EditorServiceAccountId"); + + b.HasIndex("SecretId") + .HasDatabaseName("IX_SecretVersion_SecretId"); + + b.ToTable("SecretVersion"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ServiceAccount", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Archives") + .HasColumnType("longtext"); + + b.Property("Attachments") + .HasColumnType("longtext"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Data") + .HasColumnType("longtext"); + + b.Property("DeletedDate") + .HasColumnType("datetime(6)"); + + b.Property("Favorites") + .HasColumnType("longtext"); + + b.Property("Folders") + .HasColumnType("longtext"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Reprompt") + .HasColumnType("tinyint unsigned"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId"); + + b.ToTable("Cipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .HasColumnType("longtext"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Folder", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CipherId") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("tinyint unsigned"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("CipherId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SecurityTask", (string)null); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.Property("ProjectsId") + .HasColumnType("char(36)"); + + b.Property("SecretsId") + .HasColumnType("char(36)"); + + b.HasKey("ProjectsId", "SecretsId"); + + b.HasIndex("SecretsId"); + + b.ToTable("ProjectSecret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedProjectId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedSecretId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedProjectId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedSecretId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedProjectId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedSecretId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Collection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Pam.Models.AccessRule", null) + .WithMany() + .HasForeignKey("AccessRuleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Collections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.CollectionGroup", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Collection", "Collection") + .WithMany("CollectionGroups") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.CollectionUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Collection", "Collection") + .WithMany("CollectionUsers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("CollectionUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationInviteLink", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Policies") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.AuthRequest", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Device", "ResponseDevice") + .WithMany() + .HasForeignKey("ResponseDeviceId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("ResponseDevice"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantee") + .WithMany() + .HasForeignKey("GranteeId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantor") + .WithMany() + .HasForeignKey("GrantorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Grantee"); + + b.Navigation("Grantor"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoConfigs") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoUsers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("SsoUsers") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Platform.Installation", "Installation") + .WithMany() + .HasForeignKey("InstallationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Installation"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationPlanMigrationCohortAssignment", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationPlanMigrationCohort", "Cohort") + .WithMany() + .HasForeignKey("CohortId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cohort"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationApplication", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegrationConfiguration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegration", "OrganizationIntegration") + .WithMany() + .HasForeignKey("OrganizationIntegrationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("OrganizationIntegration"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.PasswordHealthReportApplication", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany("CollectionCiphers") + .HasForeignKey("CipherId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Collection", "Collection") + .WithMany("CollectionCiphers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Collection"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Groups") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany("GroupUsers") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("GroupUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("ApiKeys") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Connections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Domains") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoredOrganization") + .WithMany() + .HasForeignKey("SponsoredOrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoringOrganization") + .WithMany() + .HasForeignKey("SponsoringOrganizationId"); + + b.Navigation("SponsoredOrganization"); + + b.Navigation("SponsoringOrganization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("OrganizationUsers") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("OrganizationUsers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.PlayItem", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Transactions") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Transactions") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.UserSignatureKeyPair", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", "Task") + .WithMany() + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Task"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", "Notification") + .WithMany() + .HasForeignKey("NotificationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Notification"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Pam.Models.AccessRule", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ApiKeys") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.SecretVersion", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "EditorOrganizationUser") + .WithMany() + .HasForeignKey("EditorOrganizationUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "EditorServiceAccount") + .WithMany() + .HasForeignKey("EditorServiceAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "Secret") + .WithMany("SecretVersions") + .HasForeignKey("SecretId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("EditorOrganizationUser"); + + b.Navigation("EditorServiceAccount"); + + b.Navigation("Secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Ciphers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Ciphers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Folders") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany() + .HasForeignKey("CipherId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", null) + .WithMany() + .HasForeignKey("ProjectsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", null) + .WithMany() + .HasForeignKey("SecretsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedProject"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedSecret"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ProjectAccessPolicies") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedProject"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany() + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedProject"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Collection", b => + { + b.Navigation("CollectionCiphers"); + + b.Navigation("CollectionGroups"); + + b.Navigation("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("Ciphers"); + + b.Navigation("Collections"); + + b.Navigation("Connections"); + + b.Navigation("Domains"); + + b.Navigation("Groups"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("Policies"); + + b.Navigation("SsoConfigs"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Navigation("CollectionUsers"); + + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Navigation("Ciphers"); + + b.Navigation("Folders"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("SecretVersions"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ProjectAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Navigation("CollectionCiphers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/util/MySqlMigrations/Migrations/20260616102606_AddUsePamToOrganization.cs b/util/MySqlMigrations/Migrations/20260616102606_AddUsePamToOrganization.cs new file mode 100644 index 000000000000..73919eea00e1 --- /dev/null +++ b/util/MySqlMigrations/Migrations/20260616102606_AddUsePamToOrganization.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bit.MySqlMigrations.Migrations; + +/// +public partial class AddUsePamToOrganization : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "UsePam", + table: "Organization", + type: "tinyint(1)", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "UsePam", + table: "Organization"); + } +} diff --git a/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs b/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs index 45dafefe458f..edbef12d6303 100644 --- a/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs +++ b/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs @@ -341,6 +341,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("UseOrganizationDomains") .HasColumnType("tinyint(1)"); + b.Property("UsePam") + .HasColumnType("tinyint(1)"); + b.Property("UsePasswordManager") .HasColumnType("tinyint(1)"); @@ -2345,36 +2348,59 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("NotificationStatus", (string)null); }); - modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Platform.Installation", b => + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Pam.Models.AccessRule", b => { b.Property("Id") .HasColumnType("char(36)"); + b.Property("AllowsExtensions") + .HasColumnType("tinyint(1)"); + + b.Property("Conditions") + .IsRequired() + .HasColumnType("longtext"); + b.Property("CreationDate") .HasColumnType("datetime(6)"); - b.Property("Email") - .IsRequired() - .HasMaxLength(256) - .HasColumnType("varchar(256)"); + b.Property("DefaultLeaseDurationSeconds") + .HasColumnType("int"); + + b.Property("Description") + .HasColumnType("longtext"); b.Property("Enabled") .HasColumnType("tinyint(1)"); - b.Property("Key") + b.Property("MaxExtensionDurationSeconds") + .HasColumnType("int"); + + b.Property("MaxLeaseDurationSeconds") + .HasColumnType("int"); + + b.Property("Name") .IsRequired() - .HasMaxLength(150) - .HasColumnType("varchar(150)"); + .HasMaxLength(256) + .HasColumnType("varchar(256)"); - b.Property("LastActivityDate") + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") .HasColumnType("datetime(6)"); + b.Property("SingleActiveLease") + .HasColumnType("tinyint(1)"); + b.HasKey("Id"); - b.ToTable("Installation", (string)null); + b.HasIndex("OrganizationId", "Name") + .IsUnique(); + + b.ToTable("AccessRule", (string)null); }); - modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Pam.Models.AccessRule", b => + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Platform.Installation", b => { b.Property("Id") .HasColumnType("char(36)"); @@ -2382,30 +2408,25 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("CreationDate") .HasColumnType("datetime(6)"); - b.Property("Description") - .HasColumnType("longtext"); - - b.Property("Name") + b.Property("Email") .IsRequired() .HasMaxLength(256) .HasColumnType("varchar(256)"); - b.Property("OrganizationId") - .HasColumnType("char(36)"); - - b.Property("RevisionDate") - .HasColumnType("datetime(6)"); + b.Property("Enabled") + .HasColumnType("tinyint(1)"); - b.Property("Conditions") + b.Property("Key") .IsRequired() - .HasColumnType("longtext"); + .HasMaxLength(150) + .HasColumnType("varchar(150)"); - b.HasKey("Id"); + b.Property("LastActivityDate") + .HasColumnType("datetime(6)"); - b.HasIndex("OrganizationId", "Name") - .IsUnique(); + b.HasKey("Id"); - b.ToTable("AccessRule", (string)null); + b.ToTable("Installation", (string)null); }); modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy", b => diff --git a/util/PostgresMigrations/Migrations/20260616102515_AddAccessRuleLeaseConfiguration.Designer.cs b/util/PostgresMigrations/Migrations/20260616102515_AddAccessRuleLeaseConfiguration.Designer.cs new file mode 100644 index 000000000000..5d9601633d14 --- /dev/null +++ b/util/PostgresMigrations/Migrations/20260616102515_AddAccessRuleLeaseConfiguration.Designer.cs @@ -0,0 +1,3845 @@ +// +using System; +using Bit.Infrastructure.EntityFramework.Repositories; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Bit.PostgresMigrations.Migrations +{ + [DbContext(typeof(DatabaseContext))] + [Migration("20260616102515_AddAccessRuleLeaseConfiguration")] + partial class AddAccessRuleLeaseConfiguration + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("Npgsql:CollationDefinition:postgresIndetermanisticCollation", "en-u-ks-primary,en-u-ks-primary,icu,False") + .HasAnnotation("ProductVersion", "8.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Bit.Core.Dirt.Reports.Models.Data.OrganizationMemberBaseDetail", b => + { + b.Property("CipherId") + .HasColumnType("uuid"); + + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("CollectionName") + .HasColumnType("text"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("GroupName") + .HasColumnType("text"); + + b.Property("HidePasswords") + .HasColumnType("boolean"); + + b.Property("Manage") + .HasColumnType("boolean"); + + b.Property("ReadOnly") + .HasColumnType("boolean"); + + b.Property("ResetPasswordKey") + .HasColumnType("text"); + + b.Property("TwoFactorProviders") + .HasColumnType("text"); + + b.Property("UserGuid") + .HasColumnType("uuid"); + + b.Property("UserName") + .HasColumnType("text"); + + b.Property("UsesKeyConnector") + .HasColumnType("boolean"); + + b.ToTable("OrganizationMemberBaseDetails"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Collection", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccessRuleId") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DefaultUserCollectionEmail") + .HasColumnType("text"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("AccessRuleId"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Collection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.CollectionGroup", b => + { + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("HidePasswords") + .HasColumnType("boolean"); + + b.Property("Manage") + .HasColumnType("boolean"); + + b.Property("ReadOnly") + .HasColumnType("boolean"); + + b.HasKey("CollectionId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("CollectionGroups"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.CollectionUser", b => + { + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("OrganizationUserId") + .HasColumnType("uuid"); + + b.Property("HidePasswords") + .HasColumnType("boolean"); + + b.Property("Manage") + .HasColumnType("boolean"); + + b.Property("ReadOnly") + .HasColumnType("boolean"); + + b.HasKey("CollectionId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AllowAdminAccessToAllCollectionItems") + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("BillingEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("BusinessAddress1") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("BusinessAddress2") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("BusinessAddress3") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("BusinessCountry") + .HasMaxLength(2) + .HasColumnType("character varying(2)"); + + b.Property("BusinessName") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("BusinessTaxNumber") + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("ExemptFromBillingAutomation") + .HasColumnType("boolean"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Gateway") + .HasColumnType("smallint"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Identifier") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .UseCollation("postgresIndetermanisticCollation"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("LimitCollectionCreation") + .HasColumnType("boolean"); + + b.Property("LimitCollectionDeletion") + .HasColumnType("boolean"); + + b.Property("LimitItemDeletion") + .HasColumnType("boolean"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("integer"); + + b.Property("MaxAutoscaleSmSeats") + .HasColumnType("integer"); + + b.Property("MaxAutoscaleSmServiceAccounts") + .HasColumnType("integer"); + + b.Property("MaxCollections") + .HasColumnType("smallint"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OwnersNotifiedOfAutoscaling") + .HasColumnType("timestamp with time zone"); + + b.Property("Plan") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PlanType") + .HasColumnType("smallint"); + + b.Property("PrivateKey") + .HasColumnType("text"); + + b.Property("PublicKey") + .HasColumnType("text"); + + b.Property("ReferenceData") + .HasColumnType("text"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Seats") + .HasColumnType("integer"); + + b.Property("SelfHost") + .HasColumnType("boolean"); + + b.Property("SmSeats") + .HasColumnType("integer"); + + b.Property("SmServiceAccounts") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Storage") + .HasColumnType("bigint"); + + b.Property("SyncSeats") + .HasColumnType("boolean"); + + b.Property("TwoFactorProviders") + .HasColumnType("text"); + + b.Property("Use2fa") + .HasColumnType("boolean"); + + b.Property("UseAdminSponsoredFamilies") + .HasColumnType("boolean"); + + b.Property("UseApi") + .HasColumnType("boolean"); + + b.Property("UseAutomaticUserConfirmation") + .HasColumnType("boolean"); + + b.Property("UseCustomPermissions") + .HasColumnType("boolean"); + + b.Property("UseDirectory") + .HasColumnType("boolean"); + + b.Property("UseDisableSmAdsForUsers") + .HasColumnType("boolean"); + + b.Property("UseEvents") + .HasColumnType("boolean"); + + b.Property("UseGroups") + .HasColumnType("boolean"); + + b.Property("UseInviteLinks") + .HasColumnType("boolean"); + + b.Property("UseKeyConnector") + .HasColumnType("boolean"); + + b.Property("UseMyItems") + .HasColumnType("boolean"); + + b.Property("UseOrganizationDomains") + .HasColumnType("boolean"); + + b.Property("UsePasswordManager") + .HasColumnType("boolean"); + + b.Property("UsePhishingBlocker") + .HasColumnType("boolean"); + + b.Property("UsePolicies") + .HasColumnType("boolean"); + + b.Property("UseResetPassword") + .HasColumnType("boolean"); + + b.Property("UseRiskInsights") + .HasColumnType("boolean"); + + b.Property("UseScim") + .HasColumnType("boolean"); + + b.Property("UseSecretsManager") + .HasColumnType("boolean"); + + b.Property("UseSso") + .HasColumnType("boolean"); + + b.Property("UseTotp") + .HasColumnType("boolean"); + + b.Property("UsersGetPremium") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("GatewayCustomerId"); + + b.HasIndex("GatewaySubscriptionId"); + + b.HasIndex("Id", "Enabled"); + + NpgsqlIndexBuilderExtensions.IncludeProperties(b.HasIndex("Id", "Enabled"), new[] { "UseTotp", "UsersGetPremium" }); + + b.ToTable("Organization", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationInviteLink", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AllowedDomains") + .IsRequired() + .HasColumnType("text"); + + b.Property("Code") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedInviteKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("EncryptedOrgKey") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationInviteLink", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "Type") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Policy", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BillingEmail") + .HasColumnType("text"); + + b.Property("BillingPhone") + .HasColumnType("text"); + + b.Property("BusinessAddress1") + .HasColumnType("text"); + + b.Property("BusinessAddress2") + .HasColumnType("text"); + + b.Property("BusinessAddress3") + .HasColumnType("text"); + + b.Property("BusinessCountry") + .HasColumnType("text"); + + b.Property("BusinessName") + .HasColumnType("text"); + + b.Property("BusinessTaxNumber") + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DiscountId") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("Gateway") + .HasColumnType("smallint"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UseEvents") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("GatewayCustomerId"); + + b.HasIndex("GatewaySubscriptionId"); + + b.ToTable("Provider", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Settings") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ProviderId"); + + b.ToTable("ProviderOrganization", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderUser", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("Permissions") + .HasColumnType("text"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.HasIndex("UserId"); + + b.ToTable("ProviderUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.AuthRequest", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccessCode") + .HasMaxLength(25) + .HasColumnType("character varying(25)"); + + b.Property("Approved") + .HasColumnType("boolean"); + + b.Property("AuthenticationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("MasterPasswordHash") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("PublicKey") + .HasColumnType("text"); + + b.Property("RequestCountryName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RequestDeviceIdentifier") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("RequestDeviceType") + .HasColumnType("smallint"); + + b.Property("RequestIpAddress") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ResponseDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ResponseDeviceId") + .HasColumnType("uuid"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ResponseDeviceId"); + + b.HasIndex("UserId"); + + b.ToTable("AuthRequest", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("GranteeId") + .HasColumnType("uuid"); + + b.Property("GrantorId") + .HasColumnType("uuid"); + + b.Property("KeyEncrypted") + .HasColumnType("text"); + + b.Property("LastNotificationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("RecoveryInitiatedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("WaitTimeDays") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("GranteeId"); + + b.HasIndex("GrantorId"); + + b.ToTable("EmergencyAccess", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.Grant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClientId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ConsumedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .IsRequired() + .HasColumnType("text"); + + b.Property("Description") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SessionId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SubjectId") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id") + .HasName("PK_Grant") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpirationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("Grant", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("SsoConfig", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .UseCollation("postgresIndetermanisticCollation"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId"); + + b.HasIndex("OrganizationId", "ExternalId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + NpgsqlIndexBuilderExtensions.IncludeProperties(b.HasIndex("OrganizationId", "ExternalId"), new[] { "UserId" }); + + b.HasIndex("OrganizationId", "UserId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SsoUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AaGuid") + .HasColumnType("uuid"); + + b.Property("Counter") + .HasColumnType("integer"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CredentialId") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EncryptedPrivateKey") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("EncryptedPublicKey") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("EncryptedUserKey") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PublicKey") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("SupportsPrf") + .HasColumnType("boolean"); + + b.Property("Type") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("WebAuthnCredential", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ClientOrganizationMigrationRecord", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("GatewayCustomerId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("GatewaySubscriptionId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("integer"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("PlanType") + .HasColumnType("smallint"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("Seats") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId", "OrganizationId") + .IsUnique(); + + b.ToTable("ClientOrganizationMigrationRecord", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("InstallationId") + .HasColumnType("uuid"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("InstallationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationInstallation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationPlanMigrationCohort", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChurnDiscountCouponCode") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("MigrationPathId") + .HasColumnType("smallint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ProactiveDiscountCouponCode") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("Name") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationPlanMigrationCohort", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationPlanMigrationCohortAssignment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChurnDiscountAppliedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CohortId") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("MigratedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ScheduledDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("CohortId", "ScheduledDate", "MigratedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationPlanMigrationCohortAssignment", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AssignedSeats") + .HasColumnType("integer"); + + b.Property("ClientId") + .HasColumnType("uuid"); + + b.Property("ClientName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("InvoiceId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("InvoiceNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PlanName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("Total") + .HasColumnType("numeric"); + + b.Property("UsedSeats") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.ToTable("ProviderInvoiceItem", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AllocatedSeats") + .HasColumnType("integer"); + + b.Property("PlanType") + .HasColumnType("smallint"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("PurchasedSeats") + .HasColumnType("integer"); + + b.Property("SeatMinimum") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.HasIndex("Id", "PlanType") + .IsUnique(); + + b.ToTable("ProviderPlan", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.SubscriptionDiscount", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AmountOff") + .HasColumnType("bigint"); + + b.Property("AudienceType") + .HasColumnType("integer"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Currency") + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("Duration") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("DurationInMonths") + .HasColumnType("integer"); + + b.Property("EndDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PercentOff") + .HasPrecision(5, 2) + .HasColumnType("numeric(5,2)"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("StartDate") + .HasColumnType("timestamp with time zone"); + + b.Property("StripeCouponId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("StripeProductIds") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("StripeCouponId") + .IsUnique(); + + b.HasIndex("StartDate", "EndDate") + .HasDatabaseName("IX_SubscriptionDiscount_DateRange") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SubscriptionDiscount", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationApplication", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Applications") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentEncryptionKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationApplication", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegration", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Configuration") + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "Type") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationIntegration", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegrationConfiguration", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Configuration") + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("EventType") + .HasColumnType("integer"); + + b.Property("Filters") + .HasColumnType("text"); + + b.Property("OrganizationIntegrationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Template") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationIntegrationId"); + + b.ToTable("OrganizationIntegrationConfiguration", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationAtRiskCount") + .HasColumnType("integer"); + + b.Property("ApplicationCount") + .HasColumnType("integer"); + + b.Property("ApplicationData") + .HasColumnType("text"); + + b.Property("ContentEncryptionKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CriticalApplicationAtRiskCount") + .HasColumnType("integer"); + + b.Property("CriticalApplicationCount") + .HasColumnType("integer"); + + b.Property("CriticalMemberAtRiskCount") + .HasColumnType("integer"); + + b.Property("CriticalMemberCount") + .HasColumnType("integer"); + + b.Property("CriticalPasswordAtRiskCount") + .HasColumnType("integer"); + + b.Property("CriticalPasswordCount") + .HasColumnType("integer"); + + b.Property("MemberAtRiskCount") + .HasColumnType("integer"); + + b.Property("MemberCount") + .HasColumnType("integer"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("PasswordAtRiskCount") + .HasColumnType("integer"); + + b.Property("PasswordCount") + .HasColumnType("integer"); + + b.Property("ReportData") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReportFile") + .HasColumnType("text"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("SummaryData") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationReport", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.PasswordHealthReportApplication", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Uri") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("PasswordHealthReportApplication", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Cache", b => + { + b.Property("Id") + .HasMaxLength(449) + .HasColumnType("character varying(449)"); + + b.Property("AbsoluteExpiration") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SlidingExpirationInSeconds") + .HasColumnType("bigint"); + + b.Property("Value") + .IsRequired() + .HasColumnType("bytea"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpiresAtTime") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Cache", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("CipherId") + .HasColumnType("uuid"); + + b.HasKey("CollectionId", "CipherId"); + + b.HasIndex("CipherId"); + + b.ToTable("CollectionCipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Active") + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("ClientVersion") + .HasMaxLength(43) + .HasColumnType("character varying(43)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedPrivateKey") + .HasColumnType("text"); + + b.Property("EncryptedPublicKey") + .HasColumnType("text"); + + b.Property("EncryptedUserKey") + .HasColumnType("text"); + + b.Property("Identifier") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("LastActivityDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PushToken") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Identifier") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "Identifier") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Device", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Event", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ActingUserId") + .HasColumnType("uuid"); + + b.Property("CipherId") + .HasColumnType("uuid"); + + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("Date") + .HasColumnType("timestamp with time zone"); + + b.Property("DeviceType") + .HasColumnType("smallint"); + + b.Property("DomainName") + .HasColumnType("text"); + + b.Property("GrantedServiceAccountId") + .HasColumnType("uuid"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("InstallationId") + .HasColumnType("uuid"); + + b.Property("IpAddress") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("OrganizationUserId") + .HasColumnType("uuid"); + + b.Property("PolicyId") + .HasColumnType("uuid"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("ProviderOrganizationId") + .HasColumnType("uuid"); + + b.Property("ProviderUserId") + .HasColumnType("uuid"); + + b.Property("SecretId") + .HasColumnType("uuid"); + + b.Property("ServiceAccountId") + .HasColumnType("uuid"); + + b.Property("SystemUser") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("Date", "OrganizationId", "ActingUserId", "CipherId") + .HasDatabaseName("IX_Event_DateOrganizationIdUserId") + .HasAnnotation("SqlServer:Clustered", false) + .HasAnnotation("SqlServer:Include", new[] { "ServiceAccountId", "GrantedServiceAccountId" }); + + b.ToTable("Event", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Group", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("OrganizationUserId") + .HasColumnType("uuid"); + + b.HasKey("GroupId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("GroupUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Config") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationConnection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DomainName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("JobRunCount") + .HasColumnType("integer"); + + b.Property("LastCheckedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("NextRunDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Txt") + .IsRequired() + .HasColumnType("text"); + + b.Property("VerifiedDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationDomain", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("FriendlyName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("IsAdminInitiated") + .HasColumnType("boolean"); + + b.Property("LastSyncDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("OfferedToEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PlanSponsorshipType") + .HasColumnType("smallint"); + + b.Property("SponsoredOrganizationId") + .HasColumnType("uuid"); + + b.Property("SponsoringOrganizationId") + .HasColumnType("uuid"); + + b.Property("SponsoringOrganizationUserId") + .HasColumnType("uuid"); + + b.Property("ToDelete") + .HasColumnType("boolean"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("SponsoredOrganizationId"); + + b.HasIndex("SponsoringOrganizationId"); + + b.HasIndex("SponsoringOrganizationUserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationSponsorship", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccessSecretsManager") + .HasColumnType("boolean"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Permissions") + .HasColumnType("text"); + + b.Property("ResetPasswordKey") + .HasColumnType("text"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("RevocationReason") + .HasColumnType("smallint"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("StatusNew") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.PlayItem", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("PlayId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("PlayId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("PlayItem", null, t => + { + t.HasCheckConstraint("CK_PlayItem_UserOrOrganization", "(\"UserId\" IS NOT NULL AND \"OrganizationId\" IS NULL) OR (\"UserId\" IS NULL AND \"OrganizationId\" IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccessCount") + .HasColumnType("integer"); + + b.Property("AuthType") + .HasColumnType("smallint"); + + b.Property("CipherId") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .IsRequired() + .HasColumnType("text"); + + b.Property("DeletionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Disabled") + .HasColumnType("boolean"); + + b.Property("Emails") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("HideEmail") + .HasColumnType("boolean"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("MaxAccessCount") + .HasColumnType("integer"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Password") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DeletionDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Send", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.TaxRate", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Active") + .HasColumnType("boolean"); + + b.Property("Country") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PostalCode") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("Rate") + .HasColumnType("numeric"); + + b.Property("State") + .HasMaxLength(2) + .HasColumnType("character varying(2)"); + + b.HasKey("Id"); + + b.ToTable("TaxRate", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Amount") + .HasColumnType("numeric"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Details") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Gateway") + .HasColumnType("smallint"); + + b.Property("GatewayId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("PaymentMethodType") + .HasColumnType("smallint"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("Refunded") + .HasColumnType("boolean"); + + b.Property("RefundedAmount") + .HasColumnType("numeric"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ProviderId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId", "CreationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Transaction", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccountRevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("AvatarColor") + .HasMaxLength(7) + .HasColumnType("character varying(7)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Culture") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .UseCollation("postgresIndetermanisticCollation"); + + b.Property("EmailVerified") + .HasColumnType("boolean"); + + b.Property("EquivalentDomains") + .HasColumnType("text"); + + b.Property("ExcludedGlobalEquivalentDomains") + .HasColumnType("text"); + + b.Property("FailedLoginCount") + .HasColumnType("integer"); + + b.Property("ForcePasswordReset") + .HasColumnType("boolean"); + + b.Property("Gateway") + .HasColumnType("smallint"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Kdf") + .HasColumnType("smallint"); + + b.Property("KdfIterations") + .HasColumnType("integer"); + + b.Property("KdfMemory") + .HasColumnType("integer"); + + b.Property("KdfParallelism") + .HasColumnType("integer"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("LastApiKeyRotationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastEmailChangeDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastFailedLoginDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastKdfChangeDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastKeyRotationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastPasswordChangeDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("MasterPassword") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("MasterPasswordHint") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("MasterPasswordSalt") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Premium") + .HasColumnType("boolean"); + + b.Property("PremiumExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("PrivateKey") + .HasColumnType("text"); + + b.Property("PublicKey") + .HasColumnType("text"); + + b.Property("ReferenceData") + .HasColumnType("text"); + + b.Property("RenewalReminderDate") + .HasColumnType("timestamp with time zone"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("SecurityStamp") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("SecurityState") + .HasColumnType("text"); + + b.Property("SecurityVersion") + .HasColumnType("integer"); + + b.Property("SignedPublicKey") + .HasColumnType("text"); + + b.Property("Storage") + .HasColumnType("bigint"); + + b.Property("TwoFactorProviders") + .HasColumnType("text"); + + b.Property("TwoFactorRecoveryCode") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UsesKeyConnector") + .HasColumnType("boolean"); + + b.Property("V2UpgradeToken") + .HasColumnType("text"); + + b.Property("VerifyDevices") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("GatewayCustomerId"); + + b.HasIndex("GatewaySubscriptionId"); + + b.HasIndex("Premium", "PremiumExpirationDate", "RenewalReminderDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("User", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.UserSignatureKeyPair", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("SignatureAlgorithm") + .HasColumnType("smallint"); + + b.Property("SigningKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("VerifyingKey") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("UserSignatureKeyPair", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Body") + .HasMaxLength(3000) + .HasColumnType("character varying(3000)"); + + b.Property("ClientType") + .HasColumnType("smallint"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Global") + .HasColumnType("boolean"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Priority") + .HasColumnType("smallint"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("TaskId") + .HasColumnType("uuid"); + + b.Property("Title") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("TaskId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("ClientType", "Global", "UserId", "OrganizationId", "Priority", "CreationDate") + .IsDescending(false, false, false, false, true, true) + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Notification", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("NotificationId") + .HasColumnType("uuid"); + + b.Property("DeletedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ReadDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("UserId", "NotificationId") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("NotificationId"); + + b.ToTable("NotificationStatus", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Pam.Models.AccessRule", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AllowsExtensions") + .HasColumnType("boolean"); + + b.Property("Conditions") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DefaultLeaseDurationSeconds") + .HasColumnType("integer"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("MaxExtensionDurationSeconds") + .HasColumnType("integer"); + + b.Property("MaxLeaseDurationSeconds") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("SingleActiveLease") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId", "Name") + .IsUnique(); + + b.ToTable("AccessRule", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Platform.Installation", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("LastActivityDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Installation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Discriminator") + .IsRequired() + .HasMaxLength(34) + .HasColumnType("character varying(34)"); + + b.Property("Read") + .HasColumnType("boolean"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Write") + .HasColumnType("boolean"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.ToTable("AccessPolicy", (string)null); + + b.HasDiscriminator().HasValue("AccessPolicy"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ClientSecretHash") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedPayload") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ExpireAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ServiceAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ServiceAccountId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Project", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Secret", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.SecretVersion", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("EditorOrganizationUserId") + .HasColumnType("uuid"); + + b.Property("EditorServiceAccountId") + .HasColumnType("uuid"); + + b.Property("SecretId") + .HasColumnType("uuid"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b.Property("VersionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("EditorOrganizationUserId") + .HasDatabaseName("IX_SecretVersion_EditorOrganizationUserId"); + + b.HasIndex("EditorServiceAccountId") + .HasDatabaseName("IX_SecretVersion_EditorServiceAccountId"); + + b.HasIndex("SecretId") + .HasDatabaseName("IX_SecretVersion_SecretId"); + + b.ToTable("SecretVersion"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ServiceAccount", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Archives") + .HasColumnType("text"); + + b.Property("Attachments") + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .HasColumnType("text"); + + b.Property("DeletedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Favorites") + .HasColumnType("text"); + + b.Property("Folders") + .HasColumnType("text"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Reprompt") + .HasColumnType("smallint"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId"); + + b.ToTable("Cipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Folder", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CipherId") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("CipherId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SecurityTask", (string)null); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.Property("ProjectsId") + .HasColumnType("uuid"); + + b.Property("SecretsId") + .HasColumnType("uuid"); + + b.HasKey("ProjectsId", "SecretsId"); + + b.HasIndex("SecretsId"); + + b.ToTable("ProjectSecret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedProjectId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedSecretId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedProjectId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedSecretId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedProjectId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedSecretId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Collection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Pam.Models.AccessRule", null) + .WithMany() + .HasForeignKey("AccessRuleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Collections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.CollectionGroup", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Collection", "Collection") + .WithMany("CollectionGroups") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.CollectionUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Collection", "Collection") + .WithMany("CollectionUsers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("CollectionUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationInviteLink", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Policies") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.AuthRequest", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Device", "ResponseDevice") + .WithMany() + .HasForeignKey("ResponseDeviceId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("ResponseDevice"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantee") + .WithMany() + .HasForeignKey("GranteeId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantor") + .WithMany() + .HasForeignKey("GrantorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Grantee"); + + b.Navigation("Grantor"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoConfigs") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoUsers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("SsoUsers") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Platform.Installation", "Installation") + .WithMany() + .HasForeignKey("InstallationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Installation"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationPlanMigrationCohortAssignment", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationPlanMigrationCohort", "Cohort") + .WithMany() + .HasForeignKey("CohortId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cohort"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationApplication", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegrationConfiguration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegration", "OrganizationIntegration") + .WithMany() + .HasForeignKey("OrganizationIntegrationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("OrganizationIntegration"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.PasswordHealthReportApplication", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany("CollectionCiphers") + .HasForeignKey("CipherId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Collection", "Collection") + .WithMany("CollectionCiphers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Collection"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Groups") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany("GroupUsers") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("GroupUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("ApiKeys") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Connections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Domains") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoredOrganization") + .WithMany() + .HasForeignKey("SponsoredOrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoringOrganization") + .WithMany() + .HasForeignKey("SponsoringOrganizationId"); + + b.Navigation("SponsoredOrganization"); + + b.Navigation("SponsoringOrganization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("OrganizationUsers") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("OrganizationUsers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.PlayItem", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Transactions") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Transactions") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.UserSignatureKeyPair", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", "Task") + .WithMany() + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Task"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", "Notification") + .WithMany() + .HasForeignKey("NotificationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Notification"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Pam.Models.AccessRule", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ApiKeys") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.SecretVersion", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "EditorOrganizationUser") + .WithMany() + .HasForeignKey("EditorOrganizationUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "EditorServiceAccount") + .WithMany() + .HasForeignKey("EditorServiceAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "Secret") + .WithMany("SecretVersions") + .HasForeignKey("SecretId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("EditorOrganizationUser"); + + b.Navigation("EditorServiceAccount"); + + b.Navigation("Secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Ciphers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Ciphers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Folders") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany() + .HasForeignKey("CipherId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", null) + .WithMany() + .HasForeignKey("ProjectsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", null) + .WithMany() + .HasForeignKey("SecretsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedProject"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedSecret"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ProjectAccessPolicies") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedProject"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany() + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedProject"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Collection", b => + { + b.Navigation("CollectionCiphers"); + + b.Navigation("CollectionGroups"); + + b.Navigation("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("Ciphers"); + + b.Navigation("Collections"); + + b.Navigation("Connections"); + + b.Navigation("Domains"); + + b.Navigation("Groups"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("Policies"); + + b.Navigation("SsoConfigs"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Navigation("CollectionUsers"); + + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Navigation("Ciphers"); + + b.Navigation("Folders"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("SecretVersions"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ProjectAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Navigation("CollectionCiphers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/util/PostgresMigrations/Migrations/20260616102515_AddAccessRuleLeaseConfiguration.cs b/util/PostgresMigrations/Migrations/20260616102515_AddAccessRuleLeaseConfiguration.cs new file mode 100644 index 000000000000..8402293a9934 --- /dev/null +++ b/util/PostgresMigrations/Migrations/20260616102515_AddAccessRuleLeaseConfiguration.cs @@ -0,0 +1,80 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bit.PostgresMigrations.Migrations; + +/// +public partial class AddAccessRuleLeaseConfiguration : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "AllowsExtensions", + table: "AccessRule", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "DefaultLeaseDurationSeconds", + table: "AccessRule", + type: "integer", + nullable: true); + + migrationBuilder.AddColumn( + name: "Enabled", + table: "AccessRule", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "MaxExtensionDurationSeconds", + table: "AccessRule", + type: "integer", + nullable: true); + + migrationBuilder.AddColumn( + name: "MaxLeaseDurationSeconds", + table: "AccessRule", + type: "integer", + nullable: true); + + migrationBuilder.AddColumn( + name: "SingleActiveLease", + table: "AccessRule", + type: "boolean", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "AllowsExtensions", + table: "AccessRule"); + + migrationBuilder.DropColumn( + name: "DefaultLeaseDurationSeconds", + table: "AccessRule"); + + migrationBuilder.DropColumn( + name: "Enabled", + table: "AccessRule"); + + migrationBuilder.DropColumn( + name: "MaxExtensionDurationSeconds", + table: "AccessRule"); + + migrationBuilder.DropColumn( + name: "MaxLeaseDurationSeconds", + table: "AccessRule"); + + migrationBuilder.DropColumn( + name: "SingleActiveLease", + table: "AccessRule"); + } +} diff --git a/util/PostgresMigrations/Migrations/20260616102601_AddUsePamToOrganization.Designer.cs b/util/PostgresMigrations/Migrations/20260616102601_AddUsePamToOrganization.Designer.cs new file mode 100644 index 000000000000..87c3b7d179cc --- /dev/null +++ b/util/PostgresMigrations/Migrations/20260616102601_AddUsePamToOrganization.Designer.cs @@ -0,0 +1,3848 @@ +// +using System; +using Bit.Infrastructure.EntityFramework.Repositories; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Bit.PostgresMigrations.Migrations +{ + [DbContext(typeof(DatabaseContext))] + [Migration("20260616102601_AddUsePamToOrganization")] + partial class AddUsePamToOrganization + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("Npgsql:CollationDefinition:postgresIndetermanisticCollation", "en-u-ks-primary,en-u-ks-primary,icu,False") + .HasAnnotation("ProductVersion", "8.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Bit.Core.Dirt.Reports.Models.Data.OrganizationMemberBaseDetail", b => + { + b.Property("CipherId") + .HasColumnType("uuid"); + + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("CollectionName") + .HasColumnType("text"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("GroupName") + .HasColumnType("text"); + + b.Property("HidePasswords") + .HasColumnType("boolean"); + + b.Property("Manage") + .HasColumnType("boolean"); + + b.Property("ReadOnly") + .HasColumnType("boolean"); + + b.Property("ResetPasswordKey") + .HasColumnType("text"); + + b.Property("TwoFactorProviders") + .HasColumnType("text"); + + b.Property("UserGuid") + .HasColumnType("uuid"); + + b.Property("UserName") + .HasColumnType("text"); + + b.Property("UsesKeyConnector") + .HasColumnType("boolean"); + + b.ToTable("OrganizationMemberBaseDetails"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Collection", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccessRuleId") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DefaultUserCollectionEmail") + .HasColumnType("text"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("AccessRuleId"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Collection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.CollectionGroup", b => + { + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("HidePasswords") + .HasColumnType("boolean"); + + b.Property("Manage") + .HasColumnType("boolean"); + + b.Property("ReadOnly") + .HasColumnType("boolean"); + + b.HasKey("CollectionId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("CollectionGroups"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.CollectionUser", b => + { + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("OrganizationUserId") + .HasColumnType("uuid"); + + b.Property("HidePasswords") + .HasColumnType("boolean"); + + b.Property("Manage") + .HasColumnType("boolean"); + + b.Property("ReadOnly") + .HasColumnType("boolean"); + + b.HasKey("CollectionId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AllowAdminAccessToAllCollectionItems") + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("BillingEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("BusinessAddress1") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("BusinessAddress2") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("BusinessAddress3") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("BusinessCountry") + .HasMaxLength(2) + .HasColumnType("character varying(2)"); + + b.Property("BusinessName") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("BusinessTaxNumber") + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("ExemptFromBillingAutomation") + .HasColumnType("boolean"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Gateway") + .HasColumnType("smallint"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Identifier") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .UseCollation("postgresIndetermanisticCollation"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("LimitCollectionCreation") + .HasColumnType("boolean"); + + b.Property("LimitCollectionDeletion") + .HasColumnType("boolean"); + + b.Property("LimitItemDeletion") + .HasColumnType("boolean"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("integer"); + + b.Property("MaxAutoscaleSmSeats") + .HasColumnType("integer"); + + b.Property("MaxAutoscaleSmServiceAccounts") + .HasColumnType("integer"); + + b.Property("MaxCollections") + .HasColumnType("smallint"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OwnersNotifiedOfAutoscaling") + .HasColumnType("timestamp with time zone"); + + b.Property("Plan") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PlanType") + .HasColumnType("smallint"); + + b.Property("PrivateKey") + .HasColumnType("text"); + + b.Property("PublicKey") + .HasColumnType("text"); + + b.Property("ReferenceData") + .HasColumnType("text"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Seats") + .HasColumnType("integer"); + + b.Property("SelfHost") + .HasColumnType("boolean"); + + b.Property("SmSeats") + .HasColumnType("integer"); + + b.Property("SmServiceAccounts") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Storage") + .HasColumnType("bigint"); + + b.Property("SyncSeats") + .HasColumnType("boolean"); + + b.Property("TwoFactorProviders") + .HasColumnType("text"); + + b.Property("Use2fa") + .HasColumnType("boolean"); + + b.Property("UseAdminSponsoredFamilies") + .HasColumnType("boolean"); + + b.Property("UseApi") + .HasColumnType("boolean"); + + b.Property("UseAutomaticUserConfirmation") + .HasColumnType("boolean"); + + b.Property("UseCustomPermissions") + .HasColumnType("boolean"); + + b.Property("UseDirectory") + .HasColumnType("boolean"); + + b.Property("UseDisableSmAdsForUsers") + .HasColumnType("boolean"); + + b.Property("UseEvents") + .HasColumnType("boolean"); + + b.Property("UseGroups") + .HasColumnType("boolean"); + + b.Property("UseInviteLinks") + .HasColumnType("boolean"); + + b.Property("UseKeyConnector") + .HasColumnType("boolean"); + + b.Property("UseMyItems") + .HasColumnType("boolean"); + + b.Property("UseOrganizationDomains") + .HasColumnType("boolean"); + + b.Property("UsePam") + .HasColumnType("boolean"); + + b.Property("UsePasswordManager") + .HasColumnType("boolean"); + + b.Property("UsePhishingBlocker") + .HasColumnType("boolean"); + + b.Property("UsePolicies") + .HasColumnType("boolean"); + + b.Property("UseResetPassword") + .HasColumnType("boolean"); + + b.Property("UseRiskInsights") + .HasColumnType("boolean"); + + b.Property("UseScim") + .HasColumnType("boolean"); + + b.Property("UseSecretsManager") + .HasColumnType("boolean"); + + b.Property("UseSso") + .HasColumnType("boolean"); + + b.Property("UseTotp") + .HasColumnType("boolean"); + + b.Property("UsersGetPremium") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("GatewayCustomerId"); + + b.HasIndex("GatewaySubscriptionId"); + + b.HasIndex("Id", "Enabled"); + + NpgsqlIndexBuilderExtensions.IncludeProperties(b.HasIndex("Id", "Enabled"), new[] { "UseTotp", "UsersGetPremium" }); + + b.ToTable("Organization", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationInviteLink", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AllowedDomains") + .IsRequired() + .HasColumnType("text"); + + b.Property("Code") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedInviteKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("EncryptedOrgKey") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationInviteLink", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "Type") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Policy", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BillingEmail") + .HasColumnType("text"); + + b.Property("BillingPhone") + .HasColumnType("text"); + + b.Property("BusinessAddress1") + .HasColumnType("text"); + + b.Property("BusinessAddress2") + .HasColumnType("text"); + + b.Property("BusinessAddress3") + .HasColumnType("text"); + + b.Property("BusinessCountry") + .HasColumnType("text"); + + b.Property("BusinessName") + .HasColumnType("text"); + + b.Property("BusinessTaxNumber") + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DiscountId") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("Gateway") + .HasColumnType("smallint"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UseEvents") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("GatewayCustomerId"); + + b.HasIndex("GatewaySubscriptionId"); + + b.ToTable("Provider", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Settings") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ProviderId"); + + b.ToTable("ProviderOrganization", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderUser", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("Permissions") + .HasColumnType("text"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.HasIndex("UserId"); + + b.ToTable("ProviderUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.AuthRequest", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccessCode") + .HasMaxLength(25) + .HasColumnType("character varying(25)"); + + b.Property("Approved") + .HasColumnType("boolean"); + + b.Property("AuthenticationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("MasterPasswordHash") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("PublicKey") + .HasColumnType("text"); + + b.Property("RequestCountryName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RequestDeviceIdentifier") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("RequestDeviceType") + .HasColumnType("smallint"); + + b.Property("RequestIpAddress") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ResponseDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ResponseDeviceId") + .HasColumnType("uuid"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ResponseDeviceId"); + + b.HasIndex("UserId"); + + b.ToTable("AuthRequest", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("GranteeId") + .HasColumnType("uuid"); + + b.Property("GrantorId") + .HasColumnType("uuid"); + + b.Property("KeyEncrypted") + .HasColumnType("text"); + + b.Property("LastNotificationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("RecoveryInitiatedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("WaitTimeDays") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("GranteeId"); + + b.HasIndex("GrantorId"); + + b.ToTable("EmergencyAccess", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.Grant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClientId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ConsumedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .IsRequired() + .HasColumnType("text"); + + b.Property("Description") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SessionId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SubjectId") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id") + .HasName("PK_Grant") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpirationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("Grant", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("SsoConfig", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .UseCollation("postgresIndetermanisticCollation"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId"); + + b.HasIndex("OrganizationId", "ExternalId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + NpgsqlIndexBuilderExtensions.IncludeProperties(b.HasIndex("OrganizationId", "ExternalId"), new[] { "UserId" }); + + b.HasIndex("OrganizationId", "UserId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SsoUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AaGuid") + .HasColumnType("uuid"); + + b.Property("Counter") + .HasColumnType("integer"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CredentialId") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EncryptedPrivateKey") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("EncryptedPublicKey") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("EncryptedUserKey") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PublicKey") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("SupportsPrf") + .HasColumnType("boolean"); + + b.Property("Type") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("WebAuthnCredential", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ClientOrganizationMigrationRecord", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("GatewayCustomerId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("GatewaySubscriptionId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("integer"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("PlanType") + .HasColumnType("smallint"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("Seats") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId", "OrganizationId") + .IsUnique(); + + b.ToTable("ClientOrganizationMigrationRecord", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("InstallationId") + .HasColumnType("uuid"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("InstallationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationInstallation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationPlanMigrationCohort", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChurnDiscountCouponCode") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("MigrationPathId") + .HasColumnType("smallint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ProactiveDiscountCouponCode") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("Name") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationPlanMigrationCohort", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationPlanMigrationCohortAssignment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChurnDiscountAppliedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CohortId") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("MigratedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ScheduledDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("CohortId", "ScheduledDate", "MigratedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationPlanMigrationCohortAssignment", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AssignedSeats") + .HasColumnType("integer"); + + b.Property("ClientId") + .HasColumnType("uuid"); + + b.Property("ClientName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("InvoiceId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("InvoiceNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PlanName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("Total") + .HasColumnType("numeric"); + + b.Property("UsedSeats") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.ToTable("ProviderInvoiceItem", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AllocatedSeats") + .HasColumnType("integer"); + + b.Property("PlanType") + .HasColumnType("smallint"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("PurchasedSeats") + .HasColumnType("integer"); + + b.Property("SeatMinimum") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.HasIndex("Id", "PlanType") + .IsUnique(); + + b.ToTable("ProviderPlan", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.SubscriptionDiscount", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AmountOff") + .HasColumnType("bigint"); + + b.Property("AudienceType") + .HasColumnType("integer"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Currency") + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("Duration") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("DurationInMonths") + .HasColumnType("integer"); + + b.Property("EndDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PercentOff") + .HasPrecision(5, 2) + .HasColumnType("numeric(5,2)"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("StartDate") + .HasColumnType("timestamp with time zone"); + + b.Property("StripeCouponId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("StripeProductIds") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("StripeCouponId") + .IsUnique(); + + b.HasIndex("StartDate", "EndDate") + .HasDatabaseName("IX_SubscriptionDiscount_DateRange") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SubscriptionDiscount", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationApplication", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Applications") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentEncryptionKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationApplication", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegration", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Configuration") + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "Type") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationIntegration", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegrationConfiguration", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Configuration") + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("EventType") + .HasColumnType("integer"); + + b.Property("Filters") + .HasColumnType("text"); + + b.Property("OrganizationIntegrationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Template") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationIntegrationId"); + + b.ToTable("OrganizationIntegrationConfiguration", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationAtRiskCount") + .HasColumnType("integer"); + + b.Property("ApplicationCount") + .HasColumnType("integer"); + + b.Property("ApplicationData") + .HasColumnType("text"); + + b.Property("ContentEncryptionKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CriticalApplicationAtRiskCount") + .HasColumnType("integer"); + + b.Property("CriticalApplicationCount") + .HasColumnType("integer"); + + b.Property("CriticalMemberAtRiskCount") + .HasColumnType("integer"); + + b.Property("CriticalMemberCount") + .HasColumnType("integer"); + + b.Property("CriticalPasswordAtRiskCount") + .HasColumnType("integer"); + + b.Property("CriticalPasswordCount") + .HasColumnType("integer"); + + b.Property("MemberAtRiskCount") + .HasColumnType("integer"); + + b.Property("MemberCount") + .HasColumnType("integer"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("PasswordAtRiskCount") + .HasColumnType("integer"); + + b.Property("PasswordCount") + .HasColumnType("integer"); + + b.Property("ReportData") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReportFile") + .HasColumnType("text"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("SummaryData") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationReport", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.PasswordHealthReportApplication", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Uri") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("PasswordHealthReportApplication", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Cache", b => + { + b.Property("Id") + .HasMaxLength(449) + .HasColumnType("character varying(449)"); + + b.Property("AbsoluteExpiration") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SlidingExpirationInSeconds") + .HasColumnType("bigint"); + + b.Property("Value") + .IsRequired() + .HasColumnType("bytea"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpiresAtTime") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Cache", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("CipherId") + .HasColumnType("uuid"); + + b.HasKey("CollectionId", "CipherId"); + + b.HasIndex("CipherId"); + + b.ToTable("CollectionCipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Active") + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("ClientVersion") + .HasMaxLength(43) + .HasColumnType("character varying(43)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedPrivateKey") + .HasColumnType("text"); + + b.Property("EncryptedPublicKey") + .HasColumnType("text"); + + b.Property("EncryptedUserKey") + .HasColumnType("text"); + + b.Property("Identifier") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("LastActivityDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PushToken") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Identifier") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "Identifier") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Device", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Event", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ActingUserId") + .HasColumnType("uuid"); + + b.Property("CipherId") + .HasColumnType("uuid"); + + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("Date") + .HasColumnType("timestamp with time zone"); + + b.Property("DeviceType") + .HasColumnType("smallint"); + + b.Property("DomainName") + .HasColumnType("text"); + + b.Property("GrantedServiceAccountId") + .HasColumnType("uuid"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("InstallationId") + .HasColumnType("uuid"); + + b.Property("IpAddress") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("OrganizationUserId") + .HasColumnType("uuid"); + + b.Property("PolicyId") + .HasColumnType("uuid"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("ProviderOrganizationId") + .HasColumnType("uuid"); + + b.Property("ProviderUserId") + .HasColumnType("uuid"); + + b.Property("SecretId") + .HasColumnType("uuid"); + + b.Property("ServiceAccountId") + .HasColumnType("uuid"); + + b.Property("SystemUser") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("Date", "OrganizationId", "ActingUserId", "CipherId") + .HasDatabaseName("IX_Event_DateOrganizationIdUserId") + .HasAnnotation("SqlServer:Clustered", false) + .HasAnnotation("SqlServer:Include", new[] { "ServiceAccountId", "GrantedServiceAccountId" }); + + b.ToTable("Event", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Group", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("OrganizationUserId") + .HasColumnType("uuid"); + + b.HasKey("GroupId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("GroupUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Config") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationConnection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DomainName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("JobRunCount") + .HasColumnType("integer"); + + b.Property("LastCheckedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("NextRunDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Txt") + .IsRequired() + .HasColumnType("text"); + + b.Property("VerifiedDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationDomain", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("FriendlyName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("IsAdminInitiated") + .HasColumnType("boolean"); + + b.Property("LastSyncDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("OfferedToEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PlanSponsorshipType") + .HasColumnType("smallint"); + + b.Property("SponsoredOrganizationId") + .HasColumnType("uuid"); + + b.Property("SponsoringOrganizationId") + .HasColumnType("uuid"); + + b.Property("SponsoringOrganizationUserId") + .HasColumnType("uuid"); + + b.Property("ToDelete") + .HasColumnType("boolean"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("SponsoredOrganizationId"); + + b.HasIndex("SponsoringOrganizationId"); + + b.HasIndex("SponsoringOrganizationUserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationSponsorship", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccessSecretsManager") + .HasColumnType("boolean"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Permissions") + .HasColumnType("text"); + + b.Property("ResetPasswordKey") + .HasColumnType("text"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("RevocationReason") + .HasColumnType("smallint"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("StatusNew") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.PlayItem", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("PlayId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("PlayId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("PlayItem", null, t => + { + t.HasCheckConstraint("CK_PlayItem_UserOrOrganization", "(\"UserId\" IS NOT NULL AND \"OrganizationId\" IS NULL) OR (\"UserId\" IS NULL AND \"OrganizationId\" IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccessCount") + .HasColumnType("integer"); + + b.Property("AuthType") + .HasColumnType("smallint"); + + b.Property("CipherId") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .IsRequired() + .HasColumnType("text"); + + b.Property("DeletionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Disabled") + .HasColumnType("boolean"); + + b.Property("Emails") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("HideEmail") + .HasColumnType("boolean"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("MaxAccessCount") + .HasColumnType("integer"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Password") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DeletionDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Send", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.TaxRate", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Active") + .HasColumnType("boolean"); + + b.Property("Country") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PostalCode") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("Rate") + .HasColumnType("numeric"); + + b.Property("State") + .HasMaxLength(2) + .HasColumnType("character varying(2)"); + + b.HasKey("Id"); + + b.ToTable("TaxRate", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Amount") + .HasColumnType("numeric"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Details") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Gateway") + .HasColumnType("smallint"); + + b.Property("GatewayId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("PaymentMethodType") + .HasColumnType("smallint"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("Refunded") + .HasColumnType("boolean"); + + b.Property("RefundedAmount") + .HasColumnType("numeric"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ProviderId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId", "CreationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Transaction", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccountRevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("AvatarColor") + .HasMaxLength(7) + .HasColumnType("character varying(7)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Culture") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .UseCollation("postgresIndetermanisticCollation"); + + b.Property("EmailVerified") + .HasColumnType("boolean"); + + b.Property("EquivalentDomains") + .HasColumnType("text"); + + b.Property("ExcludedGlobalEquivalentDomains") + .HasColumnType("text"); + + b.Property("FailedLoginCount") + .HasColumnType("integer"); + + b.Property("ForcePasswordReset") + .HasColumnType("boolean"); + + b.Property("Gateway") + .HasColumnType("smallint"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Kdf") + .HasColumnType("smallint"); + + b.Property("KdfIterations") + .HasColumnType("integer"); + + b.Property("KdfMemory") + .HasColumnType("integer"); + + b.Property("KdfParallelism") + .HasColumnType("integer"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("LastApiKeyRotationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastEmailChangeDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastFailedLoginDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastKdfChangeDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastKeyRotationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastPasswordChangeDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("MasterPassword") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("MasterPasswordHint") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("MasterPasswordSalt") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Premium") + .HasColumnType("boolean"); + + b.Property("PremiumExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("PrivateKey") + .HasColumnType("text"); + + b.Property("PublicKey") + .HasColumnType("text"); + + b.Property("ReferenceData") + .HasColumnType("text"); + + b.Property("RenewalReminderDate") + .HasColumnType("timestamp with time zone"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("SecurityStamp") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("SecurityState") + .HasColumnType("text"); + + b.Property("SecurityVersion") + .HasColumnType("integer"); + + b.Property("SignedPublicKey") + .HasColumnType("text"); + + b.Property("Storage") + .HasColumnType("bigint"); + + b.Property("TwoFactorProviders") + .HasColumnType("text"); + + b.Property("TwoFactorRecoveryCode") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UsesKeyConnector") + .HasColumnType("boolean"); + + b.Property("V2UpgradeToken") + .HasColumnType("text"); + + b.Property("VerifyDevices") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("GatewayCustomerId"); + + b.HasIndex("GatewaySubscriptionId"); + + b.HasIndex("Premium", "PremiumExpirationDate", "RenewalReminderDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("User", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.UserSignatureKeyPair", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("SignatureAlgorithm") + .HasColumnType("smallint"); + + b.Property("SigningKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("VerifyingKey") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("UserSignatureKeyPair", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Body") + .HasMaxLength(3000) + .HasColumnType("character varying(3000)"); + + b.Property("ClientType") + .HasColumnType("smallint"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Global") + .HasColumnType("boolean"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Priority") + .HasColumnType("smallint"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("TaskId") + .HasColumnType("uuid"); + + b.Property("Title") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("TaskId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("ClientType", "Global", "UserId", "OrganizationId", "Priority", "CreationDate") + .IsDescending(false, false, false, false, true, true) + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Notification", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("NotificationId") + .HasColumnType("uuid"); + + b.Property("DeletedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ReadDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("UserId", "NotificationId") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("NotificationId"); + + b.ToTable("NotificationStatus", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Pam.Models.AccessRule", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AllowsExtensions") + .HasColumnType("boolean"); + + b.Property("Conditions") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DefaultLeaseDurationSeconds") + .HasColumnType("integer"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("MaxExtensionDurationSeconds") + .HasColumnType("integer"); + + b.Property("MaxLeaseDurationSeconds") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("SingleActiveLease") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId", "Name") + .IsUnique(); + + b.ToTable("AccessRule", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Platform.Installation", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("LastActivityDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Installation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Discriminator") + .IsRequired() + .HasMaxLength(34) + .HasColumnType("character varying(34)"); + + b.Property("Read") + .HasColumnType("boolean"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Write") + .HasColumnType("boolean"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.ToTable("AccessPolicy", (string)null); + + b.HasDiscriminator().HasValue("AccessPolicy"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ClientSecretHash") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedPayload") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ExpireAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ServiceAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ServiceAccountId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Project", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Secret", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.SecretVersion", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("EditorOrganizationUserId") + .HasColumnType("uuid"); + + b.Property("EditorServiceAccountId") + .HasColumnType("uuid"); + + b.Property("SecretId") + .HasColumnType("uuid"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b.Property("VersionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("EditorOrganizationUserId") + .HasDatabaseName("IX_SecretVersion_EditorOrganizationUserId"); + + b.HasIndex("EditorServiceAccountId") + .HasDatabaseName("IX_SecretVersion_EditorServiceAccountId"); + + b.HasIndex("SecretId") + .HasDatabaseName("IX_SecretVersion_SecretId"); + + b.ToTable("SecretVersion"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ServiceAccount", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Archives") + .HasColumnType("text"); + + b.Property("Attachments") + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .HasColumnType("text"); + + b.Property("DeletedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Favorites") + .HasColumnType("text"); + + b.Property("Folders") + .HasColumnType("text"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Reprompt") + .HasColumnType("smallint"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId"); + + b.ToTable("Cipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Folder", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CipherId") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("CipherId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SecurityTask", (string)null); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.Property("ProjectsId") + .HasColumnType("uuid"); + + b.Property("SecretsId") + .HasColumnType("uuid"); + + b.HasKey("ProjectsId", "SecretsId"); + + b.HasIndex("SecretsId"); + + b.ToTable("ProjectSecret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedProjectId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedSecretId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedProjectId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedSecretId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedProjectId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedSecretId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Collection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Pam.Models.AccessRule", null) + .WithMany() + .HasForeignKey("AccessRuleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Collections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.CollectionGroup", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Collection", "Collection") + .WithMany("CollectionGroups") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.CollectionUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Collection", "Collection") + .WithMany("CollectionUsers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("CollectionUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationInviteLink", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Policies") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.AuthRequest", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Device", "ResponseDevice") + .WithMany() + .HasForeignKey("ResponseDeviceId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("ResponseDevice"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantee") + .WithMany() + .HasForeignKey("GranteeId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantor") + .WithMany() + .HasForeignKey("GrantorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Grantee"); + + b.Navigation("Grantor"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoConfigs") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoUsers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("SsoUsers") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Platform.Installation", "Installation") + .WithMany() + .HasForeignKey("InstallationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Installation"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationPlanMigrationCohortAssignment", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationPlanMigrationCohort", "Cohort") + .WithMany() + .HasForeignKey("CohortId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cohort"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationApplication", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegrationConfiguration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegration", "OrganizationIntegration") + .WithMany() + .HasForeignKey("OrganizationIntegrationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("OrganizationIntegration"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.PasswordHealthReportApplication", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany("CollectionCiphers") + .HasForeignKey("CipherId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Collection", "Collection") + .WithMany("CollectionCiphers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Collection"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Groups") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany("GroupUsers") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("GroupUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("ApiKeys") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Connections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Domains") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoredOrganization") + .WithMany() + .HasForeignKey("SponsoredOrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoringOrganization") + .WithMany() + .HasForeignKey("SponsoringOrganizationId"); + + b.Navigation("SponsoredOrganization"); + + b.Navigation("SponsoringOrganization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("OrganizationUsers") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("OrganizationUsers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.PlayItem", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Transactions") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Transactions") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.UserSignatureKeyPair", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", "Task") + .WithMany() + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Task"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", "Notification") + .WithMany() + .HasForeignKey("NotificationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Notification"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Pam.Models.AccessRule", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ApiKeys") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.SecretVersion", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "EditorOrganizationUser") + .WithMany() + .HasForeignKey("EditorOrganizationUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "EditorServiceAccount") + .WithMany() + .HasForeignKey("EditorServiceAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "Secret") + .WithMany("SecretVersions") + .HasForeignKey("SecretId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("EditorOrganizationUser"); + + b.Navigation("EditorServiceAccount"); + + b.Navigation("Secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Ciphers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Ciphers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Folders") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany() + .HasForeignKey("CipherId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", null) + .WithMany() + .HasForeignKey("ProjectsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", null) + .WithMany() + .HasForeignKey("SecretsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedProject"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedSecret"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ProjectAccessPolicies") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedProject"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany() + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedProject"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Collection", b => + { + b.Navigation("CollectionCiphers"); + + b.Navigation("CollectionGroups"); + + b.Navigation("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("Ciphers"); + + b.Navigation("Collections"); + + b.Navigation("Connections"); + + b.Navigation("Domains"); + + b.Navigation("Groups"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("Policies"); + + b.Navigation("SsoConfigs"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Navigation("CollectionUsers"); + + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Navigation("Ciphers"); + + b.Navigation("Folders"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("SecretVersions"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ProjectAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Navigation("CollectionCiphers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/util/PostgresMigrations/Migrations/20260616102601_AddUsePamToOrganization.cs b/util/PostgresMigrations/Migrations/20260616102601_AddUsePamToOrganization.cs new file mode 100644 index 000000000000..a7baa8cc3ab2 --- /dev/null +++ b/util/PostgresMigrations/Migrations/20260616102601_AddUsePamToOrganization.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bit.PostgresMigrations.Migrations; + +/// +public partial class AddUsePamToOrganization : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "UsePam", + table: "Organization", + type: "boolean", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "UsePam", + table: "Organization"); + } +} diff --git a/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs b/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs index 201da397b823..70df22136258 100644 --- a/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs +++ b/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs @@ -343,6 +343,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("UseOrganizationDomains") .HasColumnType("boolean"); + b.Property("UsePam") + .HasColumnType("boolean"); + b.Property("UsePasswordManager") .HasColumnType("boolean"); @@ -2351,36 +2354,59 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("NotificationStatus", (string)null); }); - modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Platform.Installation", b => + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Pam.Models.AccessRule", b => { b.Property("Id") .HasColumnType("uuid"); + b.Property("AllowsExtensions") + .HasColumnType("boolean"); + + b.Property("Conditions") + .IsRequired() + .HasColumnType("text"); + b.Property("CreationDate") .HasColumnType("timestamp with time zone"); - b.Property("Email") - .IsRequired() - .HasMaxLength(256) - .HasColumnType("character varying(256)"); + b.Property("DefaultLeaseDurationSeconds") + .HasColumnType("integer"); + + b.Property("Description") + .HasColumnType("text"); b.Property("Enabled") .HasColumnType("boolean"); - b.Property("Key") + b.Property("MaxExtensionDurationSeconds") + .HasColumnType("integer"); + + b.Property("MaxLeaseDurationSeconds") + .HasColumnType("integer"); + + b.Property("Name") .IsRequired() - .HasMaxLength(150) - .HasColumnType("character varying(150)"); + .HasMaxLength(256) + .HasColumnType("character varying(256)"); - b.Property("LastActivityDate") + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") .HasColumnType("timestamp with time zone"); + b.Property("SingleActiveLease") + .HasColumnType("boolean"); + b.HasKey("Id"); - b.ToTable("Installation", (string)null); + b.HasIndex("OrganizationId", "Name") + .IsUnique(); + + b.ToTable("AccessRule", (string)null); }); - modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Pam.Models.AccessRule", b => + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Platform.Installation", b => { b.Property("Id") .HasColumnType("uuid"); @@ -2388,30 +2414,25 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("CreationDate") .HasColumnType("timestamp with time zone"); - b.Property("Description") - .HasColumnType("text"); - - b.Property("Name") + b.Property("Email") .IsRequired() .HasMaxLength(256) .HasColumnType("character varying(256)"); - b.Property("OrganizationId") - .HasColumnType("uuid"); - - b.Property("RevisionDate") - .HasColumnType("timestamp with time zone"); + b.Property("Enabled") + .HasColumnType("boolean"); - b.Property("Conditions") + b.Property("Key") .IsRequired() - .HasColumnType("text"); + .HasMaxLength(150) + .HasColumnType("character varying(150)"); - b.HasKey("Id"); + b.Property("LastActivityDate") + .HasColumnType("timestamp with time zone"); - b.HasIndex("OrganizationId", "Name") - .IsUnique(); + b.HasKey("Id"); - b.ToTable("AccessRule", (string)null); + b.ToTable("Installation", (string)null); }); modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy", b => diff --git a/util/SqliteMigrations/Migrations/20260616102508_AddAccessRuleLeaseConfiguration.Designer.cs b/util/SqliteMigrations/Migrations/20260616102508_AddAccessRuleLeaseConfiguration.Designer.cs new file mode 100644 index 000000000000..48471cfdb20c --- /dev/null +++ b/util/SqliteMigrations/Migrations/20260616102508_AddAccessRuleLeaseConfiguration.Designer.cs @@ -0,0 +1,3828 @@ +// +using System; +using Bit.Infrastructure.EntityFramework.Repositories; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Bit.SqliteMigrations.Migrations +{ + [DbContext(typeof(DatabaseContext))] + [Migration("20260616102508_AddAccessRuleLeaseConfiguration")] + partial class AddAccessRuleLeaseConfiguration + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.8"); + + modelBuilder.Entity("Bit.Core.Dirt.Reports.Models.Data.OrganizationMemberBaseDetail", b => + { + b.Property("CipherId") + .HasColumnType("TEXT"); + + b.Property("CollectionId") + .HasColumnType("TEXT"); + + b.Property("CollectionName") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("GroupName") + .HasColumnType("TEXT"); + + b.Property("HidePasswords") + .HasColumnType("INTEGER"); + + b.Property("Manage") + .HasColumnType("INTEGER"); + + b.Property("ReadOnly") + .HasColumnType("INTEGER"); + + b.Property("ResetPasswordKey") + .HasColumnType("TEXT"); + + b.Property("TwoFactorProviders") + .HasColumnType("TEXT"); + + b.Property("UserGuid") + .HasColumnType("TEXT"); + + b.Property("UserName") + .HasColumnType("TEXT"); + + b.Property("UsesKeyConnector") + .HasColumnType("INTEGER"); + + b.ToTable("OrganizationMemberBaseDetails"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Collection", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessRuleId") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("DefaultUserCollectionEmail") + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AccessRuleId"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Collection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.CollectionGroup", b => + { + b.Property("CollectionId") + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("HidePasswords") + .HasColumnType("INTEGER"); + + b.Property("Manage") + .HasColumnType("INTEGER"); + + b.Property("ReadOnly") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("CollectionGroups"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.CollectionUser", b => + { + b.Property("CollectionId") + .HasColumnType("TEXT"); + + b.Property("OrganizationUserId") + .HasColumnType("TEXT"); + + b.Property("HidePasswords") + .HasColumnType("INTEGER"); + + b.Property("Manage") + .HasColumnType("INTEGER"); + + b.Property("ReadOnly") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AllowAdminAccessToAllCollectionItems") + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("BillingEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("BusinessAddress1") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("BusinessAddress2") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("BusinessAddress3") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("BusinessCountry") + .HasMaxLength(2) + .HasColumnType("TEXT"); + + b.Property("BusinessName") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("BusinessTaxNumber") + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("ExemptFromBillingAutomation") + .HasColumnType("INTEGER"); + + b.Property("ExpirationDate") + .HasColumnType("TEXT"); + + b.Property("Gateway") + .HasColumnType("INTEGER"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Identifier") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LimitCollectionCreation") + .HasColumnType("INTEGER"); + + b.Property("LimitCollectionDeletion") + .HasColumnType("INTEGER"); + + b.Property("LimitItemDeletion") + .HasColumnType("INTEGER"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("INTEGER"); + + b.Property("MaxAutoscaleSmSeats") + .HasColumnType("INTEGER"); + + b.Property("MaxAutoscaleSmServiceAccounts") + .HasColumnType("INTEGER"); + + b.Property("MaxCollections") + .HasColumnType("INTEGER"); + + b.Property("MaxStorageGb") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("OwnersNotifiedOfAutoscaling") + .HasColumnType("TEXT"); + + b.Property("Plan") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PlanType") + .HasColumnType("INTEGER"); + + b.Property("PrivateKey") + .HasColumnType("TEXT"); + + b.Property("PublicKey") + .HasColumnType("TEXT"); + + b.Property("ReferenceData") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Seats") + .HasColumnType("INTEGER"); + + b.Property("SelfHost") + .HasColumnType("INTEGER"); + + b.Property("SmSeats") + .HasColumnType("INTEGER"); + + b.Property("SmServiceAccounts") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Storage") + .HasColumnType("INTEGER"); + + b.Property("SyncSeats") + .HasColumnType("INTEGER"); + + b.Property("TwoFactorProviders") + .HasColumnType("TEXT"); + + b.Property("Use2fa") + .HasColumnType("INTEGER"); + + b.Property("UseAdminSponsoredFamilies") + .HasColumnType("INTEGER"); + + b.Property("UseApi") + .HasColumnType("INTEGER"); + + b.Property("UseAutomaticUserConfirmation") + .HasColumnType("INTEGER"); + + b.Property("UseCustomPermissions") + .HasColumnType("INTEGER"); + + b.Property("UseDirectory") + .HasColumnType("INTEGER"); + + b.Property("UseDisableSmAdsForUsers") + .HasColumnType("INTEGER"); + + b.Property("UseEvents") + .HasColumnType("INTEGER"); + + b.Property("UseGroups") + .HasColumnType("INTEGER"); + + b.Property("UseInviteLinks") + .HasColumnType("INTEGER"); + + b.Property("UseKeyConnector") + .HasColumnType("INTEGER"); + + b.Property("UseMyItems") + .HasColumnType("INTEGER"); + + b.Property("UseOrganizationDomains") + .HasColumnType("INTEGER"); + + b.Property("UsePasswordManager") + .HasColumnType("INTEGER"); + + b.Property("UsePhishingBlocker") + .HasColumnType("INTEGER"); + + b.Property("UsePolicies") + .HasColumnType("INTEGER"); + + b.Property("UseResetPassword") + .HasColumnType("INTEGER"); + + b.Property("UseRiskInsights") + .HasColumnType("INTEGER"); + + b.Property("UseScim") + .HasColumnType("INTEGER"); + + b.Property("UseSecretsManager") + .HasColumnType("INTEGER"); + + b.Property("UseSso") + .HasColumnType("INTEGER"); + + b.Property("UseTotp") + .HasColumnType("INTEGER"); + + b.Property("UsersGetPremium") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GatewayCustomerId"); + + b.HasIndex("GatewaySubscriptionId"); + + b.HasIndex("Id", "Enabled") + .HasAnnotation("Npgsql:IndexInclude", new[] { "UseTotp", "UsersGetPremium" }); + + b.ToTable("Organization", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationInviteLink", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AllowedDomains") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Code") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("EncryptedInviteKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("EncryptedOrgKey") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationInviteLink", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "Type") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Policy", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("BillingEmail") + .HasColumnType("TEXT"); + + b.Property("BillingPhone") + .HasColumnType("TEXT"); + + b.Property("BusinessAddress1") + .HasColumnType("TEXT"); + + b.Property("BusinessAddress2") + .HasColumnType("TEXT"); + + b.Property("BusinessAddress3") + .HasColumnType("TEXT"); + + b.Property("BusinessCountry") + .HasColumnType("TEXT"); + + b.Property("BusinessName") + .HasColumnType("TEXT"); + + b.Property("BusinessTaxNumber") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("DiscountId") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Gateway") + .HasColumnType("INTEGER"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UseEvents") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GatewayCustomerId"); + + b.HasIndex("GatewaySubscriptionId"); + + b.ToTable("Provider", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Settings") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ProviderId"); + + b.ToTable("ProviderOrganization", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("Permissions") + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.HasIndex("UserId"); + + b.ToTable("ProviderUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.AuthRequest", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessCode") + .HasMaxLength(25) + .HasColumnType("TEXT"); + + b.Property("Approved") + .HasColumnType("INTEGER"); + + b.Property("AuthenticationDate") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("MasterPasswordHash") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("PublicKey") + .HasColumnType("TEXT"); + + b.Property("RequestCountryName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("RequestDeviceIdentifier") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("RequestDeviceType") + .HasColumnType("INTEGER"); + + b.Property("RequestIpAddress") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("ResponseDate") + .HasColumnType("TEXT"); + + b.Property("ResponseDeviceId") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ResponseDeviceId"); + + b.HasIndex("UserId"); + + b.ToTable("AuthRequest", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("GranteeId") + .HasColumnType("TEXT"); + + b.Property("GrantorId") + .HasColumnType("TEXT"); + + b.Property("KeyEncrypted") + .HasColumnType("TEXT"); + + b.Property("LastNotificationDate") + .HasColumnType("TEXT"); + + b.Property("RecoveryInitiatedDate") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("WaitTimeDays") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GranteeId"); + + b.HasIndex("GrantorId"); + + b.ToTable("EmergencyAccess", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.Grant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClientId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ConsumedDate") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Data") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ExpirationDate") + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SessionId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SubjectId") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasName("PK_Grant") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpirationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("Grant", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("SsoConfig", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId"); + + b.HasIndex("OrganizationId", "ExternalId") + .IsUnique() + .HasAnnotation("Npgsql:IndexInclude", new[] { "UserId" }) + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "UserId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SsoUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AaGuid") + .HasColumnType("TEXT"); + + b.Property("Counter") + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("CredentialId") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EncryptedPrivateKey") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("EncryptedPublicKey") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("EncryptedUserKey") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PublicKey") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("SupportsPrf") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("WebAuthnCredential", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ClientOrganizationMigrationRecord", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ExpirationDate") + .HasColumnType("TEXT"); + + b.Property("GatewayCustomerId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("GatewaySubscriptionId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("INTEGER"); + + b.Property("MaxStorageGb") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("PlanType") + .HasColumnType("INTEGER"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("Seats") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId", "OrganizationId") + .IsUnique(); + + b.ToTable("ClientOrganizationMigrationRecord", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("InstallationId") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("InstallationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationInstallation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationPlanMigrationCohort", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ChurnDiscountCouponCode") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("MigrationPathId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("ProactiveDiscountCouponCode") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("Name") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationPlanMigrationCohort", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationPlanMigrationCohortAssignment", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ChurnDiscountAppliedDate") + .HasColumnType("TEXT"); + + b.Property("CohortId") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("MigratedDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("ScheduledDate") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("CohortId", "ScheduledDate", "MigratedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationPlanMigrationCohortAssignment", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AssignedSeats") + .HasColumnType("INTEGER"); + + b.Property("ClientId") + .HasColumnType("TEXT"); + + b.Property("ClientName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("InvoiceId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("InvoiceNumber") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PlanName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("Total") + .HasColumnType("TEXT"); + + b.Property("UsedSeats") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.ToTable("ProviderInvoiceItem", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AllocatedSeats") + .HasColumnType("INTEGER"); + + b.Property("PlanType") + .HasColumnType("INTEGER"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("PurchasedSeats") + .HasColumnType("INTEGER"); + + b.Property("SeatMinimum") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.HasIndex("Id", "PlanType") + .IsUnique(); + + b.ToTable("ProviderPlan", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.SubscriptionDiscount", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AmountOff") + .HasColumnType("INTEGER"); + + b.Property("AudienceType") + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Currency") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("Duration") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("DurationInMonths") + .HasColumnType("INTEGER"); + + b.Property("EndDate") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("PercentOff") + .HasPrecision(5, 2) + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("StartDate") + .HasColumnType("TEXT"); + + b.Property("StripeCouponId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("StripeProductIds") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("StripeCouponId") + .IsUnique(); + + b.HasIndex("StartDate", "EndDate") + .HasDatabaseName("IX_SubscriptionDiscount_DateRange") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SubscriptionDiscount", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationApplication", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Applications") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ContentEncryptionKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationApplication", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegration", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Configuration") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "Type") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationIntegration", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegrationConfiguration", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Configuration") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("EventType") + .HasColumnType("INTEGER"); + + b.Property("Filters") + .HasColumnType("TEXT"); + + b.Property("OrganizationIntegrationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Template") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationIntegrationId"); + + b.ToTable("OrganizationIntegrationConfiguration", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ApplicationAtRiskCount") + .HasColumnType("INTEGER"); + + b.Property("ApplicationCount") + .HasColumnType("INTEGER"); + + b.Property("ApplicationData") + .HasColumnType("TEXT"); + + b.Property("ContentEncryptionKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("CriticalApplicationAtRiskCount") + .HasColumnType("INTEGER"); + + b.Property("CriticalApplicationCount") + .HasColumnType("INTEGER"); + + b.Property("CriticalMemberAtRiskCount") + .HasColumnType("INTEGER"); + + b.Property("CriticalMemberCount") + .HasColumnType("INTEGER"); + + b.Property("CriticalPasswordAtRiskCount") + .HasColumnType("INTEGER"); + + b.Property("CriticalPasswordCount") + .HasColumnType("INTEGER"); + + b.Property("MemberAtRiskCount") + .HasColumnType("INTEGER"); + + b.Property("MemberCount") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("PasswordAtRiskCount") + .HasColumnType("INTEGER"); + + b.Property("PasswordCount") + .HasColumnType("INTEGER"); + + b.Property("ReportData") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ReportFile") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("SummaryData") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationReport", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.PasswordHealthReportApplication", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Uri") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("PasswordHealthReportApplication", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Cache", b => + { + b.Property("Id") + .HasMaxLength(449) + .HasColumnType("TEXT"); + + b.Property("AbsoluteExpiration") + .HasColumnType("TEXT"); + + b.Property("ExpiresAtTime") + .HasColumnType("TEXT"); + + b.Property("SlidingExpirationInSeconds") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasColumnType("BLOB"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpiresAtTime") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Cache", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.Property("CollectionId") + .HasColumnType("TEXT"); + + b.Property("CipherId") + .HasColumnType("TEXT"); + + b.HasKey("CollectionId", "CipherId"); + + b.HasIndex("CipherId"); + + b.ToTable("CollectionCipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Active") + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("ClientVersion") + .HasMaxLength(43) + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("EncryptedPrivateKey") + .HasColumnType("TEXT"); + + b.Property("EncryptedPublicKey") + .HasColumnType("TEXT"); + + b.Property("EncryptedUserKey") + .HasColumnType("TEXT"); + + b.Property("Identifier") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("LastActivityDate") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PushToken") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Identifier") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "Identifier") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Device", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Event", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ActingUserId") + .HasColumnType("TEXT"); + + b.Property("CipherId") + .HasColumnType("TEXT"); + + b.Property("CollectionId") + .HasColumnType("TEXT"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("DeviceType") + .HasColumnType("INTEGER"); + + b.Property("DomainName") + .HasColumnType("TEXT"); + + b.Property("GrantedServiceAccountId") + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("InstallationId") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("OrganizationUserId") + .HasColumnType("TEXT"); + + b.Property("PolicyId") + .HasColumnType("TEXT"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("ProviderOrganizationId") + .HasColumnType("TEXT"); + + b.Property("ProviderUserId") + .HasColumnType("TEXT"); + + b.Property("SecretId") + .HasColumnType("TEXT"); + + b.Property("ServiceAccountId") + .HasColumnType("TEXT"); + + b.Property("SystemUser") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("Date", "OrganizationId", "ActingUserId", "CipherId") + .HasDatabaseName("IX_Event_DateOrganizationIdUserId") + .HasAnnotation("SqlServer:Clustered", false) + .HasAnnotation("SqlServer:Include", new[] { "ServiceAccountId", "GrantedServiceAccountId" }); + + b.ToTable("Event", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Group", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("OrganizationUserId") + .HasColumnType("TEXT"); + + b.HasKey("GroupId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("GroupUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Config") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationConnection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("DomainName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("JobRunCount") + .HasColumnType("INTEGER"); + + b.Property("LastCheckedDate") + .HasColumnType("TEXT"); + + b.Property("NextRunDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Txt") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("VerifiedDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationDomain", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("FriendlyName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAdminInitiated") + .HasColumnType("INTEGER"); + + b.Property("LastSyncDate") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("OfferedToEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PlanSponsorshipType") + .HasColumnType("INTEGER"); + + b.Property("SponsoredOrganizationId") + .HasColumnType("TEXT"); + + b.Property("SponsoringOrganizationId") + .HasColumnType("TEXT"); + + b.Property("SponsoringOrganizationUserId") + .HasColumnType("TEXT"); + + b.Property("ToDelete") + .HasColumnType("INTEGER"); + + b.Property("ValidUntil") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SponsoredOrganizationId"); + + b.HasIndex("SponsoringOrganizationId"); + + b.HasIndex("SponsoringOrganizationUserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationSponsorship", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessSecretsManager") + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Permissions") + .HasColumnType("TEXT"); + + b.Property("ResetPasswordKey") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("RevocationReason") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("StatusNew") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.PlayItem", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("PlayId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("PlayId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("PlayItem", null, t => + { + t.HasCheckConstraint("CK_PlayItem_UserOrOrganization", "(\"UserId\" IS NOT NULL AND \"OrganizationId\" IS NULL) OR (\"UserId\" IS NULL AND \"OrganizationId\" IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessCount") + .HasColumnType("INTEGER"); + + b.Property("AuthType") + .HasColumnType("INTEGER"); + + b.Property("CipherId") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Data") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DeletionDate") + .HasColumnType("TEXT"); + + b.Property("Disabled") + .HasColumnType("INTEGER"); + + b.Property("Emails") + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("ExpirationDate") + .HasColumnType("TEXT"); + + b.Property("HideEmail") + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("MaxAccessCount") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeletionDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Send", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.TaxRate", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("TEXT"); + + b.Property("Active") + .HasColumnType("INTEGER"); + + b.Property("Country") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PostalCode") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("Rate") + .HasColumnType("TEXT"); + + b.Property("State") + .HasMaxLength(2) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("TaxRate", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Amount") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Gateway") + .HasColumnType("INTEGER"); + + b.Property("GatewayId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("PaymentMethodType") + .HasColumnType("INTEGER"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("Refunded") + .HasColumnType("INTEGER"); + + b.Property("RefundedAmount") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ProviderId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId", "CreationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Transaction", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccountRevisionDate") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.Property("AvatarColor") + .HasMaxLength(7) + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Culture") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailVerified") + .HasColumnType("INTEGER"); + + b.Property("EquivalentDomains") + .HasColumnType("TEXT"); + + b.Property("ExcludedGlobalEquivalentDomains") + .HasColumnType("TEXT"); + + b.Property("FailedLoginCount") + .HasColumnType("INTEGER"); + + b.Property("ForcePasswordReset") + .HasColumnType("INTEGER"); + + b.Property("Gateway") + .HasColumnType("INTEGER"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Kdf") + .HasColumnType("INTEGER"); + + b.Property("KdfIterations") + .HasColumnType("INTEGER"); + + b.Property("KdfMemory") + .HasColumnType("INTEGER"); + + b.Property("KdfParallelism") + .HasColumnType("INTEGER"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("LastApiKeyRotationDate") + .HasColumnType("TEXT"); + + b.Property("LastEmailChangeDate") + .HasColumnType("TEXT"); + + b.Property("LastFailedLoginDate") + .HasColumnType("TEXT"); + + b.Property("LastKdfChangeDate") + .HasColumnType("TEXT"); + + b.Property("LastKeyRotationDate") + .HasColumnType("TEXT"); + + b.Property("LastPasswordChangeDate") + .HasColumnType("TEXT"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("MasterPassword") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("MasterPasswordHint") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("MasterPasswordSalt") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("MaxStorageGb") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Premium") + .HasColumnType("INTEGER"); + + b.Property("PremiumExpirationDate") + .HasColumnType("TEXT"); + + b.Property("PrivateKey") + .HasColumnType("TEXT"); + + b.Property("PublicKey") + .HasColumnType("TEXT"); + + b.Property("ReferenceData") + .HasColumnType("TEXT"); + + b.Property("RenewalReminderDate") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("SecurityStamp") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("SecurityState") + .HasColumnType("TEXT"); + + b.Property("SecurityVersion") + .HasColumnType("INTEGER"); + + b.Property("SignedPublicKey") + .HasColumnType("TEXT"); + + b.Property("Storage") + .HasColumnType("INTEGER"); + + b.Property("TwoFactorProviders") + .HasColumnType("TEXT"); + + b.Property("TwoFactorRecoveryCode") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("UsesKeyConnector") + .HasColumnType("INTEGER"); + + b.Property("V2UpgradeToken") + .HasColumnType("TEXT"); + + b.Property("VerifyDevices") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("GatewayCustomerId"); + + b.HasIndex("GatewaySubscriptionId"); + + b.HasIndex("Premium", "PremiumExpirationDate", "RenewalReminderDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("User", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.UserSignatureKeyPair", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("SignatureAlgorithm") + .HasColumnType("INTEGER"); + + b.Property("SigningKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("VerifyingKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("UserSignatureKeyPair", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Body") + .HasMaxLength(3000) + .HasColumnType("TEXT"); + + b.Property("ClientType") + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Global") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Priority") + .HasColumnType("INTEGER"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("TaskId") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("TaskId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("ClientType", "Global", "UserId", "OrganizationId", "Priority", "CreationDate") + .IsDescending(false, false, false, false, true, true) + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Notification", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("NotificationId") + .HasColumnType("TEXT"); + + b.Property("DeletedDate") + .HasColumnType("TEXT"); + + b.Property("ReadDate") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "NotificationId") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("NotificationId"); + + b.ToTable("NotificationStatus", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Pam.Models.AccessRule", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AllowsExtensions") + .HasColumnType("INTEGER"); + + b.Property("Conditions") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("DefaultLeaseDurationSeconds") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("MaxExtensionDurationSeconds") + .HasColumnType("INTEGER"); + + b.Property("MaxLeaseDurationSeconds") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("SingleActiveLease") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId", "Name") + .IsUnique(); + + b.ToTable("AccessRule", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Platform.Installation", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("TEXT"); + + b.Property("LastActivityDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Installation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Discriminator") + .IsRequired() + .HasMaxLength(34) + .HasColumnType("TEXT"); + + b.Property("Read") + .HasColumnType("INTEGER"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Write") + .HasColumnType("INTEGER"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.ToTable("AccessPolicy", (string)null); + + b.HasDiscriminator().HasValue("AccessPolicy"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ClientSecretHash") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("EncryptedPayload") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("ExpireAt") + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("ServiceAccountId") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ServiceAccountId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("DeletedDate") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Project", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("DeletedDate") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("Note") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Secret", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.SecretVersion", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("EditorOrganizationUserId") + .HasColumnType("TEXT"); + + b.Property("EditorServiceAccountId") + .HasColumnType("TEXT"); + + b.Property("SecretId") + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("VersionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("EditorOrganizationUserId") + .HasDatabaseName("IX_SecretVersion_EditorOrganizationUserId"); + + b.HasIndex("EditorServiceAccountId") + .HasDatabaseName("IX_SecretVersion_EditorServiceAccountId"); + + b.HasIndex("SecretId") + .HasDatabaseName("IX_SecretVersion_SecretId"); + + b.ToTable("SecretVersion"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ServiceAccount", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Archives") + .HasColumnType("TEXT"); + + b.Property("Attachments") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("DeletedDate") + .HasColumnType("TEXT"); + + b.Property("Favorites") + .HasColumnType("TEXT"); + + b.Property("Folders") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Reprompt") + .HasColumnType("INTEGER"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId"); + + b.ToTable("Cipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Folder", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CipherId") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("CipherId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SecurityTask", (string)null); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.Property("ProjectsId") + .HasColumnType("TEXT"); + + b.Property("SecretsId") + .HasColumnType("TEXT"); + + b.HasKey("ProjectsId", "SecretsId"); + + b.HasIndex("SecretsId"); + + b.ToTable("ProjectSecret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedProjectId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedSecretId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedProjectId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedSecretId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedProjectId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedSecretId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Collection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Pam.Models.AccessRule", null) + .WithMany() + .HasForeignKey("AccessRuleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Collections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.CollectionGroup", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Collection", "Collection") + .WithMany("CollectionGroups") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.CollectionUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Collection", "Collection") + .WithMany("CollectionUsers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("CollectionUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationInviteLink", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Policies") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.AuthRequest", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Device", "ResponseDevice") + .WithMany() + .HasForeignKey("ResponseDeviceId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("ResponseDevice"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantee") + .WithMany() + .HasForeignKey("GranteeId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantor") + .WithMany() + .HasForeignKey("GrantorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Grantee"); + + b.Navigation("Grantor"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoConfigs") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoUsers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("SsoUsers") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Platform.Installation", "Installation") + .WithMany() + .HasForeignKey("InstallationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Installation"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationPlanMigrationCohortAssignment", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationPlanMigrationCohort", "Cohort") + .WithMany() + .HasForeignKey("CohortId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cohort"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationApplication", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegrationConfiguration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegration", "OrganizationIntegration") + .WithMany() + .HasForeignKey("OrganizationIntegrationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("OrganizationIntegration"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.PasswordHealthReportApplication", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany("CollectionCiphers") + .HasForeignKey("CipherId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Collection", "Collection") + .WithMany("CollectionCiphers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Collection"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Groups") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany("GroupUsers") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("GroupUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("ApiKeys") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Connections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Domains") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoredOrganization") + .WithMany() + .HasForeignKey("SponsoredOrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoringOrganization") + .WithMany() + .HasForeignKey("SponsoringOrganizationId"); + + b.Navigation("SponsoredOrganization"); + + b.Navigation("SponsoringOrganization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("OrganizationUsers") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("OrganizationUsers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.PlayItem", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Transactions") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Transactions") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.UserSignatureKeyPair", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", "Task") + .WithMany() + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Task"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", "Notification") + .WithMany() + .HasForeignKey("NotificationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Notification"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Pam.Models.AccessRule", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ApiKeys") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.SecretVersion", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "EditorOrganizationUser") + .WithMany() + .HasForeignKey("EditorOrganizationUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "EditorServiceAccount") + .WithMany() + .HasForeignKey("EditorServiceAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "Secret") + .WithMany("SecretVersions") + .HasForeignKey("SecretId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("EditorOrganizationUser"); + + b.Navigation("EditorServiceAccount"); + + b.Navigation("Secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Ciphers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Ciphers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Folders") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany() + .HasForeignKey("CipherId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", null) + .WithMany() + .HasForeignKey("ProjectsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", null) + .WithMany() + .HasForeignKey("SecretsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedProject"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedSecret"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ProjectAccessPolicies") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedProject"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany() + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedProject"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Collection", b => + { + b.Navigation("CollectionCiphers"); + + b.Navigation("CollectionGroups"); + + b.Navigation("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("Ciphers"); + + b.Navigation("Collections"); + + b.Navigation("Connections"); + + b.Navigation("Domains"); + + b.Navigation("Groups"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("Policies"); + + b.Navigation("SsoConfigs"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Navigation("CollectionUsers"); + + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Navigation("Ciphers"); + + b.Navigation("Folders"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("SecretVersions"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ProjectAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Navigation("CollectionCiphers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/util/SqliteMigrations/Migrations/20260616102508_AddAccessRuleLeaseConfiguration.cs b/util/SqliteMigrations/Migrations/20260616102508_AddAccessRuleLeaseConfiguration.cs new file mode 100644 index 000000000000..97132fa50a66 --- /dev/null +++ b/util/SqliteMigrations/Migrations/20260616102508_AddAccessRuleLeaseConfiguration.cs @@ -0,0 +1,80 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bit.SqliteMigrations.Migrations; + +/// +public partial class AddAccessRuleLeaseConfiguration : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "AllowsExtensions", + table: "AccessRule", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "DefaultLeaseDurationSeconds", + table: "AccessRule", + type: "INTEGER", + nullable: true); + + migrationBuilder.AddColumn( + name: "Enabled", + table: "AccessRule", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "MaxExtensionDurationSeconds", + table: "AccessRule", + type: "INTEGER", + nullable: true); + + migrationBuilder.AddColumn( + name: "MaxLeaseDurationSeconds", + table: "AccessRule", + type: "INTEGER", + nullable: true); + + migrationBuilder.AddColumn( + name: "SingleActiveLease", + table: "AccessRule", + type: "INTEGER", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "AllowsExtensions", + table: "AccessRule"); + + migrationBuilder.DropColumn( + name: "DefaultLeaseDurationSeconds", + table: "AccessRule"); + + migrationBuilder.DropColumn( + name: "Enabled", + table: "AccessRule"); + + migrationBuilder.DropColumn( + name: "MaxExtensionDurationSeconds", + table: "AccessRule"); + + migrationBuilder.DropColumn( + name: "MaxLeaseDurationSeconds", + table: "AccessRule"); + + migrationBuilder.DropColumn( + name: "SingleActiveLease", + table: "AccessRule"); + } +} diff --git a/util/SqliteMigrations/Migrations/20260616102557_AddUsePamToOrganization.Designer.cs b/util/SqliteMigrations/Migrations/20260616102557_AddUsePamToOrganization.Designer.cs new file mode 100644 index 000000000000..0735e524948e --- /dev/null +++ b/util/SqliteMigrations/Migrations/20260616102557_AddUsePamToOrganization.Designer.cs @@ -0,0 +1,3831 @@ +// +using System; +using Bit.Infrastructure.EntityFramework.Repositories; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Bit.SqliteMigrations.Migrations +{ + [DbContext(typeof(DatabaseContext))] + [Migration("20260616102557_AddUsePamToOrganization")] + partial class AddUsePamToOrganization + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.8"); + + modelBuilder.Entity("Bit.Core.Dirt.Reports.Models.Data.OrganizationMemberBaseDetail", b => + { + b.Property("CipherId") + .HasColumnType("TEXT"); + + b.Property("CollectionId") + .HasColumnType("TEXT"); + + b.Property("CollectionName") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("GroupName") + .HasColumnType("TEXT"); + + b.Property("HidePasswords") + .HasColumnType("INTEGER"); + + b.Property("Manage") + .HasColumnType("INTEGER"); + + b.Property("ReadOnly") + .HasColumnType("INTEGER"); + + b.Property("ResetPasswordKey") + .HasColumnType("TEXT"); + + b.Property("TwoFactorProviders") + .HasColumnType("TEXT"); + + b.Property("UserGuid") + .HasColumnType("TEXT"); + + b.Property("UserName") + .HasColumnType("TEXT"); + + b.Property("UsesKeyConnector") + .HasColumnType("INTEGER"); + + b.ToTable("OrganizationMemberBaseDetails"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Collection", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessRuleId") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("DefaultUserCollectionEmail") + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AccessRuleId"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Collection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.CollectionGroup", b => + { + b.Property("CollectionId") + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("HidePasswords") + .HasColumnType("INTEGER"); + + b.Property("Manage") + .HasColumnType("INTEGER"); + + b.Property("ReadOnly") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("CollectionGroups"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.CollectionUser", b => + { + b.Property("CollectionId") + .HasColumnType("TEXT"); + + b.Property("OrganizationUserId") + .HasColumnType("TEXT"); + + b.Property("HidePasswords") + .HasColumnType("INTEGER"); + + b.Property("Manage") + .HasColumnType("INTEGER"); + + b.Property("ReadOnly") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AllowAdminAccessToAllCollectionItems") + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("BillingEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("BusinessAddress1") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("BusinessAddress2") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("BusinessAddress3") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("BusinessCountry") + .HasMaxLength(2) + .HasColumnType("TEXT"); + + b.Property("BusinessName") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("BusinessTaxNumber") + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("ExemptFromBillingAutomation") + .HasColumnType("INTEGER"); + + b.Property("ExpirationDate") + .HasColumnType("TEXT"); + + b.Property("Gateway") + .HasColumnType("INTEGER"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Identifier") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LimitCollectionCreation") + .HasColumnType("INTEGER"); + + b.Property("LimitCollectionDeletion") + .HasColumnType("INTEGER"); + + b.Property("LimitItemDeletion") + .HasColumnType("INTEGER"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("INTEGER"); + + b.Property("MaxAutoscaleSmSeats") + .HasColumnType("INTEGER"); + + b.Property("MaxAutoscaleSmServiceAccounts") + .HasColumnType("INTEGER"); + + b.Property("MaxCollections") + .HasColumnType("INTEGER"); + + b.Property("MaxStorageGb") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("OwnersNotifiedOfAutoscaling") + .HasColumnType("TEXT"); + + b.Property("Plan") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PlanType") + .HasColumnType("INTEGER"); + + b.Property("PrivateKey") + .HasColumnType("TEXT"); + + b.Property("PublicKey") + .HasColumnType("TEXT"); + + b.Property("ReferenceData") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Seats") + .HasColumnType("INTEGER"); + + b.Property("SelfHost") + .HasColumnType("INTEGER"); + + b.Property("SmSeats") + .HasColumnType("INTEGER"); + + b.Property("SmServiceAccounts") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Storage") + .HasColumnType("INTEGER"); + + b.Property("SyncSeats") + .HasColumnType("INTEGER"); + + b.Property("TwoFactorProviders") + .HasColumnType("TEXT"); + + b.Property("Use2fa") + .HasColumnType("INTEGER"); + + b.Property("UseAdminSponsoredFamilies") + .HasColumnType("INTEGER"); + + b.Property("UseApi") + .HasColumnType("INTEGER"); + + b.Property("UseAutomaticUserConfirmation") + .HasColumnType("INTEGER"); + + b.Property("UseCustomPermissions") + .HasColumnType("INTEGER"); + + b.Property("UseDirectory") + .HasColumnType("INTEGER"); + + b.Property("UseDisableSmAdsForUsers") + .HasColumnType("INTEGER"); + + b.Property("UseEvents") + .HasColumnType("INTEGER"); + + b.Property("UseGroups") + .HasColumnType("INTEGER"); + + b.Property("UseInviteLinks") + .HasColumnType("INTEGER"); + + b.Property("UseKeyConnector") + .HasColumnType("INTEGER"); + + b.Property("UseMyItems") + .HasColumnType("INTEGER"); + + b.Property("UseOrganizationDomains") + .HasColumnType("INTEGER"); + + b.Property("UsePam") + .HasColumnType("INTEGER"); + + b.Property("UsePasswordManager") + .HasColumnType("INTEGER"); + + b.Property("UsePhishingBlocker") + .HasColumnType("INTEGER"); + + b.Property("UsePolicies") + .HasColumnType("INTEGER"); + + b.Property("UseResetPassword") + .HasColumnType("INTEGER"); + + b.Property("UseRiskInsights") + .HasColumnType("INTEGER"); + + b.Property("UseScim") + .HasColumnType("INTEGER"); + + b.Property("UseSecretsManager") + .HasColumnType("INTEGER"); + + b.Property("UseSso") + .HasColumnType("INTEGER"); + + b.Property("UseTotp") + .HasColumnType("INTEGER"); + + b.Property("UsersGetPremium") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GatewayCustomerId"); + + b.HasIndex("GatewaySubscriptionId"); + + b.HasIndex("Id", "Enabled") + .HasAnnotation("Npgsql:IndexInclude", new[] { "UseTotp", "UsersGetPremium" }); + + b.ToTable("Organization", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationInviteLink", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AllowedDomains") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Code") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("EncryptedInviteKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("EncryptedOrgKey") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationInviteLink", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "Type") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Policy", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("BillingEmail") + .HasColumnType("TEXT"); + + b.Property("BillingPhone") + .HasColumnType("TEXT"); + + b.Property("BusinessAddress1") + .HasColumnType("TEXT"); + + b.Property("BusinessAddress2") + .HasColumnType("TEXT"); + + b.Property("BusinessAddress3") + .HasColumnType("TEXT"); + + b.Property("BusinessCountry") + .HasColumnType("TEXT"); + + b.Property("BusinessName") + .HasColumnType("TEXT"); + + b.Property("BusinessTaxNumber") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("DiscountId") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Gateway") + .HasColumnType("INTEGER"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UseEvents") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GatewayCustomerId"); + + b.HasIndex("GatewaySubscriptionId"); + + b.ToTable("Provider", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Settings") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ProviderId"); + + b.ToTable("ProviderOrganization", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("Permissions") + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.HasIndex("UserId"); + + b.ToTable("ProviderUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.AuthRequest", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessCode") + .HasMaxLength(25) + .HasColumnType("TEXT"); + + b.Property("Approved") + .HasColumnType("INTEGER"); + + b.Property("AuthenticationDate") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("MasterPasswordHash") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("PublicKey") + .HasColumnType("TEXT"); + + b.Property("RequestCountryName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("RequestDeviceIdentifier") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("RequestDeviceType") + .HasColumnType("INTEGER"); + + b.Property("RequestIpAddress") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("ResponseDate") + .HasColumnType("TEXT"); + + b.Property("ResponseDeviceId") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ResponseDeviceId"); + + b.HasIndex("UserId"); + + b.ToTable("AuthRequest", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("GranteeId") + .HasColumnType("TEXT"); + + b.Property("GrantorId") + .HasColumnType("TEXT"); + + b.Property("KeyEncrypted") + .HasColumnType("TEXT"); + + b.Property("LastNotificationDate") + .HasColumnType("TEXT"); + + b.Property("RecoveryInitiatedDate") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("WaitTimeDays") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GranteeId"); + + b.HasIndex("GrantorId"); + + b.ToTable("EmergencyAccess", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.Grant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClientId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ConsumedDate") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Data") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ExpirationDate") + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SessionId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SubjectId") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasName("PK_Grant") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpirationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("Grant", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("SsoConfig", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId"); + + b.HasIndex("OrganizationId", "ExternalId") + .IsUnique() + .HasAnnotation("Npgsql:IndexInclude", new[] { "UserId" }) + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "UserId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SsoUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AaGuid") + .HasColumnType("TEXT"); + + b.Property("Counter") + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("CredentialId") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EncryptedPrivateKey") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("EncryptedPublicKey") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("EncryptedUserKey") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PublicKey") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("SupportsPrf") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("WebAuthnCredential", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ClientOrganizationMigrationRecord", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ExpirationDate") + .HasColumnType("TEXT"); + + b.Property("GatewayCustomerId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("GatewaySubscriptionId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("INTEGER"); + + b.Property("MaxStorageGb") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("PlanType") + .HasColumnType("INTEGER"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("Seats") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId", "OrganizationId") + .IsUnique(); + + b.ToTable("ClientOrganizationMigrationRecord", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("InstallationId") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("InstallationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationInstallation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationPlanMigrationCohort", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ChurnDiscountCouponCode") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("MigrationPathId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("ProactiveDiscountCouponCode") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("Name") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationPlanMigrationCohort", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationPlanMigrationCohortAssignment", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ChurnDiscountAppliedDate") + .HasColumnType("TEXT"); + + b.Property("CohortId") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("MigratedDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("ScheduledDate") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("CohortId", "ScheduledDate", "MigratedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationPlanMigrationCohortAssignment", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AssignedSeats") + .HasColumnType("INTEGER"); + + b.Property("ClientId") + .HasColumnType("TEXT"); + + b.Property("ClientName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("InvoiceId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("InvoiceNumber") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PlanName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("Total") + .HasColumnType("TEXT"); + + b.Property("UsedSeats") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.ToTable("ProviderInvoiceItem", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AllocatedSeats") + .HasColumnType("INTEGER"); + + b.Property("PlanType") + .HasColumnType("INTEGER"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("PurchasedSeats") + .HasColumnType("INTEGER"); + + b.Property("SeatMinimum") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.HasIndex("Id", "PlanType") + .IsUnique(); + + b.ToTable("ProviderPlan", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.SubscriptionDiscount", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AmountOff") + .HasColumnType("INTEGER"); + + b.Property("AudienceType") + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Currency") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("Duration") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("DurationInMonths") + .HasColumnType("INTEGER"); + + b.Property("EndDate") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("PercentOff") + .HasPrecision(5, 2) + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("StartDate") + .HasColumnType("TEXT"); + + b.Property("StripeCouponId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("StripeProductIds") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("StripeCouponId") + .IsUnique(); + + b.HasIndex("StartDate", "EndDate") + .HasDatabaseName("IX_SubscriptionDiscount_DateRange") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SubscriptionDiscount", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationApplication", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Applications") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ContentEncryptionKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationApplication", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegration", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Configuration") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "Type") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationIntegration", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegrationConfiguration", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Configuration") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("EventType") + .HasColumnType("INTEGER"); + + b.Property("Filters") + .HasColumnType("TEXT"); + + b.Property("OrganizationIntegrationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Template") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationIntegrationId"); + + b.ToTable("OrganizationIntegrationConfiguration", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ApplicationAtRiskCount") + .HasColumnType("INTEGER"); + + b.Property("ApplicationCount") + .HasColumnType("INTEGER"); + + b.Property("ApplicationData") + .HasColumnType("TEXT"); + + b.Property("ContentEncryptionKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("CriticalApplicationAtRiskCount") + .HasColumnType("INTEGER"); + + b.Property("CriticalApplicationCount") + .HasColumnType("INTEGER"); + + b.Property("CriticalMemberAtRiskCount") + .HasColumnType("INTEGER"); + + b.Property("CriticalMemberCount") + .HasColumnType("INTEGER"); + + b.Property("CriticalPasswordAtRiskCount") + .HasColumnType("INTEGER"); + + b.Property("CriticalPasswordCount") + .HasColumnType("INTEGER"); + + b.Property("MemberAtRiskCount") + .HasColumnType("INTEGER"); + + b.Property("MemberCount") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("PasswordAtRiskCount") + .HasColumnType("INTEGER"); + + b.Property("PasswordCount") + .HasColumnType("INTEGER"); + + b.Property("ReportData") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ReportFile") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("SummaryData") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationReport", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.PasswordHealthReportApplication", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Uri") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("PasswordHealthReportApplication", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Cache", b => + { + b.Property("Id") + .HasMaxLength(449) + .HasColumnType("TEXT"); + + b.Property("AbsoluteExpiration") + .HasColumnType("TEXT"); + + b.Property("ExpiresAtTime") + .HasColumnType("TEXT"); + + b.Property("SlidingExpirationInSeconds") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasColumnType("BLOB"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpiresAtTime") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Cache", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.Property("CollectionId") + .HasColumnType("TEXT"); + + b.Property("CipherId") + .HasColumnType("TEXT"); + + b.HasKey("CollectionId", "CipherId"); + + b.HasIndex("CipherId"); + + b.ToTable("CollectionCipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Active") + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("ClientVersion") + .HasMaxLength(43) + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("EncryptedPrivateKey") + .HasColumnType("TEXT"); + + b.Property("EncryptedPublicKey") + .HasColumnType("TEXT"); + + b.Property("EncryptedUserKey") + .HasColumnType("TEXT"); + + b.Property("Identifier") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("LastActivityDate") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PushToken") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Identifier") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "Identifier") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Device", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Event", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ActingUserId") + .HasColumnType("TEXT"); + + b.Property("CipherId") + .HasColumnType("TEXT"); + + b.Property("CollectionId") + .HasColumnType("TEXT"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("DeviceType") + .HasColumnType("INTEGER"); + + b.Property("DomainName") + .HasColumnType("TEXT"); + + b.Property("GrantedServiceAccountId") + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("InstallationId") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("OrganizationUserId") + .HasColumnType("TEXT"); + + b.Property("PolicyId") + .HasColumnType("TEXT"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("ProviderOrganizationId") + .HasColumnType("TEXT"); + + b.Property("ProviderUserId") + .HasColumnType("TEXT"); + + b.Property("SecretId") + .HasColumnType("TEXT"); + + b.Property("ServiceAccountId") + .HasColumnType("TEXT"); + + b.Property("SystemUser") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("Date", "OrganizationId", "ActingUserId", "CipherId") + .HasDatabaseName("IX_Event_DateOrganizationIdUserId") + .HasAnnotation("SqlServer:Clustered", false) + .HasAnnotation("SqlServer:Include", new[] { "ServiceAccountId", "GrantedServiceAccountId" }); + + b.ToTable("Event", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Group", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("OrganizationUserId") + .HasColumnType("TEXT"); + + b.HasKey("GroupId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("GroupUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Config") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationConnection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("DomainName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("JobRunCount") + .HasColumnType("INTEGER"); + + b.Property("LastCheckedDate") + .HasColumnType("TEXT"); + + b.Property("NextRunDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Txt") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("VerifiedDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationDomain", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("FriendlyName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAdminInitiated") + .HasColumnType("INTEGER"); + + b.Property("LastSyncDate") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("OfferedToEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PlanSponsorshipType") + .HasColumnType("INTEGER"); + + b.Property("SponsoredOrganizationId") + .HasColumnType("TEXT"); + + b.Property("SponsoringOrganizationId") + .HasColumnType("TEXT"); + + b.Property("SponsoringOrganizationUserId") + .HasColumnType("TEXT"); + + b.Property("ToDelete") + .HasColumnType("INTEGER"); + + b.Property("ValidUntil") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SponsoredOrganizationId"); + + b.HasIndex("SponsoringOrganizationId"); + + b.HasIndex("SponsoringOrganizationUserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationSponsorship", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessSecretsManager") + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Permissions") + .HasColumnType("TEXT"); + + b.Property("ResetPasswordKey") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("RevocationReason") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("StatusNew") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.PlayItem", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("PlayId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("PlayId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("PlayItem", null, t => + { + t.HasCheckConstraint("CK_PlayItem_UserOrOrganization", "(\"UserId\" IS NOT NULL AND \"OrganizationId\" IS NULL) OR (\"UserId\" IS NULL AND \"OrganizationId\" IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessCount") + .HasColumnType("INTEGER"); + + b.Property("AuthType") + .HasColumnType("INTEGER"); + + b.Property("CipherId") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Data") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DeletionDate") + .HasColumnType("TEXT"); + + b.Property("Disabled") + .HasColumnType("INTEGER"); + + b.Property("Emails") + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("ExpirationDate") + .HasColumnType("TEXT"); + + b.Property("HideEmail") + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("MaxAccessCount") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeletionDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Send", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.TaxRate", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("TEXT"); + + b.Property("Active") + .HasColumnType("INTEGER"); + + b.Property("Country") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PostalCode") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("Rate") + .HasColumnType("TEXT"); + + b.Property("State") + .HasMaxLength(2) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("TaxRate", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Amount") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Gateway") + .HasColumnType("INTEGER"); + + b.Property("GatewayId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("PaymentMethodType") + .HasColumnType("INTEGER"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("Refunded") + .HasColumnType("INTEGER"); + + b.Property("RefundedAmount") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ProviderId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId", "CreationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Transaction", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccountRevisionDate") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.Property("AvatarColor") + .HasMaxLength(7) + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Culture") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailVerified") + .HasColumnType("INTEGER"); + + b.Property("EquivalentDomains") + .HasColumnType("TEXT"); + + b.Property("ExcludedGlobalEquivalentDomains") + .HasColumnType("TEXT"); + + b.Property("FailedLoginCount") + .HasColumnType("INTEGER"); + + b.Property("ForcePasswordReset") + .HasColumnType("INTEGER"); + + b.Property("Gateway") + .HasColumnType("INTEGER"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Kdf") + .HasColumnType("INTEGER"); + + b.Property("KdfIterations") + .HasColumnType("INTEGER"); + + b.Property("KdfMemory") + .HasColumnType("INTEGER"); + + b.Property("KdfParallelism") + .HasColumnType("INTEGER"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("LastApiKeyRotationDate") + .HasColumnType("TEXT"); + + b.Property("LastEmailChangeDate") + .HasColumnType("TEXT"); + + b.Property("LastFailedLoginDate") + .HasColumnType("TEXT"); + + b.Property("LastKdfChangeDate") + .HasColumnType("TEXT"); + + b.Property("LastKeyRotationDate") + .HasColumnType("TEXT"); + + b.Property("LastPasswordChangeDate") + .HasColumnType("TEXT"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("MasterPassword") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("MasterPasswordHint") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("MasterPasswordSalt") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("MaxStorageGb") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Premium") + .HasColumnType("INTEGER"); + + b.Property("PremiumExpirationDate") + .HasColumnType("TEXT"); + + b.Property("PrivateKey") + .HasColumnType("TEXT"); + + b.Property("PublicKey") + .HasColumnType("TEXT"); + + b.Property("ReferenceData") + .HasColumnType("TEXT"); + + b.Property("RenewalReminderDate") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("SecurityStamp") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("SecurityState") + .HasColumnType("TEXT"); + + b.Property("SecurityVersion") + .HasColumnType("INTEGER"); + + b.Property("SignedPublicKey") + .HasColumnType("TEXT"); + + b.Property("Storage") + .HasColumnType("INTEGER"); + + b.Property("TwoFactorProviders") + .HasColumnType("TEXT"); + + b.Property("TwoFactorRecoveryCode") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("UsesKeyConnector") + .HasColumnType("INTEGER"); + + b.Property("V2UpgradeToken") + .HasColumnType("TEXT"); + + b.Property("VerifyDevices") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("GatewayCustomerId"); + + b.HasIndex("GatewaySubscriptionId"); + + b.HasIndex("Premium", "PremiumExpirationDate", "RenewalReminderDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("User", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.UserSignatureKeyPair", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("SignatureAlgorithm") + .HasColumnType("INTEGER"); + + b.Property("SigningKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("VerifyingKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("UserSignatureKeyPair", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Body") + .HasMaxLength(3000) + .HasColumnType("TEXT"); + + b.Property("ClientType") + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Global") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Priority") + .HasColumnType("INTEGER"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("TaskId") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("TaskId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("ClientType", "Global", "UserId", "OrganizationId", "Priority", "CreationDate") + .IsDescending(false, false, false, false, true, true) + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Notification", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("NotificationId") + .HasColumnType("TEXT"); + + b.Property("DeletedDate") + .HasColumnType("TEXT"); + + b.Property("ReadDate") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "NotificationId") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("NotificationId"); + + b.ToTable("NotificationStatus", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Pam.Models.AccessRule", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AllowsExtensions") + .HasColumnType("INTEGER"); + + b.Property("Conditions") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("DefaultLeaseDurationSeconds") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("MaxExtensionDurationSeconds") + .HasColumnType("INTEGER"); + + b.Property("MaxLeaseDurationSeconds") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("SingleActiveLease") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId", "Name") + .IsUnique(); + + b.ToTable("AccessRule", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Platform.Installation", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("TEXT"); + + b.Property("LastActivityDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Installation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Discriminator") + .IsRequired() + .HasMaxLength(34) + .HasColumnType("TEXT"); + + b.Property("Read") + .HasColumnType("INTEGER"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Write") + .HasColumnType("INTEGER"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.ToTable("AccessPolicy", (string)null); + + b.HasDiscriminator().HasValue("AccessPolicy"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ClientSecretHash") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("EncryptedPayload") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("ExpireAt") + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("ServiceAccountId") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ServiceAccountId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("DeletedDate") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Project", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("DeletedDate") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("Note") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Secret", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.SecretVersion", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("EditorOrganizationUserId") + .HasColumnType("TEXT"); + + b.Property("EditorServiceAccountId") + .HasColumnType("TEXT"); + + b.Property("SecretId") + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("VersionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("EditorOrganizationUserId") + .HasDatabaseName("IX_SecretVersion_EditorOrganizationUserId"); + + b.HasIndex("EditorServiceAccountId") + .HasDatabaseName("IX_SecretVersion_EditorServiceAccountId"); + + b.HasIndex("SecretId") + .HasDatabaseName("IX_SecretVersion_SecretId"); + + b.ToTable("SecretVersion"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ServiceAccount", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Archives") + .HasColumnType("TEXT"); + + b.Property("Attachments") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("DeletedDate") + .HasColumnType("TEXT"); + + b.Property("Favorites") + .HasColumnType("TEXT"); + + b.Property("Folders") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Reprompt") + .HasColumnType("INTEGER"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId"); + + b.ToTable("Cipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Folder", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CipherId") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("CipherId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SecurityTask", (string)null); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.Property("ProjectsId") + .HasColumnType("TEXT"); + + b.Property("SecretsId") + .HasColumnType("TEXT"); + + b.HasKey("ProjectsId", "SecretsId"); + + b.HasIndex("SecretsId"); + + b.ToTable("ProjectSecret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedProjectId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedSecretId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedProjectId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedSecretId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedProjectId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedSecretId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Collection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Pam.Models.AccessRule", null) + .WithMany() + .HasForeignKey("AccessRuleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Collections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.CollectionGroup", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Collection", "Collection") + .WithMany("CollectionGroups") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.CollectionUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Collection", "Collection") + .WithMany("CollectionUsers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("CollectionUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationInviteLink", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Policies") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.AuthRequest", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Device", "ResponseDevice") + .WithMany() + .HasForeignKey("ResponseDeviceId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("ResponseDevice"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantee") + .WithMany() + .HasForeignKey("GranteeId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantor") + .WithMany() + .HasForeignKey("GrantorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Grantee"); + + b.Navigation("Grantor"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoConfigs") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoUsers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("SsoUsers") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Platform.Installation", "Installation") + .WithMany() + .HasForeignKey("InstallationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Installation"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationPlanMigrationCohortAssignment", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationPlanMigrationCohort", "Cohort") + .WithMany() + .HasForeignKey("CohortId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cohort"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationApplication", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegrationConfiguration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegration", "OrganizationIntegration") + .WithMany() + .HasForeignKey("OrganizationIntegrationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("OrganizationIntegration"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.PasswordHealthReportApplication", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany("CollectionCiphers") + .HasForeignKey("CipherId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Collection", "Collection") + .WithMany("CollectionCiphers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Collection"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Groups") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany("GroupUsers") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("GroupUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("ApiKeys") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Connections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Domains") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoredOrganization") + .WithMany() + .HasForeignKey("SponsoredOrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoringOrganization") + .WithMany() + .HasForeignKey("SponsoringOrganizationId"); + + b.Navigation("SponsoredOrganization"); + + b.Navigation("SponsoringOrganization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("OrganizationUsers") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("OrganizationUsers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.PlayItem", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Transactions") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Transactions") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.UserSignatureKeyPair", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", "Task") + .WithMany() + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Task"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", "Notification") + .WithMany() + .HasForeignKey("NotificationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Notification"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Pam.Models.AccessRule", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ApiKeys") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.SecretVersion", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "EditorOrganizationUser") + .WithMany() + .HasForeignKey("EditorOrganizationUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "EditorServiceAccount") + .WithMany() + .HasForeignKey("EditorServiceAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "Secret") + .WithMany("SecretVersions") + .HasForeignKey("SecretId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("EditorOrganizationUser"); + + b.Navigation("EditorServiceAccount"); + + b.Navigation("Secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Ciphers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Ciphers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Folders") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany() + .HasForeignKey("CipherId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", null) + .WithMany() + .HasForeignKey("ProjectsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", null) + .WithMany() + .HasForeignKey("SecretsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedProject"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedSecret"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ProjectAccessPolicies") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedProject"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany() + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedProject"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Collection", b => + { + b.Navigation("CollectionCiphers"); + + b.Navigation("CollectionGroups"); + + b.Navigation("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("Ciphers"); + + b.Navigation("Collections"); + + b.Navigation("Connections"); + + b.Navigation("Domains"); + + b.Navigation("Groups"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("Policies"); + + b.Navigation("SsoConfigs"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Navigation("CollectionUsers"); + + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Navigation("Ciphers"); + + b.Navigation("Folders"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("SecretVersions"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ProjectAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Navigation("CollectionCiphers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/util/SqliteMigrations/Migrations/20260616102557_AddUsePamToOrganization.cs b/util/SqliteMigrations/Migrations/20260616102557_AddUsePamToOrganization.cs new file mode 100644 index 000000000000..01de7c1e3411 --- /dev/null +++ b/util/SqliteMigrations/Migrations/20260616102557_AddUsePamToOrganization.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bit.SqliteMigrations.Migrations; + +/// +public partial class AddUsePamToOrganization : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "UsePam", + table: "Organization", + type: "INTEGER", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "UsePam", + table: "Organization"); + } +} diff --git a/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs b/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs index f307e3197a3a..6f6890b68508 100644 --- a/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs +++ b/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs @@ -336,6 +336,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("UseOrganizationDomains") .HasColumnType("INTEGER"); + b.Property("UsePam") + .HasColumnType("INTEGER"); + b.Property("UsePasswordManager") .HasColumnType("INTEGER"); @@ -2334,36 +2337,59 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("NotificationStatus", (string)null); }); - modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Platform.Installation", b => + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Pam.Models.AccessRule", b => { b.Property("Id") .HasColumnType("TEXT"); + b.Property("AllowsExtensions") + .HasColumnType("INTEGER"); + + b.Property("Conditions") + .IsRequired() + .HasColumnType("TEXT"); + b.Property("CreationDate") .HasColumnType("TEXT"); - b.Property("Email") - .IsRequired() - .HasMaxLength(256) + b.Property("DefaultLeaseDurationSeconds") + .HasColumnType("INTEGER"); + + b.Property("Description") .HasColumnType("TEXT"); b.Property("Enabled") .HasColumnType("INTEGER"); - b.Property("Key") + b.Property("MaxExtensionDurationSeconds") + .HasColumnType("INTEGER"); + + b.Property("MaxLeaseDurationSeconds") + .HasColumnType("INTEGER"); + + b.Property("Name") .IsRequired() - .HasMaxLength(150) + .HasMaxLength(256) .HasColumnType("TEXT"); - b.Property("LastActivityDate") + b.Property("OrganizationId") .HasColumnType("TEXT"); + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("SingleActiveLease") + .HasColumnType("INTEGER"); + b.HasKey("Id"); - b.ToTable("Installation", (string)null); + b.HasIndex("OrganizationId", "Name") + .IsUnique(); + + b.ToTable("AccessRule", (string)null); }); - modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Pam.Models.AccessRule", b => + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Platform.Installation", b => { b.Property("Id") .HasColumnType("TEXT"); @@ -2371,30 +2397,25 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("CreationDate") .HasColumnType("TEXT"); - b.Property("Description") - .HasColumnType("TEXT"); - - b.Property("Name") + b.Property("Email") .IsRequired() .HasMaxLength(256) .HasColumnType("TEXT"); - b.Property("OrganizationId") - .HasColumnType("TEXT"); + b.Property("Enabled") + .HasColumnType("INTEGER"); - b.Property("RevisionDate") + b.Property("Key") + .IsRequired() + .HasMaxLength(150) .HasColumnType("TEXT"); - b.Property("Conditions") - .IsRequired() + b.Property("LastActivityDate") .HasColumnType("TEXT"); b.HasKey("Id"); - b.HasIndex("OrganizationId", "Name") - .IsUnique(); - - b.ToTable("AccessRule", (string)null); + b.ToTable("Installation", (string)null); }); modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy", b => From f5368bb8fb05c0f86d88d751b50bd274c1c9d5d6 Mon Sep 17 00:00:00 2001 From: Hinton Date: Tue, 16 Jun 2026 18:31:01 +0200 Subject: [PATCH 40/54] Convert to enums --- .../Request/AccessDecisionRequestModel.cs | 16 ++--- .../Response/AccessPreCheckResponseModel.cs | 7 +- .../AccessRequestResultResponseModel.cs | 10 +-- src/Core/Pam/Engine/AccessRuleEngine.cs | 15 +---- src/Core/Pam/Entities/AccessDecision.cs | 6 +- src/Core/Pam/Enums/AccessApprovalMode.cs | 6 +- src/Core/Pam/Enums/AccessConditionKind.cs | 13 ++++ src/Core/Pam/Enums/AccessWeekday.cs | 21 ++++++ .../Conditions/AccessWeekdayJsonConverter.cs | 52 +++++++++++++++ .../Models/Conditions/TimeOfDayCondition.cs | 6 +- src/Core/Pam/Services/AccessRuleValidator.cs | 13 +--- .../AccessRequest_CreateAutoApproved.sql | 2 +- src/Sql/dbo/Pam/Tables/AccessDecision.sql | 2 +- .../AccessRequestsControllerTests.cs | 13 +--- .../Models/AccessDecisionRequestModelTests.cs | 66 +++++++++++++++++++ .../Pam/Engine/AccessRuleEngineTests.cs | 15 +++-- .../AccessWeekdayJsonConverterTests.cs | 52 +++++++++++++++ .../AccessLeaseRepositoryTests.cs | 3 + ...026-06-16_00_PamConditionKindToTinyint.sql | 63 ++++++++++++++++++ 19 files changed, 309 insertions(+), 72 deletions(-) create mode 100644 src/Core/Pam/Enums/AccessConditionKind.cs create mode 100644 src/Core/Pam/Enums/AccessWeekday.cs create mode 100644 src/Core/Pam/Models/Conditions/AccessWeekdayJsonConverter.cs create mode 100644 test/Api.Test/Pam/Models/AccessDecisionRequestModelTests.cs create mode 100644 test/Core.Test/Pam/Models/Conditions/AccessWeekdayJsonConverterTests.cs create mode 100644 util/Migrator/DbScripts/2026-06-16_00_PamConditionKindToTinyint.sql diff --git a/src/Api/Pam/Models/Request/AccessDecisionRequestModel.cs b/src/Api/Pam/Models/Request/AccessDecisionRequestModel.cs index b9b3b1bda6fa..2c88ada5f3e5 100644 --- a/src/Api/Pam/Models/Request/AccessDecisionRequestModel.cs +++ b/src/Api/Pam/Models/Request/AccessDecisionRequestModel.cs @@ -1,31 +1,25 @@ using System.ComponentModel.DataAnnotations; -using Bit.Core.Exceptions; using Bit.Core.Pam.Enums; using Bit.Core.Pam.Models; namespace Bit.Api.Pam.Models.Request; /// -/// An approver's decision on a pending access request. is "approve" or "deny"; +/// An approver's decision on a pending access request. is the +/// value on the wire (0 = approve, 1 = deny); /// is optional. /// public class AccessDecisionRequestModel { [Required] - public string Verdict { get; set; } = null!; + [EnumDataType(typeof(AccessDecisionVerdict))] + public AccessDecisionVerdict? Verdict { get; set; } public string? Comment { get; set; } public AccessDecisionSubmission ToSubmission() => new() { - Verdict = ParseVerdict(Verdict), + Verdict = Verdict!.Value, Comment = Comment, }; - - private static AccessDecisionVerdict ParseVerdict(string verdict) => verdict?.ToLowerInvariant() switch - { - "approve" => AccessDecisionVerdict.Approve, - "deny" => AccessDecisionVerdict.Deny, - _ => throw new BadRequestException("Verdict must be either 'approve' or 'deny'."), - }; } diff --git a/src/Api/Pam/Models/Response/AccessPreCheckResponseModel.cs b/src/Api/Pam/Models/Response/AccessPreCheckResponseModel.cs index 672fdaba5905..cb8a15fd3bca 100644 --- a/src/Api/Pam/Models/Response/AccessPreCheckResponseModel.cs +++ b/src/Api/Pam/Models/Response/AccessPreCheckResponseModel.cs @@ -10,16 +10,17 @@ public AccessPreCheckResponseModel(Guid cipherId, AccessPreCheckResult result) : base("accessPreCheck") { CipherId = cipherId; - ApprovalMode = result.ApprovalMode == AccessApprovalMode.Human ? "human" : "automatic"; + ApprovalMode = result.ApprovalMode; HasActiveLease = result.HasActiveLease; } public Guid CipherId { get; } /// - /// "automatic" when a request would be approved immediately, "human" when it needs an approver. + /// when a request would be approved immediately, + /// when it needs an approver. /// - public string ApprovalMode { get; } + public AccessApprovalMode ApprovalMode { get; } /// /// True when the caller already holds an active lease: reveal the credential, no request needed. diff --git a/src/Api/Pam/Models/Response/AccessRequestResultResponseModel.cs b/src/Api/Pam/Models/Response/AccessRequestResultResponseModel.cs index 812fa1172c54..b8137c1a98a4 100644 --- a/src/Api/Pam/Models/Response/AccessRequestResultResponseModel.cs +++ b/src/Api/Pam/Models/Response/AccessRequestResultResponseModel.cs @@ -11,16 +11,16 @@ public AccessRequestResultResponseModel(AccessRequestResult result) { ArgumentNullException.ThrowIfNull(result); - ApprovalMode = result.ApprovalMode == AccessApprovalMode.Human ? "human" : "automatic"; + ApprovalMode = result.ApprovalMode; Request = new AccessRequestResponseModel(result.Request); } /// - /// "automatic" when the was approved on submit and is ready to activate (the client - /// shows "Start lease"), "human" when it is pending an approver. No lease is minted at submit on either - /// path; the requester activates the request to start the lease. + /// when the was approved on submit and is ready + /// to activate (the client shows "Start lease"), when it is pending an + /// approver. No lease is minted at submit on either path; the requester activates the request to start the lease. /// - public string ApprovalMode { get; } + public AccessApprovalMode ApprovalMode { get; } public AccessRequestResponseModel Request { get; } } diff --git a/src/Core/Pam/Engine/AccessRuleEngine.cs b/src/Core/Pam/Engine/AccessRuleEngine.cs index 69debcd9bf95..d27ec846279a 100644 --- a/src/Core/Pam/Engine/AccessRuleEngine.cs +++ b/src/Core/Pam/Engine/AccessRuleEngine.cs @@ -12,18 +12,6 @@ namespace Bit.Core.Pam.Engine; /// public sealed class AccessRuleEngine : IAccessRuleEngine { - // The conditions JSON encodes days as the lowercase three-letter abbreviations the validator accepts. - private static readonly IReadOnlyDictionary _days = new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["sun"] = DayOfWeek.Sunday, - ["mon"] = DayOfWeek.Monday, - ["tue"] = DayOfWeek.Tuesday, - ["wed"] = DayOfWeek.Wednesday, - ["thu"] = DayOfWeek.Thursday, - ["fri"] = DayOfWeek.Friday, - ["sat"] = DayOfWeek.Saturday, - }; - public AccessEvaluation Evaluate(IReadOnlyList conditions, AccessSignals signals) => AccessEvaluation.Combine(conditions.Select(condition => EvaluateCondition(condition, signals))); @@ -80,7 +68,8 @@ private static AccessEvaluation EvaluateTimeOfDay(TimeOfDayCondition condition, private static bool WindowContains(TimeWindow window, DayOfWeek day, TimeOnly time) { - var dayMatches = window.Days.Any(d => _days.TryGetValue(d, out var parsed) && parsed == day); + // AccessWeekday values align with System.DayOfWeek (Sunday = 0), so a direct cast compares correctly. + var dayMatches = window.Days.Any(d => (DayOfWeek)d == day); if (!dayMatches) { return false; diff --git a/src/Core/Pam/Entities/AccessDecision.cs b/src/Core/Pam/Entities/AccessDecision.cs index b25d06d200e4..8de0f8d06916 100644 --- a/src/Core/Pam/Entities/AccessDecision.cs +++ b/src/Core/Pam/Entities/AccessDecision.cs @@ -23,10 +23,10 @@ public class AccessDecision : ITableObject public Guid? ApproverId { get; set; } /// - /// The condition kind that decided (e.g. ip_allowlist). NULL when is - /// . + /// The condition kind that decided (e.g. ). NULL when + /// is . /// - public string? ConditionKind { get; set; } + public AccessConditionKind? ConditionKind { get; set; } public AccessDecisionVerdict Verdict { get; set; } diff --git a/src/Core/Pam/Enums/AccessApprovalMode.cs b/src/Core/Pam/Enums/AccessApprovalMode.cs index fbb2910bbe68..c1af4107b1f8 100644 --- a/src/Core/Pam/Enums/AccessApprovalMode.cs +++ b/src/Core/Pam/Enums/AccessApprovalMode.cs @@ -4,8 +4,8 @@ /// The approval path a lease request will take, surfaced by the pre-check so the client can present the right /// workflow: (pick a duration) or (pick a window + justify). /// -public enum AccessApprovalMode +public enum AccessApprovalMode : byte { - Automatic, - Human, + Automatic = 0, + Human = 1, } diff --git a/src/Core/Pam/Enums/AccessConditionKind.cs b/src/Core/Pam/Enums/AccessConditionKind.cs new file mode 100644 index 000000000000..027e87237b0e --- /dev/null +++ b/src/Core/Pam/Enums/AccessConditionKind.cs @@ -0,0 +1,13 @@ +namespace Bit.Core.Pam.Enums; + +/// +/// The kind of access condition that produced an automatic . Mirrors the +/// kind discriminator on , which remains the JSON wire format +/// for a rule's conditions; this enum is the persisted, type-safe form recorded against the decision. +/// +public enum AccessConditionKind : byte +{ + HumanApproval = 0, + IpAllowlist = 1, + TimeOfDay = 2, +} diff --git a/src/Core/Pam/Enums/AccessWeekday.cs b/src/Core/Pam/Enums/AccessWeekday.cs new file mode 100644 index 000000000000..63ce8b9f8e80 --- /dev/null +++ b/src/Core/Pam/Enums/AccessWeekday.cs @@ -0,0 +1,21 @@ +using System.Text.Json.Serialization; +using Bit.Core.Pam.Models.Conditions; + +namespace Bit.Core.Pam.Enums; + +/// +/// A day of the week used in a window. Values align with +/// (Sunday = 0) so the engine can compare directly. Serialized as the lowercase +/// three-letter tokens ("sun".."sat") via . +/// +[JsonConverter(typeof(AccessWeekdayJsonConverter))] +public enum AccessWeekday : byte +{ + Sun = 0, + Mon = 1, + Tue = 2, + Wed = 3, + Thu = 4, + Fri = 5, + Sat = 6, +} diff --git a/src/Core/Pam/Models/Conditions/AccessWeekdayJsonConverter.cs b/src/Core/Pam/Models/Conditions/AccessWeekdayJsonConverter.cs new file mode 100644 index 000000000000..6b1c6d03fe5d --- /dev/null +++ b/src/Core/Pam/Models/Conditions/AccessWeekdayJsonConverter.cs @@ -0,0 +1,52 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Bit.Core.Pam.Enums; + +namespace Bit.Core.Pam.Models.Conditions; + +/// +/// (De)serializes as the lowercase three-letter tokens the conditions JSON uses +/// ("sun".."sat"), keeping the wire format stable while the value is strongly typed in C#. This is the +/// single source of truth for the accepted day vocabulary; an unknown token fails closed with a +/// . +/// +public sealed class AccessWeekdayJsonConverter : JsonConverter +{ + private static readonly IReadOnlyDictionary _fromToken = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["sun"] = AccessWeekday.Sun, + ["mon"] = AccessWeekday.Mon, + ["tue"] = AccessWeekday.Tue, + ["wed"] = AccessWeekday.Wed, + ["thu"] = AccessWeekday.Thu, + ["fri"] = AccessWeekday.Fri, + ["sat"] = AccessWeekday.Sat, + }; + + public override AccessWeekday Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.String && + _fromToken.TryGetValue(reader.GetString()!, out var day)) + { + return day; + } + + throw new JsonException("Invalid day. Expected one of: sun, mon, tue, wed, thu, fri, sat."); + } + + public override void Write(Utf8JsonWriter writer, AccessWeekday value, JsonSerializerOptions options) => + writer.WriteStringValue(ToToken(value)); + + private static string ToToken(AccessWeekday day) => day switch + { + AccessWeekday.Sun => "sun", + AccessWeekday.Mon => "mon", + AccessWeekday.Tue => "tue", + AccessWeekday.Wed => "wed", + AccessWeekday.Thu => "thu", + AccessWeekday.Fri => "fri", + AccessWeekday.Sat => "sat", + _ => throw new ArgumentOutOfRangeException(nameof(day), day, null), + }; +} diff --git a/src/Core/Pam/Models/Conditions/TimeOfDayCondition.cs b/src/Core/Pam/Models/Conditions/TimeOfDayCondition.cs index 996bfa911d6a..0652d0fc9e69 100644 --- a/src/Core/Pam/Models/Conditions/TimeOfDayCondition.cs +++ b/src/Core/Pam/Models/Conditions/TimeOfDayCondition.cs @@ -1,4 +1,6 @@ -namespace Bit.Core.Pam.Models.Conditions; +using Bit.Core.Pam.Enums; + +namespace Bit.Core.Pam.Models.Conditions; /// /// Auto-approves a lease when the request falls inside one of the configured windows, evaluated in @@ -12,7 +14,7 @@ public sealed class TimeOfDayCondition : AccessCondition public sealed class TimeWindow { - public IReadOnlyList Days { get; init; } = []; + public IReadOnlyList Days { get; init; } = []; public string From { get; init; } = string.Empty; public string To { get; init; } = string.Empty; } diff --git a/src/Core/Pam/Services/AccessRuleValidator.cs b/src/Core/Pam/Services/AccessRuleValidator.cs index a7b69a80bb74..77898aaa1e47 100644 --- a/src/Core/Pam/Services/AccessRuleValidator.cs +++ b/src/Core/Pam/Services/AccessRuleValidator.cs @@ -9,9 +9,6 @@ public sealed partial class AccessRuleValidator : IAccessRuleValidator { private const int MaxConditions = 10; - private static readonly HashSet AllowedDays = - new(StringComparer.OrdinalIgnoreCase) { "mon", "tue", "wed", "thu", "fri", "sat", "sun" }; - private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, @@ -130,14 +127,8 @@ private static AccessRuleValidationResult ValidateTimeOfDay(TimeOfDayCondition c return AccessRuleValidationResult.Invalid("time_of_day window requires at least one day."); } - foreach (var day in window.Days) - { - if (!AllowedDays.Contains(day)) - { - return AccessRuleValidationResult.Invalid($"Invalid day: '{day}'."); - } - } - + // Day tokens are validated during deserialization by AccessWeekdayJsonConverter; an unknown token fails + // the JSON parse above and is reported as malformed. if (!TimeOfDayRegex().IsMatch(window.From)) { return AccessRuleValidationResult.Invalid($"Invalid 'from' time: '{window.From}'. Expected HH:mm."); diff --git a/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_CreateAutoApproved.sql b/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_CreateAutoApproved.sql index f4aa0cab7e79..f6095f718a15 100644 --- a/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_CreateAutoApproved.sql +++ b/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_CreateAutoApproved.sql @@ -8,7 +8,7 @@ CREATE PROCEDURE [dbo].[AccessRequest_CreateAutoApproved] @NotBefore DATETIME2(7), @NotAfter DATETIME2(7), @Reason NVARCHAR(MAX) = NULL, - @ConditionKind NVARCHAR(50) = NULL, + @ConditionKind TINYINT = NULL, @CreationDate DATETIME2(7) AS BEGIN diff --git a/src/Sql/dbo/Pam/Tables/AccessDecision.sql b/src/Sql/dbo/Pam/Tables/AccessDecision.sql index 5acc0c8744fb..9ccfce831f92 100644 --- a/src/Sql/dbo/Pam/Tables/AccessDecision.sql +++ b/src/Sql/dbo/Pam/Tables/AccessDecision.sql @@ -3,7 +3,7 @@ CREATE TABLE [dbo].[AccessDecision] ( [AccessRequestId] UNIQUEIDENTIFIER NOT NULL, [DeciderKind] TINYINT NOT NULL, [ApproverId] UNIQUEIDENTIFIER NULL, - [ConditionKind] NVARCHAR(50) NULL, + [ConditionKind] TINYINT NULL, [Verdict] TINYINT NOT NULL, [Comment] NVARCHAR(MAX) NULL, [EvaluationContext] NVARCHAR(MAX) NULL, diff --git a/test/Api.Test/Pam/Controllers/AccessRequestsControllerTests.cs b/test/Api.Test/Pam/Controllers/AccessRequestsControllerTests.cs index f7bbbc9dee66..a7b26fd6c0d7 100644 --- a/test/Api.Test/Pam/Controllers/AccessRequestsControllerTests.cs +++ b/test/Api.Test/Pam/Controllers/AccessRequestsControllerTests.cs @@ -1,7 +1,6 @@ using System.Security.Claims; using Bit.Api.Pam.Controllers; using Bit.Api.Pam.Models.Request; -using Bit.Core.Exceptions; using Bit.Core.Pam.Entities; using Bit.Core.Pam.Enums; using Bit.Core.Pam.Models; @@ -85,22 +84,12 @@ public async Task Decide_ReturnsUpdatedRow( .DecideAsync(userId, requestId, Arg.Any()) .Returns(updated); - var result = await sutProvider.Sut.Decide(requestId, new AccessDecisionRequestModel { Verdict = "approve" }); + var result = await sutProvider.Sut.Decide(requestId, new AccessDecisionRequestModel { Verdict = AccessDecisionVerdict.Approve }); Assert.Equal(updated.Id, result.Id); Assert.Equal(AccessRequestStatusNames.Approved, result.Status); } - [Theory, BitAutoData] - public async Task Decide_InvalidDecision_ThrowsBadRequest( - Guid userId, Guid requestId, SutProvider sutProvider) - { - SetupUser(sutProvider, userId); - - await Assert.ThrowsAsync( - () => sutProvider.Sut.Decide(requestId, new AccessDecisionRequestModel { Verdict = "maybe" })); - } - [Theory, BitAutoData] public async Task Activate_ReturnsMintedLease( Guid userId, Guid requestId, AccessLease lease, SutProvider sutProvider) diff --git a/test/Api.Test/Pam/Models/AccessDecisionRequestModelTests.cs b/test/Api.Test/Pam/Models/AccessDecisionRequestModelTests.cs new file mode 100644 index 000000000000..b0fe20e1d055 --- /dev/null +++ b/test/Api.Test/Pam/Models/AccessDecisionRequestModelTests.cs @@ -0,0 +1,66 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json; +using Bit.Api.Pam.Models.Request; +using Bit.Core.Pam.Enums; +using Xunit; + +namespace Bit.Api.Test.Pam.Models; + +public class AccessDecisionRequestModelTests +{ + // Mirror the API's web JSON defaults (camelCase, case-insensitive) so the test exercises the real bind path. + private static readonly JsonSerializerOptions _web = new(JsonSerializerDefaults.Web); + + [Theory] + [InlineData(0, AccessDecisionVerdict.Approve)] + [InlineData(1, AccessDecisionVerdict.Deny)] + public void Deserialize_BindsIntegerVerdictToEnum(int wire, AccessDecisionVerdict expected) + { + var model = JsonSerializer.Deserialize($$"""{"verdict":{{wire}}}""", _web); + + Assert.NotNull(model); + Assert.Equal(expected, model!.Verdict); + Assert.Equal(expected, model.ToSubmission().Verdict); + } + + [Theory] + [InlineData("\"approve\"")] + [InlineData("\"1\"")] + public void Deserialize_StringVerdict_Throws(string wire) + { + // The wire format is the integer enum value; a string is no longer accepted. + Assert.Throws( + () => JsonSerializer.Deserialize($$"""{"verdict":{{wire}}}""", _web)); + } + + [Fact] + public void Validate_MissingVerdict_IsInvalid() + { + Assert.Contains( + Validate(new AccessDecisionRequestModel { Verdict = null }), + r => r.MemberNames.Contains(nameof(AccessDecisionRequestModel.Verdict))); + } + + [Fact] + public void Validate_UndefinedVerdict_IsInvalid() + { + Assert.Contains( + Validate(new AccessDecisionRequestModel { Verdict = (AccessDecisionVerdict)5 }), + r => r.MemberNames.Contains(nameof(AccessDecisionRequestModel.Verdict))); + } + + [Theory] + [InlineData(AccessDecisionVerdict.Approve)] + [InlineData(AccessDecisionVerdict.Deny)] + public void Validate_DefinedVerdict_IsValid(AccessDecisionVerdict verdict) + { + Assert.Empty(Validate(new AccessDecisionRequestModel { Verdict = verdict })); + } + + private static List Validate(AccessDecisionRequestModel model) + { + var results = new List(); + Validator.TryValidateObject(model, new ValidationContext(model), results, validateAllProperties: true); + return results; + } +} diff --git a/test/Core.Test/Pam/Engine/AccessRuleEngineTests.cs b/test/Core.Test/Pam/Engine/AccessRuleEngineTests.cs index e70018af24a8..d3351be73433 100644 --- a/test/Core.Test/Pam/Engine/AccessRuleEngineTests.cs +++ b/test/Core.Test/Pam/Engine/AccessRuleEngineTests.cs @@ -1,5 +1,6 @@ using System.Net; using Bit.Core.Pam.Engine; +using Bit.Core.Pam.Enums; using Bit.Core.Pam.Models.Conditions; using Xunit; @@ -75,7 +76,7 @@ public void Evaluate_TimeOfDay_WithinWindow_Allows() var conditions = Set(new TimeOfDayCondition { Tz = "UTC", - Windows = [new TimeWindow { Days = ["thu"], From = "09:00", To = "17:00" }], + Windows = [new TimeWindow { Days = [AccessWeekday.Thu], From = "09:00", To = "17:00" }], }); var evaluation = _sut.Evaluate(conditions, Signals()); @@ -89,7 +90,7 @@ public void Evaluate_TimeOfDay_OutsideTimeWindow_Denies() var conditions = Set(new TimeOfDayCondition { Tz = "UTC", - Windows = [new TimeWindow { Days = ["thu"], From = "00:00", To = "06:00" }], + Windows = [new TimeWindow { Days = [AccessWeekday.Thu], From = "00:00", To = "06:00" }], }); var evaluation = _sut.Evaluate(conditions, Signals()); @@ -104,7 +105,7 @@ public void Evaluate_TimeOfDay_DayNotListed_Denies() var conditions = Set(new TimeOfDayCondition { Tz = "UTC", - Windows = [new TimeWindow { Days = ["fri"], From = "00:00", To = "23:59" }], + Windows = [new TimeWindow { Days = [AccessWeekday.Fri], From = "00:00", To = "23:59" }], }); var evaluation = _sut.Evaluate(conditions, Signals()); @@ -120,7 +121,7 @@ public void Evaluate_TimeOfDay_EvaluatesInConfiguredTimezone() var conditions = Set(new TimeOfDayCondition { Tz = "America/New_York", - Windows = [new TimeWindow { Days = ["thu"], From = "18:00", To = "20:00" }], + Windows = [new TimeWindow { Days = [AccessWeekday.Thu], From = "18:00", To = "20:00" }], }); var evaluation = _sut.Evaluate(conditions, Signals(at: new DateTimeOffset(2026, 6, 4, 23, 0, 0, TimeSpan.Zero))); @@ -134,7 +135,7 @@ public void Evaluate_TimeOfDay_UnknownTimezone_DeniesClosed() var conditions = Set(new TimeOfDayCondition { Tz = "Not/AZone", - Windows = [new TimeWindow { Days = ["thu"], From = "00:00", To = "23:59" }], + Windows = [new TimeWindow { Days = [AccessWeekday.Thu], From = "00:00", To = "23:59" }], }); var evaluation = _sut.Evaluate(conditions, Signals()); @@ -148,7 +149,7 @@ public void Evaluate_AllConditionsAllow_Allows() { var conditions = Set( new IpAllowlistCondition { Cidrs = ["10.0.0.0/8"] }, - new TimeOfDayCondition { Tz = "UTC", Windows = [new TimeWindow { Days = ["thu"], From = "09:00", To = "17:00" }] }); + new TimeOfDayCondition { Tz = "UTC", Windows = [new TimeWindow { Days = [AccessWeekday.Thu], From = "09:00", To = "17:00" }] }); var evaluation = _sut.Evaluate(conditions, Signals(IPAddress.Parse("10.1.2.3"))); @@ -160,7 +161,7 @@ public void Evaluate_OneConditionDenies_DeniesWithThatReason() { var conditions = Set( new IpAllowlistCondition { Cidrs = ["10.0.0.0/8"] }, - new TimeOfDayCondition { Tz = "UTC", Windows = [new TimeWindow { Days = ["thu"], From = "00:00", To = "06:00" }] }); + new TimeOfDayCondition { Tz = "UTC", Windows = [new TimeWindow { Days = [AccessWeekday.Thu], From = "00:00", To = "06:00" }] }); var evaluation = _sut.Evaluate(conditions, Signals(IPAddress.Parse("10.1.2.3"))); diff --git a/test/Core.Test/Pam/Models/Conditions/AccessWeekdayJsonConverterTests.cs b/test/Core.Test/Pam/Models/Conditions/AccessWeekdayJsonConverterTests.cs new file mode 100644 index 000000000000..5c08f48c25cd --- /dev/null +++ b/test/Core.Test/Pam/Models/Conditions/AccessWeekdayJsonConverterTests.cs @@ -0,0 +1,52 @@ +using System.Text.Json; +using Bit.Core.Pam.Enums; +using Xunit; + +namespace Bit.Core.Test.Pam.Models.Conditions; + +public class AccessWeekdayJsonConverterTests +{ + [Theory] + [InlineData(AccessWeekday.Sun, "\"sun\"")] + [InlineData(AccessWeekday.Mon, "\"mon\"")] + [InlineData(AccessWeekday.Tue, "\"tue\"")] + [InlineData(AccessWeekday.Wed, "\"wed\"")] + [InlineData(AccessWeekday.Thu, "\"thu\"")] + [InlineData(AccessWeekday.Fri, "\"fri\"")] + [InlineData(AccessWeekday.Sat, "\"sat\"")] + public void Serializes_AsLowercaseToken(AccessWeekday day, string expectedJson) + { + Assert.Equal(expectedJson, JsonSerializer.Serialize(day)); + } + + [Theory] + [InlineData("\"mon\"", AccessWeekday.Mon)] + [InlineData("\"MON\"", AccessWeekday.Mon)] + [InlineData("\"Sun\"", AccessWeekday.Sun)] + public void Deserializes_TokenCaseInsensitively(string json, AccessWeekday expected) + { + Assert.Equal(expected, JsonSerializer.Deserialize(json)); + } + + [Theory] + [InlineData("\"funday\"")] + [InlineData("\"\"")] + [InlineData("3")] + public void Deserialize_InvalidToken_Throws(string json) + { + Assert.Throws(() => JsonSerializer.Deserialize(json)); + } + + [Fact] + public void EnumValues_AlignWithSystemDayOfWeek() + { + // The engine casts AccessWeekday straight to System.DayOfWeek, so the numeric values must match. + Assert.Equal((int)DayOfWeek.Sunday, (int)AccessWeekday.Sun); + Assert.Equal((int)DayOfWeek.Monday, (int)AccessWeekday.Mon); + Assert.Equal((int)DayOfWeek.Tuesday, (int)AccessWeekday.Tue); + Assert.Equal((int)DayOfWeek.Wednesday, (int)AccessWeekday.Wed); + Assert.Equal((int)DayOfWeek.Thursday, (int)AccessWeekday.Thu); + Assert.Equal((int)DayOfWeek.Friday, (int)AccessWeekday.Fri); + Assert.Equal((int)DayOfWeek.Saturday, (int)AccessWeekday.Sat); + } +} diff --git a/test/Infrastructure.IntegrationTest/Pam/Repositories/AccessLeaseRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Pam/Repositories/AccessLeaseRepositoryTests.cs index 519337fbbf3e..a3c2a1580b80 100644 --- a/test/Infrastructure.IntegrationTest/Pam/Repositories/AccessLeaseRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/Pam/Repositories/AccessLeaseRepositoryTests.cs @@ -22,6 +22,9 @@ public async Task CreateAutoApprovedAsync_PersistsApprovedRequestAndDecisionWith var requesterId = Guid.NewGuid(); var (request, decision, _) = BuildAutoApproved(organization.Id, cipherId, requesterId, now, now.AddHours(1)); + // Exercise the TINYINT ConditionKind column end-to-end: the INSERT throws if the sproc param / column type + // does not accept the byte-backed enum value. + decision.ConditionKind = AccessConditionKind.IpAllowlist; await accessRequestRepository.CreateAutoApprovedAsync(request, decision); diff --git a/util/Migrator/DbScripts/2026-06-16_00_PamConditionKindToTinyint.sql b/util/Migrator/DbScripts/2026-06-16_00_PamConditionKindToTinyint.sql new file mode 100644 index 000000000000..1c7925bc7146 --- /dev/null +++ b/util/Migrator/DbScripts/2026-06-16_00_PamConditionKindToTinyint.sql @@ -0,0 +1,63 @@ +-- PAM Credential Leasing: convert [AccessDecision].[ConditionKind] from a magic-string NVARCHAR(50) to a TINYINT, +-- backed by the new AccessConditionKind enum (HumanApproval = 0, IpAllowlist = 1, TimeOfDay = 2). The column records +-- which condition produced an automatic decision; it is internal-only (never returned to clients) and currently always +-- NULL, so no data backfill is required. The conditions JSON keeps its string `kind` discriminator. Acceptable as a +-- straight type change here: the feature is an unshipped POC behind the pm-37044-pam-v-0 flag and the server + +-- migration deploy together. + +IF COL_LENGTH('[dbo].[AccessDecision]', 'ConditionKind') IS NOT NULL +BEGIN + ALTER TABLE [dbo].[AccessDecision] + ALTER COLUMN [ConditionKind] TINYINT NULL; +END +GO + +-- Re-create the only proc that takes @ConditionKind as a parameter so its type matches the column. +CREATE OR ALTER PROCEDURE [dbo].[AccessRequest_CreateAutoApproved] + @AccessRequestId UNIQUEIDENTIFIER, + @AccessDecisionId UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @CollectionId UNIQUEIDENTIFIER, + @CipherId UNIQUEIDENTIFIER, + @RequesterId UNIQUEIDENTIFIER, + @NotBefore DATETIME2(7), + @NotAfter DATETIME2(7), + @Reason NVARCHAR(MAX) = NULL, + @ConditionKind TINYINT = NULL, + @CreationDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + -- Atomically record an auto-approved request and its automatic verdict. No lease is minted here: the requester + -- activates the approved request later via [AccessLease_CreateFromApprovedRequest], exactly like the human path + -- after approval. The per-cipher single-active-lease guard therefore lives entirely on that activation path. + BEGIN TRANSACTION AccessRequest_CreateAutoApproved + + -- The request is created already resolved (Approved). ExtensionOfLeaseId stays NULL: it is reserved for extension + -- requests; provenance for an original lease flows the other way, via AccessLease.AccessRequestId. + INSERT INTO [dbo].[AccessRequest] + ( + [Id], [ExtensionOfLeaseId], [OrganizationId], [CollectionId], [CipherId], [RequesterId], + [NotBefore], [NotAfter], [Reason], [Status], [CreationDate], [ResolvedDate] + ) + VALUES + ( + @AccessRequestId, NULL, @OrganizationId, @CollectionId, @CipherId, @RequesterId, + @NotBefore, @NotAfter, @Reason, 1 /* Approved */, @CreationDate, @CreationDate + ) + + INSERT INTO [dbo].[AccessDecision] + ( + [Id], [AccessRequestId], [DeciderKind], [ApproverId], [ConditionKind], + [Verdict], [Comment], [EvaluationContext], [CreationDate] + ) + VALUES + ( + @AccessDecisionId, @AccessRequestId, 0 /* Automatic */, NULL, @ConditionKind, + 0 /* Approve */, NULL, NULL, @CreationDate + ) + + COMMIT TRANSACTION AccessRequest_CreateAutoApproved +END +GO From 5db2c9aa459186ee364717ecfad32f2f1d4adacb Mon Sep 17 00:00:00 2001 From: Patrik Svensson Date: Tue, 16 Jun 2026 21:13:09 +0200 Subject: [PATCH 41/54] PAM: fix inverted GoverningRule precedence (least-restrictive wins) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GoverningRuleResolver returned the first human-approval rule it found ("most restrictive wins") — the inverse of pam.allium, which resolves the governing rule by union/OR: an automatic grant is favoured over one that needs human approval, which is favoured over a denial. A member with a valid auto-grant path was needlessly routed to an approver. Resolve by evaluating each candidate rule through AccessRuleEngine and returning the best (lowest) AccessEvaluationOutcome (Allow < RequiresApproval < Deny), reusing the same engine the downstream decision uses. This also honours the spec's rule that a failing automatic rule must not pre-empt a path that needs approval but would grant. ResolveAsync now takes AccessSignals (caller IP + timestamp); the five callers build and pass them (three gained ICurrentContext, via the new AccessSignals.From factory). As a side effect AccessPreCheckQuery is now condition-aware instead of keying only on RequiresHumanApproval. Adds multi-collection precedence tests to GoverningRuleResolverTests. --- src/Core/Pam/Engine/AccessSignals.cs | 12 ++ .../Commands/RequestLeaseExtensionCommand.cs | 10 +- .../Commands/SubmitAccessRequestCommand.cs | 23 ++- .../Queries/AccessPreCheckQuery.cs | 10 +- .../Queries/GetCipherAccessStateQuery.cs | 12 +- .../Queries/GetLeasedCipherQuery.cs | 21 +-- .../Pam/Services/GoverningRuleResolver.cs | 45 ++++-- .../Pam/Services/IGoverningRuleResolver.cs | 14 +- .../RequestLeaseExtensionCommandTests.cs | 6 +- .../SubmitAccessRequestCommandTests.cs | 4 +- .../Pam/Queries/AccessPreCheckQueryTests.cs | 9 +- .../Queries/GetCipherAccessStateQueryTests.cs | 15 +- .../Pam/Queries/GetLeasedCipherQueryTests.cs | 2 +- .../Services/GoverningRuleResolverTests.cs | 149 ++++++++++++++++-- 14 files changed, 246 insertions(+), 86 deletions(-) diff --git a/src/Core/Pam/Engine/AccessSignals.cs b/src/Core/Pam/Engine/AccessSignals.cs index f19bb185c02c..26b45fd3a922 100644 --- a/src/Core/Pam/Engine/AccessSignals.cs +++ b/src/Core/Pam/Engine/AccessSignals.cs @@ -1,4 +1,5 @@ using System.Net; +using Bit.Core.Context; namespace Bit.Core.Pam.Engine; @@ -11,4 +12,15 @@ public sealed record AccessSignals { public required IPAddress? IpAddress { get; init; } public required DateTimeOffset Timestamp { get; init; } + + /// + /// Builds the signals for the current request: the caller's source IP from + /// (parsed, or null when it is absent or unparseable) and the supplied + /// evaluation . + /// + public static AccessSignals From(ICurrentContext currentContext, DateTimeOffset timestamp) => new() + { + IpAddress = IPAddress.TryParse(currentContext.IpAddress, out var ip) ? ip : null, + Timestamp = timestamp, + }; } diff --git a/src/Core/Pam/OrganizationFeatures/Commands/RequestLeaseExtensionCommand.cs b/src/Core/Pam/OrganizationFeatures/Commands/RequestLeaseExtensionCommand.cs index e41b46fad2a5..d1e6b9901365 100644 --- a/src/Core/Pam/OrganizationFeatures/Commands/RequestLeaseExtensionCommand.cs +++ b/src/Core/Pam/OrganizationFeatures/Commands/RequestLeaseExtensionCommand.cs @@ -1,4 +1,6 @@ -using Bit.Core.Exceptions; +using Bit.Core.Context; +using Bit.Core.Exceptions; +using Bit.Core.Pam.Engine; using Bit.Core.Pam.Entities; using Bit.Core.Pam.Enums; using Bit.Core.Pam.Models; @@ -15,6 +17,7 @@ public class RequestLeaseExtensionCommand : IRequestLeaseExtensionCommand private readonly IAccessRequestRepository _accessRequestRepository; private readonly IApproverInboxNotifier _approverInboxNotifier; private readonly IRequesterNotifier _requesterNotifier; + private readonly ICurrentContext _currentContext; private readonly TimeProvider _timeProvider; public RequestLeaseExtensionCommand( @@ -23,6 +26,7 @@ public RequestLeaseExtensionCommand( IAccessRequestRepository accessRequestRepository, IApproverInboxNotifier approverInboxNotifier, IRequesterNotifier requesterNotifier, + ICurrentContext currentContext, TimeProvider timeProvider) { _accessLeaseRepository = accessLeaseRepository; @@ -30,6 +34,7 @@ public RequestLeaseExtensionCommand( _accessRequestRepository = accessRequestRepository; _approverInboxNotifier = approverInboxNotifier; _requesterNotifier = requesterNotifier; + _currentContext = currentContext; _timeProvider = timeProvider; } @@ -52,7 +57,8 @@ public async Task ExtendAsync(Guid userId, AccessLeaseExte // Extensions reuse the cipher's governing rule, but never its approval gate: they are always auto-approved, // gated only by the rule opting in and the per-lease maximum. - var governingRule = await _resolver.ResolveAsync(userId, lease.CipherId); + var signals = AccessSignals.From(_currentContext, new DateTimeOffset(now, TimeSpan.Zero)); + var governingRule = await _resolver.ResolveAsync(userId, lease.CipherId, signals); if (governingRule is null) { throw new BadRequestException("This item does not require a lease."); diff --git a/src/Core/Pam/OrganizationFeatures/Commands/SubmitAccessRequestCommand.cs b/src/Core/Pam/OrganizationFeatures/Commands/SubmitAccessRequestCommand.cs index 8ff9358dceef..6c908e357ae9 100644 --- a/src/Core/Pam/OrganizationFeatures/Commands/SubmitAccessRequestCommand.cs +++ b/src/Core/Pam/OrganizationFeatures/Commands/SubmitAccessRequestCommand.cs @@ -1,5 +1,4 @@ -using System.Net; -using Bit.Core.Context; +using Bit.Core.Context; using Bit.Core.Exceptions; using Bit.Core.Pam.Engine; using Bit.Core.Pam.Entities; @@ -78,14 +77,15 @@ public async Task SubmitAsync(Guid userId, Guid cipherId, A throw new NotFoundException(); } - var governingRule = await _resolver.ResolveAsync(userId, cipherId); + var now = _timeProvider.GetUtcNow().UtcDateTime; + var signals = AccessSignals.From(_currentContext, new DateTimeOffset(now, TimeSpan.Zero)); + + var governingRule = await _resolver.ResolveAsync(userId, cipherId, signals); if (governingRule is null) { throw new BadRequestException("This item does not require a lease."); } - var now = _timeProvider.GetUtcNow().UtcDateTime; - if (await _accessLeaseRepository.GetActiveByRequesterIdCipherIdAsync(userId, cipherId, now) is not null) { throw new BadRequestException("You already have active access to this item."); @@ -105,11 +105,12 @@ public async Task SubmitAsync(Guid userId, Guid cipherId, A return governingRule.RequiresHumanApproval ? await RequestHumanApprovalAsync(userId, cipherId, governingRule, submission) - : await ApproveAutomaticallyAsync(userId, cipherId, governingRule, submission, now); + : await ApproveAutomaticallyAsync(userId, cipherId, governingRule, submission, now, signals); } private async Task ApproveAutomaticallyAsync( - Guid userId, Guid cipherId, GoverningRule governingRule, AccessRequestSubmission submission, DateTime now) + Guid userId, Guid cipherId, GoverningRule governingRule, AccessRequestSubmission submission, DateTime now, + AccessSignals signals) { if (submission.Start.HasValue || submission.End.HasValue) { @@ -129,7 +130,7 @@ private async Task ApproveAutomaticallyAsync( // The cipher must satisfy its access rule's conditions (source IP, time of day, ...) before the request is // auto-approved. The resolver only routes a rule here when it carries no human-approval gate, so the engine // never asks for approval on this path; any non-allow outcome is a denial we surface to the caller. - var evaluation = _ruleEngine.Evaluate(governingRule.Conditions, BuildSignals(now)); + var evaluation = _ruleEngine.Evaluate(governingRule.Conditions, signals); if (evaluation.Outcome != AccessEvaluationOutcome.Allow) { throw new BadRequestException(DenyMessage(evaluation)); @@ -295,12 +296,6 @@ await _mailService.SendPamPendingAccessRequestEmailsAsync( } } - private AccessSignals BuildSignals(DateTime now) => new() - { - IpAddress = IPAddress.TryParse(_currentContext.IpAddress, out var ip) ? ip : null, - Timestamp = new DateTimeOffset(now, TimeSpan.Zero), - }; - private static string DenyMessage(AccessEvaluation evaluation) => evaluation.Reason switch { DenyReason.NotWithinIpRange => "Access to this item is not permitted from your current network.", diff --git a/src/Core/Pam/OrganizationFeatures/Queries/AccessPreCheckQuery.cs b/src/Core/Pam/OrganizationFeatures/Queries/AccessPreCheckQuery.cs index 6a92e791d73b..87a5c50e8563 100644 --- a/src/Core/Pam/OrganizationFeatures/Queries/AccessPreCheckQuery.cs +++ b/src/Core/Pam/OrganizationFeatures/Queries/AccessPreCheckQuery.cs @@ -1,4 +1,6 @@ -using Bit.Core.Exceptions; +using Bit.Core.Context; +using Bit.Core.Exceptions; +using Bit.Core.Pam.Engine; using Bit.Core.Pam.Enums; using Bit.Core.Pam.Models; using Bit.Core.Pam.OrganizationFeatures.Queries.Interfaces; @@ -13,17 +15,20 @@ public class AccessPreCheckQuery : IAccessPreCheckQuery private readonly ICipherRepository _cipherRepository; private readonly IGoverningRuleResolver _resolver; private readonly IAccessLeaseRepository _accessLeaseRepository; + private readonly ICurrentContext _currentContext; private readonly TimeProvider _timeProvider; public AccessPreCheckQuery( ICipherRepository cipherRepository, IGoverningRuleResolver resolver, IAccessLeaseRepository accessLeaseRepository, + ICurrentContext currentContext, TimeProvider timeProvider) { _cipherRepository = cipherRepository; _resolver = resolver; _accessLeaseRepository = accessLeaseRepository; + _currentContext = currentContext; _timeProvider = timeProvider; } @@ -45,7 +50,8 @@ public async Task PreCheckAsync(Guid userId, Guid cipherId return new AccessPreCheckResult(AccessApprovalMode.Automatic, HasActiveLease: true); } - var governingRule = await _resolver.ResolveAsync(userId, cipherId); + var signals = AccessSignals.From(_currentContext, new DateTimeOffset(now, TimeSpan.Zero)); + var governingRule = await _resolver.ResolveAsync(userId, cipherId, signals); var approvalMode = governingRule?.RequiresHumanApproval == true ? AccessApprovalMode.Human : AccessApprovalMode.Automatic; diff --git a/src/Core/Pam/OrganizationFeatures/Queries/GetCipherAccessStateQuery.cs b/src/Core/Pam/OrganizationFeatures/Queries/GetCipherAccessStateQuery.cs index 296f25ea8c91..b10c2aae48f2 100644 --- a/src/Core/Pam/OrganizationFeatures/Queries/GetCipherAccessStateQuery.cs +++ b/src/Core/Pam/OrganizationFeatures/Queries/GetCipherAccessStateQuery.cs @@ -1,4 +1,6 @@ -using Bit.Core.Exceptions; +using Bit.Core.Context; +using Bit.Core.Exceptions; +using Bit.Core.Pam.Engine; using Bit.Core.Pam.Entities; using Bit.Core.Pam.Models; using Bit.Core.Pam.OrganizationFeatures.Queries.Interfaces; @@ -14,6 +16,7 @@ public class GetCipherAccessStateQuery : IGetCipherAccessStateQuery private readonly IGoverningRuleResolver _resolver; private readonly IAccessLeaseRepository _accessLeaseRepository; private readonly IAccessRequestRepository _accessRequestRepository; + private readonly ICurrentContext _currentContext; private readonly TimeProvider _timeProvider; public GetCipherAccessStateQuery( @@ -21,12 +24,14 @@ public GetCipherAccessStateQuery( IGoverningRuleResolver resolver, IAccessLeaseRepository accessLeaseRepository, IAccessRequestRepository accessRequestRepository, + ICurrentContext currentContext, TimeProvider timeProvider) { _cipherRepository = cipherRepository; _resolver = resolver; _accessLeaseRepository = accessLeaseRepository; _accessRequestRepository = accessRequestRepository; + _currentContext = currentContext; _timeProvider = timeProvider; } @@ -40,6 +45,7 @@ public async Task GetStateAsync(Guid userId, Guid cipherId) } var now = _timeProvider.GetUtcNow().UtcDateTime; + var signals = AccessSignals.From(_currentContext, new DateTimeOffset(now, TimeSpan.Zero)); var activeLease = await _accessLeaseRepository.GetActiveByRequesterIdCipherIdAsync(userId, cipherId, now); var pending = await _accessRequestRepository.GetActivePendingByRequesterIdCipherIdAsync(userId, cipherId); var approved = await _accessRequestRepository.GetActiveApprovedByRequesterIdCipherIdAsync(userId, cipherId, now); @@ -51,7 +57,7 @@ public async Task GetStateAsync(Guid userId, Guid cipherId) // Extension eligibility drives the banner's "Extend" control. A lease may be extended once, so it is // extendable only while the rule opts in and no extension has been recorded yet; surface the rule's max // length so the client can cap its duration picker. - var rule = await _resolver.ResolveAsync(userId, cipherId); + var rule = await _resolver.ResolveAsync(userId, cipherId, signals); if (rule?.AllowsExtensions == true) { var used = await _accessRequestRepository.CountExtensionsByLeaseIdAsync(activeLease.Id); @@ -59,7 +65,7 @@ public async Task GetStateAsync(Guid userId, Guid cipherId) maxExtensionDurationSeconds = rule.MaxExtensionDurationSeconds; } } - else if (pending is null && approved is null && await _resolver.ResolveAsync(userId, cipherId) is null) + else if (pending is null && approved is null && await _resolver.ResolveAsync(userId, cipherId, signals) is null) { // Nothing to report and the cipher isn't leasing-gated. (When a lease or request exists we still return a // snapshot even if the rule was since removed, so the caller's state isn't hidden.) diff --git a/src/Core/Pam/OrganizationFeatures/Queries/GetLeasedCipherQuery.cs b/src/Core/Pam/OrganizationFeatures/Queries/GetLeasedCipherQuery.cs index 2c9325bc3059..f4f826cb2f9b 100644 --- a/src/Core/Pam/OrganizationFeatures/Queries/GetLeasedCipherQuery.cs +++ b/src/Core/Pam/OrganizationFeatures/Queries/GetLeasedCipherQuery.cs @@ -1,5 +1,4 @@ -using System.Net; -using Bit.Core.Context; +using Bit.Core.Context; using Bit.Core.Pam.Engine; using Bit.Core.Pam.OrganizationFeatures.Queries.Interfaces; using Bit.Core.Pam.Repositories; @@ -45,22 +44,16 @@ public GetLeasedCipherQuery( return null; } + var signals = AccessSignals.From(_currentContext, now); + // A lease grants a window, but the access rule's environmental conditions (source IP, time of day) must // still hold at the moment the data is handed over. Approval is not re-checked here: holding the lease is // proof it was already granted, so only an outright denial withholds the data. - var governingRule = await _resolver.ResolveAsync(userId, cipherId); - if (governingRule is not null) + var governingRule = await _resolver.ResolveAsync(userId, cipherId, signals); + if (governingRule is not null + && _ruleEngine.Evaluate(governingRule.Conditions, signals).Outcome == AccessEvaluationOutcome.Deny) { - var signals = new AccessSignals - { - IpAddress = IPAddress.TryParse(_currentContext.IpAddress, out var ip) ? ip : null, - Timestamp = now, - }; - - if (_ruleEngine.Evaluate(governingRule.Conditions, signals).Outcome == AccessEvaluationOutcome.Deny) - { - return null; - } + return null; } // GetByIdAsync filters by access, so a null result means the caller cannot see the cipher. diff --git a/src/Core/Pam/Services/GoverningRuleResolver.cs b/src/Core/Pam/Services/GoverningRuleResolver.cs index c9a6309f69f5..da7c74a4ba75 100644 --- a/src/Core/Pam/Services/GoverningRuleResolver.cs +++ b/src/Core/Pam/Services/GoverningRuleResolver.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using Bit.Core.Pam.Engine; using Bit.Core.Pam.Models; using Bit.Core.Pam.Models.Conditions; using Bit.Core.Pam.Repositories; @@ -17,18 +18,21 @@ public class GoverningRuleResolver : IGoverningRuleResolver private readonly ICollectionCipherRepository _collectionCipherRepository; private readonly ICollectionRepository _collectionRepository; private readonly IAccessRuleRepository _accessRuleRepository; + private readonly IAccessRuleEngine _ruleEngine; public GoverningRuleResolver( ICollectionCipherRepository collectionCipherRepository, ICollectionRepository collectionRepository, - IAccessRuleRepository accessRuleRepository) + IAccessRuleRepository accessRuleRepository, + IAccessRuleEngine ruleEngine) { _collectionCipherRepository = collectionCipherRepository; _collectionRepository = collectionRepository; _accessRuleRepository = accessRuleRepository; + _ruleEngine = ruleEngine; } - public async Task ResolveAsync(Guid userId, Guid cipherId) + public async Task ResolveAsync(Guid userId, Guid cipherId, AccessSignals signals) { var collectionCiphers = await _collectionCipherRepository.GetManyByUserIdCipherIdAsync(userId, cipherId); if (collectionCiphers.Count == 0) @@ -39,12 +43,17 @@ public GoverningRuleResolver( var collectionIds = collectionCiphers.Select(cc => cc.CollectionId).ToHashSet(); var collections = await _collectionRepository.GetManyByManyIdsAsync(collectionIds); - // Deterministic order so the chosen governing collection is stable across calls. + // Deterministic order so ties between equally-favourable rules resolve to a stable governing collection. var governed = collections .Where(c => collectionIds.Contains(c.Id) && c.AccessRuleId.HasValue) .OrderBy(c => c.Id); - GoverningRule? automatic = null; + // Least-restrictive wins: among the rules on the collections through which the caller reaches the cipher, + // an automatic grant (Allow) is favoured over one needing human approval (RequiresApproval), which is + // favoured over a denial (Deny). Evaluating each rule against the request signals means a failing automatic + // rule (e.g. an out-of-range IP) never pre-empts a different path that would grant or route to a human. + GoverningRule? best = null; + var bestOutcome = AccessEvaluationOutcome.Deny; foreach (var collection in governed) { var accessRule = await _accessRuleRepository.GetByIdAsync(collection.AccessRuleId!.Value); @@ -54,24 +63,31 @@ public GoverningRuleResolver( } var conditions = Parse(accessRule.Conditions); - if (ContainsHumanApproval(conditions)) + var outcome = _ruleEngine.Evaluate(conditions, signals).Outcome; + if (best is not null && outcome >= bestOutcome) { - // Most restrictive wins — return as soon as a human-approval condition is found. - return new GoverningRule(collection.OrganizationId, collection.Id, true, conditions) - { - AllowsExtensions = accessRule.AllowsExtensions, - MaxExtensionDurationSeconds = accessRule.MaxExtensionDurationSeconds, - }; + continue; } - automatic ??= new GoverningRule(collection.OrganizationId, collection.Id, false, conditions) + bestOutcome = outcome; + best = new GoverningRule( + collection.OrganizationId, + collection.Id, + outcome == AccessEvaluationOutcome.RequiresApproval, + conditions) { AllowsExtensions = accessRule.AllowsExtensions, MaxExtensionDurationSeconds = accessRule.MaxExtensionDurationSeconds, }; + + if (outcome == AccessEvaluationOutcome.Allow) + { + // Nothing beats an automatic grant; stop scanning the remaining collections. + break; + } } - return automatic; + return best; } /// @@ -93,7 +109,4 @@ private static IReadOnlyList Parse(string conditionsJson) } private static IReadOnlyList FailSafe() => [new HumanApprovalCondition()]; - - private static bool ContainsHumanApproval(IReadOnlyList conditions) => - conditions.Any(condition => condition is HumanApprovalCondition); } diff --git a/src/Core/Pam/Services/IGoverningRuleResolver.cs b/src/Core/Pam/Services/IGoverningRuleResolver.cs index f0f0d065a0a7..7b34ba45ac92 100644 --- a/src/Core/Pam/Services/IGoverningRuleResolver.cs +++ b/src/Core/Pam/Services/IGoverningRuleResolver.cs @@ -1,13 +1,17 @@ -using Bit.Core.Pam.Models; +using Bit.Core.Pam.Engine; +using Bit.Core.Pam.Models; namespace Bit.Core.Pam.Services; public interface IGoverningRuleResolver { /// - /// Resolves the leasing context that governs for the caller, or null when the cipher - /// is not leasing-gated for them (no reachable collection carries an access rule). When more than one governing - /// collection applies, the most restrictive (human-approval) one wins. + /// Resolves the leasing context that governs for the caller, evaluating each + /// reachable collection's rule against the request , or null when the cipher is not + /// leasing-gated for them (no reachable collection carries an access rule). When more than one governing + /// collection applies, the least-restrictive applicable rule wins: an automatic grant is favoured over one that + /// needs human approval, which is favoured over a denial — so the caller is never routed to an approver when some + /// path would auto-grant, and a failing automatic rule never pre-empts a path that would grant. /// - Task ResolveAsync(Guid userId, Guid cipherId); + Task ResolveAsync(Guid userId, Guid cipherId, AccessSignals signals); } diff --git a/test/Core.Test/Pam/Commands/RequestLeaseExtensionCommandTests.cs b/test/Core.Test/Pam/Commands/RequestLeaseExtensionCommandTests.cs index 0300f7965ec6..2b5d0010b4f5 100644 --- a/test/Core.Test/Pam/Commands/RequestLeaseExtensionCommandTests.cs +++ b/test/Core.Test/Pam/Commands/RequestLeaseExtensionCommandTests.cs @@ -1,4 +1,5 @@ using Bit.Core.Exceptions; +using Bit.Core.Pam.Engine; using Bit.Core.Pam.Entities; using Bit.Core.Pam.Enums; using Bit.Core.Pam.Models; @@ -73,7 +74,8 @@ public async Task ExtendAsync_ItemNotGated_ThrowsBadRequest(AccessLease lease) var sutProvider = Setup(); SetupExtendableLease(sutProvider, lease); sutProvider.GetDependency() - .ResolveAsync(lease.RequesterId, lease.CipherId).Returns((GoverningRule?)null); + .ResolveAsync(lease.RequesterId, lease.CipherId, Arg.Any()) + .Returns((GoverningRule?)null); await Assert.ThrowsAsync( () => sutProvider.Sut.ExtendAsync(lease.RequesterId, Submission(lease.Id))); @@ -238,7 +240,7 @@ private static void SetupExtendableLease( // A human-approval rule still yields automatic extensions — the approval gate never applies to extensions. sutProvider.GetDependency() - .ResolveAsync(lease.RequesterId, lease.CipherId) + .ResolveAsync(lease.RequesterId, lease.CipherId, Arg.Any()) .Returns(new GoverningRule(lease.OrganizationId, lease.CollectionId, RequiresHumanApproval: true, [new HumanApprovalCondition()]) { diff --git a/test/Core.Test/Pam/Commands/SubmitAccessRequestCommandTests.cs b/test/Core.Test/Pam/Commands/SubmitAccessRequestCommandTests.cs index 69c474be99fa..f0f8a2f77479 100644 --- a/test/Core.Test/Pam/Commands/SubmitAccessRequestCommandTests.cs +++ b/test/Core.Test/Pam/Commands/SubmitAccessRequestCommandTests.cs @@ -41,7 +41,7 @@ public async Task SubmitAsync_NotLeasingGated_ThrowsBadRequest(Guid userId, Guid { var sutProvider = Setup(); SetupCipher(sutProvider, userId, cipherId); - sutProvider.GetDependency().ResolveAsync(userId, cipherId) + sutProvider.GetDependency().ResolveAsync(userId, cipherId, Arg.Any()) .Returns((GoverningRule?)null); var ex = await Assert.ThrowsAsync( @@ -403,7 +403,7 @@ private static void SetupResolution(SutProvider sutP { var condition = requiresHuman ? new HumanApprovalCondition() : (AccessCondition)new IpAllowlistCondition { Cidrs = ["10.0.0.0/8"] }; sutProvider.GetDependency() - .ResolveAsync(userId, cipherId) + .ResolveAsync(userId, cipherId, Arg.Any()) .Returns(new GoverningRule(orgId, collectionId, requiresHuman, [condition])); } diff --git a/test/Core.Test/Pam/Queries/AccessPreCheckQueryTests.cs b/test/Core.Test/Pam/Queries/AccessPreCheckQueryTests.cs index 447d566cd29e..e12d9659673a 100644 --- a/test/Core.Test/Pam/Queries/AccessPreCheckQueryTests.cs +++ b/test/Core.Test/Pam/Queries/AccessPreCheckQueryTests.cs @@ -1,4 +1,5 @@ using Bit.Core.Exceptions; +using Bit.Core.Pam.Engine; using Bit.Core.Pam.Entities; using Bit.Core.Pam.Enums; using Bit.Core.Pam.Models; @@ -35,7 +36,7 @@ public async Task PreCheckAsync_HumanApprovalCondition_ReturnsHuman( { SetupCipher(sutProvider, userId, cipherId); sutProvider.GetDependency() - .ResolveAsync(userId, cipherId) + .ResolveAsync(userId, cipherId, Arg.Any()) .Returns(new GoverningRule(orgId, collectionId, RequiresHumanApproval: true, [new HumanApprovalCondition()])); @@ -50,7 +51,7 @@ public async Task PreCheckAsync_AutoApproveRule_ReturnsAutomatic( { SetupCipher(sutProvider, userId, cipherId); sutProvider.GetDependency() - .ResolveAsync(userId, cipherId) + .ResolveAsync(userId, cipherId, Arg.Any()) .Returns(new GoverningRule(orgId, collectionId, RequiresHumanApproval: false, [new IpAllowlistCondition { Cidrs = ["10.0.0.0/8"] }])); @@ -72,7 +73,7 @@ public async Task PreCheckAsync_ExistingActiveLease_ReturnsHasActiveLease( Assert.True(result.HasActiveLease); // The approval path is irrelevant once a lease is held, so the rule resolver is never consulted. - await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().ResolveAsync(default, default); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().ResolveAsync(default, default, default); } [Theory, BitAutoData] @@ -81,7 +82,7 @@ public async Task PreCheckAsync_NotLeasingGated_ReturnsAutomatic( { SetupCipher(sutProvider, userId, cipherId); sutProvider.GetDependency() - .ResolveAsync(userId, cipherId) + .ResolveAsync(userId, cipherId, Arg.Any()) .Returns((GoverningRule?)null); var result = await sutProvider.Sut.PreCheckAsync(userId, cipherId); diff --git a/test/Core.Test/Pam/Queries/GetCipherAccessStateQueryTests.cs b/test/Core.Test/Pam/Queries/GetCipherAccessStateQueryTests.cs index ddfb0c14ad92..8d019bea246b 100644 --- a/test/Core.Test/Pam/Queries/GetCipherAccessStateQueryTests.cs +++ b/test/Core.Test/Pam/Queries/GetCipherAccessStateQueryTests.cs @@ -1,4 +1,5 @@ using Bit.Core.Exceptions; +using Bit.Core.Pam.Engine; using Bit.Core.Pam.Entities; using Bit.Core.Pam.Enums; using Bit.Core.Pam.Models; @@ -36,7 +37,7 @@ public async Task GetStateAsync_NotGatedAndNothingHeld_ThrowsNotFound( SetupCipher(sutProvider, userId, cipherId); // No active lease, no pending request, and the resolver finds no governing rule. sutProvider.GetDependency() - .ResolveAsync(userId, cipherId) + .ResolveAsync(userId, cipherId, Arg.Any()) .Returns((GoverningRule?)null); await Assert.ThrowsAsync(() => sutProvider.Sut.GetStateAsync(userId, cipherId)); @@ -68,7 +69,7 @@ public async Task GetStateAsync_LeaseHeldButRuleRemoved_StillReturnsSnapshot( .Returns(activeLease); // Access rule since removed: resolver returns null, but the held lease must not be hidden. sutProvider.GetDependency() - .ResolveAsync(userId, cipherId) + .ResolveAsync(userId, cipherId, Arg.Any()) .Returns((GoverningRule?)null); var result = await sutProvider.Sut.GetStateAsync(userId, cipherId); @@ -140,7 +141,7 @@ public async Task GetStateAsync_ApprovedHeldButRuleRemoved_StillReturnsSnapshot( .Returns(approved); // Access rule since removed: resolver returns null, but the startable approval must not be hidden. sutProvider.GetDependency() - .ResolveAsync(userId, cipherId) + .ResolveAsync(userId, cipherId, Arg.Any()) .Returns((GoverningRule?)null); var result = await sutProvider.Sut.GetStateAsync(userId, cipherId); @@ -155,7 +156,7 @@ public async Task GetStateAsync_GatedButEmpty_ReturnsEmptySnapshot( { SetupCipher(sutProvider, userId, cipherId); sutProvider.GetDependency() - .ResolveAsync(userId, cipherId) + .ResolveAsync(userId, cipherId, Arg.Any()) .Returns(new GoverningRule(orgId, collectionId, RequiresHumanApproval: true, [new HumanApprovalCondition()])); @@ -177,7 +178,7 @@ public async Task GetStateAsync_ActiveLease_NotYetExtended_AllowedWithMaxLength( .GetActiveByRequesterIdCipherIdAsync(userId, cipherId, Arg.Any()) .Returns(activeLease); sutProvider.GetDependency() - .ResolveAsync(userId, cipherId) + .ResolveAsync(userId, cipherId, Arg.Any()) .Returns(new GoverningRule(orgId, collectionId, RequiresHumanApproval: false, [new HumanApprovalCondition()]) { @@ -203,7 +204,7 @@ public async Task GetStateAsync_ActiveLease_AlreadyExtended_NotAllowed( .GetActiveByRequesterIdCipherIdAsync(userId, cipherId, Arg.Any()) .Returns(activeLease); sutProvider.GetDependency() - .ResolveAsync(userId, cipherId) + .ResolveAsync(userId, cipherId, Arg.Any()) .Returns(new GoverningRule(orgId, collectionId, RequiresHumanApproval: false, [new HumanApprovalCondition()]) { @@ -230,7 +231,7 @@ public async Task GetStateAsync_ActiveLease_ExtensionsDisallowed_ReportsNotAllow .GetActiveByRequesterIdCipherIdAsync(userId, cipherId, Arg.Any()) .Returns(activeLease); sutProvider.GetDependency() - .ResolveAsync(userId, cipherId) + .ResolveAsync(userId, cipherId, Arg.Any()) .Returns(new GoverningRule(orgId, collectionId, RequiresHumanApproval: false, [new HumanApprovalCondition()]) { diff --git a/test/Core.Test/Pam/Queries/GetLeasedCipherQueryTests.cs b/test/Core.Test/Pam/Queries/GetLeasedCipherQueryTests.cs index ae3e51eda450..8f4bb7f070b6 100644 --- a/test/Core.Test/Pam/Queries/GetLeasedCipherQueryTests.cs +++ b/test/Core.Test/Pam/Queries/GetLeasedCipherQueryTests.cs @@ -145,7 +145,7 @@ private static void SetupResolution(SutProvider sutProvide Guid orgId, Guid collectionId) { sutProvider.GetDependency() - .ResolveAsync(userId, cipherId) + .ResolveAsync(userId, cipherId, Arg.Any()) .Returns(new GoverningRule(orgId, collectionId, false, [new IpAllowlistCondition { Cidrs = ["10.0.0.0/8"] }])); } diff --git a/test/Core.Test/Pam/Services/GoverningRuleResolverTests.cs b/test/Core.Test/Pam/Services/GoverningRuleResolverTests.cs index 4afa521a67bb..4e246eada9b2 100644 --- a/test/Core.Test/Pam/Services/GoverningRuleResolverTests.cs +++ b/test/Core.Test/Pam/Services/GoverningRuleResolverTests.cs @@ -1,4 +1,6 @@ -using Bit.Core.Entities; +using System.Net; +using Bit.Core.Entities; +using Bit.Core.Pam.Engine; using Bit.Core.Pam.Entities; using Bit.Core.Pam.Models.Conditions; using Bit.Core.Pam.Repositories; @@ -14,6 +16,17 @@ namespace Bit.Core.Test.Pam.Services; [SutProviderCustomize] public class GoverningRuleResolverTests { + // The resolver now evaluates each candidate rule, so the tests drive the real engine through the substitute. + private static readonly IAccessRuleEngine _engine = new AccessRuleEngine(); + + // An in-range IP for the 10.0.0.0/8 allowlists below; out-of-range for the 192.168/172.16 allowlists, which + // therefore deny. No time-of-day conditions are used, so the timestamp is arbitrary. + private static readonly AccessSignals _signals = new() + { + IpAddress = IPAddress.Parse("10.0.0.5"), + Timestamp = new DateTimeOffset(2026, 1, 1, 12, 0, 0, TimeSpan.Zero), + }; + [Theory, BitAutoData] public async Task ResolveAsync_NoReachableCollections_ReturnsNull( SutProvider sutProvider, Guid userId, Guid cipherId) @@ -22,7 +35,7 @@ public async Task ResolveAsync_NoReachableCollections_ReturnsNull( .GetManyByUserIdCipherIdAsync(userId, cipherId) .Returns(new List()); - Assert.Null(await sutProvider.Sut.ResolveAsync(userId, cipherId)); + Assert.Null(await sutProvider.Sut.ResolveAsync(userId, cipherId, _signals)); } [Theory, BitAutoData] @@ -32,7 +45,7 @@ public async Task ResolveAsync_CollectionWithoutAccessRule_ReturnsNull( collection.AccessRuleId = null; SetupReachableCollections(sutProvider, userId, cipherId, collection); - Assert.Null(await sutProvider.Sut.ResolveAsync(userId, cipherId)); + Assert.Null(await sutProvider.Sut.ResolveAsync(userId, cipherId, _signals)); } [Theory, BitAutoData] @@ -42,7 +55,7 @@ public async Task ResolveAsync_HumanApprovalCondition_RequiresHumanApproval( rule.Conditions = """[{"kind":"human_approval"}]"""; SetupGovernedCollection(sutProvider, userId, cipherId, collection, rule); - var result = await sutProvider.Sut.ResolveAsync(userId, cipherId); + var result = await sutProvider.Sut.ResolveAsync(userId, cipherId, _signals); Assert.NotNull(result); Assert.True(result!.RequiresHumanApproval); @@ -52,13 +65,13 @@ public async Task ResolveAsync_HumanApprovalCondition_RequiresHumanApproval( } [Theory, BitAutoData] - public async Task ResolveAsync_IpAllowlistCondition_DoesNotRequireHumanApproval( + public async Task ResolveAsync_PassingIpAllowlistCondition_DoesNotRequireHumanApproval( SutProvider sutProvider, Guid userId, Guid cipherId, Collection collection, AccessRule rule) { rule.Conditions = """[{"kind":"ip_allowlist","cidrs":["10.0.0.0/8"]}]"""; SetupGovernedCollection(sutProvider, userId, cipherId, collection, rule); - var result = await sutProvider.Sut.ResolveAsync(userId, cipherId); + var result = await sutProvider.Sut.ResolveAsync(userId, cipherId, _signals); Assert.NotNull(result); Assert.False(result!.RequiresHumanApproval); @@ -73,7 +86,7 @@ public async Task ResolveAsync_ConditionsContainingHumanApproval_RequiresHumanAp rule.Conditions = """[{"kind":"ip_allowlist","cidrs":["10.0.0.0/8"]},{"kind":"human_approval"}]"""; SetupGovernedCollection(sutProvider, userId, cipherId, collection, rule); - var result = await sutProvider.Sut.ResolveAsync(userId, cipherId); + var result = await sutProvider.Sut.ResolveAsync(userId, cipherId, _signals); Assert.NotNull(result); Assert.True(result!.RequiresHumanApproval); @@ -89,7 +102,7 @@ public async Task ResolveAsync_EmptyConditions_DoesNotRequireHumanApproval( rule.Conditions = "[]"; SetupGovernedCollection(sutProvider, userId, cipherId, collection, rule); - var result = await sutProvider.Sut.ResolveAsync(userId, cipherId); + var result = await sutProvider.Sut.ResolveAsync(userId, cipherId, _signals); Assert.NotNull(result); Assert.False(result!.RequiresHumanApproval); @@ -103,7 +116,7 @@ public async Task ResolveAsync_MalformedRule_FailsSafeToHumanApproval( rule.Conditions = "not json"; SetupGovernedCollection(sutProvider, userId, cipherId, collection, rule); - var result = await sutProvider.Sut.ResolveAsync(userId, cipherId); + var result = await sutProvider.Sut.ResolveAsync(userId, cipherId, _signals); Assert.NotNull(result); Assert.True(result!.RequiresHumanApproval); @@ -111,6 +124,98 @@ public async Task ResolveAsync_MalformedRule_FailsSafeToHumanApproval( Assert.IsType(Assert.Single(result.Conditions)); } + [Theory, BitAutoData] + public async Task ResolveAsync_AutomaticGrantPath_BeatsHumanApprovalPath( + SutProvider sutProvider, Guid userId, Guid cipherId, + Collection automaticCollection, AccessRule automaticRule, Collection humanCollection, AccessRule humanRule) + { + // One reachable collection auto-grants (passing IP allowlist); the other needs human approval. + automaticRule.Conditions = """[{"kind":"ip_allowlist","cidrs":["10.0.0.0/8"]}]"""; + humanRule.Conditions = """[{"kind":"human_approval"}]"""; + SetupGovernedCollections(sutProvider, userId, cipherId, + (automaticCollection, automaticRule), (humanCollection, humanRule)); + + var result = await sutProvider.Sut.ResolveAsync(userId, cipherId, _signals); + + // Least-restrictive wins: the caller is never routed to an approver when some path would auto-grant. + Assert.NotNull(result); + Assert.False(result!.RequiresHumanApproval); + Assert.Equal(automaticCollection.Id, result.CollectionId); + } + + [Theory, BitAutoData] + public async Task ResolveAsync_FailingAutomaticRule_DoesNotPreemptGrantingHumanApprovalPath( + SutProvider sutProvider, Guid userId, Guid cipherId, + Collection automaticCollection, AccessRule automaticRule, Collection humanCollection, AccessRule humanRule) + { + // The automatic rule's IP allowlist fails for this caller; the human-approval rule would still grant. + automaticRule.Conditions = """[{"kind":"ip_allowlist","cidrs":["192.168.0.0/16"]}]"""; + humanRule.Conditions = """[{"kind":"human_approval"}]"""; + SetupGovernedCollections(sutProvider, userId, cipherId, + (automaticCollection, automaticRule), (humanCollection, humanRule)); + + var result = await sutProvider.Sut.ResolveAsync(userId, cipherId, _signals); + + // A failing automatic rule must not pre-empt a path that needs approval but would grant. + Assert.NotNull(result); + Assert.True(result!.RequiresHumanApproval); + Assert.Equal(humanCollection.Id, result.CollectionId); + } + + [Theory, BitAutoData] + public async Task ResolveAsync_NoRulePasses_ResolvesToAutoDenyPath( + SutProvider sutProvider, Guid userId, Guid cipherId, + Collection firstCollection, AccessRule firstRule, Collection secondCollection, AccessRule secondRule) + { + // Neither automatic rule's allowlist matches the caller, and no approval path exists, so every path denies. + firstRule.Conditions = """[{"kind":"ip_allowlist","cidrs":["192.168.0.0/16"]}]"""; + secondRule.Conditions = """[{"kind":"ip_allowlist","cidrs":["172.16.0.0/12"]}]"""; + SetupGovernedCollections(sutProvider, userId, cipherId, + (firstCollection, firstRule), (secondCollection, secondRule)); + + var result = await sutProvider.Sut.ResolveAsync(userId, cipherId, _signals); + + // Falls through to a deny path (not routed to a human); the auto path then surfaces the denial downstream. + Assert.NotNull(result); + Assert.False(result!.RequiresHumanApproval); + } + + [Theory, BitAutoData] + public async Task ResolveAsync_TwoAutomaticRules_ResolvesToThePassingOne( + SutProvider sutProvider, Guid userId, Guid cipherId, + Collection failingCollection, AccessRule failingRule, Collection passingCollection, AccessRule passingRule) + { + failingRule.Conditions = """[{"kind":"ip_allowlist","cidrs":["192.168.0.0/16"]}]"""; + passingRule.Conditions = """[{"kind":"ip_allowlist","cidrs":["10.0.0.0/8"]}]"""; + SetupGovernedCollections(sutProvider, userId, cipherId, + (failingCollection, failingRule), (passingCollection, passingRule)); + + var result = await sutProvider.Sut.ResolveAsync(userId, cipherId, _signals); + + Assert.NotNull(result); + Assert.False(result!.RequiresHumanApproval); + Assert.Equal(passingCollection.Id, result.CollectionId); + } + + [Theory, BitAutoData] + public async Task ResolveAsync_MalformedRuleAlongsidePassingAutomatic_AutoGrants( + SutProvider sutProvider, Guid userId, Guid cipherId, + Collection malformedCollection, AccessRule malformedRule, Collection automaticCollection, AccessRule automaticRule) + { + // The malformed rule fails safe to human approval for its own path, but a different parseable rule auto-grants, + // so the union/OR auto-grant path wins. + malformedRule.Conditions = "not json"; + automaticRule.Conditions = """[{"kind":"ip_allowlist","cidrs":["10.0.0.0/8"]}]"""; + SetupGovernedCollections(sutProvider, userId, cipherId, + (malformedCollection, malformedRule), (automaticCollection, automaticRule)); + + var result = await sutProvider.Sut.ResolveAsync(userId, cipherId, _signals); + + Assert.NotNull(result); + Assert.False(result!.RequiresHumanApproval); + Assert.Equal(automaticCollection.Id, result.CollectionId); + } + private static void SetupReachableCollections( SutProvider sutProvider, Guid userId, Guid cipherId, params Collection[] collections) { @@ -124,11 +229,27 @@ private static void SetupReachableCollections( private static void SetupGovernedCollection( SutProvider sutProvider, Guid userId, Guid cipherId, Collection collection, AccessRule rule) + => SetupGovernedCollections(sutProvider, userId, cipherId, (collection, rule)); + + private static void SetupGovernedCollections( + SutProvider sutProvider, Guid userId, Guid cipherId, + params (Collection collection, AccessRule rule)[] pairs) { - collection.AccessRuleId = rule.Id; - SetupReachableCollections(sutProvider, userId, cipherId, collection); - sutProvider.GetDependency() - .GetByIdAsync(rule.Id) - .Returns(rule); + foreach (var (collection, rule) in pairs) + { + collection.AccessRuleId = rule.Id; + } + + SetupReachableCollections(sutProvider, userId, cipherId, pairs.Select(p => p.collection).ToArray()); + + foreach (var (_, rule) in pairs) + { + sutProvider.GetDependency().GetByIdAsync(rule.Id).Returns(rule); + } + + // Drive the real engine through the substitute so resolution exercises true IP/time evaluation. + sutProvider.GetDependency() + .Evaluate(Arg.Any>(), Arg.Any()) + .Returns(ci => _engine.Evaluate(ci.ArgAt>(0), ci.ArgAt(1))); } } From 914c4120416f74ea32ecf95712cfdef83c90dd86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anders=20=C3=85berg?= Date: Tue, 16 Jun 2026 20:41:42 +0200 Subject: [PATCH 42/54] PAM: resolve approver identity (name/email) in access-request reads --- .../AccessRequestDetailsResponseModel.cs | 8 + src/Core/Pam/Models/AccessRequestDetails.cs | 9 + ...equest_ReadInboxHistoryByCollectionIds.sql | 5 +- ...equest_ReadInboxPendingByCollectionIds.sql | 5 +- .../AccessRequest_ReadManyByRequesterId.sql | 10 +- .../AccessRequestDetailsResponseModelTests.cs | 25 +++ .../AccessRequestRepositoryTests.cs | 44 +++++ ...ddApproverIdentityToAccessRequestReads.sql | 158 ++++++++++++++++++ 8 files changed, 260 insertions(+), 4 deletions(-) create mode 100644 util/Migrator/DbScripts/2026-06-16_00_AddApproverIdentityToAccessRequestReads.sql diff --git a/src/Api/Pam/Models/Response/AccessRequestDetailsResponseModel.cs b/src/Api/Pam/Models/Response/AccessRequestDetailsResponseModel.cs index eb709dbdd0c0..323b65401b02 100644 --- a/src/Api/Pam/Models/Response/AccessRequestDetailsResponseModel.cs +++ b/src/Api/Pam/Models/Response/AccessRequestDetailsResponseModel.cs @@ -28,6 +28,8 @@ public AccessRequestDetailsResponseModel(AccessRequestDetails details) SubmittedAt = details.CreationDate.AsUtc(); ResolvedAt = details.ResolvedDate.AsUtc(); ApproverId = details.ApproverId; + ApproverName = details.ApproverName; + ApproverEmail = details.ApproverEmail; ApproverComment = details.ApproverComment; ProducedLeaseId = details.ProducedLeaseId; ProducedLeaseStatus = details.ProducedLeaseStatus.HasValue @@ -66,6 +68,12 @@ public AccessRequestDetailsResponseModel(AccessRequestDetails details) /// The human approver who decided the request, or null (e.g. still pending or decided automatically). public Guid? ApproverId { get; } + /// The human approver's display name, or null. Lets the client name the resolver instead of an id. + public string? ApproverName { get; } + + /// The human approver's email, the fallback display when is unset. + public string? ApproverEmail { get; } + public string? ApproverComment { get; } /// Set once an approved request has produced a lease. diff --git a/src/Core/Pam/Models/AccessRequestDetails.cs b/src/Core/Pam/Models/AccessRequestDetails.cs index 52d0d2d0a974..de2e6b2c6fdb 100644 --- a/src/Core/Pam/Models/AccessRequestDetails.cs +++ b/src/Core/Pam/Models/AccessRequestDetails.cs @@ -39,6 +39,15 @@ public class AccessRequestDetails /// The human approver who resolved the request, or null (e.g. still pending or auto-resolved). public Guid? ApproverId { get; set; } + /// + /// The human approver's display name, denormalized from the User join so the requester's own + /// request list can name the resolver instead of showing a raw id. Null when no human resolved. + /// + public string? ApproverName { get; set; } + + /// The human approver's email, the fallback display when is unset. + public string? ApproverEmail { get; set; } + /// The human approver's comment, if any. public string? ApproverComment { get; set; } diff --git a/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_ReadInboxHistoryByCollectionIds.sql b/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_ReadInboxHistoryByCollectionIds.sql index a1c07d4d6e60..cbb580167417 100644 --- a/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_ReadInboxHistoryByCollectionIds.sql +++ b/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_ReadInboxHistoryByCollectionIds.sql @@ -25,6 +25,8 @@ BEGIN PL.[Id] AS [ProducedLeaseId], PL.[Status] AS [ProducedLeaseStatus], RES.[ApproverId] AS [ApproverId], + RES.[ApproverName] AS [ApproverName], + RES.[ApproverEmail] AS [ApproverEmail], RES.[Comment] AS [ApproverComment], JSON_VALUE(C.[Data], '$.Name') AS [CipherName], COL.[Name] AS [CollectionName], @@ -42,8 +44,9 @@ BEGIN ORDER BY L.[CreationDate] DESC ) PL OUTER APPLY ( - SELECT TOP 1 LD.[ApproverId], LD.[Comment] + SELECT TOP 1 LD.[ApproverId], LD.[Comment], AU.[Name] AS [ApproverName], AU.[Email] AS [ApproverEmail] FROM [dbo].[AccessDecision] LD + LEFT JOIN [dbo].[User] AU ON AU.[Id] = LD.[ApproverId] WHERE LD.[AccessRequestId] = LR.[Id] AND LD.[DeciderKind] = 1 -- Human ORDER BY LD.[CreationDate] ASC ) RES diff --git a/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_ReadInboxPendingByCollectionIds.sql b/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_ReadInboxPendingByCollectionIds.sql index 88ec4e0ecc5d..0401bb5cd083 100644 --- a/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_ReadInboxPendingByCollectionIds.sql +++ b/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_ReadInboxPendingByCollectionIds.sql @@ -25,6 +25,8 @@ BEGIN PL.[Id] AS [ProducedLeaseId], PL.[Status] AS [ProducedLeaseStatus], RES.[ApproverId] AS [ApproverId], + RES.[ApproverName] AS [ApproverName], + RES.[ApproverEmail] AS [ApproverEmail], RES.[Comment] AS [ApproverComment], JSON_VALUE(C.[Data], '$.Name') AS [CipherName], COL.[Name] AS [CollectionName], @@ -42,8 +44,9 @@ BEGIN ORDER BY L.[CreationDate] DESC ) PL OUTER APPLY ( - SELECT TOP 1 LD.[ApproverId], LD.[Comment] + SELECT TOP 1 LD.[ApproverId], LD.[Comment], AU.[Name] AS [ApproverName], AU.[Email] AS [ApproverEmail] FROM [dbo].[AccessDecision] LD + LEFT JOIN [dbo].[User] AU ON AU.[Id] = LD.[ApproverId] WHERE LD.[AccessRequestId] = LR.[Id] AND LD.[DeciderKind] = 1 -- Human ORDER BY LD.[CreationDate] ASC ) RES diff --git a/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_ReadManyByRequesterId.sql b/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_ReadManyByRequesterId.sql index 380a2af19593..72db6b20cfe9 100644 --- a/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_ReadManyByRequesterId.sql +++ b/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_ReadManyByRequesterId.sql @@ -6,7 +6,10 @@ BEGIN -- The caller's own requests across every org, all statuses. Unlike the approver-inbox reads this is a -- caller-scoped self-read, so the cipher/collection/requester display-name joins are intentionally omitted - -- (those name fields stay null). Capped at the 250 most recent; the client renders far fewer. + -- (those name fields stay null) -- cipher/collection names come from the caller's local vault, and the + -- requester is the caller. The approver, however, is resolved here (ApproverName/ApproverEmail) because the + -- requester has no other way to name who decided their request. Capped at the 250 most recent; the client + -- renders far fewer. SELECT TOP (250) LR.[Id], LR.[ExtensionOfLeaseId], @@ -22,6 +25,8 @@ BEGIN LR.[ResolvedDate], PL.[Id] AS [ProducedLeaseId], RES.[ApproverId] AS [ApproverId], + RES.[ApproverName] AS [ApproverName], + RES.[ApproverEmail] AS [ApproverEmail], RES.[Comment] AS [ApproverComment] FROM [dbo].[AccessRequest] LR OUTER APPLY ( @@ -31,8 +36,9 @@ BEGIN ORDER BY L.[CreationDate] DESC ) PL OUTER APPLY ( - SELECT TOP 1 LD.[ApproverId], LD.[Comment] + SELECT TOP 1 LD.[ApproverId], LD.[Comment], AU.[Name] AS [ApproverName], AU.[Email] AS [ApproverEmail] FROM [dbo].[AccessDecision] LD + LEFT JOIN [dbo].[User] AU ON AU.[Id] = LD.[ApproverId] WHERE LD.[AccessRequestId] = LR.[Id] AND LD.[DeciderKind] = 1 -- Human ORDER BY LD.[CreationDate] ASC ) RES diff --git a/test/Api.Test/Pam/Models/AccessRequestDetailsResponseModelTests.cs b/test/Api.Test/Pam/Models/AccessRequestDetailsResponseModelTests.cs index 68a140e770de..df767d1a45bd 100644 --- a/test/Api.Test/Pam/Models/AccessRequestDetailsResponseModelTests.cs +++ b/test/Api.Test/Pam/Models/AccessRequestDetailsResponseModelTests.cs @@ -50,4 +50,29 @@ public void Ctor_LeavesNullResolvedDateNull() Assert.Null(model.ResolvedAt); } + + [Fact] + public void Ctor_MapsApproverIdentity() + { + // The requester's own request list names who decided the request; the resolved approver identity must flow + // through from the denormalized details rather than being dropped (which would leave the client showing a id). + var approverId = Guid.NewGuid(); + var unspecified = new DateTime(2026, 6, 15, 13, 0, 0, DateTimeKind.Unspecified); + var details = new AccessRequestDetails + { + Status = AccessRequestStatus.Denied, + NotBefore = unspecified, + NotAfter = unspecified.AddHours(1), + CreationDate = unspecified, + ApproverId = approverId, + ApproverName = "Ada Approver", + ApproverEmail = "ada@example.com", + }; + + var model = new AccessRequestDetailsResponseModel(details); + + Assert.Equal(approverId, model.ApproverId); + Assert.Equal("Ada Approver", model.ApproverName); + Assert.Equal("ada@example.com", model.ApproverEmail); + } } diff --git a/test/Infrastructure.IntegrationTest/Pam/Repositories/AccessRequestRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Pam/Repositories/AccessRequestRepositoryTests.cs index 328a8b5899b5..1a15e9f0de29 100644 --- a/test/Infrastructure.IntegrationTest/Pam/Repositories/AccessRequestRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/Pam/Repositories/AccessRequestRepositoryTests.cs @@ -186,6 +186,10 @@ public async Task ResolveWithDecisionAsync_Approve_ResolvesRequestAndRecordsDeci var row = Assert.Single(history); Assert.Equal(approverId, row.ApproverId); Assert.Equal("approved for audit", row.ApproverComment); + // The approver id here belongs to no User row, so the identity join yields null name/email and the client + // falls back to the id. Identity resolution against a real User is covered by the My Requests read test. + Assert.Null(row.ApproverName); + Assert.Null(row.ApproverEmail); // Approval records the verdict only: no lease exists until the requester activates the approved request, // so the requester does not yet hold access and the inbox row carries no produced lease. @@ -345,6 +349,46 @@ await accessRequestRepository.CreateAsync(BuildRequest( Assert.All(mine, r => Assert.Null(r.CollectionName)); } + [DatabaseTheory, DatabaseData] + public async Task GetManyByRequesterIdAsync_ResolvesHumanApproverIdentity( + IUserRepository userRepository, + IOrganizationRepository organizationRepository, + ICollectionRepository collectionRepository, + IAccessRequestRepository accessRequestRepository) + { + // The requester's own list names who decided the request. The collection/cipher/requester joins stay + // omitted (those names come from the caller's vault), but the approver identity must resolve from the + // human decision's User so the client shows a name instead of a raw id. + var approver = await userRepository.CreateTestUserAsync("approver"); + var organization = await organizationRepository.CreateTestOrganizationAsync(); + var collection = await collectionRepository.CreateTestCollectionAsync(organization); + var now = DateTime.UtcNow; + var requesterId = Guid.NewGuid(); + + var request = await accessRequestRepository.CreateAsync(BuildRequest( + organization.Id, collection.Id, requesterId, AccessRequestStatus.Pending, now)); + + var decision = new AccessDecision + { + Id = CoreHelpers.GenerateComb(), + AccessRequestId = request.Id, + DeciderKind = AccessDeciderKind.Human, + ApproverId = approver.Id, + Verdict = AccessDecisionVerdict.Deny, + Comment = "not now", + CreationDate = now, + }; + await accessRequestRepository.ResolveWithDecisionAsync(request, decision, AccessRequestStatus.Denied, now); + + var mine = await accessRequestRepository.GetManyByRequesterIdAsync(requesterId); + + var row = Assert.Single(mine); + Assert.Equal(approver.Id, row.ApproverId); + Assert.Equal(approver.Name, row.ApproverName); + Assert.Equal(approver.Email, row.ApproverEmail); + Assert.Equal("not now", row.ApproverComment); + } + [DatabaseTheory, DatabaseData] public async Task CancelAsync_PendingRequest_TransitionsToCancelledAndStampsResolvedDate( IOrganizationRepository organizationRepository, diff --git a/util/Migrator/DbScripts/2026-06-16_00_AddApproverIdentityToAccessRequestReads.sql b/util/Migrator/DbScripts/2026-06-16_00_AddApproverIdentityToAccessRequestReads.sql new file mode 100644 index 000000000000..8141867eca9a --- /dev/null +++ b/util/Migrator/DbScripts/2026-06-16_00_AddApproverIdentityToAccessRequestReads.sql @@ -0,0 +1,158 @@ +-- PAM access-request reads: resolve the human approver's identity (name + email) alongside the existing ApproverId. +-- The reads already surfaced the resolving approver's id, but only as a raw GUID; the caller's own request list +-- ("My Requests") and the approver-inbox audit log had no way to name who decided a request. Each read resolves the +-- approver inside the existing human-decision OUTER APPLY (LEFT JOIN to [User] on the decision's ApproverId), so the +-- new ApproverName/ApproverEmail follow the same earliest-human-decision row as ApproverId/ApproverComment. + +CREATE OR ALTER PROCEDURE [dbo].[AccessRequest_ReadManyByRequesterId] + @RequesterId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + -- The caller's own requests across every org, all statuses. Unlike the approver-inbox reads this is a + -- caller-scoped self-read, so the cipher/collection/requester display-name joins are intentionally omitted + -- (those name fields stay null) -- cipher/collection names come from the caller's local vault, and the + -- requester is the caller. The approver, however, is resolved here (ApproverName/ApproverEmail) because the + -- requester has no other way to name who decided their request. Capped at the 250 most recent; the client + -- renders far fewer. + SELECT TOP (250) + LR.[Id], + LR.[ExtensionOfLeaseId], + LR.[OrganizationId], + LR.[CollectionId], + LR.[CipherId], + LR.[RequesterId], + LR.[NotBefore], + LR.[NotAfter], + LR.[Reason], + LR.[Status], + LR.[CreationDate], + LR.[ResolvedDate], + PL.[Id] AS [ProducedLeaseId], + RES.[ApproverId] AS [ApproverId], + RES.[ApproverName] AS [ApproverName], + RES.[ApproverEmail] AS [ApproverEmail], + RES.[Comment] AS [ApproverComment] + FROM [dbo].[AccessRequest] LR + OUTER APPLY ( + SELECT TOP 1 L.[Id] + FROM [dbo].[AccessLease] L + WHERE L.[AccessRequestId] = LR.[Id] + ORDER BY L.[CreationDate] DESC + ) PL + OUTER APPLY ( + SELECT TOP 1 LD.[ApproverId], LD.[Comment], AU.[Name] AS [ApproverName], AU.[Email] AS [ApproverEmail] + FROM [dbo].[AccessDecision] LD + LEFT JOIN [dbo].[User] AU ON AU.[Id] = LD.[ApproverId] + WHERE LD.[AccessRequestId] = LR.[Id] AND LD.[DeciderKind] = 1 -- Human + ORDER BY LD.[CreationDate] ASC + ) RES + WHERE LR.[RequesterId] = @RequesterId + ORDER BY LR.[CreationDate] DESC +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[AccessRequest_ReadInboxPendingByCollectionIds] + @CollectionIds [dbo].[GuidIdArray] READONLY +AS +BEGIN + SET NOCOUNT ON + + SELECT + LR.[Id], + LR.[ExtensionOfLeaseId], + LR.[OrganizationId], + LR.[CollectionId], + LR.[CipherId], + LR.[RequesterId], + LR.[NotBefore], + LR.[NotAfter], + LR.[Reason], + LR.[Status], + LR.[CreationDate], + LR.[ResolvedDate], + PL.[Id] AS [ProducedLeaseId], + PL.[Status] AS [ProducedLeaseStatus], + RES.[ApproverId] AS [ApproverId], + RES.[ApproverName] AS [ApproverName], + RES.[ApproverEmail] AS [ApproverEmail], + RES.[Comment] AS [ApproverComment], + JSON_VALUE(C.[Data], '$.Name') AS [CipherName], + COL.[Name] AS [CollectionName], + U.[Name] AS [RequesterName], + U.[Email] AS [RequesterEmail] + FROM [dbo].[AccessRequest] LR + INNER JOIN @CollectionIds CI ON CI.[Id] = LR.[CollectionId] + LEFT JOIN [dbo].[Cipher] C ON C.[Id] = LR.[CipherId] + LEFT JOIN [dbo].[Collection] COL ON COL.[Id] = LR.[CollectionId] + LEFT JOIN [dbo].[User] U ON U.[Id] = LR.[RequesterId] + OUTER APPLY ( + SELECT TOP 1 L.[Id], L.[Status] + FROM [dbo].[AccessLease] L + WHERE L.[AccessRequestId] = LR.[Id] + ORDER BY L.[CreationDate] DESC + ) PL + OUTER APPLY ( + SELECT TOP 1 LD.[ApproverId], LD.[Comment], AU.[Name] AS [ApproverName], AU.[Email] AS [ApproverEmail] + FROM [dbo].[AccessDecision] LD + LEFT JOIN [dbo].[User] AU ON AU.[Id] = LD.[ApproverId] + WHERE LD.[AccessRequestId] = LR.[Id] AND LD.[DeciderKind] = 1 -- Human + ORDER BY LD.[CreationDate] ASC + ) RES + WHERE LR.[Status] = 0 -- Pending +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[AccessRequest_ReadInboxHistoryByCollectionIds] + @CollectionIds [dbo].[GuidIdArray] READONLY, + @Since DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + SELECT + LR.[Id], + LR.[ExtensionOfLeaseId], + LR.[OrganizationId], + LR.[CollectionId], + LR.[CipherId], + LR.[RequesterId], + LR.[NotBefore], + LR.[NotAfter], + LR.[Reason], + LR.[Status], + LR.[CreationDate], + LR.[ResolvedDate], + PL.[Id] AS [ProducedLeaseId], + PL.[Status] AS [ProducedLeaseStatus], + RES.[ApproverId] AS [ApproverId], + RES.[ApproverName] AS [ApproverName], + RES.[ApproverEmail] AS [ApproverEmail], + RES.[Comment] AS [ApproverComment], + JSON_VALUE(C.[Data], '$.Name') AS [CipherName], + COL.[Name] AS [CollectionName], + U.[Name] AS [RequesterName], + U.[Email] AS [RequesterEmail] + FROM [dbo].[AccessRequest] LR + INNER JOIN @CollectionIds CI ON CI.[Id] = LR.[CollectionId] + LEFT JOIN [dbo].[Cipher] C ON C.[Id] = LR.[CipherId] + LEFT JOIN [dbo].[Collection] COL ON COL.[Id] = LR.[CollectionId] + LEFT JOIN [dbo].[User] U ON U.[Id] = LR.[RequesterId] + OUTER APPLY ( + SELECT TOP 1 L.[Id], L.[Status] + FROM [dbo].[AccessLease] L + WHERE L.[AccessRequestId] = LR.[Id] + ORDER BY L.[CreationDate] DESC + ) PL + OUTER APPLY ( + SELECT TOP 1 LD.[ApproverId], LD.[Comment], AU.[Name] AS [ApproverName], AU.[Email] AS [ApproverEmail] + FROM [dbo].[AccessDecision] LD + LEFT JOIN [dbo].[User] AU ON AU.[Id] = LD.[ApproverId] + WHERE LD.[AccessRequestId] = LR.[Id] AND LD.[DeciderKind] = 1 -- Human + ORDER BY LD.[CreationDate] ASC + ) RES + WHERE LR.[Status] <> 0 -- not Pending + AND LR.[CreationDate] >= @Since +END +GO From d6816766892c35239c420f77c57326014e378573 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anders=20=C3=85berg?= Date: Tue, 16 Jun 2026 21:35:53 +0200 Subject: [PATCH 43/54] PAM: expose the access-request decision log as a decisions[] array AccessDecision is 1-to-many with AccessRequest, so the details contract surfaces a decisions[] log (one element per decision, human or automatic) instead of flat approver fields. Resolved reads return the decisions as a second result set the repository groups onto AccessRequestDetails.Decisions; each element is {deciderKind,id,name,email,comment,verdict,decidedAt}. --- .../AccessRequestDecisionResponseModel.cs | 33 ++++ .../AccessRequestDetailsResponseModel.cs | 34 ++-- src/Core/Pam/Models/AccessDeciderKindNames.cs | 20 +++ src/Core/Pam/Models/AccessRequestDecision.cs | 33 ++++ src/Core/Pam/Models/AccessRequestDetails.cs | 18 +-- .../Commands/DecideAccessRequestCommand.cs | 19 ++- .../Repositories/AccessRequestRepository.cs | 55 ++++++- ...equest_ReadInboxHistoryByCollectionIds.sql | 39 +++-- ...equest_ReadInboxPendingByCollectionIds.sql | 16 +- .../AccessRequest_ReadManyByRequesterId.sql | 42 ++--- .../AccessRequestDetailsResponseModelTests.cs | 89 ++++++++-- .../DecideAccessRequestCommandTests.cs | 8 +- .../Queries/GetCipherAccessStateQueryTests.cs | 4 +- .../AccessRequestRepositoryTests.cs | 100 ++++++++++-- ...16_01_AddDecisionsToAccessRequestReads.sql | 153 ++++++++++++++++++ 15 files changed, 559 insertions(+), 104 deletions(-) create mode 100644 src/Api/Pam/Models/Response/AccessRequestDecisionResponseModel.cs create mode 100644 src/Core/Pam/Models/AccessDeciderKindNames.cs create mode 100644 src/Core/Pam/Models/AccessRequestDecision.cs create mode 100644 util/Migrator/DbScripts/2026-06-16_01_AddDecisionsToAccessRequestReads.sql diff --git a/src/Api/Pam/Models/Response/AccessRequestDecisionResponseModel.cs b/src/Api/Pam/Models/Response/AccessRequestDecisionResponseModel.cs new file mode 100644 index 000000000000..b9366669200b --- /dev/null +++ b/src/Api/Pam/Models/Response/AccessRequestDecisionResponseModel.cs @@ -0,0 +1,33 @@ +using Bit.Core.Pam.Enums; +using Bit.Core.Pam.Models; + +namespace Bit.Api.Pam.Models.Response; + +/// +/// One decision on an access request: who decided (), their identity for a human decision, +/// the verdict, an optional comment, and when. An element of +/// — the request's full decision log, oldest first. +/// +/// For an automatic (access-rule) decision // are null; for a +/// human decision they carry the approver (name/email denormalized by the server, null only when the user could not be +/// resolved). +/// +public class AccessRequestDecisionResponseModel +{ + /// human | automatic. + public string DeciderKind { get; init; } = AccessDeciderKindNames.Human; + + /// The human approver, or null for an automatic decision. + public Guid? Id { get; init; } + + public string? Name { get; init; } + + public string? Email { get; init; } + + public string? Comment { get; init; } + + /// The verdict reached (0 = approve, 1 = deny). + public AccessDecisionVerdict Verdict { get; init; } + + public DateTime DecidedAt { get; init; } +} diff --git a/src/Api/Pam/Models/Response/AccessRequestDetailsResponseModel.cs b/src/Api/Pam/Models/Response/AccessRequestDetailsResponseModel.cs index 323b65401b02..25c300d54ee9 100644 --- a/src/Api/Pam/Models/Response/AccessRequestDetailsResponseModel.cs +++ b/src/Api/Pam/Models/Response/AccessRequestDetailsResponseModel.cs @@ -27,10 +27,20 @@ public AccessRequestDetailsResponseModel(AccessRequestDetails details) Reason = details.Reason; SubmittedAt = details.CreationDate.AsUtc(); ResolvedAt = details.ResolvedDate.AsUtc(); - ApproverId = details.ApproverId; - ApproverName = details.ApproverName; - ApproverEmail = details.ApproverEmail; - ApproverComment = details.ApproverComment; + // The request's full decision log, oldest first: one element per recorded decision (human or automatic). + // Empty only while pending (no decision recorded yet). + Decisions = details.Decisions + .Select(d => new AccessRequestDecisionResponseModel + { + DeciderKind = AccessDeciderKindNames.From(d.DeciderKind), + Id = d.Id, + Name = d.Name, + Email = d.Email, + Comment = d.Comment, + Verdict = d.Verdict, + DecidedAt = d.DecidedAt.AsUtc(), + }) + .ToList(); ProducedLeaseId = details.ProducedLeaseId; ProducedLeaseStatus = details.ProducedLeaseStatus.HasValue ? AccessLeaseStatusNames.From(details.ProducedLeaseStatus.Value) @@ -65,16 +75,12 @@ public AccessRequestDetailsResponseModel(AccessRequestDetails details) /// Distinct from ; set when an approved request lapses unactivated. Not tracked in v1. public DateTime? ExpiredAt => null; - /// The human approver who decided the request, or null (e.g. still pending or decided automatically). - public Guid? ApproverId { get; } - - /// The human approver's display name, or null. Lets the client name the resolver instead of an id. - public string? ApproverName { get; } - - /// The human approver's email, the fallback display when is unset. - public string? ApproverEmail { get; } - - public string? ApproverComment { get; } + /// + /// The request's decision log, oldest first — one element per decision (human or automatic). Each carries who + /// decided (deciderKind), the verdict, and (for a human decision) the approver's identity and comment. + /// Empty only while pending. An array so multi-party approval lands without breaking the contract. + /// + public IEnumerable Decisions { get; } /// Set once an approved request has produced a lease. public Guid? ProducedLeaseId { get; } diff --git a/src/Core/Pam/Models/AccessDeciderKindNames.cs b/src/Core/Pam/Models/AccessDeciderKindNames.cs new file mode 100644 index 000000000000..925a39921d7c --- /dev/null +++ b/src/Core/Pam/Models/AccessDeciderKindNames.cs @@ -0,0 +1,20 @@ +using Bit.Core.Pam.Enums; + +namespace Bit.Core.Pam.Models; + +/// +/// Maps the backend to the vocabulary the client expects on a decision: +/// human | automatic. Mirrors / . +/// +public static class AccessDeciderKindNames +{ + public const string Human = "human"; + public const string Automatic = "automatic"; + + public static string From(AccessDeciderKind kind) => kind switch + { + AccessDeciderKind.Human => Human, + AccessDeciderKind.Automatic => Automatic, + _ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null), + }; +} diff --git a/src/Core/Pam/Models/AccessRequestDecision.cs b/src/Core/Pam/Models/AccessRequestDecision.cs new file mode 100644 index 000000000000..8ab9bdc59793 --- /dev/null +++ b/src/Core/Pam/Models/AccessRequestDecision.cs @@ -0,0 +1,33 @@ +using Bit.Core.Pam.Enums; + +namespace Bit.Core.Pam.Models; + +/// +/// One decision on an , projected from an +/// row. The element of — there is one per recorded decision, human or +/// automatic. A human decision carries the approver's identity ( plus the denormalized name/email); an +/// automatic decision has none ( null — it was decided by an access-rule condition). +/// +public class AccessRequestDecision +{ + /// Who decided: a human approver or an automatic condition evaluation. + public AccessDeciderKind DeciderKind { get; set; } + + /// The human approver, or null for an automatic decision. + public Guid? Id { get; set; } + + /// The human approver's display name, or null (automatic, or the server could not resolve the user). + public string? Name { get; set; } + + /// The human approver's email, the fallback display when is unset. + public string? Email { get; set; } + + /// The decision's comment (a human approver's note, or a future automatic-evaluation reason), if any. + public string? Comment { get; set; } + + /// The verdict reached. + public AccessDecisionVerdict Verdict { get; set; } + + /// When the decision was made (the decision's CreationDate). + public DateTime DecidedAt { get; set; } +} diff --git a/src/Core/Pam/Models/AccessRequestDetails.cs b/src/Core/Pam/Models/AccessRequestDetails.cs index de2e6b2c6fdb..c3439bf15503 100644 --- a/src/Core/Pam/Models/AccessRequestDetails.cs +++ b/src/Core/Pam/Models/AccessRequestDetails.cs @@ -36,20 +36,14 @@ public class AccessRequestDetails /// public AccessLeaseStatus? ProducedLeaseStatus { get; set; } - /// The human approver who resolved the request, or null (e.g. still pending or auto-resolved). - public Guid? ApproverId { get; set; } - /// - /// The human approver's display name, denormalized from the User join so the requester's own - /// request list can name the resolver instead of showing a raw id. Null when no human resolved. + /// Every decision recorded against this request, oldest first — one element per + /// row (human or automatic; identity denormalized from the User join for + /// human decisions). Empty only while pending (no decision recorded yet). The resolved reads return the decisions + /// as a second result set that the repository groups onto this list; the constructed reads (decision result, + /// cipher access-state snapshot) set it directly. /// - public string? ApproverName { get; set; } - - /// The human approver's email, the fallback display when is unset. - public string? ApproverEmail { get; set; } - - /// The human approver's comment, if any. - public string? ApproverComment { get; set; } + public List Decisions { get; set; } = new(); /// The cipher's client-encrypted name. The only cipher attribute the inbox exposes. public string? CipherName { get; set; } diff --git a/src/Core/Pam/OrganizationFeatures/Commands/DecideAccessRequestCommand.cs b/src/Core/Pam/OrganizationFeatures/Commands/DecideAccessRequestCommand.cs index c20b17935f88..b3d3870f9f99 100644 --- a/src/Core/Pam/OrganizationFeatures/Commands/DecideAccessRequestCommand.cs +++ b/src/Core/Pam/OrganizationFeatures/Commands/DecideAccessRequestCommand.cs @@ -86,9 +86,9 @@ public async Task DecideAsync(Guid userId, Guid requestId, // an approval becomes activatable without a manual refresh. await _requesterNotifier.NotifyRequesterAsync(request.RequesterId); - // The client repaints the row from Status, ResolvedAt, and ApproverComment, so those must be accurate; the - // denormalized display fields already live on the client's existing row. Project from what we just wrote - // rather than re-reading. + // The client repaints the row from Status, ResolvedAt, and the single Decisions element (verdict + comment), + // so those must be accurate; the approver's denormalized name/email is resolved on the next read. Project from + // what we just wrote rather than re-reading. return new AccessRequestDetails { Id = request.Id, @@ -103,8 +103,17 @@ public async Task DecideAsync(Guid userId, Guid requestId, Status = status, CreationDate = request.CreationDate, ResolvedDate = now, - ApproverId = decision.ApproverId, - ApproverComment = decision.Comment, + Decisions = + [ + new AccessRequestDecision + { + DeciderKind = AccessDeciderKind.Human, + Id = userId, + Comment = decision.Comment, + Verdict = decision.Verdict, + DecidedAt = now, + }, + ], }; } } diff --git a/src/Infrastructure.Dapper/Pam/Repositories/AccessRequestRepository.cs b/src/Infrastructure.Dapper/Pam/Repositories/AccessRequestRepository.cs index cae7cd7b1d96..15ee6b9bea8e 100644 --- a/src/Infrastructure.Dapper/Pam/Repositories/AccessRequestRepository.cs +++ b/src/Infrastructure.Dapper/Pam/Repositories/AccessRequestRepository.cs @@ -69,12 +69,12 @@ await connection.ExecuteAsync( public async Task> GetManyByRequesterIdAsync(Guid requesterId) { await using var connection = new SqlConnection(ConnectionString); - var results = await connection.QueryAsync( + using var results = await connection.QueryMultipleAsync( $"[{Schema}].[AccessRequest_ReadManyByRequesterId]", new { RequesterId = requesterId }, commandType: CommandType.StoredProcedure); - return results.ToList(); + return await ReadDetailsWithDecisionsAsync(results); } public async Task> GetManyInboxPendingByCollectionIdsAsync(IEnumerable collectionIds) @@ -103,12 +103,12 @@ public async Task> GetManyInboxHistoryByCollec } await using var connection = new SqlConnection(ConnectionString); - var results = await connection.QueryAsync( + using var results = await connection.QueryMultipleAsync( $"[{Schema}].[AccessRequest_ReadInboxHistoryByCollectionIds]", new { CollectionIds = ids.ToGuidIdArrayTVP(), Since = since }, commandType: CommandType.StoredProcedure); - return results.ToList(); + return await ReadDetailsWithDecisionsAsync(results); } public async Task ResolveWithDecisionAsync(AccessRequest request, AccessDecision decision, AccessRequestStatus status, DateTime now) @@ -188,4 +188,51 @@ public async Task CreateApprovedExtensionAsync(AccessR return (AccessLeaseExtendOutcome)result; } + + /// + /// Reads a two-result-set access-request projection: result 1 is the request rows, result 2 is every decision row + /// (human or automatic) keyed by AccessRequestId (ordered oldest-first by the procedure). Groups the decisions onto + /// each request's ; a pending request keeps its empty list. + /// + private static async Task> ReadDetailsWithDecisionsAsync(SqlMapper.GridReader reader) + { + var details = (await reader.ReadAsync()).ToList(); + var decisionsByRequest = (await reader.ReadAsync()) + .GroupBy(row => row.AccessRequestId) + .ToDictionary(group => group.Key, group => group.Select(row => row.ToDecision()).ToList()); + + foreach (var detail in details) + { + if (decisionsByRequest.TryGetValue(detail.Id, out var decisions)) + { + detail.Decisions = decisions; + } + } + + return details; + } + + /// A decision row from the decision result set, carrying its AccessRequestId for grouping. + private sealed class DecisionRow + { + public Guid AccessRequestId { get; set; } + public AccessDeciderKind DeciderKind { get; set; } + public Guid? Id { get; set; } + public string? Name { get; set; } + public string? Email { get; set; } + public string? Comment { get; set; } + public AccessDecisionVerdict Verdict { get; set; } + public DateTime DecidedAt { get; set; } + + public AccessRequestDecision ToDecision() => new() + { + DeciderKind = DeciderKind, + Id = Id, + Name = Name, + Email = Email, + Comment = Comment, + Verdict = Verdict, + DecidedAt = DecidedAt, + }; + } } diff --git a/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_ReadInboxHistoryByCollectionIds.sql b/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_ReadInboxHistoryByCollectionIds.sql index cbb580167417..cd3759d795ee 100644 --- a/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_ReadInboxHistoryByCollectionIds.sql +++ b/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_ReadInboxHistoryByCollectionIds.sql @@ -5,10 +5,13 @@ AS BEGIN SET NOCOUNT ON - -- The approver history: resolved requests (anything no longer Pending) created on or after @Since, for the - -- supplied (caller-manageable) collections. Same projection as the pending inbox. History rows that produced a - -- lease carry ProducedLeaseId so the client can target the Revoke action at the lease, plus ProducedLeaseStatus - -- so the client can tell a still-live lease from one that has ended (and not offer Revoke on an ended lease). + -- The approver history, returned as two result sets so the caller can attach each request's full decision list + -- without an N+1: + -- 1) the resolved requests (anything no longer Pending) created on or after @Since, for the supplied + -- (caller-manageable) collections, with denormalized display fields. Rows that produced a lease carry + -- ProducedLeaseId/ProducedLeaseStatus so the client can target (and gate) the Revoke action. + -- 2) every decision (human or automatic) for those requests, keyed by AccessRequestId and ordered oldest-first; + -- DeciderKind says which, and a human decision's identity is denormalized from [User]. SELECT LR.[Id], LR.[ExtensionOfLeaseId], @@ -24,10 +27,6 @@ BEGIN LR.[ResolvedDate], PL.[Id] AS [ProducedLeaseId], PL.[Status] AS [ProducedLeaseStatus], - RES.[ApproverId] AS [ApproverId], - RES.[ApproverName] AS [ApproverName], - RES.[ApproverEmail] AS [ApproverEmail], - RES.[Comment] AS [ApproverComment], JSON_VALUE(C.[Data], '$.Name') AS [CipherName], COL.[Name] AS [CollectionName], U.[Name] AS [RequesterName], @@ -43,13 +42,23 @@ BEGIN WHERE L.[AccessRequestId] = LR.[Id] ORDER BY L.[CreationDate] DESC ) PL - OUTER APPLY ( - SELECT TOP 1 LD.[ApproverId], LD.[Comment], AU.[Name] AS [ApproverName], AU.[Email] AS [ApproverEmail] - FROM [dbo].[AccessDecision] LD - LEFT JOIN [dbo].[User] AU ON AU.[Id] = LD.[ApproverId] - WHERE LD.[AccessRequestId] = LR.[Id] AND LD.[DeciderKind] = 1 -- Human - ORDER BY LD.[CreationDate] ASC - ) RES WHERE LR.[Status] <> 0 -- not Pending AND LR.[CreationDate] >= @Since + + SELECT + AD.[AccessRequestId], + AD.[DeciderKind] AS [DeciderKind], + AD.[ApproverId] AS [Id], + AU.[Name] AS [Name], + AU.[Email] AS [Email], + AD.[Comment] AS [Comment], + AD.[Verdict] AS [Verdict], + AD.[CreationDate] AS [DecidedAt] + FROM [dbo].[AccessDecision] AD + INNER JOIN [dbo].[AccessRequest] LR ON LR.[Id] = AD.[AccessRequestId] + INNER JOIN @CollectionIds CI ON CI.[Id] = LR.[CollectionId] + LEFT JOIN [dbo].[User] AU ON AU.[Id] = AD.[ApproverId] + WHERE LR.[Status] <> 0 -- not Pending + AND LR.[CreationDate] >= @Since + ORDER BY AD.[AccessRequestId], AD.[CreationDate] ASC END diff --git a/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_ReadInboxPendingByCollectionIds.sql b/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_ReadInboxPendingByCollectionIds.sql index 0401bb5cd083..3b10c00c9d65 100644 --- a/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_ReadInboxPendingByCollectionIds.sql +++ b/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_ReadInboxPendingByCollectionIds.sql @@ -6,9 +6,8 @@ BEGIN -- The approver inbox: pending requests for the supplied (caller-manageable) collections, joined with the -- denormalized display fields the client needs (cipher/collection names, requester identity) so it avoids an N+1. - -- ApproverId/ApproverComment come from the EARLIEST human decision so a later revocation decision (also human, - -- recorded against the same request) never overwrites the original approve/deny resolver. ProducedLeaseId is the - -- lease that the request birthed, if any. ExtensionOfLeaseId is the parent lease for extension requests. + -- A pending request has not been decided by anyone yet, so it carries no approvers (the caller leaves the + -- request's approvers list empty); only the resolved reads return a second decision result set. SELECT LR.[Id], LR.[ExtensionOfLeaseId], @@ -24,10 +23,6 @@ BEGIN LR.[ResolvedDate], PL.[Id] AS [ProducedLeaseId], PL.[Status] AS [ProducedLeaseStatus], - RES.[ApproverId] AS [ApproverId], - RES.[ApproverName] AS [ApproverName], - RES.[ApproverEmail] AS [ApproverEmail], - RES.[Comment] AS [ApproverComment], JSON_VALUE(C.[Data], '$.Name') AS [CipherName], COL.[Name] AS [CollectionName], U.[Name] AS [RequesterName], @@ -43,12 +38,5 @@ BEGIN WHERE L.[AccessRequestId] = LR.[Id] ORDER BY L.[CreationDate] DESC ) PL - OUTER APPLY ( - SELECT TOP 1 LD.[ApproverId], LD.[Comment], AU.[Name] AS [ApproverName], AU.[Email] AS [ApproverEmail] - FROM [dbo].[AccessDecision] LD - LEFT JOIN [dbo].[User] AU ON AU.[Id] = LD.[ApproverId] - WHERE LD.[AccessRequestId] = LR.[Id] AND LD.[DeciderKind] = 1 -- Human - ORDER BY LD.[CreationDate] ASC - ) RES WHERE LR.[Status] = 0 -- Pending END diff --git a/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_ReadManyByRequesterId.sql b/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_ReadManyByRequesterId.sql index 72db6b20cfe9..6a62b70b201c 100644 --- a/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_ReadManyByRequesterId.sql +++ b/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_ReadManyByRequesterId.sql @@ -4,12 +4,14 @@ AS BEGIN SET NOCOUNT ON - -- The caller's own requests across every org, all statuses. Unlike the approver-inbox reads this is a - -- caller-scoped self-read, so the cipher/collection/requester display-name joins are intentionally omitted - -- (those name fields stay null) -- cipher/collection names come from the caller's local vault, and the - -- requester is the caller. The approver, however, is resolved here (ApproverName/ApproverEmail) because the - -- requester has no other way to name who decided their request. Capped at the 250 most recent; the client - -- renders far fewer. + -- The caller's own requests, returned as two result sets so the caller can attach each request's decision list + -- without an N+1: + -- 1) the caller's requests (TOP 250 most recent), all statuses. Unlike the approver-inbox reads this is a + -- caller-scoped self-read, so the cipher/collection/requester display-name joins are intentionally omitted + -- (those names come from the caller's local vault, and the requester is the caller). + -- 2) every decision (human or automatic) on the caller's requests, keyed by AccessRequestId and ordered + -- oldest-first; DeciderKind says which, and a human decision's identity is denormalized from [User] -- the + -- requester has no other way to name who decided their request. SELECT TOP (250) LR.[Id], LR.[ExtensionOfLeaseId], @@ -23,11 +25,7 @@ BEGIN LR.[Status], LR.[CreationDate], LR.[ResolvedDate], - PL.[Id] AS [ProducedLeaseId], - RES.[ApproverId] AS [ApproverId], - RES.[ApproverName] AS [ApproverName], - RES.[ApproverEmail] AS [ApproverEmail], - RES.[Comment] AS [ApproverComment] + PL.[Id] AS [ProducedLeaseId] FROM [dbo].[AccessRequest] LR OUTER APPLY ( SELECT TOP 1 L.[Id] @@ -35,13 +33,21 @@ BEGIN WHERE L.[AccessRequestId] = LR.[Id] ORDER BY L.[CreationDate] DESC ) PL - OUTER APPLY ( - SELECT TOP 1 LD.[ApproverId], LD.[Comment], AU.[Name] AS [ApproverName], AU.[Email] AS [ApproverEmail] - FROM [dbo].[AccessDecision] LD - LEFT JOIN [dbo].[User] AU ON AU.[Id] = LD.[ApproverId] - WHERE LD.[AccessRequestId] = LR.[Id] AND LD.[DeciderKind] = 1 -- Human - ORDER BY LD.[CreationDate] ASC - ) RES WHERE LR.[RequesterId] = @RequesterId ORDER BY LR.[CreationDate] DESC + + SELECT + AD.[AccessRequestId], + AD.[DeciderKind] AS [DeciderKind], + AD.[ApproverId] AS [Id], + AU.[Name] AS [Name], + AU.[Email] AS [Email], + AD.[Comment] AS [Comment], + AD.[Verdict] AS [Verdict], + AD.[CreationDate] AS [DecidedAt] + FROM [dbo].[AccessDecision] AD + INNER JOIN [dbo].[AccessRequest] LR ON LR.[Id] = AD.[AccessRequestId] + LEFT JOIN [dbo].[User] AU ON AU.[Id] = AD.[ApproverId] + WHERE LR.[RequesterId] = @RequesterId + ORDER BY AD.[AccessRequestId], AD.[CreationDate] ASC END diff --git a/test/Api.Test/Pam/Models/AccessRequestDetailsResponseModelTests.cs b/test/Api.Test/Pam/Models/AccessRequestDetailsResponseModelTests.cs index df767d1a45bd..9f15ddc11cf9 100644 --- a/test/Api.Test/Pam/Models/AccessRequestDetailsResponseModelTests.cs +++ b/test/Api.Test/Pam/Models/AccessRequestDetailsResponseModelTests.cs @@ -52,27 +52,98 @@ public void Ctor_LeavesNullResolvedDateNull() } [Fact] - public void Ctor_MapsApproverIdentity() + public void Ctor_MapsHumanApproverAsSingleApproversElement() { - // The requester's own request list names who decided the request; the resolved approver identity must flow - // through from the denormalized details rather than being dropped (which would leave the client showing a id). + // The requester's own request list names who decided the request; the resolved approver identity, verdict, and + // comment must flow through as a single Approvers element rather than being dropped (which would leave the + // client showing a raw id). The array shape future-proofs the contract for multi-party approval. var approverId = Guid.NewGuid(); var unspecified = new DateTime(2026, 6, 15, 13, 0, 0, DateTimeKind.Unspecified); + var decidedAt = unspecified.AddMinutes(20); var details = new AccessRequestDetails { Status = AccessRequestStatus.Denied, NotBefore = unspecified, NotAfter = unspecified.AddHours(1), CreationDate = unspecified, - ApproverId = approverId, - ApproverName = "Ada Approver", - ApproverEmail = "ada@example.com", + ResolvedDate = decidedAt, + Decisions = + [ + new AccessRequestDecision + { + DeciderKind = AccessDeciderKind.Human, + Id = approverId, + Name = "Ada Approver", + Email = "ada@example.com", + Comment = "Outside approved hours", + Verdict = AccessDecisionVerdict.Deny, + DecidedAt = decidedAt, + }, + ], }; var model = new AccessRequestDetailsResponseModel(details); - Assert.Equal(approverId, model.ApproverId); - Assert.Equal("Ada Approver", model.ApproverName); - Assert.Equal("ada@example.com", model.ApproverEmail); + var decision = Assert.Single(model.Decisions); + Assert.Equal(AccessDeciderKindNames.Human, decision.DeciderKind); + Assert.Equal(approverId, decision.Id!.Value); + Assert.Equal("Ada Approver", decision.Name); + Assert.Equal("ada@example.com", decision.Email); + Assert.Equal("Outside approved hours", decision.Comment); + Assert.Equal(AccessDecisionVerdict.Deny, decision.Verdict); + Assert.Equal(decidedAt.Ticks, decision.DecidedAt.Ticks); + Assert.Equal(DateTimeKind.Utc, decision.DecidedAt.Kind); + } + + [Fact] + public void Ctor_MapsAutomaticDecisionWithNoApproverIdentity() + { + // An automatic (access-rule) decision is surfaced like any other, but with deciderKind "automatic" and no + // approver identity — the client renders it as a rule-driven decision rather than a person. + var unspecified = new DateTime(2026, 6, 15, 13, 0, 0, DateTimeKind.Unspecified); + var details = new AccessRequestDetails + { + Status = AccessRequestStatus.Approved, + NotBefore = unspecified, + NotAfter = unspecified.AddHours(1), + CreationDate = unspecified, + ResolvedDate = unspecified.AddMinutes(1), + Decisions = + [ + new AccessRequestDecision + { + DeciderKind = AccessDeciderKind.Automatic, + Id = null, + Verdict = AccessDecisionVerdict.Approve, + DecidedAt = unspecified.AddMinutes(1), + }, + ], + }; + + var model = new AccessRequestDetailsResponseModel(details); + + var decision = Assert.Single(model.Decisions); + Assert.Equal(AccessDeciderKindNames.Automatic, decision.DeciderKind); + Assert.Null(decision.Id); + Assert.Null(decision.Name); + Assert.Equal(AccessDecisionVerdict.Approve, decision.Verdict); + } + + [Fact] + public void Ctor_LeavesDecisionsEmptyWhenPending() + { + // A pending request has no decision recorded yet, so the decision log is empty. + var unspecified = new DateTime(2026, 6, 15, 13, 0, 0, DateTimeKind.Unspecified); + var details = new AccessRequestDetails + { + Status = AccessRequestStatus.Pending, + NotBefore = unspecified, + NotAfter = unspecified.AddHours(1), + CreationDate = unspecified, + }; + + var model = new AccessRequestDetailsResponseModel(details); + + Assert.Empty(model.Decisions); } } diff --git a/test/Core.Test/Pam/Commands/DecideAccessRequestCommandTests.cs b/test/Core.Test/Pam/Commands/DecideAccessRequestCommandTests.cs index b21a66d0e674..037b76550d9a 100644 --- a/test/Core.Test/Pam/Commands/DecideAccessRequestCommandTests.cs +++ b/test/Core.Test/Pam/Commands/DecideAccessRequestCommandTests.cs @@ -124,8 +124,12 @@ public async Task DecideAsync_Approve_ResolvesAndWritesHumanDecision(Guid userId Assert.Equal(AccessRequestStatus.Approved, result.Status); Assert.Equal(_now, result.ResolvedDate); - Assert.Equal(userId, result.ApproverId); - Assert.Equal("looks good", result.ApproverComment); + var decision = Assert.Single(result.Decisions); + Assert.Equal(AccessDeciderKind.Human, decision.DeciderKind); + Assert.Equal(userId, decision.Id!.Value); + Assert.Equal(AccessDecisionVerdict.Approve, decision.Verdict); + Assert.Equal("looks good", decision.Comment); + Assert.Equal(_now, decision.DecidedAt); // Approval records the verdict only; no lease is minted until the requester activates the approved request. await sutProvider.GetDependency().Received(1).ResolveWithDecisionAsync( request, diff --git a/test/Core.Test/Pam/Queries/GetCipherAccessStateQueryTests.cs b/test/Core.Test/Pam/Queries/GetCipherAccessStateQueryTests.cs index 8d019bea246b..f034d81d8691 100644 --- a/test/Core.Test/Pam/Queries/GetCipherAccessStateQueryTests.cs +++ b/test/Core.Test/Pam/Queries/GetCipherAccessStateQueryTests.cs @@ -98,7 +98,7 @@ public async Task GetStateAsync_PendingRequest_MapsToDetails( Assert.Equal(AccessRequestStatus.Pending, result.PendingRequest.Status); // Pending has produced no lease and has no resolver yet; display-name fields are not populated. Assert.Null(result.PendingRequest.ProducedLeaseId); - Assert.Null(result.PendingRequest.ApproverId); + Assert.Empty(result.PendingRequest.Decisions); Assert.Null(result.PendingRequest.CipherName); } @@ -126,7 +126,7 @@ public async Task GetStateAsync_ApprovedRequest_MapsToDetails( // The approved read excludes activated rows, so no lease id; the caller-scoped snapshot carries no approver // identity or display-name fields. Assert.Null(result.ApprovedRequest.ProducedLeaseId); - Assert.Null(result.ApprovedRequest.ApproverId); + Assert.Empty(result.ApprovedRequest.Decisions); Assert.Null(result.ApprovedRequest.CipherName); } diff --git a/test/Infrastructure.IntegrationTest/Pam/Repositories/AccessRequestRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Pam/Repositories/AccessRequestRepositoryTests.cs index 1a15e9f0de29..58c5333bb76e 100644 --- a/test/Infrastructure.IntegrationTest/Pam/Repositories/AccessRequestRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/Pam/Repositories/AccessRequestRepositoryTests.cs @@ -180,16 +180,22 @@ public async Task ResolveWithDecisionAsync_Approve_ResolvesRequestAndRecordsDeci Assert.Equal(AccessRequestStatus.Approved, persisted!.Status); Assert.NotNull(persisted.ResolvedDate); - // The human decision surfaces as the resolver in the inbox projection. + // The human decision surfaces as a single element of the inbox projection's decision log. var history = await accessRequestRepository.GetManyInboxHistoryByCollectionIdsAsync( [collection.Id], now.AddDays(-1)); var row = Assert.Single(history); - Assert.Equal(approverId, row.ApproverId); - Assert.Equal("approved for audit", row.ApproverComment); + var recorded = Assert.Single(row.Decisions); + Assert.Equal(AccessDeciderKind.Human, recorded.DeciderKind); + Assert.Equal(approverId, recorded.Id!.Value); + Assert.Equal("approved for audit", recorded.Comment); + // Verdict and decision timestamp come straight from the AccessDecision row, so the contract exposes what each + // approver decided and when. + Assert.Equal(AccessDecisionVerdict.Approve, recorded.Verdict); + Assert.Equal(now, recorded.DecidedAt); // The approver id here belongs to no User row, so the identity join yields null name/email and the client // falls back to the id. Identity resolution against a real User is covered by the My Requests read test. - Assert.Null(row.ApproverName); - Assert.Null(row.ApproverEmail); + Assert.Null(recorded.Name); + Assert.Null(recorded.Email); // Approval records the verdict only: no lease exists until the requester activates the approved request, // so the requester does not yet hold access and the inbox row carries no produced lease. @@ -383,10 +389,86 @@ public async Task GetManyByRequesterIdAsync_ResolvesHumanApproverIdentity( var mine = await accessRequestRepository.GetManyByRequesterIdAsync(requesterId); var row = Assert.Single(mine); - Assert.Equal(approver.Id, row.ApproverId); - Assert.Equal(approver.Name, row.ApproverName); - Assert.Equal(approver.Email, row.ApproverEmail); - Assert.Equal("not now", row.ApproverComment); + var resolver = Assert.Single(row.Decisions); + Assert.Equal(AccessDeciderKind.Human, resolver.DeciderKind); + Assert.Equal(approver.Id, resolver.Id!.Value); + Assert.Equal(approver.Name, resolver.Name); + Assert.Equal(approver.Email, resolver.Email); + Assert.Equal("not now", resolver.Comment); + Assert.Equal(AccessDecisionVerdict.Deny, resolver.Verdict); + Assert.Equal(now, resolver.DecidedAt); + } + + [DatabaseTheory, DatabaseData] + public async Task GetManyInboxHistoryByCollectionIdsAsync_MultipleHumanDecisions_ProjectsFullHistoryOldestFirst( + IOrganizationRepository organizationRepository, + ICollectionRepository collectionRepository, + IAccessRequestRepository accessRequestRepository) + { + // AccessDecision is 1-to-many with AccessRequest, so the approvers array carries every human decision oldest + // first: an approval followed by a managing approver retracting the unactivated approval surfaces both, rather + // than collapsing to a single resolver. + var organization = await organizationRepository.CreateTestOrganizationAsync(); + var collection = await collectionRepository.CreateTestCollectionAsync(organization); + var now = DateTime.UtcNow; + var firstApproverId = Guid.NewGuid(); + var secondApproverId = Guid.NewGuid(); + + var request = await accessRequestRepository.CreateAsync(new AccessRequest + { + OrganizationId = organization.Id, + CollectionId = collection.Id, + CipherId = Guid.NewGuid(), + RequesterId = Guid.NewGuid(), + NotBefore = now.AddHours(-1), + NotAfter = now.AddHours(1), + Reason = "audit", + Status = AccessRequestStatus.Pending, + CreationDate = now, + }); + + // First decision: approve. + await accessRequestRepository.ResolveWithDecisionAsync( + request, + new AccessDecision + { + Id = CoreHelpers.GenerateComb(), + AccessRequestId = request.Id, + DeciderKind = AccessDeciderKind.Human, + ApproverId = firstApproverId, + Verdict = AccessDecisionVerdict.Approve, + Comment = "approved", + CreationDate = now, + }, + AccessRequestStatus.Approved, + now); + + // Second decision: a managing approver retracts the still-unactivated approval (records a Deny). + await accessRequestRepository.CancelWithDecisionAsync( + request, + new AccessDecision + { + Id = CoreHelpers.GenerateComb(), + AccessRequestId = request.Id, + DeciderKind = AccessDeciderKind.Human, + ApproverId = secondApproverId, + Verdict = AccessDecisionVerdict.Deny, + Comment = "retracted", + CreationDate = now.AddMinutes(1), + }, + now.AddMinutes(1)); + + var history = await accessRequestRepository.GetManyInboxHistoryByCollectionIdsAsync( + [collection.Id], now.AddDays(-1)); + var row = Assert.Single(history); + Assert.Equal(2, row.Decisions.Count); + Assert.Equal(AccessDeciderKind.Human, row.Decisions[0].DeciderKind); + Assert.Equal(firstApproverId, row.Decisions[0].Id!.Value); + Assert.Equal(AccessDecisionVerdict.Approve, row.Decisions[0].Verdict); + Assert.Equal("approved", row.Decisions[0].Comment); + Assert.Equal(secondApproverId, row.Decisions[1].Id!.Value); + Assert.Equal(AccessDecisionVerdict.Deny, row.Decisions[1].Verdict); + Assert.Equal("retracted", row.Decisions[1].Comment); } [DatabaseTheory, DatabaseData] diff --git a/util/Migrator/DbScripts/2026-06-16_01_AddDecisionsToAccessRequestReads.sql b/util/Migrator/DbScripts/2026-06-16_01_AddDecisionsToAccessRequestReads.sql new file mode 100644 index 000000000000..9e172f6705d9 --- /dev/null +++ b/util/Migrator/DbScripts/2026-06-16_01_AddDecisionsToAccessRequestReads.sql @@ -0,0 +1,153 @@ +-- PAM access-request reads: return each request's decisions as a second result set instead of collapsing to a single +-- denormalized resolver. AccessDecision is a 1-to-many with AccessRequest, so the resolved reads now return (1) the +-- request rows and (2) every decision (human or automatic) keyed by AccessRequestId, oldest first -- DeciderKind says +-- which, and a human decision's identity is denormalized from [User]; the caller groups them onto each request's +-- decision list. This drops the previous TOP-1 earliest-decision collapse and the flat +-- ApproverId/ApproverName/ApproverEmail/ApproverComment columns. Pending requests have no decision yet, so that read +-- stays a single result set. + +CREATE OR ALTER PROCEDURE [dbo].[AccessRequest_ReadManyByRequesterId] + @RequesterId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT TOP (250) + LR.[Id], + LR.[ExtensionOfLeaseId], + LR.[OrganizationId], + LR.[CollectionId], + LR.[CipherId], + LR.[RequesterId], + LR.[NotBefore], + LR.[NotAfter], + LR.[Reason], + LR.[Status], + LR.[CreationDate], + LR.[ResolvedDate], + PL.[Id] AS [ProducedLeaseId] + FROM [dbo].[AccessRequest] LR + OUTER APPLY ( + SELECT TOP 1 L.[Id] + FROM [dbo].[AccessLease] L + WHERE L.[AccessRequestId] = LR.[Id] + ORDER BY L.[CreationDate] DESC + ) PL + WHERE LR.[RequesterId] = @RequesterId + ORDER BY LR.[CreationDate] DESC + + SELECT + AD.[AccessRequestId], + AD.[DeciderKind] AS [DeciderKind], + AD.[ApproverId] AS [Id], + AU.[Name] AS [Name], + AU.[Email] AS [Email], + AD.[Comment] AS [Comment], + AD.[Verdict] AS [Verdict], + AD.[CreationDate] AS [DecidedAt] + FROM [dbo].[AccessDecision] AD + INNER JOIN [dbo].[AccessRequest] LR ON LR.[Id] = AD.[AccessRequestId] + LEFT JOIN [dbo].[User] AU ON AU.[Id] = AD.[ApproverId] + WHERE LR.[RequesterId] = @RequesterId + ORDER BY AD.[AccessRequestId], AD.[CreationDate] ASC +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[AccessRequest_ReadInboxPendingByCollectionIds] + @CollectionIds [dbo].[GuidIdArray] READONLY +AS +BEGIN + SET NOCOUNT ON + + SELECT + LR.[Id], + LR.[ExtensionOfLeaseId], + LR.[OrganizationId], + LR.[CollectionId], + LR.[CipherId], + LR.[RequesterId], + LR.[NotBefore], + LR.[NotAfter], + LR.[Reason], + LR.[Status], + LR.[CreationDate], + LR.[ResolvedDate], + PL.[Id] AS [ProducedLeaseId], + PL.[Status] AS [ProducedLeaseStatus], + JSON_VALUE(C.[Data], '$.Name') AS [CipherName], + COL.[Name] AS [CollectionName], + U.[Name] AS [RequesterName], + U.[Email] AS [RequesterEmail] + FROM [dbo].[AccessRequest] LR + INNER JOIN @CollectionIds CI ON CI.[Id] = LR.[CollectionId] + LEFT JOIN [dbo].[Cipher] C ON C.[Id] = LR.[CipherId] + LEFT JOIN [dbo].[Collection] COL ON COL.[Id] = LR.[CollectionId] + LEFT JOIN [dbo].[User] U ON U.[Id] = LR.[RequesterId] + OUTER APPLY ( + SELECT TOP 1 L.[Id], L.[Status] + FROM [dbo].[AccessLease] L + WHERE L.[AccessRequestId] = LR.[Id] + ORDER BY L.[CreationDate] DESC + ) PL + WHERE LR.[Status] = 0 -- Pending +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[AccessRequest_ReadInboxHistoryByCollectionIds] + @CollectionIds [dbo].[GuidIdArray] READONLY, + @Since DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + SELECT + LR.[Id], + LR.[ExtensionOfLeaseId], + LR.[OrganizationId], + LR.[CollectionId], + LR.[CipherId], + LR.[RequesterId], + LR.[NotBefore], + LR.[NotAfter], + LR.[Reason], + LR.[Status], + LR.[CreationDate], + LR.[ResolvedDate], + PL.[Id] AS [ProducedLeaseId], + PL.[Status] AS [ProducedLeaseStatus], + JSON_VALUE(C.[Data], '$.Name') AS [CipherName], + COL.[Name] AS [CollectionName], + U.[Name] AS [RequesterName], + U.[Email] AS [RequesterEmail] + FROM [dbo].[AccessRequest] LR + INNER JOIN @CollectionIds CI ON CI.[Id] = LR.[CollectionId] + LEFT JOIN [dbo].[Cipher] C ON C.[Id] = LR.[CipherId] + LEFT JOIN [dbo].[Collection] COL ON COL.[Id] = LR.[CollectionId] + LEFT JOIN [dbo].[User] U ON U.[Id] = LR.[RequesterId] + OUTER APPLY ( + SELECT TOP 1 L.[Id], L.[Status] + FROM [dbo].[AccessLease] L + WHERE L.[AccessRequestId] = LR.[Id] + ORDER BY L.[CreationDate] DESC + ) PL + WHERE LR.[Status] <> 0 -- not Pending + AND LR.[CreationDate] >= @Since + + SELECT + AD.[AccessRequestId], + AD.[DeciderKind] AS [DeciderKind], + AD.[ApproverId] AS [Id], + AU.[Name] AS [Name], + AU.[Email] AS [Email], + AD.[Comment] AS [Comment], + AD.[Verdict] AS [Verdict], + AD.[CreationDate] AS [DecidedAt] + FROM [dbo].[AccessDecision] AD + INNER JOIN [dbo].[AccessRequest] LR ON LR.[Id] = AD.[AccessRequestId] + INNER JOIN @CollectionIds CI ON CI.[Id] = LR.[CollectionId] + LEFT JOIN [dbo].[User] AU ON AU.[Id] = AD.[ApproverId] + WHERE LR.[Status] <> 0 -- not Pending + AND LR.[CreationDate] >= @Since + ORDER BY AD.[AccessRequestId], AD.[CreationDate] ASC +END +GO From b85f8cec358b7e61a8ac30647ebd420c6c6cd1a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anders=20=C3=85berg?= Date: Wed, 17 Jun 2026 18:27:02 +0200 Subject: [PATCH 44/54] PAM: flip AccessDecisionVerdict to deny=0, approve=1 --- .../Request/AccessDecisionRequestModel.cs | 2 +- .../AccessRequestDecisionResponseModel.cs | 2 +- src/Core/Pam/Enums/AccessDeciderKind.cs | 4 +- .../Stored Procedures/AccessLease_Revoke.sql | 2 +- .../AccessRequest_CreateApprovedExtension.sql | 2 +- .../AccessRequest_CreateAutoApproved.sql | 2 +- .../Models/AccessDecisionRequestModelTests.cs | 4 +- ...026-06-17_00_FlipAccessDecisionVerdict.sql | 181 ++++++++++++++++++ 8 files changed, 190 insertions(+), 9 deletions(-) create mode 100644 util/Migrator/DbScripts/2026-06-17_00_FlipAccessDecisionVerdict.sql diff --git a/src/Api/Pam/Models/Request/AccessDecisionRequestModel.cs b/src/Api/Pam/Models/Request/AccessDecisionRequestModel.cs index 2c88ada5f3e5..fc457dce0ee0 100644 --- a/src/Api/Pam/Models/Request/AccessDecisionRequestModel.cs +++ b/src/Api/Pam/Models/Request/AccessDecisionRequestModel.cs @@ -6,7 +6,7 @@ namespace Bit.Api.Pam.Models.Request; /// /// An approver's decision on a pending access request. is the -/// value on the wire (0 = approve, 1 = deny); +/// value on the wire (0 = deny, 1 = approve); /// is optional. /// public class AccessDecisionRequestModel diff --git a/src/Api/Pam/Models/Response/AccessRequestDecisionResponseModel.cs b/src/Api/Pam/Models/Response/AccessRequestDecisionResponseModel.cs index b9366669200b..60d5d5910e71 100644 --- a/src/Api/Pam/Models/Response/AccessRequestDecisionResponseModel.cs +++ b/src/Api/Pam/Models/Response/AccessRequestDecisionResponseModel.cs @@ -26,7 +26,7 @@ public class AccessRequestDecisionResponseModel public string? Comment { get; init; } - /// The verdict reached (0 = approve, 1 = deny). + /// The verdict reached (0 = deny, 1 = approve). public AccessDecisionVerdict Verdict { get; init; } public DateTime DecidedAt { get; init; } diff --git a/src/Core/Pam/Enums/AccessDeciderKind.cs b/src/Core/Pam/Enums/AccessDeciderKind.cs index 2b423c43b7bc..00ddfec06e22 100644 --- a/src/Core/Pam/Enums/AccessDeciderKind.cs +++ b/src/Core/Pam/Enums/AccessDeciderKind.cs @@ -14,6 +14,6 @@ public enum AccessDeciderKind : byte /// public enum AccessDecisionVerdict : byte { - Approve = 0, - Deny = 1, + Deny = 0, + Approve = 1, } diff --git a/src/Sql/dbo/Pam/Stored Procedures/AccessLease_Revoke.sql b/src/Sql/dbo/Pam/Stored Procedures/AccessLease_Revoke.sql index 503194a4452e..5295360d1b57 100644 --- a/src/Sql/dbo/Pam/Stored Procedures/AccessLease_Revoke.sql +++ b/src/Sql/dbo/Pam/Stored Procedures/AccessLease_Revoke.sql @@ -28,7 +28,7 @@ BEGIN VALUES ( @AccessDecisionId, @AccessRequestId, 1 /* Human */, @RevokedBy, NULL, - 1 /* Deny */, @Reason, NULL, @Now + 0 /* Deny */, @Reason, NULL, @Now ) COMMIT TRANSACTION AccessLease_Revoke diff --git a/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_CreateApprovedExtension.sql b/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_CreateApprovedExtension.sql index c1eb4f821e52..0a715e0446d1 100644 --- a/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_CreateApprovedExtension.sql +++ b/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_CreateApprovedExtension.sql @@ -68,7 +68,7 @@ BEGIN VALUES ( @AccessDecisionId, @AccessRequestId, 0 /* Automatic */, NULL, NULL, - 0 /* Approve */, NULL, NULL, @Now + 1 /* Approve */, NULL, NULL, @Now ) UPDATE [dbo].[AccessLease] diff --git a/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_CreateAutoApproved.sql b/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_CreateAutoApproved.sql index f6095f718a15..b9d9d3d9eb54 100644 --- a/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_CreateAutoApproved.sql +++ b/src/Sql/dbo/Pam/Stored Procedures/AccessRequest_CreateAutoApproved.sql @@ -40,7 +40,7 @@ BEGIN VALUES ( @AccessDecisionId, @AccessRequestId, 0 /* Automatic */, NULL, @ConditionKind, - 0 /* Approve */, NULL, NULL, @CreationDate + 1 /* Approve */, NULL, NULL, @CreationDate ) COMMIT TRANSACTION AccessRequest_CreateAutoApproved diff --git a/test/Api.Test/Pam/Models/AccessDecisionRequestModelTests.cs b/test/Api.Test/Pam/Models/AccessDecisionRequestModelTests.cs index b0fe20e1d055..aefc6b87814a 100644 --- a/test/Api.Test/Pam/Models/AccessDecisionRequestModelTests.cs +++ b/test/Api.Test/Pam/Models/AccessDecisionRequestModelTests.cs @@ -12,8 +12,8 @@ public class AccessDecisionRequestModelTests private static readonly JsonSerializerOptions _web = new(JsonSerializerDefaults.Web); [Theory] - [InlineData(0, AccessDecisionVerdict.Approve)] - [InlineData(1, AccessDecisionVerdict.Deny)] + [InlineData(0, AccessDecisionVerdict.Deny)] + [InlineData(1, AccessDecisionVerdict.Approve)] public void Deserialize_BindsIntegerVerdictToEnum(int wire, AccessDecisionVerdict expected) { var model = JsonSerializer.Deserialize($$"""{"verdict":{{wire}}}""", _web); diff --git a/util/Migrator/DbScripts/2026-06-17_00_FlipAccessDecisionVerdict.sql b/util/Migrator/DbScripts/2026-06-17_00_FlipAccessDecisionVerdict.sql new file mode 100644 index 000000000000..708aa898393e --- /dev/null +++ b/util/Migrator/DbScripts/2026-06-17_00_FlipAccessDecisionVerdict.sql @@ -0,0 +1,181 @@ +-- Flip the AccessDecisionVerdict encoding so the values read naturally falsy/truthy: Deny = 0, Approve = 1 +-- (previously Approve = 0, Deny = 1). The member names are unchanged; only the numeric wire/stored values swap. +-- +-- DATA: any existing AccessDecision rows were written under the old encoding, so this migration MUST flip their +-- stored Verdict in the same step the procedures change -- otherwise every historical verdict inverts. The CASE +-- swap below is a no-op on an empty table and self-inverse, but this migration is NOT safe to run twice. +UPDATE [dbo].[AccessDecision] +SET [Verdict] = CASE [Verdict] WHEN 0 THEN 1 ELSE 0 END +GO + +-- Auto-approved request: the automatic verdict literal moves from 0 to 1 (Approve). +CREATE OR ALTER PROCEDURE [dbo].[AccessRequest_CreateAutoApproved] + @AccessRequestId UNIQUEIDENTIFIER, + @AccessDecisionId UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @CollectionId UNIQUEIDENTIFIER, + @CipherId UNIQUEIDENTIFIER, + @RequesterId UNIQUEIDENTIFIER, + @NotBefore DATETIME2(7), + @NotAfter DATETIME2(7), + @Reason NVARCHAR(MAX) = NULL, + @ConditionKind TINYINT = NULL, + @CreationDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + -- Atomically record an auto-approved request and its automatic verdict. No lease is minted here: the requester + -- activates the approved request later via [AccessLease_CreateFromApprovedRequest], exactly like the human path + -- after approval. The per-cipher single-active-lease guard therefore lives entirely on that activation path. + BEGIN TRANSACTION AccessRequest_CreateAutoApproved + + -- The request is created already resolved (Approved). ExtensionOfLeaseId stays NULL: it is reserved for extension + -- requests; provenance for an original lease flows the other way, via AccessLease.AccessRequestId. + INSERT INTO [dbo].[AccessRequest] + ( + [Id], [ExtensionOfLeaseId], [OrganizationId], [CollectionId], [CipherId], [RequesterId], + [NotBefore], [NotAfter], [Reason], [Status], [CreationDate], [ResolvedDate] + ) + VALUES + ( + @AccessRequestId, NULL, @OrganizationId, @CollectionId, @CipherId, @RequesterId, + @NotBefore, @NotAfter, @Reason, 1 /* Approved */, @CreationDate, @CreationDate + ) + + INSERT INTO [dbo].[AccessDecision] + ( + [Id], [AccessRequestId], [DeciderKind], [ApproverId], [ConditionKind], + [Verdict], [Comment], [EvaluationContext], [CreationDate] + ) + VALUES + ( + @AccessDecisionId, @AccessRequestId, 0 /* Automatic */, NULL, @ConditionKind, + 1 /* Approve */, NULL, NULL, @CreationDate + ) + + COMMIT TRANSACTION AccessRequest_CreateAutoApproved +END +GO + +-- Auto-approved extension: the automatic verdict literal moves from 0 to 1 (Approve). +CREATE OR ALTER PROCEDURE [dbo].[AccessRequest_CreateApprovedExtension] + @AccessRequestId UNIQUEIDENTIFIER, + @AccessDecisionId UNIQUEIDENTIFIER, + @ExtensionOfLeaseId UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @CollectionId UNIQUEIDENTIFIER, + @CipherId UNIQUEIDENTIFIER, + @RequesterId UNIQUEIDENTIFIER, + @NotBefore DATETIME2(7), + @NotAfter DATETIME2(7), + @Reason NVARCHAR(MAX) = NULL, + @Now DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + -- An explicit transaction holds the per-lease range lock until the writes commit, so concurrent extensions of + -- the same lease serialize. XACT_ABORT guarantees rollback (and a clean pooled connection) on any error. + SET XACT_ABORT ON + + BEGIN TRANSACTION + + -- Lock the parent lease row for the life of the transaction. A second concurrent extension of the same lease + -- blocks here until this transaction commits, then re-counts below and sees this extension. The lease must + -- still be active and in-window to be extendable; outcome 0 is distinct from the cap conflict (-1). + IF NOT EXISTS ( + SELECT 1 + FROM [dbo].[AccessLease] WITH (UPDLOCK, HOLDLOCK) + WHERE [Id] = @ExtensionOfLeaseId + AND [RequesterId] = @RequesterId + AND [Status] = 0 /* Active */ + AND [NotAfter] > @Now + ) + BEGIN + ROLLBACK TRANSACTION + SELECT 0 -- LeaseNotActive + RETURN + END + + -- A lease may be extended exactly once. Counted under the lease lock, so it is race-safe against a concurrent + -- extension of the same lease. + IF EXISTS (SELECT 1 FROM [dbo].[AccessRequest] WHERE [ExtensionOfLeaseId] = @ExtensionOfLeaseId) + BEGIN + ROLLBACK TRANSACTION + SELECT -1 -- AlreadyExtended + RETURN + END + + -- Record the auto-approved extension request and its automatic verdict, then push the parent lease's end out in + -- place. No new lease is minted — extending reuses the existing lease, preserving the single-active-lease + -- invariant. The request's window spans the extension ([old lease end] .. [new lease end]); its NotAfter is the + -- lease's new end. + INSERT INTO [dbo].[AccessRequest] + ( + [Id], [ExtensionOfLeaseId], [OrganizationId], [CollectionId], [CipherId], [RequesterId], + [NotBefore], [NotAfter], [Reason], [Status], [CreationDate], [ResolvedDate] + ) + VALUES + ( + @AccessRequestId, @ExtensionOfLeaseId, @OrganizationId, @CollectionId, @CipherId, @RequesterId, + @NotBefore, @NotAfter, @Reason, 1 /* Approved */, @Now, @Now + ) + + INSERT INTO [dbo].[AccessDecision] + ( + [Id], [AccessRequestId], [DeciderKind], [ApproverId], [ConditionKind], + [Verdict], [Comment], [EvaluationContext], [CreationDate] + ) + VALUES + ( + @AccessDecisionId, @AccessRequestId, 0 /* Automatic */, NULL, NULL, + 1 /* Approve */, NULL, NULL, @Now + ) + + UPDATE [dbo].[AccessLease] + SET [NotAfter] = @NotAfter + WHERE [Id] = @ExtensionOfLeaseId + + COMMIT TRANSACTION + + SELECT 1 -- Extended +END +GO + +-- Lease revocation: the human Deny verdict literal moves from 1 to 0 (Deny). +CREATE OR ALTER PROCEDURE [dbo].[AccessLease_Revoke] + @AccessLeaseId UNIQUEIDENTIFIER, + @AccessRequestId UNIQUEIDENTIFIER, + @RevokedBy UNIQUEIDENTIFIER, + @AccessDecisionId UNIQUEIDENTIFIER, + @Reason NVARCHAR(MAX) = NULL, + @Now DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + -- Atomically revoke an active lease and capture who/why. The revocation reason has no dedicated column, so it is + -- preserved as a human AccessDecision (Deny) against the lease's originating request, keeping the audit trail + -- without a schema change. The WHERE guard keeps revocation idempotent if two approvers race. + BEGIN TRANSACTION AccessLease_Revoke + + UPDATE [dbo].[AccessLease] + SET [Status] = 2 /* Revoked */, + [RevokedDate] = @Now, + [RevokedBy] = @RevokedBy + WHERE [Id] = @AccessLeaseId AND [Status] = 0 -- Active + + INSERT INTO [dbo].[AccessDecision] + ( + [Id], [AccessRequestId], [DeciderKind], [ApproverId], [ConditionKind], + [Verdict], [Comment], [EvaluationContext], [CreationDate] + ) + VALUES + ( + @AccessDecisionId, @AccessRequestId, 1 /* Human */, @RevokedBy, NULL, + 0 /* Deny */, @Reason, NULL, @Now + ) + + COMMIT TRANSACTION AccessLease_Revoke +END +GO From 610d50426e62729041416d11ad64391909a5b07f Mon Sep 17 00:00:00 2001 From: Hinton Date: Thu, 18 Jun 2026 13:23:00 +0200 Subject: [PATCH 45/54] Add access checks to all endpoints --- .../Controllers/EmergencyAccessController.cs | 9 +- .../Response/EmergencyAccessResponseModel.cs | 11 +- .../Pam/Controllers/CipherLeaseController.cs | 8 +- .../OrganizationExportController.cs | 13 +- .../OrganizationExportResponseModel.cs | 9 +- .../LeaseFilterExemptAttribute.cs | 20 ++ .../Vault/Controllers/CiphersController.cs | 190 +++++++++----- src/Api/Vault/Controllers/SyncController.cs | 50 +--- .../Models/Response/CipherResponseModel.cs | 248 +++++++++++++++--- .../Models/Response/SyncResponseModel.cs | 18 +- src/Core/AssemblyInfo.cs | 1 + ...OrganizationServiceCollectionExtensions.cs | 2 + src/Core/Pam/Services/CipherLeaseGate.cs | 201 ++++++++++++++ src/Core/Pam/Services/ICipherLeaseGate.cs | 70 +++++ .../Vault/Authorization/FullCipherAccess.cs | 51 ++++ .../Services/Implementations/CipherService.cs | 47 +++- .../EmergencyAccessControllerTests.cs | 1 + .../Controllers/CipherLeaseControllerTests.cs | 3 +- .../CipherLeaseGateBypassCustomization.cs | 42 +++ .../Controllers/CiphersControllerTests.cs | 12 +- .../Vault/Controllers/SyncControllerTests.cs | 9 + .../CipherLeaseFilterEnforcementTests.cs | 76 ++++++ .../Response/CipherResponseModelTests.cs | 28 +- .../Pam/Services/CipherLeaseGateTests.cs | 222 ++++++++++++++++ .../Vault/Services/CipherServiceTests.cs | 43 +++ 25 files changed, 1206 insertions(+), 178 deletions(-) create mode 100644 src/Api/Vault/Authorization/LeaseFilterExemptAttribute.cs create mode 100644 src/Core/Pam/Services/CipherLeaseGate.cs create mode 100644 src/Core/Pam/Services/ICipherLeaseGate.cs create mode 100644 src/Core/Vault/Authorization/FullCipherAccess.cs create mode 100644 test/Api.Test/Vault/AutoFixture/CipherLeaseGateBypassCustomization.cs create mode 100644 test/Api.Test/Vault/Models/Response/CipherLeaseFilterEnforcementTests.cs create mode 100644 test/Core.Test/Pam/Services/CipherLeaseGateTests.cs diff --git a/src/Api/Auth/Controllers/EmergencyAccessController.cs b/src/Api/Auth/Controllers/EmergencyAccessController.cs index 4e9f9bf96851..970419a395aa 100644 --- a/src/Api/Auth/Controllers/EmergencyAccessController.cs +++ b/src/Api/Auth/Controllers/EmergencyAccessController.cs @@ -9,6 +9,7 @@ using Bit.Api.Vault.Models.Response; using Bit.Core.Auth.UserFeatures.EmergencyAccess; using Bit.Core.Exceptions; +using Bit.Core.Pam.Services; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; @@ -25,17 +26,20 @@ public class EmergencyAccessController : Controller private readonly IEmergencyAccessRepository _emergencyAccessRepository; private readonly IEmergencyAccessService _emergencyAccessService; private readonly IGlobalSettings _globalSettings; + private readonly ICipherLeaseGate _cipherLeaseGate; public EmergencyAccessController( IUserService userService, IEmergencyAccessRepository emergencyAccessRepository, IEmergencyAccessService emergencyAccessService, - IGlobalSettings globalSettings) + IGlobalSettings globalSettings, + ICipherLeaseGate cipherLeaseGate) { _userService = userService; _emergencyAccessRepository = emergencyAccessRepository; _emergencyAccessService = emergencyAccessService; _globalSettings = globalSettings; + _cipherLeaseGate = cipherLeaseGate; } [HttpGet("trusted")] @@ -203,7 +207,8 @@ public async Task ViewCiphers(Guid id) { var user = await _userService.GetUserByPrincipalAsync(User); var viewResult = await _emergencyAccessService.ViewAsync(id, user); - return new EmergencyAccessViewResponseModel(_globalSettings, viewResult.EmergencyAccess, viewResult.Ciphers, user); + return new EmergencyAccessViewResponseModel(_globalSettings, viewResult.EmergencyAccess, viewResult.Ciphers, user, + _cipherLeaseGate.Unrestricted()); } [HttpGet("{id}/{cipherId}/attachment/{attachmentId}")] diff --git a/src/Api/Auth/Models/Response/EmergencyAccessResponseModel.cs b/src/Api/Auth/Models/Response/EmergencyAccessResponseModel.cs index aa21b18bef91..eee91cfddfeb 100644 --- a/src/Api/Auth/Models/Response/EmergencyAccessResponseModel.cs +++ b/src/Api/Auth/Models/Response/EmergencyAccessResponseModel.cs @@ -9,6 +9,7 @@ using Bit.Core.Enums; using Bit.Core.Models.Api; using Bit.Core.Settings; +using Bit.Core.Vault.Authorization; using Bit.Core.Vault.Models.Data; namespace Bit.Api.Auth.Models.Response; @@ -129,15 +130,19 @@ public EmergencyAccessViewResponseModel( IGlobalSettings globalSettings, EmergencyAccess emergencyAccess, IEnumerable ciphers, - User user) + User user, + FullCipherAccess fullCipherAccess) : base("emergencyAccessView") { KeyEncrypted = emergencyAccess.KeyEncrypted; + // Emergency access only retrieves personal ciphers, which are never leasing-gated, so full data + // is released (organizationAbility is not needed for personal ciphers). Ciphers = ciphers.Select(cipher => - new CipherResponseModel( + new FullCipherResponseModel( + fullCipherAccess, cipher, user, - null, // Emergency access only retrieves personal ciphers so organizationAbility is not needed + null, globalSettings)); } diff --git a/src/Api/Pam/Controllers/CipherLeaseController.cs b/src/Api/Pam/Controllers/CipherLeaseController.cs index 4b76dfa42afd..ffa9089e082b 100644 --- a/src/Api/Pam/Controllers/CipherLeaseController.cs +++ b/src/Api/Pam/Controllers/CipherLeaseController.cs @@ -5,6 +5,7 @@ using Bit.Core.Exceptions; using Bit.Core.Pam.OrganizationFeatures.Commands.Interfaces; using Bit.Core.Pam.OrganizationFeatures.Queries.Interfaces; +using Bit.Core.Pam.Services; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; @@ -25,6 +26,7 @@ public class CipherLeaseController( IGetLeasedCipherQuery getLeasedCipherQuery, IApplicationCacheService applicationCacheService, ICollectionCipherRepository collectionCipherRepository, + ICipherLeaseGate cipherLeaseGate, GlobalSettings globalSettings) : Controller { @@ -95,6 +97,10 @@ public async Task GetCipher(Guid id) : null; var collectionCiphers = await collectionCipherRepository.GetManyByUserIdCipherIdAsync(user.Id, id); - return new CipherDetailsResponseModel(cipher, user, organizationAbility, globalSettings, collectionCiphers); + // The query above already confirmed an active lease, so the gate authorizes full data here. + var access = await cipherLeaseGate.AuthorizeReadAsync(user.Id, cipher); + return access is null + ? new CipherDetailsResponseModel(cipher, user, organizationAbility, globalSettings, collectionCiphers) + : new FullCipherDetailsResponseModel(access, cipher, user, organizationAbility, globalSettings, collectionCiphers); } } diff --git a/src/Api/Tools/Controllers/OrganizationExportController.cs b/src/Api/Tools/Controllers/OrganizationExportController.cs index ff0bff1150d7..7728b3256987 100644 --- a/src/Api/Tools/Controllers/OrganizationExportController.cs +++ b/src/Api/Tools/Controllers/OrganizationExportController.cs @@ -2,6 +2,7 @@ using Bit.Api.Tools.Models.Response; using Bit.Core.AdminConsole.OrganizationFeatures.Shared.Authorization; using Bit.Core.Exceptions; +using Bit.Core.Pam.Services; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; @@ -20,6 +21,7 @@ public class OrganizationExportController : Controller private readonly IAuthorizationService _authorizationService; private readonly IOrganizationCiphersQuery _organizationCiphersQuery; private readonly ICollectionRepository _collectionRepository; + private readonly ICipherLeaseGate _cipherLeaseGate; public OrganizationExportController( IUserService userService, @@ -27,13 +29,15 @@ public OrganizationExportController( IAuthorizationService authorizationService, IOrganizationCiphersQuery organizationCiphersQuery, ICollectionRepository collectionRepository, - IFeatureService featureService) + IFeatureService featureService, + ICipherLeaseGate cipherLeaseGate) { _userService = userService; _globalSettings = globalSettings; _authorizationService = authorizationService; _organizationCiphersQuery = organizationCiphersQuery; _collectionRepository = collectionRepository; + _cipherLeaseGate = cipherLeaseGate; } [HttpGet("export")] @@ -50,8 +54,9 @@ public async Task Export(Guid organizationId) .GetManySharedCollectionsByOrganizationIdAsync(organizationId); await Task.WhenAll(ciphersTask, collectionsTask); + // Whole-vault export is authorized through org-wide permissions, so nothing is leasing-gated. return Ok(new OrganizationExportResponseModel(ciphersTask.Result, collectionsTask.Result, - _globalSettings)); + _globalSettings, _cipherLeaseGate.Unrestricted())); } var canExportManaged = await _authorizationService.AuthorizeAsync(User, new OrganizationScope(organizationId), @@ -68,7 +73,9 @@ public async Task Export(Guid organizationId) var managedCiphers = await _organizationCiphersQuery.GetOrganizationCiphersByCollectionIds(organizationId, managedOrgCollections.Select(c => c.Id)); - return Ok(new OrganizationExportResponseModel(managedCiphers, managedOrgCollections, _globalSettings)); + // Leasing-gated ciphers the exporter holds no valid lease for are excluded from the export. + var fullAccess = await _cipherLeaseGate.AuthorizeReadManyAsync(userId, managedCiphers); + return Ok(new OrganizationExportResponseModel(managedCiphers, managedOrgCollections, _globalSettings, fullAccess)); } // Unauthorized diff --git a/src/Api/Tools/Models/Response/OrganizationExportResponseModel.cs b/src/Api/Tools/Models/Response/OrganizationExportResponseModel.cs index 208c7f7aefde..0469aa319252 100644 --- a/src/Api/Tools/Models/Response/OrganizationExportResponseModel.cs +++ b/src/Api/Tools/Models/Response/OrganizationExportResponseModel.cs @@ -7,6 +7,7 @@ using Bit.Core.Entities; using Bit.Core.Models.Api; using Bit.Core.Settings; +using Bit.Core.Vault.Authorization; using Bit.Core.Vault.Models.Data; namespace Bit.Api.Tools.Models.Response; @@ -18,9 +19,13 @@ public OrganizationExportResponseModel() : base("organizationExport") } public OrganizationExportResponseModel(IEnumerable ciphers, - IEnumerable collections, GlobalSettings globalSettings) : this() + IEnumerable collections, GlobalSettings globalSettings, FullCipherAccess fullCipherAccess) : this() { - Ciphers = ciphers.Select(c => new CipherMiniDetailsResponseModel(c, globalSettings)); + // Under PAM credential leasing, a leasing-gated cipher the exporter cannot fully access is + // excluded from the export entirely — a partially-stripped export is not a usable backup. + Ciphers = ciphers + .Where(c => fullCipherAccess.Authorizes(c.Id)) + .Select(c => new FullCipherMiniDetailsResponseModel(fullCipherAccess, c, globalSettings)); Collections = collections.Select(c => new CollectionResponseModel(c)); } diff --git a/src/Api/Vault/Authorization/LeaseFilterExemptAttribute.cs b/src/Api/Vault/Authorization/LeaseFilterExemptAttribute.cs new file mode 100644 index 000000000000..64c52693f996 --- /dev/null +++ b/src/Api/Vault/Authorization/LeaseFilterExemptAttribute.cs @@ -0,0 +1,20 @@ +namespace Bit.Api.Vault.Authorization; + +/// +/// Documents an intentional choice that a controller action returning cipher data does not need PAM +/// credential-leasing filtering — for example, because it only ever handles personal ciphers, or +/// because authorization is enforced out-of-band. The lease-filter fitness test allows actions that +/// carry this marker (and otherwise requires a sanctioned response type), mirroring how +/// NoopAuthorizeAttribute documents intentional non-authorization. +/// +[AttributeUsage(AttributeTargets.Method)] +public class LeaseFilterExemptAttribute : Attribute +{ + public LeaseFilterExemptAttribute(string reason) + { + Reason = reason; + } + + /// Why this action is exempt from lease filtering. Required, for auditability. + public string Reason { get; } +} diff --git a/src/Api/Vault/Controllers/CiphersController.cs b/src/Api/Vault/Controllers/CiphersController.cs index 6b91ed1b0f15..525387e56eeb 100644 --- a/src/Api/Vault/Controllers/CiphersController.cs +++ b/src/Api/Vault/Controllers/CiphersController.cs @@ -14,7 +14,9 @@ using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; +using Bit.Core.Models.Data; using Bit.Core.Models.Data.Organizations; +using Bit.Core.Pam.Services; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; @@ -51,6 +53,7 @@ public class CiphersController : Controller private readonly ICollectionRepository _collectionRepository; private readonly IArchiveCiphersCommand _archiveCiphersCommand; private readonly IUnarchiveCiphersCommand _unarchiveCiphersCommand; + private readonly ICipherLeaseGate _cipherLeaseGate; public CiphersController( ICipherRepository cipherRepository, @@ -65,7 +68,8 @@ public CiphersController( IApplicationCacheService applicationCacheService, ICollectionRepository collectionRepository, IArchiveCiphersCommand archiveCiphersCommand, - IUnarchiveCiphersCommand unarchiveCiphersCommand) + IUnarchiveCiphersCommand unarchiveCiphersCommand, + ICipherLeaseGate cipherLeaseGate) { _cipherRepository = cipherRepository; _collectionCipherRepository = collectionCipherRepository; @@ -80,6 +84,49 @@ public CiphersController( _collectionRepository = collectionRepository; _archiveCiphersCommand = archiveCiphersCommand; _unarchiveCiphersCommand = unarchiveCiphersCommand; + _cipherLeaseGate = cipherLeaseGate; + } + + /// + /// Builds a cipher response for a personal/member read or write-return, honouring credential leasing: + /// a leasing-gated cipher with no valid active lease yields the partial shape, otherwise the full one. + /// + private async Task BuildCipherResponseAsync(CipherDetails cipher, User user) + { + var organizationAbility = await GetOrganizationAbilityAsync(cipher); + var access = await _cipherLeaseGate.AuthorizeReadAsync(user.Id, cipher); + return access is null + ? new CipherResponseModel(cipher, user, organizationAbility, _globalSettings) + : new FullCipherResponseModel(access, cipher, user, organizationAbility, _globalSettings); + } + + /// Details variant of . + private async Task BuildCipherDetailsResponseAsync( + CipherDetails cipher, User user, IEnumerable collectionCiphers) + { + var organizationAbility = await GetOrganizationAbilityAsync(cipher); + var access = await _cipherLeaseGate.AuthorizeReadAsync(user.Id, cipher); + return access is null + ? new CipherDetailsResponseModel(cipher, user, organizationAbility, _globalSettings, collectionCiphers) + : new FullCipherDetailsResponseModel(access, cipher, user, organizationAbility, _globalSettings, collectionCiphers); + } + + /// + /// Bulk variant for write-returns over a set of ciphers (archive/unarchive). Leasing-gated ciphers + /// the caller cannot fully access fall back to the partial shape; the gated set is resolved once. + /// + private async Task> BuildCipherResponsesAsync( + ICollection ciphers, User user, + IDictionary organizationAbilities) + { + var fullAccess = await _cipherLeaseGate.AuthorizeReadManyAsync(user.Id, ciphers); + return ciphers.Select(cipher => + { + var organizationAbility = GetOrganizationAbility(cipher, organizationAbilities); + return fullAccess.Authorizes(cipher.Id) + ? new FullCipherResponseModel(fullAccess, cipher, user, organizationAbility, _globalSettings) + : new CipherResponseModel(cipher, user, organizationAbility, _globalSettings); + }); } [HttpGet("{id}")] @@ -92,9 +139,7 @@ public async Task Get(Guid id) throw new NotFoundException(); } - var organizationAbility = await GetOrganizationAbilityAsync(cipher); - - return new CipherResponseModel(cipher, user, organizationAbility, _globalSettings); + return await BuildCipherResponseAsync(cipher, user); } [HttpGet("{id}/admin")] @@ -110,7 +155,10 @@ public async Task GetAdmin(string id) var collectionCiphers = await _collectionCipherRepository.GetManyByOrganizationIdAsync(cipher.OrganizationId.Value); var collectionCiphersGroupDict = collectionCiphers.GroupBy(c => c.CipherId).ToDictionary(s => s.Key); - return new CipherMiniDetailsResponseModel(cipher, _globalSettings, collectionCiphersGroupDict, cipher.OrganizationUseTotp); + // Admin/org-wide path: authorized through org permissions with no collection-membership path, so + // the cipher is never leasing-gated for this caller and full data is released. + return new FullCipherMiniDetailsResponseModel(_cipherLeaseGate.Unrestricted(), cipher, _globalSettings, + collectionCiphersGroupDict, cipher.OrganizationUseTotp); } [HttpGet("{id}/details")] @@ -123,9 +171,8 @@ public async Task GetDetails(Guid id) throw new NotFoundException(); } - var organizationAbility = await GetOrganizationAbilityAsync(cipher); var collectionCiphers = await _collectionCipherRepository.GetManyByUserIdCipherIdAsync(user.Id, id); - return new CipherDetailsResponseModel(cipher, user, organizationAbility, _globalSettings, collectionCiphers); + return await BuildCipherDetailsResponseAsync(cipher, user, collectionCiphers); } [HttpGet("{id}/full-details")] @@ -143,18 +190,25 @@ public async Task> GetAll() // TODO: Use hasOrgs proper for cipher listing here? var ciphers = await _cipherRepository.GetManyByUserIdAsync(user.Id, withOrganizations: true); Dictionary> collectionCiphersGroupDict = null; + IEnumerable collections = null; if (hasOrgs) { var collectionCiphers = await _collectionCipherRepository.GetManyByUserIdAsync(user.Id); collectionCiphersGroupDict = collectionCiphers.GroupBy(c => c.CipherId).ToDictionary(s => s.Key); + collections = await _collectionRepository.GetManyByUserIdAsync(user.Id); } var organizationAbilities = await GetOrganizationAbilitiesAsync(ciphers); - var responses = ciphers.Select(cipher => new CipherDetailsResponseModel( - cipher, - user, - GetOrganizationAbility(cipher, organizationAbilities), - _globalSettings, - collectionCiphersGroupDict)).ToArray(); + + // Bulk reads strip every leasing-gated cipher regardless of lease state; secrets are only ever + // released through the single-cipher GET above. The gated set is computed in-memory. + var fullAccess = await _cipherLeaseGate.AuthorizeReadManyAsync(user.Id, ciphers, collections, collectionCiphersGroupDict); + var responses = ciphers.Select(cipher => + { + var organizationAbility = GetOrganizationAbility(cipher, organizationAbilities); + return fullAccess.Authorizes(cipher.Id) + ? new FullCipherDetailsResponseModel(fullAccess, cipher, user, organizationAbility, _globalSettings, collectionCiphersGroupDict) + : new CipherDetailsResponseModel(cipher, user, organizationAbility, _globalSettings, collectionCiphersGroupDict); + }).ToArray(); return new ListResponseModel(responses); } @@ -181,8 +235,7 @@ public async Task Post([FromBody] CipherRequestModel model) } await _cipherService.SaveDetailsAsync(cipher, user.Id, model.LastKnownRevisionDate, null, cipher.OrganizationId.HasValue); - var response = new CipherResponseModel(cipher, user, await GetOrganizationAbilityAsync(cipher), _globalSettings); - return response; + return await BuildCipherResponseAsync(cipher, user); } [HttpPost("create")] @@ -235,8 +288,8 @@ public async Task PostAdmin([FromBody] CipherCreateRequ await _cipherService.SaveAsync(cipher, userId, model.Cipher.LastKnownRevisionDate, model.CollectionIds, true, false); - var response = new CipherMiniResponseModel(cipher, _globalSettings, false); - return response; + // Admin create through org-wide permissions; the cipher is not leasing-gated for this caller. + return new FullCipherMiniResponseModel(_cipherLeaseGate.Unrestricted(), cipher, _globalSettings, false); } [HttpPut("{id}")] @@ -272,8 +325,7 @@ public async Task Put(Guid id, [FromBody] CipherRequestMode await _cipherService.SaveDetailsAsync(model.ToCipherDetails(cipher), user.Id, model.LastKnownRevisionDate, collectionIds); - var response = new CipherResponseModel(cipher, user, await GetOrganizationAbilityAsync(cipher), _globalSettings); - return response; + return await BuildCipherResponseAsync(cipher, user); } [HttpPost("{id}")] @@ -312,8 +364,8 @@ public async Task PutAdmin(Guid id, [FromBody] CipherRe var cipherClone = model.ToCipher(cipher).Clone(); await _cipherService.SaveAsync(cipherClone, userId, model.LastKnownRevisionDate, collectionIds, true, false); - var response = new CipherMiniResponseModel(cipherClone, _globalSettings, cipher.OrganizationUseTotp); - return response; + // Admin edit through org-wide permissions; the cipher is not leasing-gated for this caller. + return new FullCipherMiniResponseModel(_cipherLeaseGate.Unrestricted(), cipherClone, _globalSettings, cipher.OrganizationUseTotp); } [HttpPost("{id}/admin")] @@ -337,9 +389,12 @@ await _organizationCiphersQuery.GetAllOrganizationCiphersExcludingDefaultUserCol : await _organizationCiphersQuery.GetAllOrganizationCiphers(organizationId); + // Reaching this endpoint requires can-access-all-ciphers (org-wide) permission, so these ciphers + // are not leasing-gated for this caller and full data is released. + var fullAccess = _cipherLeaseGate.Unrestricted(); var allOrganizationCipherResponses = allOrganizationCiphers.Select(c => - new CipherMiniDetailsResponseModel(c, _globalSettings, c.OrganizationUseTotp) + new FullCipherMiniDetailsResponseModel(fullAccess, c, _globalSettings, c.OrganizationUseTotp) ); return new ListResponseModel(allOrganizationCipherResponses); @@ -366,10 +421,24 @@ public async Task> GetAssignedOrga })); } + var cipherList = ciphers.ToList(); var user = await _userService.GetUserByPrincipalAsync(User); var organizationAbility = await _applicationCacheService.GetOrganizationAbilityAsync(organizationId); - var responses = ciphers.Select(cipher => - new CipherDetailsResponseModel(cipher, user, organizationAbility, _globalSettings)); + + // Member read: leasing-gated ciphers (reachable only through leasing-enabled collections) are + // delivered partial here too, computed in-memory from the user's collections and each cipher's + // own collection memberships. Secrets are only released through the single-cipher GET. + var collections = await _collectionRepository.GetManyByUserIdAsync(user.Id); + var collectionCiphersGroupDict = cipherList + .SelectMany(c => (c.CollectionIds ?? []).Select(cid => new CollectionCipher { CipherId = c.Id, CollectionId = cid })) + .GroupBy(cc => cc.CipherId) + .ToDictionary(g => g.Key); + var fullAccess = await _cipherLeaseGate.AuthorizeReadManyAsync(user.Id, cipherList, collections, collectionCiphersGroupDict); + + var responses = cipherList.Select(cipher => + fullAccess.Authorizes(cipher.Id) + ? new FullCipherDetailsResponseModel(fullAccess, cipher, user, organizationAbility, _globalSettings) + : new CipherDetailsResponseModel(cipher, user, organizationAbility, _globalSettings)); return new ListResponseModel(responses); } @@ -709,8 +778,7 @@ public async Task PutPartial(Guid id, [FromBody] CipherPart await _cipherRepository.UpdatePartialAsync(id, user.Id, folderId, model.Favorite); var updatedCipher = await GetByIdAsync(id, user.Id); - var response = new CipherResponseModel(updatedCipher, user, await GetOrganizationAbilityAsync(updatedCipher), _globalSettings); - return response; + return await BuildCipherResponseAsync(updatedCipher, user); } [HttpPost("{id}/partial")] @@ -748,8 +816,7 @@ public async Task PutShare(Guid id, [FromBody] CipherShareR model.CollectionIds.Select(c => new Guid(c)), user.Id, model.Cipher.LastKnownRevisionDate); var sharedCipher = await GetByIdAsync(id, user.Id); - var response = new CipherResponseModel(sharedCipher, user, await GetOrganizationAbilityAsync(sharedCipher), _globalSettings); - return response; + return await BuildCipherResponseAsync(sharedCipher, user); } [HttpPost("{id}/share")] @@ -776,7 +843,7 @@ await _cipherService.SaveCollectionsAsync(cipher, var updatedCipher = await GetByIdAsync(id, user.Id); var collectionCiphers = await _collectionCipherRepository.GetManyByUserIdCipherIdAsync(user.Id, id); - return new CipherDetailsResponseModel(updatedCipher, user, await GetOrganizationAbilityAsync(updatedCipher), _globalSettings, collectionCiphers); + return await BuildCipherDetailsResponseAsync(updatedCipher, user, collectionCiphers); } [HttpPost("{id}/collections")] @@ -809,7 +876,7 @@ await _cipherService.SaveCollectionsAsync(cipher, Unavailable = updatedCipher is null, Cipher = updatedCipher is null ? null - : new CipherDetailsResponseModel(updatedCipher, user, await GetOrganizationAbilityAsync(updatedCipher), _globalSettings, collectionCiphers) + : await BuildCipherDetailsResponseAsync(updatedCipher, user, collectionCiphers) }; return response; } @@ -847,7 +914,10 @@ public async Task PutCollectionsAdmin(string id, var collectionCiphers = await _collectionCipherRepository.GetManyByOrganizationIdAsync(cipher.OrganizationId.Value); var collectionCiphersGroupDict = collectionCiphers.GroupBy(c => c.CipherId).ToDictionary(s => s.Key); - return new CipherMiniDetailsResponseModel(cipher, _globalSettings, collectionCiphersGroupDict, cipher.OrganizationUseTotp); + // Admin/org-wide path: authorized through org permissions with no collection-membership path, so + // the cipher is never leasing-gated for this caller and full data is released. + return new FullCipherMiniDetailsResponseModel(_cipherLeaseGate.Unrestricted(), cipher, _globalSettings, + collectionCiphersGroupDict, cipher.OrganizationUseTotp); } [HttpPost("{id}/collections-admin")] @@ -892,7 +962,8 @@ public async Task PutArchive(Guid id) } var archivedCipher = archivedCipherOrganizationDetails.First(); - return new CipherResponseModel(archivedCipher, await _userService.GetUserByPrincipalAsync(User), await GetOrganizationAbilityAsync(archivedCipher), _globalSettings); + var user = await _userService.GetUserByPrincipalAsync(User); + return await BuildCipherResponseAsync(archivedCipher, user); } [HttpPut("archive")] @@ -916,8 +987,7 @@ public async Task> PutArchiveMany([FromBo } var organizationAbilities = await GetOrganizationAbilitiesAsync(archivedCiphers); - var responses = archivedCiphers.Select(cipher => - new CipherResponseModel(cipher, user, GetOrganizationAbility(cipher, organizationAbilities), _globalSettings)).ToArray(); + var responses = (await BuildCipherResponsesAsync(archivedCiphers, user, organizationAbilities)).ToArray(); return new ListResponseModel(responses); } @@ -1092,11 +1162,8 @@ public async Task PutUnarchive(Guid id) } var unarchivedCipher = unarchivedCipherDetails.First(); - return new CipherResponseModel(unarchivedCipher, - await _userService.GetUserByPrincipalAsync(User), - await GetOrganizationAbilityAsync(unarchivedCipher), - _globalSettings - ); + var user = await _userService.GetUserByPrincipalAsync(User); + return await BuildCipherResponseAsync(unarchivedCipher, user); } [HttpPut("unarchive")] @@ -1120,8 +1187,7 @@ public async Task> PutUnarchiveMany([From } var organizationAbilities = await GetOrganizationAbilitiesAsync(unarchivedCipherOrganizationDetails); - var responses = unarchivedCipherOrganizationDetails.Select(cipher => - new CipherResponseModel(cipher, user, GetOrganizationAbility(cipher, organizationAbilities), _globalSettings)).ToArray(); + var responses = (await BuildCipherResponsesAsync(unarchivedCipherOrganizationDetails, user, organizationAbilities)).ToArray(); return new ListResponseModel(responses); } @@ -1137,11 +1203,7 @@ public async Task PutRestore(Guid id) } await _cipherService.RestoreAsync(cipher, user.Id); - return new CipherResponseModel( - cipher, - user, - await GetOrganizationAbilityAsync(cipher), - _globalSettings); + return await BuildCipherResponseAsync(cipher, user); } [HttpPut("{id}/restore-admin")] @@ -1156,7 +1218,8 @@ public async Task PutRestoreAdmin(Guid id) } await _cipherService.RestoreAsync(new CipherDetails(cipher), userId, true); - return new CipherMiniResponseModel(cipher, _globalSettings, cipher.OrganizationUseTotp); + // Admin restore through org-wide permissions; the cipher is not leasing-gated for this caller. + return new FullCipherMiniResponseModel(_cipherLeaseGate.Unrestricted(), cipher, _globalSettings, cipher.OrganizationUseTotp); } [HttpPut("restore")] @@ -1171,7 +1234,10 @@ public async Task> PutRestoreMany([Fr var cipherIdsToRestore = new HashSet(model.Ids.Select(i => new Guid(i))); var restoredCiphers = await _cipherService.RestoreManyAsync(cipherIdsToRestore, userId); - var responses = restoredCiphers.Select(c => new CipherMiniResponseModel(c, _globalSettings, c.OrganizationUseTotp)); + var fullAccess = await _cipherLeaseGate.AuthorizeReadManyAsync(userId, restoredCiphers); + var responses = restoredCiphers.Select(c => fullAccess.Authorizes(c.Id) + ? new FullCipherMiniResponseModel(fullAccess, c, _globalSettings, c.OrganizationUseTotp) + : new CipherMiniResponseModel(c, _globalSettings, c.OrganizationUseTotp)); return new ListResponseModel(responses); } @@ -1198,7 +1264,9 @@ public async Task> PutRestoreManyAdmi var userId = _userService.GetProperUserId(User).Value; var restoredCiphers = await _cipherService.RestoreManyAsync(cipherIdsToRestore, userId, model.OrganizationId, true); - var responses = restoredCiphers.Select(c => new CipherMiniResponseModel(c, _globalSettings, c.OrganizationUseTotp)); + // Admin restore through org-wide permissions; these ciphers are not leasing-gated for this caller. + var fullAccess = _cipherLeaseGate.Unrestricted(); + var responses = restoredCiphers.Select(c => new FullCipherMiniResponseModel(fullAccess, c, _globalSettings, c.OrganizationUseTotp)); return new ListResponseModel(responses); } @@ -1266,7 +1334,10 @@ public async Task> PutShareMany([From userId ); - var response = updated.Select(c => new CipherMiniResponseModel(c, _globalSettings, c.OrganizationUseTotp)); + var fullAccess = await _cipherLeaseGate.AuthorizeReadManyAsync(userId, updated); + var response = updated.Select(c => fullAccess.Authorizes(c.Id) + ? new FullCipherMiniResponseModel(fullAccess, c, _globalSettings, c.OrganizationUseTotp) + : new CipherMiniResponseModel(c, _globalSettings, c.OrganizationUseTotp)); return new ListResponseModel(response); } @@ -1340,12 +1411,10 @@ await _cipherRepository.GetOrganizationDetailsByIdAsync(id) : AttachmentId = attachmentId, Url = uploadUrl, FileUploadType = _attachmentStorageService.FileUploadType, - CipherResponse = request.AdminRequest ? null : new CipherResponseModel( - cipherDetails, - user, - await GetOrganizationAbilityAsync(cipherDetails), - _globalSettings), - CipherMiniResponse = request.AdminRequest ? new CipherMiniResponseModel(cipher, _globalSettings, cipher.OrganizationUseTotp) : null, + CipherResponse = request.AdminRequest ? null : await BuildCipherResponseAsync(cipherDetails, user), + CipherMiniResponse = request.AdminRequest + ? new FullCipherMiniResponseModel(_cipherLeaseGate.Unrestricted(), cipher, _globalSettings, cipher.OrganizationUseTotp) + : null, }; } @@ -1433,11 +1502,7 @@ await _cipherService.CreateAttachmentAsync(cipher, stream, fileName, key, Request.ContentLength.GetValueOrDefault(0), user.Id, false, lastKnownRevisionDate); }); - return new CipherResponseModel( - cipher, - user, - await GetOrganizationAbilityAsync(cipher), - _globalSettings); + return await BuildCipherResponseAsync(cipher, user); } [HttpPost("{id}/attachment-admin")] @@ -1465,7 +1530,8 @@ await _cipherService.CreateAttachmentAsync(cipher, stream, fileName, key, Request.ContentLength.GetValueOrDefault(0), userId, true, lastKnownRevisionDate); }); - return new CipherMiniResponseModel(cipher, _globalSettings, cipher.OrganizationUseTotp); + // Admin attachment upload through org-wide permissions; the cipher is not leasing-gated here. + return new FullCipherMiniResponseModel(_cipherLeaseGate.Unrestricted(), cipher, _globalSettings, cipher.OrganizationUseTotp); } [HttpGet("{id}/attachment/{attachmentId}/admin")] diff --git a/src/Api/Vault/Controllers/SyncController.cs b/src/Api/Vault/Controllers/SyncController.cs index dfe816e77d5c..9d8b21d1cfc8 100644 --- a/src/Api/Vault/Controllers/SyncController.cs +++ b/src/Api/Vault/Controllers/SyncController.cs @@ -17,6 +17,7 @@ using Bit.Core.Models.Data; using Bit.Core.Models.Data.Organizations; using Bit.Core.Models.Data.Organizations.OrganizationUsers; +using Bit.Core.Pam.Services; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; @@ -50,6 +51,7 @@ public class SyncController : Controller private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; private readonly IWebAuthnCredentialRepository _webAuthnCredentialRepository; private readonly IUserAccountKeysQuery _userAccountKeysQuery; + private readonly ICipherLeaseGate _cipherLeaseGate; public SyncController( IUserService userService, @@ -67,7 +69,8 @@ public SyncController( IApplicationCacheService applicationCacheService, ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, IWebAuthnCredentialRepository webAuthnCredentialRepository, - IUserAccountKeysQuery userAccountKeysQuery) + IUserAccountKeysQuery userAccountKeysQuery, + ICipherLeaseGate cipherLeaseGate) { _userService = userService; _folderRepository = folderRepository; @@ -85,6 +88,7 @@ public SyncController( _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; _webAuthnCredentialRepository = webAuthnCredentialRepository; _userAccountKeysQuery = userAccountKeysQuery; + _cipherLeaseGate = cipherLeaseGate; } [HttpGet("")] @@ -122,10 +126,9 @@ await _providerUserRepository.GetManyOrganizationDetailsByUserAsync(user.Id, } // PAM credential leasing: ciphers reachable only through leasing-enabled collections are delivered - // with reduced data during the passive sync. The active GET /ciphers/{id} path is unchanged. - var partialDataCipherIds = _featureService.IsEnabled(FeatureFlagKeys.Pam) - ? GetPartialDataCipherIds(collections, collectionCiphersGroupDict) - : new HashSet(); + // with reduced data during the passive sync. The active GET /ciphers/{id} path is unchanged. The + // witness authorizes the non-gated subset; gated ciphers fall through to the partial shape. + var fullCipherAccess = await _cipherLeaseGate.AuthorizeReadManyAsync(user.Id, ciphers, collections, collectionCiphersGroupDict); var userTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user); var userHasPremiumFromOrganization = await _userService.HasPremiumFromOrganization(user); @@ -153,45 +156,10 @@ await _providerUserRepository.GetManyOrganizationDetailsByUserAsync(user.Id, var response = new SyncResponseModel(_globalSettings, user, userAccountKeys, userTwoFactorEnabled, userHasPremiumFromOrganization, organizationAbilities, organizationIdsClaimingActiveUser, organizationUserDetails, providerUserDetails, providerUserOrganizationDetails, folders, collections, ciphers, collectionCiphersGroupDict, excludeDomains, policies, sends, webAuthnCredentials, - policiesNew, organizationUserDetailsNew, partialDataCipherIds); + policiesNew, organizationUserDetailsNew, fullCipherAccess); return response; } - /// - /// Returns the IDs of ciphers the user can reach only through leasing-enabled collections - /// (those with an ). A cipher reachable through any non-leasing - /// collection — or owned personally with no collection mapping — is excluded and receives full data. - /// - private static ISet GetPartialDataCipherIds( - IEnumerable collections, - IDictionary> collectionCiphersGroupDict) - { - var partialDataCipherIds = new HashSet(); - if (collections == null || collectionCiphersGroupDict == null) - { - return partialDataCipherIds; - } - - var leasingCollectionIds = collections - .Where(c => c.AccessRuleId.HasValue) - .Select(c => c.Id) - .ToHashSet(); - if (leasingCollectionIds.Count == 0) - { - return partialDataCipherIds; - } - - foreach (var (cipherId, collectionCiphers) in collectionCiphersGroupDict) - { - if (collectionCiphers.Any() && collectionCiphers.All(cc => leasingCollectionIds.Contains(cc.CollectionId))) - { - partialDataCipherIds.Add(cipherId); - } - } - - return partialDataCipherIds; - } - private async Task> GetOrganizationAbilitiesAsync(ICollection ciphers) { var orgIds = ciphers diff --git a/src/Api/Vault/Models/Response/CipherResponseModel.cs b/src/Api/Vault/Models/Response/CipherResponseModel.cs index e760afe95794..75953d198cc6 100644 --- a/src/Api/Vault/Models/Response/CipherResponseModel.cs +++ b/src/Api/Vault/Models/Response/CipherResponseModel.cs @@ -6,6 +6,7 @@ using Bit.Core.Models.Api; using Bit.Core.Models.Data.Organizations; using Bit.Core.Settings; +using Bit.Core.Vault.Authorization; using Bit.Core.Vault.Entities; using Bit.Core.Vault.Enums; using Bit.Core.Vault.Models.Data; @@ -16,7 +17,19 @@ namespace Bit.Api.Vault.Models.Response; public class CipherMiniResponseModel : ResponseModel { - public CipherMiniResponseModel(Cipher cipher, IGlobalSettings globalSettings, bool orgUseTotp, string obj = "cipherMini", bool isPartial = false) + // PARTIAL/safe constructor. Under PAM credential leasing the secret Data blob is never emitted from + // here — only the reduced PartialData. Any path that uses this type without a FullCipherAccess witness + // therefore fails closed: a missed migration returns partial data (a visible bug), never a leak. + public CipherMiniResponseModel(Cipher cipher, IGlobalSettings globalSettings, bool orgUseTotp, + string obj = "cipherMini") + : this(cipher, globalSettings, orgUseTotp, obj, partial: true) + { + } + + // Shared construction. When partial is false the secret Data is left null for a derived Full* type to + // populate via PopulateFullData; this constructor never emits secret data on its own. + protected CipherMiniResponseModel(Cipher cipher, IGlobalSettings globalSettings, bool orgUseTotp, + string obj, bool partial) : base(obj) { if (cipher == null) @@ -26,7 +39,6 @@ public CipherMiniResponseModel(Cipher cipher, IGlobalSettings globalSettings, bo Id = cipher.Id; Type = cipher.Type; - Data = cipher.Data; RevisionDate = cipher.RevisionDate; OrganizationId = cipher.OrganizationId; Attachments = AttachmentResponseModel.FromCipher(cipher, globalSettings); @@ -36,15 +48,24 @@ public CipherMiniResponseModel(Cipher cipher, IGlobalSettings globalSettings, bo Reprompt = cipher.Reprompt.GetValueOrDefault(CipherRepromptType.None); Key = cipher.Key; - if (isPartial) + if (partial && !cipher.IsDataBlobEncrypted()) { - // Under credential leasing the full data blob and all the obsolete typed fields are withheld; - // the reduced blob is returned only in PartialData, signalling that this cipher is leasing-gated. - // The client decrypts PartialData itself. - Data = null; + // The reduced blob signals the cipher is leasing-gated; the client decrypts PartialData itself. + // An opaque (SDK-encrypted) blob can't be reshaped without decrypting, so nothing is returned. PartialData = PartialCipherData.Strip(cipher.Type, cipher.Data); - return; } + } + + /// + /// Populates the full secret data blob (and the obsolete typed fields) for a Full* response. Requires a + /// witness authorizing this cipher, so full secret data cannot be emitted + /// without first passing through the leasing gate that mints the witness. + /// + protected void PopulateFullData(FullCipherAccess access, Cipher cipher) + { + access.Require(cipher.Id); + + Data = cipher.Data; if (cipher.IsDataBlobEncrypted()) { @@ -107,51 +128,54 @@ public CipherMiniResponseModel(Cipher cipher, IGlobalSettings globalSettings, bo public Guid Id { get; set; } public Guid? OrganizationId { get; set; } public CipherType Type { get; set; } - public string Data { get; set; } + + // Setter is locked so the secret blob can only ever be populated through the witness-gated + // PopulateFullData path, never via a public constructor or object initializer. + public string Data { get; protected set; } /// /// The reduced data blob returned in place of when the caller can only reach this /// cipher through leasing-enabled collections (PAM credential leasing). Contains the encrypted title - /// and, for logins, the encrypted URIs — never the dropped secrets. Null for non-leased ciphers. + /// and, for logins, the encrypted URIs — never the dropped secrets. Null for full responses. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string PartialData { get; set; } [Obsolete("Use Data instead.")] - public string Name { get; set; } + public string Name { get; protected set; } [Obsolete("Use Data instead.")] - public string Notes { get; set; } + public string Notes { get; protected set; } [Obsolete("Use Data instead.")] - public CipherLoginModel Login { get; set; } + public CipherLoginModel Login { get; protected set; } [Obsolete("Use Data instead.")] - public CipherCardModel Card { get; set; } + public CipherCardModel Card { get; protected set; } [Obsolete("Use Data instead.")] - public CipherIdentityModel Identity { get; set; } + public CipherIdentityModel Identity { get; protected set; } [Obsolete("Use Data instead.")] - public CipherSecureNoteModel SecureNote { get; set; } + public CipherSecureNoteModel SecureNote { get; protected set; } [Obsolete("Use Data instead.")] - public CipherSSHKeyModel SSHKey { get; set; } + public CipherSSHKeyModel SSHKey { get; protected set; } [Obsolete("Use Data instead.")] - public CipherBankAccountModel BankAccount { get; set; } + public CipherBankAccountModel BankAccount { get; protected set; } [Obsolete("Use Data instead.")] - public CipherDriversLicenseModel DriversLicense { get; set; } + public CipherDriversLicenseModel DriversLicense { get; protected set; } [Obsolete("Use Data instead.")] - public CipherPassportModel Passport { get; set; } + public CipherPassportModel Passport { get; protected set; } [Obsolete("Use Data instead.")] - public IEnumerable Fields { get; set; } + public IEnumerable Fields { get; protected set; } [Obsolete("Use Data instead.")] - public IEnumerable PasswordHistory { get; set; } + public IEnumerable PasswordHistory { get; protected set; } public IEnumerable Attachments { get; set; } public bool OrganizationUseTotp { get; set; } public DateTime RevisionDate { get; set; } @@ -160,6 +184,22 @@ public CipherMiniResponseModel(Cipher cipher, IGlobalSettings globalSettings, bo public CipherRepromptType Reprompt { get; set; } public string Key { get; set; } } + +/// +/// The full-data counterpart of . Its constructor requires a +/// witness (minted only by the leasing gate), making emission of full +/// secret data a deliberate, type-checked act. +/// +public class FullCipherMiniResponseModel : CipherMiniResponseModel +{ + public FullCipherMiniResponseModel(FullCipherAccess access, Cipher cipher, IGlobalSettings globalSettings, + bool orgUseTotp, string obj = "cipherMini") + : base(cipher, globalSettings, orgUseTotp, obj, partial: false) + { + PopulateFullData(access, cipher); + } +} + #nullable enable public class CipherResponseModel : CipherMiniResponseModel { @@ -168,9 +208,19 @@ public CipherResponseModel( User user, OrganizationAbility? organizationAbility, IGlobalSettings globalSettings, - string obj = "cipher", - bool isPartial = false) - : base(cipher, globalSettings, cipher.OrganizationUseTotp, obj, isPartial) + string obj = "cipher") + : this(cipher, user, organizationAbility, globalSettings, obj, partial: true) + { + } + + protected CipherResponseModel( + CipherDetails cipher, + User user, + OrganizationAbility? organizationAbility, + IGlobalSettings globalSettings, + string obj, + bool partial) + : base(cipher, globalSettings, cipher.OrganizationUseTotp, obj, partial) { FolderId = cipher.FolderId; Favorite = cipher.Favorite; @@ -188,6 +238,22 @@ public CipherResponseModel( public CipherPermissionsResponseModel Permissions { get; set; } } +/// Full-data counterpart of ; requires a gate-minted witness. +public class FullCipherResponseModel : CipherResponseModel +{ + public FullCipherResponseModel( + FullCipherAccess access, + CipherDetails cipher, + User user, + OrganizationAbility? organizationAbility, + IGlobalSettings globalSettings, + string obj = "cipher") + : base(cipher, user, organizationAbility, globalSettings, obj, partial: false) + { + PopulateFullData(access, cipher); + } +} + public class CipherDetailsResponseModel : CipherResponseModel { public CipherDetailsResponseModel( @@ -195,9 +261,18 @@ public CipherDetailsResponseModel( User user, OrganizationAbility? organizationAbility, GlobalSettings globalSettings, - IDictionary> collectionCiphers, string obj = "cipherDetails", - bool isPartial = false) - : base(cipher, user, organizationAbility, globalSettings, obj, isPartial) + IDictionary> collectionCiphers, string obj = "cipherDetails") + : this(cipher, user, organizationAbility, globalSettings, collectionCiphers, obj, partial: true) + { + } + + protected CipherDetailsResponseModel( + CipherDetails cipher, + User user, + OrganizationAbility? organizationAbility, + GlobalSettings globalSettings, + IDictionary> collectionCiphers, string obj, bool partial) + : base(cipher, user, organizationAbility, globalSettings, obj, partial) { if (collectionCiphers?.TryGetValue(cipher.Id, out var collectionCipher) ?? false) { @@ -215,7 +290,17 @@ public CipherDetailsResponseModel( OrganizationAbility? organizationAbility, GlobalSettings globalSettings, IEnumerable collectionCiphers, string obj = "cipherDetails") - : base(cipher, user, organizationAbility, globalSettings, obj) + : this(cipher, user, organizationAbility, globalSettings, collectionCiphers, obj, partial: true) + { + } + + protected CipherDetailsResponseModel( + CipherDetails cipher, + User user, + OrganizationAbility? organizationAbility, + GlobalSettings globalSettings, + IEnumerable collectionCiphers, string obj, bool partial) + : base(cipher, user, organizationAbility, globalSettings, obj, partial) { CollectionIds = collectionCiphers?.Select(c => c.CollectionId) ?? []; } @@ -226,7 +311,17 @@ public CipherDetailsResponseModel( OrganizationAbility? organizationAbility, GlobalSettings globalSettings, string obj = "cipherDetails") - : base(cipher, user, organizationAbility, globalSettings, obj) + : this(cipher, user, organizationAbility, globalSettings, obj, partial: true) + { + } + + protected CipherDetailsResponseModel( + CipherDetailsWithCollections cipher, + User user, + OrganizationAbility? organizationAbility, + GlobalSettings globalSettings, + string obj, bool partial) + : base(cipher, user, organizationAbility, globalSettings, obj, partial) { CollectionIds = cipher.CollectionIds ?? []; } @@ -234,11 +329,59 @@ public CipherDetailsResponseModel( public IEnumerable CollectionIds { get; set; } } +/// Full-data counterpart of ; requires a gate-minted witness. +public class FullCipherDetailsResponseModel : CipherDetailsResponseModel +{ + public FullCipherDetailsResponseModel( + FullCipherAccess access, + CipherDetails cipher, + User user, + OrganizationAbility? organizationAbility, + GlobalSettings globalSettings, + IDictionary> collectionCiphers, string obj = "cipherDetails") + : base(cipher, user, organizationAbility, globalSettings, collectionCiphers, obj, partial: false) + { + PopulateFullData(access, cipher); + } + + public FullCipherDetailsResponseModel( + FullCipherAccess access, + CipherDetails cipher, + User user, + OrganizationAbility? organizationAbility, + GlobalSettings globalSettings, + IEnumerable collectionCiphers, string obj = "cipherDetails") + : base(cipher, user, organizationAbility, globalSettings, collectionCiphers, obj, partial: false) + { + PopulateFullData(access, cipher); + } + + public FullCipherDetailsResponseModel( + FullCipherAccess access, + CipherDetailsWithCollections cipher, + User user, + OrganizationAbility? organizationAbility, + GlobalSettings globalSettings, + string obj = "cipherDetails") + : base(cipher, user, organizationAbility, globalSettings, obj, partial: false) + { + PopulateFullData(access, cipher); + } +} + public class CipherMiniDetailsResponseModel : CipherMiniResponseModel { public CipherMiniDetailsResponseModel(Cipher cipher, GlobalSettings globalSettings, - IDictionary> collectionCiphers, bool orgUseTotp, string obj = "cipherMiniDetails") - : base(cipher, globalSettings, orgUseTotp, obj) + IDictionary> collectionCiphers, bool orgUseTotp, + string obj = "cipherMiniDetails") + : this(cipher, globalSettings, collectionCiphers, orgUseTotp, obj, partial: true) + { + } + + protected CipherMiniDetailsResponseModel(Cipher cipher, GlobalSettings globalSettings, + IDictionary> collectionCiphers, bool orgUseTotp, + string obj, bool partial) + : base(cipher, globalSettings, orgUseTotp, obj, partial) { if (collectionCiphers?.TryGetValue(cipher.Id, out var collectionCipher) ?? false) { @@ -252,17 +395,50 @@ public CipherMiniDetailsResponseModel(Cipher cipher, GlobalSettings globalSettin public CipherMiniDetailsResponseModel(CipherOrganizationDetailsWithCollections cipher, GlobalSettings globalSettings, bool orgUseTotp, string obj = "cipherMiniDetails") - : base(cipher, globalSettings, orgUseTotp, obj) + : this(cipher, globalSettings, orgUseTotp, obj, partial: true) { - CollectionIds = cipher.CollectionIds ?? []; + } + + protected CipherMiniDetailsResponseModel(CipherOrganizationDetailsWithCollections cipher, + GlobalSettings globalSettings, bool orgUseTotp, string obj, bool partial) + : base(cipher, globalSettings, orgUseTotp, obj, partial) + { + CollectionIds = cipher.CollectionIds ?? new List(); } public CipherMiniDetailsResponseModel(CipherOrganizationDetailsWithCollections cipher, GlobalSettings globalSettings, string obj = "cipherMiniDetails") - : base(cipher, globalSettings, cipher.OrganizationUseTotp, obj) + : this(cipher, globalSettings, cipher.OrganizationUseTotp, obj, partial: true) { - CollectionIds = cipher.CollectionIds ?? new List(); } public IEnumerable CollectionIds { get; set; } } + +/// Full-data counterpart of ; requires a gate-minted witness. +public class FullCipherMiniDetailsResponseModel : CipherMiniDetailsResponseModel +{ + public FullCipherMiniDetailsResponseModel(FullCipherAccess access, Cipher cipher, GlobalSettings globalSettings, + IDictionary> collectionCiphers, bool orgUseTotp, + string obj = "cipherMiniDetails") + : base(cipher, globalSettings, collectionCiphers, orgUseTotp, obj, partial: false) + { + PopulateFullData(access, cipher); + } + + public FullCipherMiniDetailsResponseModel(FullCipherAccess access, + CipherOrganizationDetailsWithCollections cipher, GlobalSettings globalSettings, bool orgUseTotp, + string obj = "cipherMiniDetails") + : base(cipher, globalSettings, orgUseTotp, obj, partial: false) + { + PopulateFullData(access, cipher); + } + + public FullCipherMiniDetailsResponseModel(FullCipherAccess access, + CipherOrganizationDetailsWithCollections cipher, GlobalSettings globalSettings, + string obj = "cipherMiniDetails") + : base(cipher, globalSettings, cipher.OrganizationUseTotp, obj, partial: false) + { + PopulateFullData(access, cipher); + } +} diff --git a/src/Api/Vault/Models/Response/SyncResponseModel.cs b/src/Api/Vault/Models/Response/SyncResponseModel.cs index 02203387d351..fb33a72b3b39 100644 --- a/src/Api/Vault/Models/Response/SyncResponseModel.cs +++ b/src/Api/Vault/Models/Response/SyncResponseModel.cs @@ -19,6 +19,7 @@ using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Settings; using Bit.Core.Tools.Entities; +using Bit.Core.Vault.Authorization; using Bit.Core.Vault.Entities; using Bit.Core.Vault.Models.Data; @@ -47,21 +48,22 @@ public SyncResponseModel( IEnumerable webAuthnCredentials, IEnumerable policiesNew = null, IEnumerable organizationUserDetailsNew = null, - ISet partialDataCipherIds = null) + FullCipherAccess fullCipherAccess = null) : this() { Profile = new ProfileResponseModel(user, userAccountKeysData, organizationUserDetails, providerUserDetails, providerUserOrganizationDetails, userTwoFactorEnabled, userHasPremiumFromOrganization, organizationIdsClaimingingUser, organizationUserDetailsNew); Folders = folders.Select(f => new FolderResponseModel(f)); + // A leasing-gated cipher (one the witness does not authorize) is delivered partial; when no + // witness is supplied every cipher falls back to the partial shape, keeping sync fail-closed. Ciphers = ciphers.Select(cipher => - new CipherDetailsResponseModel( - cipher, - user, - GetOrganizationAbility(cipher, organizationAbilities), - globalSettings, - collectionCiphersDict, - isPartial: partialDataCipherIds?.Contains(cipher.Id) ?? false)); + { + var organizationAbility = GetOrganizationAbility(cipher, organizationAbilities); + return fullCipherAccess is not null && fullCipherAccess.Authorizes(cipher.Id) + ? new FullCipherDetailsResponseModel(fullCipherAccess, cipher, user, organizationAbility, globalSettings, collectionCiphersDict) + : new CipherDetailsResponseModel(cipher, user, organizationAbility, globalSettings, collectionCiphersDict); + }); Collections = collections?.Select( c => new CollectionDetailsResponseModel(c)) ?? new List(); Domains = excludeDomains ? null : new DomainsResponseModel(user, false); diff --git a/src/Core/AssemblyInfo.cs b/src/Core/AssemblyInfo.cs index 66f5b58ef845..3efafdab91b0 100644 --- a/src/Core/AssemblyInfo.cs +++ b/src/Core/AssemblyInfo.cs @@ -1,4 +1,5 @@ using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("Core.Test")] +[assembly: InternalsVisibleTo("Api.Test")] [assembly: InternalsVisibleTo("Identity.IntegrationTest")] diff --git a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs index f66edc3cb273..ea3ab1b77be8 100644 --- a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs +++ b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs @@ -221,6 +221,8 @@ public static void AddPamServices(this IServiceCollection services) services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.TryAddSingleton(TimeProvider.System); + services.AddScoped(); } private static void AddOrganizationGroupCommands(this IServiceCollection services) diff --git a/src/Core/Pam/Services/CipherLeaseGate.cs b/src/Core/Pam/Services/CipherLeaseGate.cs new file mode 100644 index 000000000000..3082b4cb8d37 --- /dev/null +++ b/src/Core/Pam/Services/CipherLeaseGate.cs @@ -0,0 +1,201 @@ +using Bit.Core.Context; +using Bit.Core.Entities; +using Bit.Core.Exceptions; +using Bit.Core.Models.Data; +using Bit.Core.Pam.Engine; +using Bit.Core.Pam.Repositories; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Vault.Authorization; +using Bit.Core.Vault.Entities; + +namespace Bit.Core.Pam.Services; + +/// +public class CipherLeaseGate : ICipherLeaseGate +{ + private readonly IFeatureService _featureService; + private readonly IGoverningRuleResolver _resolver; + private readonly IAccessLeaseRepository _accessLeaseRepository; + private readonly ICollectionRepository _collectionRepository; + private readonly ICollectionCipherRepository _collectionCipherRepository; + private readonly ICurrentContext _currentContext; + private readonly TimeProvider _timeProvider; + + public CipherLeaseGate( + IFeatureService featureService, + IGoverningRuleResolver resolver, + IAccessLeaseRepository accessLeaseRepository, + ICollectionRepository collectionRepository, + ICollectionCipherRepository collectionCipherRepository, + ICurrentContext currentContext, + TimeProvider timeProvider) + { + _featureService = featureService; + _resolver = resolver; + _accessLeaseRepository = accessLeaseRepository; + _collectionRepository = collectionRepository; + _collectionCipherRepository = collectionCipherRepository; + _currentContext = currentContext; + _timeProvider = timeProvider; + } + + private bool Enabled => _featureService.IsEnabled(FeatureFlagKeys.Pam); + + public async Task AuthorizeReadAsync(Guid userId, Cipher cipher) + { + if (!Enabled) + { + return FullCipherAccess.Unrestricted(); + } + + return await IsBlockedAsync(userId, cipher.Id) + ? null + : FullCipherAccess.ForCipher(cipher.Id); + } + + public Task AuthorizeReadManyAsync( + Guid userId, + IEnumerable ciphers, + IEnumerable? collections, + IDictionary>? collectionCiphersByCipher) + { + if (!Enabled) + { + return Task.FromResult(FullCipherAccess.Unrestricted()); + } + + return Task.FromResult(BuildBulkWitness(ciphers, collections, collectionCiphersByCipher)); + } + + public async Task AuthorizeReadManyAsync(Guid userId, IEnumerable ciphers) + { + if (!Enabled) + { + return FullCipherAccess.Unrestricted(); + } + + var collections = await _collectionRepository.GetManyByUserIdAsync(userId); + var collectionCiphers = await _collectionCipherRepository.GetManyByUserIdAsync(userId); + var collectionCiphersByCipher = collectionCiphers.GroupBy(c => c.CipherId).ToDictionary(g => g.Key); + return BuildBulkWitness(ciphers, collections, collectionCiphersByCipher); + } + + // Bulk reads never release a gated cipher's secrets, regardless of lease state, so the witness + // authorizes only the non-gated subset and the gated ones fall through to the partial shape. + private FullCipherAccess BuildBulkWitness( + IEnumerable ciphers, + IEnumerable? collections, + IDictionary>? collectionCiphersByCipher) + { + var gated = GetGatedCipherIds(collections, collectionCiphersByCipher); + var authorized = ciphers.Select(c => c.Id).Where(id => !gated.Contains(id)); + return FullCipherAccess.ForCiphers(authorized); + } + + public ISet GetGatedCipherIds( + IEnumerable? collections, + IDictionary>? collectionCiphersByCipher) + { + var gated = new HashSet(); + if (!Enabled || collections == null || collectionCiphersByCipher == null) + { + return gated; + } + + var leasingCollectionIds = collections + .Where(c => c.AccessRuleId.HasValue) + .Select(c => c.Id) + .ToHashSet(); + if (leasingCollectionIds.Count == 0) + { + return gated; + } + + foreach (var (cipherId, collectionCiphers) in collectionCiphersByCipher) + { + if (collectionCiphers.Any() && collectionCiphers.All(cc => leasingCollectionIds.Contains(cc.CollectionId))) + { + gated.Add(cipherId); + } + } + + return gated; + } + + public async Task EnsureCanMutateAsync(Guid userId, Cipher cipher) + { + if (!Enabled) + { + return FullCipherAccess.Unrestricted(); + } + + if (await IsBlockedAsync(userId, cipher.Id)) + { + throw new NotFoundException(); + } + + return FullCipherAccess.ForCipher(cipher.Id); + } + + public async Task EnsureCanMutateManyAsync(Guid userId, IEnumerable ciphers) + { + if (!Enabled) + { + return FullCipherAccess.Unrestricted(); + } + + var cipherList = ciphers as ICollection ?? ciphers.ToList(); + if (cipherList.Count == 0) + { + return FullCipherAccess.ForCiphers([]); + } + + var now = _timeProvider.GetUtcNow().UtcDateTime; + var leasedCipherIds = (await _accessLeaseRepository.GetManyActiveByRequesterIdAsync(userId, now)) + .Select(l => l.CipherId) + .ToHashSet(); + var signals = AccessSignals.From(_currentContext, new DateTimeOffset(now, TimeSpan.Zero)); + + foreach (var cipher in cipherList) + { + if (leasedCipherIds.Contains(cipher.Id)) + { + // A valid lease overrides gating, so no resolve is needed. + continue; + } + + if (await _resolver.ResolveAsync(userId, cipher.Id, signals) is not null) + { + // Gated with no lease: refuse the whole batch, mirroring how the service hides + // inaccessible ciphers. + throw new NotFoundException(); + } + } + + return FullCipherAccess.ForCiphers(cipherList.Select(c => c.Id)); + } + + public FullCipherAccess Unrestricted() => + // The flag-off path is unrestricted anyway; gating only ever narrows access, never widens it. + FullCipherAccess.Unrestricted(); + + /// + /// True when the cipher is leasing-gated for the caller and they hold no valid active lease — the + /// shared "withhold full data" condition behind both read and mutation decisions. Resolves the + /// governing rule first so non-gated ciphers (the common case) cost no lease query. + /// + private async Task IsBlockedAsync(Guid userId, Guid cipherId) + { + var now = _timeProvider.GetUtcNow().UtcDateTime; + var signals = AccessSignals.From(_currentContext, new DateTimeOffset(now, TimeSpan.Zero)); + + if (await _resolver.ResolveAsync(userId, cipherId, signals) is null) + { + return false; + } + + var activeLease = await _accessLeaseRepository.GetActiveByRequesterIdCipherIdAsync(userId, cipherId, now); + return activeLease is null; + } +} diff --git a/src/Core/Pam/Services/ICipherLeaseGate.cs b/src/Core/Pam/Services/ICipherLeaseGate.cs new file mode 100644 index 000000000000..791c216a362d --- /dev/null +++ b/src/Core/Pam/Services/ICipherLeaseGate.cs @@ -0,0 +1,70 @@ +using Bit.Core.Entities; +using Bit.Core.Models.Data; +using Bit.Core.Vault.Authorization; +using Bit.Core.Vault.Entities; + +namespace Bit.Core.Pam.Services; + +/// +/// The single decision point for PAM credential leasing in Vault code. A cipher reachable only through +/// leasing-enabled collections is "leasing-gated": its secrets are withheld (partial data) unless the +/// caller holds a valid active lease, and mutating it is refused without one. Every method is a no-op / +/// "unrestricted" when the Pam feature flag is off, so flag-off behaviour matches main. +/// +public interface ICipherLeaseGate +{ + /// + /// Per-cipher read decision. Returns a witness authorizing full data + /// when the caller may see it (not gated, or gated with a valid active lease), or null when + /// the caller is blocked and must receive the partial shape. + /// + Task AuthorizeReadAsync(Guid userId, Cipher cipher); + + /// + /// Bulk read decision. Returns a single witness authorizing full data for the non-gated subset of + /// , computed in-memory from the supplied collections and mappings (no + /// per-cipher queries). Bulk reads strip every gated cipher regardless of lease state — + /// secrets are only ever released through . + /// + Task AuthorizeReadManyAsync( + Guid userId, + IEnumerable ciphers, + IEnumerable? collections, + IDictionary>? collectionCiphersByCipher); + + /// + /// Self-loading variant of + /// for callers (e.g. bulk write-returns) that have not already loaded the caller's collections and + /// mappings. Loads them once — but only when the flag is on, so the flag-off path stays query-free. + /// + Task AuthorizeReadManyAsync(Guid userId, IEnumerable ciphers); + + /// + /// The set of cipher ids reachable only through leasing-enabled collections (those carrying + /// a ). Computed in-memory, no queries. A cipher reachable + /// through any non-leasing collection — or personally owned with no mapping — is excluded. + /// + ISet GetGatedCipherIds( + IEnumerable? collections, + IDictionary>? collectionCiphersByCipher); + + /// + /// Throws when mutating + /// is refused (gated, no valid active lease); otherwise returns a witness authorizing the cipher so + /// the caller can build a full response for the mutated cipher without re-querying. + /// + Task EnsureCanMutateAsync(Guid userId, Cipher cipher); + + /// + /// Bulk variant of . Throws if any cipher is gated with no valid + /// active lease; otherwise returns a witness authorizing all of them. + /// + Task EnsureCanMutateManyAsync(Guid userId, IEnumerable ciphers); + + /// + /// Mints an unrestricted witness for a context that has already been authorized out-of-band — org + /// admins acting through org-wide permissions, personal vaults, and export/manage flows the + /// controller has already gated. Use deliberately; it authorizes full data for any cipher. + /// + FullCipherAccess Unrestricted(); +} diff --git a/src/Core/Vault/Authorization/FullCipherAccess.cs b/src/Core/Vault/Authorization/FullCipherAccess.cs new file mode 100644 index 000000000000..52a188847c04 --- /dev/null +++ b/src/Core/Vault/Authorization/FullCipherAccess.cs @@ -0,0 +1,51 @@ +namespace Bit.Core.Vault.Authorization; + +/// +/// A capability that authorizes returning a cipher's full secret data under PAM credential +/// leasing. It is minted only by the leasing gate (ICipherLeaseGate) — application code cannot +/// fabricate one — and is required by the constructors of the Full* cipher response models. This +/// makes emitting full secret data a deliberate, type-checked act: the default (partial) response +/// shapes need no witness, so a path that forgets to obtain one fails closed. +/// +public sealed class FullCipherAccess +{ + private readonly bool _unrestricted; + private readonly HashSet _authorizedCipherIds; + + private FullCipherAccess(bool unrestricted, HashSet? authorizedCipherIds) + { + _unrestricted = unrestricted; + _authorizedCipherIds = authorizedCipherIds ?? new HashSet(); + } + + /// + /// Authorizes full data for any cipher. Minted by the gate for contexts that have already been + /// authorized out-of-band (org admins, personal vaults, the flag-off no-op path). + /// + internal static FullCipherAccess Unrestricted() => new(unrestricted: true, authorizedCipherIds: null); + + /// Authorizes full data for exactly the given cipher. + internal static FullCipherAccess ForCipher(Guid cipherId) => new(unrestricted: false, [cipherId]); + + /// Authorizes full data for exactly the given set of ciphers. + internal static FullCipherAccess ForCiphers(IEnumerable cipherIds) => + new(unrestricted: false, cipherIds.ToHashSet()); + + /// Whether this witness authorizes full data for . + public bool Authorizes(Guid cipherId) => _unrestricted || _authorizedCipherIds.Contains(cipherId); + + /// + /// Throws when this witness does not authorize . Called by the + /// Full* response model constructors so a full response cannot be built for a cipher the + /// witness does not cover — keeping bulk lists fail-closed per element, not just at single reads. + /// + public void Require(Guid cipherId) + { + if (!Authorizes(cipherId)) + { + throw new InvalidOperationException( + "A full cipher response was constructed for a cipher the caller is not authorized to " + + "read in full. This indicates a credential-leasing filtering bug."); + } + } +} diff --git a/src/Core/Vault/Services/Implementations/CipherService.cs b/src/Core/Vault/Services/Implementations/CipherService.cs index 81738f9325d4..109190e90784 100644 --- a/src/Core/Vault/Services/Implementations/CipherService.cs +++ b/src/Core/Vault/Services/Implementations/CipherService.cs @@ -9,6 +9,7 @@ using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Data.Organizations; +using Bit.Core.Pam.Services; using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Services; @@ -43,6 +44,7 @@ public class CipherService : ICipherService private readonly IPolicyRequirementQuery _policyRequirementQuery; private readonly IApplicationCacheService _applicationCacheService; private readonly IPricingClient _pricingClient; + private readonly ICipherLeaseGate _cipherLeaseGate; public CipherService( ICipherRepository cipherRepository, @@ -60,7 +62,8 @@ public CipherService( IGetCipherPermissionsForUserQuery getCipherPermissionsForUserQuery, IPolicyRequirementQuery policyRequirementQuery, IApplicationCacheService applicationCacheService, - IPricingClient pricingClient) + IPricingClient pricingClient, + ICipherLeaseGate cipherLeaseGate) { _cipherRepository = cipherRepository; _folderRepository = folderRepository; @@ -78,6 +81,7 @@ public CipherService( _policyRequirementQuery = policyRequirementQuery; _applicationCacheService = applicationCacheService; _pricingClient = pricingClient; + _cipherLeaseGate = cipherLeaseGate; } public async Task SaveAsync(Cipher cipher, Guid savingUserId, DateTime? lastKnownRevisionDate, @@ -88,6 +92,13 @@ public async Task SaveAsync(Cipher cipher, Guid savingUserId, DateTime? lastKnow throw new BadRequestException("You do not have permissions to edit this."); } + // Editing an existing leasing-gated cipher requires a valid active lease. New ciphers have no + // collection paths yet, so they are never gated; admin/internal flows skip the lease gate too. + if (!skipPermissionCheck && cipher.Id != default(Guid)) + { + await _cipherLeaseGate.EnsureCanMutateAsync(savingUserId, cipher); + } + if (cipher.Id == default(Guid)) { if (cipher.OrganizationId.HasValue && collectionIds != null) @@ -128,6 +139,12 @@ public async Task SaveDetailsAsync(CipherDetails cipher, Guid savingUserId, Date throw new BadRequestException("You do not have permissions to edit this."); } + // Editing an existing leasing-gated cipher requires a valid active lease; new ciphers are never gated. + if (!skipPermissionCheck && cipher.Id != default(Guid)) + { + await _cipherLeaseGate.EnsureCanMutateAsync(savingUserId, cipher); + } + cipher.UserId = savingUserId; if (cipher.Id == default(Guid)) { @@ -424,6 +441,11 @@ public async Task DeleteAsync(CipherDetails cipherDetails, Guid deletingUserId, throw new BadRequestException("You do not have permissions to delete this."); } + if (!orgAdmin) + { + await _cipherLeaseGate.EnsureCanMutateAsync(deletingUserId, cipherDetails); + } + await _cipherRepository.DeleteAsync(cipherDetails); await _attachmentStorageService.DeleteAttachmentsForCipherAsync(cipherDetails.Id); await _eventService.LogCipherEventAsync(cipherDetails, EventType.Cipher_Deleted); @@ -448,6 +470,7 @@ public async Task DeleteManyAsync(IEnumerable cipherIds, Guid deletingUser var ciphers = await _cipherRepository.GetManyByUserIdAsync(deletingUserId); var filteredCiphers = await FilterCiphersByDeletePermission(ciphers, cipherIdsSet, deletingUserId); deletingCiphers = filteredCiphers.Select(c => (Cipher)c).ToList(); + await _cipherLeaseGate.EnsureCanMutateManyAsync(deletingUserId, deletingCiphers); await _cipherRepository.DeleteAsync(deletingCiphers.Select(c => c.Id), deletingUserId); } @@ -513,6 +536,10 @@ public async Task DeleteAttachmentsForOrganizationAsync(Guid organizationId) public async Task MoveManyAsync(IEnumerable cipherIds, Guid? destinationFolderId, Guid movingUserId) { + // Moving a leasing-gated cipher (changing its folder) requires a valid active lease. The gate only + // reads each cipher's id, so lightweight placeholders avoid an extra repository read. + await _cipherLeaseGate.EnsureCanMutateManyAsync(movingUserId, cipherIds.Select(id => new Cipher { Id = id })); + if (destinationFolderId.HasValue) { var folder = await _folderRepository.GetByIdAsync(destinationFolderId.Value); @@ -715,6 +742,7 @@ await _collectionCipherRepository.UpdateCollectionsForAdminAsync(cipher.Id, { throw new BadRequestException("You do not have permissions to edit this."); } + await _cipherLeaseGate.EnsureCanMutateAsync(savingUserId, cipher); await _collectionCipherRepository.UpdateCollectionsAsync(cipher.Id, savingUserId, collectionIds); } @@ -731,6 +759,11 @@ public async Task SoftDeleteAsync(CipherDetails cipherDetails, Guid deletingUser throw new BadRequestException("You do not have permissions to soft delete this."); } + if (!orgAdmin) + { + await _cipherLeaseGate.EnsureCanMutateAsync(deletingUserId, cipherDetails); + } + if (cipherDetails.DeletedDate.HasValue) { // Already soft-deleted, we can safely ignore this @@ -763,6 +796,7 @@ public async Task SoftDeleteManyAsync(IEnumerable cipherIds, Guid deleting var ciphers = await _cipherRepository.GetManyByUserIdAsync(deletingUserId); var filteredCiphers = await FilterCiphersByDeletePermission(ciphers, cipherIdsSet, deletingUserId); deletingCiphers = filteredCiphers.Select(c => (Cipher)c).ToList(); + await _cipherLeaseGate.EnsureCanMutateManyAsync(deletingUserId, deletingCiphers); await _cipherRepository.SoftDeleteAsync(deletingCiphers.Select(c => c.Id), deletingUserId); } @@ -786,6 +820,11 @@ public async Task RestoreAsync(CipherDetails cipherDetails, Guid restoringUserId throw new BadRequestException("You do not have permissions to delete this."); } + if (!orgAdmin) + { + await _cipherLeaseGate.EnsureCanMutateAsync(restoringUserId, cipherDetails); + } + if (!cipherDetails.DeletedDate.HasValue) { // Already restored, we can safely ignore this @@ -824,6 +863,7 @@ public async Task> RestoreManyAsync(IEnum var ciphers = await _cipherRepository.GetManyByUserIdAsync(restoringUserId); var filteredCiphers = await FilterCiphersByDeletePermission(ciphers, cipherIdsSet, restoringUserId); restoringCiphers = filteredCiphers.Select(c => (CipherOrganizationDetails)c).ToList(); + await _cipherLeaseGate.EnsureCanMutateManyAsync(restoringUserId, restoringCiphers); revisionDate = await _cipherRepository.RestoreAsync(restoringCiphers.Select(c => c.Id), restoringUserId); } @@ -933,6 +973,11 @@ public async Task ValidateCipherEditForAttachmentAsync(Cipher cipher, Guid savin throw new BadRequestException("You do not have permissions to edit this."); } + if (!orgAdmin) + { + await _cipherLeaseGate.EnsureCanMutateAsync(savingUserId, cipher); + } + if (requestLength < 1) { throw new BadRequestException("No data to attach."); diff --git a/test/Api.Test/Auth/Controllers/EmergencyAccessControllerTests.cs b/test/Api.Test/Auth/Controllers/EmergencyAccessControllerTests.cs index e5cacb3f163c..697858d0ec9c 100644 --- a/test/Api.Test/Auth/Controllers/EmergencyAccessControllerTests.cs +++ b/test/Api.Test/Auth/Controllers/EmergencyAccessControllerTests.cs @@ -23,6 +23,7 @@ namespace Bit.Api.Test.Auth.Controllers; [ControllerCustomize(typeof(EmergencyAccessController))] [SutProviderCustomize] +[Bit.Api.Test.Vault.AutoFixture.CipherLeaseGateBypassCustomize] public class EmergencyAccessControllerTests { [Theory, BitAutoData] diff --git a/test/Api.Test/Pam/Controllers/CipherLeaseControllerTests.cs b/test/Api.Test/Pam/Controllers/CipherLeaseControllerTests.cs index 75b6332b7146..ca4254f363c3 100644 --- a/test/Api.Test/Pam/Controllers/CipherLeaseControllerTests.cs +++ b/test/Api.Test/Pam/Controllers/CipherLeaseControllerTests.cs @@ -19,6 +19,7 @@ namespace Bit.Api.Test.Pam.Controllers; [ControllerCustomize(typeof(CipherLeaseController))] [SutProviderCustomize] +[Bit.Api.Test.Vault.AutoFixture.CipherLeaseGateBypassCustomize] public class CipherLeaseControllerTests { [Theory, BitAutoData] @@ -84,7 +85,7 @@ public async Task GetCipher_LeasedCipher_ReturnsFullData( var result = await sutProvider.Sut.GetCipher(id); - Assert.IsType(result); + Assert.IsAssignableFrom(result); Assert.Equal(id, result.Id); Assert.Equal("2.iv|ct|mac", result.Data); // full data present Assert.Null(result.PartialData); // isPartial == false diff --git a/test/Api.Test/Vault/AutoFixture/CipherLeaseGateBypassCustomization.cs b/test/Api.Test/Vault/AutoFixture/CipherLeaseGateBypassCustomization.cs new file mode 100644 index 000000000000..78f8299ae8c8 --- /dev/null +++ b/test/Api.Test/Vault/AutoFixture/CipherLeaseGateBypassCustomization.cs @@ -0,0 +1,42 @@ +using AutoFixture; +using Bit.Core.Entities; +using Bit.Core.Models.Data; +using Bit.Core.Pam.Services; +using Bit.Core.Vault.Authorization; +using Bit.Core.Vault.Entities; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; + +namespace Bit.Api.Test.Vault.AutoFixture; + +/// +/// Injects an substitute pre-configured to authorize full data for every +/// cipher — the flag-off / not-gated behaviour. This lets leasing-agnostic controller tests assert their +/// existing full-data expectations without each one having to stub the gate. Tests that exercise gating +/// re-stub the dependency (e.g. make AuthorizeReadAsync return null) after building the SUT. +/// +public class CipherLeaseGateBypassCustomization : ICustomization +{ + public void Customize(IFixture fixture) + { + var gate = Substitute.For(); + var unrestricted = FullCipherAccess.Unrestricted(); + + gate.Unrestricted().Returns(unrestricted); + gate.AuthorizeReadAsync(Arg.Any(), Arg.Any()).Returns(unrestricted); + gate.AuthorizeReadManyAsync(Arg.Any(), Arg.Any>()).Returns(unrestricted); + gate.AuthorizeReadManyAsync(Arg.Any(), Arg.Any>(), + Arg.Any>(), + Arg.Any>>()) + .Returns(unrestricted); + gate.EnsureCanMutateAsync(Arg.Any(), Arg.Any()).Returns(unrestricted); + gate.EnsureCanMutateManyAsync(Arg.Any(), Arg.Any>()).Returns(unrestricted); + + fixture.Inject(gate); + } +} + +public class CipherLeaseGateBypassCustomizeAttribute : BitCustomizeAttribute +{ + public override ICustomization GetCustomization() => new CipherLeaseGateBypassCustomization(); +} diff --git a/test/Api.Test/Vault/Controllers/CiphersControllerTests.cs b/test/Api.Test/Vault/Controllers/CiphersControllerTests.cs index 849036bec930..2141e6c49770 100644 --- a/test/Api.Test/Vault/Controllers/CiphersControllerTests.cs +++ b/test/Api.Test/Vault/Controllers/CiphersControllerTests.cs @@ -1,6 +1,7 @@ using System.Security.Claims; using System.Text.Json; using Bit.Api.Auth.Models.Request.Accounts; +using Bit.Api.Test.Vault.AutoFixture; using Bit.Api.Vault.Controllers; using Bit.Api.Vault.Models; using Bit.Api.Vault.Models.Request; @@ -30,6 +31,7 @@ namespace Bit.Api.Test.Controllers; [ControllerCustomize(typeof(CiphersController))] [SutProviderCustomize] +[CipherLeaseGateBypassCustomize] public class CiphersControllerTests { [Theory, BitAutoData] @@ -1177,7 +1179,7 @@ public async Task PutRestoreAdmin_WithOwnerOrAdmin_WithManagePermission_Restores var result = await sutProvider.Sut.PutRestoreAdmin(cipherOrgDetails.Id); - Assert.IsType(result); + Assert.IsAssignableFrom(result); await sutProvider.GetDependency().Received(1).RestoreAsync(Arg.Is( (cd) => cd.OrganizationId.Equals(cipherOrgDetails.OrganizationId)), userId, true); } @@ -1246,7 +1248,7 @@ public async Task PutRestoreAdmin_WithOwnerOrAdmin_WithAccessToUnassignedCipher_ var result = await sutProvider.Sut.PutRestoreAdmin(cipherOrgDetails.Id); - Assert.IsType(result); + Assert.IsAssignableFrom(result); await sutProvider.GetDependency().Received(1).RestoreAsync(Arg.Is( (cd) => cd.OrganizationId.Equals(cipherOrgDetails.OrganizationId)), userId, true); } @@ -1275,7 +1277,7 @@ public async Task PutRestoreAdmin_WithOwnerOrAdmin_WithAccessToAllCollectionItem var result = await sutProvider.Sut.PutRestoreAdmin(cipherOrgDetails.Id); - Assert.IsType(result); + Assert.IsAssignableFrom(result); await sutProvider.GetDependency().Received(1).RestoreAsync(Arg.Is( (cd) => cd.OrganizationId.Equals(cipherOrgDetails.OrganizationId)), userId, true); } @@ -1299,7 +1301,7 @@ public async Task PutRestoreAdmin_WithCustomUser_WithEditAnyCollectionTrue_Resto var result = await sutProvider.Sut.PutRestoreAdmin(cipherOrgDetails.Id); - Assert.IsType(result); + Assert.IsAssignableFrom(result); await sutProvider.GetDependency().Received(1).RestoreAsync(Arg.Is( (cd) => cd.OrganizationId.Equals(cipherOrgDetails.OrganizationId)), userId, true); } @@ -1339,7 +1341,7 @@ public async Task PutRestoreAdmin_WithOwnerOrAdmin_WithEditPermission_LimitItemD var result = await sutProvider.Sut.PutRestoreAdmin(cipherDetails.Id); - Assert.IsType(result); + Assert.IsAssignableFrom(result); await sutProvider.GetDependency().Received(1).RestoreAsync(Arg.Is( (cd) => cd.OrganizationId.Equals(cipherOrgDetails.OrganizationId)), userId, true); } diff --git a/test/Api.Test/Vault/Controllers/SyncControllerTests.cs b/test/Api.Test/Vault/Controllers/SyncControllerTests.cs index 1894b2e5dca8..ac807e173cea 100644 --- a/test/Api.Test/Vault/Controllers/SyncControllerTests.cs +++ b/test/Api.Test/Vault/Controllers/SyncControllerTests.cs @@ -19,11 +19,13 @@ using Bit.Core.Models.Data; using Bit.Core.Models.Data.Organizations; using Bit.Core.Models.Data.Organizations.OrganizationUsers; +using Bit.Core.Pam.Services; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Test.Billing.Mocks; using Bit.Core.Tools.Entities; using Bit.Core.Tools.Repositories; +using Bit.Core.Vault.Authorization; using Bit.Core.Vault.Entities; using Bit.Core.Vault.Enums; using Bit.Core.Vault.Models.Data; @@ -38,6 +40,7 @@ namespace Bit.Api.Test.Controllers; [ControllerCustomize(typeof(SyncController))] [SutProviderCustomize] +[Bit.Api.Test.Vault.AutoFixture.CipherLeaseGateBypassCustomize] public class SyncControllerTests { [Theory] @@ -724,6 +727,12 @@ public async Task Get_Leasing_FlagEnabled_CipherOnlyInLeasingCollection_ReturnsP collections: new List { new() { Id = leasingCollectionId, OrganizationId = orgId, AccessRuleId = Guid.NewGuid() } }, collectionCiphers: new List { new() { CipherId = cipherId, CollectionId = leasingCollectionId } }, pamFlagEnabled: true); + // The gate withholds this leasing-gated cipher: its witness authorizes nothing, so sync delivers partial data. + sutProvider.GetDependency() + .AuthorizeReadManyAsync(Arg.Any(), Arg.Any>(), + Arg.Any>(), + Arg.Any>>()) + .Returns(FullCipherAccess.ForCiphers(Array.Empty())); var result = await sutProvider.Sut.Get(); diff --git a/test/Api.Test/Vault/Models/Response/CipherLeaseFilterEnforcementTests.cs b/test/Api.Test/Vault/Models/Response/CipherLeaseFilterEnforcementTests.cs new file mode 100644 index 000000000000..b9e60dbabef0 --- /dev/null +++ b/test/Api.Test/Vault/Models/Response/CipherLeaseFilterEnforcementTests.cs @@ -0,0 +1,76 @@ +using System.Reflection; +using Bit.Api.Vault.Models.Response; +using Bit.Core.Vault.Authorization; +using Xunit; + +namespace Bit.Api.Test.Vault.Models.Response; + +/// +/// Architecture (fitness) tests for PAM credential-leasing filtering. The primary guarantee is enforced +/// by the type system: secret data on a cipher response can only be populated through a +/// witness that the leasing gate alone mints. These tests lock that +/// invariant in place so a future change that re-opens a public setter — or makes the witness publicly +/// mintable — fails CI rather than silently leaking secrets. +/// +public class CipherLeaseFilterEnforcementTests +{ + // The secret members carried by the cipher response models. None may be settable by external code + // (public setter / object initializer); they are populated only inside the witness-gated full path. + public static readonly TheoryData SecretProperties = new() + { + nameof(CipherMiniResponseModel.Data), + nameof(CipherMiniResponseModel.Name), + nameof(CipherMiniResponseModel.Notes), + nameof(CipherMiniResponseModel.Login), + nameof(CipherMiniResponseModel.Card), + nameof(CipherMiniResponseModel.Identity), + nameof(CipherMiniResponseModel.SecureNote), + nameof(CipherMiniResponseModel.SSHKey), + nameof(CipherMiniResponseModel.BankAccount), + nameof(CipherMiniResponseModel.DriversLicense), + nameof(CipherMiniResponseModel.Passport), + nameof(CipherMiniResponseModel.Fields), + nameof(CipherMiniResponseModel.PasswordHistory), + }; + + [Theory] + [MemberData(nameof(SecretProperties))] + public void SecretProperties_HaveNoPublicSetter(string propertyName) + { + var property = typeof(CipherMiniResponseModel).GetProperty(propertyName); + + Assert.NotNull(property); + var setter = property!.SetMethod; + Assert.True(setter is null || !setter.IsPublic, + $"{propertyName} must not have a public setter — secret data may only be populated through the " + + "witness-gated full-data path, never via a public constructor or object initializer."); + } + + [Fact] + public void FullCipherAccess_CannotBeMintedByPublicApi() + { + // The witness has no public constructor; only the leasing gate (in Bit.Core) mints one via the + // internal factory methods. This keeps emitting full secret data a deliberate, gate-mediated act. + var publicConstructors = typeof(FullCipherAccess) + .GetConstructors(BindingFlags.Public | BindingFlags.Instance); + + Assert.Empty(publicConstructors); + + var publicFactories = typeof(FullCipherAccess) + .GetMethods(BindingFlags.Public | BindingFlags.Static) + .Where(m => m.ReturnType == typeof(FullCipherAccess)); + + Assert.Empty(publicFactories); + } + + [Fact] + public void FullResponseTypes_DeriveFromTheirPartialCounterpart() + { + // The Full* types are the only producers of secret data and are siblings of the safe (partial) + // types, so a list of the base type holds a polymorphic mix without a separate wire contract. + Assert.True(typeof(CipherResponseModel).IsAssignableFrom(typeof(FullCipherResponseModel))); + Assert.True(typeof(CipherDetailsResponseModel).IsAssignableFrom(typeof(FullCipherDetailsResponseModel))); + Assert.True(typeof(CipherMiniResponseModel).IsAssignableFrom(typeof(FullCipherMiniResponseModel))); + Assert.True(typeof(CipherMiniDetailsResponseModel).IsAssignableFrom(typeof(FullCipherMiniDetailsResponseModel))); + } +} diff --git a/test/Api.Test/Vault/Models/Response/CipherResponseModelTests.cs b/test/Api.Test/Vault/Models/Response/CipherResponseModelTests.cs index 43898a161880..110a4207a03c 100644 --- a/test/Api.Test/Vault/Models/Response/CipherResponseModelTests.cs +++ b/test/Api.Test/Vault/Models/Response/CipherResponseModelTests.cs @@ -1,6 +1,7 @@ using System.Text.Json; using Bit.Api.Vault.Models.Response; using Bit.Core.Settings; +using Bit.Core.Vault.Authorization; using Bit.Core.Vault.Entities; using Bit.Core.Vault.Enums; using Bit.Core.Vault.Models.Data; @@ -46,7 +47,7 @@ public void Constructor_DriversLicense_DeserializesAllFields() CreationDate = DateTime.UtcNow, }; - var response = new CipherMiniResponseModel(cipher, _globalSettings, false); + var response = new FullCipherMiniResponseModel(FullCipherAccess.Unrestricted(), cipher, _globalSettings, false); Assert.Equal(CipherType.DriversLicense, response.Type); Assert.Equal("2.name|encrypted", response.Name); @@ -80,7 +81,7 @@ public void Constructor_DriversLicense_WithMinimalData_DeserializesSuccessfully( CreationDate = DateTime.UtcNow, }; - var response = new CipherMiniResponseModel(cipher, _globalSettings, false); + var response = new FullCipherMiniResponseModel(FullCipherAccess.Unrestricted(), cipher, _globalSettings, false); Assert.Equal(CipherType.DriversLicense, response.Type); Assert.NotNull(response.DriversLicense); @@ -118,7 +119,7 @@ public void Constructor_Passport_DeserializesAllFields() CreationDate = DateTime.UtcNow, }; - var response = new CipherMiniResponseModel(cipher, _globalSettings, false); + var response = new FullCipherMiniResponseModel(FullCipherAccess.Unrestricted(), cipher, _globalSettings, false); Assert.Equal(CipherType.Passport, response.Type); Assert.Equal("2.name|encrypted", response.Name); @@ -154,7 +155,7 @@ public void Constructor_Passport_WithMinimalData_DeserializesSuccessfully() CreationDate = DateTime.UtcNow, }; - var response = new CipherMiniResponseModel(cipher, _globalSettings, false); + var response = new FullCipherMiniResponseModel(FullCipherAccess.Unrestricted(), cipher, _globalSettings, false); Assert.Equal(CipherType.Passport, response.Type); Assert.NotNull(response.Passport); @@ -186,7 +187,7 @@ public void Constructor_DriversLicense_WithCustomFields_IncludesFields() CreationDate = DateTime.UtcNow, }; - var response = new CipherMiniResponseModel(cipher, _globalSettings, false); + var response = new FullCipherMiniResponseModel(FullCipherAccess.Unrestricted(), cipher, _globalSettings, false); Assert.NotNull(response.Fields); Assert.Single(response.Fields); @@ -215,7 +216,7 @@ public void Constructor_Passport_WithCustomFields_IncludesFields() CreationDate = DateTime.UtcNow, }; - var response = new CipherMiniResponseModel(cipher, _globalSettings, false); + var response = new FullCipherMiniResponseModel(FullCipherAccess.Unrestricted(), cipher, _globalSettings, false); Assert.NotNull(response.Fields); Assert.Single(response.Fields); @@ -241,7 +242,7 @@ public void Constructor_DriversLicense_PreservesRawDataField() CreationDate = DateTime.UtcNow, }; - var response = new CipherMiniResponseModel(cipher, _globalSettings, false); + var response = new FullCipherMiniResponseModel(FullCipherAccess.Unrestricted(), cipher, _globalSettings, false); Assert.Equal(serializedData, response.Data); } @@ -265,7 +266,7 @@ public void Constructor_Passport_PreservesRawDataField() CreationDate = DateTime.UtcNow, }; - var response = new CipherMiniResponseModel(cipher, _globalSettings, false); + var response = new FullCipherMiniResponseModel(FullCipherAccess.Unrestricted(), cipher, _globalSettings, false); Assert.Equal(serializedData, response.Data); } @@ -295,7 +296,7 @@ public void Constructor_Partial_Login_KeepsNameAndUrisAndStripsSecrets() CreationDate = DateTime.UtcNow, }; - var response = new CipherMiniResponseModel(cipher, _globalSettings, false, isPartial: true); + var response = new CipherMiniResponseModel(cipher, _globalSettings, false); // Full data is withheld; the reduced blob is returned only in the separate PartialData field. Assert.Null(response.Data); @@ -338,7 +339,7 @@ public void Constructor_Partial_NonLogin_KeepsOnlyName() CreationDate = DateTime.UtcNow, }; - var response = new CipherMiniResponseModel(cipher, _globalSettings, false, isPartial: true); + var response = new CipherMiniResponseModel(cipher, _globalSettings, false); Assert.Null(response.Data); Assert.NotNull(response.PartialData); @@ -364,7 +365,7 @@ public void Constructor_Partial_BlobEncryptedData_WithholdsData() CreationDate = DateTime.UtcNow, }; - var response = new CipherMiniResponseModel(cipher, _globalSettings, false, isPartial: true); + var response = new CipherMiniResponseModel(cipher, _globalSettings, false); // An opaque blob can't be reshaped, so neither full nor partial data is returned. Assert.Null(response.Data); @@ -393,7 +394,8 @@ public void Constructor_NotPartial_PreservesFullData() CreationDate = DateTime.UtcNow, }; - var response = new CipherMiniResponseModel(cipher, _globalSettings, false, isPartial: false); + // The full-data path requires a gate-minted witness; the default-named type is partial only. + var response = new FullCipherMiniResponseModel(FullCipherAccess.Unrestricted(), cipher, _globalSettings, false); Assert.Equal(serializedData, response.Data); Assert.Null(response.PartialData); @@ -422,7 +424,7 @@ public void Constructor_OpaqueData_DoesNotThrowAndSkipsLegacyFields(CipherType t CreationDate = DateTime.UtcNow, }; - var response = new CipherMiniResponseModel(cipher, _globalSettings, false); + var response = new FullCipherMiniResponseModel(FullCipherAccess.Unrestricted(), cipher, _globalSettings, false); Assert.Equal(type, response.Type); Assert.Equal(opaque, response.Data); diff --git a/test/Core.Test/Pam/Services/CipherLeaseGateTests.cs b/test/Core.Test/Pam/Services/CipherLeaseGateTests.cs new file mode 100644 index 000000000000..e2667b295b5d --- /dev/null +++ b/test/Core.Test/Pam/Services/CipherLeaseGateTests.cs @@ -0,0 +1,222 @@ +using Bit.Core.Entities; +using Bit.Core.Exceptions; +using Bit.Core.Models.Data; +using Bit.Core.Pam.Engine; +using Bit.Core.Pam.Entities; +using Bit.Core.Pam.Models; +using Bit.Core.Pam.Models.Conditions; +using Bit.Core.Pam.Repositories; +using Bit.Core.Pam.Services; +using Bit.Core.Services; +using Bit.Core.Vault.Entities; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Pam.Services; + +[SutProviderCustomize] +public class CipherLeaseGateTests +{ + private static void EnableFlag(SutProvider sutProvider) => + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.Pam).Returns(true); + + private static void Gated(SutProvider sutProvider, Guid userId, Guid cipherId) => + sutProvider.GetDependency() + .ResolveAsync(userId, cipherId, Arg.Any()) + .Returns(new GoverningRule(Guid.NewGuid(), Guid.NewGuid(), RequiresHumanApproval: false, + Array.Empty())); + + private static void HasActiveLease(SutProvider sutProvider, Guid userId, Guid cipherId) => + sutProvider.GetDependency() + .GetActiveByRequesterIdCipherIdAsync(userId, cipherId, Arg.Any()) + .Returns(new AccessLease { CipherId = cipherId }); + + // --- AuthorizeReadAsync ------------------------------------------------------------------------ + + [Theory, BitAutoData] + public async Task AuthorizeReadAsync_FlagOff_AuthorizesWithoutQuerying( + SutProvider sutProvider, Guid userId, Guid cipherId) + { + var access = await sutProvider.Sut.AuthorizeReadAsync(userId, new Cipher { Id = cipherId }); + + Assert.NotNull(access); + Assert.True(access!.Authorizes(cipherId)); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs().ResolveAsync(default, default, default!); + } + + [Theory, BitAutoData] + public async Task AuthorizeReadAsync_NotGated_Authorizes( + SutProvider sutProvider, Guid userId, Guid cipherId) + { + EnableFlag(sutProvider); + sutProvider.GetDependency() + .ResolveAsync(userId, cipherId, Arg.Any()) + .Returns((GoverningRule?)null); + + var access = await sutProvider.Sut.AuthorizeReadAsync(userId, new Cipher { Id = cipherId }); + + Assert.NotNull(access); + Assert.True(access!.Authorizes(cipherId)); + } + + [Theory, BitAutoData] + public async Task AuthorizeReadAsync_GatedNoLease_ReturnsNull( + SutProvider sutProvider, Guid userId, Guid cipherId) + { + EnableFlag(sutProvider); + Gated(sutProvider, userId, cipherId); + + var access = await sutProvider.Sut.AuthorizeReadAsync(userId, new Cipher { Id = cipherId }); + + Assert.Null(access); + } + + [Theory, BitAutoData] + public async Task AuthorizeReadAsync_GatedWithLease_Authorizes( + SutProvider sutProvider, Guid userId, Guid cipherId) + { + EnableFlag(sutProvider); + Gated(sutProvider, userId, cipherId); + HasActiveLease(sutProvider, userId, cipherId); + + var access = await sutProvider.Sut.AuthorizeReadAsync(userId, new Cipher { Id = cipherId }); + + Assert.NotNull(access); + Assert.True(access!.Authorizes(cipherId)); + } + + // --- GetGatedCipherIds ------------------------------------------------------------------------- + + [Theory, BitAutoData] + public void GetGatedCipherIds_FlagOff_Empty( + SutProvider sutProvider, Guid leasingCollectionId, Guid cipherId) + { + var collections = new[] { new CollectionDetails { Id = leasingCollectionId, AccessRuleId = Guid.NewGuid() } }; + var mappings = Group(new CollectionCipher { CipherId = cipherId, CollectionId = leasingCollectionId }); + + var gated = sutProvider.Sut.GetGatedCipherIds(collections, mappings); + + Assert.Empty(gated); + } + + [Theory, BitAutoData] + public void GetGatedCipherIds_ReachableOnlyThroughLeasingCollection_IsGated( + SutProvider sutProvider, Guid leasingCollectionId, Guid cipherId) + { + EnableFlag(sutProvider); + var collections = new[] { new CollectionDetails { Id = leasingCollectionId, AccessRuleId = Guid.NewGuid() } }; + var mappings = Group(new CollectionCipher { CipherId = cipherId, CollectionId = leasingCollectionId }); + + var gated = sutProvider.Sut.GetGatedCipherIds(collections, mappings); + + Assert.Contains(cipherId, gated); + } + + [Theory, BitAutoData] + public void GetGatedCipherIds_AlsoReachableThroughNonLeasingCollection_NotGated( + SutProvider sutProvider, Guid leasingCollectionId, Guid plainCollectionId, Guid cipherId) + { + EnableFlag(sutProvider); + var collections = new[] + { + new CollectionDetails { Id = leasingCollectionId, AccessRuleId = Guid.NewGuid() }, + new CollectionDetails { Id = plainCollectionId, AccessRuleId = null }, + }; + var mappings = Group( + new CollectionCipher { CipherId = cipherId, CollectionId = leasingCollectionId }, + new CollectionCipher { CipherId = cipherId, CollectionId = plainCollectionId }); + + var gated = sutProvider.Sut.GetGatedCipherIds(collections, mappings); + + Assert.DoesNotContain(cipherId, gated); + } + + // --- AuthorizeReadManyAsync -------------------------------------------------------------------- + + [Theory, BitAutoData] + public async Task AuthorizeReadManyAsync_AuthorizesNonGatedOnly( + SutProvider sutProvider, Guid userId, Guid leasingCollectionId, Guid gatedCipherId, Guid plainCipherId) + { + EnableFlag(sutProvider); + var collections = new[] { new CollectionDetails { Id = leasingCollectionId, AccessRuleId = Guid.NewGuid() } }; + var mappings = Group(new CollectionCipher { CipherId = gatedCipherId, CollectionId = leasingCollectionId }); + var ciphers = new[] { new Cipher { Id = gatedCipherId }, new Cipher { Id = plainCipherId } }; + + var access = await sutProvider.Sut.AuthorizeReadManyAsync(userId, ciphers, collections, mappings); + + Assert.False(access.Authorizes(gatedCipherId)); + Assert.True(access.Authorizes(plainCipherId)); + } + + // --- EnsureCanMutateAsync ---------------------------------------------------------------------- + + [Theory, BitAutoData] + public async Task EnsureCanMutateAsync_FlagOff_DoesNotThrow( + SutProvider sutProvider, Guid userId, Guid cipherId) + { + var access = await sutProvider.Sut.EnsureCanMutateAsync(userId, new Cipher { Id = cipherId }); + Assert.True(access.Authorizes(cipherId)); + } + + [Theory, BitAutoData] + public async Task EnsureCanMutateAsync_GatedNoLease_ThrowsNotFound( + SutProvider sutProvider, Guid userId, Guid cipherId) + { + EnableFlag(sutProvider); + Gated(sutProvider, userId, cipherId); + + await Assert.ThrowsAsync(() => + sutProvider.Sut.EnsureCanMutateAsync(userId, new Cipher { Id = cipherId })); + } + + [Theory, BitAutoData] + public async Task EnsureCanMutateAsync_GatedWithLease_DoesNotThrow( + SutProvider sutProvider, Guid userId, Guid cipherId) + { + EnableFlag(sutProvider); + Gated(sutProvider, userId, cipherId); + HasActiveLease(sutProvider, userId, cipherId); + + var access = await sutProvider.Sut.EnsureCanMutateAsync(userId, new Cipher { Id = cipherId }); + + Assert.True(access.Authorizes(cipherId)); + } + + // --- EnsureCanMutateManyAsync ------------------------------------------------------------------ + + [Theory, BitAutoData] + public async Task EnsureCanMutateManyAsync_LeasedCipher_SkipsResolve( + SutProvider sutProvider, Guid userId, Guid cipherId) + { + EnableFlag(sutProvider); + sutProvider.GetDependency() + .GetManyActiveByRequesterIdAsync(userId, Arg.Any()) + .Returns(new List { new() { CipherId = cipherId } }); + + await sutProvider.Sut.EnsureCanMutateManyAsync(userId, new[] { new Cipher { Id = cipherId } }); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs().ResolveAsync(default, default, default!); + } + + [Theory, BitAutoData] + public async Task EnsureCanMutateManyAsync_OneGatedNoLease_ThrowsNotFound( + SutProvider sutProvider, Guid userId, Guid gatedCipherId, Guid plainCipherId) + { + EnableFlag(sutProvider); + sutProvider.GetDependency() + .GetManyActiveByRequesterIdAsync(userId, Arg.Any()) + .Returns(new List()); + Gated(sutProvider, userId, gatedCipherId); + + await Assert.ThrowsAsync(() => + sutProvider.Sut.EnsureCanMutateManyAsync(userId, + new[] { new Cipher { Id = plainCipherId }, new Cipher { Id = gatedCipherId } })); + } + + private static IDictionary> Group(params CollectionCipher[] collectionCiphers) => + collectionCiphers.GroupBy(cc => cc.CipherId).ToDictionary(g => g.Key); +} diff --git a/test/Core.Test/Vault/Services/CipherServiceTests.cs b/test/Core.Test/Vault/Services/CipherServiceTests.cs index 00c4c4d8630f..37204ea0b7ee 100644 --- a/test/Core.Test/Vault/Services/CipherServiceTests.cs +++ b/test/Core.Test/Vault/Services/CipherServiceTests.cs @@ -11,6 +11,7 @@ using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Data.Organizations; +using Bit.Core.Pam.Services; using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Services; @@ -23,6 +24,7 @@ using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; +using NSubstitute.ExceptionExtensions; using Xunit; namespace Bit.Core.Test.Services; @@ -2486,4 +2488,45 @@ await sutProvider.GetDependency() .DeleteAttachmentsForCipherAsync(cipher.Id); } } + + [Theory, BitAutoData] + public async Task SaveDetailsAsync_ExistingLeasingGatedCipher_ThrowsAndDoesNotPersist( + SutProvider sutProvider, CipherDetails cipher) + { + // Personal cipher so the edit-permission check passes and we reach the lease gate. + var savingUserId = cipher.UserId!.Value; + sutProvider.GetDependency() + .EnsureCanMutateAsync(savingUserId, cipher) + .ThrowsAsync(new NotFoundException()); + + await Assert.ThrowsAsync(() => + sutProvider.Sut.SaveDetailsAsync(cipher, savingUserId, cipher.RevisionDate)); + + await sutProvider.GetDependency().DidNotReceive().ReplaceAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task SaveAsync_NewCipher_DoesNotInvokeLeaseGate( + SutProvider sutProvider, Cipher cipher) + { + // A new cipher has no collection paths yet, so it is never leasing-gated. + cipher.Id = default; + var savingUserId = cipher.UserId!.Value; + + await sutProvider.Sut.SaveAsync(cipher, savingUserId, null); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs().EnsureCanMutateAsync(default, default!); + } + + [Theory, BitAutoData] + public async Task DeleteAsync_OrgAdmin_DoesNotInvokeLeaseGate( + SutProvider sutProvider, CipherDetails cipher, Guid deletingUserId) + { + // Admins act through org-wide permissions with no collection-membership path, so the gate is skipped. + await sutProvider.Sut.DeleteAsync(cipher, deletingUserId, orgAdmin: true); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs().EnsureCanMutateAsync(default, default!); + } } From e131b0273fe97c28f12f1f5e1efac72ba5387bb4 Mon Sep 17 00:00:00 2001 From: Hinton Date: Thu, 18 Jun 2026 17:17:26 +0200 Subject: [PATCH 46/54] Extract ITableObject, IRepository, and comb-guid generation into a Data base project Introduce src/Data as the new base of the dependency graph (no project references). Relocate ITableObject and IRepository there, and move the sequential comb-guid generation into a new CombGuid helper. Namespaces are preserved (Bit.Core.Entities, Bit.Core.Repositories, Bit.Core.Utilities) so the ~1000 existing consumers are untouched; CoreHelpers.GenerateComb now delegates to CombGuid. Core gains its first project reference (-> Data). This is a self-contained, feature-agnostic refactor intended to be cherry-picked onto main ahead of dependent work. --- bitwarden-server.slnx | 1 + src/Core/Core.csproj | 4 ++ src/Core/Utilities/CoreHelpers.cs | 27 ++-------- src/Data/Data.csproj | 3 ++ src/{Core => Data}/Entities/ITableObject.cs | 0 .../Repositories/IRepository.cs | 0 src/Data/Utilities/CombGuid.cs | 54 +++++++++++++++++++ 7 files changed, 65 insertions(+), 24 deletions(-) create mode 100644 src/Data/Data.csproj rename src/{Core => Data}/Entities/ITableObject.cs (100%) rename src/{Core => Data}/Repositories/IRepository.cs (100%) create mode 100644 src/Data/Utilities/CombGuid.cs diff --git a/bitwarden-server.slnx b/bitwarden-server.slnx index 28f4406b31fc..a8f2665e638f 100644 --- a/bitwarden-server.slnx +++ b/bitwarden-server.slnx @@ -19,6 +19,7 @@ + diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj index 84d04239638c..20214c45cbd9 100644 --- a/src/Core/Core.csproj +++ b/src/Core/Core.csproj @@ -76,6 +76,10 @@ + + + + diff --git a/src/Core/Utilities/CoreHelpers.cs b/src/Core/Utilities/CoreHelpers.cs index 4c090b850e3f..1b931a44ee9b 100644 --- a/src/Core/Utilities/CoreHelpers.cs +++ b/src/Core/Utilities/CoreHelpers.cs @@ -45,11 +45,11 @@ public static class CoreHelpers /// information for sequential ordering. This should be preferred to for any database IDs. ///
/// - /// ref: https://github.com/nhibernate/nhibernate-core/blob/master/src/NHibernate/Id/GuidCombGenerator.cs + /// Delegates to , which is the shared implementation in the Data project. /// /// A comb Guid. public static Guid GenerateComb() - => GenerateComb(Guid.NewGuid(), DateTime.UtcNow); + => CombGuid.Generate(); /// /// Implementation of with input parameters to remove randomness. @@ -59,28 +59,7 @@ public static Guid GenerateComb() /// You probably don't want to use this method and instead want to use with no parameters /// internal static Guid GenerateComb(Guid startingGuid, DateTime time) - { - var guidArray = startingGuid.ToByteArray(); - - // Get the days and milliseconds which will be used to build the byte string - var days = new TimeSpan(time.Ticks - _baseDateTicks); - var msecs = time.TimeOfDay; - - // Convert to a byte array - // Note that SQL Server is accurate to 1/300th of a millisecond so we divide by 3.333333 - var daysArray = BitConverter.GetBytes(days.Days); - var msecsArray = BitConverter.GetBytes((long)(msecs.TotalMilliseconds / 3.333333)); - - // Reverse the bytes to match SQL Servers ordering - Array.Reverse(daysArray); - Array.Reverse(msecsArray); - - // Copy the bytes into the guid - Array.Copy(daysArray, daysArray.Length - 2, guidArray, guidArray.Length - 6, 2); - Array.Copy(msecsArray, msecsArray.Length - 4, guidArray, guidArray.Length - 4, 4); - - return new Guid(guidArray); - } + => CombGuid.Generate(startingGuid, time); internal static DateTime DateFromComb(Guid combGuid) { diff --git a/src/Data/Data.csproj b/src/Data/Data.csproj new file mode 100644 index 000000000000..61718a1f8749 --- /dev/null +++ b/src/Data/Data.csproj @@ -0,0 +1,3 @@ + + + diff --git a/src/Core/Entities/ITableObject.cs b/src/Data/Entities/ITableObject.cs similarity index 100% rename from src/Core/Entities/ITableObject.cs rename to src/Data/Entities/ITableObject.cs diff --git a/src/Core/Repositories/IRepository.cs b/src/Data/Repositories/IRepository.cs similarity index 100% rename from src/Core/Repositories/IRepository.cs rename to src/Data/Repositories/IRepository.cs diff --git a/src/Data/Utilities/CombGuid.cs b/src/Data/Utilities/CombGuid.cs new file mode 100644 index 000000000000..2393c66f19ae --- /dev/null +++ b/src/Data/Utilities/CombGuid.cs @@ -0,0 +1,54 @@ +#nullable enable + +namespace Bit.Core.Utilities; + +/// +/// Generates sequential ("comb") values for SQL Server. Embedding timestamp information makes +/// the IDs sort roughly in creation order, which prevents SQL Server index fragmentation. Prefer +/// over for any database IDs. +/// +/// +/// ref: https://github.com/nhibernate/nhibernate-core/blob/master/src/NHibernate/Id/GuidCombGenerator.cs +/// +public static class CombGuid +{ + private static readonly long _baseDateTicks = new DateTime(1900, 1, 1).Ticks; + + /// + /// Generate a sequential comb for the current instant. + /// + /// A comb Guid. + public static Guid Generate() + => Generate(Guid.NewGuid(), DateTime.UtcNow); + + /// + /// Implementation of with input parameters to remove randomness. + /// This should NOT be used outside of testing. + /// + /// + /// You probably don't want to use this method and instead want to use with no parameters + /// + public static Guid Generate(Guid startingGuid, DateTime time) + { + var guidArray = startingGuid.ToByteArray(); + + // Get the days and milliseconds which will be used to build the byte string + var days = new TimeSpan(time.Ticks - _baseDateTicks); + var msecs = time.TimeOfDay; + + // Convert to a byte array + // Note that SQL Server is accurate to 1/300th of a millisecond so we divide by 3.333333 + var daysArray = BitConverter.GetBytes(days.Days); + var msecsArray = BitConverter.GetBytes((long)(msecs.TotalMilliseconds / 3.333333)); + + // Reverse the bytes to match SQL Servers ordering + Array.Reverse(daysArray); + Array.Reverse(msecsArray); + + // Copy the bytes into the guid + Array.Copy(daysArray, daysArray.Length - 2, guidArray, guidArray.Length - 6, 2); + Array.Copy(msecsArray, msecsArray.Length - 4, guidArray, guidArray.Length - 4, 4); + + return new Guid(guidArray); + } +} From 2bb3290f1b2c933f0b75b2b396cfb938ac80b8d7 Mon Sep 17 00:00:00 2001 From: Hinton Date: Thu, 18 Jun 2026 17:23:21 +0200 Subject: [PATCH 47/54] PAM: rename Bit.Core.Pam.* namespaces to Bit.Pam.* Mechanical namespace rename ahead of extracting PAM into its own libraries. Files stay physically in place for now; only namespaces and using directives change. Two non-token fixes: CipherLeaseGate gains 'using Bit.Core;' (it relied on implicit Bit.Core parent-namespace resolution for FeatureFlagKeys, which the new Bit.Pam.* namespace no longer provides), and the EF AccessRule model's unprefixed Core.Pam.* references are fully qualified to Bit.Pam.*. --- .../Controllers/EmergencyAccessController.cs | 2 +- .../Controllers/AccessRequestsController.cs | 8 ++++---- .../Pam/Controllers/AccessRulesController.cs | 4 ++-- .../Pam/Controllers/CipherLeaseController.cs | 6 +++--- src/Api/Pam/Controllers/LeasesController.cs | 4 ++-- .../Request/AccessDecisionRequestModel.cs | 4 ++-- .../AccessLeaseExtensionRequestModel.cs | 2 +- .../Request/AccessRequestCreateRequestModel.cs | 2 +- .../Models/Request/AccessRuleRequestModel.cs | 2 +- .../Response/AccessLeaseResponseModel.cs | 6 +++--- .../Response/AccessPreCheckResponseModel.cs | 4 ++-- .../AccessRequestDecisionResponseModel.cs | 4 ++-- .../AccessRequestDetailsResponseModel.cs | 2 +- .../Response/AccessRequestResponseModel.cs | 4 ++-- .../AccessRequestResultResponseModel.cs | 4 ++-- .../Models/Response/AccessRuleResponseModel.cs | 2 +- .../Response/CipherAccessStateResponseModel.cs | 2 +- .../OrganizationExportController.cs | 2 +- src/Api/Vault/Controllers/CiphersController.cs | 2 +- src/Api/Vault/Controllers/SyncController.cs | 2 +- src/Core/AdminConsole/Entities/Collection.cs | 2 +- .../OrganizationServiceCollectionExtensions.cs | 12 ++++++------ src/Core/Pam/Engine/AccessEvaluation.cs | 2 +- src/Core/Pam/Engine/AccessRuleEngine.cs | 4 ++-- src/Core/Pam/Engine/AccessSignals.cs | 2 +- src/Core/Pam/Engine/IAccessRuleEngine.cs | 4 ++-- src/Core/Pam/Entities/AccessDecision.cs | 4 ++-- src/Core/Pam/Entities/AccessLease.cs | 4 ++-- src/Core/Pam/Entities/AccessRequest.cs | 4 ++-- src/Core/Pam/Entities/AccessRule.cs | 2 +- src/Core/Pam/Enums/AccessApprovalMode.cs | 2 +- src/Core/Pam/Enums/AccessConditionKind.cs | 2 +- src/Core/Pam/Enums/AccessDeciderKind.cs | 2 +- src/Core/Pam/Enums/AccessLeaseExtendOutcome.cs | 2 +- src/Core/Pam/Enums/AccessLeaseMintOutcome.cs | 2 +- src/Core/Pam/Enums/AccessLeaseStatus.cs | 2 +- src/Core/Pam/Enums/AccessRequestStatus.cs | 2 +- src/Core/Pam/Enums/AccessWeekday.cs | 4 ++-- src/Core/Pam/Models/AccessDeciderKindNames.cs | 4 ++-- .../Pam/Models/AccessDecisionSubmission.cs | 4 ++-- .../Models/AccessLeaseExtensionSubmission.cs | 2 +- src/Core/Pam/Models/AccessLeaseStatusNames.cs | 4 ++-- src/Core/Pam/Models/AccessPreCheckResult.cs | 4 ++-- src/Core/Pam/Models/AccessRequestDecision.cs | 4 ++-- src/Core/Pam/Models/AccessRequestDetails.cs | 4 ++-- src/Core/Pam/Models/AccessRequestResult.cs | 6 +++--- .../Pam/Models/AccessRequestStatusNames.cs | 4 ++-- src/Core/Pam/Models/AccessRequestSubmission.cs | 2 +- src/Core/Pam/Models/AccessRuleDetails.cs | 4 ++-- src/Core/Pam/Models/CipherAccessState.cs | 4 ++-- .../Pam/Models/Conditions/AccessCondition.cs | 2 +- .../Conditions/AccessWeekdayJsonConverter.cs | 4 ++-- .../Conditions/HumanApprovalCondition.cs | 2 +- .../Models/Conditions/IpAllowlistCondition.cs | 2 +- .../Models/Conditions/TimeOfDayCondition.cs | 4 ++-- src/Core/Pam/Models/GoverningRule.cs | 4 ++-- .../Commands/ActivateAccessRequestCommand.cs | 16 ++++++++-------- .../Commands/CancelAccessRequestCommand.cs | 14 +++++++------- .../Commands/CreateAccessRuleCommand.cs | 12 ++++++------ .../Commands/DecideAccessRequestCommand.cs | 14 +++++++------- .../Commands/DeleteAccessRuleCommand.cs | 6 +++--- .../IActivateAccessRequestCommand.cs | 4 ++-- .../Interfaces/ICancelAccessRequestCommand.cs | 2 +- .../Interfaces/ICreateAccessRuleCommand.cs | 6 +++--- .../Interfaces/IDecideAccessRequestCommand.cs | 4 ++-- .../Interfaces/IDeleteAccessRuleCommand.cs | 2 +- .../IRequestLeaseExtensionCommand.cs | 4 ++-- .../Interfaces/IRevokeAccessLeaseCommand.cs | 2 +- .../Interfaces/ISubmitAccessRequestCommand.cs | 4 ++-- .../Interfaces/IUpdateAccessRuleCommand.cs | 6 +++--- .../Commands/RequestLeaseExtensionCommand.cs | 18 +++++++++--------- .../Commands/RevokeAccessLeaseCommand.cs | 12 ++++++------ .../Commands/SubmitAccessRequestCommand.cs | 16 ++++++++-------- .../Commands/UpdateAccessRuleCommand.cs | 12 ++++++------ .../Queries/AccessPreCheckQuery.cs | 16 ++++++++-------- .../Queries/GetCipherAccessStateQuery.cs | 14 +++++++------- .../Queries/GetLeasedCipherQuery.cs | 10 +++++----- .../Queries/Interfaces/IAccessPreCheckQuery.cs | 4 ++-- .../Interfaces/IGetCipherAccessStateQuery.cs | 4 ++-- .../Interfaces/IGetLeasedCipherQuery.cs | 2 +- .../Interfaces/IListActiveLeasesQuery.cs | 4 ++-- .../Interfaces/IListInboxHistoryQuery.cs | 4 ++-- .../Interfaces/IListInboxRequestsQuery.cs | 4 ++-- .../Interfaces/IListLeaseHistoryQuery.cs | 4 ++-- .../Interfaces/IListMyAccessRequestsQuery.cs | 4 ++-- .../IListMyActiveAccessLeasesQuery.cs | 4 ++-- .../Queries/ListActiveLeasesQuery.cs | 10 +++++----- .../Queries/ListInboxHistoryQuery.cs | 10 +++++----- .../Queries/ListInboxRequestsQuery.cs | 10 +++++----- .../Queries/ListLeaseHistoryQuery.cs | 10 +++++----- .../Queries/ListMyAccessRequestsQuery.cs | 8 ++++---- .../Queries/ListMyActiveAccessLeasesQuery.cs | 8 ++++---- .../Pam/Repositories/IAccessLeaseRepository.cs | 6 +++--- .../Repositories/IAccessRequestRepository.cs | 8 ++++---- .../Pam/Repositories/IAccessRuleRepository.cs | 8 ++++---- src/Core/Pam/Services/AccessRuleValidator.cs | 4 ++-- .../Services/ApproverCollectionAccessQuery.cs | 2 +- src/Core/Pam/Services/ApproverInboxNotifier.cs | 2 +- src/Core/Pam/Services/CipherLeaseGate.cs | 9 +++++---- src/Core/Pam/Services/GoverningRuleResolver.cs | 10 +++++----- src/Core/Pam/Services/IAccessRuleValidator.cs | 2 +- .../Services/IApproverCollectionAccessQuery.cs | 2 +- .../Pam/Services/IApproverInboxNotifier.cs | 2 +- src/Core/Pam/Services/ICipherLeaseGate.cs | 2 +- .../Pam/Services/IGoverningRuleResolver.cs | 6 +++--- src/Core/Pam/Services/IRequesterNotifier.cs | 2 +- .../Services/ISingleActiveLeaseEvaluator.cs | 2 +- src/Core/Pam/Services/RequesterNotifier.cs | 4 ++-- .../Pam/Services/SingleActiveLeaseEvaluator.cs | 6 +++--- .../Services/Implementations/CipherService.cs | 2 +- .../DapperServiceCollectionExtensions.cs | 2 +- .../Pam/Repositories/AccessLeaseRepository.cs | 6 +++--- .../Repositories/AccessRequestRepository.cs | 8 ++++---- .../Pam/Repositories/AccessRuleRepository.cs | 6 +++--- ...tityFrameworkServiceCollectionExtensions.cs | 2 +- .../Pam/Models/AccessRule.cs | 6 +++--- .../Pam/Repositories/AccessRuleRepository.cs | 6 +++--- .../AccessRequestsControllerTests.cs | 10 +++++----- .../Controllers/CipherLeaseControllerTests.cs | 6 +++--- .../Pam/Controllers/LeasesControllerTests.cs | 10 +++++----- .../Models/AccessDecisionRequestModelTests.cs | 2 +- .../Models/AccessLeaseResponseModelTests.cs | 6 +++--- .../AccessRequestDetailsResponseModelTests.cs | 6 +++--- .../CipherLeaseGateBypassCustomization.cs | 2 +- .../Vault/Controllers/SyncControllerTests.cs | 2 +- .../ActivateAccessRequestCommandTests.cs | 12 ++++++------ .../CancelAccessRequestCommandTests.cs | 12 ++++++------ .../Commands/CreateAccessRuleCommandTests.cs | 8 ++++---- .../DecideAccessRequestCommandTests.cs | 12 ++++++------ .../Commands/DeleteAccessRuleCommandTests.cs | 6 +++--- .../RequestLeaseExtensionCommandTests.cs | 16 ++++++++-------- .../Commands/RevokeAccessLeaseCommandTests.cs | 10 +++++----- .../SubmitAccessRequestCommandTests.cs | 16 ++++++++-------- .../Commands/UpdateAccessRuleCommandTests.cs | 10 +++++----- .../Pam/Engine/AccessRuleEngineTests.cs | 6 +++--- .../Pam/Models/AccessLeaseStatusNamesTests.cs | 4 ++-- .../Models/AccessRequestStatusNamesTests.cs | 4 ++-- .../AccessWeekdayJsonConverterTests.cs | 2 +- .../Pam/Queries/AccessPreCheckQueryTests.cs | 16 ++++++++-------- .../Queries/GetCipherAccessStateQueryTests.cs | 16 ++++++++-------- .../Pam/Queries/GetLeasedCipherQueryTests.cs | 16 ++++++++-------- .../Pam/Queries/ListActiveLeasesQueryTests.cs | 8 ++++---- .../Pam/Queries/ListInboxHistoryQueryTests.cs | 8 ++++---- .../Pam/Queries/ListInboxRequestsQueryTests.cs | 8 ++++---- .../Pam/Queries/ListLeaseHistoryQueryTests.cs | 8 ++++---- .../Queries/ListMyAccessRequestsQueryTests.cs | 6 +++--- .../ListMyActiveAccessLeasesQueryTests.cs | 6 +++--- .../Pam/Services/AccessRuleValidatorTests.cs | 2 +- .../ApproverCollectionAccessQueryTests.cs | 2 +- .../Pam/Services/ApproverInboxNotifierTests.cs | 4 ++-- .../Pam/Services/CipherLeaseGateTests.cs | 12 ++++++------ .../Pam/Services/GoverningRuleResolverTests.cs | 10 +++++----- .../Pam/Services/RequesterNotifierTests.cs | 4 ++-- .../SingleActiveLeaseEvaluatorTests.cs | 6 +++--- .../Vault/Services/CipherServiceTests.cs | 2 +- .../Repositories/AccessLeaseRepositoryTests.cs | 8 ++++---- .../AccessRequestExtensionRepositoryTests.cs | 8 ++++---- .../AccessRequestRepositoryTests.cs | 8 ++++---- .../Repositories/AccessRuleRepositoryTests.cs | 4 ++-- 159 files changed, 457 insertions(+), 456 deletions(-) diff --git a/src/Api/Auth/Controllers/EmergencyAccessController.cs b/src/Api/Auth/Controllers/EmergencyAccessController.cs index 970419a395aa..e5dd6013762f 100644 --- a/src/Api/Auth/Controllers/EmergencyAccessController.cs +++ b/src/Api/Auth/Controllers/EmergencyAccessController.cs @@ -9,10 +9,10 @@ using Bit.Api.Vault.Models.Response; using Bit.Core.Auth.UserFeatures.EmergencyAccess; using Bit.Core.Exceptions; -using Bit.Core.Pam.Services; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; +using Bit.Pam.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; diff --git a/src/Api/Pam/Controllers/AccessRequestsController.cs b/src/Api/Pam/Controllers/AccessRequestsController.cs index cda269a00a56..03f24786c597 100644 --- a/src/Api/Pam/Controllers/AccessRequestsController.cs +++ b/src/Api/Pam/Controllers/AccessRequestsController.cs @@ -1,11 +1,11 @@ -using Bit.Api.Models.Response; +using Bit.Api.Models.Response; using Bit.Api.Pam.Models.Request; using Bit.Api.Pam.Models.Response; using Bit.Core; -using Bit.Core.Pam.OrganizationFeatures.Commands.Interfaces; -using Bit.Core.Pam.OrganizationFeatures.Queries.Interfaces; using Bit.Core.Services; using Bit.Core.Utilities; +using Bit.Pam.OrganizationFeatures.Commands.Interfaces; +using Bit.Pam.OrganizationFeatures.Queries.Interfaces; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -107,4 +107,4 @@ public async Task Revoke(Guid id) await cancelAccessRequestCommand.CancelAsync(userId, id); return NoContent(); } -} \ No newline at end of file +} diff --git a/src/Api/Pam/Controllers/AccessRulesController.cs b/src/Api/Pam/Controllers/AccessRulesController.cs index 0a97e94a4fcf..5fbc501a653d 100644 --- a/src/Api/Pam/Controllers/AccessRulesController.cs +++ b/src/Api/Pam/Controllers/AccessRulesController.cs @@ -4,9 +4,9 @@ using Bit.Core; using Bit.Core.Context; using Bit.Core.Exceptions; -using Bit.Core.Pam.OrganizationFeatures.Commands.Interfaces; -using Bit.Core.Pam.Repositories; using Bit.Core.Utilities; +using Bit.Pam.OrganizationFeatures.Commands.Interfaces; +using Bit.Pam.Repositories; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; diff --git a/src/Api/Pam/Controllers/CipherLeaseController.cs b/src/Api/Pam/Controllers/CipherLeaseController.cs index ffa9089e082b..3b6f81721fe4 100644 --- a/src/Api/Pam/Controllers/CipherLeaseController.cs +++ b/src/Api/Pam/Controllers/CipherLeaseController.cs @@ -3,13 +3,13 @@ using Bit.Api.Vault.Models.Response; using Bit.Core; using Bit.Core.Exceptions; -using Bit.Core.Pam.OrganizationFeatures.Commands.Interfaces; -using Bit.Core.Pam.OrganizationFeatures.Queries.Interfaces; -using Bit.Core.Pam.Services; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; using Bit.Core.Utilities; +using Bit.Pam.OrganizationFeatures.Commands.Interfaces; +using Bit.Pam.OrganizationFeatures.Queries.Interfaces; +using Bit.Pam.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; diff --git a/src/Api/Pam/Controllers/LeasesController.cs b/src/Api/Pam/Controllers/LeasesController.cs index 44b6914530df..0a093c87053e 100644 --- a/src/Api/Pam/Controllers/LeasesController.cs +++ b/src/Api/Pam/Controllers/LeasesController.cs @@ -2,10 +2,10 @@ using Bit.Api.Pam.Models.Request; using Bit.Api.Pam.Models.Response; using Bit.Core; -using Bit.Core.Pam.OrganizationFeatures.Commands.Interfaces; -using Bit.Core.Pam.OrganizationFeatures.Queries.Interfaces; using Bit.Core.Services; using Bit.Core.Utilities; +using Bit.Pam.OrganizationFeatures.Commands.Interfaces; +using Bit.Pam.OrganizationFeatures.Queries.Interfaces; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; diff --git a/src/Api/Pam/Models/Request/AccessDecisionRequestModel.cs b/src/Api/Pam/Models/Request/AccessDecisionRequestModel.cs index fc457dce0ee0..6163ff31bacd 100644 --- a/src/Api/Pam/Models/Request/AccessDecisionRequestModel.cs +++ b/src/Api/Pam/Models/Request/AccessDecisionRequestModel.cs @@ -1,6 +1,6 @@ using System.ComponentModel.DataAnnotations; -using Bit.Core.Pam.Enums; -using Bit.Core.Pam.Models; +using Bit.Pam.Enums; +using Bit.Pam.Models; namespace Bit.Api.Pam.Models.Request; diff --git a/src/Api/Pam/Models/Request/AccessLeaseExtensionRequestModel.cs b/src/Api/Pam/Models/Request/AccessLeaseExtensionRequestModel.cs index a26e063b5428..0eb669e063aa 100644 --- a/src/Api/Pam/Models/Request/AccessLeaseExtensionRequestModel.cs +++ b/src/Api/Pam/Models/Request/AccessLeaseExtensionRequestModel.cs @@ -1,4 +1,4 @@ -using Bit.Core.Pam.Models; +using Bit.Pam.Models; namespace Bit.Api.Pam.Models.Request; diff --git a/src/Api/Pam/Models/Request/AccessRequestCreateRequestModel.cs b/src/Api/Pam/Models/Request/AccessRequestCreateRequestModel.cs index 631a4f3149ea..81fa81f3e2b6 100644 --- a/src/Api/Pam/Models/Request/AccessRequestCreateRequestModel.cs +++ b/src/Api/Pam/Models/Request/AccessRequestCreateRequestModel.cs @@ -1,4 +1,4 @@ -using Bit.Core.Pam.Models; +using Bit.Pam.Models; namespace Bit.Api.Pam.Models.Request; diff --git a/src/Api/Pam/Models/Request/AccessRuleRequestModel.cs b/src/Api/Pam/Models/Request/AccessRuleRequestModel.cs index 91ed87d40a81..4826b900f1a2 100644 --- a/src/Api/Pam/Models/Request/AccessRuleRequestModel.cs +++ b/src/Api/Pam/Models/Request/AccessRuleRequestModel.cs @@ -1,6 +1,6 @@ using System.ComponentModel.DataAnnotations; using System.Text.Json; -using Bit.Core.Pam.Entities; +using Bit.Pam.Entities; namespace Bit.Api.Pam.Models.Request; diff --git a/src/Api/Pam/Models/Response/AccessLeaseResponseModel.cs b/src/Api/Pam/Models/Response/AccessLeaseResponseModel.cs index 491a0132f1c7..967d20d80caf 100644 --- a/src/Api/Pam/Models/Response/AccessLeaseResponseModel.cs +++ b/src/Api/Pam/Models/Response/AccessLeaseResponseModel.cs @@ -1,6 +1,6 @@ -using Bit.Core.Models.Api; -using Bit.Core.Pam.Entities; -using Bit.Core.Pam.Models; +using Bit.Core.Models.Api; +using Bit.Pam.Entities; +using Bit.Pam.Models; namespace Bit.Api.Pam.Models.Response; diff --git a/src/Api/Pam/Models/Response/AccessPreCheckResponseModel.cs b/src/Api/Pam/Models/Response/AccessPreCheckResponseModel.cs index cb8a15fd3bca..1ffd0c297e29 100644 --- a/src/Api/Pam/Models/Response/AccessPreCheckResponseModel.cs +++ b/src/Api/Pam/Models/Response/AccessPreCheckResponseModel.cs @@ -1,6 +1,6 @@ using Bit.Core.Models.Api; -using Bit.Core.Pam.Enums; -using Bit.Core.Pam.Models; +using Bit.Pam.Enums; +using Bit.Pam.Models; namespace Bit.Api.Pam.Models.Response; diff --git a/src/Api/Pam/Models/Response/AccessRequestDecisionResponseModel.cs b/src/Api/Pam/Models/Response/AccessRequestDecisionResponseModel.cs index 60d5d5910e71..2c1f137cfd1c 100644 --- a/src/Api/Pam/Models/Response/AccessRequestDecisionResponseModel.cs +++ b/src/Api/Pam/Models/Response/AccessRequestDecisionResponseModel.cs @@ -1,5 +1,5 @@ -using Bit.Core.Pam.Enums; -using Bit.Core.Pam.Models; +using Bit.Pam.Enums; +using Bit.Pam.Models; namespace Bit.Api.Pam.Models.Response; diff --git a/src/Api/Pam/Models/Response/AccessRequestDetailsResponseModel.cs b/src/Api/Pam/Models/Response/AccessRequestDetailsResponseModel.cs index 25c300d54ee9..37c972f77571 100644 --- a/src/Api/Pam/Models/Response/AccessRequestDetailsResponseModel.cs +++ b/src/Api/Pam/Models/Response/AccessRequestDetailsResponseModel.cs @@ -1,5 +1,5 @@ using Bit.Core.Models.Api; -using Bit.Core.Pam.Models; +using Bit.Pam.Models; namespace Bit.Api.Pam.Models.Response; diff --git a/src/Api/Pam/Models/Response/AccessRequestResponseModel.cs b/src/Api/Pam/Models/Response/AccessRequestResponseModel.cs index 41bbd002e532..2eb153f20b5d 100644 --- a/src/Api/Pam/Models/Response/AccessRequestResponseModel.cs +++ b/src/Api/Pam/Models/Response/AccessRequestResponseModel.cs @@ -1,6 +1,6 @@ using Bit.Core.Models.Api; -using Bit.Core.Pam.Entities; -using Bit.Core.Pam.Models; +using Bit.Pam.Entities; +using Bit.Pam.Models; namespace Bit.Api.Pam.Models.Response; diff --git a/src/Api/Pam/Models/Response/AccessRequestResultResponseModel.cs b/src/Api/Pam/Models/Response/AccessRequestResultResponseModel.cs index b8137c1a98a4..ce7a56190f35 100644 --- a/src/Api/Pam/Models/Response/AccessRequestResultResponseModel.cs +++ b/src/Api/Pam/Models/Response/AccessRequestResultResponseModel.cs @@ -1,6 +1,6 @@ using Bit.Core.Models.Api; -using Bit.Core.Pam.Enums; -using Bit.Core.Pam.Models; +using Bit.Pam.Enums; +using Bit.Pam.Models; namespace Bit.Api.Pam.Models.Response; diff --git a/src/Api/Pam/Models/Response/AccessRuleResponseModel.cs b/src/Api/Pam/Models/Response/AccessRuleResponseModel.cs index f5e50bb3e2a8..f34796de2020 100644 --- a/src/Api/Pam/Models/Response/AccessRuleResponseModel.cs +++ b/src/Api/Pam/Models/Response/AccessRuleResponseModel.cs @@ -1,6 +1,6 @@ using System.Text.Json; using Bit.Core.Models.Api; -using Bit.Core.Pam.Models; +using Bit.Pam.Models; namespace Bit.Api.Pam.Models.Response; diff --git a/src/Api/Pam/Models/Response/CipherAccessStateResponseModel.cs b/src/Api/Pam/Models/Response/CipherAccessStateResponseModel.cs index bf66ca6d7927..ce66fed160ec 100644 --- a/src/Api/Pam/Models/Response/CipherAccessStateResponseModel.cs +++ b/src/Api/Pam/Models/Response/CipherAccessStateResponseModel.cs @@ -1,5 +1,5 @@ using Bit.Core.Models.Api; -using Bit.Core.Pam.Models; +using Bit.Pam.Models; namespace Bit.Api.Pam.Models.Response; diff --git a/src/Api/Tools/Controllers/OrganizationExportController.cs b/src/Api/Tools/Controllers/OrganizationExportController.cs index 7728b3256987..944a2e9f63b2 100644 --- a/src/Api/Tools/Controllers/OrganizationExportController.cs +++ b/src/Api/Tools/Controllers/OrganizationExportController.cs @@ -2,11 +2,11 @@ using Bit.Api.Tools.Models.Response; using Bit.Core.AdminConsole.OrganizationFeatures.Shared.Authorization; using Bit.Core.Exceptions; -using Bit.Core.Pam.Services; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; using Bit.Core.Vault.Queries; +using Bit.Pam.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; diff --git a/src/Api/Vault/Controllers/CiphersController.cs b/src/Api/Vault/Controllers/CiphersController.cs index 525387e56eeb..64a7dc19789c 100644 --- a/src/Api/Vault/Controllers/CiphersController.cs +++ b/src/Api/Vault/Controllers/CiphersController.cs @@ -16,7 +16,6 @@ using Bit.Core.Exceptions; using Bit.Core.Models.Data; using Bit.Core.Models.Data.Organizations; -using Bit.Core.Pam.Services; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; @@ -29,6 +28,7 @@ using Bit.Core.Vault.Queries; using Bit.Core.Vault.Repositories; using Bit.Core.Vault.Services; +using Bit.Pam.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; diff --git a/src/Api/Vault/Controllers/SyncController.cs b/src/Api/Vault/Controllers/SyncController.cs index 9d8b21d1cfc8..b113265cfb7b 100644 --- a/src/Api/Vault/Controllers/SyncController.cs +++ b/src/Api/Vault/Controllers/SyncController.cs @@ -17,13 +17,13 @@ using Bit.Core.Models.Data; using Bit.Core.Models.Data.Organizations; using Bit.Core.Models.Data.Organizations.OrganizationUsers; -using Bit.Core.Pam.Services; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; using Bit.Core.Tools.Repositories; using Bit.Core.Vault.Models.Data; using Bit.Core.Vault.Repositories; +using Bit.Pam.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; diff --git a/src/Core/AdminConsole/Entities/Collection.cs b/src/Core/AdminConsole/Entities/Collection.cs index b9a1829ab21a..688e3fac9f37 100644 --- a/src/Core/AdminConsole/Entities/Collection.cs +++ b/src/Core/AdminConsole/Entities/Collection.cs @@ -47,7 +47,7 @@ public class Collection : ITableObject /// public string? DefaultUserCollectionEmail { get; set; } /// - /// Reference to a that gates + /// Reference to a that gates /// PAM credential leasing for this collection. Null means leasing is disabled for the collection. /// public Guid? AccessRuleId { get; set; } diff --git a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs index ea3ab1b77be8..65828dc996cc 100644 --- a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs +++ b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs @@ -40,15 +40,15 @@ using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; using Bit.Core.OrganizationFeatures.OrganizationUsers; using Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces; -using Bit.Core.Pam.Engine; -using Bit.Core.Pam.OrganizationFeatures.Commands; -using Bit.Core.Pam.OrganizationFeatures.Commands.Interfaces; -using Bit.Core.Pam.OrganizationFeatures.Queries; -using Bit.Core.Pam.OrganizationFeatures.Queries.Interfaces; -using Bit.Core.Pam.Services; using Bit.Core.Services; using Bit.Core.Settings; using Bit.Core.Tokens; +using Bit.Pam.Engine; +using Bit.Pam.OrganizationFeatures.Commands; +using Bit.Pam.OrganizationFeatures.Commands.Interfaces; +using Bit.Pam.OrganizationFeatures.Queries; +using Bit.Pam.OrganizationFeatures.Queries.Interfaces; +using Bit.Pam.Services; using Core.AdminConsole.OrganizationFeatures.OrganizationUsers; using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Microsoft.AspNetCore.Authorization; diff --git a/src/Core/Pam/Engine/AccessEvaluation.cs b/src/Core/Pam/Engine/AccessEvaluation.cs index b613da14e032..fefba5b0bcbe 100644 --- a/src/Core/Pam/Engine/AccessEvaluation.cs +++ b/src/Core/Pam/Engine/AccessEvaluation.cs @@ -1,4 +1,4 @@ -namespace Bit.Core.Pam.Engine; +namespace Bit.Pam.Engine; public enum AccessEvaluationOutcome { diff --git a/src/Core/Pam/Engine/AccessRuleEngine.cs b/src/Core/Pam/Engine/AccessRuleEngine.cs index d27ec846279a..84f0df3fd845 100644 --- a/src/Core/Pam/Engine/AccessRuleEngine.cs +++ b/src/Core/Pam/Engine/AccessRuleEngine.cs @@ -1,8 +1,8 @@ using System.Globalization; using System.Net; -using Bit.Core.Pam.Models.Conditions; +using Bit.Pam.Models.Conditions; -namespace Bit.Core.Pam.Engine; +namespace Bit.Pam.Engine; /// /// Evaluates the access rule's flat list of s against the caller's signals. Each diff --git a/src/Core/Pam/Engine/AccessSignals.cs b/src/Core/Pam/Engine/AccessSignals.cs index 26b45fd3a922..fddaa1d56873 100644 --- a/src/Core/Pam/Engine/AccessSignals.cs +++ b/src/Core/Pam/Engine/AccessSignals.cs @@ -1,7 +1,7 @@ using System.Net; using Bit.Core.Context; -namespace Bit.Core.Pam.Engine; +namespace Bit.Pam.Engine; /// /// The request-time inputs an access rule is evaluated against: the caller's source IP and the instant the diff --git a/src/Core/Pam/Engine/IAccessRuleEngine.cs b/src/Core/Pam/Engine/IAccessRuleEngine.cs index 80b5f5513d48..8669aac9d3e4 100644 --- a/src/Core/Pam/Engine/IAccessRuleEngine.cs +++ b/src/Core/Pam/Engine/IAccessRuleEngine.cs @@ -1,6 +1,6 @@ -using Bit.Core.Pam.Models.Conditions; +using Bit.Pam.Models.Conditions; -namespace Bit.Core.Pam.Engine; +namespace Bit.Pam.Engine; /// /// Evaluates an access rule's conditions — a flat list of ANDed together — against diff --git a/src/Core/Pam/Entities/AccessDecision.cs b/src/Core/Pam/Entities/AccessDecision.cs index 8de0f8d06916..2380f76332d9 100644 --- a/src/Core/Pam/Entities/AccessDecision.cs +++ b/src/Core/Pam/Entities/AccessDecision.cs @@ -1,8 +1,8 @@ using Bit.Core.Entities; -using Bit.Core.Pam.Enums; using Bit.Core.Utilities; +using Bit.Pam.Enums; -namespace Bit.Core.Pam.Entities; +namespace Bit.Pam.Entities; /// /// A single decision on a . In v0 there is exactly one decision per request: an automated diff --git a/src/Core/Pam/Entities/AccessLease.cs b/src/Core/Pam/Entities/AccessLease.cs index 7a889dc99fc0..5db1eb9bcb7f 100644 --- a/src/Core/Pam/Entities/AccessLease.cs +++ b/src/Core/Pam/Entities/AccessLease.cs @@ -1,8 +1,8 @@ using Bit.Core.Entities; -using Bit.Core.Pam.Enums; using Bit.Core.Utilities; +using Bit.Pam.Enums; -namespace Bit.Core.Pam.Entities; +namespace Bit.Pam.Entities; /// /// An active grant of access to a cipher, born from an approved . Only diff --git a/src/Core/Pam/Entities/AccessRequest.cs b/src/Core/Pam/Entities/AccessRequest.cs index 041c4ded4761..067dae88f84d 100644 --- a/src/Core/Pam/Entities/AccessRequest.cs +++ b/src/Core/Pam/Entities/AccessRequest.cs @@ -1,8 +1,8 @@ using Bit.Core.Entities; -using Bit.Core.Pam.Enums; using Bit.Core.Utilities; +using Bit.Pam.Enums; -namespace Bit.Core.Pam.Entities; +namespace Bit.Pam.Entities; /// /// A request to lease access to a cipher in a leasing-governed collection. Auto-approved requests are created diff --git a/src/Core/Pam/Entities/AccessRule.cs b/src/Core/Pam/Entities/AccessRule.cs index 8fec55209de0..b84bbe02d480 100644 --- a/src/Core/Pam/Entities/AccessRule.cs +++ b/src/Core/Pam/Entities/AccessRule.cs @@ -2,7 +2,7 @@ using Bit.Core.Entities; using Bit.Core.Utilities; -namespace Bit.Core.Pam.Entities; +namespace Bit.Pam.Entities; /// /// A reusable, org-scoped PAM access rule. Referenced by collections (and eventually Secrets Manager diff --git a/src/Core/Pam/Enums/AccessApprovalMode.cs b/src/Core/Pam/Enums/AccessApprovalMode.cs index c1af4107b1f8..80cf754990dd 100644 --- a/src/Core/Pam/Enums/AccessApprovalMode.cs +++ b/src/Core/Pam/Enums/AccessApprovalMode.cs @@ -1,4 +1,4 @@ -namespace Bit.Core.Pam.Enums; +namespace Bit.Pam.Enums; /// /// The approval path a lease request will take, surfaced by the pre-check so the client can present the right diff --git a/src/Core/Pam/Enums/AccessConditionKind.cs b/src/Core/Pam/Enums/AccessConditionKind.cs index 027e87237b0e..5546e62b3f89 100644 --- a/src/Core/Pam/Enums/AccessConditionKind.cs +++ b/src/Core/Pam/Enums/AccessConditionKind.cs @@ -1,4 +1,4 @@ -namespace Bit.Core.Pam.Enums; +namespace Bit.Pam.Enums; /// /// The kind of access condition that produced an automatic . Mirrors the diff --git a/src/Core/Pam/Enums/AccessDeciderKind.cs b/src/Core/Pam/Enums/AccessDeciderKind.cs index 00ddfec06e22..5aa0f2bbc2e0 100644 --- a/src/Core/Pam/Enums/AccessDeciderKind.cs +++ b/src/Core/Pam/Enums/AccessDeciderKind.cs @@ -1,4 +1,4 @@ -namespace Bit.Core.Pam.Enums; +namespace Bit.Pam.Enums; /// /// Who made a : an automatic condition evaluation or a human approver. diff --git a/src/Core/Pam/Enums/AccessLeaseExtendOutcome.cs b/src/Core/Pam/Enums/AccessLeaseExtendOutcome.cs index 545d97b9743b..a1baadaeb510 100644 --- a/src/Core/Pam/Enums/AccessLeaseExtendOutcome.cs +++ b/src/Core/Pam/Enums/AccessLeaseExtendOutcome.cs @@ -1,4 +1,4 @@ -namespace Bit.Core.Pam.Enums; +namespace Bit.Pam.Enums; /// /// The result of a race-safe lease extension. The extension stored procedure returns a distinct integer code so the diff --git a/src/Core/Pam/Enums/AccessLeaseMintOutcome.cs b/src/Core/Pam/Enums/AccessLeaseMintOutcome.cs index e76a3d72f5d9..822b8f14baf7 100644 --- a/src/Core/Pam/Enums/AccessLeaseMintOutcome.cs +++ b/src/Core/Pam/Enums/AccessLeaseMintOutcome.cs @@ -1,4 +1,4 @@ -namespace Bit.Core.Pam.Enums; +namespace Bit.Pam.Enums; /// /// The result of a race-safe lease mint. The mint stored procedures return a distinct integer code so the caller can diff --git a/src/Core/Pam/Enums/AccessLeaseStatus.cs b/src/Core/Pam/Enums/AccessLeaseStatus.cs index 45ed140b53ab..04fae936d560 100644 --- a/src/Core/Pam/Enums/AccessLeaseStatus.cs +++ b/src/Core/Pam/Enums/AccessLeaseStatus.cs @@ -1,4 +1,4 @@ -namespace Bit.Core.Pam.Enums; +namespace Bit.Pam.Enums; /// /// Lifecycle of a . Only leases authorize access. diff --git a/src/Core/Pam/Enums/AccessRequestStatus.cs b/src/Core/Pam/Enums/AccessRequestStatus.cs index ddeba23dc1ae..d6dcd6ad36b1 100644 --- a/src/Core/Pam/Enums/AccessRequestStatus.cs +++ b/src/Core/Pam/Enums/AccessRequestStatus.cs @@ -1,4 +1,4 @@ -namespace Bit.Core.Pam.Enums; +namespace Bit.Pam.Enums; /// /// Lifecycle of a . A request starts and moves to exactly diff --git a/src/Core/Pam/Enums/AccessWeekday.cs b/src/Core/Pam/Enums/AccessWeekday.cs index 63ce8b9f8e80..a53893dbd747 100644 --- a/src/Core/Pam/Enums/AccessWeekday.cs +++ b/src/Core/Pam/Enums/AccessWeekday.cs @@ -1,7 +1,7 @@ using System.Text.Json.Serialization; -using Bit.Core.Pam.Models.Conditions; +using Bit.Pam.Models.Conditions; -namespace Bit.Core.Pam.Enums; +namespace Bit.Pam.Enums; /// /// A day of the week used in a window. Values align with diff --git a/src/Core/Pam/Models/AccessDeciderKindNames.cs b/src/Core/Pam/Models/AccessDeciderKindNames.cs index 925a39921d7c..b03c22ed5d22 100644 --- a/src/Core/Pam/Models/AccessDeciderKindNames.cs +++ b/src/Core/Pam/Models/AccessDeciderKindNames.cs @@ -1,6 +1,6 @@ -using Bit.Core.Pam.Enums; +using Bit.Pam.Enums; -namespace Bit.Core.Pam.Models; +namespace Bit.Pam.Models; /// /// Maps the backend to the vocabulary the client expects on a decision: diff --git a/src/Core/Pam/Models/AccessDecisionSubmission.cs b/src/Core/Pam/Models/AccessDecisionSubmission.cs index 9c6e5dd5a90c..9b4015042622 100644 --- a/src/Core/Pam/Models/AccessDecisionSubmission.cs +++ b/src/Core/Pam/Models/AccessDecisionSubmission.cs @@ -1,6 +1,6 @@ -using Bit.Core.Pam.Enums; +using Bit.Pam.Enums; -namespace Bit.Core.Pam.Models; +namespace Bit.Pam.Models; /// /// An approver's decision on a pending lease request: approve or deny, with an optional comment. diff --git a/src/Core/Pam/Models/AccessLeaseExtensionSubmission.cs b/src/Core/Pam/Models/AccessLeaseExtensionSubmission.cs index 47e0199cbea7..58f8f596328c 100644 --- a/src/Core/Pam/Models/AccessLeaseExtensionSubmission.cs +++ b/src/Core/Pam/Models/AccessLeaseExtensionSubmission.cs @@ -1,4 +1,4 @@ -namespace Bit.Core.Pam.Models; +namespace Bit.Pam.Models; /// /// A request to extend an active lease. Extensions are always auto-approved, subject to the governing rule's diff --git a/src/Core/Pam/Models/AccessLeaseStatusNames.cs b/src/Core/Pam/Models/AccessLeaseStatusNames.cs index 7a21942edf12..80cfb88a7bff 100644 --- a/src/Core/Pam/Models/AccessLeaseStatusNames.cs +++ b/src/Core/Pam/Models/AccessLeaseStatusNames.cs @@ -1,6 +1,6 @@ -using Bit.Core.Pam.Enums; +using Bit.Pam.Enums; -namespace Bit.Core.Pam.Models; +namespace Bit.Pam.Models; /// /// Maps the backend to the status vocabulary the leasing client expects: diff --git a/src/Core/Pam/Models/AccessPreCheckResult.cs b/src/Core/Pam/Models/AccessPreCheckResult.cs index 0e5ad9b87e52..5056ec4d8a5e 100644 --- a/src/Core/Pam/Models/AccessPreCheckResult.cs +++ b/src/Core/Pam/Models/AccessPreCheckResult.cs @@ -1,6 +1,6 @@ -using Bit.Core.Pam.Enums; +using Bit.Pam.Enums; -namespace Bit.Core.Pam.Models; +namespace Bit.Pam.Models; /// /// The result of a pre-check. When is true the caller already holds an active lease for diff --git a/src/Core/Pam/Models/AccessRequestDecision.cs b/src/Core/Pam/Models/AccessRequestDecision.cs index 8ab9bdc59793..088699d3b834 100644 --- a/src/Core/Pam/Models/AccessRequestDecision.cs +++ b/src/Core/Pam/Models/AccessRequestDecision.cs @@ -1,6 +1,6 @@ -using Bit.Core.Pam.Enums; +using Bit.Pam.Enums; -namespace Bit.Core.Pam.Models; +namespace Bit.Pam.Models; /// /// One decision on an , projected from an diff --git a/src/Core/Pam/Models/AccessRequestDetails.cs b/src/Core/Pam/Models/AccessRequestDetails.cs index c3439bf15503..27ea42cb2468 100644 --- a/src/Core/Pam/Models/AccessRequestDetails.cs +++ b/src/Core/Pam/Models/AccessRequestDetails.cs @@ -1,6 +1,6 @@ -using Bit.Core.Pam.Enums; +using Bit.Pam.Enums; -namespace Bit.Core.Pam.Models; +namespace Bit.Pam.Models; /// /// A lease request projected for the approver inbox: every field plus the diff --git a/src/Core/Pam/Models/AccessRequestResult.cs b/src/Core/Pam/Models/AccessRequestResult.cs index b58b90c41be7..298db1bc53bf 100644 --- a/src/Core/Pam/Models/AccessRequestResult.cs +++ b/src/Core/Pam/Models/AccessRequestResult.cs @@ -1,7 +1,7 @@ -using Bit.Core.Pam.Entities; -using Bit.Core.Pam.Enums; +using Bit.Pam.Entities; +using Bit.Pam.Enums; -namespace Bit.Core.Pam.Models; +namespace Bit.Pam.Models; /// /// The result of submitting an access request. Neither path mints a lease at submit: the diff --git a/src/Core/Pam/Models/AccessRequestStatusNames.cs b/src/Core/Pam/Models/AccessRequestStatusNames.cs index 7d5cfcd33cad..58676d54df94 100644 --- a/src/Core/Pam/Models/AccessRequestStatusNames.cs +++ b/src/Core/Pam/Models/AccessRequestStatusNames.cs @@ -1,6 +1,6 @@ -using Bit.Core.Pam.Enums; +using Bit.Pam.Enums; -namespace Bit.Core.Pam.Models; +namespace Bit.Pam.Models; /// /// Maps the backend (plus whether the request has produced a lease) to the status diff --git a/src/Core/Pam/Models/AccessRequestSubmission.cs b/src/Core/Pam/Models/AccessRequestSubmission.cs index a135051d74b4..7ce7be48ef73 100644 --- a/src/Core/Pam/Models/AccessRequestSubmission.cs +++ b/src/Core/Pam/Models/AccessRequestSubmission.cs @@ -1,4 +1,4 @@ -namespace Bit.Core.Pam.Models; +namespace Bit.Pam.Models; /// /// A request to lease a cipher. The automatic path supplies (and an optional diff --git a/src/Core/Pam/Models/AccessRuleDetails.cs b/src/Core/Pam/Models/AccessRuleDetails.cs index c178e475ad8e..1bb0c7d6c99b 100644 --- a/src/Core/Pam/Models/AccessRuleDetails.cs +++ b/src/Core/Pam/Models/AccessRuleDetails.cs @@ -1,6 +1,6 @@ -using Bit.Core.Pam.Entities; +using Bit.Pam.Entities; -namespace Bit.Core.Pam.Models; +namespace Bit.Pam.Models; /// /// An together with the IDs of the collections it governs. diff --git a/src/Core/Pam/Models/CipherAccessState.cs b/src/Core/Pam/Models/CipherAccessState.cs index 90bc9281e91b..b8d15a96b7cd 100644 --- a/src/Core/Pam/Models/CipherAccessState.cs +++ b/src/Core/Pam/Models/CipherAccessState.cs @@ -1,6 +1,6 @@ -using Bit.Core.Pam.Entities; +using Bit.Pam.Entities; -namespace Bit.Core.Pam.Models; +namespace Bit.Pam.Models; /// /// The caller's access state for a single cipher: the active lease they hold (if any), their pending request (if diff --git a/src/Core/Pam/Models/Conditions/AccessCondition.cs b/src/Core/Pam/Models/Conditions/AccessCondition.cs index 55ec2f771445..e1bb9c6e3d21 100644 --- a/src/Core/Pam/Models/Conditions/AccessCondition.cs +++ b/src/Core/Pam/Models/Conditions/AccessCondition.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace Bit.Core.Pam.Models.Conditions; +namespace Bit.Pam.Models.Conditions; /// /// Base type for a single leaf condition in an access rule's flat conditions list. Polymorphic deserialization is diff --git a/src/Core/Pam/Models/Conditions/AccessWeekdayJsonConverter.cs b/src/Core/Pam/Models/Conditions/AccessWeekdayJsonConverter.cs index 6b1c6d03fe5d..a45da3345432 100644 --- a/src/Core/Pam/Models/Conditions/AccessWeekdayJsonConverter.cs +++ b/src/Core/Pam/Models/Conditions/AccessWeekdayJsonConverter.cs @@ -1,8 +1,8 @@ using System.Text.Json; using System.Text.Json.Serialization; -using Bit.Core.Pam.Enums; +using Bit.Pam.Enums; -namespace Bit.Core.Pam.Models.Conditions; +namespace Bit.Pam.Models.Conditions; /// /// (De)serializes as the lowercase three-letter tokens the conditions JSON uses diff --git a/src/Core/Pam/Models/Conditions/HumanApprovalCondition.cs b/src/Core/Pam/Models/Conditions/HumanApprovalCondition.cs index 4f14c8833810..b823570fc242 100644 --- a/src/Core/Pam/Models/Conditions/HumanApprovalCondition.cs +++ b/src/Core/Pam/Models/Conditions/HumanApprovalCondition.cs @@ -1,4 +1,4 @@ -namespace Bit.Core.Pam.Models.Conditions; +namespace Bit.Pam.Models.Conditions; /// /// Always requires a human decision before a lease can be issued. diff --git a/src/Core/Pam/Models/Conditions/IpAllowlistCondition.cs b/src/Core/Pam/Models/Conditions/IpAllowlistCondition.cs index fc2f186a9bc2..fc7eba12fa83 100644 --- a/src/Core/Pam/Models/Conditions/IpAllowlistCondition.cs +++ b/src/Core/Pam/Models/Conditions/IpAllowlistCondition.cs @@ -1,4 +1,4 @@ -namespace Bit.Core.Pam.Models.Conditions; +namespace Bit.Pam.Models.Conditions; /// /// Auto-approves a lease when the requester's IP matches a listed CIDR; otherwise denies. diff --git a/src/Core/Pam/Models/Conditions/TimeOfDayCondition.cs b/src/Core/Pam/Models/Conditions/TimeOfDayCondition.cs index 0652d0fc9e69..b9b04c4aba2b 100644 --- a/src/Core/Pam/Models/Conditions/TimeOfDayCondition.cs +++ b/src/Core/Pam/Models/Conditions/TimeOfDayCondition.cs @@ -1,6 +1,6 @@ -using Bit.Core.Pam.Enums; +using Bit.Pam.Enums; -namespace Bit.Core.Pam.Models.Conditions; +namespace Bit.Pam.Models.Conditions; /// /// Auto-approves a lease when the request falls inside one of the configured windows, evaluated in diff --git a/src/Core/Pam/Models/GoverningRule.cs b/src/Core/Pam/Models/GoverningRule.cs index 7c9261e456f7..e5b45ccf0f8c 100644 --- a/src/Core/Pam/Models/GoverningRule.cs +++ b/src/Core/Pam/Models/GoverningRule.cs @@ -1,6 +1,6 @@ -using Bit.Core.Pam.Models.Conditions; +using Bit.Pam.Models.Conditions; -namespace Bit.Core.Pam.Models; +namespace Bit.Pam.Models; /// /// The access rule that governs a cipher for a particular caller: which collection's rule applies, the owning diff --git a/src/Core/Pam/OrganizationFeatures/Commands/ActivateAccessRequestCommand.cs b/src/Core/Pam/OrganizationFeatures/Commands/ActivateAccessRequestCommand.cs index 2d76780dd104..7ed83ad559d8 100644 --- a/src/Core/Pam/OrganizationFeatures/Commands/ActivateAccessRequestCommand.cs +++ b/src/Core/Pam/OrganizationFeatures/Commands/ActivateAccessRequestCommand.cs @@ -1,11 +1,11 @@ -using Bit.Core.Exceptions; -using Bit.Core.Pam.Entities; -using Bit.Core.Pam.Enums; -using Bit.Core.Pam.OrganizationFeatures.Commands.Interfaces; -using Bit.Core.Pam.Repositories; -using Bit.Core.Pam.Services; - -namespace Bit.Core.Pam.OrganizationFeatures.Commands; +using Bit.Core.Exceptions; +using Bit.Pam.Entities; +using Bit.Pam.Enums; +using Bit.Pam.OrganizationFeatures.Commands.Interfaces; +using Bit.Pam.Repositories; +using Bit.Pam.Services; + +namespace Bit.Pam.OrganizationFeatures.Commands; public class ActivateAccessRequestCommand : IActivateAccessRequestCommand { diff --git a/src/Core/Pam/OrganizationFeatures/Commands/CancelAccessRequestCommand.cs b/src/Core/Pam/OrganizationFeatures/Commands/CancelAccessRequestCommand.cs index 213ebe26b59d..92477e39a7b6 100644 --- a/src/Core/Pam/OrganizationFeatures/Commands/CancelAccessRequestCommand.cs +++ b/src/Core/Pam/OrganizationFeatures/Commands/CancelAccessRequestCommand.cs @@ -1,11 +1,11 @@ -using Bit.Core.Exceptions; -using Bit.Core.Pam.Entities; -using Bit.Core.Pam.Enums; -using Bit.Core.Pam.OrganizationFeatures.Commands.Interfaces; -using Bit.Core.Pam.Repositories; -using Bit.Core.Pam.Services; +using Bit.Core.Exceptions; +using Bit.Pam.Entities; +using Bit.Pam.Enums; +using Bit.Pam.OrganizationFeatures.Commands.Interfaces; +using Bit.Pam.Repositories; +using Bit.Pam.Services; -namespace Bit.Core.Pam.OrganizationFeatures.Commands; +namespace Bit.Pam.OrganizationFeatures.Commands; public class CancelAccessRequestCommand : ICancelAccessRequestCommand { diff --git a/src/Core/Pam/OrganizationFeatures/Commands/CreateAccessRuleCommand.cs b/src/Core/Pam/OrganizationFeatures/Commands/CreateAccessRuleCommand.cs index 6b634a8f45e8..984ae3046d6f 100644 --- a/src/Core/Pam/OrganizationFeatures/Commands/CreateAccessRuleCommand.cs +++ b/src/Core/Pam/OrganizationFeatures/Commands/CreateAccessRuleCommand.cs @@ -1,13 +1,13 @@ using Bit.Core.Entities; using Bit.Core.Exceptions; -using Bit.Core.Pam.Entities; -using Bit.Core.Pam.Models; -using Bit.Core.Pam.OrganizationFeatures.Commands.Interfaces; -using Bit.Core.Pam.Repositories; -using Bit.Core.Pam.Services; using Bit.Core.Repositories; +using Bit.Pam.Entities; +using Bit.Pam.Models; +using Bit.Pam.OrganizationFeatures.Commands.Interfaces; +using Bit.Pam.Repositories; +using Bit.Pam.Services; -namespace Bit.Core.Pam.OrganizationFeatures.Commands; +namespace Bit.Pam.OrganizationFeatures.Commands; public class CreateAccessRuleCommand : ICreateAccessRuleCommand { diff --git a/src/Core/Pam/OrganizationFeatures/Commands/DecideAccessRequestCommand.cs b/src/Core/Pam/OrganizationFeatures/Commands/DecideAccessRequestCommand.cs index b3d3870f9f99..1e70e2336228 100644 --- a/src/Core/Pam/OrganizationFeatures/Commands/DecideAccessRequestCommand.cs +++ b/src/Core/Pam/OrganizationFeatures/Commands/DecideAccessRequestCommand.cs @@ -1,12 +1,12 @@ using Bit.Core.Exceptions; -using Bit.Core.Pam.Entities; -using Bit.Core.Pam.Enums; -using Bit.Core.Pam.Models; -using Bit.Core.Pam.OrganizationFeatures.Commands.Interfaces; -using Bit.Core.Pam.Repositories; -using Bit.Core.Pam.Services; +using Bit.Pam.Entities; +using Bit.Pam.Enums; +using Bit.Pam.Models; +using Bit.Pam.OrganizationFeatures.Commands.Interfaces; +using Bit.Pam.Repositories; +using Bit.Pam.Services; -namespace Bit.Core.Pam.OrganizationFeatures.Commands; +namespace Bit.Pam.OrganizationFeatures.Commands; public class DecideAccessRequestCommand : IDecideAccessRequestCommand { diff --git a/src/Core/Pam/OrganizationFeatures/Commands/DeleteAccessRuleCommand.cs b/src/Core/Pam/OrganizationFeatures/Commands/DeleteAccessRuleCommand.cs index 4e5c1d6e5931..b1987f7d7c4e 100644 --- a/src/Core/Pam/OrganizationFeatures/Commands/DeleteAccessRuleCommand.cs +++ b/src/Core/Pam/OrganizationFeatures/Commands/DeleteAccessRuleCommand.cs @@ -1,8 +1,8 @@ using Bit.Core.Exceptions; -using Bit.Core.Pam.OrganizationFeatures.Commands.Interfaces; -using Bit.Core.Pam.Repositories; +using Bit.Pam.OrganizationFeatures.Commands.Interfaces; +using Bit.Pam.Repositories; -namespace Bit.Core.Pam.OrganizationFeatures.Commands; +namespace Bit.Pam.OrganizationFeatures.Commands; public class DeleteAccessRuleCommand : IDeleteAccessRuleCommand { diff --git a/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IActivateAccessRequestCommand.cs b/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IActivateAccessRequestCommand.cs index b327a20fd0a5..48e8d244f3a5 100644 --- a/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IActivateAccessRequestCommand.cs +++ b/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IActivateAccessRequestCommand.cs @@ -1,6 +1,6 @@ -using Bit.Core.Pam.Entities; +using Bit.Pam.Entities; -namespace Bit.Core.Pam.OrganizationFeatures.Commands.Interfaces; +namespace Bit.Pam.OrganizationFeatures.Commands.Interfaces; public interface IActivateAccessRequestCommand { diff --git a/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/ICancelAccessRequestCommand.cs b/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/ICancelAccessRequestCommand.cs index 051bfc5f32d0..4571399abc4a 100644 --- a/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/ICancelAccessRequestCommand.cs +++ b/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/ICancelAccessRequestCommand.cs @@ -1,4 +1,4 @@ -namespace Bit.Core.Pam.OrganizationFeatures.Commands.Interfaces; +namespace Bit.Pam.OrganizationFeatures.Commands.Interfaces; public interface ICancelAccessRequestCommand { diff --git a/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/ICreateAccessRuleCommand.cs b/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/ICreateAccessRuleCommand.cs index 6ae3a7b53542..ff2007445208 100644 --- a/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/ICreateAccessRuleCommand.cs +++ b/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/ICreateAccessRuleCommand.cs @@ -1,7 +1,7 @@ -using Bit.Core.Pam.Entities; -using Bit.Core.Pam.Models; +using Bit.Pam.Entities; +using Bit.Pam.Models; -namespace Bit.Core.Pam.OrganizationFeatures.Commands.Interfaces; +namespace Bit.Pam.OrganizationFeatures.Commands.Interfaces; public interface ICreateAccessRuleCommand { diff --git a/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IDecideAccessRequestCommand.cs b/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IDecideAccessRequestCommand.cs index 6f50c021911a..658777882c95 100644 --- a/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IDecideAccessRequestCommand.cs +++ b/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IDecideAccessRequestCommand.cs @@ -1,6 +1,6 @@ -using Bit.Core.Pam.Models; +using Bit.Pam.Models; -namespace Bit.Core.Pam.OrganizationFeatures.Commands.Interfaces; +namespace Bit.Pam.OrganizationFeatures.Commands.Interfaces; public interface IDecideAccessRequestCommand { diff --git a/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IDeleteAccessRuleCommand.cs b/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IDeleteAccessRuleCommand.cs index 0d23dd73f096..78fdfbf78c31 100644 --- a/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IDeleteAccessRuleCommand.cs +++ b/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IDeleteAccessRuleCommand.cs @@ -1,4 +1,4 @@ -namespace Bit.Core.Pam.OrganizationFeatures.Commands.Interfaces; +namespace Bit.Pam.OrganizationFeatures.Commands.Interfaces; public interface IDeleteAccessRuleCommand { diff --git a/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IRequestLeaseExtensionCommand.cs b/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IRequestLeaseExtensionCommand.cs index 9e05b42d0d69..46e0bf3373d2 100644 --- a/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IRequestLeaseExtensionCommand.cs +++ b/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IRequestLeaseExtensionCommand.cs @@ -1,6 +1,6 @@ -using Bit.Core.Pam.Models; +using Bit.Pam.Models; -namespace Bit.Core.Pam.OrganizationFeatures.Commands.Interfaces; +namespace Bit.Pam.OrganizationFeatures.Commands.Interfaces; public interface IRequestLeaseExtensionCommand { diff --git a/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IRevokeAccessLeaseCommand.cs b/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IRevokeAccessLeaseCommand.cs index c76ff99caeae..abefcd0b2623 100644 --- a/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IRevokeAccessLeaseCommand.cs +++ b/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IRevokeAccessLeaseCommand.cs @@ -1,4 +1,4 @@ -namespace Bit.Core.Pam.OrganizationFeatures.Commands.Interfaces; +namespace Bit.Pam.OrganizationFeatures.Commands.Interfaces; public interface IRevokeAccessLeaseCommand { diff --git a/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/ISubmitAccessRequestCommand.cs b/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/ISubmitAccessRequestCommand.cs index 2503ae78bf55..05239cd634f7 100644 --- a/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/ISubmitAccessRequestCommand.cs +++ b/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/ISubmitAccessRequestCommand.cs @@ -1,6 +1,6 @@ -using Bit.Core.Pam.Models; +using Bit.Pam.Models; -namespace Bit.Core.Pam.OrganizationFeatures.Commands.Interfaces; +namespace Bit.Pam.OrganizationFeatures.Commands.Interfaces; public interface ISubmitAccessRequestCommand { diff --git a/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IUpdateAccessRuleCommand.cs b/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IUpdateAccessRuleCommand.cs index a53a57a85977..eb62e2352017 100644 --- a/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IUpdateAccessRuleCommand.cs +++ b/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IUpdateAccessRuleCommand.cs @@ -1,7 +1,7 @@ -using Bit.Core.Pam.Entities; -using Bit.Core.Pam.Models; +using Bit.Pam.Entities; +using Bit.Pam.Models; -namespace Bit.Core.Pam.OrganizationFeatures.Commands.Interfaces; +namespace Bit.Pam.OrganizationFeatures.Commands.Interfaces; public interface IUpdateAccessRuleCommand { diff --git a/src/Core/Pam/OrganizationFeatures/Commands/RequestLeaseExtensionCommand.cs b/src/Core/Pam/OrganizationFeatures/Commands/RequestLeaseExtensionCommand.cs index d1e6b9901365..05b8f5bde500 100644 --- a/src/Core/Pam/OrganizationFeatures/Commands/RequestLeaseExtensionCommand.cs +++ b/src/Core/Pam/OrganizationFeatures/Commands/RequestLeaseExtensionCommand.cs @@ -1,14 +1,14 @@ using Bit.Core.Context; using Bit.Core.Exceptions; -using Bit.Core.Pam.Engine; -using Bit.Core.Pam.Entities; -using Bit.Core.Pam.Enums; -using Bit.Core.Pam.Models; -using Bit.Core.Pam.OrganizationFeatures.Commands.Interfaces; -using Bit.Core.Pam.Repositories; -using Bit.Core.Pam.Services; - -namespace Bit.Core.Pam.OrganizationFeatures.Commands; +using Bit.Pam.Engine; +using Bit.Pam.Entities; +using Bit.Pam.Enums; +using Bit.Pam.Models; +using Bit.Pam.OrganizationFeatures.Commands.Interfaces; +using Bit.Pam.Repositories; +using Bit.Pam.Services; + +namespace Bit.Pam.OrganizationFeatures.Commands; public class RequestLeaseExtensionCommand : IRequestLeaseExtensionCommand { diff --git a/src/Core/Pam/OrganizationFeatures/Commands/RevokeAccessLeaseCommand.cs b/src/Core/Pam/OrganizationFeatures/Commands/RevokeAccessLeaseCommand.cs index 178b58b07d94..e32841acc6dc 100644 --- a/src/Core/Pam/OrganizationFeatures/Commands/RevokeAccessLeaseCommand.cs +++ b/src/Core/Pam/OrganizationFeatures/Commands/RevokeAccessLeaseCommand.cs @@ -1,11 +1,11 @@ using Bit.Core.Exceptions; -using Bit.Core.Pam.Entities; -using Bit.Core.Pam.Enums; -using Bit.Core.Pam.OrganizationFeatures.Commands.Interfaces; -using Bit.Core.Pam.Repositories; -using Bit.Core.Pam.Services; +using Bit.Pam.Entities; +using Bit.Pam.Enums; +using Bit.Pam.OrganizationFeatures.Commands.Interfaces; +using Bit.Pam.Repositories; +using Bit.Pam.Services; -namespace Bit.Core.Pam.OrganizationFeatures.Commands; +namespace Bit.Pam.OrganizationFeatures.Commands; public class RevokeAccessLeaseCommand : IRevokeAccessLeaseCommand { diff --git a/src/Core/Pam/OrganizationFeatures/Commands/SubmitAccessRequestCommand.cs b/src/Core/Pam/OrganizationFeatures/Commands/SubmitAccessRequestCommand.cs index 6c908e357ae9..c9cb15e08bba 100644 --- a/src/Core/Pam/OrganizationFeatures/Commands/SubmitAccessRequestCommand.cs +++ b/src/Core/Pam/OrganizationFeatures/Commands/SubmitAccessRequestCommand.cs @@ -1,18 +1,18 @@ using Bit.Core.Context; using Bit.Core.Exceptions; -using Bit.Core.Pam.Engine; -using Bit.Core.Pam.Entities; -using Bit.Core.Pam.Enums; -using Bit.Core.Pam.Models; -using Bit.Core.Pam.OrganizationFeatures.Commands.Interfaces; -using Bit.Core.Pam.Repositories; -using Bit.Core.Pam.Services; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Vault.Repositories; +using Bit.Pam.Engine; +using Bit.Pam.Entities; +using Bit.Pam.Enums; +using Bit.Pam.Models; +using Bit.Pam.OrganizationFeatures.Commands.Interfaces; +using Bit.Pam.Repositories; +using Bit.Pam.Services; using Microsoft.Extensions.Logging; -namespace Bit.Core.Pam.OrganizationFeatures.Commands; +namespace Bit.Pam.OrganizationFeatures.Commands; public class SubmitAccessRequestCommand : ISubmitAccessRequestCommand { diff --git a/src/Core/Pam/OrganizationFeatures/Commands/UpdateAccessRuleCommand.cs b/src/Core/Pam/OrganizationFeatures/Commands/UpdateAccessRuleCommand.cs index 709390693347..c3cbb39b3694 100644 --- a/src/Core/Pam/OrganizationFeatures/Commands/UpdateAccessRuleCommand.cs +++ b/src/Core/Pam/OrganizationFeatures/Commands/UpdateAccessRuleCommand.cs @@ -1,12 +1,12 @@ using Bit.Core.Exceptions; -using Bit.Core.Pam.Entities; -using Bit.Core.Pam.Models; -using Bit.Core.Pam.OrganizationFeatures.Commands.Interfaces; -using Bit.Core.Pam.Repositories; -using Bit.Core.Pam.Services; using Bit.Core.Repositories; +using Bit.Pam.Entities; +using Bit.Pam.Models; +using Bit.Pam.OrganizationFeatures.Commands.Interfaces; +using Bit.Pam.Repositories; +using Bit.Pam.Services; -namespace Bit.Core.Pam.OrganizationFeatures.Commands; +namespace Bit.Pam.OrganizationFeatures.Commands; public class UpdateAccessRuleCommand : IUpdateAccessRuleCommand { diff --git a/src/Core/Pam/OrganizationFeatures/Queries/AccessPreCheckQuery.cs b/src/Core/Pam/OrganizationFeatures/Queries/AccessPreCheckQuery.cs index 87a5c50e8563..a5965bc9ef3a 100644 --- a/src/Core/Pam/OrganizationFeatures/Queries/AccessPreCheckQuery.cs +++ b/src/Core/Pam/OrganizationFeatures/Queries/AccessPreCheckQuery.cs @@ -1,14 +1,14 @@ using Bit.Core.Context; using Bit.Core.Exceptions; -using Bit.Core.Pam.Engine; -using Bit.Core.Pam.Enums; -using Bit.Core.Pam.Models; -using Bit.Core.Pam.OrganizationFeatures.Queries.Interfaces; -using Bit.Core.Pam.Repositories; -using Bit.Core.Pam.Services; using Bit.Core.Vault.Repositories; - -namespace Bit.Core.Pam.OrganizationFeatures.Queries; +using Bit.Pam.Engine; +using Bit.Pam.Enums; +using Bit.Pam.Models; +using Bit.Pam.OrganizationFeatures.Queries.Interfaces; +using Bit.Pam.Repositories; +using Bit.Pam.Services; + +namespace Bit.Pam.OrganizationFeatures.Queries; public class AccessPreCheckQuery : IAccessPreCheckQuery { diff --git a/src/Core/Pam/OrganizationFeatures/Queries/GetCipherAccessStateQuery.cs b/src/Core/Pam/OrganizationFeatures/Queries/GetCipherAccessStateQuery.cs index b10c2aae48f2..a17f32983463 100644 --- a/src/Core/Pam/OrganizationFeatures/Queries/GetCipherAccessStateQuery.cs +++ b/src/Core/Pam/OrganizationFeatures/Queries/GetCipherAccessStateQuery.cs @@ -1,14 +1,14 @@ using Bit.Core.Context; using Bit.Core.Exceptions; -using Bit.Core.Pam.Engine; -using Bit.Core.Pam.Entities; -using Bit.Core.Pam.Models; -using Bit.Core.Pam.OrganizationFeatures.Queries.Interfaces; -using Bit.Core.Pam.Repositories; -using Bit.Core.Pam.Services; using Bit.Core.Vault.Repositories; +using Bit.Pam.Engine; +using Bit.Pam.Entities; +using Bit.Pam.Models; +using Bit.Pam.OrganizationFeatures.Queries.Interfaces; +using Bit.Pam.Repositories; +using Bit.Pam.Services; -namespace Bit.Core.Pam.OrganizationFeatures.Queries; +namespace Bit.Pam.OrganizationFeatures.Queries; public class GetCipherAccessStateQuery : IGetCipherAccessStateQuery { diff --git a/src/Core/Pam/OrganizationFeatures/Queries/GetLeasedCipherQuery.cs b/src/Core/Pam/OrganizationFeatures/Queries/GetLeasedCipherQuery.cs index f4f826cb2f9b..7e93d74d75dc 100644 --- a/src/Core/Pam/OrganizationFeatures/Queries/GetLeasedCipherQuery.cs +++ b/src/Core/Pam/OrganizationFeatures/Queries/GetLeasedCipherQuery.cs @@ -1,12 +1,12 @@ using Bit.Core.Context; -using Bit.Core.Pam.Engine; -using Bit.Core.Pam.OrganizationFeatures.Queries.Interfaces; -using Bit.Core.Pam.Repositories; -using Bit.Core.Pam.Services; using Bit.Core.Vault.Models.Data; using Bit.Core.Vault.Repositories; +using Bit.Pam.Engine; +using Bit.Pam.OrganizationFeatures.Queries.Interfaces; +using Bit.Pam.Repositories; +using Bit.Pam.Services; -namespace Bit.Core.Pam.OrganizationFeatures.Queries; +namespace Bit.Pam.OrganizationFeatures.Queries; public class GetLeasedCipherQuery : IGetLeasedCipherQuery { diff --git a/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IAccessPreCheckQuery.cs b/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IAccessPreCheckQuery.cs index 56640d2d7986..6344413701e0 100644 --- a/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IAccessPreCheckQuery.cs +++ b/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IAccessPreCheckQuery.cs @@ -1,6 +1,6 @@ -using Bit.Core.Pam.Models; +using Bit.Pam.Models; -namespace Bit.Core.Pam.OrganizationFeatures.Queries.Interfaces; +namespace Bit.Pam.OrganizationFeatures.Queries.Interfaces; public interface IAccessPreCheckQuery { diff --git a/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IGetCipherAccessStateQuery.cs b/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IGetCipherAccessStateQuery.cs index faf668df6fb7..d37b807b2eb6 100644 --- a/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IGetCipherAccessStateQuery.cs +++ b/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IGetCipherAccessStateQuery.cs @@ -1,6 +1,6 @@ -using Bit.Core.Pam.Models; +using Bit.Pam.Models; -namespace Bit.Core.Pam.OrganizationFeatures.Queries.Interfaces; +namespace Bit.Pam.OrganizationFeatures.Queries.Interfaces; public interface IGetCipherAccessStateQuery { diff --git a/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IGetLeasedCipherQuery.cs b/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IGetLeasedCipherQuery.cs index 49d76d562e9d..0dfb13196852 100644 --- a/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IGetLeasedCipherQuery.cs +++ b/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IGetLeasedCipherQuery.cs @@ -1,6 +1,6 @@ using Bit.Core.Vault.Models.Data; -namespace Bit.Core.Pam.OrganizationFeatures.Queries.Interfaces; +namespace Bit.Pam.OrganizationFeatures.Queries.Interfaces; public interface IGetLeasedCipherQuery { diff --git a/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IListActiveLeasesQuery.cs b/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IListActiveLeasesQuery.cs index 878427b0adf2..1706d4f9b03d 100644 --- a/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IListActiveLeasesQuery.cs +++ b/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IListActiveLeasesQuery.cs @@ -1,6 +1,6 @@ -using Bit.Core.Pam.Entities; +using Bit.Pam.Entities; -namespace Bit.Core.Pam.OrganizationFeatures.Queries.Interfaces; +namespace Bit.Pam.OrganizationFeatures.Queries.Interfaces; public interface IListActiveLeasesQuery { diff --git a/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IListInboxHistoryQuery.cs b/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IListInboxHistoryQuery.cs index 9fa4a0f21c08..2a376d9323fb 100644 --- a/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IListInboxHistoryQuery.cs +++ b/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IListInboxHistoryQuery.cs @@ -1,6 +1,6 @@ -using Bit.Core.Pam.Models; +using Bit.Pam.Models; -namespace Bit.Core.Pam.OrganizationFeatures.Queries.Interfaces; +namespace Bit.Pam.OrganizationFeatures.Queries.Interfaces; public interface IListInboxHistoryQuery { diff --git a/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IListInboxRequestsQuery.cs b/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IListInboxRequestsQuery.cs index 8806ce94c9c7..cd00e4f6089a 100644 --- a/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IListInboxRequestsQuery.cs +++ b/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IListInboxRequestsQuery.cs @@ -1,6 +1,6 @@ -using Bit.Core.Pam.Models; +using Bit.Pam.Models; -namespace Bit.Core.Pam.OrganizationFeatures.Queries.Interfaces; +namespace Bit.Pam.OrganizationFeatures.Queries.Interfaces; public interface IListInboxRequestsQuery { diff --git a/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IListLeaseHistoryQuery.cs b/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IListLeaseHistoryQuery.cs index 8d1f59e94195..371074491ad6 100644 --- a/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IListLeaseHistoryQuery.cs +++ b/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IListLeaseHistoryQuery.cs @@ -1,6 +1,6 @@ -using Bit.Core.Pam.Entities; +using Bit.Pam.Entities; -namespace Bit.Core.Pam.OrganizationFeatures.Queries.Interfaces; +namespace Bit.Pam.OrganizationFeatures.Queries.Interfaces; public interface IListLeaseHistoryQuery { diff --git a/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IListMyAccessRequestsQuery.cs b/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IListMyAccessRequestsQuery.cs index 6e600eafdc9f..7cfa19793971 100644 --- a/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IListMyAccessRequestsQuery.cs +++ b/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IListMyAccessRequestsQuery.cs @@ -1,6 +1,6 @@ -using Bit.Core.Pam.Models; +using Bit.Pam.Models; -namespace Bit.Core.Pam.OrganizationFeatures.Queries.Interfaces; +namespace Bit.Pam.OrganizationFeatures.Queries.Interfaces; public interface IListMyAccessRequestsQuery { diff --git a/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IListMyActiveAccessLeasesQuery.cs b/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IListMyActiveAccessLeasesQuery.cs index bb28775d63a8..791936838820 100644 --- a/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IListMyActiveAccessLeasesQuery.cs +++ b/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IListMyActiveAccessLeasesQuery.cs @@ -1,6 +1,6 @@ -using Bit.Core.Pam.Entities; +using Bit.Pam.Entities; -namespace Bit.Core.Pam.OrganizationFeatures.Queries.Interfaces; +namespace Bit.Pam.OrganizationFeatures.Queries.Interfaces; public interface IListMyActiveAccessLeasesQuery { diff --git a/src/Core/Pam/OrganizationFeatures/Queries/ListActiveLeasesQuery.cs b/src/Core/Pam/OrganizationFeatures/Queries/ListActiveLeasesQuery.cs index 35575b1be6a4..145d5717f141 100644 --- a/src/Core/Pam/OrganizationFeatures/Queries/ListActiveLeasesQuery.cs +++ b/src/Core/Pam/OrganizationFeatures/Queries/ListActiveLeasesQuery.cs @@ -1,9 +1,9 @@ -using Bit.Core.Pam.Entities; -using Bit.Core.Pam.OrganizationFeatures.Queries.Interfaces; -using Bit.Core.Pam.Repositories; -using Bit.Core.Pam.Services; +using Bit.Pam.Entities; +using Bit.Pam.OrganizationFeatures.Queries.Interfaces; +using Bit.Pam.Repositories; +using Bit.Pam.Services; -namespace Bit.Core.Pam.OrganizationFeatures.Queries; +namespace Bit.Pam.OrganizationFeatures.Queries; public class ListActiveLeasesQuery : IListActiveLeasesQuery { diff --git a/src/Core/Pam/OrganizationFeatures/Queries/ListInboxHistoryQuery.cs b/src/Core/Pam/OrganizationFeatures/Queries/ListInboxHistoryQuery.cs index 0249b20d11ac..a9e55c105d73 100644 --- a/src/Core/Pam/OrganizationFeatures/Queries/ListInboxHistoryQuery.cs +++ b/src/Core/Pam/OrganizationFeatures/Queries/ListInboxHistoryQuery.cs @@ -1,9 +1,9 @@ -using Bit.Core.Pam.Models; -using Bit.Core.Pam.OrganizationFeatures.Queries.Interfaces; -using Bit.Core.Pam.Repositories; -using Bit.Core.Pam.Services; +using Bit.Pam.Models; +using Bit.Pam.OrganizationFeatures.Queries.Interfaces; +using Bit.Pam.Repositories; +using Bit.Pam.Services; -namespace Bit.Core.Pam.OrganizationFeatures.Queries; +namespace Bit.Pam.OrganizationFeatures.Queries; public class ListInboxHistoryQuery : IListInboxHistoryQuery { diff --git a/src/Core/Pam/OrganizationFeatures/Queries/ListInboxRequestsQuery.cs b/src/Core/Pam/OrganizationFeatures/Queries/ListInboxRequestsQuery.cs index d0901b9976c2..2cdfbe242603 100644 --- a/src/Core/Pam/OrganizationFeatures/Queries/ListInboxRequestsQuery.cs +++ b/src/Core/Pam/OrganizationFeatures/Queries/ListInboxRequestsQuery.cs @@ -1,9 +1,9 @@ -using Bit.Core.Pam.Models; -using Bit.Core.Pam.OrganizationFeatures.Queries.Interfaces; -using Bit.Core.Pam.Repositories; -using Bit.Core.Pam.Services; +using Bit.Pam.Models; +using Bit.Pam.OrganizationFeatures.Queries.Interfaces; +using Bit.Pam.Repositories; +using Bit.Pam.Services; -namespace Bit.Core.Pam.OrganizationFeatures.Queries; +namespace Bit.Pam.OrganizationFeatures.Queries; public class ListInboxRequestsQuery : IListInboxRequestsQuery { diff --git a/src/Core/Pam/OrganizationFeatures/Queries/ListLeaseHistoryQuery.cs b/src/Core/Pam/OrganizationFeatures/Queries/ListLeaseHistoryQuery.cs index ea49f2d1092f..dd437deb7dc9 100644 --- a/src/Core/Pam/OrganizationFeatures/Queries/ListLeaseHistoryQuery.cs +++ b/src/Core/Pam/OrganizationFeatures/Queries/ListLeaseHistoryQuery.cs @@ -1,9 +1,9 @@ -using Bit.Core.Pam.Entities; -using Bit.Core.Pam.OrganizationFeatures.Queries.Interfaces; -using Bit.Core.Pam.Repositories; -using Bit.Core.Pam.Services; +using Bit.Pam.Entities; +using Bit.Pam.OrganizationFeatures.Queries.Interfaces; +using Bit.Pam.Repositories; +using Bit.Pam.Services; -namespace Bit.Core.Pam.OrganizationFeatures.Queries; +namespace Bit.Pam.OrganizationFeatures.Queries; public class ListLeaseHistoryQuery : IListLeaseHistoryQuery { diff --git a/src/Core/Pam/OrganizationFeatures/Queries/ListMyAccessRequestsQuery.cs b/src/Core/Pam/OrganizationFeatures/Queries/ListMyAccessRequestsQuery.cs index d6959db280a6..58ccd1bd6b0f 100644 --- a/src/Core/Pam/OrganizationFeatures/Queries/ListMyAccessRequestsQuery.cs +++ b/src/Core/Pam/OrganizationFeatures/Queries/ListMyAccessRequestsQuery.cs @@ -1,8 +1,8 @@ -using Bit.Core.Pam.Models; -using Bit.Core.Pam.OrganizationFeatures.Queries.Interfaces; -using Bit.Core.Pam.Repositories; +using Bit.Pam.Models; +using Bit.Pam.OrganizationFeatures.Queries.Interfaces; +using Bit.Pam.Repositories; -namespace Bit.Core.Pam.OrganizationFeatures.Queries; +namespace Bit.Pam.OrganizationFeatures.Queries; public class ListMyAccessRequestsQuery : IListMyAccessRequestsQuery { diff --git a/src/Core/Pam/OrganizationFeatures/Queries/ListMyActiveAccessLeasesQuery.cs b/src/Core/Pam/OrganizationFeatures/Queries/ListMyActiveAccessLeasesQuery.cs index 09a818ab4b24..8c3cc5f0c77a 100644 --- a/src/Core/Pam/OrganizationFeatures/Queries/ListMyActiveAccessLeasesQuery.cs +++ b/src/Core/Pam/OrganizationFeatures/Queries/ListMyActiveAccessLeasesQuery.cs @@ -1,8 +1,8 @@ -using Bit.Core.Pam.Entities; -using Bit.Core.Pam.OrganizationFeatures.Queries.Interfaces; -using Bit.Core.Pam.Repositories; +using Bit.Pam.Entities; +using Bit.Pam.OrganizationFeatures.Queries.Interfaces; +using Bit.Pam.Repositories; -namespace Bit.Core.Pam.OrganizationFeatures.Queries; +namespace Bit.Pam.OrganizationFeatures.Queries; public class ListMyActiveAccessLeasesQuery : IListMyActiveAccessLeasesQuery { diff --git a/src/Core/Pam/Repositories/IAccessLeaseRepository.cs b/src/Core/Pam/Repositories/IAccessLeaseRepository.cs index 7a99f36570c0..d75965b77ea6 100644 --- a/src/Core/Pam/Repositories/IAccessLeaseRepository.cs +++ b/src/Core/Pam/Repositories/IAccessLeaseRepository.cs @@ -1,7 +1,7 @@ -using Bit.Core.Pam.Entities; -using Bit.Core.Pam.Enums; +using Bit.Pam.Entities; +using Bit.Pam.Enums; -namespace Bit.Core.Pam.Repositories; +namespace Bit.Pam.Repositories; public interface IAccessLeaseRepository { diff --git a/src/Core/Pam/Repositories/IAccessRequestRepository.cs b/src/Core/Pam/Repositories/IAccessRequestRepository.cs index 10167ef65ddc..875616976e4e 100644 --- a/src/Core/Pam/Repositories/IAccessRequestRepository.cs +++ b/src/Core/Pam/Repositories/IAccessRequestRepository.cs @@ -1,8 +1,8 @@ -using Bit.Core.Pam.Entities; -using Bit.Core.Pam.Enums; -using Bit.Core.Pam.Models; +using Bit.Pam.Entities; +using Bit.Pam.Enums; +using Bit.Pam.Models; -namespace Bit.Core.Pam.Repositories; +namespace Bit.Pam.Repositories; public interface IAccessRequestRepository { diff --git a/src/Core/Pam/Repositories/IAccessRuleRepository.cs b/src/Core/Pam/Repositories/IAccessRuleRepository.cs index c2fc29ea314e..f7aa426ca486 100644 --- a/src/Core/Pam/Repositories/IAccessRuleRepository.cs +++ b/src/Core/Pam/Repositories/IAccessRuleRepository.cs @@ -1,8 +1,8 @@ -using Bit.Core.Pam.Entities; -using Bit.Core.Pam.Models; -using Bit.Core.Repositories; +using Bit.Core.Repositories; +using Bit.Pam.Entities; +using Bit.Pam.Models; -namespace Bit.Core.Pam.Repositories; +namespace Bit.Pam.Repositories; public interface IAccessRuleRepository : IRepository { diff --git a/src/Core/Pam/Services/AccessRuleValidator.cs b/src/Core/Pam/Services/AccessRuleValidator.cs index 77898aaa1e47..9fd4f75bbe52 100644 --- a/src/Core/Pam/Services/AccessRuleValidator.cs +++ b/src/Core/Pam/Services/AccessRuleValidator.cs @@ -1,9 +1,9 @@ using System.Net; using System.Text.Json; using System.Text.RegularExpressions; -using Bit.Core.Pam.Models.Conditions; +using Bit.Pam.Models.Conditions; -namespace Bit.Core.Pam.Services; +namespace Bit.Pam.Services; public sealed partial class AccessRuleValidator : IAccessRuleValidator { diff --git a/src/Core/Pam/Services/ApproverCollectionAccessQuery.cs b/src/Core/Pam/Services/ApproverCollectionAccessQuery.cs index 260c9839032f..e2bbe4d0aec2 100644 --- a/src/Core/Pam/Services/ApproverCollectionAccessQuery.cs +++ b/src/Core/Pam/Services/ApproverCollectionAccessQuery.cs @@ -3,7 +3,7 @@ using Bit.Core.Repositories; using Bit.Core.Services; -namespace Bit.Core.Pam.Services; +namespace Bit.Pam.Services; public class ApproverCollectionAccessQuery : IApproverCollectionAccessQuery { diff --git a/src/Core/Pam/Services/ApproverInboxNotifier.cs b/src/Core/Pam/Services/ApproverInboxNotifier.cs index aadca81f86a5..c4fa2d2c49b1 100644 --- a/src/Core/Pam/Services/ApproverInboxNotifier.cs +++ b/src/Core/Pam/Services/ApproverInboxNotifier.cs @@ -1,7 +1,7 @@ using Bit.Core.Platform.Push; using Bit.Core.Repositories; -namespace Bit.Core.Pam.Services; +namespace Bit.Pam.Services; public class ApproverInboxNotifier : IApproverInboxNotifier { diff --git a/src/Core/Pam/Services/CipherLeaseGate.cs b/src/Core/Pam/Services/CipherLeaseGate.cs index 3082b4cb8d37..5eb3fc42172c 100644 --- a/src/Core/Pam/Services/CipherLeaseGate.cs +++ b/src/Core/Pam/Services/CipherLeaseGate.cs @@ -1,15 +1,16 @@ -using Bit.Core.Context; +using Bit.Core; +using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Exceptions; using Bit.Core.Models.Data; -using Bit.Core.Pam.Engine; -using Bit.Core.Pam.Repositories; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Vault.Authorization; using Bit.Core.Vault.Entities; +using Bit.Pam.Engine; +using Bit.Pam.Repositories; -namespace Bit.Core.Pam.Services; +namespace Bit.Pam.Services; /// public class CipherLeaseGate : ICipherLeaseGate diff --git a/src/Core/Pam/Services/GoverningRuleResolver.cs b/src/Core/Pam/Services/GoverningRuleResolver.cs index da7c74a4ba75..84437c85c640 100644 --- a/src/Core/Pam/Services/GoverningRuleResolver.cs +++ b/src/Core/Pam/Services/GoverningRuleResolver.cs @@ -1,11 +1,11 @@ using System.Text.Json; -using Bit.Core.Pam.Engine; -using Bit.Core.Pam.Models; -using Bit.Core.Pam.Models.Conditions; -using Bit.Core.Pam.Repositories; using Bit.Core.Repositories; +using Bit.Pam.Engine; +using Bit.Pam.Models; +using Bit.Pam.Models.Conditions; +using Bit.Pam.Repositories; -namespace Bit.Core.Pam.Services; +namespace Bit.Pam.Services; public class GoverningRuleResolver : IGoverningRuleResolver { diff --git a/src/Core/Pam/Services/IAccessRuleValidator.cs b/src/Core/Pam/Services/IAccessRuleValidator.cs index 1215e4e160dd..7166ac75bcdf 100644 --- a/src/Core/Pam/Services/IAccessRuleValidator.cs +++ b/src/Core/Pam/Services/IAccessRuleValidator.cs @@ -1,4 +1,4 @@ -namespace Bit.Core.Pam.Services; +namespace Bit.Pam.Services; public interface IAccessRuleValidator { diff --git a/src/Core/Pam/Services/IApproverCollectionAccessQuery.cs b/src/Core/Pam/Services/IApproverCollectionAccessQuery.cs index cd7317b8b9b6..d6c2b35ef93d 100644 --- a/src/Core/Pam/Services/IApproverCollectionAccessQuery.cs +++ b/src/Core/Pam/Services/IApproverCollectionAccessQuery.cs @@ -1,4 +1,4 @@ -namespace Bit.Core.Pam.Services; +namespace Bit.Pam.Services; /// /// Resolves which collections the current user can Manage — the single authorization predicate for the approver diff --git a/src/Core/Pam/Services/IApproverInboxNotifier.cs b/src/Core/Pam/Services/IApproverInboxNotifier.cs index 122606e1f906..ea5e57004eda 100644 --- a/src/Core/Pam/Services/IApproverInboxNotifier.cs +++ b/src/Core/Pam/Services/IApproverInboxNotifier.cs @@ -1,4 +1,4 @@ -namespace Bit.Core.Pam.Services; +namespace Bit.Pam.Services; /// /// Pushes the RefreshApproverInbox signal to every user who can Manage a collection, telling their clients to diff --git a/src/Core/Pam/Services/ICipherLeaseGate.cs b/src/Core/Pam/Services/ICipherLeaseGate.cs index 791c216a362d..68a3e6c15a52 100644 --- a/src/Core/Pam/Services/ICipherLeaseGate.cs +++ b/src/Core/Pam/Services/ICipherLeaseGate.cs @@ -3,7 +3,7 @@ using Bit.Core.Vault.Authorization; using Bit.Core.Vault.Entities; -namespace Bit.Core.Pam.Services; +namespace Bit.Pam.Services; /// /// The single decision point for PAM credential leasing in Vault code. A cipher reachable only through diff --git a/src/Core/Pam/Services/IGoverningRuleResolver.cs b/src/Core/Pam/Services/IGoverningRuleResolver.cs index 7b34ba45ac92..1d230b7c45a5 100644 --- a/src/Core/Pam/Services/IGoverningRuleResolver.cs +++ b/src/Core/Pam/Services/IGoverningRuleResolver.cs @@ -1,7 +1,7 @@ -using Bit.Core.Pam.Engine; -using Bit.Core.Pam.Models; +using Bit.Pam.Engine; +using Bit.Pam.Models; -namespace Bit.Core.Pam.Services; +namespace Bit.Pam.Services; public interface IGoverningRuleResolver { diff --git a/src/Core/Pam/Services/IRequesterNotifier.cs b/src/Core/Pam/Services/IRequesterNotifier.cs index bc819e650f75..c4d8a84f9ef9 100644 --- a/src/Core/Pam/Services/IRequesterNotifier.cs +++ b/src/Core/Pam/Services/IRequesterNotifier.cs @@ -1,4 +1,4 @@ -namespace Bit.Core.Pam.Services; +namespace Bit.Pam.Services; /// /// Pushes the RefreshAccessRequest signal to a single requester, telling their clients to re-fetch their own diff --git a/src/Core/Pam/Services/ISingleActiveLeaseEvaluator.cs b/src/Core/Pam/Services/ISingleActiveLeaseEvaluator.cs index 80005f45f80f..7a29201b1b34 100644 --- a/src/Core/Pam/Services/ISingleActiveLeaseEvaluator.cs +++ b/src/Core/Pam/Services/ISingleActiveLeaseEvaluator.cs @@ -1,4 +1,4 @@ -namespace Bit.Core.Pam.Services; +namespace Bit.Pam.Services; public interface ISingleActiveLeaseEvaluator { diff --git a/src/Core/Pam/Services/RequesterNotifier.cs b/src/Core/Pam/Services/RequesterNotifier.cs index 9a50f5b1b6ca..2371f63a7f84 100644 --- a/src/Core/Pam/Services/RequesterNotifier.cs +++ b/src/Core/Pam/Services/RequesterNotifier.cs @@ -1,6 +1,6 @@ -using Bit.Core.Platform.Push; +using Bit.Core.Platform.Push; -namespace Bit.Core.Pam.Services; +namespace Bit.Pam.Services; public class RequesterNotifier : IRequesterNotifier { diff --git a/src/Core/Pam/Services/SingleActiveLeaseEvaluator.cs b/src/Core/Pam/Services/SingleActiveLeaseEvaluator.cs index f7e4cc65beaa..f515d58b2ed8 100644 --- a/src/Core/Pam/Services/SingleActiveLeaseEvaluator.cs +++ b/src/Core/Pam/Services/SingleActiveLeaseEvaluator.cs @@ -1,7 +1,7 @@ -using Bit.Core.Pam.Repositories; -using Bit.Core.Repositories; +using Bit.Core.Repositories; +using Bit.Pam.Repositories; -namespace Bit.Core.Pam.Services; +namespace Bit.Pam.Services; public class SingleActiveLeaseEvaluator : ISingleActiveLeaseEvaluator { diff --git a/src/Core/Vault/Services/Implementations/CipherService.cs b/src/Core/Vault/Services/Implementations/CipherService.cs index 109190e90784..8a8fa0de8681 100644 --- a/src/Core/Vault/Services/Implementations/CipherService.cs +++ b/src/Core/Vault/Services/Implementations/CipherService.cs @@ -9,7 +9,6 @@ using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Data.Organizations; -using Bit.Core.Pam.Services; using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Services; @@ -21,6 +20,7 @@ using Bit.Core.Vault.Models.Data; using Bit.Core.Vault.Queries; using Bit.Core.Vault.Repositories; +using Bit.Pam.Services; namespace Bit.Core.Vault.Services; public class CipherService : ICipherService diff --git a/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs b/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs index 5ceb7a742e9e..11ad4846c399 100644 --- a/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs +++ b/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs @@ -8,7 +8,6 @@ using Bit.Core.Dirt.Repositories; using Bit.Core.KeyManagement.Repositories; using Bit.Core.NotificationCenter.Repositories; -using Bit.Core.Pam.Repositories; using Bit.Core.Platform.Installations; using Bit.Core.Repositories; using Bit.Core.SecretsManager.Repositories; @@ -26,6 +25,7 @@ using Bit.Infrastructure.Dapper.SecretsManager.Repositories; using Bit.Infrastructure.Dapper.Tools.Repositories; using Bit.Infrastructure.Dapper.Vault.Repositories; +using Bit.Pam.Repositories; using Microsoft.Extensions.DependencyInjection; namespace Bit.Infrastructure.Dapper; diff --git a/src/Infrastructure.Dapper/Pam/Repositories/AccessLeaseRepository.cs b/src/Infrastructure.Dapper/Pam/Repositories/AccessLeaseRepository.cs index bff21842d547..8ce083cbe1a9 100644 --- a/src/Infrastructure.Dapper/Pam/Repositories/AccessLeaseRepository.cs +++ b/src/Infrastructure.Dapper/Pam/Repositories/AccessLeaseRepository.cs @@ -1,9 +1,9 @@ using System.Data; -using Bit.Core.Pam.Entities; -using Bit.Core.Pam.Enums; -using Bit.Core.Pam.Repositories; using Bit.Core.Settings; using Bit.Infrastructure.Dapper.Repositories; +using Bit.Pam.Entities; +using Bit.Pam.Enums; +using Bit.Pam.Repositories; using Dapper; using Microsoft.Data.SqlClient; diff --git a/src/Infrastructure.Dapper/Pam/Repositories/AccessRequestRepository.cs b/src/Infrastructure.Dapper/Pam/Repositories/AccessRequestRepository.cs index 15ee6b9bea8e..28795fb1b502 100644 --- a/src/Infrastructure.Dapper/Pam/Repositories/AccessRequestRepository.cs +++ b/src/Infrastructure.Dapper/Pam/Repositories/AccessRequestRepository.cs @@ -1,10 +1,10 @@ using System.Data; -using Bit.Core.Pam.Entities; -using Bit.Core.Pam.Enums; -using Bit.Core.Pam.Models; -using Bit.Core.Pam.Repositories; using Bit.Core.Settings; 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; diff --git a/src/Infrastructure.Dapper/Pam/Repositories/AccessRuleRepository.cs b/src/Infrastructure.Dapper/Pam/Repositories/AccessRuleRepository.cs index 03d468bf9e4f..50d64955838c 100644 --- a/src/Infrastructure.Dapper/Pam/Repositories/AccessRuleRepository.cs +++ b/src/Infrastructure.Dapper/Pam/Repositories/AccessRuleRepository.cs @@ -1,9 +1,9 @@ using System.Data; -using Bit.Core.Pam.Entities; -using Bit.Core.Pam.Models; -using Bit.Core.Pam.Repositories; 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; diff --git a/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs b/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs index 94bd0e677532..1d4c7474ca56 100644 --- a/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs +++ b/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs @@ -9,7 +9,6 @@ using Bit.Core.Enums; using Bit.Core.KeyManagement.Repositories; using Bit.Core.NotificationCenter.Repositories; -using Bit.Core.Pam.Repositories; using Bit.Core.Platform.Installations; using Bit.Core.Repositories; using Bit.Core.SecretsManager.Repositories; @@ -27,6 +26,7 @@ using Bit.Infrastructure.EntityFramework.SecretsManager.Repositories; using Bit.Infrastructure.EntityFramework.Tools.Repositories; using Bit.Infrastructure.EntityFramework.Vault.Repositories; +using Bit.Pam.Repositories; using LinqToDB.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; diff --git a/src/Infrastructure.EntityFramework/Pam/Models/AccessRule.cs b/src/Infrastructure.EntityFramework/Pam/Models/AccessRule.cs index 3ea2c533515a..8fb27d2990bd 100644 --- a/src/Infrastructure.EntityFramework/Pam/Models/AccessRule.cs +++ b/src/Infrastructure.EntityFramework/Pam/Models/AccessRule.cs @@ -6,7 +6,7 @@ namespace Bit.Infrastructure.EntityFramework.Pam.Models; -public class AccessRule : Core.Pam.Entities.AccessRule +public class AccessRule : Bit.Pam.Entities.AccessRule { public virtual Organization Organization { get; set; } } @@ -15,7 +15,7 @@ public class AccessRuleMapperProfile : Profile { public AccessRuleMapperProfile() { - CreateMap().ReverseMap(); - CreateMap(); + CreateMap().ReverseMap(); + CreateMap(); } } diff --git a/src/Infrastructure.EntityFramework/Pam/Repositories/AccessRuleRepository.cs b/src/Infrastructure.EntityFramework/Pam/Repositories/AccessRuleRepository.cs index 76650121393f..87862f65713b 100644 --- a/src/Infrastructure.EntityFramework/Pam/Repositories/AccessRuleRepository.cs +++ b/src/Infrastructure.EntityFramework/Pam/Repositories/AccessRuleRepository.cs @@ -1,10 +1,10 @@ using AutoMapper; -using Bit.Core.Pam.Models; -using Bit.Core.Pam.Repositories; using Bit.Infrastructure.EntityFramework.Repositories; +using Bit.Pam.Models; +using Bit.Pam.Repositories; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; -using CoreEntity = Bit.Core.Pam.Entities.AccessRule; +using CoreEntity = Bit.Pam.Entities.AccessRule; using EfModel = Bit.Infrastructure.EntityFramework.Pam.Models.AccessRule; #nullable enable diff --git a/test/Api.Test/Pam/Controllers/AccessRequestsControllerTests.cs b/test/Api.Test/Pam/Controllers/AccessRequestsControllerTests.cs index a7b26fd6c0d7..f8215fbcb51f 100644 --- a/test/Api.Test/Pam/Controllers/AccessRequestsControllerTests.cs +++ b/test/Api.Test/Pam/Controllers/AccessRequestsControllerTests.cs @@ -1,12 +1,12 @@ using System.Security.Claims; using Bit.Api.Pam.Controllers; using Bit.Api.Pam.Models.Request; -using Bit.Core.Pam.Entities; -using Bit.Core.Pam.Enums; -using Bit.Core.Pam.Models; -using Bit.Core.Pam.OrganizationFeatures.Commands.Interfaces; -using Bit.Core.Pam.OrganizationFeatures.Queries.Interfaces; using Bit.Core.Services; +using Bit.Pam.Entities; +using Bit.Pam.Enums; +using Bit.Pam.Models; +using Bit.Pam.OrganizationFeatures.Commands.Interfaces; +using Bit.Pam.OrganizationFeatures.Queries.Interfaces; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using Microsoft.AspNetCore.Mvc; diff --git a/test/Api.Test/Pam/Controllers/CipherLeaseControllerTests.cs b/test/Api.Test/Pam/Controllers/CipherLeaseControllerTests.cs index ca4254f363c3..212cabbb08a7 100644 --- a/test/Api.Test/Pam/Controllers/CipherLeaseControllerTests.cs +++ b/test/Api.Test/Pam/Controllers/CipherLeaseControllerTests.cs @@ -4,10 +4,10 @@ using Bit.Core.Entities; using Bit.Core.Exceptions; using Bit.Core.Models.Data.Organizations; -using Bit.Core.Pam.OrganizationFeatures.Queries.Interfaces; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Vault.Models.Data; +using Bit.Pam.OrganizationFeatures.Queries.Interfaces; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; @@ -24,14 +24,14 @@ public class CipherLeaseControllerTests { [Theory, BitAutoData] public async Task State_ReturnsSnapshotFromQuery( - Guid id, Guid userId, Bit.Core.Pam.Entities.AccessLease activeLease, SutProvider sutProvider) + Guid id, Guid userId, Bit.Pam.Entities.AccessLease activeLease, SutProvider sutProvider) { sutProvider.GetDependency() .GetProperUserId(Arg.Any()) .Returns(userId); sutProvider.GetDependency() .GetStateAsync(userId, id) - .Returns(new Bit.Core.Pam.Models.CipherAccessState(id, activeLease, null, null)); + .Returns(new Bit.Pam.Models.CipherAccessState(id, activeLease, null, null)); var result = await sutProvider.Sut.State(id); diff --git a/test/Api.Test/Pam/Controllers/LeasesControllerTests.cs b/test/Api.Test/Pam/Controllers/LeasesControllerTests.cs index 916852ed3987..6680bcea062d 100644 --- a/test/Api.Test/Pam/Controllers/LeasesControllerTests.cs +++ b/test/Api.Test/Pam/Controllers/LeasesControllerTests.cs @@ -1,12 +1,12 @@ using System.Security.Claims; using Bit.Api.Pam.Controllers; using Bit.Api.Pam.Models.Request; -using Bit.Core.Pam.Entities; -using Bit.Core.Pam.Enums; -using Bit.Core.Pam.Models; -using Bit.Core.Pam.OrganizationFeatures.Commands.Interfaces; -using Bit.Core.Pam.OrganizationFeatures.Queries.Interfaces; using Bit.Core.Services; +using Bit.Pam.Entities; +using Bit.Pam.Enums; +using Bit.Pam.Models; +using Bit.Pam.OrganizationFeatures.Commands.Interfaces; +using Bit.Pam.OrganizationFeatures.Queries.Interfaces; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using Microsoft.AspNetCore.Mvc; diff --git a/test/Api.Test/Pam/Models/AccessDecisionRequestModelTests.cs b/test/Api.Test/Pam/Models/AccessDecisionRequestModelTests.cs index aefc6b87814a..a6c1c0c1d804 100644 --- a/test/Api.Test/Pam/Models/AccessDecisionRequestModelTests.cs +++ b/test/Api.Test/Pam/Models/AccessDecisionRequestModelTests.cs @@ -1,7 +1,7 @@ using System.ComponentModel.DataAnnotations; using System.Text.Json; using Bit.Api.Pam.Models.Request; -using Bit.Core.Pam.Enums; +using Bit.Pam.Enums; using Xunit; namespace Bit.Api.Test.Pam.Models; diff --git a/test/Api.Test/Pam/Models/AccessLeaseResponseModelTests.cs b/test/Api.Test/Pam/Models/AccessLeaseResponseModelTests.cs index df5528b966fa..2462df990315 100644 --- a/test/Api.Test/Pam/Models/AccessLeaseResponseModelTests.cs +++ b/test/Api.Test/Pam/Models/AccessLeaseResponseModelTests.cs @@ -1,7 +1,7 @@ using Bit.Api.Pam.Models.Response; -using Bit.Core.Pam.Entities; -using Bit.Core.Pam.Enums; -using Bit.Core.Pam.Models; +using Bit.Pam.Entities; +using Bit.Pam.Enums; +using Bit.Pam.Models; using Bit.Test.Common.AutoFixture.Attributes; using Xunit; diff --git a/test/Api.Test/Pam/Models/AccessRequestDetailsResponseModelTests.cs b/test/Api.Test/Pam/Models/AccessRequestDetailsResponseModelTests.cs index 9f15ddc11cf9..8eb90cc38bc0 100644 --- a/test/Api.Test/Pam/Models/AccessRequestDetailsResponseModelTests.cs +++ b/test/Api.Test/Pam/Models/AccessRequestDetailsResponseModelTests.cs @@ -1,6 +1,6 @@ -using Bit.Api.Pam.Models.Response; -using Bit.Core.Pam.Enums; -using Bit.Core.Pam.Models; +using Bit.Api.Pam.Models.Response; +using Bit.Pam.Enums; +using Bit.Pam.Models; using Xunit; namespace Bit.Api.Test.Pam.Models; diff --git a/test/Api.Test/Vault/AutoFixture/CipherLeaseGateBypassCustomization.cs b/test/Api.Test/Vault/AutoFixture/CipherLeaseGateBypassCustomization.cs index 78f8299ae8c8..92c1f1599b9d 100644 --- a/test/Api.Test/Vault/AutoFixture/CipherLeaseGateBypassCustomization.cs +++ b/test/Api.Test/Vault/AutoFixture/CipherLeaseGateBypassCustomization.cs @@ -1,9 +1,9 @@ using AutoFixture; using Bit.Core.Entities; using Bit.Core.Models.Data; -using Bit.Core.Pam.Services; using Bit.Core.Vault.Authorization; using Bit.Core.Vault.Entities; +using Bit.Pam.Services; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; diff --git a/test/Api.Test/Vault/Controllers/SyncControllerTests.cs b/test/Api.Test/Vault/Controllers/SyncControllerTests.cs index ac807e173cea..5e6267e6e761 100644 --- a/test/Api.Test/Vault/Controllers/SyncControllerTests.cs +++ b/test/Api.Test/Vault/Controllers/SyncControllerTests.cs @@ -19,7 +19,6 @@ using Bit.Core.Models.Data; using Bit.Core.Models.Data.Organizations; using Bit.Core.Models.Data.Organizations.OrganizationUsers; -using Bit.Core.Pam.Services; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Test.Billing.Mocks; @@ -30,6 +29,7 @@ using Bit.Core.Vault.Enums; using Bit.Core.Vault.Models.Data; using Bit.Core.Vault.Repositories; +using Bit.Pam.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; diff --git a/test/Core.Test/Pam/Commands/ActivateAccessRequestCommandTests.cs b/test/Core.Test/Pam/Commands/ActivateAccessRequestCommandTests.cs index 697267be604e..f2b3c729f85f 100644 --- a/test/Core.Test/Pam/Commands/ActivateAccessRequestCommandTests.cs +++ b/test/Core.Test/Pam/Commands/ActivateAccessRequestCommandTests.cs @@ -1,9 +1,9 @@ -using Bit.Core.Exceptions; -using Bit.Core.Pam.Entities; -using Bit.Core.Pam.Enums; -using Bit.Core.Pam.OrganizationFeatures.Commands; -using Bit.Core.Pam.Repositories; -using Bit.Core.Pam.Services; +using Bit.Core.Exceptions; +using Bit.Pam.Entities; +using Bit.Pam.Enums; +using Bit.Pam.OrganizationFeatures.Commands; +using Bit.Pam.Repositories; +using Bit.Pam.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using Microsoft.Extensions.Time.Testing; diff --git a/test/Core.Test/Pam/Commands/CancelAccessRequestCommandTests.cs b/test/Core.Test/Pam/Commands/CancelAccessRequestCommandTests.cs index 58ecbd08c126..56a05c47a2aa 100644 --- a/test/Core.Test/Pam/Commands/CancelAccessRequestCommandTests.cs +++ b/test/Core.Test/Pam/Commands/CancelAccessRequestCommandTests.cs @@ -1,9 +1,9 @@ -using Bit.Core.Exceptions; -using Bit.Core.Pam.Entities; -using Bit.Core.Pam.Enums; -using Bit.Core.Pam.OrganizationFeatures.Commands; -using Bit.Core.Pam.Repositories; -using Bit.Core.Pam.Services; +using Bit.Core.Exceptions; +using Bit.Pam.Entities; +using Bit.Pam.Enums; +using Bit.Pam.OrganizationFeatures.Commands; +using Bit.Pam.Repositories; +using Bit.Pam.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using Microsoft.Extensions.Time.Testing; diff --git a/test/Core.Test/Pam/Commands/CreateAccessRuleCommandTests.cs b/test/Core.Test/Pam/Commands/CreateAccessRuleCommandTests.cs index c2e3c3375d87..fed46e3dd27c 100644 --- a/test/Core.Test/Pam/Commands/CreateAccessRuleCommandTests.cs +++ b/test/Core.Test/Pam/Commands/CreateAccessRuleCommandTests.cs @@ -1,10 +1,10 @@ using Bit.Core.Entities; using Bit.Core.Exceptions; -using Bit.Core.Pam.Entities; -using Bit.Core.Pam.OrganizationFeatures.Commands; -using Bit.Core.Pam.Repositories; -using Bit.Core.Pam.Services; using Bit.Core.Repositories; +using Bit.Pam.Entities; +using Bit.Pam.OrganizationFeatures.Commands; +using Bit.Pam.Repositories; +using Bit.Pam.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using Microsoft.Extensions.Time.Testing; diff --git a/test/Core.Test/Pam/Commands/DecideAccessRequestCommandTests.cs b/test/Core.Test/Pam/Commands/DecideAccessRequestCommandTests.cs index 037b76550d9a..ca570b7a33b5 100644 --- a/test/Core.Test/Pam/Commands/DecideAccessRequestCommandTests.cs +++ b/test/Core.Test/Pam/Commands/DecideAccessRequestCommandTests.cs @@ -1,10 +1,10 @@ using Bit.Core.Exceptions; -using Bit.Core.Pam.Entities; -using Bit.Core.Pam.Enums; -using Bit.Core.Pam.Models; -using Bit.Core.Pam.OrganizationFeatures.Commands; -using Bit.Core.Pam.Repositories; -using Bit.Core.Pam.Services; +using Bit.Pam.Entities; +using Bit.Pam.Enums; +using Bit.Pam.Models; +using Bit.Pam.OrganizationFeatures.Commands; +using Bit.Pam.Repositories; +using Bit.Pam.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using Microsoft.Extensions.Time.Testing; diff --git a/test/Core.Test/Pam/Commands/DeleteAccessRuleCommandTests.cs b/test/Core.Test/Pam/Commands/DeleteAccessRuleCommandTests.cs index 2c2f0e3aadc4..6403114a6cbc 100644 --- a/test/Core.Test/Pam/Commands/DeleteAccessRuleCommandTests.cs +++ b/test/Core.Test/Pam/Commands/DeleteAccessRuleCommandTests.cs @@ -1,7 +1,7 @@ using Bit.Core.Exceptions; -using Bit.Core.Pam.Entities; -using Bit.Core.Pam.OrganizationFeatures.Commands; -using Bit.Core.Pam.Repositories; +using Bit.Pam.Entities; +using Bit.Pam.OrganizationFeatures.Commands; +using Bit.Pam.Repositories; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; diff --git a/test/Core.Test/Pam/Commands/RequestLeaseExtensionCommandTests.cs b/test/Core.Test/Pam/Commands/RequestLeaseExtensionCommandTests.cs index 2b5d0010b4f5..c49b1d24f595 100644 --- a/test/Core.Test/Pam/Commands/RequestLeaseExtensionCommandTests.cs +++ b/test/Core.Test/Pam/Commands/RequestLeaseExtensionCommandTests.cs @@ -1,12 +1,12 @@ using Bit.Core.Exceptions; -using Bit.Core.Pam.Engine; -using Bit.Core.Pam.Entities; -using Bit.Core.Pam.Enums; -using Bit.Core.Pam.Models; -using Bit.Core.Pam.Models.Conditions; -using Bit.Core.Pam.OrganizationFeatures.Commands; -using Bit.Core.Pam.Repositories; -using Bit.Core.Pam.Services; +using Bit.Pam.Engine; +using Bit.Pam.Entities; +using Bit.Pam.Enums; +using Bit.Pam.Models; +using Bit.Pam.Models.Conditions; +using Bit.Pam.OrganizationFeatures.Commands; +using Bit.Pam.Repositories; +using Bit.Pam.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using Microsoft.Extensions.Time.Testing; diff --git a/test/Core.Test/Pam/Commands/RevokeAccessLeaseCommandTests.cs b/test/Core.Test/Pam/Commands/RevokeAccessLeaseCommandTests.cs index 77296dd8cc92..f23e3d6cb709 100644 --- a/test/Core.Test/Pam/Commands/RevokeAccessLeaseCommandTests.cs +++ b/test/Core.Test/Pam/Commands/RevokeAccessLeaseCommandTests.cs @@ -1,9 +1,9 @@ using Bit.Core.Exceptions; -using Bit.Core.Pam.Entities; -using Bit.Core.Pam.Enums; -using Bit.Core.Pam.OrganizationFeatures.Commands; -using Bit.Core.Pam.Repositories; -using Bit.Core.Pam.Services; +using Bit.Pam.Entities; +using Bit.Pam.Enums; +using Bit.Pam.OrganizationFeatures.Commands; +using Bit.Pam.Repositories; +using Bit.Pam.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using Microsoft.Extensions.Time.Testing; diff --git a/test/Core.Test/Pam/Commands/SubmitAccessRequestCommandTests.cs b/test/Core.Test/Pam/Commands/SubmitAccessRequestCommandTests.cs index f0f8a2f77479..ae945c5c3580 100644 --- a/test/Core.Test/Pam/Commands/SubmitAccessRequestCommandTests.cs +++ b/test/Core.Test/Pam/Commands/SubmitAccessRequestCommandTests.cs @@ -1,18 +1,18 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.Entities; using Bit.Core.Exceptions; -using Bit.Core.Pam.Engine; -using Bit.Core.Pam.Entities; -using Bit.Core.Pam.Enums; -using Bit.Core.Pam.Models; -using Bit.Core.Pam.Models.Conditions; -using Bit.Core.Pam.OrganizationFeatures.Commands; -using Bit.Core.Pam.Repositories; -using Bit.Core.Pam.Services; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Vault.Models.Data; using Bit.Core.Vault.Repositories; +using Bit.Pam.Engine; +using Bit.Pam.Entities; +using Bit.Pam.Enums; +using Bit.Pam.Models; +using Bit.Pam.Models.Conditions; +using Bit.Pam.OrganizationFeatures.Commands; +using Bit.Pam.Repositories; +using Bit.Pam.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using Microsoft.Extensions.Time.Testing; diff --git a/test/Core.Test/Pam/Commands/UpdateAccessRuleCommandTests.cs b/test/Core.Test/Pam/Commands/UpdateAccessRuleCommandTests.cs index ffb312b08f89..ad8d7a8d2693 100644 --- a/test/Core.Test/Pam/Commands/UpdateAccessRuleCommandTests.cs +++ b/test/Core.Test/Pam/Commands/UpdateAccessRuleCommandTests.cs @@ -1,11 +1,11 @@ using Bit.Core.Entities; using Bit.Core.Exceptions; -using Bit.Core.Pam.Entities; -using Bit.Core.Pam.Models; -using Bit.Core.Pam.OrganizationFeatures.Commands; -using Bit.Core.Pam.Repositories; -using Bit.Core.Pam.Services; using Bit.Core.Repositories; +using Bit.Pam.Entities; +using Bit.Pam.Models; +using Bit.Pam.OrganizationFeatures.Commands; +using Bit.Pam.Repositories; +using Bit.Pam.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using Microsoft.Extensions.Time.Testing; diff --git a/test/Core.Test/Pam/Engine/AccessRuleEngineTests.cs b/test/Core.Test/Pam/Engine/AccessRuleEngineTests.cs index d3351be73433..c038c40a96c9 100644 --- a/test/Core.Test/Pam/Engine/AccessRuleEngineTests.cs +++ b/test/Core.Test/Pam/Engine/AccessRuleEngineTests.cs @@ -1,7 +1,7 @@ using System.Net; -using Bit.Core.Pam.Engine; -using Bit.Core.Pam.Enums; -using Bit.Core.Pam.Models.Conditions; +using Bit.Pam.Engine; +using Bit.Pam.Enums; +using Bit.Pam.Models.Conditions; using Xunit; namespace Bit.Core.Test.Pam.Engine; diff --git a/test/Core.Test/Pam/Models/AccessLeaseStatusNamesTests.cs b/test/Core.Test/Pam/Models/AccessLeaseStatusNamesTests.cs index d08b1dcda42e..a381d1ffb82b 100644 --- a/test/Core.Test/Pam/Models/AccessLeaseStatusNamesTests.cs +++ b/test/Core.Test/Pam/Models/AccessLeaseStatusNamesTests.cs @@ -1,5 +1,5 @@ -using Bit.Core.Pam.Enums; -using Bit.Core.Pam.Models; +using Bit.Pam.Enums; +using Bit.Pam.Models; using Xunit; namespace Bit.Core.Test.Pam.Models; diff --git a/test/Core.Test/Pam/Models/AccessRequestStatusNamesTests.cs b/test/Core.Test/Pam/Models/AccessRequestStatusNamesTests.cs index 8ef20d29ff0b..031dd2f4f7ff 100644 --- a/test/Core.Test/Pam/Models/AccessRequestStatusNamesTests.cs +++ b/test/Core.Test/Pam/Models/AccessRequestStatusNamesTests.cs @@ -1,5 +1,5 @@ -using Bit.Core.Pam.Enums; -using Bit.Core.Pam.Models; +using Bit.Pam.Enums; +using Bit.Pam.Models; using Xunit; namespace Bit.Core.Test.Pam.Models; diff --git a/test/Core.Test/Pam/Models/Conditions/AccessWeekdayJsonConverterTests.cs b/test/Core.Test/Pam/Models/Conditions/AccessWeekdayJsonConverterTests.cs index 5c08f48c25cd..f63f0b2109b1 100644 --- a/test/Core.Test/Pam/Models/Conditions/AccessWeekdayJsonConverterTests.cs +++ b/test/Core.Test/Pam/Models/Conditions/AccessWeekdayJsonConverterTests.cs @@ -1,5 +1,5 @@ using System.Text.Json; -using Bit.Core.Pam.Enums; +using Bit.Pam.Enums; using Xunit; namespace Bit.Core.Test.Pam.Models.Conditions; diff --git a/test/Core.Test/Pam/Queries/AccessPreCheckQueryTests.cs b/test/Core.Test/Pam/Queries/AccessPreCheckQueryTests.cs index e12d9659673a..49a18089fe8e 100644 --- a/test/Core.Test/Pam/Queries/AccessPreCheckQueryTests.cs +++ b/test/Core.Test/Pam/Queries/AccessPreCheckQueryTests.cs @@ -1,14 +1,14 @@ using Bit.Core.Exceptions; -using Bit.Core.Pam.Engine; -using Bit.Core.Pam.Entities; -using Bit.Core.Pam.Enums; -using Bit.Core.Pam.Models; -using Bit.Core.Pam.Models.Conditions; -using Bit.Core.Pam.OrganizationFeatures.Queries; -using Bit.Core.Pam.Repositories; -using Bit.Core.Pam.Services; using Bit.Core.Vault.Models.Data; using Bit.Core.Vault.Repositories; +using Bit.Pam.Engine; +using Bit.Pam.Entities; +using Bit.Pam.Enums; +using Bit.Pam.Models; +using Bit.Pam.Models.Conditions; +using Bit.Pam.OrganizationFeatures.Queries; +using Bit.Pam.Repositories; +using Bit.Pam.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; diff --git a/test/Core.Test/Pam/Queries/GetCipherAccessStateQueryTests.cs b/test/Core.Test/Pam/Queries/GetCipherAccessStateQueryTests.cs index f034d81d8691..f81721e2982f 100644 --- a/test/Core.Test/Pam/Queries/GetCipherAccessStateQueryTests.cs +++ b/test/Core.Test/Pam/Queries/GetCipherAccessStateQueryTests.cs @@ -1,14 +1,14 @@ using Bit.Core.Exceptions; -using Bit.Core.Pam.Engine; -using Bit.Core.Pam.Entities; -using Bit.Core.Pam.Enums; -using Bit.Core.Pam.Models; -using Bit.Core.Pam.Models.Conditions; -using Bit.Core.Pam.OrganizationFeatures.Queries; -using Bit.Core.Pam.Repositories; -using Bit.Core.Pam.Services; using Bit.Core.Vault.Models.Data; using Bit.Core.Vault.Repositories; +using Bit.Pam.Engine; +using Bit.Pam.Entities; +using Bit.Pam.Enums; +using Bit.Pam.Models; +using Bit.Pam.Models.Conditions; +using Bit.Pam.OrganizationFeatures.Queries; +using Bit.Pam.Repositories; +using Bit.Pam.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; diff --git a/test/Core.Test/Pam/Queries/GetLeasedCipherQueryTests.cs b/test/Core.Test/Pam/Queries/GetLeasedCipherQueryTests.cs index 8f4bb7f070b6..232eb19b3d00 100644 --- a/test/Core.Test/Pam/Queries/GetLeasedCipherQueryTests.cs +++ b/test/Core.Test/Pam/Queries/GetLeasedCipherQueryTests.cs @@ -1,12 +1,12 @@ -using Bit.Core.Pam.Engine; -using Bit.Core.Pam.Entities; -using Bit.Core.Pam.Models; -using Bit.Core.Pam.Models.Conditions; -using Bit.Core.Pam.OrganizationFeatures.Queries; -using Bit.Core.Pam.Repositories; -using Bit.Core.Pam.Services; -using Bit.Core.Vault.Models.Data; +using Bit.Core.Vault.Models.Data; using Bit.Core.Vault.Repositories; +using Bit.Pam.Engine; +using Bit.Pam.Entities; +using Bit.Pam.Models; +using Bit.Pam.Models.Conditions; +using Bit.Pam.OrganizationFeatures.Queries; +using Bit.Pam.Repositories; +using Bit.Pam.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using Microsoft.Extensions.Time.Testing; diff --git a/test/Core.Test/Pam/Queries/ListActiveLeasesQueryTests.cs b/test/Core.Test/Pam/Queries/ListActiveLeasesQueryTests.cs index 25dbc806e8a5..061f80f2b530 100644 --- a/test/Core.Test/Pam/Queries/ListActiveLeasesQueryTests.cs +++ b/test/Core.Test/Pam/Queries/ListActiveLeasesQueryTests.cs @@ -1,7 +1,7 @@ -using Bit.Core.Pam.Entities; -using Bit.Core.Pam.OrganizationFeatures.Queries; -using Bit.Core.Pam.Repositories; -using Bit.Core.Pam.Services; +using Bit.Pam.Entities; +using Bit.Pam.OrganizationFeatures.Queries; +using Bit.Pam.Repositories; +using Bit.Pam.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using Microsoft.Extensions.Time.Testing; diff --git a/test/Core.Test/Pam/Queries/ListInboxHistoryQueryTests.cs b/test/Core.Test/Pam/Queries/ListInboxHistoryQueryTests.cs index d01cd86bf2c1..457785a3f0f0 100644 --- a/test/Core.Test/Pam/Queries/ListInboxHistoryQueryTests.cs +++ b/test/Core.Test/Pam/Queries/ListInboxHistoryQueryTests.cs @@ -1,7 +1,7 @@ -using Bit.Core.Pam.Models; -using Bit.Core.Pam.OrganizationFeatures.Queries; -using Bit.Core.Pam.Repositories; -using Bit.Core.Pam.Services; +using Bit.Pam.Models; +using Bit.Pam.OrganizationFeatures.Queries; +using Bit.Pam.Repositories; +using Bit.Pam.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using Microsoft.Extensions.Time.Testing; diff --git a/test/Core.Test/Pam/Queries/ListInboxRequestsQueryTests.cs b/test/Core.Test/Pam/Queries/ListInboxRequestsQueryTests.cs index e883177d027b..f73e0c122a4b 100644 --- a/test/Core.Test/Pam/Queries/ListInboxRequestsQueryTests.cs +++ b/test/Core.Test/Pam/Queries/ListInboxRequestsQueryTests.cs @@ -1,7 +1,7 @@ -using Bit.Core.Pam.Models; -using Bit.Core.Pam.OrganizationFeatures.Queries; -using Bit.Core.Pam.Repositories; -using Bit.Core.Pam.Services; +using Bit.Pam.Models; +using Bit.Pam.OrganizationFeatures.Queries; +using Bit.Pam.Repositories; +using Bit.Pam.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; diff --git a/test/Core.Test/Pam/Queries/ListLeaseHistoryQueryTests.cs b/test/Core.Test/Pam/Queries/ListLeaseHistoryQueryTests.cs index 69bc27c3db9c..cd355ef7de7e 100644 --- a/test/Core.Test/Pam/Queries/ListLeaseHistoryQueryTests.cs +++ b/test/Core.Test/Pam/Queries/ListLeaseHistoryQueryTests.cs @@ -1,7 +1,7 @@ -using Bit.Core.Pam.Entities; -using Bit.Core.Pam.OrganizationFeatures.Queries; -using Bit.Core.Pam.Repositories; -using Bit.Core.Pam.Services; +using Bit.Pam.Entities; +using Bit.Pam.OrganizationFeatures.Queries; +using Bit.Pam.Repositories; +using Bit.Pam.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using Microsoft.Extensions.Time.Testing; diff --git a/test/Core.Test/Pam/Queries/ListMyAccessRequestsQueryTests.cs b/test/Core.Test/Pam/Queries/ListMyAccessRequestsQueryTests.cs index e676be8e47e3..110e8e7ec522 100644 --- a/test/Core.Test/Pam/Queries/ListMyAccessRequestsQueryTests.cs +++ b/test/Core.Test/Pam/Queries/ListMyAccessRequestsQueryTests.cs @@ -1,6 +1,6 @@ -using Bit.Core.Pam.Models; -using Bit.Core.Pam.OrganizationFeatures.Queries; -using Bit.Core.Pam.Repositories; +using Bit.Pam.Models; +using Bit.Pam.OrganizationFeatures.Queries; +using Bit.Pam.Repositories; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; diff --git a/test/Core.Test/Pam/Queries/ListMyActiveAccessLeasesQueryTests.cs b/test/Core.Test/Pam/Queries/ListMyActiveAccessLeasesQueryTests.cs index ab162ef2843b..27b1564af1cd 100644 --- a/test/Core.Test/Pam/Queries/ListMyActiveAccessLeasesQueryTests.cs +++ b/test/Core.Test/Pam/Queries/ListMyActiveAccessLeasesQueryTests.cs @@ -1,6 +1,6 @@ -using Bit.Core.Pam.Entities; -using Bit.Core.Pam.OrganizationFeatures.Queries; -using Bit.Core.Pam.Repositories; +using Bit.Pam.Entities; +using Bit.Pam.OrganizationFeatures.Queries; +using Bit.Pam.Repositories; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; diff --git a/test/Core.Test/Pam/Services/AccessRuleValidatorTests.cs b/test/Core.Test/Pam/Services/AccessRuleValidatorTests.cs index df0d84be50c3..70d7cd263597 100644 --- a/test/Core.Test/Pam/Services/AccessRuleValidatorTests.cs +++ b/test/Core.Test/Pam/Services/AccessRuleValidatorTests.cs @@ -1,4 +1,4 @@ -using Bit.Core.Pam.Services; +using Bit.Pam.Services; using Xunit; namespace Bit.Core.Test.Pam.Services; diff --git a/test/Core.Test/Pam/Services/ApproverCollectionAccessQueryTests.cs b/test/Core.Test/Pam/Services/ApproverCollectionAccessQueryTests.cs index 2547e0361ab2..21b6caa1487b 100644 --- a/test/Core.Test/Pam/Services/ApproverCollectionAccessQueryTests.cs +++ b/test/Core.Test/Pam/Services/ApproverCollectionAccessQueryTests.cs @@ -3,9 +3,9 @@ using Bit.Core.Enums; using Bit.Core.Models.Data; using Bit.Core.Models.Data.Organizations; -using Bit.Core.Pam.Services; using Bit.Core.Repositories; using Bit.Core.Services; +using Bit.Pam.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; diff --git a/test/Core.Test/Pam/Services/ApproverInboxNotifierTests.cs b/test/Core.Test/Pam/Services/ApproverInboxNotifierTests.cs index 9ca237122634..de59c2aa4d59 100644 --- a/test/Core.Test/Pam/Services/ApproverInboxNotifierTests.cs +++ b/test/Core.Test/Pam/Services/ApproverInboxNotifierTests.cs @@ -1,6 +1,6 @@ -using Bit.Core.Pam.Services; -using Bit.Core.Platform.Push; +using Bit.Core.Platform.Push; using Bit.Core.Repositories; +using Bit.Pam.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; diff --git a/test/Core.Test/Pam/Services/CipherLeaseGateTests.cs b/test/Core.Test/Pam/Services/CipherLeaseGateTests.cs index e2667b295b5d..ff8a75e3e88c 100644 --- a/test/Core.Test/Pam/Services/CipherLeaseGateTests.cs +++ b/test/Core.Test/Pam/Services/CipherLeaseGateTests.cs @@ -1,14 +1,14 @@ using Bit.Core.Entities; using Bit.Core.Exceptions; using Bit.Core.Models.Data; -using Bit.Core.Pam.Engine; -using Bit.Core.Pam.Entities; -using Bit.Core.Pam.Models; -using Bit.Core.Pam.Models.Conditions; -using Bit.Core.Pam.Repositories; -using Bit.Core.Pam.Services; using Bit.Core.Services; using Bit.Core.Vault.Entities; +using Bit.Pam.Engine; +using Bit.Pam.Entities; +using Bit.Pam.Models; +using Bit.Pam.Models.Conditions; +using Bit.Pam.Repositories; +using Bit.Pam.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; diff --git a/test/Core.Test/Pam/Services/GoverningRuleResolverTests.cs b/test/Core.Test/Pam/Services/GoverningRuleResolverTests.cs index 4e246eada9b2..32fb9ee71f3a 100644 --- a/test/Core.Test/Pam/Services/GoverningRuleResolverTests.cs +++ b/test/Core.Test/Pam/Services/GoverningRuleResolverTests.cs @@ -1,11 +1,11 @@ using System.Net; using Bit.Core.Entities; -using Bit.Core.Pam.Engine; -using Bit.Core.Pam.Entities; -using Bit.Core.Pam.Models.Conditions; -using Bit.Core.Pam.Repositories; -using Bit.Core.Pam.Services; using Bit.Core.Repositories; +using Bit.Pam.Engine; +using Bit.Pam.Entities; +using Bit.Pam.Models.Conditions; +using Bit.Pam.Repositories; +using Bit.Pam.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; diff --git a/test/Core.Test/Pam/Services/RequesterNotifierTests.cs b/test/Core.Test/Pam/Services/RequesterNotifierTests.cs index 2997a5cb08ee..37d048d0d953 100644 --- a/test/Core.Test/Pam/Services/RequesterNotifierTests.cs +++ b/test/Core.Test/Pam/Services/RequesterNotifierTests.cs @@ -1,5 +1,5 @@ -using Bit.Core.Pam.Services; -using Bit.Core.Platform.Push; +using Bit.Core.Platform.Push; +using Bit.Pam.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; diff --git a/test/Core.Test/Pam/Services/SingleActiveLeaseEvaluatorTests.cs b/test/Core.Test/Pam/Services/SingleActiveLeaseEvaluatorTests.cs index 7a0a55c4a1a9..a6d06a164936 100644 --- a/test/Core.Test/Pam/Services/SingleActiveLeaseEvaluatorTests.cs +++ b/test/Core.Test/Pam/Services/SingleActiveLeaseEvaluatorTests.cs @@ -1,8 +1,8 @@ using Bit.Core.Entities; -using Bit.Core.Pam.Entities; -using Bit.Core.Pam.Repositories; -using Bit.Core.Pam.Services; using Bit.Core.Repositories; +using Bit.Pam.Entities; +using Bit.Pam.Repositories; +using Bit.Pam.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; diff --git a/test/Core.Test/Vault/Services/CipherServiceTests.cs b/test/Core.Test/Vault/Services/CipherServiceTests.cs index 37204ea0b7ee..1b0bfab6cce4 100644 --- a/test/Core.Test/Vault/Services/CipherServiceTests.cs +++ b/test/Core.Test/Vault/Services/CipherServiceTests.cs @@ -11,7 +11,6 @@ using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Data.Organizations; -using Bit.Core.Pam.Services; using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Services; @@ -21,6 +20,7 @@ using Bit.Core.Vault.Models.Data; using Bit.Core.Vault.Repositories; using Bit.Core.Vault.Services; +using Bit.Pam.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; diff --git a/test/Infrastructure.IntegrationTest/Pam/Repositories/AccessLeaseRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Pam/Repositories/AccessLeaseRepositoryTests.cs index a3c2a1580b80..2631cba9385a 100644 --- a/test/Infrastructure.IntegrationTest/Pam/Repositories/AccessLeaseRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/Pam/Repositories/AccessLeaseRepositoryTests.cs @@ -1,9 +1,9 @@ -using Bit.Core.Pam.Entities; -using Bit.Core.Pam.Enums; -using Bit.Core.Pam.Repositories; -using Bit.Core.Repositories; +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; diff --git a/test/Infrastructure.IntegrationTest/Pam/Repositories/AccessRequestExtensionRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Pam/Repositories/AccessRequestExtensionRepositoryTests.cs index ef5c3c32df02..ab2b8bca8bf8 100644 --- a/test/Infrastructure.IntegrationTest/Pam/Repositories/AccessRequestExtensionRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/Pam/Repositories/AccessRequestExtensionRepositoryTests.cs @@ -1,9 +1,9 @@ -using Bit.Core.Pam.Entities; -using Bit.Core.Pam.Enums; -using Bit.Core.Pam.Repositories; -using Bit.Core.Repositories; +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; diff --git a/test/Infrastructure.IntegrationTest/Pam/Repositories/AccessRequestRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Pam/Repositories/AccessRequestRepositoryTests.cs index 58c5333bb76e..d631873c7137 100644 --- a/test/Infrastructure.IntegrationTest/Pam/Repositories/AccessRequestRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/Pam/Repositories/AccessRequestRepositoryTests.cs @@ -1,9 +1,9 @@ -using Bit.Core.Pam.Entities; -using Bit.Core.Pam.Enums; -using Bit.Core.Pam.Repositories; -using Bit.Core.Repositories; +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; diff --git a/test/Infrastructure.IntegrationTest/Pam/Repositories/AccessRuleRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Pam/Repositories/AccessRuleRepositoryTests.cs index 91d524dc093c..ca7768b2b9ae 100644 --- a/test/Infrastructure.IntegrationTest/Pam/Repositories/AccessRuleRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/Pam/Repositories/AccessRuleRepositoryTests.cs @@ -1,8 +1,8 @@ using Bit.Core.Entities; -using Bit.Core.Pam.Entities; -using Bit.Core.Pam.Repositories; using Bit.Core.Repositories; using Bit.Infrastructure.IntegrationTest.AdminConsole; +using Bit.Pam.Entities; +using Bit.Pam.Repositories; using Xunit; namespace Bit.Infrastructure.IntegrationTest.Pam.Repositories; From c6d36e14604a44c39c97737199cdd6ba1b44e6a7 Mon Sep 17 00:00:00 2001 From: Hinton Date: Thu, 18 Jun 2026 17:33:33 +0200 Subject: [PATCH 48/54] PAM: extract the pure PAM domain into a Pam.Domain library below Core Create src/Pam.Domain (references only Data, never Core) and move the pure PAM domain into it: entities, enums, models/conditions, the rule engine, repository interfaces, and the pure command/query/service interfaces and query implementations. Core, Infrastructure.Dapper, Infrastructure.EntityFramework, and Api now reference Pam.Domain. To keep the lower lib Core-free: - entities use CombGuid.Generate() (Data) instead of CoreHelpers.GenerateComb(); - AccessSignals.From now takes the caller IP string instead of ICurrentContext, so the record stays pure; its callers pass currentContext.IpAddress. Core-coupled command/query/service implementations and the Vault<->PAM gate remain in Core for now (moved to the upper Pam lib in a follow-up). --- bitwarden-server.slnx | 1 + src/Api/Api.csproj | 1 + src/Core/Core.csproj | 1 + .../Commands/RequestLeaseExtensionCommand.cs | 2 +- .../Commands/SubmitAccessRequestCommand.cs | 2 +- .../Queries/AccessPreCheckQuery.cs | 2 +- .../Queries/GetCipherAccessStateQuery.cs | 2 +- .../Queries/GetLeasedCipherQuery.cs | 2 +- src/Core/Pam/Services/CipherLeaseGate.cs | 4 ++-- .../Infrastructure.Dapper.csproj | 1 + .../Infrastructure.EntityFramework.csproj | 1 + .../Pam => Pam.Domain}/Engine/AccessEvaluation.cs | 0 .../Pam => Pam.Domain}/Engine/AccessRuleEngine.cs | 0 src/{Core/Pam => Pam.Domain}/Engine/AccessSignals.cs | 11 +++++------ .../Pam => Pam.Domain}/Engine/IAccessRuleEngine.cs | 0 .../Pam => Pam.Domain}/Entities/AccessDecision.cs | 2 +- src/{Core/Pam => Pam.Domain}/Entities/AccessLease.cs | 2 +- .../Pam => Pam.Domain}/Entities/AccessRequest.cs | 2 +- src/{Core/Pam => Pam.Domain}/Entities/AccessRule.cs | 2 +- .../Pam => Pam.Domain}/Enums/AccessApprovalMode.cs | 0 .../Pam => Pam.Domain}/Enums/AccessConditionKind.cs | 0 .../Pam => Pam.Domain}/Enums/AccessDeciderKind.cs | 0 .../Enums/AccessLeaseExtendOutcome.cs | 0 .../Enums/AccessLeaseMintOutcome.cs | 0 .../Pam => Pam.Domain}/Enums/AccessLeaseStatus.cs | 0 .../Pam => Pam.Domain}/Enums/AccessRequestStatus.cs | 0 src/{Core/Pam => Pam.Domain}/Enums/AccessWeekday.cs | 0 .../Models/AccessDeciderKindNames.cs | 0 .../Models/AccessDecisionSubmission.cs | 0 .../Models/AccessLeaseExtensionSubmission.cs | 0 .../Models/AccessLeaseStatusNames.cs | 0 .../Pam => Pam.Domain}/Models/AccessPreCheckResult.cs | 0 .../Models/AccessRequestDecision.cs | 0 .../Pam => Pam.Domain}/Models/AccessRequestDetails.cs | 0 .../Pam => Pam.Domain}/Models/AccessRequestResult.cs | 0 .../Models/AccessRequestStatusNames.cs | 0 .../Models/AccessRequestSubmission.cs | 0 .../Pam => Pam.Domain}/Models/AccessRuleDetails.cs | 0 .../Pam => Pam.Domain}/Models/CipherAccessState.cs | 0 .../Models/Conditions/AccessCondition.cs | 0 .../Models/Conditions/AccessWeekdayJsonConverter.cs | 0 .../Models/Conditions/HumanApprovalCondition.cs | 0 .../Models/Conditions/IpAllowlistCondition.cs | 0 .../Models/Conditions/TimeOfDayCondition.cs | 0 src/{Core/Pam => Pam.Domain}/Models/GoverningRule.cs | 0 .../Interfaces/IActivateAccessRequestCommand.cs | 0 .../Interfaces/ICancelAccessRequestCommand.cs | 0 .../Commands/Interfaces/ICreateAccessRuleCommand.cs | 0 .../Interfaces/IDecideAccessRequestCommand.cs | 0 .../Commands/Interfaces/IDeleteAccessRuleCommand.cs | 0 .../Interfaces/IRequestLeaseExtensionCommand.cs | 0 .../Commands/Interfaces/IRevokeAccessLeaseCommand.cs | 0 .../Interfaces/ISubmitAccessRequestCommand.cs | 0 .../Commands/Interfaces/IUpdateAccessRuleCommand.cs | 0 .../Queries/Interfaces/IAccessPreCheckQuery.cs | 0 .../Queries/Interfaces/IGetCipherAccessStateQuery.cs | 0 .../Queries/Interfaces/IListActiveLeasesQuery.cs | 0 .../Queries/Interfaces/IListInboxHistoryQuery.cs | 0 .../Queries/Interfaces/IListInboxRequestsQuery.cs | 0 .../Queries/Interfaces/IListLeaseHistoryQuery.cs | 0 .../Queries/Interfaces/IListMyAccessRequestsQuery.cs | 0 .../Interfaces/IListMyActiveAccessLeasesQuery.cs | 0 .../Queries/ListActiveLeasesQuery.cs | 0 .../Queries/ListInboxHistoryQuery.cs | 0 .../Queries/ListInboxRequestsQuery.cs | 0 .../Queries/ListLeaseHistoryQuery.cs | 0 .../Queries/ListMyAccessRequestsQuery.cs | 0 .../Queries/ListMyActiveAccessLeasesQuery.cs | 0 src/Pam.Domain/Pam.Domain.csproj | 11 +++++++++++ .../Repositories/IAccessLeaseRepository.cs | 0 .../Repositories/IAccessRequestRepository.cs | 0 .../Repositories/IAccessRuleRepository.cs | 0 .../Services/AccessRuleValidator.cs | 0 .../Services/IAccessRuleValidator.cs | 0 .../Services/IApproverCollectionAccessQuery.cs | 0 .../Services/IApproverInboxNotifier.cs | 0 .../Services/IGoverningRuleResolver.cs | 0 .../Pam => Pam.Domain}/Services/IRequesterNotifier.cs | 0 .../Services/ISingleActiveLeaseEvaluator.cs | 0 79 files changed, 32 insertions(+), 17 deletions(-) rename src/{Core/Pam => Pam.Domain}/Engine/AccessEvaluation.cs (100%) rename src/{Core/Pam => Pam.Domain}/Engine/AccessRuleEngine.cs (100%) rename src/{Core/Pam => Pam.Domain}/Engine/AccessSignals.cs (63%) rename src/{Core/Pam => Pam.Domain}/Engine/IAccessRuleEngine.cs (100%) rename src/{Core/Pam => Pam.Domain}/Entities/AccessDecision.cs (97%) rename src/{Core/Pam => Pam.Domain}/Entities/AccessLease.cs (96%) rename src/{Core/Pam => Pam.Domain}/Entities/AccessRequest.cs (98%) rename src/{Core/Pam => Pam.Domain}/Entities/AccessRule.cs (98%) rename src/{Core/Pam => Pam.Domain}/Enums/AccessApprovalMode.cs (100%) rename src/{Core/Pam => Pam.Domain}/Enums/AccessConditionKind.cs (100%) rename src/{Core/Pam => Pam.Domain}/Enums/AccessDeciderKind.cs (100%) rename src/{Core/Pam => Pam.Domain}/Enums/AccessLeaseExtendOutcome.cs (100%) rename src/{Core/Pam => Pam.Domain}/Enums/AccessLeaseMintOutcome.cs (100%) rename src/{Core/Pam => Pam.Domain}/Enums/AccessLeaseStatus.cs (100%) rename src/{Core/Pam => Pam.Domain}/Enums/AccessRequestStatus.cs (100%) rename src/{Core/Pam => Pam.Domain}/Enums/AccessWeekday.cs (100%) rename src/{Core/Pam => Pam.Domain}/Models/AccessDeciderKindNames.cs (100%) rename src/{Core/Pam => Pam.Domain}/Models/AccessDecisionSubmission.cs (100%) rename src/{Core/Pam => Pam.Domain}/Models/AccessLeaseExtensionSubmission.cs (100%) rename src/{Core/Pam => Pam.Domain}/Models/AccessLeaseStatusNames.cs (100%) rename src/{Core/Pam => Pam.Domain}/Models/AccessPreCheckResult.cs (100%) rename src/{Core/Pam => Pam.Domain}/Models/AccessRequestDecision.cs (100%) rename src/{Core/Pam => Pam.Domain}/Models/AccessRequestDetails.cs (100%) rename src/{Core/Pam => Pam.Domain}/Models/AccessRequestResult.cs (100%) rename src/{Core/Pam => Pam.Domain}/Models/AccessRequestStatusNames.cs (100%) rename src/{Core/Pam => Pam.Domain}/Models/AccessRequestSubmission.cs (100%) rename src/{Core/Pam => Pam.Domain}/Models/AccessRuleDetails.cs (100%) rename src/{Core/Pam => Pam.Domain}/Models/CipherAccessState.cs (100%) rename src/{Core/Pam => Pam.Domain}/Models/Conditions/AccessCondition.cs (100%) rename src/{Core/Pam => Pam.Domain}/Models/Conditions/AccessWeekdayJsonConverter.cs (100%) rename src/{Core/Pam => Pam.Domain}/Models/Conditions/HumanApprovalCondition.cs (100%) rename src/{Core/Pam => Pam.Domain}/Models/Conditions/IpAllowlistCondition.cs (100%) rename src/{Core/Pam => Pam.Domain}/Models/Conditions/TimeOfDayCondition.cs (100%) rename src/{Core/Pam => Pam.Domain}/Models/GoverningRule.cs (100%) rename src/{Core/Pam => Pam.Domain}/OrganizationFeatures/Commands/Interfaces/IActivateAccessRequestCommand.cs (100%) rename src/{Core/Pam => Pam.Domain}/OrganizationFeatures/Commands/Interfaces/ICancelAccessRequestCommand.cs (100%) rename src/{Core/Pam => Pam.Domain}/OrganizationFeatures/Commands/Interfaces/ICreateAccessRuleCommand.cs (100%) rename src/{Core/Pam => Pam.Domain}/OrganizationFeatures/Commands/Interfaces/IDecideAccessRequestCommand.cs (100%) rename src/{Core/Pam => Pam.Domain}/OrganizationFeatures/Commands/Interfaces/IDeleteAccessRuleCommand.cs (100%) rename src/{Core/Pam => Pam.Domain}/OrganizationFeatures/Commands/Interfaces/IRequestLeaseExtensionCommand.cs (100%) rename src/{Core/Pam => Pam.Domain}/OrganizationFeatures/Commands/Interfaces/IRevokeAccessLeaseCommand.cs (100%) rename src/{Core/Pam => Pam.Domain}/OrganizationFeatures/Commands/Interfaces/ISubmitAccessRequestCommand.cs (100%) rename src/{Core/Pam => Pam.Domain}/OrganizationFeatures/Commands/Interfaces/IUpdateAccessRuleCommand.cs (100%) rename src/{Core/Pam => Pam.Domain}/OrganizationFeatures/Queries/Interfaces/IAccessPreCheckQuery.cs (100%) rename src/{Core/Pam => Pam.Domain}/OrganizationFeatures/Queries/Interfaces/IGetCipherAccessStateQuery.cs (100%) rename src/{Core/Pam => Pam.Domain}/OrganizationFeatures/Queries/Interfaces/IListActiveLeasesQuery.cs (100%) rename src/{Core/Pam => Pam.Domain}/OrganizationFeatures/Queries/Interfaces/IListInboxHistoryQuery.cs (100%) rename src/{Core/Pam => Pam.Domain}/OrganizationFeatures/Queries/Interfaces/IListInboxRequestsQuery.cs (100%) rename src/{Core/Pam => Pam.Domain}/OrganizationFeatures/Queries/Interfaces/IListLeaseHistoryQuery.cs (100%) rename src/{Core/Pam => Pam.Domain}/OrganizationFeatures/Queries/Interfaces/IListMyAccessRequestsQuery.cs (100%) rename src/{Core/Pam => Pam.Domain}/OrganizationFeatures/Queries/Interfaces/IListMyActiveAccessLeasesQuery.cs (100%) rename src/{Core/Pam => Pam.Domain}/OrganizationFeatures/Queries/ListActiveLeasesQuery.cs (100%) rename src/{Core/Pam => Pam.Domain}/OrganizationFeatures/Queries/ListInboxHistoryQuery.cs (100%) rename src/{Core/Pam => Pam.Domain}/OrganizationFeatures/Queries/ListInboxRequestsQuery.cs (100%) rename src/{Core/Pam => Pam.Domain}/OrganizationFeatures/Queries/ListLeaseHistoryQuery.cs (100%) rename src/{Core/Pam => Pam.Domain}/OrganizationFeatures/Queries/ListMyAccessRequestsQuery.cs (100%) rename src/{Core/Pam => Pam.Domain}/OrganizationFeatures/Queries/ListMyActiveAccessLeasesQuery.cs (100%) create mode 100644 src/Pam.Domain/Pam.Domain.csproj rename src/{Core/Pam => Pam.Domain}/Repositories/IAccessLeaseRepository.cs (100%) rename src/{Core/Pam => Pam.Domain}/Repositories/IAccessRequestRepository.cs (100%) rename src/{Core/Pam => Pam.Domain}/Repositories/IAccessRuleRepository.cs (100%) rename src/{Core/Pam => Pam.Domain}/Services/AccessRuleValidator.cs (100%) rename src/{Core/Pam => Pam.Domain}/Services/IAccessRuleValidator.cs (100%) rename src/{Core/Pam => Pam.Domain}/Services/IApproverCollectionAccessQuery.cs (100%) rename src/{Core/Pam => Pam.Domain}/Services/IApproverInboxNotifier.cs (100%) rename src/{Core/Pam => Pam.Domain}/Services/IGoverningRuleResolver.cs (100%) rename src/{Core/Pam => Pam.Domain}/Services/IRequesterNotifier.cs (100%) rename src/{Core/Pam => Pam.Domain}/Services/ISingleActiveLeaseEvaluator.cs (100%) diff --git a/bitwarden-server.slnx b/bitwarden-server.slnx index a8f2665e638f..50bb994edadc 100644 --- a/bitwarden-server.slnx +++ b/bitwarden-server.slnx @@ -27,6 +27,7 @@ + diff --git a/src/Api/Api.csproj b/src/Api/Api.csproj index ed91f16ac0ad..0c41fa17d914 100644 --- a/src/Api/Api.csproj +++ b/src/Api/Api.csproj @@ -23,6 +23,7 @@ + diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj index 20214c45cbd9..3bd5aacc5552 100644 --- a/src/Core/Core.csproj +++ b/src/Core/Core.csproj @@ -78,6 +78,7 @@ + diff --git a/src/Core/Pam/OrganizationFeatures/Commands/RequestLeaseExtensionCommand.cs b/src/Core/Pam/OrganizationFeatures/Commands/RequestLeaseExtensionCommand.cs index 05b8f5bde500..568fc1935432 100644 --- a/src/Core/Pam/OrganizationFeatures/Commands/RequestLeaseExtensionCommand.cs +++ b/src/Core/Pam/OrganizationFeatures/Commands/RequestLeaseExtensionCommand.cs @@ -57,7 +57,7 @@ public async Task ExtendAsync(Guid userId, AccessLeaseExte // Extensions reuse the cipher's governing rule, but never its approval gate: they are always auto-approved, // gated only by the rule opting in and the per-lease maximum. - var signals = AccessSignals.From(_currentContext, new DateTimeOffset(now, TimeSpan.Zero)); + var signals = AccessSignals.From(_currentContext.IpAddress, new DateTimeOffset(now, TimeSpan.Zero)); var governingRule = await _resolver.ResolveAsync(userId, lease.CipherId, signals); if (governingRule is null) { diff --git a/src/Core/Pam/OrganizationFeatures/Commands/SubmitAccessRequestCommand.cs b/src/Core/Pam/OrganizationFeatures/Commands/SubmitAccessRequestCommand.cs index c9cb15e08bba..a2c69eedef13 100644 --- a/src/Core/Pam/OrganizationFeatures/Commands/SubmitAccessRequestCommand.cs +++ b/src/Core/Pam/OrganizationFeatures/Commands/SubmitAccessRequestCommand.cs @@ -78,7 +78,7 @@ public async Task SubmitAsync(Guid userId, Guid cipherId, A } var now = _timeProvider.GetUtcNow().UtcDateTime; - var signals = AccessSignals.From(_currentContext, new DateTimeOffset(now, TimeSpan.Zero)); + var signals = AccessSignals.From(_currentContext.IpAddress, new DateTimeOffset(now, TimeSpan.Zero)); var governingRule = await _resolver.ResolveAsync(userId, cipherId, signals); if (governingRule is null) diff --git a/src/Core/Pam/OrganizationFeatures/Queries/AccessPreCheckQuery.cs b/src/Core/Pam/OrganizationFeatures/Queries/AccessPreCheckQuery.cs index a5965bc9ef3a..26402be5976e 100644 --- a/src/Core/Pam/OrganizationFeatures/Queries/AccessPreCheckQuery.cs +++ b/src/Core/Pam/OrganizationFeatures/Queries/AccessPreCheckQuery.cs @@ -50,7 +50,7 @@ public async Task PreCheckAsync(Guid userId, Guid cipherId return new AccessPreCheckResult(AccessApprovalMode.Automatic, HasActiveLease: true); } - var signals = AccessSignals.From(_currentContext, new DateTimeOffset(now, TimeSpan.Zero)); + var signals = AccessSignals.From(_currentContext.IpAddress, new DateTimeOffset(now, TimeSpan.Zero)); var governingRule = await _resolver.ResolveAsync(userId, cipherId, signals); var approvalMode = governingRule?.RequiresHumanApproval == true ? AccessApprovalMode.Human diff --git a/src/Core/Pam/OrganizationFeatures/Queries/GetCipherAccessStateQuery.cs b/src/Core/Pam/OrganizationFeatures/Queries/GetCipherAccessStateQuery.cs index a17f32983463..f717f1fcb337 100644 --- a/src/Core/Pam/OrganizationFeatures/Queries/GetCipherAccessStateQuery.cs +++ b/src/Core/Pam/OrganizationFeatures/Queries/GetCipherAccessStateQuery.cs @@ -45,7 +45,7 @@ public async Task GetStateAsync(Guid userId, Guid cipherId) } var now = _timeProvider.GetUtcNow().UtcDateTime; - var signals = AccessSignals.From(_currentContext, new DateTimeOffset(now, TimeSpan.Zero)); + var signals = AccessSignals.From(_currentContext.IpAddress, new DateTimeOffset(now, TimeSpan.Zero)); var activeLease = await _accessLeaseRepository.GetActiveByRequesterIdCipherIdAsync(userId, cipherId, now); var pending = await _accessRequestRepository.GetActivePendingByRequesterIdCipherIdAsync(userId, cipherId); var approved = await _accessRequestRepository.GetActiveApprovedByRequesterIdCipherIdAsync(userId, cipherId, now); diff --git a/src/Core/Pam/OrganizationFeatures/Queries/GetLeasedCipherQuery.cs b/src/Core/Pam/OrganizationFeatures/Queries/GetLeasedCipherQuery.cs index 7e93d74d75dc..154702d70ea3 100644 --- a/src/Core/Pam/OrganizationFeatures/Queries/GetLeasedCipherQuery.cs +++ b/src/Core/Pam/OrganizationFeatures/Queries/GetLeasedCipherQuery.cs @@ -44,7 +44,7 @@ public GetLeasedCipherQuery( return null; } - var signals = AccessSignals.From(_currentContext, now); + var signals = AccessSignals.From(_currentContext.IpAddress, now); // A lease grants a window, but the access rule's environmental conditions (source IP, time of day) must // still hold at the moment the data is handed over. Approval is not re-checked here: holding the lease is diff --git a/src/Core/Pam/Services/CipherLeaseGate.cs b/src/Core/Pam/Services/CipherLeaseGate.cs index 5eb3fc42172c..85337802a441 100644 --- a/src/Core/Pam/Services/CipherLeaseGate.cs +++ b/src/Core/Pam/Services/CipherLeaseGate.cs @@ -156,7 +156,7 @@ public async Task EnsureCanMutateManyAsync(Guid userId, IEnume var leasedCipherIds = (await _accessLeaseRepository.GetManyActiveByRequesterIdAsync(userId, now)) .Select(l => l.CipherId) .ToHashSet(); - var signals = AccessSignals.From(_currentContext, new DateTimeOffset(now, TimeSpan.Zero)); + var signals = AccessSignals.From(_currentContext.IpAddress, new DateTimeOffset(now, TimeSpan.Zero)); foreach (var cipher in cipherList) { @@ -189,7 +189,7 @@ public FullCipherAccess Unrestricted() => private async Task IsBlockedAsync(Guid userId, Guid cipherId) { var now = _timeProvider.GetUtcNow().UtcDateTime; - var signals = AccessSignals.From(_currentContext, new DateTimeOffset(now, TimeSpan.Zero)); + var signals = AccessSignals.From(_currentContext.IpAddress, new DateTimeOffset(now, TimeSpan.Zero)); if (await _resolver.ResolveAsync(userId, cipherId, signals) is null) { diff --git a/src/Infrastructure.Dapper/Infrastructure.Dapper.csproj b/src/Infrastructure.Dapper/Infrastructure.Dapper.csproj index 49164781a6c6..a46bf1c2fe90 100644 --- a/src/Infrastructure.Dapper/Infrastructure.Dapper.csproj +++ b/src/Infrastructure.Dapper/Infrastructure.Dapper.csproj @@ -7,6 +7,7 @@ + diff --git a/src/Infrastructure.EntityFramework/Infrastructure.EntityFramework.csproj b/src/Infrastructure.EntityFramework/Infrastructure.EntityFramework.csproj index b9852ba11cdf..8285e6778441 100644 --- a/src/Infrastructure.EntityFramework/Infrastructure.EntityFramework.csproj +++ b/src/Infrastructure.EntityFramework/Infrastructure.EntityFramework.csproj @@ -19,5 +19,6 @@ + diff --git a/src/Core/Pam/Engine/AccessEvaluation.cs b/src/Pam.Domain/Engine/AccessEvaluation.cs similarity index 100% rename from src/Core/Pam/Engine/AccessEvaluation.cs rename to src/Pam.Domain/Engine/AccessEvaluation.cs diff --git a/src/Core/Pam/Engine/AccessRuleEngine.cs b/src/Pam.Domain/Engine/AccessRuleEngine.cs similarity index 100% rename from src/Core/Pam/Engine/AccessRuleEngine.cs rename to src/Pam.Domain/Engine/AccessRuleEngine.cs diff --git a/src/Core/Pam/Engine/AccessSignals.cs b/src/Pam.Domain/Engine/AccessSignals.cs similarity index 63% rename from src/Core/Pam/Engine/AccessSignals.cs rename to src/Pam.Domain/Engine/AccessSignals.cs index fddaa1d56873..2a616c5296c8 100644 --- a/src/Core/Pam/Engine/AccessSignals.cs +++ b/src/Pam.Domain/Engine/AccessSignals.cs @@ -1,5 +1,4 @@ using System.Net; -using Bit.Core.Context; namespace Bit.Pam.Engine; @@ -14,13 +13,13 @@ public sealed record AccessSignals public required DateTimeOffset Timestamp { get; init; } /// - /// Builds the signals for the current request: the caller's source IP from - /// (parsed, or null when it is absent or unparseable) and the supplied - /// evaluation . + /// Builds the signals for the current request: the caller's source IP (parsed, or null when it is absent or + /// unparseable) and the supplied evaluation . Callers typically pass the request's + /// source address (e.g. ICurrentContext.IpAddress). /// - public static AccessSignals From(ICurrentContext currentContext, DateTimeOffset timestamp) => new() + public static AccessSignals From(string? ipAddress, DateTimeOffset timestamp) => new() { - IpAddress = IPAddress.TryParse(currentContext.IpAddress, out var ip) ? ip : null, + IpAddress = IPAddress.TryParse(ipAddress, out var ip) ? ip : null, Timestamp = timestamp, }; } diff --git a/src/Core/Pam/Engine/IAccessRuleEngine.cs b/src/Pam.Domain/Engine/IAccessRuleEngine.cs similarity index 100% rename from src/Core/Pam/Engine/IAccessRuleEngine.cs rename to src/Pam.Domain/Engine/IAccessRuleEngine.cs diff --git a/src/Core/Pam/Entities/AccessDecision.cs b/src/Pam.Domain/Entities/AccessDecision.cs similarity index 97% rename from src/Core/Pam/Entities/AccessDecision.cs rename to src/Pam.Domain/Entities/AccessDecision.cs index 2380f76332d9..a46b45e5954b 100644 --- a/src/Core/Pam/Entities/AccessDecision.cs +++ b/src/Pam.Domain/Entities/AccessDecision.cs @@ -44,6 +44,6 @@ public class AccessDecision : ITableObject public void SetNewId() { - Id = CoreHelpers.GenerateComb(); + Id = CombGuid.Generate(); } } diff --git a/src/Core/Pam/Entities/AccessLease.cs b/src/Pam.Domain/Entities/AccessLease.cs similarity index 96% rename from src/Core/Pam/Entities/AccessLease.cs rename to src/Pam.Domain/Entities/AccessLease.cs index 5db1eb9bcb7f..9901bcc3ff7f 100644 --- a/src/Core/Pam/Entities/AccessLease.cs +++ b/src/Pam.Domain/Entities/AccessLease.cs @@ -34,6 +34,6 @@ public class AccessLease : ITableObject public void SetNewId() { - Id = CoreHelpers.GenerateComb(); + Id = CombGuid.Generate(); } } diff --git a/src/Core/Pam/Entities/AccessRequest.cs b/src/Pam.Domain/Entities/AccessRequest.cs similarity index 98% rename from src/Core/Pam/Entities/AccessRequest.cs rename to src/Pam.Domain/Entities/AccessRequest.cs index 067dae88f84d..85c7f12e6141 100644 --- a/src/Core/Pam/Entities/AccessRequest.cs +++ b/src/Pam.Domain/Entities/AccessRequest.cs @@ -52,6 +52,6 @@ public class AccessRequest : ITableObject public void SetNewId() { - Id = CoreHelpers.GenerateComb(); + Id = CombGuid.Generate(); } } diff --git a/src/Core/Pam/Entities/AccessRule.cs b/src/Pam.Domain/Entities/AccessRule.cs similarity index 98% rename from src/Core/Pam/Entities/AccessRule.cs rename to src/Pam.Domain/Entities/AccessRule.cs index b84bbe02d480..b2a108f668b4 100644 --- a/src/Core/Pam/Entities/AccessRule.cs +++ b/src/Pam.Domain/Entities/AccessRule.cs @@ -67,6 +67,6 @@ public class AccessRule : ITableObject public void SetNewId() { - Id = CoreHelpers.GenerateComb(); + Id = CombGuid.Generate(); } } diff --git a/src/Core/Pam/Enums/AccessApprovalMode.cs b/src/Pam.Domain/Enums/AccessApprovalMode.cs similarity index 100% rename from src/Core/Pam/Enums/AccessApprovalMode.cs rename to src/Pam.Domain/Enums/AccessApprovalMode.cs diff --git a/src/Core/Pam/Enums/AccessConditionKind.cs b/src/Pam.Domain/Enums/AccessConditionKind.cs similarity index 100% rename from src/Core/Pam/Enums/AccessConditionKind.cs rename to src/Pam.Domain/Enums/AccessConditionKind.cs diff --git a/src/Core/Pam/Enums/AccessDeciderKind.cs b/src/Pam.Domain/Enums/AccessDeciderKind.cs similarity index 100% rename from src/Core/Pam/Enums/AccessDeciderKind.cs rename to src/Pam.Domain/Enums/AccessDeciderKind.cs diff --git a/src/Core/Pam/Enums/AccessLeaseExtendOutcome.cs b/src/Pam.Domain/Enums/AccessLeaseExtendOutcome.cs similarity index 100% rename from src/Core/Pam/Enums/AccessLeaseExtendOutcome.cs rename to src/Pam.Domain/Enums/AccessLeaseExtendOutcome.cs diff --git a/src/Core/Pam/Enums/AccessLeaseMintOutcome.cs b/src/Pam.Domain/Enums/AccessLeaseMintOutcome.cs similarity index 100% rename from src/Core/Pam/Enums/AccessLeaseMintOutcome.cs rename to src/Pam.Domain/Enums/AccessLeaseMintOutcome.cs diff --git a/src/Core/Pam/Enums/AccessLeaseStatus.cs b/src/Pam.Domain/Enums/AccessLeaseStatus.cs similarity index 100% rename from src/Core/Pam/Enums/AccessLeaseStatus.cs rename to src/Pam.Domain/Enums/AccessLeaseStatus.cs diff --git a/src/Core/Pam/Enums/AccessRequestStatus.cs b/src/Pam.Domain/Enums/AccessRequestStatus.cs similarity index 100% rename from src/Core/Pam/Enums/AccessRequestStatus.cs rename to src/Pam.Domain/Enums/AccessRequestStatus.cs diff --git a/src/Core/Pam/Enums/AccessWeekday.cs b/src/Pam.Domain/Enums/AccessWeekday.cs similarity index 100% rename from src/Core/Pam/Enums/AccessWeekday.cs rename to src/Pam.Domain/Enums/AccessWeekday.cs diff --git a/src/Core/Pam/Models/AccessDeciderKindNames.cs b/src/Pam.Domain/Models/AccessDeciderKindNames.cs similarity index 100% rename from src/Core/Pam/Models/AccessDeciderKindNames.cs rename to src/Pam.Domain/Models/AccessDeciderKindNames.cs diff --git a/src/Core/Pam/Models/AccessDecisionSubmission.cs b/src/Pam.Domain/Models/AccessDecisionSubmission.cs similarity index 100% rename from src/Core/Pam/Models/AccessDecisionSubmission.cs rename to src/Pam.Domain/Models/AccessDecisionSubmission.cs diff --git a/src/Core/Pam/Models/AccessLeaseExtensionSubmission.cs b/src/Pam.Domain/Models/AccessLeaseExtensionSubmission.cs similarity index 100% rename from src/Core/Pam/Models/AccessLeaseExtensionSubmission.cs rename to src/Pam.Domain/Models/AccessLeaseExtensionSubmission.cs diff --git a/src/Core/Pam/Models/AccessLeaseStatusNames.cs b/src/Pam.Domain/Models/AccessLeaseStatusNames.cs similarity index 100% rename from src/Core/Pam/Models/AccessLeaseStatusNames.cs rename to src/Pam.Domain/Models/AccessLeaseStatusNames.cs diff --git a/src/Core/Pam/Models/AccessPreCheckResult.cs b/src/Pam.Domain/Models/AccessPreCheckResult.cs similarity index 100% rename from src/Core/Pam/Models/AccessPreCheckResult.cs rename to src/Pam.Domain/Models/AccessPreCheckResult.cs diff --git a/src/Core/Pam/Models/AccessRequestDecision.cs b/src/Pam.Domain/Models/AccessRequestDecision.cs similarity index 100% rename from src/Core/Pam/Models/AccessRequestDecision.cs rename to src/Pam.Domain/Models/AccessRequestDecision.cs diff --git a/src/Core/Pam/Models/AccessRequestDetails.cs b/src/Pam.Domain/Models/AccessRequestDetails.cs similarity index 100% rename from src/Core/Pam/Models/AccessRequestDetails.cs rename to src/Pam.Domain/Models/AccessRequestDetails.cs diff --git a/src/Core/Pam/Models/AccessRequestResult.cs b/src/Pam.Domain/Models/AccessRequestResult.cs similarity index 100% rename from src/Core/Pam/Models/AccessRequestResult.cs rename to src/Pam.Domain/Models/AccessRequestResult.cs diff --git a/src/Core/Pam/Models/AccessRequestStatusNames.cs b/src/Pam.Domain/Models/AccessRequestStatusNames.cs similarity index 100% rename from src/Core/Pam/Models/AccessRequestStatusNames.cs rename to src/Pam.Domain/Models/AccessRequestStatusNames.cs diff --git a/src/Core/Pam/Models/AccessRequestSubmission.cs b/src/Pam.Domain/Models/AccessRequestSubmission.cs similarity index 100% rename from src/Core/Pam/Models/AccessRequestSubmission.cs rename to src/Pam.Domain/Models/AccessRequestSubmission.cs diff --git a/src/Core/Pam/Models/AccessRuleDetails.cs b/src/Pam.Domain/Models/AccessRuleDetails.cs similarity index 100% rename from src/Core/Pam/Models/AccessRuleDetails.cs rename to src/Pam.Domain/Models/AccessRuleDetails.cs diff --git a/src/Core/Pam/Models/CipherAccessState.cs b/src/Pam.Domain/Models/CipherAccessState.cs similarity index 100% rename from src/Core/Pam/Models/CipherAccessState.cs rename to src/Pam.Domain/Models/CipherAccessState.cs diff --git a/src/Core/Pam/Models/Conditions/AccessCondition.cs b/src/Pam.Domain/Models/Conditions/AccessCondition.cs similarity index 100% rename from src/Core/Pam/Models/Conditions/AccessCondition.cs rename to src/Pam.Domain/Models/Conditions/AccessCondition.cs diff --git a/src/Core/Pam/Models/Conditions/AccessWeekdayJsonConverter.cs b/src/Pam.Domain/Models/Conditions/AccessWeekdayJsonConverter.cs similarity index 100% rename from src/Core/Pam/Models/Conditions/AccessWeekdayJsonConverter.cs rename to src/Pam.Domain/Models/Conditions/AccessWeekdayJsonConverter.cs diff --git a/src/Core/Pam/Models/Conditions/HumanApprovalCondition.cs b/src/Pam.Domain/Models/Conditions/HumanApprovalCondition.cs similarity index 100% rename from src/Core/Pam/Models/Conditions/HumanApprovalCondition.cs rename to src/Pam.Domain/Models/Conditions/HumanApprovalCondition.cs diff --git a/src/Core/Pam/Models/Conditions/IpAllowlistCondition.cs b/src/Pam.Domain/Models/Conditions/IpAllowlistCondition.cs similarity index 100% rename from src/Core/Pam/Models/Conditions/IpAllowlistCondition.cs rename to src/Pam.Domain/Models/Conditions/IpAllowlistCondition.cs diff --git a/src/Core/Pam/Models/Conditions/TimeOfDayCondition.cs b/src/Pam.Domain/Models/Conditions/TimeOfDayCondition.cs similarity index 100% rename from src/Core/Pam/Models/Conditions/TimeOfDayCondition.cs rename to src/Pam.Domain/Models/Conditions/TimeOfDayCondition.cs diff --git a/src/Core/Pam/Models/GoverningRule.cs b/src/Pam.Domain/Models/GoverningRule.cs similarity index 100% rename from src/Core/Pam/Models/GoverningRule.cs rename to src/Pam.Domain/Models/GoverningRule.cs diff --git a/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IActivateAccessRequestCommand.cs b/src/Pam.Domain/OrganizationFeatures/Commands/Interfaces/IActivateAccessRequestCommand.cs similarity index 100% rename from src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IActivateAccessRequestCommand.cs rename to src/Pam.Domain/OrganizationFeatures/Commands/Interfaces/IActivateAccessRequestCommand.cs diff --git a/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/ICancelAccessRequestCommand.cs b/src/Pam.Domain/OrganizationFeatures/Commands/Interfaces/ICancelAccessRequestCommand.cs similarity index 100% rename from src/Core/Pam/OrganizationFeatures/Commands/Interfaces/ICancelAccessRequestCommand.cs rename to src/Pam.Domain/OrganizationFeatures/Commands/Interfaces/ICancelAccessRequestCommand.cs diff --git a/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/ICreateAccessRuleCommand.cs b/src/Pam.Domain/OrganizationFeatures/Commands/Interfaces/ICreateAccessRuleCommand.cs similarity index 100% rename from src/Core/Pam/OrganizationFeatures/Commands/Interfaces/ICreateAccessRuleCommand.cs rename to src/Pam.Domain/OrganizationFeatures/Commands/Interfaces/ICreateAccessRuleCommand.cs diff --git a/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IDecideAccessRequestCommand.cs b/src/Pam.Domain/OrganizationFeatures/Commands/Interfaces/IDecideAccessRequestCommand.cs similarity index 100% rename from src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IDecideAccessRequestCommand.cs rename to src/Pam.Domain/OrganizationFeatures/Commands/Interfaces/IDecideAccessRequestCommand.cs diff --git a/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IDeleteAccessRuleCommand.cs b/src/Pam.Domain/OrganizationFeatures/Commands/Interfaces/IDeleteAccessRuleCommand.cs similarity index 100% rename from src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IDeleteAccessRuleCommand.cs rename to src/Pam.Domain/OrganizationFeatures/Commands/Interfaces/IDeleteAccessRuleCommand.cs diff --git a/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IRequestLeaseExtensionCommand.cs b/src/Pam.Domain/OrganizationFeatures/Commands/Interfaces/IRequestLeaseExtensionCommand.cs similarity index 100% rename from src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IRequestLeaseExtensionCommand.cs rename to src/Pam.Domain/OrganizationFeatures/Commands/Interfaces/IRequestLeaseExtensionCommand.cs diff --git a/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IRevokeAccessLeaseCommand.cs b/src/Pam.Domain/OrganizationFeatures/Commands/Interfaces/IRevokeAccessLeaseCommand.cs similarity index 100% rename from src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IRevokeAccessLeaseCommand.cs rename to src/Pam.Domain/OrganizationFeatures/Commands/Interfaces/IRevokeAccessLeaseCommand.cs diff --git a/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/ISubmitAccessRequestCommand.cs b/src/Pam.Domain/OrganizationFeatures/Commands/Interfaces/ISubmitAccessRequestCommand.cs similarity index 100% rename from src/Core/Pam/OrganizationFeatures/Commands/Interfaces/ISubmitAccessRequestCommand.cs rename to src/Pam.Domain/OrganizationFeatures/Commands/Interfaces/ISubmitAccessRequestCommand.cs diff --git a/src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IUpdateAccessRuleCommand.cs b/src/Pam.Domain/OrganizationFeatures/Commands/Interfaces/IUpdateAccessRuleCommand.cs similarity index 100% rename from src/Core/Pam/OrganizationFeatures/Commands/Interfaces/IUpdateAccessRuleCommand.cs rename to src/Pam.Domain/OrganizationFeatures/Commands/Interfaces/IUpdateAccessRuleCommand.cs diff --git a/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IAccessPreCheckQuery.cs b/src/Pam.Domain/OrganizationFeatures/Queries/Interfaces/IAccessPreCheckQuery.cs similarity index 100% rename from src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IAccessPreCheckQuery.cs rename to src/Pam.Domain/OrganizationFeatures/Queries/Interfaces/IAccessPreCheckQuery.cs diff --git a/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IGetCipherAccessStateQuery.cs b/src/Pam.Domain/OrganizationFeatures/Queries/Interfaces/IGetCipherAccessStateQuery.cs similarity index 100% rename from src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IGetCipherAccessStateQuery.cs rename to src/Pam.Domain/OrganizationFeatures/Queries/Interfaces/IGetCipherAccessStateQuery.cs diff --git a/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IListActiveLeasesQuery.cs b/src/Pam.Domain/OrganizationFeatures/Queries/Interfaces/IListActiveLeasesQuery.cs similarity index 100% rename from src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IListActiveLeasesQuery.cs rename to src/Pam.Domain/OrganizationFeatures/Queries/Interfaces/IListActiveLeasesQuery.cs diff --git a/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IListInboxHistoryQuery.cs b/src/Pam.Domain/OrganizationFeatures/Queries/Interfaces/IListInboxHistoryQuery.cs similarity index 100% rename from src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IListInboxHistoryQuery.cs rename to src/Pam.Domain/OrganizationFeatures/Queries/Interfaces/IListInboxHistoryQuery.cs diff --git a/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IListInboxRequestsQuery.cs b/src/Pam.Domain/OrganizationFeatures/Queries/Interfaces/IListInboxRequestsQuery.cs similarity index 100% rename from src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IListInboxRequestsQuery.cs rename to src/Pam.Domain/OrganizationFeatures/Queries/Interfaces/IListInboxRequestsQuery.cs diff --git a/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IListLeaseHistoryQuery.cs b/src/Pam.Domain/OrganizationFeatures/Queries/Interfaces/IListLeaseHistoryQuery.cs similarity index 100% rename from src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IListLeaseHistoryQuery.cs rename to src/Pam.Domain/OrganizationFeatures/Queries/Interfaces/IListLeaseHistoryQuery.cs diff --git a/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IListMyAccessRequestsQuery.cs b/src/Pam.Domain/OrganizationFeatures/Queries/Interfaces/IListMyAccessRequestsQuery.cs similarity index 100% rename from src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IListMyAccessRequestsQuery.cs rename to src/Pam.Domain/OrganizationFeatures/Queries/Interfaces/IListMyAccessRequestsQuery.cs diff --git a/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IListMyActiveAccessLeasesQuery.cs b/src/Pam.Domain/OrganizationFeatures/Queries/Interfaces/IListMyActiveAccessLeasesQuery.cs similarity index 100% rename from src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IListMyActiveAccessLeasesQuery.cs rename to src/Pam.Domain/OrganizationFeatures/Queries/Interfaces/IListMyActiveAccessLeasesQuery.cs diff --git a/src/Core/Pam/OrganizationFeatures/Queries/ListActiveLeasesQuery.cs b/src/Pam.Domain/OrganizationFeatures/Queries/ListActiveLeasesQuery.cs similarity index 100% rename from src/Core/Pam/OrganizationFeatures/Queries/ListActiveLeasesQuery.cs rename to src/Pam.Domain/OrganizationFeatures/Queries/ListActiveLeasesQuery.cs diff --git a/src/Core/Pam/OrganizationFeatures/Queries/ListInboxHistoryQuery.cs b/src/Pam.Domain/OrganizationFeatures/Queries/ListInboxHistoryQuery.cs similarity index 100% rename from src/Core/Pam/OrganizationFeatures/Queries/ListInboxHistoryQuery.cs rename to src/Pam.Domain/OrganizationFeatures/Queries/ListInboxHistoryQuery.cs diff --git a/src/Core/Pam/OrganizationFeatures/Queries/ListInboxRequestsQuery.cs b/src/Pam.Domain/OrganizationFeatures/Queries/ListInboxRequestsQuery.cs similarity index 100% rename from src/Core/Pam/OrganizationFeatures/Queries/ListInboxRequestsQuery.cs rename to src/Pam.Domain/OrganizationFeatures/Queries/ListInboxRequestsQuery.cs diff --git a/src/Core/Pam/OrganizationFeatures/Queries/ListLeaseHistoryQuery.cs b/src/Pam.Domain/OrganizationFeatures/Queries/ListLeaseHistoryQuery.cs similarity index 100% rename from src/Core/Pam/OrganizationFeatures/Queries/ListLeaseHistoryQuery.cs rename to src/Pam.Domain/OrganizationFeatures/Queries/ListLeaseHistoryQuery.cs diff --git a/src/Core/Pam/OrganizationFeatures/Queries/ListMyAccessRequestsQuery.cs b/src/Pam.Domain/OrganizationFeatures/Queries/ListMyAccessRequestsQuery.cs similarity index 100% rename from src/Core/Pam/OrganizationFeatures/Queries/ListMyAccessRequestsQuery.cs rename to src/Pam.Domain/OrganizationFeatures/Queries/ListMyAccessRequestsQuery.cs diff --git a/src/Core/Pam/OrganizationFeatures/Queries/ListMyActiveAccessLeasesQuery.cs b/src/Pam.Domain/OrganizationFeatures/Queries/ListMyActiveAccessLeasesQuery.cs similarity index 100% rename from src/Core/Pam/OrganizationFeatures/Queries/ListMyActiveAccessLeasesQuery.cs rename to src/Pam.Domain/OrganizationFeatures/Queries/ListMyActiveAccessLeasesQuery.cs diff --git a/src/Pam.Domain/Pam.Domain.csproj b/src/Pam.Domain/Pam.Domain.csproj new file mode 100644 index 000000000000..6cd371bf4747 --- /dev/null +++ b/src/Pam.Domain/Pam.Domain.csproj @@ -0,0 +1,11 @@ + + + + Bit.Pam + + + + + + + diff --git a/src/Core/Pam/Repositories/IAccessLeaseRepository.cs b/src/Pam.Domain/Repositories/IAccessLeaseRepository.cs similarity index 100% rename from src/Core/Pam/Repositories/IAccessLeaseRepository.cs rename to src/Pam.Domain/Repositories/IAccessLeaseRepository.cs diff --git a/src/Core/Pam/Repositories/IAccessRequestRepository.cs b/src/Pam.Domain/Repositories/IAccessRequestRepository.cs similarity index 100% rename from src/Core/Pam/Repositories/IAccessRequestRepository.cs rename to src/Pam.Domain/Repositories/IAccessRequestRepository.cs diff --git a/src/Core/Pam/Repositories/IAccessRuleRepository.cs b/src/Pam.Domain/Repositories/IAccessRuleRepository.cs similarity index 100% rename from src/Core/Pam/Repositories/IAccessRuleRepository.cs rename to src/Pam.Domain/Repositories/IAccessRuleRepository.cs diff --git a/src/Core/Pam/Services/AccessRuleValidator.cs b/src/Pam.Domain/Services/AccessRuleValidator.cs similarity index 100% rename from src/Core/Pam/Services/AccessRuleValidator.cs rename to src/Pam.Domain/Services/AccessRuleValidator.cs diff --git a/src/Core/Pam/Services/IAccessRuleValidator.cs b/src/Pam.Domain/Services/IAccessRuleValidator.cs similarity index 100% rename from src/Core/Pam/Services/IAccessRuleValidator.cs rename to src/Pam.Domain/Services/IAccessRuleValidator.cs diff --git a/src/Core/Pam/Services/IApproverCollectionAccessQuery.cs b/src/Pam.Domain/Services/IApproverCollectionAccessQuery.cs similarity index 100% rename from src/Core/Pam/Services/IApproverCollectionAccessQuery.cs rename to src/Pam.Domain/Services/IApproverCollectionAccessQuery.cs diff --git a/src/Core/Pam/Services/IApproverInboxNotifier.cs b/src/Pam.Domain/Services/IApproverInboxNotifier.cs similarity index 100% rename from src/Core/Pam/Services/IApproverInboxNotifier.cs rename to src/Pam.Domain/Services/IApproverInboxNotifier.cs diff --git a/src/Core/Pam/Services/IGoverningRuleResolver.cs b/src/Pam.Domain/Services/IGoverningRuleResolver.cs similarity index 100% rename from src/Core/Pam/Services/IGoverningRuleResolver.cs rename to src/Pam.Domain/Services/IGoverningRuleResolver.cs diff --git a/src/Core/Pam/Services/IRequesterNotifier.cs b/src/Pam.Domain/Services/IRequesterNotifier.cs similarity index 100% rename from src/Core/Pam/Services/IRequesterNotifier.cs rename to src/Pam.Domain/Services/IRequesterNotifier.cs diff --git a/src/Core/Pam/Services/ISingleActiveLeaseEvaluator.cs b/src/Pam.Domain/Services/ISingleActiveLeaseEvaluator.cs similarity index 100% rename from src/Core/Pam/Services/ISingleActiveLeaseEvaluator.cs rename to src/Pam.Domain/Services/ISingleActiveLeaseEvaluator.cs From 3d575066e11b3fc08257eedfaa93dc4137e8ea02 Mon Sep 17 00:00:00 2001 From: Hinton Date: Thu, 18 Jun 2026 17:43:23 +0200 Subject: [PATCH 49/54] PAM: move Core-coupled PAM orchestration into the upper Pam library Create src/Pam (references Core + Pam.Domain) for the implementations that genuinely depend on Core: the command implementations, the Core-coupled query implementations (and the Vault-typed IGetLeasedCipherQuery interface), and the service implementations. The Vault<->PAM gate (ICipherLeaseGate + CipherLeaseGate) stays in Core, since Core's CipherService consumes it and FullCipherAccess is minted via internal factories. It only needs PAM types from the lower Pam.Domain lib. DI: AddPamServices moves to Pam's PamServiceCollectionExtensions and is invoked from the SharedWeb composition root, right after AddOrganizationServices, so every host that registers organization services also registers PAM. Core no longer references any PAM implementation. Test projects (Core.Test, Api.Test, Infrastructure.IntegrationTest) reference the new projects. Full solution builds; PAM unit tests pass. --- bitwarden-server.slnx | 1 + src/Api/Api.csproj | 1 + ...OrganizationServiceCollectionExtensions.cs | 38 ---------------- .../Commands/ActivateAccessRequestCommand.cs | 0 .../Commands/CancelAccessRequestCommand.cs | 0 .../Commands/CreateAccessRuleCommand.cs | 0 .../Commands/DecideAccessRequestCommand.cs | 0 .../Commands/DeleteAccessRuleCommand.cs | 0 .../Commands/RequestLeaseExtensionCommand.cs | 0 .../Commands/RevokeAccessLeaseCommand.cs | 0 .../Commands/SubmitAccessRequestCommand.cs | 0 .../Commands/UpdateAccessRuleCommand.cs | 0 .../Queries/AccessPreCheckQuery.cs | 0 .../Queries/GetCipherAccessStateQuery.cs | 0 .../Queries/GetLeasedCipherQuery.cs | 0 .../Interfaces/IGetLeasedCipherQuery.cs | 0 src/Pam/Pam.csproj | 12 +++++ src/Pam/PamServiceCollectionExtensions.cs | 44 +++++++++++++++++++ .../Services/ApproverCollectionAccessQuery.cs | 0 .../Pam/Services/ApproverInboxNotifier.cs | 0 .../Pam/Services/GoverningRuleResolver.cs | 0 .../Pam/Services/RequesterNotifier.cs | 0 .../Services/SingleActiveLeaseEvaluator.cs | 0 src/SharedWeb/SharedWeb.csproj | 1 + .../Utilities/ServiceCollectionExtensions.cs | 2 + test/Api.Test/Api.Test.csproj | 2 + test/Core.Test/Core.Test.csproj | 2 + .../Infrastructure.IntegrationTest.csproj | 1 + 28 files changed, 66 insertions(+), 38 deletions(-) rename src/{Core => }/Pam/OrganizationFeatures/Commands/ActivateAccessRequestCommand.cs (100%) rename src/{Core => }/Pam/OrganizationFeatures/Commands/CancelAccessRequestCommand.cs (100%) rename src/{Core => }/Pam/OrganizationFeatures/Commands/CreateAccessRuleCommand.cs (100%) rename src/{Core => }/Pam/OrganizationFeatures/Commands/DecideAccessRequestCommand.cs (100%) rename src/{Core => }/Pam/OrganizationFeatures/Commands/DeleteAccessRuleCommand.cs (100%) rename src/{Core => }/Pam/OrganizationFeatures/Commands/RequestLeaseExtensionCommand.cs (100%) rename src/{Core => }/Pam/OrganizationFeatures/Commands/RevokeAccessLeaseCommand.cs (100%) rename src/{Core => }/Pam/OrganizationFeatures/Commands/SubmitAccessRequestCommand.cs (100%) rename src/{Core => }/Pam/OrganizationFeatures/Commands/UpdateAccessRuleCommand.cs (100%) rename src/{Core => }/Pam/OrganizationFeatures/Queries/AccessPreCheckQuery.cs (100%) rename src/{Core => }/Pam/OrganizationFeatures/Queries/GetCipherAccessStateQuery.cs (100%) rename src/{Core => }/Pam/OrganizationFeatures/Queries/GetLeasedCipherQuery.cs (100%) rename src/{Core => }/Pam/OrganizationFeatures/Queries/Interfaces/IGetLeasedCipherQuery.cs (100%) create mode 100644 src/Pam/Pam.csproj create mode 100644 src/Pam/PamServiceCollectionExtensions.cs rename src/{Core => }/Pam/Services/ApproverCollectionAccessQuery.cs (100%) rename src/{Core => }/Pam/Services/ApproverInboxNotifier.cs (100%) rename src/{Core => }/Pam/Services/GoverningRuleResolver.cs (100%) rename src/{Core => }/Pam/Services/RequesterNotifier.cs (100%) rename src/{Core => }/Pam/Services/SingleActiveLeaseEvaluator.cs (100%) diff --git a/bitwarden-server.slnx b/bitwarden-server.slnx index 50bb994edadc..79e5de1937f8 100644 --- a/bitwarden-server.slnx +++ b/bitwarden-server.slnx @@ -28,6 +28,7 @@ + diff --git a/src/Api/Api.csproj b/src/Api/Api.csproj index 0c41fa17d914..6d21cfddab26 100644 --- a/src/Api/Api.csproj +++ b/src/Api/Api.csproj @@ -24,6 +24,7 @@ + diff --git a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs index 65828dc996cc..5b5ea84fb9e5 100644 --- a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs +++ b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs @@ -43,12 +43,6 @@ using Bit.Core.Services; using Bit.Core.Settings; using Bit.Core.Tokens; -using Bit.Pam.Engine; -using Bit.Pam.OrganizationFeatures.Commands; -using Bit.Pam.OrganizationFeatures.Commands.Interfaces; -using Bit.Pam.OrganizationFeatures.Queries; -using Bit.Pam.OrganizationFeatures.Queries.Interfaces; -using Bit.Pam.Services; using Core.AdminConsole.OrganizationFeatures.OrganizationUsers; using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Microsoft.AspNetCore.Authorization; @@ -73,7 +67,6 @@ public static void AddOrganizationServices(this IServiceCollection services, IGl services.AddOrganizationSponsorshipCommands(globalSettings); services.AddOrganizationApiKeyCommandsQueries(); services.AddOrganizationCollectionCommands(); - services.AddPamServices(); services.AddOrganizationGroupCommands(); services.AddOrganizationInviteLinkCommandsQueries(); services.AddOrganizationDomainCommandsQueries(); @@ -194,37 +187,6 @@ public static void AddOrganizationCollectionCommands(this IServiceCollection ser services.AddScoped(); } - public static void AddPamServices(this IServiceCollection services) - { - services.AddSingleton(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddSingleton(); - 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(); - services.AddScoped(); - services.AddScoped(); - services.TryAddSingleton(TimeProvider.System); - services.AddScoped(); - } - private static void AddOrganizationGroupCommands(this IServiceCollection services) { services.AddScoped(); diff --git a/src/Core/Pam/OrganizationFeatures/Commands/ActivateAccessRequestCommand.cs b/src/Pam/OrganizationFeatures/Commands/ActivateAccessRequestCommand.cs similarity index 100% rename from src/Core/Pam/OrganizationFeatures/Commands/ActivateAccessRequestCommand.cs rename to src/Pam/OrganizationFeatures/Commands/ActivateAccessRequestCommand.cs diff --git a/src/Core/Pam/OrganizationFeatures/Commands/CancelAccessRequestCommand.cs b/src/Pam/OrganizationFeatures/Commands/CancelAccessRequestCommand.cs similarity index 100% rename from src/Core/Pam/OrganizationFeatures/Commands/CancelAccessRequestCommand.cs rename to src/Pam/OrganizationFeatures/Commands/CancelAccessRequestCommand.cs diff --git a/src/Core/Pam/OrganizationFeatures/Commands/CreateAccessRuleCommand.cs b/src/Pam/OrganizationFeatures/Commands/CreateAccessRuleCommand.cs similarity index 100% rename from src/Core/Pam/OrganizationFeatures/Commands/CreateAccessRuleCommand.cs rename to src/Pam/OrganizationFeatures/Commands/CreateAccessRuleCommand.cs diff --git a/src/Core/Pam/OrganizationFeatures/Commands/DecideAccessRequestCommand.cs b/src/Pam/OrganizationFeatures/Commands/DecideAccessRequestCommand.cs similarity index 100% rename from src/Core/Pam/OrganizationFeatures/Commands/DecideAccessRequestCommand.cs rename to src/Pam/OrganizationFeatures/Commands/DecideAccessRequestCommand.cs diff --git a/src/Core/Pam/OrganizationFeatures/Commands/DeleteAccessRuleCommand.cs b/src/Pam/OrganizationFeatures/Commands/DeleteAccessRuleCommand.cs similarity index 100% rename from src/Core/Pam/OrganizationFeatures/Commands/DeleteAccessRuleCommand.cs rename to src/Pam/OrganizationFeatures/Commands/DeleteAccessRuleCommand.cs diff --git a/src/Core/Pam/OrganizationFeatures/Commands/RequestLeaseExtensionCommand.cs b/src/Pam/OrganizationFeatures/Commands/RequestLeaseExtensionCommand.cs similarity index 100% rename from src/Core/Pam/OrganizationFeatures/Commands/RequestLeaseExtensionCommand.cs rename to src/Pam/OrganizationFeatures/Commands/RequestLeaseExtensionCommand.cs diff --git a/src/Core/Pam/OrganizationFeatures/Commands/RevokeAccessLeaseCommand.cs b/src/Pam/OrganizationFeatures/Commands/RevokeAccessLeaseCommand.cs similarity index 100% rename from src/Core/Pam/OrganizationFeatures/Commands/RevokeAccessLeaseCommand.cs rename to src/Pam/OrganizationFeatures/Commands/RevokeAccessLeaseCommand.cs diff --git a/src/Core/Pam/OrganizationFeatures/Commands/SubmitAccessRequestCommand.cs b/src/Pam/OrganizationFeatures/Commands/SubmitAccessRequestCommand.cs similarity index 100% rename from src/Core/Pam/OrganizationFeatures/Commands/SubmitAccessRequestCommand.cs rename to src/Pam/OrganizationFeatures/Commands/SubmitAccessRequestCommand.cs diff --git a/src/Core/Pam/OrganizationFeatures/Commands/UpdateAccessRuleCommand.cs b/src/Pam/OrganizationFeatures/Commands/UpdateAccessRuleCommand.cs similarity index 100% rename from src/Core/Pam/OrganizationFeatures/Commands/UpdateAccessRuleCommand.cs rename to src/Pam/OrganizationFeatures/Commands/UpdateAccessRuleCommand.cs diff --git a/src/Core/Pam/OrganizationFeatures/Queries/AccessPreCheckQuery.cs b/src/Pam/OrganizationFeatures/Queries/AccessPreCheckQuery.cs similarity index 100% rename from src/Core/Pam/OrganizationFeatures/Queries/AccessPreCheckQuery.cs rename to src/Pam/OrganizationFeatures/Queries/AccessPreCheckQuery.cs diff --git a/src/Core/Pam/OrganizationFeatures/Queries/GetCipherAccessStateQuery.cs b/src/Pam/OrganizationFeatures/Queries/GetCipherAccessStateQuery.cs similarity index 100% rename from src/Core/Pam/OrganizationFeatures/Queries/GetCipherAccessStateQuery.cs rename to src/Pam/OrganizationFeatures/Queries/GetCipherAccessStateQuery.cs diff --git a/src/Core/Pam/OrganizationFeatures/Queries/GetLeasedCipherQuery.cs b/src/Pam/OrganizationFeatures/Queries/GetLeasedCipherQuery.cs similarity index 100% rename from src/Core/Pam/OrganizationFeatures/Queries/GetLeasedCipherQuery.cs rename to src/Pam/OrganizationFeatures/Queries/GetLeasedCipherQuery.cs diff --git a/src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IGetLeasedCipherQuery.cs b/src/Pam/OrganizationFeatures/Queries/Interfaces/IGetLeasedCipherQuery.cs similarity index 100% rename from src/Core/Pam/OrganizationFeatures/Queries/Interfaces/IGetLeasedCipherQuery.cs rename to src/Pam/OrganizationFeatures/Queries/Interfaces/IGetLeasedCipherQuery.cs diff --git a/src/Pam/Pam.csproj b/src/Pam/Pam.csproj new file mode 100644 index 000000000000..3f1f81d12c2c --- /dev/null +++ b/src/Pam/Pam.csproj @@ -0,0 +1,12 @@ + + + + Bit.Pam + + + + + + + + diff --git a/src/Pam/PamServiceCollectionExtensions.cs b/src/Pam/PamServiceCollectionExtensions.cs new file mode 100644 index 000000000000..aa39a6250da0 --- /dev/null +++ b/src/Pam/PamServiceCollectionExtensions.cs @@ -0,0 +1,44 @@ +using Bit.Pam.Engine; +using Bit.Pam.OrganizationFeatures.Commands; +using Bit.Pam.OrganizationFeatures.Commands.Interfaces; +using Bit.Pam.OrganizationFeatures.Queries; +using Bit.Pam.OrganizationFeatures.Queries.Interfaces; +using Bit.Pam.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Bit.Pam; + +public static class PamServiceCollectionExtensions +{ + public static void AddPamServices(this IServiceCollection services) + { + services.AddSingleton(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddSingleton(); + 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(); + services.AddScoped(); + services.AddScoped(); + services.TryAddSingleton(TimeProvider.System); + services.AddScoped(); + } +} diff --git a/src/Core/Pam/Services/ApproverCollectionAccessQuery.cs b/src/Pam/Services/ApproverCollectionAccessQuery.cs similarity index 100% rename from src/Core/Pam/Services/ApproverCollectionAccessQuery.cs rename to src/Pam/Services/ApproverCollectionAccessQuery.cs diff --git a/src/Core/Pam/Services/ApproverInboxNotifier.cs b/src/Pam/Services/ApproverInboxNotifier.cs similarity index 100% rename from src/Core/Pam/Services/ApproverInboxNotifier.cs rename to src/Pam/Services/ApproverInboxNotifier.cs diff --git a/src/Core/Pam/Services/GoverningRuleResolver.cs b/src/Pam/Services/GoverningRuleResolver.cs similarity index 100% rename from src/Core/Pam/Services/GoverningRuleResolver.cs rename to src/Pam/Services/GoverningRuleResolver.cs diff --git a/src/Core/Pam/Services/RequesterNotifier.cs b/src/Pam/Services/RequesterNotifier.cs similarity index 100% rename from src/Core/Pam/Services/RequesterNotifier.cs rename to src/Pam/Services/RequesterNotifier.cs diff --git a/src/Core/Pam/Services/SingleActiveLeaseEvaluator.cs b/src/Pam/Services/SingleActiveLeaseEvaluator.cs similarity index 100% rename from src/Core/Pam/Services/SingleActiveLeaseEvaluator.cs rename to src/Pam/Services/SingleActiveLeaseEvaluator.cs diff --git a/src/SharedWeb/SharedWeb.csproj b/src/SharedWeb/SharedWeb.csproj index 58164a3d89f6..294d2d88d662 100644 --- a/src/SharedWeb/SharedWeb.csproj +++ b/src/SharedWeb/SharedWeb.csproj @@ -9,6 +9,7 @@ + diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs index aa9595b3966b..281bb51b6332 100644 --- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs +++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs @@ -60,6 +60,7 @@ using Bit.Core.Vault.Services; using Bit.Infrastructure.Dapper; using Bit.Infrastructure.EntityFramework; +using Bit.Pam; using Bit.SharedWeb.Play; using DnsClient; using Duende.IdentityModel; @@ -163,6 +164,7 @@ public static void AddBaseServices(this IServiceCollection services, IGlobalSett services.AddUserServices(globalSettings); services.AddTrialInitiationServices(); services.AddOrganizationServices(globalSettings); + services.AddPamServices(); services.AddPolicyServices(); services.AddScoped(); services.AddScoped(); diff --git a/test/Api.Test/Api.Test.csproj b/test/Api.Test/Api.Test.csproj index da9cdcff060d..7c6b2afe7536 100644 --- a/test/Api.Test/Api.Test.csproj +++ b/test/Api.Test/Api.Test.csproj @@ -24,6 +24,8 @@ + + diff --git a/test/Core.Test/Core.Test.csproj b/test/Core.Test/Core.Test.csproj index 833422ddb99b..eb7f75825309 100644 --- a/test/Core.Test/Core.Test.csproj +++ b/test/Core.Test/Core.Test.csproj @@ -24,6 +24,8 @@ + + diff --git a/test/Infrastructure.IntegrationTest/Infrastructure.IntegrationTest.csproj b/test/Infrastructure.IntegrationTest/Infrastructure.IntegrationTest.csproj index f7ac9e4a62d5..cb29bed85743 100644 --- a/test/Infrastructure.IntegrationTest/Infrastructure.IntegrationTest.csproj +++ b/test/Infrastructure.IntegrationTest/Infrastructure.IntegrationTest.csproj @@ -26,6 +26,7 @@ + From 44c38ac15623f695502c9696b9e6ad73c63172e1 Mon Sep 17 00:00:00 2001 From: Hinton Date: Thu, 18 Jun 2026 19:18:09 +0200 Subject: [PATCH 50/54] PAM: license the implementation under the Bitwarden License Move all PAM business logic into a new commercial project bitwarden_license/src/Commercial.Pam (namespace Bit.Commercial.Pam): the command/query/service implementations, the rule engine, and the real CipherLeaseGate. The AGPL Pam.Domain keeps the data models, enums, and all interfaces; Core keeps the ICipherLeaseGate interface. Open-source builds: - A NoopCipherLeaseGate (Core) is the always-unrestricted ICipherLeaseGate fallback, registered broadly via TryAddScoped in SharedWeb.AddBaseServices; the real gate is registered (last-wins) by AddCommercialPamServices in Api's non-OSS Startup branch. - The PAM API controllers (src/Api/Pam) are excluded from OSS via + a conditional Commercial.Pam reference. Core exposes its internal FullCipherAccess mints to Commercial.Pam via InternalsVisibleTo. PAM repositories, EF model, DbContext config, and mail stay AGPL (data access for the open-source data models). src/Pam is removed; PAM implementation + controller tests move to bitwarden_license/test/Commercial.Pam.Test; pure-domain and CipherService tests stay in Core.Test. Verified: full commercial solution build; OSS builds (Api/Admin/Identity); Commercial.Pam.Test (247) and Core.Test PAM/CipherService (107) pass. --- bitwarden-server.slnx | 5 ++- .../src/Commercial.Pam/Commercial.Pam.csproj | 12 ++++++ .../Engine/AccessRuleEngine.cs | 3 +- .../Commands/ActivateAccessRequestCommand.cs | 2 +- .../Commands/CancelAccessRequestCommand.cs | 2 +- .../Commands/CreateAccessRuleCommand.cs | 2 +- .../Commands/DecideAccessRequestCommand.cs | 2 +- .../Commands/DeleteAccessRuleCommand.cs | 2 +- .../Commands/RequestLeaseExtensionCommand.cs | 2 +- .../Commands/RevokeAccessLeaseCommand.cs | 2 +- .../Commands/SubmitAccessRequestCommand.cs | 2 +- .../Commands/UpdateAccessRuleCommand.cs | 2 +- .../Queries/AccessPreCheckQuery.cs | 2 +- .../Queries/GetCipherAccessStateQuery.cs | 2 +- .../Queries/GetLeasedCipherQuery.cs | 4 +- .../Interfaces/IGetLeasedCipherQuery.cs | 2 +- .../Queries/ListActiveLeasesQuery.cs | 2 +- .../Queries/ListInboxHistoryQuery.cs | 2 +- .../Queries/ListInboxRequestsQuery.cs | 2 +- .../Queries/ListLeaseHistoryQuery.cs | 2 +- .../Queries/ListMyAccessRequestsQuery.cs | 2 +- .../Queries/ListMyActiveAccessLeasesQuery.cs | 2 +- .../Services/AccessRuleValidator.cs | 3 +- .../Services/ApproverCollectionAccessQuery.cs | 3 +- .../Services/ApproverInboxNotifier.cs | 3 +- .../Services/CipherLeaseGate.cs | 3 +- .../Services/GoverningRuleResolver.cs | 3 +- .../Services/RequesterNotifier.cs | 3 +- .../Services/SingleActiveLeaseEvaluator.cs | 3 +- .../Utilities/ServiceCollectionExtensions.cs | 15 ++++--- .../AccessRequestsControllerTests.cs | 2 +- .../Controllers/CipherLeaseControllerTests.cs | 3 +- .../Api}/Controllers/LeasesControllerTests.cs | 2 +- .../Models/AccessDecisionRequestModelTests.cs | 2 +- .../Models/AccessLeaseResponseModelTests.cs | 2 +- .../AccessRequestDetailsResponseModelTests.cs | 2 +- .../ActivateAccessRequestCommandTests.cs | 4 +- .../CancelAccessRequestCommandTests.cs | 4 +- .../Commands/CreateAccessRuleCommandTests.cs | 4 +- .../DecideAccessRequestCommandTests.cs | 4 +- .../Commands/DeleteAccessRuleCommandTests.cs | 4 +- .../RequestLeaseExtensionCommandTests.cs | 4 +- .../Commands/RevokeAccessLeaseCommandTests.cs | 4 +- .../SubmitAccessRequestCommandTests.cs | 4 +- .../Commands/UpdateAccessRuleCommandTests.cs | 4 +- .../Commercial.Pam.Test.csproj | 26 ++++++++++++ .../Engine/AccessRuleEngineTests.cs | 3 +- .../Queries/AccessPreCheckQueryTests.cs | 4 +- .../Queries/GetCipherAccessStateQueryTests.cs | 4 +- .../Queries/GetLeasedCipherQueryTests.cs | 4 +- .../Queries/ListActiveLeasesQueryTests.cs | 4 +- .../Queries/ListInboxHistoryQueryTests.cs | 4 +- .../Queries/ListInboxRequestsQueryTests.cs | 4 +- .../Queries/ListLeaseHistoryQueryTests.cs | 4 +- .../Queries/ListMyAccessRequestsQueryTests.cs | 4 +- .../ListMyActiveAccessLeasesQueryTests.cs | 4 +- .../Services/AccessRuleValidatorTests.cs | 3 +- .../ApproverCollectionAccessQueryTests.cs | 3 +- .../Services/ApproverInboxNotifierTests.cs | 3 +- .../Services/CipherLeaseGateTests.cs | 4 +- .../Services/GoverningRuleResolverTests.cs | 4 +- .../Services/RequesterNotifierTests.cs | 3 +- .../SingleActiveLeaseEvaluatorTests.cs | 3 +- src/Api/Api.csproj | 8 +++- .../Pam/Controllers/CipherLeaseController.cs | 1 + src/Api/Startup.cs | 2 + src/Core/Core.csproj | 1 + src/Core/Pam/Services/NoopCipherLeaseGate.cs | 40 +++++++++++++++++++ src/Pam/Pam.csproj | 12 ------ src/SharedWeb/SharedWeb.csproj | 1 - .../Utilities/ServiceCollectionExtensions.cs | 4 +- test/Api.Test/Api.Test.csproj | 1 - test/Core.Test/Core.Test.csproj | 1 - 73 files changed, 201 insertions(+), 103 deletions(-) create mode 100644 bitwarden_license/src/Commercial.Pam/Commercial.Pam.csproj rename {src/Pam.Domain => bitwarden_license/src/Commercial.Pam}/Engine/AccessRuleEngine.cs (98%) rename {src/Pam => bitwarden_license/src/Commercial.Pam}/OrganizationFeatures/Commands/ActivateAccessRequestCommand.cs (98%) rename {src/Pam => bitwarden_license/src/Commercial.Pam}/OrganizationFeatures/Commands/CancelAccessRequestCommand.cs (98%) rename {src/Pam => bitwarden_license/src/Commercial.Pam}/OrganizationFeatures/Commands/CreateAccessRuleCommand.cs (98%) rename {src/Pam => bitwarden_license/src/Commercial.Pam}/OrganizationFeatures/Commands/DecideAccessRequestCommand.cs (98%) rename {src/Pam => bitwarden_license/src/Commercial.Pam}/OrganizationFeatures/Commands/DeleteAccessRuleCommand.cs (91%) rename {src/Pam => bitwarden_license/src/Commercial.Pam}/OrganizationFeatures/Commands/RequestLeaseExtensionCommand.cs (99%) rename {src/Pam => bitwarden_license/src/Commercial.Pam}/OrganizationFeatures/Commands/RevokeAccessLeaseCommand.cs (98%) rename {src/Pam => bitwarden_license/src/Commercial.Pam}/OrganizationFeatures/Commands/SubmitAccessRequestCommand.cs (99%) rename {src/Pam => bitwarden_license/src/Commercial.Pam}/OrganizationFeatures/Commands/UpdateAccessRuleCommand.cs (98%) rename {src/Pam => bitwarden_license/src/Commercial.Pam}/OrganizationFeatures/Queries/AccessPreCheckQuery.cs (97%) rename {src/Pam => bitwarden_license/src/Commercial.Pam}/OrganizationFeatures/Queries/GetCipherAccessStateQuery.cs (98%) rename {src/Pam => bitwarden_license/src/Commercial.Pam}/OrganizationFeatures/Queries/GetLeasedCipherQuery.cs (95%) rename {src/Pam => bitwarden_license/src/Commercial.Pam}/OrganizationFeatures/Queries/Interfaces/IGetLeasedCipherQuery.cs (87%) rename {src/Pam.Domain => bitwarden_license/src/Commercial.Pam}/OrganizationFeatures/Queries/ListActiveLeasesQuery.cs (95%) rename {src/Pam.Domain => bitwarden_license/src/Commercial.Pam}/OrganizationFeatures/Queries/ListInboxHistoryQuery.cs (96%) rename {src/Pam.Domain => bitwarden_license/src/Commercial.Pam}/OrganizationFeatures/Queries/ListInboxRequestsQuery.cs (94%) rename {src/Pam.Domain => bitwarden_license/src/Commercial.Pam}/OrganizationFeatures/Queries/ListLeaseHistoryQuery.cs (96%) rename {src/Pam.Domain => bitwarden_license/src/Commercial.Pam}/OrganizationFeatures/Queries/ListMyAccessRequestsQuery.cs (90%) rename {src/Pam.Domain => bitwarden_license/src/Commercial.Pam}/OrganizationFeatures/Queries/ListMyActiveAccessLeasesQuery.cs (92%) rename {src/Pam.Domain => bitwarden_license/src/Commercial.Pam}/Services/AccessRuleValidator.cs (98%) rename {src/Pam => bitwarden_license/src/Commercial.Pam}/Services/ApproverCollectionAccessQuery.cs (97%) rename {src/Pam => bitwarden_license/src/Commercial.Pam}/Services/ApproverInboxNotifier.cs (93%) rename {src/Core/Pam => bitwarden_license/src/Commercial.Pam}/Services/CipherLeaseGate.cs (99%) rename {src/Pam => bitwarden_license/src/Commercial.Pam}/Services/GoverningRuleResolver.cs (98%) rename {src/Pam => bitwarden_license/src/Commercial.Pam}/Services/RequesterNotifier.cs (88%) rename {src/Pam => bitwarden_license/src/Commercial.Pam}/Services/SingleActiveLeaseEvaluator.cs (97%) rename src/Pam/PamServiceCollectionExtensions.cs => bitwarden_license/src/Commercial.Pam/Utilities/ServiceCollectionExtensions.cs (84%) rename {test/Api.Test/Pam => bitwarden_license/test/Commercial.Pam.Test/Api}/Controllers/AccessRequestsControllerTests.cs (98%) rename {test/Api.Test/Pam => bitwarden_license/test/Commercial.Pam.Test/Api}/Controllers/CipherLeaseControllerTests.cs (96%) rename {test/Api.Test/Pam => bitwarden_license/test/Commercial.Pam.Test/Api}/Controllers/LeasesControllerTests.cs (98%) rename {test/Api.Test/Pam => bitwarden_license/test/Commercial.Pam.Test/Api}/Models/AccessDecisionRequestModelTests.cs (98%) rename {test/Api.Test/Pam => bitwarden_license/test/Commercial.Pam.Test/Api}/Models/AccessLeaseResponseModelTests.cs (98%) rename {test/Api.Test/Pam => bitwarden_license/test/Commercial.Pam.Test/Api}/Models/AccessRequestDetailsResponseModelTests.cs (99%) rename {test/Core.Test/Pam => bitwarden_license/test/Commercial.Pam.Test}/Commands/ActivateAccessRequestCommandTests.cs (99%) rename {test/Core.Test/Pam => bitwarden_license/test/Commercial.Pam.Test}/Commands/CancelAccessRequestCommandTests.cs (98%) rename {test/Core.Test/Pam => bitwarden_license/test/Commercial.Pam.Test}/Commands/CreateAccessRuleCommandTests.cs (99%) rename {test/Core.Test/Pam => bitwarden_license/test/Commercial.Pam.Test}/Commands/DecideAccessRequestCommandTests.cs (98%) rename {test/Core.Test/Pam => bitwarden_license/test/Commercial.Pam.Test}/Commands/DeleteAccessRuleCommandTests.cs (94%) rename {test/Core.Test/Pam => bitwarden_license/test/Commercial.Pam.Test}/Commands/RequestLeaseExtensionCommandTests.cs (99%) rename {test/Core.Test/Pam => bitwarden_license/test/Commercial.Pam.Test}/Commands/RevokeAccessLeaseCommandTests.cs (98%) rename {test/Core.Test/Pam => bitwarden_license/test/Commercial.Pam.Test}/Commands/SubmitAccessRequestCommandTests.cs (99%) rename {test/Core.Test/Pam => bitwarden_license/test/Commercial.Pam.Test}/Commands/UpdateAccessRuleCommandTests.cs (99%) create mode 100644 bitwarden_license/test/Commercial.Pam.Test/Commercial.Pam.Test.csproj rename {test/Core.Test/Pam => bitwarden_license/test/Commercial.Pam.Test}/Engine/AccessRuleEngineTests.cs (99%) rename {test/Core.Test/Pam => bitwarden_license/test/Commercial.Pam.Test}/Queries/AccessPreCheckQueryTests.cs (97%) rename {test/Core.Test/Pam => bitwarden_license/test/Commercial.Pam.Test}/Queries/GetCipherAccessStateQueryTests.cs (99%) rename {test/Core.Test/Pam => bitwarden_license/test/Commercial.Pam.Test}/Queries/GetLeasedCipherQueryTests.cs (98%) rename {test/Core.Test/Pam => bitwarden_license/test/Commercial.Pam.Test}/Queries/ListActiveLeasesQueryTests.cs (95%) rename {test/Core.Test/Pam => bitwarden_license/test/Commercial.Pam.Test}/Queries/ListInboxHistoryQueryTests.cs (95%) rename {test/Core.Test/Pam => bitwarden_license/test/Commercial.Pam.Test}/Queries/ListInboxRequestsQueryTests.cs (94%) rename {test/Core.Test/Pam => bitwarden_license/test/Commercial.Pam.Test}/Queries/ListLeaseHistoryQueryTests.cs (95%) rename {test/Core.Test/Pam => bitwarden_license/test/Commercial.Pam.Test}/Queries/ListMyAccessRequestsQueryTests.cs (91%) rename {test/Core.Test/Pam => bitwarden_license/test/Commercial.Pam.Test}/Queries/ListMyActiveAccessLeasesQueryTests.cs (92%) rename {test/Core.Test/Pam => bitwarden_license/test/Commercial.Pam.Test}/Services/AccessRuleValidatorTests.cs (98%) rename {test/Core.Test/Pam => bitwarden_license/test/Commercial.Pam.Test}/Services/ApproverCollectionAccessQueryTests.cs (98%) rename {test/Core.Test/Pam => bitwarden_license/test/Commercial.Pam.Test}/Services/ApproverInboxNotifierTests.cs (95%) rename {test/Core.Test/Pam => bitwarden_license/test/Commercial.Pam.Test}/Services/CipherLeaseGateTests.cs (99%) rename {test/Core.Test/Pam => bitwarden_license/test/Commercial.Pam.Test}/Services/GoverningRuleResolverTests.cs (99%) rename {test/Core.Test/Pam => bitwarden_license/test/Commercial.Pam.Test}/Services/RequesterNotifierTests.cs (88%) rename {test/Core.Test/Pam => bitwarden_license/test/Commercial.Pam.Test}/Services/SingleActiveLeaseEvaluatorTests.cs (98%) create mode 100644 src/Core/Pam/Services/NoopCipherLeaseGate.cs delete mode 100644 src/Pam/Pam.csproj diff --git a/bitwarden-server.slnx b/bitwarden-server.slnx index 79e5de1937f8..835e4b2b57f8 100644 --- a/bitwarden-server.slnx +++ b/bitwarden-server.slnx @@ -28,7 +28,6 @@ - @@ -40,11 +39,15 @@ + + + + diff --git a/bitwarden_license/src/Commercial.Pam/Commercial.Pam.csproj b/bitwarden_license/src/Commercial.Pam/Commercial.Pam.csproj new file mode 100644 index 000000000000..26f86c7d10be --- /dev/null +++ b/bitwarden_license/src/Commercial.Pam/Commercial.Pam.csproj @@ -0,0 +1,12 @@ + + + + Bit.Commercial.Pam + + + + + + + + diff --git a/src/Pam.Domain/Engine/AccessRuleEngine.cs b/bitwarden_license/src/Commercial.Pam/Engine/AccessRuleEngine.cs similarity index 98% rename from src/Pam.Domain/Engine/AccessRuleEngine.cs rename to bitwarden_license/src/Commercial.Pam/Engine/AccessRuleEngine.cs index 84f0df3fd845..107b44f1033b 100644 --- a/src/Pam.Domain/Engine/AccessRuleEngine.cs +++ b/bitwarden_license/src/Commercial.Pam/Engine/AccessRuleEngine.cs @@ -1,8 +1,9 @@ using System.Globalization; +using Bit.Pam.Engine; using System.Net; using Bit.Pam.Models.Conditions; -namespace Bit.Pam.Engine; +namespace Bit.Commercial.Pam.Engine; /// /// Evaluates the access rule's flat list of s against the caller's signals. Each diff --git a/src/Pam/OrganizationFeatures/Commands/ActivateAccessRequestCommand.cs b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/ActivateAccessRequestCommand.cs similarity index 98% rename from src/Pam/OrganizationFeatures/Commands/ActivateAccessRequestCommand.cs rename to bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/ActivateAccessRequestCommand.cs index 7ed83ad559d8..9b578593af9d 100644 --- a/src/Pam/OrganizationFeatures/Commands/ActivateAccessRequestCommand.cs +++ b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/ActivateAccessRequestCommand.cs @@ -5,7 +5,7 @@ using Bit.Pam.Repositories; using Bit.Pam.Services; -namespace Bit.Pam.OrganizationFeatures.Commands; +namespace Bit.Commercial.Pam.OrganizationFeatures.Commands; public class ActivateAccessRequestCommand : IActivateAccessRequestCommand { diff --git a/src/Pam/OrganizationFeatures/Commands/CancelAccessRequestCommand.cs b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/CancelAccessRequestCommand.cs similarity index 98% rename from src/Pam/OrganizationFeatures/Commands/CancelAccessRequestCommand.cs rename to bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/CancelAccessRequestCommand.cs index 92477e39a7b6..f5ec111994b7 100644 --- a/src/Pam/OrganizationFeatures/Commands/CancelAccessRequestCommand.cs +++ b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/CancelAccessRequestCommand.cs @@ -5,7 +5,7 @@ using Bit.Pam.Repositories; using Bit.Pam.Services; -namespace Bit.Pam.OrganizationFeatures.Commands; +namespace Bit.Commercial.Pam.OrganizationFeatures.Commands; public class CancelAccessRequestCommand : ICancelAccessRequestCommand { diff --git a/src/Pam/OrganizationFeatures/Commands/CreateAccessRuleCommand.cs b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/CreateAccessRuleCommand.cs similarity index 98% rename from src/Pam/OrganizationFeatures/Commands/CreateAccessRuleCommand.cs rename to bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/CreateAccessRuleCommand.cs index 984ae3046d6f..6e5f73ec57c2 100644 --- a/src/Pam/OrganizationFeatures/Commands/CreateAccessRuleCommand.cs +++ b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/CreateAccessRuleCommand.cs @@ -7,7 +7,7 @@ using Bit.Pam.Repositories; using Bit.Pam.Services; -namespace Bit.Pam.OrganizationFeatures.Commands; +namespace Bit.Commercial.Pam.OrganizationFeatures.Commands; public class CreateAccessRuleCommand : ICreateAccessRuleCommand { diff --git a/src/Pam/OrganizationFeatures/Commands/DecideAccessRequestCommand.cs b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/DecideAccessRequestCommand.cs similarity index 98% rename from src/Pam/OrganizationFeatures/Commands/DecideAccessRequestCommand.cs rename to bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/DecideAccessRequestCommand.cs index 1e70e2336228..872faa2f4053 100644 --- a/src/Pam/OrganizationFeatures/Commands/DecideAccessRequestCommand.cs +++ b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/DecideAccessRequestCommand.cs @@ -6,7 +6,7 @@ using Bit.Pam.Repositories; using Bit.Pam.Services; -namespace Bit.Pam.OrganizationFeatures.Commands; +namespace Bit.Commercial.Pam.OrganizationFeatures.Commands; public class DecideAccessRequestCommand : IDecideAccessRequestCommand { diff --git a/src/Pam/OrganizationFeatures/Commands/DeleteAccessRuleCommand.cs b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/DeleteAccessRuleCommand.cs similarity index 91% rename from src/Pam/OrganizationFeatures/Commands/DeleteAccessRuleCommand.cs rename to bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/DeleteAccessRuleCommand.cs index b1987f7d7c4e..59836df73ee1 100644 --- a/src/Pam/OrganizationFeatures/Commands/DeleteAccessRuleCommand.cs +++ b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/DeleteAccessRuleCommand.cs @@ -2,7 +2,7 @@ using Bit.Pam.OrganizationFeatures.Commands.Interfaces; using Bit.Pam.Repositories; -namespace Bit.Pam.OrganizationFeatures.Commands; +namespace Bit.Commercial.Pam.OrganizationFeatures.Commands; public class DeleteAccessRuleCommand : IDeleteAccessRuleCommand { diff --git a/src/Pam/OrganizationFeatures/Commands/RequestLeaseExtensionCommand.cs b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/RequestLeaseExtensionCommand.cs similarity index 99% rename from src/Pam/OrganizationFeatures/Commands/RequestLeaseExtensionCommand.cs rename to bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/RequestLeaseExtensionCommand.cs index 568fc1935432..1acc8329a394 100644 --- a/src/Pam/OrganizationFeatures/Commands/RequestLeaseExtensionCommand.cs +++ b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/RequestLeaseExtensionCommand.cs @@ -8,7 +8,7 @@ using Bit.Pam.Repositories; using Bit.Pam.Services; -namespace Bit.Pam.OrganizationFeatures.Commands; +namespace Bit.Commercial.Pam.OrganizationFeatures.Commands; public class RequestLeaseExtensionCommand : IRequestLeaseExtensionCommand { diff --git a/src/Pam/OrganizationFeatures/Commands/RevokeAccessLeaseCommand.cs b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/RevokeAccessLeaseCommand.cs similarity index 98% rename from src/Pam/OrganizationFeatures/Commands/RevokeAccessLeaseCommand.cs rename to bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/RevokeAccessLeaseCommand.cs index e32841acc6dc..df1be5df674b 100644 --- a/src/Pam/OrganizationFeatures/Commands/RevokeAccessLeaseCommand.cs +++ b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/RevokeAccessLeaseCommand.cs @@ -5,7 +5,7 @@ using Bit.Pam.Repositories; using Bit.Pam.Services; -namespace Bit.Pam.OrganizationFeatures.Commands; +namespace Bit.Commercial.Pam.OrganizationFeatures.Commands; public class RevokeAccessLeaseCommand : IRevokeAccessLeaseCommand { diff --git a/src/Pam/OrganizationFeatures/Commands/SubmitAccessRequestCommand.cs b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/SubmitAccessRequestCommand.cs similarity index 99% rename from src/Pam/OrganizationFeatures/Commands/SubmitAccessRequestCommand.cs rename to bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/SubmitAccessRequestCommand.cs index a2c69eedef13..47c9b80610e3 100644 --- a/src/Pam/OrganizationFeatures/Commands/SubmitAccessRequestCommand.cs +++ b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/SubmitAccessRequestCommand.cs @@ -12,7 +12,7 @@ using Bit.Pam.Services; using Microsoft.Extensions.Logging; -namespace Bit.Pam.OrganizationFeatures.Commands; +namespace Bit.Commercial.Pam.OrganizationFeatures.Commands; public class SubmitAccessRequestCommand : ISubmitAccessRequestCommand { diff --git a/src/Pam/OrganizationFeatures/Commands/UpdateAccessRuleCommand.cs b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/UpdateAccessRuleCommand.cs similarity index 98% rename from src/Pam/OrganizationFeatures/Commands/UpdateAccessRuleCommand.cs rename to bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/UpdateAccessRuleCommand.cs index c3cbb39b3694..1c5bc1d79f5c 100644 --- a/src/Pam/OrganizationFeatures/Commands/UpdateAccessRuleCommand.cs +++ b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/UpdateAccessRuleCommand.cs @@ -6,7 +6,7 @@ using Bit.Pam.Repositories; using Bit.Pam.Services; -namespace Bit.Pam.OrganizationFeatures.Commands; +namespace Bit.Commercial.Pam.OrganizationFeatures.Commands; public class UpdateAccessRuleCommand : IUpdateAccessRuleCommand { diff --git a/src/Pam/OrganizationFeatures/Queries/AccessPreCheckQuery.cs b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/AccessPreCheckQuery.cs similarity index 97% rename from src/Pam/OrganizationFeatures/Queries/AccessPreCheckQuery.cs rename to bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/AccessPreCheckQuery.cs index 26402be5976e..c611f160e56a 100644 --- a/src/Pam/OrganizationFeatures/Queries/AccessPreCheckQuery.cs +++ b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/AccessPreCheckQuery.cs @@ -8,7 +8,7 @@ using Bit.Pam.Repositories; using Bit.Pam.Services; -namespace Bit.Pam.OrganizationFeatures.Queries; +namespace Bit.Commercial.Pam.OrganizationFeatures.Queries; public class AccessPreCheckQuery : IAccessPreCheckQuery { diff --git a/src/Pam/OrganizationFeatures/Queries/GetCipherAccessStateQuery.cs b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/GetCipherAccessStateQuery.cs similarity index 98% rename from src/Pam/OrganizationFeatures/Queries/GetCipherAccessStateQuery.cs rename to bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/GetCipherAccessStateQuery.cs index f717f1fcb337..11056016d75a 100644 --- a/src/Pam/OrganizationFeatures/Queries/GetCipherAccessStateQuery.cs +++ b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/GetCipherAccessStateQuery.cs @@ -8,7 +8,7 @@ using Bit.Pam.Repositories; using Bit.Pam.Services; -namespace Bit.Pam.OrganizationFeatures.Queries; +namespace Bit.Commercial.Pam.OrganizationFeatures.Queries; public class GetCipherAccessStateQuery : IGetCipherAccessStateQuery { diff --git a/src/Pam/OrganizationFeatures/Queries/GetLeasedCipherQuery.cs b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/GetLeasedCipherQuery.cs similarity index 95% rename from src/Pam/OrganizationFeatures/Queries/GetLeasedCipherQuery.cs rename to bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/GetLeasedCipherQuery.cs index 154702d70ea3..6b9a4082e7ef 100644 --- a/src/Pam/OrganizationFeatures/Queries/GetLeasedCipherQuery.cs +++ b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/GetLeasedCipherQuery.cs @@ -2,11 +2,11 @@ using Bit.Core.Vault.Models.Data; using Bit.Core.Vault.Repositories; using Bit.Pam.Engine; -using Bit.Pam.OrganizationFeatures.Queries.Interfaces; +using Bit.Commercial.Pam.OrganizationFeatures.Queries.Interfaces; using Bit.Pam.Repositories; using Bit.Pam.Services; -namespace Bit.Pam.OrganizationFeatures.Queries; +namespace Bit.Commercial.Pam.OrganizationFeatures.Queries; public class GetLeasedCipherQuery : IGetLeasedCipherQuery { diff --git a/src/Pam/OrganizationFeatures/Queries/Interfaces/IGetLeasedCipherQuery.cs b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/Interfaces/IGetLeasedCipherQuery.cs similarity index 87% rename from src/Pam/OrganizationFeatures/Queries/Interfaces/IGetLeasedCipherQuery.cs rename to bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/Interfaces/IGetLeasedCipherQuery.cs index 0dfb13196852..882c4b194893 100644 --- a/src/Pam/OrganizationFeatures/Queries/Interfaces/IGetLeasedCipherQuery.cs +++ b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/Interfaces/IGetLeasedCipherQuery.cs @@ -1,6 +1,6 @@ using Bit.Core.Vault.Models.Data; -namespace Bit.Pam.OrganizationFeatures.Queries.Interfaces; +namespace Bit.Commercial.Pam.OrganizationFeatures.Queries.Interfaces; public interface IGetLeasedCipherQuery { diff --git a/src/Pam.Domain/OrganizationFeatures/Queries/ListActiveLeasesQuery.cs b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/ListActiveLeasesQuery.cs similarity index 95% rename from src/Pam.Domain/OrganizationFeatures/Queries/ListActiveLeasesQuery.cs rename to bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/ListActiveLeasesQuery.cs index 145d5717f141..a5dd82666b9d 100644 --- a/src/Pam.Domain/OrganizationFeatures/Queries/ListActiveLeasesQuery.cs +++ b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/ListActiveLeasesQuery.cs @@ -3,7 +3,7 @@ using Bit.Pam.Repositories; using Bit.Pam.Services; -namespace Bit.Pam.OrganizationFeatures.Queries; +namespace Bit.Commercial.Pam.OrganizationFeatures.Queries; public class ListActiveLeasesQuery : IListActiveLeasesQuery { diff --git a/src/Pam.Domain/OrganizationFeatures/Queries/ListInboxHistoryQuery.cs b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/ListInboxHistoryQuery.cs similarity index 96% rename from src/Pam.Domain/OrganizationFeatures/Queries/ListInboxHistoryQuery.cs rename to bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/ListInboxHistoryQuery.cs index a9e55c105d73..1246fda1e1e4 100644 --- a/src/Pam.Domain/OrganizationFeatures/Queries/ListInboxHistoryQuery.cs +++ b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/ListInboxHistoryQuery.cs @@ -3,7 +3,7 @@ using Bit.Pam.Repositories; using Bit.Pam.Services; -namespace Bit.Pam.OrganizationFeatures.Queries; +namespace Bit.Commercial.Pam.OrganizationFeatures.Queries; public class ListInboxHistoryQuery : IListInboxHistoryQuery { diff --git a/src/Pam.Domain/OrganizationFeatures/Queries/ListInboxRequestsQuery.cs b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/ListInboxRequestsQuery.cs similarity index 94% rename from src/Pam.Domain/OrganizationFeatures/Queries/ListInboxRequestsQuery.cs rename to bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/ListInboxRequestsQuery.cs index 2cdfbe242603..90a1045e9b3e 100644 --- a/src/Pam.Domain/OrganizationFeatures/Queries/ListInboxRequestsQuery.cs +++ b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/ListInboxRequestsQuery.cs @@ -3,7 +3,7 @@ using Bit.Pam.Repositories; using Bit.Pam.Services; -namespace Bit.Pam.OrganizationFeatures.Queries; +namespace Bit.Commercial.Pam.OrganizationFeatures.Queries; public class ListInboxRequestsQuery : IListInboxRequestsQuery { diff --git a/src/Pam.Domain/OrganizationFeatures/Queries/ListLeaseHistoryQuery.cs b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/ListLeaseHistoryQuery.cs similarity index 96% rename from src/Pam.Domain/OrganizationFeatures/Queries/ListLeaseHistoryQuery.cs rename to bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/ListLeaseHistoryQuery.cs index dd437deb7dc9..e730c1607edf 100644 --- a/src/Pam.Domain/OrganizationFeatures/Queries/ListLeaseHistoryQuery.cs +++ b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/ListLeaseHistoryQuery.cs @@ -3,7 +3,7 @@ using Bit.Pam.Repositories; using Bit.Pam.Services; -namespace Bit.Pam.OrganizationFeatures.Queries; +namespace Bit.Commercial.Pam.OrganizationFeatures.Queries; public class ListLeaseHistoryQuery : IListLeaseHistoryQuery { diff --git a/src/Pam.Domain/OrganizationFeatures/Queries/ListMyAccessRequestsQuery.cs b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/ListMyAccessRequestsQuery.cs similarity index 90% rename from src/Pam.Domain/OrganizationFeatures/Queries/ListMyAccessRequestsQuery.cs rename to bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/ListMyAccessRequestsQuery.cs index 58ccd1bd6b0f..5983f9c38fed 100644 --- a/src/Pam.Domain/OrganizationFeatures/Queries/ListMyAccessRequestsQuery.cs +++ b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/ListMyAccessRequestsQuery.cs @@ -2,7 +2,7 @@ using Bit.Pam.OrganizationFeatures.Queries.Interfaces; using Bit.Pam.Repositories; -namespace Bit.Pam.OrganizationFeatures.Queries; +namespace Bit.Commercial.Pam.OrganizationFeatures.Queries; public class ListMyAccessRequestsQuery : IListMyAccessRequestsQuery { diff --git a/src/Pam.Domain/OrganizationFeatures/Queries/ListMyActiveAccessLeasesQuery.cs b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/ListMyActiveAccessLeasesQuery.cs similarity index 92% rename from src/Pam.Domain/OrganizationFeatures/Queries/ListMyActiveAccessLeasesQuery.cs rename to bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/ListMyActiveAccessLeasesQuery.cs index 8c3cc5f0c77a..36207d9b1ede 100644 --- a/src/Pam.Domain/OrganizationFeatures/Queries/ListMyActiveAccessLeasesQuery.cs +++ b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/ListMyActiveAccessLeasesQuery.cs @@ -2,7 +2,7 @@ using Bit.Pam.OrganizationFeatures.Queries.Interfaces; using Bit.Pam.Repositories; -namespace Bit.Pam.OrganizationFeatures.Queries; +namespace Bit.Commercial.Pam.OrganizationFeatures.Queries; public class ListMyActiveAccessLeasesQuery : IListMyActiveAccessLeasesQuery { diff --git a/src/Pam.Domain/Services/AccessRuleValidator.cs b/bitwarden_license/src/Commercial.Pam/Services/AccessRuleValidator.cs similarity index 98% rename from src/Pam.Domain/Services/AccessRuleValidator.cs rename to bitwarden_license/src/Commercial.Pam/Services/AccessRuleValidator.cs index 9fd4f75bbe52..e01e3e5be259 100644 --- a/src/Pam.Domain/Services/AccessRuleValidator.cs +++ b/bitwarden_license/src/Commercial.Pam/Services/AccessRuleValidator.cs @@ -1,9 +1,10 @@ using System.Net; +using Bit.Pam.Services; using System.Text.Json; using System.Text.RegularExpressions; using Bit.Pam.Models.Conditions; -namespace Bit.Pam.Services; +namespace Bit.Commercial.Pam.Services; public sealed partial class AccessRuleValidator : IAccessRuleValidator { diff --git a/src/Pam/Services/ApproverCollectionAccessQuery.cs b/bitwarden_license/src/Commercial.Pam/Services/ApproverCollectionAccessQuery.cs similarity index 97% rename from src/Pam/Services/ApproverCollectionAccessQuery.cs rename to bitwarden_license/src/Commercial.Pam/Services/ApproverCollectionAccessQuery.cs index e2bbe4d0aec2..fd0b6d0b3af1 100644 --- a/src/Pam/Services/ApproverCollectionAccessQuery.cs +++ b/bitwarden_license/src/Commercial.Pam/Services/ApproverCollectionAccessQuery.cs @@ -1,9 +1,10 @@ using Bit.Core.Context; +using Bit.Pam.Services; using Bit.Core.Enums; using Bit.Core.Repositories; using Bit.Core.Services; -namespace Bit.Pam.Services; +namespace Bit.Commercial.Pam.Services; public class ApproverCollectionAccessQuery : IApproverCollectionAccessQuery { diff --git a/src/Pam/Services/ApproverInboxNotifier.cs b/bitwarden_license/src/Commercial.Pam/Services/ApproverInboxNotifier.cs similarity index 93% rename from src/Pam/Services/ApproverInboxNotifier.cs rename to bitwarden_license/src/Commercial.Pam/Services/ApproverInboxNotifier.cs index c4fa2d2c49b1..44d57b83c39c 100644 --- a/src/Pam/Services/ApproverInboxNotifier.cs +++ b/bitwarden_license/src/Commercial.Pam/Services/ApproverInboxNotifier.cs @@ -1,7 +1,8 @@ using Bit.Core.Platform.Push; +using Bit.Pam.Services; using Bit.Core.Repositories; -namespace Bit.Pam.Services; +namespace Bit.Commercial.Pam.Services; public class ApproverInboxNotifier : IApproverInboxNotifier { diff --git a/src/Core/Pam/Services/CipherLeaseGate.cs b/bitwarden_license/src/Commercial.Pam/Services/CipherLeaseGate.cs similarity index 99% rename from src/Core/Pam/Services/CipherLeaseGate.cs rename to bitwarden_license/src/Commercial.Pam/Services/CipherLeaseGate.cs index 85337802a441..f73fa804c1e5 100644 --- a/src/Core/Pam/Services/CipherLeaseGate.cs +++ b/bitwarden_license/src/Commercial.Pam/Services/CipherLeaseGate.cs @@ -1,4 +1,5 @@ using Bit.Core; +using Bit.Pam.Services; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Exceptions; @@ -10,7 +11,7 @@ using Bit.Pam.Engine; using Bit.Pam.Repositories; -namespace Bit.Pam.Services; +namespace Bit.Commercial.Pam.Services; /// public class CipherLeaseGate : ICipherLeaseGate diff --git a/src/Pam/Services/GoverningRuleResolver.cs b/bitwarden_license/src/Commercial.Pam/Services/GoverningRuleResolver.cs similarity index 98% rename from src/Pam/Services/GoverningRuleResolver.cs rename to bitwarden_license/src/Commercial.Pam/Services/GoverningRuleResolver.cs index 84437c85c640..9fe20aa8344c 100644 --- a/src/Pam/Services/GoverningRuleResolver.cs +++ b/bitwarden_license/src/Commercial.Pam/Services/GoverningRuleResolver.cs @@ -1,11 +1,12 @@ using System.Text.Json; +using Bit.Pam.Services; using Bit.Core.Repositories; using Bit.Pam.Engine; using Bit.Pam.Models; using Bit.Pam.Models.Conditions; using Bit.Pam.Repositories; -namespace Bit.Pam.Services; +namespace Bit.Commercial.Pam.Services; public class GoverningRuleResolver : IGoverningRuleResolver { diff --git a/src/Pam/Services/RequesterNotifier.cs b/bitwarden_license/src/Commercial.Pam/Services/RequesterNotifier.cs similarity index 88% rename from src/Pam/Services/RequesterNotifier.cs rename to bitwarden_license/src/Commercial.Pam/Services/RequesterNotifier.cs index 2371f63a7f84..e3bdbdcdedc0 100644 --- a/src/Pam/Services/RequesterNotifier.cs +++ b/bitwarden_license/src/Commercial.Pam/Services/RequesterNotifier.cs @@ -1,6 +1,7 @@ using Bit.Core.Platform.Push; +using Bit.Pam.Services; -namespace Bit.Pam.Services; +namespace Bit.Commercial.Pam.Services; public class RequesterNotifier : IRequesterNotifier { diff --git a/src/Pam/Services/SingleActiveLeaseEvaluator.cs b/bitwarden_license/src/Commercial.Pam/Services/SingleActiveLeaseEvaluator.cs similarity index 97% rename from src/Pam/Services/SingleActiveLeaseEvaluator.cs rename to bitwarden_license/src/Commercial.Pam/Services/SingleActiveLeaseEvaluator.cs index f515d58b2ed8..c59d49d1702f 100644 --- a/src/Pam/Services/SingleActiveLeaseEvaluator.cs +++ b/bitwarden_license/src/Commercial.Pam/Services/SingleActiveLeaseEvaluator.cs @@ -1,7 +1,8 @@ using Bit.Core.Repositories; +using Bit.Pam.Services; using Bit.Pam.Repositories; -namespace Bit.Pam.Services; +namespace Bit.Commercial.Pam.Services; public class SingleActiveLeaseEvaluator : ISingleActiveLeaseEvaluator { diff --git a/src/Pam/PamServiceCollectionExtensions.cs b/bitwarden_license/src/Commercial.Pam/Utilities/ServiceCollectionExtensions.cs similarity index 84% rename from src/Pam/PamServiceCollectionExtensions.cs rename to bitwarden_license/src/Commercial.Pam/Utilities/ServiceCollectionExtensions.cs index aa39a6250da0..630aea3086a4 100644 --- a/src/Pam/PamServiceCollectionExtensions.cs +++ b/bitwarden_license/src/Commercial.Pam/Utilities/ServiceCollectionExtensions.cs @@ -1,17 +1,20 @@ -using Bit.Pam.Engine; -using Bit.Pam.OrganizationFeatures.Commands; +using Bit.Commercial.Pam.Engine; +using Bit.Commercial.Pam.OrganizationFeatures.Commands; +using Bit.Commercial.Pam.OrganizationFeatures.Queries; +using Bit.Commercial.Pam.OrganizationFeatures.Queries.Interfaces; +using Bit.Commercial.Pam.Services; +using Bit.Pam.Engine; using Bit.Pam.OrganizationFeatures.Commands.Interfaces; -using Bit.Pam.OrganizationFeatures.Queries; using Bit.Pam.OrganizationFeatures.Queries.Interfaces; using Bit.Pam.Services; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; -namespace Bit.Pam; +namespace Bit.Commercial.Pam.Utilities; -public static class PamServiceCollectionExtensions +public static class ServiceCollectionExtensions { - public static void AddPamServices(this IServiceCollection services) + public static void AddCommercialPamServices(this IServiceCollection services) { services.AddSingleton(); services.AddScoped(); diff --git a/test/Api.Test/Pam/Controllers/AccessRequestsControllerTests.cs b/bitwarden_license/test/Commercial.Pam.Test/Api/Controllers/AccessRequestsControllerTests.cs similarity index 98% rename from test/Api.Test/Pam/Controllers/AccessRequestsControllerTests.cs rename to bitwarden_license/test/Commercial.Pam.Test/Api/Controllers/AccessRequestsControllerTests.cs index f8215fbcb51f..0ca37aa4642b 100644 --- a/test/Api.Test/Pam/Controllers/AccessRequestsControllerTests.cs +++ b/bitwarden_license/test/Commercial.Pam.Test/Api/Controllers/AccessRequestsControllerTests.cs @@ -13,7 +13,7 @@ using NSubstitute; using Xunit; -namespace Bit.Api.Test.Pam.Controllers; +namespace Bit.Commercial.Pam.Test.Api.Controllers; [ControllerCustomize(typeof(AccessRequestsController))] [SutProviderCustomize] diff --git a/test/Api.Test/Pam/Controllers/CipherLeaseControllerTests.cs b/bitwarden_license/test/Commercial.Pam.Test/Api/Controllers/CipherLeaseControllerTests.cs similarity index 96% rename from test/Api.Test/Pam/Controllers/CipherLeaseControllerTests.cs rename to bitwarden_license/test/Commercial.Pam.Test/Api/Controllers/CipherLeaseControllerTests.cs index 212cabbb08a7..09c79fc1ce63 100644 --- a/test/Api.Test/Pam/Controllers/CipherLeaseControllerTests.cs +++ b/bitwarden_license/test/Commercial.Pam.Test/Api/Controllers/CipherLeaseControllerTests.cs @@ -1,4 +1,5 @@ using System.Security.Claims; +using Bit.Commercial.Pam.OrganizationFeatures.Queries.Interfaces; using Bit.Api.Pam.Controllers; using Bit.Api.Vault.Models.Response; using Bit.Core.Entities; @@ -15,7 +16,7 @@ using Xunit; using CipherType = Bit.Core.Vault.Enums.CipherType; -namespace Bit.Api.Test.Pam.Controllers; +namespace Bit.Commercial.Pam.Test.Api.Controllers; [ControllerCustomize(typeof(CipherLeaseController))] [SutProviderCustomize] diff --git a/test/Api.Test/Pam/Controllers/LeasesControllerTests.cs b/bitwarden_license/test/Commercial.Pam.Test/Api/Controllers/LeasesControllerTests.cs similarity index 98% rename from test/Api.Test/Pam/Controllers/LeasesControllerTests.cs rename to bitwarden_license/test/Commercial.Pam.Test/Api/Controllers/LeasesControllerTests.cs index 6680bcea062d..c8bc5adb09d8 100644 --- a/test/Api.Test/Pam/Controllers/LeasesControllerTests.cs +++ b/bitwarden_license/test/Commercial.Pam.Test/Api/Controllers/LeasesControllerTests.cs @@ -13,7 +13,7 @@ using NSubstitute; using Xunit; -namespace Bit.Api.Test.Pam.Controllers; +namespace Bit.Commercial.Pam.Test.Api.Controllers; [ControllerCustomize(typeof(LeasesController))] [SutProviderCustomize] diff --git a/test/Api.Test/Pam/Models/AccessDecisionRequestModelTests.cs b/bitwarden_license/test/Commercial.Pam.Test/Api/Models/AccessDecisionRequestModelTests.cs similarity index 98% rename from test/Api.Test/Pam/Models/AccessDecisionRequestModelTests.cs rename to bitwarden_license/test/Commercial.Pam.Test/Api/Models/AccessDecisionRequestModelTests.cs index a6c1c0c1d804..6dd285da73b4 100644 --- a/test/Api.Test/Pam/Models/AccessDecisionRequestModelTests.cs +++ b/bitwarden_license/test/Commercial.Pam.Test/Api/Models/AccessDecisionRequestModelTests.cs @@ -4,7 +4,7 @@ using Bit.Pam.Enums; using Xunit; -namespace Bit.Api.Test.Pam.Models; +namespace Bit.Commercial.Pam.Test.Api.Models; public class AccessDecisionRequestModelTests { diff --git a/test/Api.Test/Pam/Models/AccessLeaseResponseModelTests.cs b/bitwarden_license/test/Commercial.Pam.Test/Api/Models/AccessLeaseResponseModelTests.cs similarity index 98% rename from test/Api.Test/Pam/Models/AccessLeaseResponseModelTests.cs rename to bitwarden_license/test/Commercial.Pam.Test/Api/Models/AccessLeaseResponseModelTests.cs index 2462df990315..d857fb71f33e 100644 --- a/test/Api.Test/Pam/Models/AccessLeaseResponseModelTests.cs +++ b/bitwarden_license/test/Commercial.Pam.Test/Api/Models/AccessLeaseResponseModelTests.cs @@ -5,7 +5,7 @@ using Bit.Test.Common.AutoFixture.Attributes; using Xunit; -namespace Bit.Api.Test.Pam.Models; +namespace Bit.Commercial.Pam.Test.Api.Models; public class AccessLeaseResponseModelTests { diff --git a/test/Api.Test/Pam/Models/AccessRequestDetailsResponseModelTests.cs b/bitwarden_license/test/Commercial.Pam.Test/Api/Models/AccessRequestDetailsResponseModelTests.cs similarity index 99% rename from test/Api.Test/Pam/Models/AccessRequestDetailsResponseModelTests.cs rename to bitwarden_license/test/Commercial.Pam.Test/Api/Models/AccessRequestDetailsResponseModelTests.cs index 8eb90cc38bc0..231b24644b31 100644 --- a/test/Api.Test/Pam/Models/AccessRequestDetailsResponseModelTests.cs +++ b/bitwarden_license/test/Commercial.Pam.Test/Api/Models/AccessRequestDetailsResponseModelTests.cs @@ -3,7 +3,7 @@ using Bit.Pam.Models; using Xunit; -namespace Bit.Api.Test.Pam.Models; +namespace Bit.Commercial.Pam.Test.Api.Models; public class AccessRequestDetailsResponseModelTests { diff --git a/test/Core.Test/Pam/Commands/ActivateAccessRequestCommandTests.cs b/bitwarden_license/test/Commercial.Pam.Test/Commands/ActivateAccessRequestCommandTests.cs similarity index 99% rename from test/Core.Test/Pam/Commands/ActivateAccessRequestCommandTests.cs rename to bitwarden_license/test/Commercial.Pam.Test/Commands/ActivateAccessRequestCommandTests.cs index f2b3c729f85f..bbe2e4412d1a 100644 --- a/test/Core.Test/Pam/Commands/ActivateAccessRequestCommandTests.cs +++ b/bitwarden_license/test/Commercial.Pam.Test/Commands/ActivateAccessRequestCommandTests.cs @@ -1,7 +1,7 @@ using Bit.Core.Exceptions; using Bit.Pam.Entities; using Bit.Pam.Enums; -using Bit.Pam.OrganizationFeatures.Commands; +using Bit.Commercial.Pam.OrganizationFeatures.Commands; using Bit.Pam.Repositories; using Bit.Pam.Services; using Bit.Test.Common.AutoFixture; @@ -10,7 +10,7 @@ using NSubstitute; using Xunit; -namespace Bit.Core.Test.Pam.Commands; +namespace Bit.Commercial.Pam.Test.Commands; [SutProviderCustomize] public class ActivateAccessRequestCommandTests diff --git a/test/Core.Test/Pam/Commands/CancelAccessRequestCommandTests.cs b/bitwarden_license/test/Commercial.Pam.Test/Commands/CancelAccessRequestCommandTests.cs similarity index 98% rename from test/Core.Test/Pam/Commands/CancelAccessRequestCommandTests.cs rename to bitwarden_license/test/Commercial.Pam.Test/Commands/CancelAccessRequestCommandTests.cs index 56a05c47a2aa..071fa04a70fa 100644 --- a/test/Core.Test/Pam/Commands/CancelAccessRequestCommandTests.cs +++ b/bitwarden_license/test/Commercial.Pam.Test/Commands/CancelAccessRequestCommandTests.cs @@ -1,7 +1,7 @@ using Bit.Core.Exceptions; using Bit.Pam.Entities; using Bit.Pam.Enums; -using Bit.Pam.OrganizationFeatures.Commands; +using Bit.Commercial.Pam.OrganizationFeatures.Commands; using Bit.Pam.Repositories; using Bit.Pam.Services; using Bit.Test.Common.AutoFixture; @@ -10,7 +10,7 @@ using NSubstitute; using Xunit; -namespace Bit.Core.Test.Pam.Commands; +namespace Bit.Commercial.Pam.Test.Commands; [SutProviderCustomize] public class CancelAccessRequestCommandTests diff --git a/test/Core.Test/Pam/Commands/CreateAccessRuleCommandTests.cs b/bitwarden_license/test/Commercial.Pam.Test/Commands/CreateAccessRuleCommandTests.cs similarity index 99% rename from test/Core.Test/Pam/Commands/CreateAccessRuleCommandTests.cs rename to bitwarden_license/test/Commercial.Pam.Test/Commands/CreateAccessRuleCommandTests.cs index fed46e3dd27c..72a1ed6bfc9c 100644 --- a/test/Core.Test/Pam/Commands/CreateAccessRuleCommandTests.cs +++ b/bitwarden_license/test/Commercial.Pam.Test/Commands/CreateAccessRuleCommandTests.cs @@ -2,7 +2,7 @@ using Bit.Core.Exceptions; using Bit.Core.Repositories; using Bit.Pam.Entities; -using Bit.Pam.OrganizationFeatures.Commands; +using Bit.Commercial.Pam.OrganizationFeatures.Commands; using Bit.Pam.Repositories; using Bit.Pam.Services; using Bit.Test.Common.AutoFixture; @@ -11,7 +11,7 @@ using NSubstitute; using Xunit; -namespace Bit.Core.Test.Pam.Commands; +namespace Bit.Commercial.Pam.Test.Commands; [SutProviderCustomize] public class CreateAccessRuleCommandTests diff --git a/test/Core.Test/Pam/Commands/DecideAccessRequestCommandTests.cs b/bitwarden_license/test/Commercial.Pam.Test/Commands/DecideAccessRequestCommandTests.cs similarity index 98% rename from test/Core.Test/Pam/Commands/DecideAccessRequestCommandTests.cs rename to bitwarden_license/test/Commercial.Pam.Test/Commands/DecideAccessRequestCommandTests.cs index ca570b7a33b5..0059773fde30 100644 --- a/test/Core.Test/Pam/Commands/DecideAccessRequestCommandTests.cs +++ b/bitwarden_license/test/Commercial.Pam.Test/Commands/DecideAccessRequestCommandTests.cs @@ -2,7 +2,7 @@ using Bit.Pam.Entities; using Bit.Pam.Enums; using Bit.Pam.Models; -using Bit.Pam.OrganizationFeatures.Commands; +using Bit.Commercial.Pam.OrganizationFeatures.Commands; using Bit.Pam.Repositories; using Bit.Pam.Services; using Bit.Test.Common.AutoFixture; @@ -11,7 +11,7 @@ using NSubstitute; using Xunit; -namespace Bit.Core.Test.Pam.Commands; +namespace Bit.Commercial.Pam.Test.Commands; [SutProviderCustomize] public class DecideAccessRequestCommandTests diff --git a/test/Core.Test/Pam/Commands/DeleteAccessRuleCommandTests.cs b/bitwarden_license/test/Commercial.Pam.Test/Commands/DeleteAccessRuleCommandTests.cs similarity index 94% rename from test/Core.Test/Pam/Commands/DeleteAccessRuleCommandTests.cs rename to bitwarden_license/test/Commercial.Pam.Test/Commands/DeleteAccessRuleCommandTests.cs index 6403114a6cbc..7a19853c4cb4 100644 --- a/test/Core.Test/Pam/Commands/DeleteAccessRuleCommandTests.cs +++ b/bitwarden_license/test/Commercial.Pam.Test/Commands/DeleteAccessRuleCommandTests.cs @@ -1,13 +1,13 @@ using Bit.Core.Exceptions; using Bit.Pam.Entities; -using Bit.Pam.OrganizationFeatures.Commands; +using Bit.Commercial.Pam.OrganizationFeatures.Commands; using Bit.Pam.Repositories; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; using Xunit; -namespace Bit.Core.Test.Pam.Commands; +namespace Bit.Commercial.Pam.Test.Commands; [SutProviderCustomize] public class DeleteAccessRuleCommandTests diff --git a/test/Core.Test/Pam/Commands/RequestLeaseExtensionCommandTests.cs b/bitwarden_license/test/Commercial.Pam.Test/Commands/RequestLeaseExtensionCommandTests.cs similarity index 99% rename from test/Core.Test/Pam/Commands/RequestLeaseExtensionCommandTests.cs rename to bitwarden_license/test/Commercial.Pam.Test/Commands/RequestLeaseExtensionCommandTests.cs index c49b1d24f595..314a368894ef 100644 --- a/test/Core.Test/Pam/Commands/RequestLeaseExtensionCommandTests.cs +++ b/bitwarden_license/test/Commercial.Pam.Test/Commands/RequestLeaseExtensionCommandTests.cs @@ -4,7 +4,7 @@ using Bit.Pam.Enums; using Bit.Pam.Models; using Bit.Pam.Models.Conditions; -using Bit.Pam.OrganizationFeatures.Commands; +using Bit.Commercial.Pam.OrganizationFeatures.Commands; using Bit.Pam.Repositories; using Bit.Pam.Services; using Bit.Test.Common.AutoFixture; @@ -13,7 +13,7 @@ using NSubstitute; using Xunit; -namespace Bit.Core.Test.Pam.Commands; +namespace Bit.Commercial.Pam.Test.Commands; [SutProviderCustomize] public class RequestLeaseExtensionCommandTests diff --git a/test/Core.Test/Pam/Commands/RevokeAccessLeaseCommandTests.cs b/bitwarden_license/test/Commercial.Pam.Test/Commands/RevokeAccessLeaseCommandTests.cs similarity index 98% rename from test/Core.Test/Pam/Commands/RevokeAccessLeaseCommandTests.cs rename to bitwarden_license/test/Commercial.Pam.Test/Commands/RevokeAccessLeaseCommandTests.cs index f23e3d6cb709..220a6f93e089 100644 --- a/test/Core.Test/Pam/Commands/RevokeAccessLeaseCommandTests.cs +++ b/bitwarden_license/test/Commercial.Pam.Test/Commands/RevokeAccessLeaseCommandTests.cs @@ -1,7 +1,7 @@ using Bit.Core.Exceptions; using Bit.Pam.Entities; using Bit.Pam.Enums; -using Bit.Pam.OrganizationFeatures.Commands; +using Bit.Commercial.Pam.OrganizationFeatures.Commands; using Bit.Pam.Repositories; using Bit.Pam.Services; using Bit.Test.Common.AutoFixture; @@ -10,7 +10,7 @@ using NSubstitute; using Xunit; -namespace Bit.Core.Test.Pam.Commands; +namespace Bit.Commercial.Pam.Test.Commands; [SutProviderCustomize] public class RevokeAccessLeaseCommandTests diff --git a/test/Core.Test/Pam/Commands/SubmitAccessRequestCommandTests.cs b/bitwarden_license/test/Commercial.Pam.Test/Commands/SubmitAccessRequestCommandTests.cs similarity index 99% rename from test/Core.Test/Pam/Commands/SubmitAccessRequestCommandTests.cs rename to bitwarden_license/test/Commercial.Pam.Test/Commands/SubmitAccessRequestCommandTests.cs index ae945c5c3580..a48aeaf30d3f 100644 --- a/test/Core.Test/Pam/Commands/SubmitAccessRequestCommandTests.cs +++ b/bitwarden_license/test/Commercial.Pam.Test/Commands/SubmitAccessRequestCommandTests.cs @@ -10,7 +10,7 @@ using Bit.Pam.Enums; using Bit.Pam.Models; using Bit.Pam.Models.Conditions; -using Bit.Pam.OrganizationFeatures.Commands; +using Bit.Commercial.Pam.OrganizationFeatures.Commands; using Bit.Pam.Repositories; using Bit.Pam.Services; using Bit.Test.Common.AutoFixture; @@ -19,7 +19,7 @@ using NSubstitute; using Xunit; -namespace Bit.Core.Test.Pam.Commands; +namespace Bit.Commercial.Pam.Test.Commands; [SutProviderCustomize] public class SubmitAccessRequestCommandTests diff --git a/test/Core.Test/Pam/Commands/UpdateAccessRuleCommandTests.cs b/bitwarden_license/test/Commercial.Pam.Test/Commands/UpdateAccessRuleCommandTests.cs similarity index 99% rename from test/Core.Test/Pam/Commands/UpdateAccessRuleCommandTests.cs rename to bitwarden_license/test/Commercial.Pam.Test/Commands/UpdateAccessRuleCommandTests.cs index ad8d7a8d2693..c5327527b7a2 100644 --- a/test/Core.Test/Pam/Commands/UpdateAccessRuleCommandTests.cs +++ b/bitwarden_license/test/Commercial.Pam.Test/Commands/UpdateAccessRuleCommandTests.cs @@ -3,7 +3,7 @@ using Bit.Core.Repositories; using Bit.Pam.Entities; using Bit.Pam.Models; -using Bit.Pam.OrganizationFeatures.Commands; +using Bit.Commercial.Pam.OrganizationFeatures.Commands; using Bit.Pam.Repositories; using Bit.Pam.Services; using Bit.Test.Common.AutoFixture; @@ -12,7 +12,7 @@ using NSubstitute; using Xunit; -namespace Bit.Core.Test.Pam.Commands; +namespace Bit.Commercial.Pam.Test.Commands; [SutProviderCustomize] public class UpdateAccessRuleCommandTests diff --git a/bitwarden_license/test/Commercial.Pam.Test/Commercial.Pam.Test.csproj b/bitwarden_license/test/Commercial.Pam.Test/Commercial.Pam.Test.csproj new file mode 100644 index 000000000000..c00aaffed34c --- /dev/null +++ b/bitwarden_license/test/Commercial.Pam.Test/Commercial.Pam.Test.csproj @@ -0,0 +1,26 @@ + + + + false + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + diff --git a/test/Core.Test/Pam/Engine/AccessRuleEngineTests.cs b/bitwarden_license/test/Commercial.Pam.Test/Engine/AccessRuleEngineTests.cs similarity index 99% rename from test/Core.Test/Pam/Engine/AccessRuleEngineTests.cs rename to bitwarden_license/test/Commercial.Pam.Test/Engine/AccessRuleEngineTests.cs index c038c40a96c9..752744120289 100644 --- a/test/Core.Test/Pam/Engine/AccessRuleEngineTests.cs +++ b/bitwarden_license/test/Commercial.Pam.Test/Engine/AccessRuleEngineTests.cs @@ -1,10 +1,11 @@ using System.Net; +using Bit.Commercial.Pam.Engine; using Bit.Pam.Engine; using Bit.Pam.Enums; using Bit.Pam.Models.Conditions; using Xunit; -namespace Bit.Core.Test.Pam.Engine; +namespace Bit.Commercial.Pam.Test.Engine; public class AccessRuleEngineTests { diff --git a/test/Core.Test/Pam/Queries/AccessPreCheckQueryTests.cs b/bitwarden_license/test/Commercial.Pam.Test/Queries/AccessPreCheckQueryTests.cs similarity index 97% rename from test/Core.Test/Pam/Queries/AccessPreCheckQueryTests.cs rename to bitwarden_license/test/Commercial.Pam.Test/Queries/AccessPreCheckQueryTests.cs index 49a18089fe8e..1fe24e60b1e0 100644 --- a/test/Core.Test/Pam/Queries/AccessPreCheckQueryTests.cs +++ b/bitwarden_license/test/Commercial.Pam.Test/Queries/AccessPreCheckQueryTests.cs @@ -6,7 +6,7 @@ using Bit.Pam.Enums; using Bit.Pam.Models; using Bit.Pam.Models.Conditions; -using Bit.Pam.OrganizationFeatures.Queries; +using Bit.Commercial.Pam.OrganizationFeatures.Queries; using Bit.Pam.Repositories; using Bit.Pam.Services; using Bit.Test.Common.AutoFixture; @@ -14,7 +14,7 @@ using NSubstitute; using Xunit; -namespace Bit.Core.Test.Pam.Queries; +namespace Bit.Commercial.Pam.Test.Queries; [SutProviderCustomize] public class AccessPreCheckQueryTests diff --git a/test/Core.Test/Pam/Queries/GetCipherAccessStateQueryTests.cs b/bitwarden_license/test/Commercial.Pam.Test/Queries/GetCipherAccessStateQueryTests.cs similarity index 99% rename from test/Core.Test/Pam/Queries/GetCipherAccessStateQueryTests.cs rename to bitwarden_license/test/Commercial.Pam.Test/Queries/GetCipherAccessStateQueryTests.cs index f81721e2982f..eeb59e3fda23 100644 --- a/test/Core.Test/Pam/Queries/GetCipherAccessStateQueryTests.cs +++ b/bitwarden_license/test/Commercial.Pam.Test/Queries/GetCipherAccessStateQueryTests.cs @@ -6,7 +6,7 @@ using Bit.Pam.Enums; using Bit.Pam.Models; using Bit.Pam.Models.Conditions; -using Bit.Pam.OrganizationFeatures.Queries; +using Bit.Commercial.Pam.OrganizationFeatures.Queries; using Bit.Pam.Repositories; using Bit.Pam.Services; using Bit.Test.Common.AutoFixture; @@ -14,7 +14,7 @@ using NSubstitute; using Xunit; -namespace Bit.Core.Test.Pam.Queries; +namespace Bit.Commercial.Pam.Test.Queries; [SutProviderCustomize] public class GetCipherAccessStateQueryTests diff --git a/test/Core.Test/Pam/Queries/GetLeasedCipherQueryTests.cs b/bitwarden_license/test/Commercial.Pam.Test/Queries/GetLeasedCipherQueryTests.cs similarity index 98% rename from test/Core.Test/Pam/Queries/GetLeasedCipherQueryTests.cs rename to bitwarden_license/test/Commercial.Pam.Test/Queries/GetLeasedCipherQueryTests.cs index 232eb19b3d00..3c3415da7e43 100644 --- a/test/Core.Test/Pam/Queries/GetLeasedCipherQueryTests.cs +++ b/bitwarden_license/test/Commercial.Pam.Test/Queries/GetLeasedCipherQueryTests.cs @@ -4,7 +4,7 @@ using Bit.Pam.Entities; using Bit.Pam.Models; using Bit.Pam.Models.Conditions; -using Bit.Pam.OrganizationFeatures.Queries; +using Bit.Commercial.Pam.OrganizationFeatures.Queries; using Bit.Pam.Repositories; using Bit.Pam.Services; using Bit.Test.Common.AutoFixture; @@ -13,7 +13,7 @@ using NSubstitute; using Xunit; -namespace Bit.Core.Test.Pam.Queries; +namespace Bit.Commercial.Pam.Test.Queries; [SutProviderCustomize] public class GetLeasedCipherQueryTests diff --git a/test/Core.Test/Pam/Queries/ListActiveLeasesQueryTests.cs b/bitwarden_license/test/Commercial.Pam.Test/Queries/ListActiveLeasesQueryTests.cs similarity index 95% rename from test/Core.Test/Pam/Queries/ListActiveLeasesQueryTests.cs rename to bitwarden_license/test/Commercial.Pam.Test/Queries/ListActiveLeasesQueryTests.cs index 061f80f2b530..f95f69842050 100644 --- a/test/Core.Test/Pam/Queries/ListActiveLeasesQueryTests.cs +++ b/bitwarden_license/test/Commercial.Pam.Test/Queries/ListActiveLeasesQueryTests.cs @@ -1,5 +1,5 @@ using Bit.Pam.Entities; -using Bit.Pam.OrganizationFeatures.Queries; +using Bit.Commercial.Pam.OrganizationFeatures.Queries; using Bit.Pam.Repositories; using Bit.Pam.Services; using Bit.Test.Common.AutoFixture; @@ -8,7 +8,7 @@ using NSubstitute; using Xunit; -namespace Bit.Core.Test.Pam.Queries; +namespace Bit.Commercial.Pam.Test.Queries; [SutProviderCustomize] public class ListActiveLeasesQueryTests diff --git a/test/Core.Test/Pam/Queries/ListInboxHistoryQueryTests.cs b/bitwarden_license/test/Commercial.Pam.Test/Queries/ListInboxHistoryQueryTests.cs similarity index 95% rename from test/Core.Test/Pam/Queries/ListInboxHistoryQueryTests.cs rename to bitwarden_license/test/Commercial.Pam.Test/Queries/ListInboxHistoryQueryTests.cs index 457785a3f0f0..9426ff1b4f79 100644 --- a/test/Core.Test/Pam/Queries/ListInboxHistoryQueryTests.cs +++ b/bitwarden_license/test/Commercial.Pam.Test/Queries/ListInboxHistoryQueryTests.cs @@ -1,5 +1,5 @@ using Bit.Pam.Models; -using Bit.Pam.OrganizationFeatures.Queries; +using Bit.Commercial.Pam.OrganizationFeatures.Queries; using Bit.Pam.Repositories; using Bit.Pam.Services; using Bit.Test.Common.AutoFixture; @@ -8,7 +8,7 @@ using NSubstitute; using Xunit; -namespace Bit.Core.Test.Pam.Queries; +namespace Bit.Commercial.Pam.Test.Queries; [SutProviderCustomize] public class ListInboxHistoryQueryTests diff --git a/test/Core.Test/Pam/Queries/ListInboxRequestsQueryTests.cs b/bitwarden_license/test/Commercial.Pam.Test/Queries/ListInboxRequestsQueryTests.cs similarity index 94% rename from test/Core.Test/Pam/Queries/ListInboxRequestsQueryTests.cs rename to bitwarden_license/test/Commercial.Pam.Test/Queries/ListInboxRequestsQueryTests.cs index f73e0c122a4b..9e12fef4c09a 100644 --- a/test/Core.Test/Pam/Queries/ListInboxRequestsQueryTests.cs +++ b/bitwarden_license/test/Commercial.Pam.Test/Queries/ListInboxRequestsQueryTests.cs @@ -1,5 +1,5 @@ using Bit.Pam.Models; -using Bit.Pam.OrganizationFeatures.Queries; +using Bit.Commercial.Pam.OrganizationFeatures.Queries; using Bit.Pam.Repositories; using Bit.Pam.Services; using Bit.Test.Common.AutoFixture; @@ -7,7 +7,7 @@ using NSubstitute; using Xunit; -namespace Bit.Core.Test.Pam.Queries; +namespace Bit.Commercial.Pam.Test.Queries; [SutProviderCustomize] public class ListInboxRequestsQueryTests diff --git a/test/Core.Test/Pam/Queries/ListLeaseHistoryQueryTests.cs b/bitwarden_license/test/Commercial.Pam.Test/Queries/ListLeaseHistoryQueryTests.cs similarity index 95% rename from test/Core.Test/Pam/Queries/ListLeaseHistoryQueryTests.cs rename to bitwarden_license/test/Commercial.Pam.Test/Queries/ListLeaseHistoryQueryTests.cs index cd355ef7de7e..477792083202 100644 --- a/test/Core.Test/Pam/Queries/ListLeaseHistoryQueryTests.cs +++ b/bitwarden_license/test/Commercial.Pam.Test/Queries/ListLeaseHistoryQueryTests.cs @@ -1,5 +1,5 @@ using Bit.Pam.Entities; -using Bit.Pam.OrganizationFeatures.Queries; +using Bit.Commercial.Pam.OrganizationFeatures.Queries; using Bit.Pam.Repositories; using Bit.Pam.Services; using Bit.Test.Common.AutoFixture; @@ -8,7 +8,7 @@ using NSubstitute; using Xunit; -namespace Bit.Core.Test.Pam.Queries; +namespace Bit.Commercial.Pam.Test.Queries; [SutProviderCustomize] public class ListLeaseHistoryQueryTests diff --git a/test/Core.Test/Pam/Queries/ListMyAccessRequestsQueryTests.cs b/bitwarden_license/test/Commercial.Pam.Test/Queries/ListMyAccessRequestsQueryTests.cs similarity index 91% rename from test/Core.Test/Pam/Queries/ListMyAccessRequestsQueryTests.cs rename to bitwarden_license/test/Commercial.Pam.Test/Queries/ListMyAccessRequestsQueryTests.cs index 110e8e7ec522..f4c5514d5a2c 100644 --- a/test/Core.Test/Pam/Queries/ListMyAccessRequestsQueryTests.cs +++ b/bitwarden_license/test/Commercial.Pam.Test/Queries/ListMyAccessRequestsQueryTests.cs @@ -1,12 +1,12 @@ using Bit.Pam.Models; -using Bit.Pam.OrganizationFeatures.Queries; +using Bit.Commercial.Pam.OrganizationFeatures.Queries; using Bit.Pam.Repositories; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; using Xunit; -namespace Bit.Core.Test.Pam.Queries; +namespace Bit.Commercial.Pam.Test.Queries; [SutProviderCustomize] public class ListMyAccessRequestsQueryTests diff --git a/test/Core.Test/Pam/Queries/ListMyActiveAccessLeasesQueryTests.cs b/bitwarden_license/test/Commercial.Pam.Test/Queries/ListMyActiveAccessLeasesQueryTests.cs similarity index 92% rename from test/Core.Test/Pam/Queries/ListMyActiveAccessLeasesQueryTests.cs rename to bitwarden_license/test/Commercial.Pam.Test/Queries/ListMyActiveAccessLeasesQueryTests.cs index 27b1564af1cd..2b6e99e7fddc 100644 --- a/test/Core.Test/Pam/Queries/ListMyActiveAccessLeasesQueryTests.cs +++ b/bitwarden_license/test/Commercial.Pam.Test/Queries/ListMyActiveAccessLeasesQueryTests.cs @@ -1,12 +1,12 @@ using Bit.Pam.Entities; -using Bit.Pam.OrganizationFeatures.Queries; +using Bit.Commercial.Pam.OrganizationFeatures.Queries; using Bit.Pam.Repositories; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; using Xunit; -namespace Bit.Core.Test.Pam.Queries; +namespace Bit.Commercial.Pam.Test.Queries; [SutProviderCustomize] public class ListMyActiveAccessLeasesQueryTests diff --git a/test/Core.Test/Pam/Services/AccessRuleValidatorTests.cs b/bitwarden_license/test/Commercial.Pam.Test/Services/AccessRuleValidatorTests.cs similarity index 98% rename from test/Core.Test/Pam/Services/AccessRuleValidatorTests.cs rename to bitwarden_license/test/Commercial.Pam.Test/Services/AccessRuleValidatorTests.cs index 70d7cd263597..ccdff6bbfc36 100644 --- a/test/Core.Test/Pam/Services/AccessRuleValidatorTests.cs +++ b/bitwarden_license/test/Commercial.Pam.Test/Services/AccessRuleValidatorTests.cs @@ -1,7 +1,8 @@ using Bit.Pam.Services; +using Bit.Commercial.Pam.Services; using Xunit; -namespace Bit.Core.Test.Pam.Services; +namespace Bit.Commercial.Pam.Test.Services; public class AccessRuleValidatorTests { diff --git a/test/Core.Test/Pam/Services/ApproverCollectionAccessQueryTests.cs b/bitwarden_license/test/Commercial.Pam.Test/Services/ApproverCollectionAccessQueryTests.cs similarity index 98% rename from test/Core.Test/Pam/Services/ApproverCollectionAccessQueryTests.cs rename to bitwarden_license/test/Commercial.Pam.Test/Services/ApproverCollectionAccessQueryTests.cs index 21b6caa1487b..f5ad5d8ff056 100644 --- a/test/Core.Test/Pam/Services/ApproverCollectionAccessQueryTests.cs +++ b/bitwarden_license/test/Commercial.Pam.Test/Services/ApproverCollectionAccessQueryTests.cs @@ -1,4 +1,5 @@ using Bit.Core.Context; +using Bit.Commercial.Pam.Services; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Models.Data; @@ -11,7 +12,7 @@ using NSubstitute; using Xunit; -namespace Bit.Core.Test.Pam.Services; +namespace Bit.Commercial.Pam.Test.Services; [SutProviderCustomize] public class ApproverCollectionAccessQueryTests diff --git a/test/Core.Test/Pam/Services/ApproverInboxNotifierTests.cs b/bitwarden_license/test/Commercial.Pam.Test/Services/ApproverInboxNotifierTests.cs similarity index 95% rename from test/Core.Test/Pam/Services/ApproverInboxNotifierTests.cs rename to bitwarden_license/test/Commercial.Pam.Test/Services/ApproverInboxNotifierTests.cs index de59c2aa4d59..fc91f3a8de7c 100644 --- a/test/Core.Test/Pam/Services/ApproverInboxNotifierTests.cs +++ b/bitwarden_license/test/Commercial.Pam.Test/Services/ApproverInboxNotifierTests.cs @@ -1,4 +1,5 @@ using Bit.Core.Platform.Push; +using Bit.Commercial.Pam.Services; using Bit.Core.Repositories; using Bit.Pam.Services; using Bit.Test.Common.AutoFixture; @@ -6,7 +7,7 @@ using NSubstitute; using Xunit; -namespace Bit.Core.Test.Pam.Services; +namespace Bit.Commercial.Pam.Test.Services; [SutProviderCustomize] public class ApproverInboxNotifierTests diff --git a/test/Core.Test/Pam/Services/CipherLeaseGateTests.cs b/bitwarden_license/test/Commercial.Pam.Test/Services/CipherLeaseGateTests.cs similarity index 99% rename from test/Core.Test/Pam/Services/CipherLeaseGateTests.cs rename to bitwarden_license/test/Commercial.Pam.Test/Services/CipherLeaseGateTests.cs index ff8a75e3e88c..dd9f298c58f6 100644 --- a/test/Core.Test/Pam/Services/CipherLeaseGateTests.cs +++ b/bitwarden_license/test/Commercial.Pam.Test/Services/CipherLeaseGateTests.cs @@ -1,4 +1,6 @@ using Bit.Core.Entities; +using Bit.Core; +using Bit.Commercial.Pam.Services; using Bit.Core.Exceptions; using Bit.Core.Models.Data; using Bit.Core.Services; @@ -14,7 +16,7 @@ using NSubstitute; using Xunit; -namespace Bit.Core.Test.Pam.Services; +namespace Bit.Commercial.Pam.Test.Services; [SutProviderCustomize] public class CipherLeaseGateTests diff --git a/test/Core.Test/Pam/Services/GoverningRuleResolverTests.cs b/bitwarden_license/test/Commercial.Pam.Test/Services/GoverningRuleResolverTests.cs similarity index 99% rename from test/Core.Test/Pam/Services/GoverningRuleResolverTests.cs rename to bitwarden_license/test/Commercial.Pam.Test/Services/GoverningRuleResolverTests.cs index 32fb9ee71f3a..d48632787032 100644 --- a/test/Core.Test/Pam/Services/GoverningRuleResolverTests.cs +++ b/bitwarden_license/test/Commercial.Pam.Test/Services/GoverningRuleResolverTests.cs @@ -1,4 +1,6 @@ using System.Net; +using Bit.Commercial.Pam.Engine; +using Bit.Commercial.Pam.Services; using Bit.Core.Entities; using Bit.Core.Repositories; using Bit.Pam.Engine; @@ -11,7 +13,7 @@ using NSubstitute; using Xunit; -namespace Bit.Core.Test.Pam.Services; +namespace Bit.Commercial.Pam.Test.Services; [SutProviderCustomize] public class GoverningRuleResolverTests diff --git a/test/Core.Test/Pam/Services/RequesterNotifierTests.cs b/bitwarden_license/test/Commercial.Pam.Test/Services/RequesterNotifierTests.cs similarity index 88% rename from test/Core.Test/Pam/Services/RequesterNotifierTests.cs rename to bitwarden_license/test/Commercial.Pam.Test/Services/RequesterNotifierTests.cs index 37d048d0d953..76abdddd3952 100644 --- a/test/Core.Test/Pam/Services/RequesterNotifierTests.cs +++ b/bitwarden_license/test/Commercial.Pam.Test/Services/RequesterNotifierTests.cs @@ -1,11 +1,12 @@ using Bit.Core.Platform.Push; +using Bit.Commercial.Pam.Services; using Bit.Pam.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; using Xunit; -namespace Bit.Core.Test.Pam.Services; +namespace Bit.Commercial.Pam.Test.Services; [SutProviderCustomize] public class RequesterNotifierTests diff --git a/test/Core.Test/Pam/Services/SingleActiveLeaseEvaluatorTests.cs b/bitwarden_license/test/Commercial.Pam.Test/Services/SingleActiveLeaseEvaluatorTests.cs similarity index 98% rename from test/Core.Test/Pam/Services/SingleActiveLeaseEvaluatorTests.cs rename to bitwarden_license/test/Commercial.Pam.Test/Services/SingleActiveLeaseEvaluatorTests.cs index a6d06a164936..d1069b1a864d 100644 --- a/test/Core.Test/Pam/Services/SingleActiveLeaseEvaluatorTests.cs +++ b/bitwarden_license/test/Commercial.Pam.Test/Services/SingleActiveLeaseEvaluatorTests.cs @@ -1,4 +1,5 @@ using Bit.Core.Entities; +using Bit.Commercial.Pam.Services; using Bit.Core.Repositories; using Bit.Pam.Entities; using Bit.Pam.Repositories; @@ -8,7 +9,7 @@ using NSubstitute; using Xunit; -namespace Bit.Core.Test.Pam.Services; +namespace Bit.Commercial.Pam.Test.Services; [SutProviderCustomize] public class SingleActiveLeaseEvaluatorTests diff --git a/src/Api/Api.csproj b/src/Api/Api.csproj index 6d21cfddab26..793374eccdd8 100644 --- a/src/Api/Api.csproj +++ b/src/Api/Api.csproj @@ -24,7 +24,6 @@ - @@ -32,8 +31,15 @@ + + + + + + + diff --git a/src/Api/Pam/Controllers/CipherLeaseController.cs b/src/Api/Pam/Controllers/CipherLeaseController.cs index 3b6f81721fe4..08304f6f6ccf 100644 --- a/src/Api/Pam/Controllers/CipherLeaseController.cs +++ b/src/Api/Pam/Controllers/CipherLeaseController.cs @@ -1,6 +1,7 @@ using Bit.Api.Pam.Models.Request; using Bit.Api.Pam.Models.Response; using Bit.Api.Vault.Models.Response; +using Bit.Commercial.Pam.OrganizationFeatures.Queries.Interfaces; using Bit.Core; using Bit.Core.Exceptions; using Bit.Core.Repositories; diff --git a/src/Api/Startup.cs b/src/Api/Startup.cs index 7ead64850e2a..815f01308252 100644 --- a/src/Api/Startup.cs +++ b/src/Api/Startup.cs @@ -40,6 +40,7 @@ using Bit.Commercial.Core.SecretsManager; using Bit.Commercial.Core.Utilities; using Bit.Commercial.Infrastructure.EntityFramework.SecretsManager; +using Bit.Commercial.Pam.Utilities; #endif namespace Bit.Api; @@ -204,6 +205,7 @@ public void ConfigureServices(IServiceCollection services) #else services.AddCommercialCoreServices(); services.AddCommercialSecretsManagerServices(); + services.AddCommercialPamServices(); services.AddSecretsManagerEfRepositories(); Jobs.JobsHostedService.AddCommercialSecretsManagerJobServices(services); #endif diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj index 3bd5aacc5552..6fff0ea4065f 100644 --- a/src/Core/Core.csproj +++ b/src/Core/Core.csproj @@ -88,5 +88,6 @@ + diff --git a/src/Core/Pam/Services/NoopCipherLeaseGate.cs b/src/Core/Pam/Services/NoopCipherLeaseGate.cs new file mode 100644 index 000000000000..ae78a87e485d --- /dev/null +++ b/src/Core/Pam/Services/NoopCipherLeaseGate.cs @@ -0,0 +1,40 @@ +using Bit.Core.Entities; +using Bit.Core.Models.Data; +using Bit.Core.Vault.Authorization; +using Bit.Core.Vault.Entities; + +namespace Bit.Pam.Services; + +/// +/// Open-source fallback for . PAM credential leasing is a commercial feature, so in +/// builds without the commercial implementation the gate never gates: every cipher is fully accessible, matching +/// the behaviour when the PAM feature flag is off. The real gating logic lives in the commercial Pam library. +/// +public class NoopCipherLeaseGate : ICipherLeaseGate +{ + public Task AuthorizeReadAsync(Guid userId, Cipher cipher) + => Task.FromResult(FullCipherAccess.Unrestricted()); + + public Task AuthorizeReadManyAsync( + Guid userId, + IEnumerable ciphers, + IEnumerable? collections, + IDictionary>? collectionCiphersByCipher) + => Task.FromResult(FullCipherAccess.Unrestricted()); + + public Task AuthorizeReadManyAsync(Guid userId, IEnumerable ciphers) + => Task.FromResult(FullCipherAccess.Unrestricted()); + + public ISet GetGatedCipherIds( + IEnumerable? collections, + IDictionary>? collectionCiphersByCipher) + => new HashSet(); + + public Task EnsureCanMutateAsync(Guid userId, Cipher cipher) + => Task.FromResult(FullCipherAccess.Unrestricted()); + + public Task EnsureCanMutateManyAsync(Guid userId, IEnumerable ciphers) + => Task.FromResult(FullCipherAccess.Unrestricted()); + + public FullCipherAccess Unrestricted() => FullCipherAccess.Unrestricted(); +} diff --git a/src/Pam/Pam.csproj b/src/Pam/Pam.csproj deleted file mode 100644 index 3f1f81d12c2c..000000000000 --- a/src/Pam/Pam.csproj +++ /dev/null @@ -1,12 +0,0 @@ - - - - Bit.Pam - - - - - - - - diff --git a/src/SharedWeb/SharedWeb.csproj b/src/SharedWeb/SharedWeb.csproj index 294d2d88d662..58164a3d89f6 100644 --- a/src/SharedWeb/SharedWeb.csproj +++ b/src/SharedWeb/SharedWeb.csproj @@ -9,7 +9,6 @@ - diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs index 281bb51b6332..5f7070e94454 100644 --- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs +++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs @@ -60,7 +60,7 @@ using Bit.Core.Vault.Services; using Bit.Infrastructure.Dapper; using Bit.Infrastructure.EntityFramework; -using Bit.Pam; +using Bit.Pam.Services; using Bit.SharedWeb.Play; using DnsClient; using Duende.IdentityModel; @@ -164,7 +164,7 @@ public static void AddBaseServices(this IServiceCollection services, IGlobalSett services.AddUserServices(globalSettings); services.AddTrialInitiationServices(); services.AddOrganizationServices(globalSettings); - services.AddPamServices(); + services.TryAddScoped(); services.AddPolicyServices(); services.AddScoped(); services.AddScoped(); diff --git a/test/Api.Test/Api.Test.csproj b/test/Api.Test/Api.Test.csproj index 7c6b2afe7536..1887e11c05b5 100644 --- a/test/Api.Test/Api.Test.csproj +++ b/test/Api.Test/Api.Test.csproj @@ -25,7 +25,6 @@ - diff --git a/test/Core.Test/Core.Test.csproj b/test/Core.Test/Core.Test.csproj index eb7f75825309..a17943911e21 100644 --- a/test/Core.Test/Core.Test.csproj +++ b/test/Core.Test/Core.Test.csproj @@ -25,7 +25,6 @@ - From 7d6f55326ee72038cb7d27b11b7ddc4c92ba2882 Mon Sep 17 00:00:00 2001 From: Hinton Date: Thu, 18 Jun 2026 20:16:36 +0200 Subject: [PATCH 51/54] PAM: move the proprietary PAM domain into the Commercial.Pam library Keep only the data-layer contract the OSS persistence/Core layer binds to in Pam.Domain (the 4 entities, 3 repository interfaces, the AccessRequestDetails/ AccessRuleDetails/AccessRequestDecision models, and the 6 contract enums), and move everything the OSS build never compiles into Commercial.Pam, re-namespaced to Bit.Commercial.Pam.*: the engine, command/query interfaces, service interfaces, the conditions, the submission/result models, and the AccessApprovalMode/AccessWeekday enums. The three enum wire-format helpers (Access{DeciderKind,LeaseStatus,RequestStatus}Names) are used only by the Api response models, so they move to Api/Pam/Models/Response under Bit.Api.Pam.Models.Response instead. --- .../src/Commercial.Pam}/Engine/AccessEvaluation.cs | 2 +- .../src/Commercial.Pam/Engine/AccessRuleEngine.cs | 3 +-- .../src/Commercial.Pam}/Engine/AccessSignals.cs | 2 +- .../Commercial.Pam}/Engine/IAccessRuleEngine.cs | 4 ++-- .../Commercial.Pam}/Enums/AccessApprovalMode.cs | 2 +- .../src/Commercial.Pam}/Enums/AccessWeekday.cs | 4 ++-- .../Models/AccessDecisionSubmission.cs | 2 +- .../Models/AccessLeaseExtensionSubmission.cs | 2 +- .../Commercial.Pam}/Models/AccessPreCheckResult.cs | 5 ++--- .../Commercial.Pam}/Models/AccessRequestResult.cs | 5 +++-- .../Models/AccessRequestSubmission.cs | 2 +- .../Commercial.Pam}/Models/CipherAccessState.cs | 3 ++- .../Models/Conditions/AccessCondition.cs | 2 +- .../Conditions/AccessWeekdayJsonConverter.cs | 4 ++-- .../Models/Conditions/HumanApprovalCondition.cs | 2 +- .../Models/Conditions/IpAllowlistCondition.cs | 2 +- .../Models/Conditions/TimeOfDayCondition.cs | 5 ++--- .../src/Commercial.Pam}/Models/GoverningRule.cs | 4 ++-- .../Commands/ActivateAccessRequestCommand.cs | 6 +++--- .../Commands/CancelAccessRequestCommand.cs | 6 +++--- .../Commands/CreateAccessRuleCommand.cs | 6 +++--- .../Commands/DecideAccessRequestCommand.cs | 7 ++++--- .../Commands/DeleteAccessRuleCommand.cs | 4 ++-- .../Interfaces/IActivateAccessRequestCommand.cs | 2 +- .../Interfaces/ICancelAccessRequestCommand.cs | 2 +- .../Interfaces/ICreateAccessRuleCommand.cs | 2 +- .../Interfaces/IDecideAccessRequestCommand.cs | 6 +++--- .../Interfaces/IDeleteAccessRuleCommand.cs | 2 +- .../Interfaces/IRequestLeaseExtensionCommand.cs | 6 +++--- .../Interfaces/IRevokeAccessLeaseCommand.cs | 2 +- .../Interfaces/ISubmitAccessRequestCommand.cs | 5 ++--- .../Interfaces/IUpdateAccessRuleCommand.cs | 2 +- .../Commands/RequestLeaseExtensionCommand.cs | 9 +++++---- .../Commands/RevokeAccessLeaseCommand.cs | 6 +++--- .../Commands/SubmitAccessRequestCommand.cs | 10 +++++----- .../Commands/UpdateAccessRuleCommand.cs | 6 +++--- .../Queries/AccessPreCheckQuery.cs | 12 ++++++------ .../Queries/GetCipherAccessStateQuery.cs | 9 +++++---- .../Queries/GetLeasedCipherQuery.cs | 8 ++++---- .../Queries/Interfaces/IAccessPreCheckQuery.cs | 5 ++--- .../Interfaces/IGetCipherAccessStateQuery.cs | 5 ++--- .../Queries/Interfaces/IListActiveLeasesQuery.cs | 2 +- .../Queries/Interfaces/IListInboxHistoryQuery.cs | 2 +- .../Queries/Interfaces/IListInboxRequestsQuery.cs | 2 +- .../Queries/Interfaces/IListLeaseHistoryQuery.cs | 2 +- .../Interfaces/IListMyAccessRequestsQuery.cs | 2 +- .../Interfaces/IListMyActiveAccessLeasesQuery.cs | 2 +- .../Queries/ListActiveLeasesQuery.cs | 6 +++--- .../Queries/ListInboxHistoryQuery.cs | 6 +++--- .../Queries/ListInboxRequestsQuery.cs | 6 +++--- .../Queries/ListLeaseHistoryQuery.cs | 6 +++--- .../Queries/ListMyAccessRequestsQuery.cs | 4 ++-- .../Queries/ListMyActiveAccessLeasesQuery.cs | 4 ++-- .../Commercial.Pam/Services/AccessRuleValidator.cs | 3 +-- .../Services/ApproverCollectionAccessQuery.cs | 1 - .../Services/ApproverInboxNotifier.cs | 1 - .../src/Commercial.Pam/Services/CipherLeaseGate.cs | 6 +++--- .../Services/GoverningRuleResolver.cs | 7 +++---- .../Services/IAccessRuleValidator.cs | 2 +- .../Services/IApproverCollectionAccessQuery.cs | 2 +- .../Services/IApproverInboxNotifier.cs | 2 +- .../Services/IGoverningRuleResolver.cs | 6 +++--- .../Commercial.Pam}/Services/IRequesterNotifier.cs | 2 +- .../Services/ISingleActiveLeaseEvaluator.cs | 2 +- .../Commercial.Pam/Services/RequesterNotifier.cs | 1 - .../Services/SingleActiveLeaseEvaluator.cs | 1 - .../Utilities/ServiceCollectionExtensions.cs | 6 ++---- .../Controllers/AccessRequestsControllerTests.cs | 6 ++++-- .../Api/Controllers/CipherLeaseControllerTests.cs | 5 ++--- .../Api/Controllers/LeasesControllerTests.cs | 6 ++++-- .../Api/Models/AccessLeaseResponseModelTests.cs | 1 - .../Api}/Models/AccessLeaseStatusNamesTests.cs | 6 +++--- .../Api}/Models/AccessRequestStatusNamesTests.cs | 6 +++--- .../Commands/ActivateAccessRequestCommandTests.cs | 6 +++--- .../Commands/CancelAccessRequestCommandTests.cs | 6 +++--- .../Commands/CreateAccessRuleCommandTests.cs | 6 +++--- .../Commands/DecideAccessRequestCommandTests.cs | 8 ++++---- .../Commands/DeleteAccessRuleCommandTests.cs | 4 ++-- .../Commands/RequestLeaseExtensionCommandTests.cs | 12 ++++++------ .../Commands/RevokeAccessLeaseCommandTests.cs | 6 +++--- .../Commands/SubmitAccessRequestCommandTests.cs | 13 +++++++------ .../Commands/UpdateAccessRuleCommandTests.cs | 6 +++--- .../Engine/AccessRuleEngineTests.cs | 5 ++--- .../Conditions/AccessWeekdayJsonConverterTests.cs | 4 ++-- .../Queries/AccessPreCheckQueryTests.cs | 14 +++++++------- .../Queries/GetCipherAccessStateQueryTests.cs | 12 ++++++------ .../Queries/GetLeasedCipherQueryTests.cs | 12 ++++++------ .../Queries/ListActiveLeasesQueryTests.cs | 6 +++--- .../Queries/ListInboxHistoryQueryTests.cs | 6 +++--- .../Queries/ListInboxRequestsQueryTests.cs | 6 +++--- .../Queries/ListLeaseHistoryQueryTests.cs | 6 +++--- .../Queries/ListMyAccessRequestsQueryTests.cs | 4 ++-- .../Queries/ListMyActiveAccessLeasesQueryTests.cs | 4 ++-- .../Services/AccessRuleValidatorTests.cs | 3 +-- .../Services/ApproverCollectionAccessQueryTests.cs | 5 ++--- .../Services/ApproverInboxNotifierTests.cs | 5 ++--- .../Services/CipherLeaseGateTests.cs | 11 +++++------ .../Services/GoverningRuleResolverTests.cs | 4 +--- .../Services/RequesterNotifierTests.cs | 5 ++--- .../Services/SingleActiveLeaseEvaluatorTests.cs | 5 ++--- .../Pam/Controllers/AccessRequestsController.cs | 4 ++-- src/Api/Pam/Controllers/AccessRulesController.cs | 2 +- src/Api/Pam/Controllers/CipherLeaseController.cs | 3 +-- src/Api/Pam/Controllers/LeasesController.cs | 4 ++-- .../Models/Request/AccessDecisionRequestModel.cs | 2 +- .../Request/AccessLeaseExtensionRequestModel.cs | 3 +-- .../Request/AccessRequestCreateRequestModel.cs | 3 +-- .../Pam/Models/Response}/AccessDeciderKindNames.cs | 2 +- .../Models/Response/AccessLeaseResponseModel.cs | 1 - .../Pam/Models/Response}/AccessLeaseStatusNames.cs | 2 +- .../Models/Response/AccessPreCheckResponseModel.cs | 6 +++--- .../Response/AccessRequestDecisionResponseModel.cs | 1 - .../Models/Response/AccessRequestResponseModel.cs | 1 - .../Response/AccessRequestResultResponseModel.cs | 6 +++--- .../Models/Response}/AccessRequestStatusNames.cs | 2 +- .../Response/CipherAccessStateResponseModel.cs | 4 ++-- .../Pam/Models/Response/PamDateTimeExtensions.cs | 2 +- 117 files changed, 255 insertions(+), 276 deletions(-) rename {src/Pam.Domain => bitwarden_license/src/Commercial.Pam}/Engine/AccessEvaluation.cs (97%) rename {src/Pam.Domain => bitwarden_license/src/Commercial.Pam}/Engine/AccessSignals.cs (96%) rename {src/Pam.Domain => bitwarden_license/src/Commercial.Pam}/Engine/IAccessRuleEngine.cs (88%) rename {src/Pam.Domain => bitwarden_license/src/Commercial.Pam}/Enums/AccessApprovalMode.cs (89%) rename {src/Pam.Domain => bitwarden_license/src/Commercial.Pam}/Enums/AccessWeekday.cs (87%) rename {src/Pam.Domain => bitwarden_license/src/Commercial.Pam}/Models/AccessDecisionSubmission.cs (89%) rename {src/Pam.Domain => bitwarden_license/src/Commercial.Pam}/Models/AccessLeaseExtensionSubmission.cs (93%) rename {src/Pam.Domain => bitwarden_license/src/Commercial.Pam}/Models/AccessPreCheckResult.cs (87%) rename {src/Pam.Domain => bitwarden_license/src/Commercial.Pam}/Models/AccessRequestResult.cs (90%) rename {src/Pam.Domain => bitwarden_license/src/Commercial.Pam}/Models/AccessRequestSubmission.cs (93%) rename {src/Pam.Domain => bitwarden_license/src/Commercial.Pam}/Models/CipherAccessState.cs (94%) rename {src/Pam.Domain => bitwarden_license/src/Commercial.Pam}/Models/Conditions/AccessCondition.cs (92%) rename {src/Pam.Domain => bitwarden_license/src/Commercial.Pam}/Models/Conditions/AccessWeekdayJsonConverter.cs (96%) rename {src/Pam.Domain => bitwarden_license/src/Commercial.Pam}/Models/Conditions/HumanApprovalCondition.cs (75%) rename {src/Pam.Domain => bitwarden_license/src/Commercial.Pam}/Models/Conditions/IpAllowlistCondition.cs (82%) rename {src/Pam.Domain => bitwarden_license/src/Commercial.Pam}/Models/Conditions/TimeOfDayCondition.cs (87%) rename {src/Pam.Domain => bitwarden_license/src/Commercial.Pam}/Models/GoverningRule.cs (92%) rename {src/Pam.Domain => bitwarden_license/src/Commercial.Pam}/OrganizationFeatures/Commands/Interfaces/IActivateAccessRequestCommand.cs (94%) rename {src/Pam.Domain => bitwarden_license/src/Commercial.Pam}/OrganizationFeatures/Commands/Interfaces/ICancelAccessRequestCommand.cs (91%) rename {src/Pam.Domain => bitwarden_license/src/Commercial.Pam}/OrganizationFeatures/Commands/Interfaces/ICreateAccessRuleCommand.cs (81%) rename {src/Pam.Domain => bitwarden_license/src/Commercial.Pam}/OrganizationFeatures/Commands/Interfaces/IDecideAccessRequestCommand.cs (90%) rename {src/Pam.Domain => bitwarden_license/src/Commercial.Pam}/OrganizationFeatures/Commands/Interfaces/IDeleteAccessRuleCommand.cs (57%) rename {src/Pam.Domain => bitwarden_license/src/Commercial.Pam}/OrganizationFeatures/Commands/Interfaces/IRequestLeaseExtensionCommand.cs (90%) rename {src/Pam.Domain => bitwarden_license/src/Commercial.Pam}/OrganizationFeatures/Commands/Interfaces/IRevokeAccessLeaseCommand.cs (91%) rename {src/Pam.Domain => bitwarden_license/src/Commercial.Pam}/OrganizationFeatures/Commands/Interfaces/ISubmitAccessRequestCommand.cs (80%) rename {src/Pam.Domain => bitwarden_license/src/Commercial.Pam}/OrganizationFeatures/Commands/Interfaces/IUpdateAccessRuleCommand.cs (83%) rename {src/Pam.Domain => bitwarden_license/src/Commercial.Pam}/OrganizationFeatures/Queries/Interfaces/IAccessPreCheckQuery.cs (75%) rename {src/Pam.Domain => bitwarden_license/src/Commercial.Pam}/OrganizationFeatures/Queries/Interfaces/IGetCipherAccessStateQuery.cs (81%) rename {src/Pam.Domain => bitwarden_license/src/Commercial.Pam}/OrganizationFeatures/Queries/Interfaces/IListActiveLeasesQuery.cs (89%) rename {src/Pam.Domain => bitwarden_license/src/Commercial.Pam}/OrganizationFeatures/Queries/Interfaces/IListInboxHistoryQuery.cs (85%) rename {src/Pam.Domain => bitwarden_license/src/Commercial.Pam}/OrganizationFeatures/Queries/Interfaces/IListInboxRequestsQuery.cs (83%) rename {src/Pam.Domain => bitwarden_license/src/Commercial.Pam}/OrganizationFeatures/Queries/Interfaces/IListLeaseHistoryQuery.cs (89%) rename {src/Pam.Domain => bitwarden_license/src/Commercial.Pam}/OrganizationFeatures/Queries/Interfaces/IListMyAccessRequestsQuery.cs (83%) rename {src/Pam.Domain => bitwarden_license/src/Commercial.Pam}/OrganizationFeatures/Queries/Interfaces/IListMyActiveAccessLeasesQuery.cs (82%) rename {src/Pam.Domain => bitwarden_license/src/Commercial.Pam}/Services/IAccessRuleValidator.cs (93%) rename {src/Pam.Domain => bitwarden_license/src/Commercial.Pam}/Services/IApproverCollectionAccessQuery.cs (95%) rename {src/Pam.Domain => bitwarden_license/src/Commercial.Pam}/Services/IApproverInboxNotifier.cs (90%) rename {src/Pam.Domain => bitwarden_license/src/Commercial.Pam}/Services/IGoverningRuleResolver.cs (88%) rename {src/Pam.Domain => bitwarden_license/src/Commercial.Pam}/Services/IRequesterNotifier.cs (93%) rename {src/Pam.Domain => bitwarden_license/src/Commercial.Pam}/Services/ISingleActiveLeaseEvaluator.cs (94%) rename {test/Core.Test/Pam => bitwarden_license/test/Commercial.Pam.Test/Api}/Models/AccessLeaseStatusNamesTests.cs (81%) rename {test/Core.Test/Pam => bitwarden_license/test/Commercial.Pam.Test/Api}/Models/AccessRequestStatusNamesTests.cs (88%) rename {test/Core.Test/Pam => bitwarden_license/test/Commercial.Pam.Test}/Models/Conditions/AccessWeekdayJsonConverterTests.cs (95%) rename src/{Pam.Domain/Models => Api/Pam/Models/Response}/AccessDeciderKindNames.cs (94%) rename src/{Pam.Domain/Models => Api/Pam/Models/Response}/AccessLeaseStatusNames.cs (95%) rename src/{Pam.Domain/Models => Api/Pam/Models/Response}/AccessRequestStatusNames.cs (96%) diff --git a/src/Pam.Domain/Engine/AccessEvaluation.cs b/bitwarden_license/src/Commercial.Pam/Engine/AccessEvaluation.cs similarity index 97% rename from src/Pam.Domain/Engine/AccessEvaluation.cs rename to bitwarden_license/src/Commercial.Pam/Engine/AccessEvaluation.cs index fefba5b0bcbe..d21c67772673 100644 --- a/src/Pam.Domain/Engine/AccessEvaluation.cs +++ b/bitwarden_license/src/Commercial.Pam/Engine/AccessEvaluation.cs @@ -1,4 +1,4 @@ -namespace Bit.Pam.Engine; +namespace Bit.Commercial.Pam.Engine; public enum AccessEvaluationOutcome { diff --git a/bitwarden_license/src/Commercial.Pam/Engine/AccessRuleEngine.cs b/bitwarden_license/src/Commercial.Pam/Engine/AccessRuleEngine.cs index 107b44f1033b..453c054bbb3b 100644 --- a/bitwarden_license/src/Commercial.Pam/Engine/AccessRuleEngine.cs +++ b/bitwarden_license/src/Commercial.Pam/Engine/AccessRuleEngine.cs @@ -1,7 +1,6 @@ using System.Globalization; -using Bit.Pam.Engine; using System.Net; -using Bit.Pam.Models.Conditions; +using Bit.Commercial.Pam.Models.Conditions; namespace Bit.Commercial.Pam.Engine; diff --git a/src/Pam.Domain/Engine/AccessSignals.cs b/bitwarden_license/src/Commercial.Pam/Engine/AccessSignals.cs similarity index 96% rename from src/Pam.Domain/Engine/AccessSignals.cs rename to bitwarden_license/src/Commercial.Pam/Engine/AccessSignals.cs index 2a616c5296c8..6594abd104c0 100644 --- a/src/Pam.Domain/Engine/AccessSignals.cs +++ b/bitwarden_license/src/Commercial.Pam/Engine/AccessSignals.cs @@ -1,6 +1,6 @@ using System.Net; -namespace Bit.Pam.Engine; +namespace Bit.Commercial.Pam.Engine; /// /// The request-time inputs an access rule is evaluated against: the caller's source IP and the instant the diff --git a/src/Pam.Domain/Engine/IAccessRuleEngine.cs b/bitwarden_license/src/Commercial.Pam/Engine/IAccessRuleEngine.cs similarity index 88% rename from src/Pam.Domain/Engine/IAccessRuleEngine.cs rename to bitwarden_license/src/Commercial.Pam/Engine/IAccessRuleEngine.cs index 8669aac9d3e4..b3cd1c003525 100644 --- a/src/Pam.Domain/Engine/IAccessRuleEngine.cs +++ b/bitwarden_license/src/Commercial.Pam/Engine/IAccessRuleEngine.cs @@ -1,6 +1,6 @@ -using Bit.Pam.Models.Conditions; +using Bit.Commercial.Pam.Models.Conditions; -namespace Bit.Pam.Engine; +namespace Bit.Commercial.Pam.Engine; /// /// Evaluates an access rule's conditions — a flat list of ANDed together — against diff --git a/src/Pam.Domain/Enums/AccessApprovalMode.cs b/bitwarden_license/src/Commercial.Pam/Enums/AccessApprovalMode.cs similarity index 89% rename from src/Pam.Domain/Enums/AccessApprovalMode.cs rename to bitwarden_license/src/Commercial.Pam/Enums/AccessApprovalMode.cs index 80cf754990dd..4ba9a6867356 100644 --- a/src/Pam.Domain/Enums/AccessApprovalMode.cs +++ b/bitwarden_license/src/Commercial.Pam/Enums/AccessApprovalMode.cs @@ -1,4 +1,4 @@ -namespace Bit.Pam.Enums; +namespace Bit.Commercial.Pam.Enums; /// /// The approval path a lease request will take, surfaced by the pre-check so the client can present the right diff --git a/src/Pam.Domain/Enums/AccessWeekday.cs b/bitwarden_license/src/Commercial.Pam/Enums/AccessWeekday.cs similarity index 87% rename from src/Pam.Domain/Enums/AccessWeekday.cs rename to bitwarden_license/src/Commercial.Pam/Enums/AccessWeekday.cs index a53893dbd747..b8693bb06d6c 100644 --- a/src/Pam.Domain/Enums/AccessWeekday.cs +++ b/bitwarden_license/src/Commercial.Pam/Enums/AccessWeekday.cs @@ -1,7 +1,7 @@ using System.Text.Json.Serialization; -using Bit.Pam.Models.Conditions; +using Bit.Commercial.Pam.Models.Conditions; -namespace Bit.Pam.Enums; +namespace Bit.Commercial.Pam.Enums; /// /// A day of the week used in a window. Values align with diff --git a/src/Pam.Domain/Models/AccessDecisionSubmission.cs b/bitwarden_license/src/Commercial.Pam/Models/AccessDecisionSubmission.cs similarity index 89% rename from src/Pam.Domain/Models/AccessDecisionSubmission.cs rename to bitwarden_license/src/Commercial.Pam/Models/AccessDecisionSubmission.cs index 9b4015042622..28f3ce21c814 100644 --- a/src/Pam.Domain/Models/AccessDecisionSubmission.cs +++ b/bitwarden_license/src/Commercial.Pam/Models/AccessDecisionSubmission.cs @@ -1,6 +1,6 @@ using Bit.Pam.Enums; -namespace Bit.Pam.Models; +namespace Bit.Commercial.Pam.Models; /// /// An approver's decision on a pending lease request: approve or deny, with an optional comment. diff --git a/src/Pam.Domain/Models/AccessLeaseExtensionSubmission.cs b/bitwarden_license/src/Commercial.Pam/Models/AccessLeaseExtensionSubmission.cs similarity index 93% rename from src/Pam.Domain/Models/AccessLeaseExtensionSubmission.cs rename to bitwarden_license/src/Commercial.Pam/Models/AccessLeaseExtensionSubmission.cs index 58f8f596328c..bb3b975ed5d4 100644 --- a/src/Pam.Domain/Models/AccessLeaseExtensionSubmission.cs +++ b/bitwarden_license/src/Commercial.Pam/Models/AccessLeaseExtensionSubmission.cs @@ -1,4 +1,4 @@ -namespace Bit.Pam.Models; +namespace Bit.Commercial.Pam.Models; /// /// A request to extend an active lease. Extensions are always auto-approved, subject to the governing rule's diff --git a/src/Pam.Domain/Models/AccessPreCheckResult.cs b/bitwarden_license/src/Commercial.Pam/Models/AccessPreCheckResult.cs similarity index 87% rename from src/Pam.Domain/Models/AccessPreCheckResult.cs rename to bitwarden_license/src/Commercial.Pam/Models/AccessPreCheckResult.cs index 5056ec4d8a5e..1400384739d0 100644 --- a/src/Pam.Domain/Models/AccessPreCheckResult.cs +++ b/bitwarden_license/src/Commercial.Pam/Models/AccessPreCheckResult.cs @@ -1,6 +1,5 @@ -using Bit.Pam.Enums; - -namespace Bit.Pam.Models; +using Bit.Commercial.Pam.Enums; +namespace Bit.Commercial.Pam.Models; /// /// The result of a pre-check. When is true the caller already holds an active lease for diff --git a/src/Pam.Domain/Models/AccessRequestResult.cs b/bitwarden_license/src/Commercial.Pam/Models/AccessRequestResult.cs similarity index 90% rename from src/Pam.Domain/Models/AccessRequestResult.cs rename to bitwarden_license/src/Commercial.Pam/Models/AccessRequestResult.cs index 298db1bc53bf..005602cd4267 100644 --- a/src/Pam.Domain/Models/AccessRequestResult.cs +++ b/bitwarden_license/src/Commercial.Pam/Models/AccessRequestResult.cs @@ -1,7 +1,8 @@ -using Bit.Pam.Entities; +using Bit.Commercial.Pam.Enums; +using Bit.Pam.Entities; using Bit.Pam.Enums; -namespace Bit.Pam.Models; +namespace Bit.Commercial.Pam.Models; /// /// The result of submitting an access request. Neither path mints a lease at submit: the diff --git a/src/Pam.Domain/Models/AccessRequestSubmission.cs b/bitwarden_license/src/Commercial.Pam/Models/AccessRequestSubmission.cs similarity index 93% rename from src/Pam.Domain/Models/AccessRequestSubmission.cs rename to bitwarden_license/src/Commercial.Pam/Models/AccessRequestSubmission.cs index 7ce7be48ef73..cd473ddfddc9 100644 --- a/src/Pam.Domain/Models/AccessRequestSubmission.cs +++ b/bitwarden_license/src/Commercial.Pam/Models/AccessRequestSubmission.cs @@ -1,4 +1,4 @@ -namespace Bit.Pam.Models; +namespace Bit.Commercial.Pam.Models; /// /// A request to lease a cipher. The automatic path supplies (and an optional diff --git a/src/Pam.Domain/Models/CipherAccessState.cs b/bitwarden_license/src/Commercial.Pam/Models/CipherAccessState.cs similarity index 94% rename from src/Pam.Domain/Models/CipherAccessState.cs rename to bitwarden_license/src/Commercial.Pam/Models/CipherAccessState.cs index b8d15a96b7cd..0cfd604abd06 100644 --- a/src/Pam.Domain/Models/CipherAccessState.cs +++ b/bitwarden_license/src/Commercial.Pam/Models/CipherAccessState.cs @@ -1,6 +1,7 @@ using Bit.Pam.Entities; -namespace Bit.Pam.Models; +using Bit.Pam.Models; +namespace Bit.Commercial.Pam.Models; /// /// The caller's access state for a single cipher: the active lease they hold (if any), their pending request (if diff --git a/src/Pam.Domain/Models/Conditions/AccessCondition.cs b/bitwarden_license/src/Commercial.Pam/Models/Conditions/AccessCondition.cs similarity index 92% rename from src/Pam.Domain/Models/Conditions/AccessCondition.cs rename to bitwarden_license/src/Commercial.Pam/Models/Conditions/AccessCondition.cs index e1bb9c6e3d21..cf68f6e0a7aa 100644 --- a/src/Pam.Domain/Models/Conditions/AccessCondition.cs +++ b/bitwarden_license/src/Commercial.Pam/Models/Conditions/AccessCondition.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace Bit.Pam.Models.Conditions; +namespace Bit.Commercial.Pam.Models.Conditions; /// /// Base type for a single leaf condition in an access rule's flat conditions list. Polymorphic deserialization is diff --git a/src/Pam.Domain/Models/Conditions/AccessWeekdayJsonConverter.cs b/bitwarden_license/src/Commercial.Pam/Models/Conditions/AccessWeekdayJsonConverter.cs similarity index 96% rename from src/Pam.Domain/Models/Conditions/AccessWeekdayJsonConverter.cs rename to bitwarden_license/src/Commercial.Pam/Models/Conditions/AccessWeekdayJsonConverter.cs index a45da3345432..cb463cacf51b 100644 --- a/src/Pam.Domain/Models/Conditions/AccessWeekdayJsonConverter.cs +++ b/bitwarden_license/src/Commercial.Pam/Models/Conditions/AccessWeekdayJsonConverter.cs @@ -1,8 +1,8 @@ using System.Text.Json; using System.Text.Json.Serialization; -using Bit.Pam.Enums; +using Bit.Commercial.Pam.Enums; -namespace Bit.Pam.Models.Conditions; +namespace Bit.Commercial.Pam.Models.Conditions; /// /// (De)serializes as the lowercase three-letter tokens the conditions JSON uses diff --git a/src/Pam.Domain/Models/Conditions/HumanApprovalCondition.cs b/bitwarden_license/src/Commercial.Pam/Models/Conditions/HumanApprovalCondition.cs similarity index 75% rename from src/Pam.Domain/Models/Conditions/HumanApprovalCondition.cs rename to bitwarden_license/src/Commercial.Pam/Models/Conditions/HumanApprovalCondition.cs index b823570fc242..73bbf53a15b1 100644 --- a/src/Pam.Domain/Models/Conditions/HumanApprovalCondition.cs +++ b/bitwarden_license/src/Commercial.Pam/Models/Conditions/HumanApprovalCondition.cs @@ -1,4 +1,4 @@ -namespace Bit.Pam.Models.Conditions; +namespace Bit.Commercial.Pam.Models.Conditions; /// /// Always requires a human decision before a lease can be issued. diff --git a/src/Pam.Domain/Models/Conditions/IpAllowlistCondition.cs b/bitwarden_license/src/Commercial.Pam/Models/Conditions/IpAllowlistCondition.cs similarity index 82% rename from src/Pam.Domain/Models/Conditions/IpAllowlistCondition.cs rename to bitwarden_license/src/Commercial.Pam/Models/Conditions/IpAllowlistCondition.cs index fc7eba12fa83..9f095b778311 100644 --- a/src/Pam.Domain/Models/Conditions/IpAllowlistCondition.cs +++ b/bitwarden_license/src/Commercial.Pam/Models/Conditions/IpAllowlistCondition.cs @@ -1,4 +1,4 @@ -namespace Bit.Pam.Models.Conditions; +namespace Bit.Commercial.Pam.Models.Conditions; /// /// Auto-approves a lease when the requester's IP matches a listed CIDR; otherwise denies. diff --git a/src/Pam.Domain/Models/Conditions/TimeOfDayCondition.cs b/bitwarden_license/src/Commercial.Pam/Models/Conditions/TimeOfDayCondition.cs similarity index 87% rename from src/Pam.Domain/Models/Conditions/TimeOfDayCondition.cs rename to bitwarden_license/src/Commercial.Pam/Models/Conditions/TimeOfDayCondition.cs index b9b04c4aba2b..ee1be2a44eed 100644 --- a/src/Pam.Domain/Models/Conditions/TimeOfDayCondition.cs +++ b/bitwarden_license/src/Commercial.Pam/Models/Conditions/TimeOfDayCondition.cs @@ -1,6 +1,5 @@ -using Bit.Pam.Enums; - -namespace Bit.Pam.Models.Conditions; +using Bit.Commercial.Pam.Enums; +namespace Bit.Commercial.Pam.Models.Conditions; /// /// Auto-approves a lease when the request falls inside one of the configured windows, evaluated in diff --git a/src/Pam.Domain/Models/GoverningRule.cs b/bitwarden_license/src/Commercial.Pam/Models/GoverningRule.cs similarity index 92% rename from src/Pam.Domain/Models/GoverningRule.cs rename to bitwarden_license/src/Commercial.Pam/Models/GoverningRule.cs index e5b45ccf0f8c..05caab465854 100644 --- a/src/Pam.Domain/Models/GoverningRule.cs +++ b/bitwarden_license/src/Commercial.Pam/Models/GoverningRule.cs @@ -1,6 +1,6 @@ -using Bit.Pam.Models.Conditions; +using Bit.Commercial.Pam.Models.Conditions; -namespace Bit.Pam.Models; +namespace Bit.Commercial.Pam.Models; /// /// The access rule that governs a cipher for a particular caller: which collection's rule applies, the owning diff --git a/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/ActivateAccessRequestCommand.cs b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/ActivateAccessRequestCommand.cs index 9b578593af9d..2069e61e9445 100644 --- a/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/ActivateAccessRequestCommand.cs +++ b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/ActivateAccessRequestCommand.cs @@ -1,9 +1,9 @@ -using Bit.Core.Exceptions; +using Bit.Commercial.Pam.OrganizationFeatures.Commands.Interfaces; +using Bit.Commercial.Pam.Services; +using Bit.Core.Exceptions; using Bit.Pam.Entities; using Bit.Pam.Enums; -using Bit.Pam.OrganizationFeatures.Commands.Interfaces; using Bit.Pam.Repositories; -using Bit.Pam.Services; namespace Bit.Commercial.Pam.OrganizationFeatures.Commands; diff --git a/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/CancelAccessRequestCommand.cs b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/CancelAccessRequestCommand.cs index f5ec111994b7..a934caf6efb9 100644 --- a/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/CancelAccessRequestCommand.cs +++ b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/CancelAccessRequestCommand.cs @@ -1,9 +1,9 @@ -using Bit.Core.Exceptions; +using Bit.Commercial.Pam.OrganizationFeatures.Commands.Interfaces; +using Bit.Commercial.Pam.Services; +using Bit.Core.Exceptions; using Bit.Pam.Entities; using Bit.Pam.Enums; -using Bit.Pam.OrganizationFeatures.Commands.Interfaces; using Bit.Pam.Repositories; -using Bit.Pam.Services; namespace Bit.Commercial.Pam.OrganizationFeatures.Commands; diff --git a/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/CreateAccessRuleCommand.cs b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/CreateAccessRuleCommand.cs index 6e5f73ec57c2..3ee74a581d0b 100644 --- a/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/CreateAccessRuleCommand.cs +++ b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/CreateAccessRuleCommand.cs @@ -1,11 +1,11 @@ -using Bit.Core.Entities; +using Bit.Commercial.Pam.OrganizationFeatures.Commands.Interfaces; +using Bit.Commercial.Pam.Services; +using Bit.Core.Entities; using Bit.Core.Exceptions; using Bit.Core.Repositories; using Bit.Pam.Entities; using Bit.Pam.Models; -using Bit.Pam.OrganizationFeatures.Commands.Interfaces; using Bit.Pam.Repositories; -using Bit.Pam.Services; namespace Bit.Commercial.Pam.OrganizationFeatures.Commands; diff --git a/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/DecideAccessRequestCommand.cs b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/DecideAccessRequestCommand.cs index 872faa2f4053..7ff819b74189 100644 --- a/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/DecideAccessRequestCommand.cs +++ b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/DecideAccessRequestCommand.cs @@ -1,10 +1,11 @@ -using Bit.Core.Exceptions; +using Bit.Commercial.Pam.Models; +using Bit.Commercial.Pam.OrganizationFeatures.Commands.Interfaces; +using Bit.Commercial.Pam.Services; +using Bit.Core.Exceptions; using Bit.Pam.Entities; using Bit.Pam.Enums; using Bit.Pam.Models; -using Bit.Pam.OrganizationFeatures.Commands.Interfaces; using Bit.Pam.Repositories; -using Bit.Pam.Services; namespace Bit.Commercial.Pam.OrganizationFeatures.Commands; diff --git a/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/DeleteAccessRuleCommand.cs b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/DeleteAccessRuleCommand.cs index 59836df73ee1..afd166ff3b9e 100644 --- a/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/DeleteAccessRuleCommand.cs +++ b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/DeleteAccessRuleCommand.cs @@ -1,5 +1,5 @@ -using Bit.Core.Exceptions; -using Bit.Pam.OrganizationFeatures.Commands.Interfaces; +using Bit.Commercial.Pam.OrganizationFeatures.Commands.Interfaces; +using Bit.Core.Exceptions; using Bit.Pam.Repositories; namespace Bit.Commercial.Pam.OrganizationFeatures.Commands; diff --git a/src/Pam.Domain/OrganizationFeatures/Commands/Interfaces/IActivateAccessRequestCommand.cs b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/Interfaces/IActivateAccessRequestCommand.cs similarity index 94% rename from src/Pam.Domain/OrganizationFeatures/Commands/Interfaces/IActivateAccessRequestCommand.cs rename to bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/Interfaces/IActivateAccessRequestCommand.cs index 48e8d244f3a5..2410fc632931 100644 --- a/src/Pam.Domain/OrganizationFeatures/Commands/Interfaces/IActivateAccessRequestCommand.cs +++ b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/Interfaces/IActivateAccessRequestCommand.cs @@ -1,6 +1,6 @@ using Bit.Pam.Entities; -namespace Bit.Pam.OrganizationFeatures.Commands.Interfaces; +namespace Bit.Commercial.Pam.OrganizationFeatures.Commands.Interfaces; public interface IActivateAccessRequestCommand { diff --git a/src/Pam.Domain/OrganizationFeatures/Commands/Interfaces/ICancelAccessRequestCommand.cs b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/Interfaces/ICancelAccessRequestCommand.cs similarity index 91% rename from src/Pam.Domain/OrganizationFeatures/Commands/Interfaces/ICancelAccessRequestCommand.cs rename to bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/Interfaces/ICancelAccessRequestCommand.cs index 4571399abc4a..086198bd206f 100644 --- a/src/Pam.Domain/OrganizationFeatures/Commands/Interfaces/ICancelAccessRequestCommand.cs +++ b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/Interfaces/ICancelAccessRequestCommand.cs @@ -1,4 +1,4 @@ -namespace Bit.Pam.OrganizationFeatures.Commands.Interfaces; +namespace Bit.Commercial.Pam.OrganizationFeatures.Commands.Interfaces; public interface ICancelAccessRequestCommand { diff --git a/src/Pam.Domain/OrganizationFeatures/Commands/Interfaces/ICreateAccessRuleCommand.cs b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/Interfaces/ICreateAccessRuleCommand.cs similarity index 81% rename from src/Pam.Domain/OrganizationFeatures/Commands/Interfaces/ICreateAccessRuleCommand.cs rename to bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/Interfaces/ICreateAccessRuleCommand.cs index ff2007445208..2ed1a09a8893 100644 --- a/src/Pam.Domain/OrganizationFeatures/Commands/Interfaces/ICreateAccessRuleCommand.cs +++ b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/Interfaces/ICreateAccessRuleCommand.cs @@ -1,7 +1,7 @@ using Bit.Pam.Entities; using Bit.Pam.Models; -namespace Bit.Pam.OrganizationFeatures.Commands.Interfaces; +namespace Bit.Commercial.Pam.OrganizationFeatures.Commands.Interfaces; public interface ICreateAccessRuleCommand { diff --git a/src/Pam.Domain/OrganizationFeatures/Commands/Interfaces/IDecideAccessRequestCommand.cs b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/Interfaces/IDecideAccessRequestCommand.cs similarity index 90% rename from src/Pam.Domain/OrganizationFeatures/Commands/Interfaces/IDecideAccessRequestCommand.cs rename to bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/Interfaces/IDecideAccessRequestCommand.cs index 658777882c95..476820e38c19 100644 --- a/src/Pam.Domain/OrganizationFeatures/Commands/Interfaces/IDecideAccessRequestCommand.cs +++ b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/Interfaces/IDecideAccessRequestCommand.cs @@ -1,6 +1,6 @@ -using Bit.Pam.Models; - -namespace Bit.Pam.OrganizationFeatures.Commands.Interfaces; +using Bit.Commercial.Pam.Models; +using Bit.Pam.Models; +namespace Bit.Commercial.Pam.OrganizationFeatures.Commands.Interfaces; public interface IDecideAccessRequestCommand { diff --git a/src/Pam.Domain/OrganizationFeatures/Commands/Interfaces/IDeleteAccessRuleCommand.cs b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/Interfaces/IDeleteAccessRuleCommand.cs similarity index 57% rename from src/Pam.Domain/OrganizationFeatures/Commands/Interfaces/IDeleteAccessRuleCommand.cs rename to bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/Interfaces/IDeleteAccessRuleCommand.cs index 78fdfbf78c31..f16f77cb32fd 100644 --- a/src/Pam.Domain/OrganizationFeatures/Commands/Interfaces/IDeleteAccessRuleCommand.cs +++ b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/Interfaces/IDeleteAccessRuleCommand.cs @@ -1,4 +1,4 @@ -namespace Bit.Pam.OrganizationFeatures.Commands.Interfaces; +namespace Bit.Commercial.Pam.OrganizationFeatures.Commands.Interfaces; public interface IDeleteAccessRuleCommand { diff --git a/src/Pam.Domain/OrganizationFeatures/Commands/Interfaces/IRequestLeaseExtensionCommand.cs b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/Interfaces/IRequestLeaseExtensionCommand.cs similarity index 90% rename from src/Pam.Domain/OrganizationFeatures/Commands/Interfaces/IRequestLeaseExtensionCommand.cs rename to bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/Interfaces/IRequestLeaseExtensionCommand.cs index 46e0bf3373d2..2f61987bcfeb 100644 --- a/src/Pam.Domain/OrganizationFeatures/Commands/Interfaces/IRequestLeaseExtensionCommand.cs +++ b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/Interfaces/IRequestLeaseExtensionCommand.cs @@ -1,6 +1,6 @@ -using Bit.Pam.Models; - -namespace Bit.Pam.OrganizationFeatures.Commands.Interfaces; +using Bit.Commercial.Pam.Models; +using Bit.Pam.Models; +namespace Bit.Commercial.Pam.OrganizationFeatures.Commands.Interfaces; public interface IRequestLeaseExtensionCommand { diff --git a/src/Pam.Domain/OrganizationFeatures/Commands/Interfaces/IRevokeAccessLeaseCommand.cs b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/Interfaces/IRevokeAccessLeaseCommand.cs similarity index 91% rename from src/Pam.Domain/OrganizationFeatures/Commands/Interfaces/IRevokeAccessLeaseCommand.cs rename to bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/Interfaces/IRevokeAccessLeaseCommand.cs index abefcd0b2623..232d64e7c5da 100644 --- a/src/Pam.Domain/OrganizationFeatures/Commands/Interfaces/IRevokeAccessLeaseCommand.cs +++ b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/Interfaces/IRevokeAccessLeaseCommand.cs @@ -1,4 +1,4 @@ -namespace Bit.Pam.OrganizationFeatures.Commands.Interfaces; +namespace Bit.Commercial.Pam.OrganizationFeatures.Commands.Interfaces; public interface IRevokeAccessLeaseCommand { diff --git a/src/Pam.Domain/OrganizationFeatures/Commands/Interfaces/ISubmitAccessRequestCommand.cs b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/Interfaces/ISubmitAccessRequestCommand.cs similarity index 80% rename from src/Pam.Domain/OrganizationFeatures/Commands/Interfaces/ISubmitAccessRequestCommand.cs rename to bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/Interfaces/ISubmitAccessRequestCommand.cs index 05239cd634f7..24b28f71fbb7 100644 --- a/src/Pam.Domain/OrganizationFeatures/Commands/Interfaces/ISubmitAccessRequestCommand.cs +++ b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/Interfaces/ISubmitAccessRequestCommand.cs @@ -1,6 +1,5 @@ -using Bit.Pam.Models; - -namespace Bit.Pam.OrganizationFeatures.Commands.Interfaces; +using Bit.Commercial.Pam.Models; +namespace Bit.Commercial.Pam.OrganizationFeatures.Commands.Interfaces; public interface ISubmitAccessRequestCommand { diff --git a/src/Pam.Domain/OrganizationFeatures/Commands/Interfaces/IUpdateAccessRuleCommand.cs b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/Interfaces/IUpdateAccessRuleCommand.cs similarity index 83% rename from src/Pam.Domain/OrganizationFeatures/Commands/Interfaces/IUpdateAccessRuleCommand.cs rename to bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/Interfaces/IUpdateAccessRuleCommand.cs index eb62e2352017..93b563d6d3d2 100644 --- a/src/Pam.Domain/OrganizationFeatures/Commands/Interfaces/IUpdateAccessRuleCommand.cs +++ b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/Interfaces/IUpdateAccessRuleCommand.cs @@ -1,7 +1,7 @@ using Bit.Pam.Entities; using Bit.Pam.Models; -namespace Bit.Pam.OrganizationFeatures.Commands.Interfaces; +namespace Bit.Commercial.Pam.OrganizationFeatures.Commands.Interfaces; public interface IUpdateAccessRuleCommand { diff --git a/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/RequestLeaseExtensionCommand.cs b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/RequestLeaseExtensionCommand.cs index 1acc8329a394..20c9c4533a10 100644 --- a/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/RequestLeaseExtensionCommand.cs +++ b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/RequestLeaseExtensionCommand.cs @@ -1,12 +1,13 @@ -using Bit.Core.Context; +using Bit.Commercial.Pam.Engine; +using Bit.Commercial.Pam.Models; +using Bit.Commercial.Pam.OrganizationFeatures.Commands.Interfaces; +using Bit.Commercial.Pam.Services; +using Bit.Core.Context; using Bit.Core.Exceptions; -using Bit.Pam.Engine; using Bit.Pam.Entities; using Bit.Pam.Enums; using Bit.Pam.Models; -using Bit.Pam.OrganizationFeatures.Commands.Interfaces; using Bit.Pam.Repositories; -using Bit.Pam.Services; namespace Bit.Commercial.Pam.OrganizationFeatures.Commands; diff --git a/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/RevokeAccessLeaseCommand.cs b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/RevokeAccessLeaseCommand.cs index df1be5df674b..cf0a2b8aa514 100644 --- a/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/RevokeAccessLeaseCommand.cs +++ b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/RevokeAccessLeaseCommand.cs @@ -1,9 +1,9 @@ -using Bit.Core.Exceptions; +using Bit.Commercial.Pam.OrganizationFeatures.Commands.Interfaces; +using Bit.Commercial.Pam.Services; +using Bit.Core.Exceptions; using Bit.Pam.Entities; using Bit.Pam.Enums; -using Bit.Pam.OrganizationFeatures.Commands.Interfaces; using Bit.Pam.Repositories; -using Bit.Pam.Services; namespace Bit.Commercial.Pam.OrganizationFeatures.Commands; diff --git a/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/SubmitAccessRequestCommand.cs b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/SubmitAccessRequestCommand.cs index 47c9b80610e3..bf4a6bca63b1 100644 --- a/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/SubmitAccessRequestCommand.cs +++ b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/SubmitAccessRequestCommand.cs @@ -1,15 +1,15 @@ -using Bit.Core.Context; +using Bit.Commercial.Pam.Engine; +using Bit.Commercial.Pam.Models; +using Bit.Commercial.Pam.OrganizationFeatures.Commands.Interfaces; +using Bit.Commercial.Pam.Services; +using Bit.Core.Context; using Bit.Core.Exceptions; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Vault.Repositories; -using Bit.Pam.Engine; using Bit.Pam.Entities; using Bit.Pam.Enums; -using Bit.Pam.Models; -using Bit.Pam.OrganizationFeatures.Commands.Interfaces; using Bit.Pam.Repositories; -using Bit.Pam.Services; using Microsoft.Extensions.Logging; namespace Bit.Commercial.Pam.OrganizationFeatures.Commands; diff --git a/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/UpdateAccessRuleCommand.cs b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/UpdateAccessRuleCommand.cs index 1c5bc1d79f5c..ecc32e72430f 100644 --- a/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/UpdateAccessRuleCommand.cs +++ b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Commands/UpdateAccessRuleCommand.cs @@ -1,10 +1,10 @@ -using Bit.Core.Exceptions; +using Bit.Commercial.Pam.OrganizationFeatures.Commands.Interfaces; +using Bit.Commercial.Pam.Services; +using Bit.Core.Exceptions; using Bit.Core.Repositories; using Bit.Pam.Entities; using Bit.Pam.Models; -using Bit.Pam.OrganizationFeatures.Commands.Interfaces; using Bit.Pam.Repositories; -using Bit.Pam.Services; namespace Bit.Commercial.Pam.OrganizationFeatures.Commands; diff --git a/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/AccessPreCheckQuery.cs b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/AccessPreCheckQuery.cs index c611f160e56a..2f6acd757b5f 100644 --- a/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/AccessPreCheckQuery.cs +++ b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/AccessPreCheckQuery.cs @@ -1,12 +1,12 @@ -using Bit.Core.Context; +using Bit.Commercial.Pam.Engine; +using Bit.Commercial.Pam.Enums; +using Bit.Commercial.Pam.Models; +using Bit.Commercial.Pam.OrganizationFeatures.Queries.Interfaces; +using Bit.Commercial.Pam.Services; +using Bit.Core.Context; using Bit.Core.Exceptions; using Bit.Core.Vault.Repositories; -using Bit.Pam.Engine; -using Bit.Pam.Enums; -using Bit.Pam.Models; -using Bit.Pam.OrganizationFeatures.Queries.Interfaces; using Bit.Pam.Repositories; -using Bit.Pam.Services; namespace Bit.Commercial.Pam.OrganizationFeatures.Queries; diff --git a/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/GetCipherAccessStateQuery.cs b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/GetCipherAccessStateQuery.cs index 11056016d75a..afaf6d4e48c7 100644 --- a/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/GetCipherAccessStateQuery.cs +++ b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/GetCipherAccessStateQuery.cs @@ -1,12 +1,13 @@ -using Bit.Core.Context; +using Bit.Commercial.Pam.Engine; +using Bit.Commercial.Pam.Models; +using Bit.Commercial.Pam.OrganizationFeatures.Queries.Interfaces; +using Bit.Commercial.Pam.Services; +using Bit.Core.Context; using Bit.Core.Exceptions; using Bit.Core.Vault.Repositories; -using Bit.Pam.Engine; using Bit.Pam.Entities; using Bit.Pam.Models; -using Bit.Pam.OrganizationFeatures.Queries.Interfaces; using Bit.Pam.Repositories; -using Bit.Pam.Services; namespace Bit.Commercial.Pam.OrganizationFeatures.Queries; diff --git a/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/GetLeasedCipherQuery.cs b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/GetLeasedCipherQuery.cs index 6b9a4082e7ef..16110361f770 100644 --- a/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/GetLeasedCipherQuery.cs +++ b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/GetLeasedCipherQuery.cs @@ -1,10 +1,10 @@ -using Bit.Core.Context; +using Bit.Commercial.Pam.Engine; +using Bit.Commercial.Pam.OrganizationFeatures.Queries.Interfaces; +using Bit.Commercial.Pam.Services; +using Bit.Core.Context; using Bit.Core.Vault.Models.Data; using Bit.Core.Vault.Repositories; -using Bit.Pam.Engine; -using Bit.Commercial.Pam.OrganizationFeatures.Queries.Interfaces; using Bit.Pam.Repositories; -using Bit.Pam.Services; namespace Bit.Commercial.Pam.OrganizationFeatures.Queries; diff --git a/src/Pam.Domain/OrganizationFeatures/Queries/Interfaces/IAccessPreCheckQuery.cs b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/Interfaces/IAccessPreCheckQuery.cs similarity index 75% rename from src/Pam.Domain/OrganizationFeatures/Queries/Interfaces/IAccessPreCheckQuery.cs rename to bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/Interfaces/IAccessPreCheckQuery.cs index 6344413701e0..b4fb773493ec 100644 --- a/src/Pam.Domain/OrganizationFeatures/Queries/Interfaces/IAccessPreCheckQuery.cs +++ b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/Interfaces/IAccessPreCheckQuery.cs @@ -1,6 +1,5 @@ -using Bit.Pam.Models; - -namespace Bit.Pam.OrganizationFeatures.Queries.Interfaces; +using Bit.Commercial.Pam.Models; +namespace Bit.Commercial.Pam.OrganizationFeatures.Queries.Interfaces; public interface IAccessPreCheckQuery { diff --git a/src/Pam.Domain/OrganizationFeatures/Queries/Interfaces/IGetCipherAccessStateQuery.cs b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/Interfaces/IGetCipherAccessStateQuery.cs similarity index 81% rename from src/Pam.Domain/OrganizationFeatures/Queries/Interfaces/IGetCipherAccessStateQuery.cs rename to bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/Interfaces/IGetCipherAccessStateQuery.cs index d37b807b2eb6..566d23aa6763 100644 --- a/src/Pam.Domain/OrganizationFeatures/Queries/Interfaces/IGetCipherAccessStateQuery.cs +++ b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/Interfaces/IGetCipherAccessStateQuery.cs @@ -1,6 +1,5 @@ -using Bit.Pam.Models; - -namespace Bit.Pam.OrganizationFeatures.Queries.Interfaces; +using Bit.Commercial.Pam.Models; +namespace Bit.Commercial.Pam.OrganizationFeatures.Queries.Interfaces; public interface IGetCipherAccessStateQuery { diff --git a/src/Pam.Domain/OrganizationFeatures/Queries/Interfaces/IListActiveLeasesQuery.cs b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/Interfaces/IListActiveLeasesQuery.cs similarity index 89% rename from src/Pam.Domain/OrganizationFeatures/Queries/Interfaces/IListActiveLeasesQuery.cs rename to bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/Interfaces/IListActiveLeasesQuery.cs index 1706d4f9b03d..8ff16ec418d6 100644 --- a/src/Pam.Domain/OrganizationFeatures/Queries/Interfaces/IListActiveLeasesQuery.cs +++ b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/Interfaces/IListActiveLeasesQuery.cs @@ -1,6 +1,6 @@ using Bit.Pam.Entities; -namespace Bit.Pam.OrganizationFeatures.Queries.Interfaces; +namespace Bit.Commercial.Pam.OrganizationFeatures.Queries.Interfaces; public interface IListActiveLeasesQuery { diff --git a/src/Pam.Domain/OrganizationFeatures/Queries/Interfaces/IListInboxHistoryQuery.cs b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/Interfaces/IListInboxHistoryQuery.cs similarity index 85% rename from src/Pam.Domain/OrganizationFeatures/Queries/Interfaces/IListInboxHistoryQuery.cs rename to bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/Interfaces/IListInboxHistoryQuery.cs index 2a376d9323fb..6e1664767a7c 100644 --- a/src/Pam.Domain/OrganizationFeatures/Queries/Interfaces/IListInboxHistoryQuery.cs +++ b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/Interfaces/IListInboxHistoryQuery.cs @@ -1,6 +1,6 @@ using Bit.Pam.Models; -namespace Bit.Pam.OrganizationFeatures.Queries.Interfaces; +namespace Bit.Commercial.Pam.OrganizationFeatures.Queries.Interfaces; public interface IListInboxHistoryQuery { diff --git a/src/Pam.Domain/OrganizationFeatures/Queries/Interfaces/IListInboxRequestsQuery.cs b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/Interfaces/IListInboxRequestsQuery.cs similarity index 83% rename from src/Pam.Domain/OrganizationFeatures/Queries/Interfaces/IListInboxRequestsQuery.cs rename to bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/Interfaces/IListInboxRequestsQuery.cs index cd00e4f6089a..01221b30c72c 100644 --- a/src/Pam.Domain/OrganizationFeatures/Queries/Interfaces/IListInboxRequestsQuery.cs +++ b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/Interfaces/IListInboxRequestsQuery.cs @@ -1,6 +1,6 @@ using Bit.Pam.Models; -namespace Bit.Pam.OrganizationFeatures.Queries.Interfaces; +namespace Bit.Commercial.Pam.OrganizationFeatures.Queries.Interfaces; public interface IListInboxRequestsQuery { diff --git a/src/Pam.Domain/OrganizationFeatures/Queries/Interfaces/IListLeaseHistoryQuery.cs b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/Interfaces/IListLeaseHistoryQuery.cs similarity index 89% rename from src/Pam.Domain/OrganizationFeatures/Queries/Interfaces/IListLeaseHistoryQuery.cs rename to bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/Interfaces/IListLeaseHistoryQuery.cs index 371074491ad6..e6af8614d296 100644 --- a/src/Pam.Domain/OrganizationFeatures/Queries/Interfaces/IListLeaseHistoryQuery.cs +++ b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/Interfaces/IListLeaseHistoryQuery.cs @@ -1,6 +1,6 @@ using Bit.Pam.Entities; -namespace Bit.Pam.OrganizationFeatures.Queries.Interfaces; +namespace Bit.Commercial.Pam.OrganizationFeatures.Queries.Interfaces; public interface IListLeaseHistoryQuery { diff --git a/src/Pam.Domain/OrganizationFeatures/Queries/Interfaces/IListMyAccessRequestsQuery.cs b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/Interfaces/IListMyAccessRequestsQuery.cs similarity index 83% rename from src/Pam.Domain/OrganizationFeatures/Queries/Interfaces/IListMyAccessRequestsQuery.cs rename to bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/Interfaces/IListMyAccessRequestsQuery.cs index 7cfa19793971..7c45c55c6cdb 100644 --- a/src/Pam.Domain/OrganizationFeatures/Queries/Interfaces/IListMyAccessRequestsQuery.cs +++ b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/Interfaces/IListMyAccessRequestsQuery.cs @@ -1,6 +1,6 @@ using Bit.Pam.Models; -namespace Bit.Pam.OrganizationFeatures.Queries.Interfaces; +namespace Bit.Commercial.Pam.OrganizationFeatures.Queries.Interfaces; public interface IListMyAccessRequestsQuery { diff --git a/src/Pam.Domain/OrganizationFeatures/Queries/Interfaces/IListMyActiveAccessLeasesQuery.cs b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/Interfaces/IListMyActiveAccessLeasesQuery.cs similarity index 82% rename from src/Pam.Domain/OrganizationFeatures/Queries/Interfaces/IListMyActiveAccessLeasesQuery.cs rename to bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/Interfaces/IListMyActiveAccessLeasesQuery.cs index 791936838820..2fe1fcc95a7f 100644 --- a/src/Pam.Domain/OrganizationFeatures/Queries/Interfaces/IListMyActiveAccessLeasesQuery.cs +++ b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/Interfaces/IListMyActiveAccessLeasesQuery.cs @@ -1,6 +1,6 @@ using Bit.Pam.Entities; -namespace Bit.Pam.OrganizationFeatures.Queries.Interfaces; +namespace Bit.Commercial.Pam.OrganizationFeatures.Queries.Interfaces; public interface IListMyActiveAccessLeasesQuery { diff --git a/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/ListActiveLeasesQuery.cs b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/ListActiveLeasesQuery.cs index a5dd82666b9d..093fa2d6842d 100644 --- a/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/ListActiveLeasesQuery.cs +++ b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/ListActiveLeasesQuery.cs @@ -1,7 +1,7 @@ -using Bit.Pam.Entities; -using Bit.Pam.OrganizationFeatures.Queries.Interfaces; +using Bit.Commercial.Pam.OrganizationFeatures.Queries.Interfaces; +using Bit.Commercial.Pam.Services; +using Bit.Pam.Entities; using Bit.Pam.Repositories; -using Bit.Pam.Services; namespace Bit.Commercial.Pam.OrganizationFeatures.Queries; diff --git a/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/ListInboxHistoryQuery.cs b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/ListInboxHistoryQuery.cs index 1246fda1e1e4..ffcd904606ad 100644 --- a/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/ListInboxHistoryQuery.cs +++ b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/ListInboxHistoryQuery.cs @@ -1,7 +1,7 @@ -using Bit.Pam.Models; -using Bit.Pam.OrganizationFeatures.Queries.Interfaces; +using Bit.Commercial.Pam.OrganizationFeatures.Queries.Interfaces; +using Bit.Commercial.Pam.Services; +using Bit.Pam.Models; using Bit.Pam.Repositories; -using Bit.Pam.Services; namespace Bit.Commercial.Pam.OrganizationFeatures.Queries; diff --git a/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/ListInboxRequestsQuery.cs b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/ListInboxRequestsQuery.cs index 90a1045e9b3e..b5a6ecb69769 100644 --- a/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/ListInboxRequestsQuery.cs +++ b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/ListInboxRequestsQuery.cs @@ -1,7 +1,7 @@ -using Bit.Pam.Models; -using Bit.Pam.OrganizationFeatures.Queries.Interfaces; +using Bit.Commercial.Pam.OrganizationFeatures.Queries.Interfaces; +using Bit.Commercial.Pam.Services; +using Bit.Pam.Models; using Bit.Pam.Repositories; -using Bit.Pam.Services; namespace Bit.Commercial.Pam.OrganizationFeatures.Queries; diff --git a/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/ListLeaseHistoryQuery.cs b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/ListLeaseHistoryQuery.cs index e730c1607edf..2244c697aef6 100644 --- a/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/ListLeaseHistoryQuery.cs +++ b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/ListLeaseHistoryQuery.cs @@ -1,7 +1,7 @@ -using Bit.Pam.Entities; -using Bit.Pam.OrganizationFeatures.Queries.Interfaces; +using Bit.Commercial.Pam.OrganizationFeatures.Queries.Interfaces; +using Bit.Commercial.Pam.Services; +using Bit.Pam.Entities; using Bit.Pam.Repositories; -using Bit.Pam.Services; namespace Bit.Commercial.Pam.OrganizationFeatures.Queries; diff --git a/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/ListMyAccessRequestsQuery.cs b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/ListMyAccessRequestsQuery.cs index 5983f9c38fed..dcb0ace0bbe1 100644 --- a/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/ListMyAccessRequestsQuery.cs +++ b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/ListMyAccessRequestsQuery.cs @@ -1,5 +1,5 @@ -using Bit.Pam.Models; -using Bit.Pam.OrganizationFeatures.Queries.Interfaces; +using Bit.Commercial.Pam.OrganizationFeatures.Queries.Interfaces; +using Bit.Pam.Models; using Bit.Pam.Repositories; namespace Bit.Commercial.Pam.OrganizationFeatures.Queries; diff --git a/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/ListMyActiveAccessLeasesQuery.cs b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/ListMyActiveAccessLeasesQuery.cs index 36207d9b1ede..6a7bca15fcbb 100644 --- a/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/ListMyActiveAccessLeasesQuery.cs +++ b/bitwarden_license/src/Commercial.Pam/OrganizationFeatures/Queries/ListMyActiveAccessLeasesQuery.cs @@ -1,5 +1,5 @@ -using Bit.Pam.Entities; -using Bit.Pam.OrganizationFeatures.Queries.Interfaces; +using Bit.Commercial.Pam.OrganizationFeatures.Queries.Interfaces; +using Bit.Pam.Entities; using Bit.Pam.Repositories; namespace Bit.Commercial.Pam.OrganizationFeatures.Queries; diff --git a/bitwarden_license/src/Commercial.Pam/Services/AccessRuleValidator.cs b/bitwarden_license/src/Commercial.Pam/Services/AccessRuleValidator.cs index e01e3e5be259..9ebc5c33b2aa 100644 --- a/bitwarden_license/src/Commercial.Pam/Services/AccessRuleValidator.cs +++ b/bitwarden_license/src/Commercial.Pam/Services/AccessRuleValidator.cs @@ -1,8 +1,7 @@ using System.Net; -using Bit.Pam.Services; using System.Text.Json; using System.Text.RegularExpressions; -using Bit.Pam.Models.Conditions; +using Bit.Commercial.Pam.Models.Conditions; namespace Bit.Commercial.Pam.Services; diff --git a/bitwarden_license/src/Commercial.Pam/Services/ApproverCollectionAccessQuery.cs b/bitwarden_license/src/Commercial.Pam/Services/ApproverCollectionAccessQuery.cs index fd0b6d0b3af1..80a49f40912b 100644 --- a/bitwarden_license/src/Commercial.Pam/Services/ApproverCollectionAccessQuery.cs +++ b/bitwarden_license/src/Commercial.Pam/Services/ApproverCollectionAccessQuery.cs @@ -1,5 +1,4 @@ using Bit.Core.Context; -using Bit.Pam.Services; using Bit.Core.Enums; using Bit.Core.Repositories; using Bit.Core.Services; diff --git a/bitwarden_license/src/Commercial.Pam/Services/ApproverInboxNotifier.cs b/bitwarden_license/src/Commercial.Pam/Services/ApproverInboxNotifier.cs index 44d57b83c39c..f6f0cb3134ab 100644 --- a/bitwarden_license/src/Commercial.Pam/Services/ApproverInboxNotifier.cs +++ b/bitwarden_license/src/Commercial.Pam/Services/ApproverInboxNotifier.cs @@ -1,5 +1,4 @@ using Bit.Core.Platform.Push; -using Bit.Pam.Services; using Bit.Core.Repositories; namespace Bit.Commercial.Pam.Services; diff --git a/bitwarden_license/src/Commercial.Pam/Services/CipherLeaseGate.cs b/bitwarden_license/src/Commercial.Pam/Services/CipherLeaseGate.cs index f73fa804c1e5..a2e83bda99a4 100644 --- a/bitwarden_license/src/Commercial.Pam/Services/CipherLeaseGate.cs +++ b/bitwarden_license/src/Commercial.Pam/Services/CipherLeaseGate.cs @@ -1,5 +1,5 @@ -using Bit.Core; -using Bit.Pam.Services; +using Bit.Commercial.Pam.Engine; +using Bit.Core; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Exceptions; @@ -8,8 +8,8 @@ using Bit.Core.Services; using Bit.Core.Vault.Authorization; using Bit.Core.Vault.Entities; -using Bit.Pam.Engine; using Bit.Pam.Repositories; +using Bit.Pam.Services; namespace Bit.Commercial.Pam.Services; diff --git a/bitwarden_license/src/Commercial.Pam/Services/GoverningRuleResolver.cs b/bitwarden_license/src/Commercial.Pam/Services/GoverningRuleResolver.cs index 9fe20aa8344c..8eccc33ddca6 100644 --- a/bitwarden_license/src/Commercial.Pam/Services/GoverningRuleResolver.cs +++ b/bitwarden_license/src/Commercial.Pam/Services/GoverningRuleResolver.cs @@ -1,9 +1,8 @@ using System.Text.Json; -using Bit.Pam.Services; +using Bit.Commercial.Pam.Engine; +using Bit.Commercial.Pam.Models; +using Bit.Commercial.Pam.Models.Conditions; using Bit.Core.Repositories; -using Bit.Pam.Engine; -using Bit.Pam.Models; -using Bit.Pam.Models.Conditions; using Bit.Pam.Repositories; namespace Bit.Commercial.Pam.Services; diff --git a/src/Pam.Domain/Services/IAccessRuleValidator.cs b/bitwarden_license/src/Commercial.Pam/Services/IAccessRuleValidator.cs similarity index 93% rename from src/Pam.Domain/Services/IAccessRuleValidator.cs rename to bitwarden_license/src/Commercial.Pam/Services/IAccessRuleValidator.cs index 7166ac75bcdf..12afdd5e13f7 100644 --- a/src/Pam.Domain/Services/IAccessRuleValidator.cs +++ b/bitwarden_license/src/Commercial.Pam/Services/IAccessRuleValidator.cs @@ -1,4 +1,4 @@ -namespace Bit.Pam.Services; +namespace Bit.Commercial.Pam.Services; public interface IAccessRuleValidator { diff --git a/src/Pam.Domain/Services/IApproverCollectionAccessQuery.cs b/bitwarden_license/src/Commercial.Pam/Services/IApproverCollectionAccessQuery.cs similarity index 95% rename from src/Pam.Domain/Services/IApproverCollectionAccessQuery.cs rename to bitwarden_license/src/Commercial.Pam/Services/IApproverCollectionAccessQuery.cs index d6c2b35ef93d..202e179c0462 100644 --- a/src/Pam.Domain/Services/IApproverCollectionAccessQuery.cs +++ b/bitwarden_license/src/Commercial.Pam/Services/IApproverCollectionAccessQuery.cs @@ -1,4 +1,4 @@ -namespace Bit.Pam.Services; +namespace Bit.Commercial.Pam.Services; /// /// Resolves which collections the current user can Manage — the single authorization predicate for the approver diff --git a/src/Pam.Domain/Services/IApproverInboxNotifier.cs b/bitwarden_license/src/Commercial.Pam/Services/IApproverInboxNotifier.cs similarity index 90% rename from src/Pam.Domain/Services/IApproverInboxNotifier.cs rename to bitwarden_license/src/Commercial.Pam/Services/IApproverInboxNotifier.cs index ea5e57004eda..d52ce58b8e3e 100644 --- a/src/Pam.Domain/Services/IApproverInboxNotifier.cs +++ b/bitwarden_license/src/Commercial.Pam/Services/IApproverInboxNotifier.cs @@ -1,4 +1,4 @@ -namespace Bit.Pam.Services; +namespace Bit.Commercial.Pam.Services; /// /// Pushes the RefreshApproverInbox signal to every user who can Manage a collection, telling their clients to diff --git a/src/Pam.Domain/Services/IGoverningRuleResolver.cs b/bitwarden_license/src/Commercial.Pam/Services/IGoverningRuleResolver.cs similarity index 88% rename from src/Pam.Domain/Services/IGoverningRuleResolver.cs rename to bitwarden_license/src/Commercial.Pam/Services/IGoverningRuleResolver.cs index 1d230b7c45a5..9e31d9d98b8f 100644 --- a/src/Pam.Domain/Services/IGoverningRuleResolver.cs +++ b/bitwarden_license/src/Commercial.Pam/Services/IGoverningRuleResolver.cs @@ -1,7 +1,7 @@ -using Bit.Pam.Engine; -using Bit.Pam.Models; +using Bit.Commercial.Pam.Engine; +using Bit.Commercial.Pam.Models; -namespace Bit.Pam.Services; +namespace Bit.Commercial.Pam.Services; public interface IGoverningRuleResolver { diff --git a/src/Pam.Domain/Services/IRequesterNotifier.cs b/bitwarden_license/src/Commercial.Pam/Services/IRequesterNotifier.cs similarity index 93% rename from src/Pam.Domain/Services/IRequesterNotifier.cs rename to bitwarden_license/src/Commercial.Pam/Services/IRequesterNotifier.cs index c4d8a84f9ef9..d0dbf5082990 100644 --- a/src/Pam.Domain/Services/IRequesterNotifier.cs +++ b/bitwarden_license/src/Commercial.Pam/Services/IRequesterNotifier.cs @@ -1,4 +1,4 @@ -namespace Bit.Pam.Services; +namespace Bit.Commercial.Pam.Services; /// /// Pushes the RefreshAccessRequest signal to a single requester, telling their clients to re-fetch their own diff --git a/src/Pam.Domain/Services/ISingleActiveLeaseEvaluator.cs b/bitwarden_license/src/Commercial.Pam/Services/ISingleActiveLeaseEvaluator.cs similarity index 94% rename from src/Pam.Domain/Services/ISingleActiveLeaseEvaluator.cs rename to bitwarden_license/src/Commercial.Pam/Services/ISingleActiveLeaseEvaluator.cs index 7a29201b1b34..d28b4c515a2c 100644 --- a/src/Pam.Domain/Services/ISingleActiveLeaseEvaluator.cs +++ b/bitwarden_license/src/Commercial.Pam/Services/ISingleActiveLeaseEvaluator.cs @@ -1,4 +1,4 @@ -namespace Bit.Pam.Services; +namespace Bit.Commercial.Pam.Services; public interface ISingleActiveLeaseEvaluator { diff --git a/bitwarden_license/src/Commercial.Pam/Services/RequesterNotifier.cs b/bitwarden_license/src/Commercial.Pam/Services/RequesterNotifier.cs index e3bdbdcdedc0..f4e439260fb0 100644 --- a/bitwarden_license/src/Commercial.Pam/Services/RequesterNotifier.cs +++ b/bitwarden_license/src/Commercial.Pam/Services/RequesterNotifier.cs @@ -1,5 +1,4 @@ using Bit.Core.Platform.Push; -using Bit.Pam.Services; namespace Bit.Commercial.Pam.Services; diff --git a/bitwarden_license/src/Commercial.Pam/Services/SingleActiveLeaseEvaluator.cs b/bitwarden_license/src/Commercial.Pam/Services/SingleActiveLeaseEvaluator.cs index c59d49d1702f..87653bf65daa 100644 --- a/bitwarden_license/src/Commercial.Pam/Services/SingleActiveLeaseEvaluator.cs +++ b/bitwarden_license/src/Commercial.Pam/Services/SingleActiveLeaseEvaluator.cs @@ -1,5 +1,4 @@ using Bit.Core.Repositories; -using Bit.Pam.Services; using Bit.Pam.Repositories; namespace Bit.Commercial.Pam.Services; diff --git a/bitwarden_license/src/Commercial.Pam/Utilities/ServiceCollectionExtensions.cs b/bitwarden_license/src/Commercial.Pam/Utilities/ServiceCollectionExtensions.cs index 630aea3086a4..fd77eaca772b 100644 --- a/bitwarden_license/src/Commercial.Pam/Utilities/ServiceCollectionExtensions.cs +++ b/bitwarden_license/src/Commercial.Pam/Utilities/ServiceCollectionExtensions.cs @@ -1,11 +1,9 @@ -using Bit.Commercial.Pam.Engine; +using Bit.Commercial.Pam.Engine; using Bit.Commercial.Pam.OrganizationFeatures.Commands; +using Bit.Commercial.Pam.OrganizationFeatures.Commands.Interfaces; using Bit.Commercial.Pam.OrganizationFeatures.Queries; using Bit.Commercial.Pam.OrganizationFeatures.Queries.Interfaces; using Bit.Commercial.Pam.Services; -using Bit.Pam.Engine; -using Bit.Pam.OrganizationFeatures.Commands.Interfaces; -using Bit.Pam.OrganizationFeatures.Queries.Interfaces; using Bit.Pam.Services; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; diff --git a/bitwarden_license/test/Commercial.Pam.Test/Api/Controllers/AccessRequestsControllerTests.cs b/bitwarden_license/test/Commercial.Pam.Test/Api/Controllers/AccessRequestsControllerTests.cs index 0ca37aa4642b..2c19545b7427 100644 --- a/bitwarden_license/test/Commercial.Pam.Test/Api/Controllers/AccessRequestsControllerTests.cs +++ b/bitwarden_license/test/Commercial.Pam.Test/Api/Controllers/AccessRequestsControllerTests.cs @@ -1,12 +1,14 @@ using System.Security.Claims; using Bit.Api.Pam.Controllers; using Bit.Api.Pam.Models.Request; +using Bit.Api.Pam.Models.Response; +using Bit.Commercial.Pam.Models; +using Bit.Commercial.Pam.OrganizationFeatures.Commands.Interfaces; +using Bit.Commercial.Pam.OrganizationFeatures.Queries.Interfaces; using Bit.Core.Services; using Bit.Pam.Entities; using Bit.Pam.Enums; using Bit.Pam.Models; -using Bit.Pam.OrganizationFeatures.Commands.Interfaces; -using Bit.Pam.OrganizationFeatures.Queries.Interfaces; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using Microsoft.AspNetCore.Mvc; diff --git a/bitwarden_license/test/Commercial.Pam.Test/Api/Controllers/CipherLeaseControllerTests.cs b/bitwarden_license/test/Commercial.Pam.Test/Api/Controllers/CipherLeaseControllerTests.cs index 09c79fc1ce63..fca64b5b3147 100644 --- a/bitwarden_license/test/Commercial.Pam.Test/Api/Controllers/CipherLeaseControllerTests.cs +++ b/bitwarden_license/test/Commercial.Pam.Test/Api/Controllers/CipherLeaseControllerTests.cs @@ -1,14 +1,13 @@ using System.Security.Claims; -using Bit.Commercial.Pam.OrganizationFeatures.Queries.Interfaces; using Bit.Api.Pam.Controllers; using Bit.Api.Vault.Models.Response; +using Bit.Commercial.Pam.OrganizationFeatures.Queries.Interfaces; using Bit.Core.Entities; using Bit.Core.Exceptions; using Bit.Core.Models.Data.Organizations; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Vault.Models.Data; -using Bit.Pam.OrganizationFeatures.Queries.Interfaces; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; @@ -32,7 +31,7 @@ public async Task State_ReturnsSnapshotFromQuery( .Returns(userId); sutProvider.GetDependency() .GetStateAsync(userId, id) - .Returns(new Bit.Pam.Models.CipherAccessState(id, activeLease, null, null)); + .Returns(new Bit.Commercial.Pam.Models.CipherAccessState(id, activeLease, null, null)); var result = await sutProvider.Sut.State(id); diff --git a/bitwarden_license/test/Commercial.Pam.Test/Api/Controllers/LeasesControllerTests.cs b/bitwarden_license/test/Commercial.Pam.Test/Api/Controllers/LeasesControllerTests.cs index c8bc5adb09d8..dbf871b95abb 100644 --- a/bitwarden_license/test/Commercial.Pam.Test/Api/Controllers/LeasesControllerTests.cs +++ b/bitwarden_license/test/Commercial.Pam.Test/Api/Controllers/LeasesControllerTests.cs @@ -1,12 +1,14 @@ using System.Security.Claims; using Bit.Api.Pam.Controllers; using Bit.Api.Pam.Models.Request; +using Bit.Api.Pam.Models.Response; +using Bit.Commercial.Pam.Models; +using Bit.Commercial.Pam.OrganizationFeatures.Commands.Interfaces; +using Bit.Commercial.Pam.OrganizationFeatures.Queries.Interfaces; using Bit.Core.Services; using Bit.Pam.Entities; using Bit.Pam.Enums; using Bit.Pam.Models; -using Bit.Pam.OrganizationFeatures.Commands.Interfaces; -using Bit.Pam.OrganizationFeatures.Queries.Interfaces; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using Microsoft.AspNetCore.Mvc; diff --git a/bitwarden_license/test/Commercial.Pam.Test/Api/Models/AccessLeaseResponseModelTests.cs b/bitwarden_license/test/Commercial.Pam.Test/Api/Models/AccessLeaseResponseModelTests.cs index d857fb71f33e..53eec12eaec7 100644 --- a/bitwarden_license/test/Commercial.Pam.Test/Api/Models/AccessLeaseResponseModelTests.cs +++ b/bitwarden_license/test/Commercial.Pam.Test/Api/Models/AccessLeaseResponseModelTests.cs @@ -1,7 +1,6 @@ using Bit.Api.Pam.Models.Response; using Bit.Pam.Entities; using Bit.Pam.Enums; -using Bit.Pam.Models; using Bit.Test.Common.AutoFixture.Attributes; using Xunit; diff --git a/test/Core.Test/Pam/Models/AccessLeaseStatusNamesTests.cs b/bitwarden_license/test/Commercial.Pam.Test/Api/Models/AccessLeaseStatusNamesTests.cs similarity index 81% rename from test/Core.Test/Pam/Models/AccessLeaseStatusNamesTests.cs rename to bitwarden_license/test/Commercial.Pam.Test/Api/Models/AccessLeaseStatusNamesTests.cs index a381d1ffb82b..1545847af7ac 100644 --- a/test/Core.Test/Pam/Models/AccessLeaseStatusNamesTests.cs +++ b/bitwarden_license/test/Commercial.Pam.Test/Api/Models/AccessLeaseStatusNamesTests.cs @@ -1,8 +1,8 @@ -using Bit.Pam.Enums; -using Bit.Pam.Models; +using Bit.Api.Pam.Models.Response; +using Bit.Pam.Enums; using Xunit; -namespace Bit.Core.Test.Pam.Models; +namespace Bit.Commercial.Pam.Test.Api.Models; public class AccessLeaseStatusNamesTests { diff --git a/test/Core.Test/Pam/Models/AccessRequestStatusNamesTests.cs b/bitwarden_license/test/Commercial.Pam.Test/Api/Models/AccessRequestStatusNamesTests.cs similarity index 88% rename from test/Core.Test/Pam/Models/AccessRequestStatusNamesTests.cs rename to bitwarden_license/test/Commercial.Pam.Test/Api/Models/AccessRequestStatusNamesTests.cs index 031dd2f4f7ff..cbe8c1c35436 100644 --- a/test/Core.Test/Pam/Models/AccessRequestStatusNamesTests.cs +++ b/bitwarden_license/test/Commercial.Pam.Test/Api/Models/AccessRequestStatusNamesTests.cs @@ -1,8 +1,8 @@ -using Bit.Pam.Enums; -using Bit.Pam.Models; +using Bit.Api.Pam.Models.Response; +using Bit.Pam.Enums; using Xunit; -namespace Bit.Core.Test.Pam.Models; +namespace Bit.Commercial.Pam.Test.Api.Models; public class AccessRequestStatusNamesTests { diff --git a/bitwarden_license/test/Commercial.Pam.Test/Commands/ActivateAccessRequestCommandTests.cs b/bitwarden_license/test/Commercial.Pam.Test/Commands/ActivateAccessRequestCommandTests.cs index bbe2e4412d1a..8926c988d776 100644 --- a/bitwarden_license/test/Commercial.Pam.Test/Commands/ActivateAccessRequestCommandTests.cs +++ b/bitwarden_license/test/Commercial.Pam.Test/Commands/ActivateAccessRequestCommandTests.cs @@ -1,9 +1,9 @@ -using Bit.Core.Exceptions; +using Bit.Commercial.Pam.OrganizationFeatures.Commands; +using Bit.Commercial.Pam.Services; +using Bit.Core.Exceptions; using Bit.Pam.Entities; using Bit.Pam.Enums; -using Bit.Commercial.Pam.OrganizationFeatures.Commands; using Bit.Pam.Repositories; -using Bit.Pam.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using Microsoft.Extensions.Time.Testing; diff --git a/bitwarden_license/test/Commercial.Pam.Test/Commands/CancelAccessRequestCommandTests.cs b/bitwarden_license/test/Commercial.Pam.Test/Commands/CancelAccessRequestCommandTests.cs index 071fa04a70fa..46aec34d9eb5 100644 --- a/bitwarden_license/test/Commercial.Pam.Test/Commands/CancelAccessRequestCommandTests.cs +++ b/bitwarden_license/test/Commercial.Pam.Test/Commands/CancelAccessRequestCommandTests.cs @@ -1,9 +1,9 @@ -using Bit.Core.Exceptions; +using Bit.Commercial.Pam.OrganizationFeatures.Commands; +using Bit.Commercial.Pam.Services; +using Bit.Core.Exceptions; using Bit.Pam.Entities; using Bit.Pam.Enums; -using Bit.Commercial.Pam.OrganizationFeatures.Commands; using Bit.Pam.Repositories; -using Bit.Pam.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using Microsoft.Extensions.Time.Testing; diff --git a/bitwarden_license/test/Commercial.Pam.Test/Commands/CreateAccessRuleCommandTests.cs b/bitwarden_license/test/Commercial.Pam.Test/Commands/CreateAccessRuleCommandTests.cs index 72a1ed6bfc9c..493007caef29 100644 --- a/bitwarden_license/test/Commercial.Pam.Test/Commands/CreateAccessRuleCommandTests.cs +++ b/bitwarden_license/test/Commercial.Pam.Test/Commands/CreateAccessRuleCommandTests.cs @@ -1,10 +1,10 @@ -using Bit.Core.Entities; +using Bit.Commercial.Pam.OrganizationFeatures.Commands; +using Bit.Commercial.Pam.Services; +using Bit.Core.Entities; using Bit.Core.Exceptions; using Bit.Core.Repositories; using Bit.Pam.Entities; -using Bit.Commercial.Pam.OrganizationFeatures.Commands; using Bit.Pam.Repositories; -using Bit.Pam.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using Microsoft.Extensions.Time.Testing; diff --git a/bitwarden_license/test/Commercial.Pam.Test/Commands/DecideAccessRequestCommandTests.cs b/bitwarden_license/test/Commercial.Pam.Test/Commands/DecideAccessRequestCommandTests.cs index 0059773fde30..4f849b49f094 100644 --- a/bitwarden_license/test/Commercial.Pam.Test/Commands/DecideAccessRequestCommandTests.cs +++ b/bitwarden_license/test/Commercial.Pam.Test/Commands/DecideAccessRequestCommandTests.cs @@ -1,10 +1,10 @@ -using Bit.Core.Exceptions; +using Bit.Commercial.Pam.Models; +using Bit.Commercial.Pam.OrganizationFeatures.Commands; +using Bit.Commercial.Pam.Services; +using Bit.Core.Exceptions; using Bit.Pam.Entities; using Bit.Pam.Enums; -using Bit.Pam.Models; -using Bit.Commercial.Pam.OrganizationFeatures.Commands; using Bit.Pam.Repositories; -using Bit.Pam.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using Microsoft.Extensions.Time.Testing; diff --git a/bitwarden_license/test/Commercial.Pam.Test/Commands/DeleteAccessRuleCommandTests.cs b/bitwarden_license/test/Commercial.Pam.Test/Commands/DeleteAccessRuleCommandTests.cs index 7a19853c4cb4..248c1279787c 100644 --- a/bitwarden_license/test/Commercial.Pam.Test/Commands/DeleteAccessRuleCommandTests.cs +++ b/bitwarden_license/test/Commercial.Pam.Test/Commands/DeleteAccessRuleCommandTests.cs @@ -1,6 +1,6 @@ -using Bit.Core.Exceptions; +using Bit.Commercial.Pam.OrganizationFeatures.Commands; +using Bit.Core.Exceptions; using Bit.Pam.Entities; -using Bit.Commercial.Pam.OrganizationFeatures.Commands; using Bit.Pam.Repositories; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; diff --git a/bitwarden_license/test/Commercial.Pam.Test/Commands/RequestLeaseExtensionCommandTests.cs b/bitwarden_license/test/Commercial.Pam.Test/Commands/RequestLeaseExtensionCommandTests.cs index 314a368894ef..bbd0d29ae113 100644 --- a/bitwarden_license/test/Commercial.Pam.Test/Commands/RequestLeaseExtensionCommandTests.cs +++ b/bitwarden_license/test/Commercial.Pam.Test/Commands/RequestLeaseExtensionCommandTests.cs @@ -1,12 +1,12 @@ -using Bit.Core.Exceptions; -using Bit.Pam.Engine; +using Bit.Commercial.Pam.Engine; +using Bit.Commercial.Pam.Models; +using Bit.Commercial.Pam.Models.Conditions; +using Bit.Commercial.Pam.OrganizationFeatures.Commands; +using Bit.Commercial.Pam.Services; +using Bit.Core.Exceptions; using Bit.Pam.Entities; using Bit.Pam.Enums; -using Bit.Pam.Models; -using Bit.Pam.Models.Conditions; -using Bit.Commercial.Pam.OrganizationFeatures.Commands; using Bit.Pam.Repositories; -using Bit.Pam.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using Microsoft.Extensions.Time.Testing; diff --git a/bitwarden_license/test/Commercial.Pam.Test/Commands/RevokeAccessLeaseCommandTests.cs b/bitwarden_license/test/Commercial.Pam.Test/Commands/RevokeAccessLeaseCommandTests.cs index 220a6f93e089..a667e6edb2e6 100644 --- a/bitwarden_license/test/Commercial.Pam.Test/Commands/RevokeAccessLeaseCommandTests.cs +++ b/bitwarden_license/test/Commercial.Pam.Test/Commands/RevokeAccessLeaseCommandTests.cs @@ -1,9 +1,9 @@ -using Bit.Core.Exceptions; +using Bit.Commercial.Pam.OrganizationFeatures.Commands; +using Bit.Commercial.Pam.Services; +using Bit.Core.Exceptions; using Bit.Pam.Entities; using Bit.Pam.Enums; -using Bit.Commercial.Pam.OrganizationFeatures.Commands; using Bit.Pam.Repositories; -using Bit.Pam.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using Microsoft.Extensions.Time.Testing; diff --git a/bitwarden_license/test/Commercial.Pam.Test/Commands/SubmitAccessRequestCommandTests.cs b/bitwarden_license/test/Commercial.Pam.Test/Commands/SubmitAccessRequestCommandTests.cs index a48aeaf30d3f..68123488abb3 100644 --- a/bitwarden_license/test/Commercial.Pam.Test/Commands/SubmitAccessRequestCommandTests.cs +++ b/bitwarden_license/test/Commercial.Pam.Test/Commands/SubmitAccessRequestCommandTests.cs @@ -1,18 +1,19 @@ -using Bit.Core.AdminConsole.Entities; +using Bit.Commercial.Pam.Engine; +using Bit.Commercial.Pam.Enums; +using Bit.Commercial.Pam.Models; +using Bit.Commercial.Pam.Models.Conditions; +using Bit.Commercial.Pam.OrganizationFeatures.Commands; +using Bit.Commercial.Pam.Services; +using Bit.Core.AdminConsole.Entities; using Bit.Core.Entities; using Bit.Core.Exceptions; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Vault.Models.Data; using Bit.Core.Vault.Repositories; -using Bit.Pam.Engine; using Bit.Pam.Entities; using Bit.Pam.Enums; -using Bit.Pam.Models; -using Bit.Pam.Models.Conditions; -using Bit.Commercial.Pam.OrganizationFeatures.Commands; using Bit.Pam.Repositories; -using Bit.Pam.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using Microsoft.Extensions.Time.Testing; diff --git a/bitwarden_license/test/Commercial.Pam.Test/Commands/UpdateAccessRuleCommandTests.cs b/bitwarden_license/test/Commercial.Pam.Test/Commands/UpdateAccessRuleCommandTests.cs index c5327527b7a2..004936ea2c83 100644 --- a/bitwarden_license/test/Commercial.Pam.Test/Commands/UpdateAccessRuleCommandTests.cs +++ b/bitwarden_license/test/Commercial.Pam.Test/Commands/UpdateAccessRuleCommandTests.cs @@ -1,11 +1,11 @@ -using Bit.Core.Entities; +using Bit.Commercial.Pam.OrganizationFeatures.Commands; +using Bit.Commercial.Pam.Services; +using Bit.Core.Entities; using Bit.Core.Exceptions; using Bit.Core.Repositories; using Bit.Pam.Entities; using Bit.Pam.Models; -using Bit.Commercial.Pam.OrganizationFeatures.Commands; using Bit.Pam.Repositories; -using Bit.Pam.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using Microsoft.Extensions.Time.Testing; diff --git a/bitwarden_license/test/Commercial.Pam.Test/Engine/AccessRuleEngineTests.cs b/bitwarden_license/test/Commercial.Pam.Test/Engine/AccessRuleEngineTests.cs index 752744120289..19ae67dfb553 100644 --- a/bitwarden_license/test/Commercial.Pam.Test/Engine/AccessRuleEngineTests.cs +++ b/bitwarden_license/test/Commercial.Pam.Test/Engine/AccessRuleEngineTests.cs @@ -1,8 +1,7 @@ using System.Net; using Bit.Commercial.Pam.Engine; -using Bit.Pam.Engine; -using Bit.Pam.Enums; -using Bit.Pam.Models.Conditions; +using Bit.Commercial.Pam.Enums; +using Bit.Commercial.Pam.Models.Conditions; using Xunit; namespace Bit.Commercial.Pam.Test.Engine; diff --git a/test/Core.Test/Pam/Models/Conditions/AccessWeekdayJsonConverterTests.cs b/bitwarden_license/test/Commercial.Pam.Test/Models/Conditions/AccessWeekdayJsonConverterTests.cs similarity index 95% rename from test/Core.Test/Pam/Models/Conditions/AccessWeekdayJsonConverterTests.cs rename to bitwarden_license/test/Commercial.Pam.Test/Models/Conditions/AccessWeekdayJsonConverterTests.cs index f63f0b2109b1..c6db6a37f924 100644 --- a/test/Core.Test/Pam/Models/Conditions/AccessWeekdayJsonConverterTests.cs +++ b/bitwarden_license/test/Commercial.Pam.Test/Models/Conditions/AccessWeekdayJsonConverterTests.cs @@ -1,8 +1,8 @@ using System.Text.Json; -using Bit.Pam.Enums; +using Bit.Commercial.Pam.Enums; using Xunit; -namespace Bit.Core.Test.Pam.Models.Conditions; +namespace Bit.Commercial.Pam.Test.Models.Conditions; public class AccessWeekdayJsonConverterTests { diff --git a/bitwarden_license/test/Commercial.Pam.Test/Queries/AccessPreCheckQueryTests.cs b/bitwarden_license/test/Commercial.Pam.Test/Queries/AccessPreCheckQueryTests.cs index 1fe24e60b1e0..156466d28788 100644 --- a/bitwarden_license/test/Commercial.Pam.Test/Queries/AccessPreCheckQueryTests.cs +++ b/bitwarden_license/test/Commercial.Pam.Test/Queries/AccessPreCheckQueryTests.cs @@ -1,14 +1,14 @@ -using Bit.Core.Exceptions; +using Bit.Commercial.Pam.Engine; +using Bit.Commercial.Pam.Enums; +using Bit.Commercial.Pam.Models; +using Bit.Commercial.Pam.Models.Conditions; +using Bit.Commercial.Pam.OrganizationFeatures.Queries; +using Bit.Commercial.Pam.Services; +using Bit.Core.Exceptions; using Bit.Core.Vault.Models.Data; using Bit.Core.Vault.Repositories; -using Bit.Pam.Engine; using Bit.Pam.Entities; -using Bit.Pam.Enums; -using Bit.Pam.Models; -using Bit.Pam.Models.Conditions; -using Bit.Commercial.Pam.OrganizationFeatures.Queries; using Bit.Pam.Repositories; -using Bit.Pam.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; diff --git a/bitwarden_license/test/Commercial.Pam.Test/Queries/GetCipherAccessStateQueryTests.cs b/bitwarden_license/test/Commercial.Pam.Test/Queries/GetCipherAccessStateQueryTests.cs index eeb59e3fda23..d16ef8a8e34a 100644 --- a/bitwarden_license/test/Commercial.Pam.Test/Queries/GetCipherAccessStateQueryTests.cs +++ b/bitwarden_license/test/Commercial.Pam.Test/Queries/GetCipherAccessStateQueryTests.cs @@ -1,14 +1,14 @@ -using Bit.Core.Exceptions; +using Bit.Commercial.Pam.Engine; +using Bit.Commercial.Pam.Models; +using Bit.Commercial.Pam.Models.Conditions; +using Bit.Commercial.Pam.OrganizationFeatures.Queries; +using Bit.Commercial.Pam.Services; +using Bit.Core.Exceptions; using Bit.Core.Vault.Models.Data; using Bit.Core.Vault.Repositories; -using Bit.Pam.Engine; using Bit.Pam.Entities; using Bit.Pam.Enums; -using Bit.Pam.Models; -using Bit.Pam.Models.Conditions; -using Bit.Commercial.Pam.OrganizationFeatures.Queries; using Bit.Pam.Repositories; -using Bit.Pam.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; diff --git a/bitwarden_license/test/Commercial.Pam.Test/Queries/GetLeasedCipherQueryTests.cs b/bitwarden_license/test/Commercial.Pam.Test/Queries/GetLeasedCipherQueryTests.cs index 3c3415da7e43..34a759a98899 100644 --- a/bitwarden_license/test/Commercial.Pam.Test/Queries/GetLeasedCipherQueryTests.cs +++ b/bitwarden_license/test/Commercial.Pam.Test/Queries/GetLeasedCipherQueryTests.cs @@ -1,12 +1,12 @@ -using Bit.Core.Vault.Models.Data; +using Bit.Commercial.Pam.Engine; +using Bit.Commercial.Pam.Models; +using Bit.Commercial.Pam.Models.Conditions; +using Bit.Commercial.Pam.OrganizationFeatures.Queries; +using Bit.Commercial.Pam.Services; +using Bit.Core.Vault.Models.Data; using Bit.Core.Vault.Repositories; -using Bit.Pam.Engine; using Bit.Pam.Entities; -using Bit.Pam.Models; -using Bit.Pam.Models.Conditions; -using Bit.Commercial.Pam.OrganizationFeatures.Queries; using Bit.Pam.Repositories; -using Bit.Pam.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using Microsoft.Extensions.Time.Testing; diff --git a/bitwarden_license/test/Commercial.Pam.Test/Queries/ListActiveLeasesQueryTests.cs b/bitwarden_license/test/Commercial.Pam.Test/Queries/ListActiveLeasesQueryTests.cs index f95f69842050..a64701cff4f0 100644 --- a/bitwarden_license/test/Commercial.Pam.Test/Queries/ListActiveLeasesQueryTests.cs +++ b/bitwarden_license/test/Commercial.Pam.Test/Queries/ListActiveLeasesQueryTests.cs @@ -1,7 +1,7 @@ -using Bit.Pam.Entities; -using Bit.Commercial.Pam.OrganizationFeatures.Queries; +using Bit.Commercial.Pam.OrganizationFeatures.Queries; +using Bit.Commercial.Pam.Services; +using Bit.Pam.Entities; using Bit.Pam.Repositories; -using Bit.Pam.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using Microsoft.Extensions.Time.Testing; diff --git a/bitwarden_license/test/Commercial.Pam.Test/Queries/ListInboxHistoryQueryTests.cs b/bitwarden_license/test/Commercial.Pam.Test/Queries/ListInboxHistoryQueryTests.cs index 9426ff1b4f79..0b29ab4d9a09 100644 --- a/bitwarden_license/test/Commercial.Pam.Test/Queries/ListInboxHistoryQueryTests.cs +++ b/bitwarden_license/test/Commercial.Pam.Test/Queries/ListInboxHistoryQueryTests.cs @@ -1,7 +1,7 @@ -using Bit.Pam.Models; -using Bit.Commercial.Pam.OrganizationFeatures.Queries; +using Bit.Commercial.Pam.OrganizationFeatures.Queries; +using Bit.Commercial.Pam.Services; +using Bit.Pam.Models; using Bit.Pam.Repositories; -using Bit.Pam.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using Microsoft.Extensions.Time.Testing; diff --git a/bitwarden_license/test/Commercial.Pam.Test/Queries/ListInboxRequestsQueryTests.cs b/bitwarden_license/test/Commercial.Pam.Test/Queries/ListInboxRequestsQueryTests.cs index 9e12fef4c09a..d8b183cdc073 100644 --- a/bitwarden_license/test/Commercial.Pam.Test/Queries/ListInboxRequestsQueryTests.cs +++ b/bitwarden_license/test/Commercial.Pam.Test/Queries/ListInboxRequestsQueryTests.cs @@ -1,7 +1,7 @@ -using Bit.Pam.Models; -using Bit.Commercial.Pam.OrganizationFeatures.Queries; +using Bit.Commercial.Pam.OrganizationFeatures.Queries; +using Bit.Commercial.Pam.Services; +using Bit.Pam.Models; using Bit.Pam.Repositories; -using Bit.Pam.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; diff --git a/bitwarden_license/test/Commercial.Pam.Test/Queries/ListLeaseHistoryQueryTests.cs b/bitwarden_license/test/Commercial.Pam.Test/Queries/ListLeaseHistoryQueryTests.cs index 477792083202..1923374c8b39 100644 --- a/bitwarden_license/test/Commercial.Pam.Test/Queries/ListLeaseHistoryQueryTests.cs +++ b/bitwarden_license/test/Commercial.Pam.Test/Queries/ListLeaseHistoryQueryTests.cs @@ -1,7 +1,7 @@ -using Bit.Pam.Entities; -using Bit.Commercial.Pam.OrganizationFeatures.Queries; +using Bit.Commercial.Pam.OrganizationFeatures.Queries; +using Bit.Commercial.Pam.Services; +using Bit.Pam.Entities; using Bit.Pam.Repositories; -using Bit.Pam.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using Microsoft.Extensions.Time.Testing; diff --git a/bitwarden_license/test/Commercial.Pam.Test/Queries/ListMyAccessRequestsQueryTests.cs b/bitwarden_license/test/Commercial.Pam.Test/Queries/ListMyAccessRequestsQueryTests.cs index f4c5514d5a2c..c5492af628c1 100644 --- a/bitwarden_license/test/Commercial.Pam.Test/Queries/ListMyAccessRequestsQueryTests.cs +++ b/bitwarden_license/test/Commercial.Pam.Test/Queries/ListMyAccessRequestsQueryTests.cs @@ -1,5 +1,5 @@ -using Bit.Pam.Models; -using Bit.Commercial.Pam.OrganizationFeatures.Queries; +using Bit.Commercial.Pam.OrganizationFeatures.Queries; +using Bit.Pam.Models; using Bit.Pam.Repositories; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; diff --git a/bitwarden_license/test/Commercial.Pam.Test/Queries/ListMyActiveAccessLeasesQueryTests.cs b/bitwarden_license/test/Commercial.Pam.Test/Queries/ListMyActiveAccessLeasesQueryTests.cs index 2b6e99e7fddc..60763aecf016 100644 --- a/bitwarden_license/test/Commercial.Pam.Test/Queries/ListMyActiveAccessLeasesQueryTests.cs +++ b/bitwarden_license/test/Commercial.Pam.Test/Queries/ListMyActiveAccessLeasesQueryTests.cs @@ -1,5 +1,5 @@ -using Bit.Pam.Entities; -using Bit.Commercial.Pam.OrganizationFeatures.Queries; +using Bit.Commercial.Pam.OrganizationFeatures.Queries; +using Bit.Pam.Entities; using Bit.Pam.Repositories; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; diff --git a/bitwarden_license/test/Commercial.Pam.Test/Services/AccessRuleValidatorTests.cs b/bitwarden_license/test/Commercial.Pam.Test/Services/AccessRuleValidatorTests.cs index ccdff6bbfc36..ba0384343f6a 100644 --- a/bitwarden_license/test/Commercial.Pam.Test/Services/AccessRuleValidatorTests.cs +++ b/bitwarden_license/test/Commercial.Pam.Test/Services/AccessRuleValidatorTests.cs @@ -1,5 +1,4 @@ -using Bit.Pam.Services; -using Bit.Commercial.Pam.Services; +using Bit.Commercial.Pam.Services; using Xunit; namespace Bit.Commercial.Pam.Test.Services; diff --git a/bitwarden_license/test/Commercial.Pam.Test/Services/ApproverCollectionAccessQueryTests.cs b/bitwarden_license/test/Commercial.Pam.Test/Services/ApproverCollectionAccessQueryTests.cs index f5ad5d8ff056..fc79d8186bcf 100644 --- a/bitwarden_license/test/Commercial.Pam.Test/Services/ApproverCollectionAccessQueryTests.cs +++ b/bitwarden_license/test/Commercial.Pam.Test/Services/ApproverCollectionAccessQueryTests.cs @@ -1,12 +1,11 @@ -using Bit.Core.Context; -using Bit.Commercial.Pam.Services; +using Bit.Commercial.Pam.Services; +using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Models.Data; using Bit.Core.Models.Data.Organizations; using Bit.Core.Repositories; using Bit.Core.Services; -using Bit.Pam.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; diff --git a/bitwarden_license/test/Commercial.Pam.Test/Services/ApproverInboxNotifierTests.cs b/bitwarden_license/test/Commercial.Pam.Test/Services/ApproverInboxNotifierTests.cs index fc91f3a8de7c..1011e3796386 100644 --- a/bitwarden_license/test/Commercial.Pam.Test/Services/ApproverInboxNotifierTests.cs +++ b/bitwarden_license/test/Commercial.Pam.Test/Services/ApproverInboxNotifierTests.cs @@ -1,7 +1,6 @@ -using Bit.Core.Platform.Push; -using Bit.Commercial.Pam.Services; +using Bit.Commercial.Pam.Services; +using Bit.Core.Platform.Push; using Bit.Core.Repositories; -using Bit.Pam.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; diff --git a/bitwarden_license/test/Commercial.Pam.Test/Services/CipherLeaseGateTests.cs b/bitwarden_license/test/Commercial.Pam.Test/Services/CipherLeaseGateTests.cs index dd9f298c58f6..e32bf7295abf 100644 --- a/bitwarden_license/test/Commercial.Pam.Test/Services/CipherLeaseGateTests.cs +++ b/bitwarden_license/test/Commercial.Pam.Test/Services/CipherLeaseGateTests.cs @@ -1,16 +1,15 @@ -using Bit.Core.Entities; -using Bit.Core; +using Bit.Commercial.Pam.Engine; +using Bit.Commercial.Pam.Models; +using Bit.Commercial.Pam.Models.Conditions; using Bit.Commercial.Pam.Services; +using Bit.Core; +using Bit.Core.Entities; using Bit.Core.Exceptions; using Bit.Core.Models.Data; using Bit.Core.Services; using Bit.Core.Vault.Entities; -using Bit.Pam.Engine; using Bit.Pam.Entities; -using Bit.Pam.Models; -using Bit.Pam.Models.Conditions; using Bit.Pam.Repositories; -using Bit.Pam.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; diff --git a/bitwarden_license/test/Commercial.Pam.Test/Services/GoverningRuleResolverTests.cs b/bitwarden_license/test/Commercial.Pam.Test/Services/GoverningRuleResolverTests.cs index d48632787032..7875024f3d52 100644 --- a/bitwarden_license/test/Commercial.Pam.Test/Services/GoverningRuleResolverTests.cs +++ b/bitwarden_license/test/Commercial.Pam.Test/Services/GoverningRuleResolverTests.cs @@ -1,13 +1,11 @@ using System.Net; using Bit.Commercial.Pam.Engine; +using Bit.Commercial.Pam.Models.Conditions; using Bit.Commercial.Pam.Services; using Bit.Core.Entities; using Bit.Core.Repositories; -using Bit.Pam.Engine; using Bit.Pam.Entities; -using Bit.Pam.Models.Conditions; using Bit.Pam.Repositories; -using Bit.Pam.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; diff --git a/bitwarden_license/test/Commercial.Pam.Test/Services/RequesterNotifierTests.cs b/bitwarden_license/test/Commercial.Pam.Test/Services/RequesterNotifierTests.cs index 76abdddd3952..b59925cb072e 100644 --- a/bitwarden_license/test/Commercial.Pam.Test/Services/RequesterNotifierTests.cs +++ b/bitwarden_license/test/Commercial.Pam.Test/Services/RequesterNotifierTests.cs @@ -1,6 +1,5 @@ -using Bit.Core.Platform.Push; -using Bit.Commercial.Pam.Services; -using Bit.Pam.Services; +using Bit.Commercial.Pam.Services; +using Bit.Core.Platform.Push; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; diff --git a/bitwarden_license/test/Commercial.Pam.Test/Services/SingleActiveLeaseEvaluatorTests.cs b/bitwarden_license/test/Commercial.Pam.Test/Services/SingleActiveLeaseEvaluatorTests.cs index d1069b1a864d..c92655a7f91a 100644 --- a/bitwarden_license/test/Commercial.Pam.Test/Services/SingleActiveLeaseEvaluatorTests.cs +++ b/bitwarden_license/test/Commercial.Pam.Test/Services/SingleActiveLeaseEvaluatorTests.cs @@ -1,9 +1,8 @@ -using Bit.Core.Entities; -using Bit.Commercial.Pam.Services; +using Bit.Commercial.Pam.Services; +using Bit.Core.Entities; using Bit.Core.Repositories; using Bit.Pam.Entities; using Bit.Pam.Repositories; -using Bit.Pam.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; diff --git a/src/Api/Pam/Controllers/AccessRequestsController.cs b/src/Api/Pam/Controllers/AccessRequestsController.cs index 03f24786c597..bc4ce87051a9 100644 --- a/src/Api/Pam/Controllers/AccessRequestsController.cs +++ b/src/Api/Pam/Controllers/AccessRequestsController.cs @@ -1,11 +1,11 @@ using Bit.Api.Models.Response; using Bit.Api.Pam.Models.Request; using Bit.Api.Pam.Models.Response; +using Bit.Commercial.Pam.OrganizationFeatures.Commands.Interfaces; +using Bit.Commercial.Pam.OrganizationFeatures.Queries.Interfaces; using Bit.Core; using Bit.Core.Services; using Bit.Core.Utilities; -using Bit.Pam.OrganizationFeatures.Commands.Interfaces; -using Bit.Pam.OrganizationFeatures.Queries.Interfaces; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; diff --git a/src/Api/Pam/Controllers/AccessRulesController.cs b/src/Api/Pam/Controllers/AccessRulesController.cs index 5fbc501a653d..453e10559d4d 100644 --- a/src/Api/Pam/Controllers/AccessRulesController.cs +++ b/src/Api/Pam/Controllers/AccessRulesController.cs @@ -1,11 +1,11 @@ using Bit.Api.Models.Response; using Bit.Api.Pam.Models.Request; using Bit.Api.Pam.Models.Response; +using Bit.Commercial.Pam.OrganizationFeatures.Commands.Interfaces; using Bit.Core; using Bit.Core.Context; using Bit.Core.Exceptions; using Bit.Core.Utilities; -using Bit.Pam.OrganizationFeatures.Commands.Interfaces; using Bit.Pam.Repositories; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; diff --git a/src/Api/Pam/Controllers/CipherLeaseController.cs b/src/Api/Pam/Controllers/CipherLeaseController.cs index 08304f6f6ccf..c83f24918b6b 100644 --- a/src/Api/Pam/Controllers/CipherLeaseController.cs +++ b/src/Api/Pam/Controllers/CipherLeaseController.cs @@ -1,6 +1,7 @@ using Bit.Api.Pam.Models.Request; using Bit.Api.Pam.Models.Response; using Bit.Api.Vault.Models.Response; +using Bit.Commercial.Pam.OrganizationFeatures.Commands.Interfaces; using Bit.Commercial.Pam.OrganizationFeatures.Queries.Interfaces; using Bit.Core; using Bit.Core.Exceptions; @@ -8,8 +9,6 @@ using Bit.Core.Services; using Bit.Core.Settings; using Bit.Core.Utilities; -using Bit.Pam.OrganizationFeatures.Commands.Interfaces; -using Bit.Pam.OrganizationFeatures.Queries.Interfaces; using Bit.Pam.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; diff --git a/src/Api/Pam/Controllers/LeasesController.cs b/src/Api/Pam/Controllers/LeasesController.cs index 0a093c87053e..5e57f8cedbab 100644 --- a/src/Api/Pam/Controllers/LeasesController.cs +++ b/src/Api/Pam/Controllers/LeasesController.cs @@ -1,11 +1,11 @@ using Bit.Api.Models.Response; using Bit.Api.Pam.Models.Request; using Bit.Api.Pam.Models.Response; +using Bit.Commercial.Pam.OrganizationFeatures.Commands.Interfaces; +using Bit.Commercial.Pam.OrganizationFeatures.Queries.Interfaces; using Bit.Core; using Bit.Core.Services; using Bit.Core.Utilities; -using Bit.Pam.OrganizationFeatures.Commands.Interfaces; -using Bit.Pam.OrganizationFeatures.Queries.Interfaces; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; diff --git a/src/Api/Pam/Models/Request/AccessDecisionRequestModel.cs b/src/Api/Pam/Models/Request/AccessDecisionRequestModel.cs index 6163ff31bacd..1773dd2307ee 100644 --- a/src/Api/Pam/Models/Request/AccessDecisionRequestModel.cs +++ b/src/Api/Pam/Models/Request/AccessDecisionRequestModel.cs @@ -1,6 +1,6 @@ using System.ComponentModel.DataAnnotations; +using Bit.Commercial.Pam.Models; using Bit.Pam.Enums; -using Bit.Pam.Models; namespace Bit.Api.Pam.Models.Request; diff --git a/src/Api/Pam/Models/Request/AccessLeaseExtensionRequestModel.cs b/src/Api/Pam/Models/Request/AccessLeaseExtensionRequestModel.cs index 0eb669e063aa..1a91a1e41f17 100644 --- a/src/Api/Pam/Models/Request/AccessLeaseExtensionRequestModel.cs +++ b/src/Api/Pam/Models/Request/AccessLeaseExtensionRequestModel.cs @@ -1,5 +1,4 @@ -using Bit.Pam.Models; - +using Bit.Commercial.Pam.Models; namespace Bit.Api.Pam.Models.Request; /// diff --git a/src/Api/Pam/Models/Request/AccessRequestCreateRequestModel.cs b/src/Api/Pam/Models/Request/AccessRequestCreateRequestModel.cs index 81fa81f3e2b6..9fff5b254d36 100644 --- a/src/Api/Pam/Models/Request/AccessRequestCreateRequestModel.cs +++ b/src/Api/Pam/Models/Request/AccessRequestCreateRequestModel.cs @@ -1,5 +1,4 @@ -using Bit.Pam.Models; - +using Bit.Commercial.Pam.Models; namespace Bit.Api.Pam.Models.Request; /// diff --git a/src/Pam.Domain/Models/AccessDeciderKindNames.cs b/src/Api/Pam/Models/Response/AccessDeciderKindNames.cs similarity index 94% rename from src/Pam.Domain/Models/AccessDeciderKindNames.cs rename to src/Api/Pam/Models/Response/AccessDeciderKindNames.cs index b03c22ed5d22..4940f267897a 100644 --- a/src/Pam.Domain/Models/AccessDeciderKindNames.cs +++ b/src/Api/Pam/Models/Response/AccessDeciderKindNames.cs @@ -1,6 +1,6 @@ using Bit.Pam.Enums; -namespace Bit.Pam.Models; +namespace Bit.Api.Pam.Models.Response; /// /// Maps the backend to the vocabulary the client expects on a decision: diff --git a/src/Api/Pam/Models/Response/AccessLeaseResponseModel.cs b/src/Api/Pam/Models/Response/AccessLeaseResponseModel.cs index 967d20d80caf..55a43f13479e 100644 --- a/src/Api/Pam/Models/Response/AccessLeaseResponseModel.cs +++ b/src/Api/Pam/Models/Response/AccessLeaseResponseModel.cs @@ -1,6 +1,5 @@ using Bit.Core.Models.Api; using Bit.Pam.Entities; -using Bit.Pam.Models; namespace Bit.Api.Pam.Models.Response; diff --git a/src/Pam.Domain/Models/AccessLeaseStatusNames.cs b/src/Api/Pam/Models/Response/AccessLeaseStatusNames.cs similarity index 95% rename from src/Pam.Domain/Models/AccessLeaseStatusNames.cs rename to src/Api/Pam/Models/Response/AccessLeaseStatusNames.cs index 80cfb88a7bff..aa1ecba07f6e 100644 --- a/src/Pam.Domain/Models/AccessLeaseStatusNames.cs +++ b/src/Api/Pam/Models/Response/AccessLeaseStatusNames.cs @@ -1,6 +1,6 @@ using Bit.Pam.Enums; -namespace Bit.Pam.Models; +namespace Bit.Api.Pam.Models.Response; /// /// Maps the backend to the status vocabulary the leasing client expects: diff --git a/src/Api/Pam/Models/Response/AccessPreCheckResponseModel.cs b/src/Api/Pam/Models/Response/AccessPreCheckResponseModel.cs index 1ffd0c297e29..7b115dc0e2e9 100644 --- a/src/Api/Pam/Models/Response/AccessPreCheckResponseModel.cs +++ b/src/Api/Pam/Models/Response/AccessPreCheckResponseModel.cs @@ -1,6 +1,6 @@ -using Bit.Core.Models.Api; -using Bit.Pam.Enums; -using Bit.Pam.Models; +using Bit.Commercial.Pam.Enums; +using Bit.Commercial.Pam.Models; +using Bit.Core.Models.Api; namespace Bit.Api.Pam.Models.Response; diff --git a/src/Api/Pam/Models/Response/AccessRequestDecisionResponseModel.cs b/src/Api/Pam/Models/Response/AccessRequestDecisionResponseModel.cs index 2c1f137cfd1c..160266575719 100644 --- a/src/Api/Pam/Models/Response/AccessRequestDecisionResponseModel.cs +++ b/src/Api/Pam/Models/Response/AccessRequestDecisionResponseModel.cs @@ -1,5 +1,4 @@ using Bit.Pam.Enums; -using Bit.Pam.Models; namespace Bit.Api.Pam.Models.Response; diff --git a/src/Api/Pam/Models/Response/AccessRequestResponseModel.cs b/src/Api/Pam/Models/Response/AccessRequestResponseModel.cs index 2eb153f20b5d..37dfdc0ee753 100644 --- a/src/Api/Pam/Models/Response/AccessRequestResponseModel.cs +++ b/src/Api/Pam/Models/Response/AccessRequestResponseModel.cs @@ -1,6 +1,5 @@ using Bit.Core.Models.Api; using Bit.Pam.Entities; -using Bit.Pam.Models; namespace Bit.Api.Pam.Models.Response; diff --git a/src/Api/Pam/Models/Response/AccessRequestResultResponseModel.cs b/src/Api/Pam/Models/Response/AccessRequestResultResponseModel.cs index ce7a56190f35..c6e9ea9bafc8 100644 --- a/src/Api/Pam/Models/Response/AccessRequestResultResponseModel.cs +++ b/src/Api/Pam/Models/Response/AccessRequestResultResponseModel.cs @@ -1,6 +1,6 @@ -using Bit.Core.Models.Api; -using Bit.Pam.Enums; -using Bit.Pam.Models; +using Bit.Commercial.Pam.Enums; +using Bit.Commercial.Pam.Models; +using Bit.Core.Models.Api; namespace Bit.Api.Pam.Models.Response; diff --git a/src/Pam.Domain/Models/AccessRequestStatusNames.cs b/src/Api/Pam/Models/Response/AccessRequestStatusNames.cs similarity index 96% rename from src/Pam.Domain/Models/AccessRequestStatusNames.cs rename to src/Api/Pam/Models/Response/AccessRequestStatusNames.cs index 58676d54df94..54bb626d97df 100644 --- a/src/Pam.Domain/Models/AccessRequestStatusNames.cs +++ b/src/Api/Pam/Models/Response/AccessRequestStatusNames.cs @@ -1,6 +1,6 @@ using Bit.Pam.Enums; -namespace Bit.Pam.Models; +namespace Bit.Api.Pam.Models.Response; /// /// Maps the backend (plus whether the request has produced a lease) to the status diff --git a/src/Api/Pam/Models/Response/CipherAccessStateResponseModel.cs b/src/Api/Pam/Models/Response/CipherAccessStateResponseModel.cs index ce66fed160ec..1d002a77ec15 100644 --- a/src/Api/Pam/Models/Response/CipherAccessStateResponseModel.cs +++ b/src/Api/Pam/Models/Response/CipherAccessStateResponseModel.cs @@ -1,5 +1,5 @@ -using Bit.Core.Models.Api; -using Bit.Pam.Models; +using Bit.Commercial.Pam.Models; +using Bit.Core.Models.Api; namespace Bit.Api.Pam.Models.Response; diff --git a/src/Api/Pam/Models/Response/PamDateTimeExtensions.cs b/src/Api/Pam/Models/Response/PamDateTimeExtensions.cs index 6d44aefbb455..07e7bf4233a2 100644 --- a/src/Api/Pam/Models/Response/PamDateTimeExtensions.cs +++ b/src/Api/Pam/Models/Response/PamDateTimeExtensions.cs @@ -1,4 +1,4 @@ -namespace Bit.Api.Pam.Models.Response; +namespace Bit.Api.Pam.Models.Response; /// /// Marks PAM response timestamps as UTC for serialization. From dca402b4fbd8c1711409f70fa88e8e8e81511244 Mon Sep 17 00:00:00 2001 From: Hinton Date: Mon, 22 Jun 2026 10:01:50 +0200 Subject: [PATCH 52/54] Change to use WebAPI --- src/Api/Pam/Controllers/AccessRequestsController.cs | 5 +++-- src/Api/Pam/Controllers/AccessRulesController.cs | 7 ++++--- src/Api/Pam/Controllers/CipherLeaseController.cs | 5 +++-- src/Api/Pam/Controllers/LeasesController.cs | 7 ++++--- src/Api/Startup.cs | 7 +++++++ test/Common/AutoFixture/ControllerCustomization.cs | 6 +++--- 6 files changed, 24 insertions(+), 13 deletions(-) diff --git a/src/Api/Pam/Controllers/AccessRequestsController.cs b/src/Api/Pam/Controllers/AccessRequestsController.cs index bc4ce87051a9..6ed58a6b2ef8 100644 --- a/src/Api/Pam/Controllers/AccessRequestsController.cs +++ b/src/Api/Pam/Controllers/AccessRequestsController.cs @@ -17,6 +17,7 @@ namespace Bit.Api.Pam.Controllers; /// approver's queue (requests on collections the caller can Manage, plus the decision). Activating an approved request /// mints a lease, which from then on lives under the leases resource. /// +[ApiController] [Route("access-requests")] [Authorize("Application")] [RequireFeature(FeatureFlagKeys.Pam)] @@ -28,7 +29,7 @@ public class AccessRequestsController( IListMyAccessRequestsQuery listMyAccessRequestsQuery, IActivateAccessRequestCommand activateAccessRequestCommand, ICancelAccessRequestCommand cancelAccessRequestCommand) - : Controller + : ControllerBase { /// /// Returns the caller's pending approver queue: requests on collections the caller can Manage that are still @@ -73,7 +74,7 @@ public async Task> GetMine( /// not decide their own request. /// [HttpPost("{id:guid}/decision")] - public async Task Decide(Guid id, [FromBody] AccessDecisionRequestModel model) + public async Task Decide(Guid id, AccessDecisionRequestModel model) { var userId = userService.GetProperUserId(User)!.Value; var result = await decideAccessRequestCommand.DecideAsync(userId, id, model.ToSubmission()); diff --git a/src/Api/Pam/Controllers/AccessRulesController.cs b/src/Api/Pam/Controllers/AccessRulesController.cs index 453e10559d4d..94cac656f03c 100644 --- a/src/Api/Pam/Controllers/AccessRulesController.cs +++ b/src/Api/Pam/Controllers/AccessRulesController.cs @@ -12,6 +12,7 @@ namespace Bit.Api.Pam.Controllers; +[ApiController] [Route("organizations/{orgId:guid}/access-rules")] [Authorize("Application")] [RequireFeature(FeatureFlagKeys.Pam)] @@ -21,7 +22,7 @@ public class AccessRulesController( ICreateAccessRuleCommand createCommand, IUpdateAccessRuleCommand updateCommand, IDeleteAccessRuleCommand deleteCommand) - : Controller + : ControllerBase { [HttpGet("")] public async Task> GetAll(Guid orgId) @@ -48,7 +49,7 @@ public async Task Get(Guid orgId, Guid id) } [HttpPost("")] - public async Task Post(Guid orgId, [FromBody] AccessRuleRequestModel model) + public async Task Post(Guid orgId, AccessRuleRequestModel model) { await EnsureAdminAsync(orgId); @@ -57,7 +58,7 @@ public async Task Post(Guid orgId, [FromBody] AccessRul } [HttpPut("{id:guid}")] - public async Task Put(Guid orgId, Guid id, [FromBody] AccessRuleRequestModel model) + public async Task Put(Guid orgId, Guid id, AccessRuleRequestModel model) { await EnsureAdminAsync(orgId); diff --git a/src/Api/Pam/Controllers/CipherLeaseController.cs b/src/Api/Pam/Controllers/CipherLeaseController.cs index c83f24918b6b..75493154c8be 100644 --- a/src/Api/Pam/Controllers/CipherLeaseController.cs +++ b/src/Api/Pam/Controllers/CipherLeaseController.cs @@ -15,6 +15,7 @@ namespace Bit.Api.Pam.Controllers; +[ApiController] [Route("ciphers/{id:guid}/lease")] [Authorize("Application")] [RequireFeature(FeatureFlagKeys.Pam)] @@ -28,7 +29,7 @@ public class CipherLeaseController( ICollectionCipherRepository collectionCipherRepository, ICipherLeaseGate cipherLeaseGate, GlobalSettings globalSettings) - : Controller + : ControllerBase { /// /// Reports whether leasing this cipher would be approved automatically or require human approval, so the client @@ -61,7 +62,7 @@ public async Task State(Guid id) /// lease here — the requester activates the approved request (POST access-requests/{id}/activate). /// [HttpPost("")] - public async Task Post(Guid id, [FromBody] AccessRequestCreateRequestModel model) + public async Task Post(Guid id, AccessRequestCreateRequestModel model) { var userId = userService.GetProperUserId(User)!.Value; var result = await submitAccessRequestCommand.SubmitAsync(userId, id, model.ToSubmission()); diff --git a/src/Api/Pam/Controllers/LeasesController.cs b/src/Api/Pam/Controllers/LeasesController.cs index 5e57f8cedbab..d4adb9b4ec12 100644 --- a/src/Api/Pam/Controllers/LeasesController.cs +++ b/src/Api/Pam/Controllers/LeasesController.cs @@ -18,6 +18,7 @@ namespace Bit.Api.Pam.Controllers; /// The governance scope mirrors the approver inbox — the caller's manageable collections — so an org admin or /// collection manager sees all access in their scope. /// +[ApiController] [Route("leases")] [Authorize("Application")] [RequireFeature(FeatureFlagKeys.Pam)] @@ -28,7 +29,7 @@ public class LeasesController( IListMyActiveAccessLeasesQuery listMyActiveAccessLeasesQuery, IRevokeAccessLeaseCommand revokeAccessLeaseCommand, IRequestLeaseExtensionCommand requestLeaseExtensionCommand) - : Controller + : ControllerBase { /// /// Returns every currently-active lease on collections the caller can Manage. @@ -71,7 +72,7 @@ public async Task> GetMine() /// Manage the lease's collection. /// [HttpPost("{id:guid}/revoke")] - public async Task Revoke(Guid id, [FromBody] AccessLeaseRevokeRequestModel model) + public async Task Revoke(Guid id, AccessLeaseRevokeRequestModel model) { var userId = userService.GetProperUserId(User)!.Value; await revokeAccessLeaseCommand.RevokeAsync(userId, id, model.Reason); @@ -84,7 +85,7 @@ public async Task Revoke(Guid id, [FromBody] AccessLeaseRevokeReq /// pushed out in place rather than minting a new lease. Only the lease's requester may extend it. /// [HttpPost("{id:guid}/extend")] - public async Task Extend(Guid id, [FromBody] AccessLeaseExtensionRequestModel model) + public async Task Extend(Guid id, AccessLeaseExtensionRequestModel model) { var userId = userService.GetProperUserId(User)!.Value; var details = await requestLeaseExtensionCommand.ExtendAsync(userId, model.ToSubmission(id)); diff --git a/src/Api/Startup.cs b/src/Api/Startup.cs index 815f01308252..c322c31bd812 100644 --- a/src/Api/Startup.cs +++ b/src/Api/Startup.cs @@ -215,6 +215,13 @@ public void ConfigureServices(IServiceCollection services) { config.Conventions.Add(new ApiExplorerGroupConvention()); config.Conventions.Add(new PublicApiControllersModelConvention()); + }) + .ConfigureApiBehaviorOptions(options => + { + // The PAM controllers are [ApiController]; keep Bitwarden's ErrorResponseModel 400 contract + // (produced by ModelStateValidationFilterAttribute) instead of the framework's default + // ValidationProblemDetails. Only affects [ApiController] controllers. + options.SuppressModelStateInvalidFilter = true; }); services.AddSwaggerGen(globalSettings, Environment); diff --git a/test/Common/AutoFixture/ControllerCustomization.cs b/test/Common/AutoFixture/ControllerCustomization.cs index 91fffbf09971..9a5a32dc2b3c 100644 --- a/test/Common/AutoFixture/ControllerCustomization.cs +++ b/test/Common/AutoFixture/ControllerCustomization.cs @@ -12,9 +12,9 @@ public class ControllerCustomization : ICustomization private readonly Type _controllerType; public ControllerCustomization(Type controllerType) { - if (!controllerType.IsAssignableTo(typeof(Controller))) + if (!controllerType.IsAssignableTo(typeof(ControllerBase))) { - throw new Exception($"{nameof(controllerType)} must derive from {typeof(Controller).Name}"); + throw new Exception($"{nameof(controllerType)} must derive from {typeof(ControllerBase).Name}"); } _controllerType = controllerType; @@ -25,7 +25,7 @@ public void Customize(IFixture fixture) fixture.Customizations.Add(new BuilderWithoutAutoProperties(_controllerType)); } } -public class ControllerCustomization : ICustomization where T : Controller +public class ControllerCustomization : ICustomization where T : ControllerBase { public void Customize(IFixture fixture) => new ControllerCustomization(typeof(T)).Customize(fixture); } From dc50c32ba08e3b508d9dd0153aca6360603e8a00 Mon Sep 17 00:00:00 2001 From: Hinton Date: Mon, 22 Jun 2026 17:30:43 +0200 Subject: [PATCH 53/54] Move to minimal api --- .../Api/Endpoints/AccessRequestEndpoints.cs | 43 +++++++ .../Api/Endpoints/AccessRuleEndpoints.cs | 36 ++++++ .../Api/Endpoints/CipherLeaseEndpoints.cs | 30 +++++ .../PamExceptionHandlerEndpointFilter.cs | 92 +++++++++++++++ .../Filters/PamValidationEndpointFilter.cs | 44 +++++++ .../Filters/RequireFeatureEndpointFilter.cs | 23 ++++ .../Handlers/AccessRequestEndpointsHandler.cs | 67 +++++++++++ .../Handlers/AccessRuleEndpointsHandler.cs | 32 ++--- .../Handlers/CipherLeaseEndpointsHandler.cs | 41 +++++++ .../Handlers/LeaseEndpointsHandler.cs | 59 ++++++++++ .../Api/Endpoints/LeaseEndpoints.cs | 39 ++++++ .../Api/Endpoints/PamEndpointsExtensions.cs | 46 ++++++++ .../Request/AccessDecisionRequestModel.cs | 2 +- .../AccessLeaseExtensionRequestModel.cs | 2 +- .../Request/AccessLeaseRevokeRequestModel.cs | 2 +- .../AccessRequestCreateRequestModel.cs | 2 +- .../Models/Request/AccessRuleRequestModel.cs | 2 +- .../Models/Response/AccessDeciderKindNames.cs | 2 +- .../Response/AccessLeaseResponseModel.cs | 2 +- .../Models/Response/AccessLeaseStatusNames.cs | 2 +- .../Response/AccessPreCheckResponseModel.cs | 2 +- .../AccessRequestDecisionResponseModel.cs | 2 +- .../AccessRequestDetailsResponseModel.cs | 2 +- .../Response/AccessRequestResponseModel.cs | 2 +- .../AccessRequestResultResponseModel.cs | 2 +- .../Response/AccessRequestStatusNames.cs | 2 +- .../Response/AccessRuleResponseModel.cs | 2 +- .../CipherAccessStateResponseModel.cs | 2 +- .../Models/Response/PamDateTimeExtensions.cs | 2 +- .../Api/PamApiServiceCollectionExtensions.cs | 19 +++ .../src/Commercial.Pam/Commercial.Pam.csproj | 17 +++ .../Controllers/CipherLeaseControllerTests.cs | 23 +--- .../AccessRequestEndpointsHandlerTests.cs} | 49 ++++---- .../CipherLeaseEndpointsHandlerTests.cs | 36 ++++++ .../PamExceptionHandlerEndpointFilterTests.cs | 90 ++++++++++++++ .../PamValidationEndpointFilterTests.cs | 73 ++++++++++++ .../RequireFeatureEndpointFilterTests.cs | 59 ++++++++++ .../LeaseEndpointsHandlerTests.cs} | 45 ++++--- .../Models/AccessDecisionRequestModelTests.cs | 2 +- .../Models/AccessLeaseResponseModelTests.cs | 2 +- .../Api/Models/AccessLeaseStatusNamesTests.cs | 2 +- .../AccessRequestDetailsResponseModelTests.cs | 2 +- .../Models/AccessRequestStatusNamesTests.cs | 2 +- .../Controllers/CollectionsController.cs | 1 + .../Controllers/GroupsController.cs | 2 +- .../OrganizationAuthRequestsController.cs | 2 +- .../OrganizationDomainController.cs | 2 +- .../OrganizationUsersController.cs | 2 +- .../Controllers/OrganizationsController.cs | 1 + .../Controllers/PoliciesController.cs | 2 +- .../ProviderOrganizationsController.cs | 2 +- .../Controllers/ProviderUsersController.cs | 2 +- ...ganizationDomainSsoDetailsResponseModel.cs | 2 +- .../Auth/Controllers/AccountsController.cs | 1 + .../Controllers/AuthRequestsController.cs | 2 +- .../Controllers/EmergencyAccessController.cs | 2 +- .../Auth/Controllers/TwoFactorController.cs | 1 + .../Auth/Controllers/WebAuthnController.cs | 2 +- .../OrganizationSponsorshipsController.cs | 2 +- .../Billing/Controllers/PlansController.cs | 1 + src/Api/Controllers/DevicesController.cs | 1 + ...ostedOrganizationSponsorshipsController.cs | 2 +- src/Api/Dirt/Controllers/EventsController.cs | 2 +- .../Controllers/NotificationsController.cs | 2 +- .../Controllers/AccessRequestsController.cs | 111 ------------------ .../Pam/Controllers/CipherLeaseController.cs | 55 ++------- src/Api/Pam/Controllers/LeasesController.cs | 94 --------------- .../Controllers/AccessPoliciesController.cs | 4 +- .../Controllers/ProjectsController.cs | 2 +- .../Controllers/SecretVersionsController.cs | 4 +- .../Controllers/SecretsController.cs | 2 +- .../SecretsManagerEventsController.cs | 2 +- .../Controllers/ServiceAccountsController.cs | 2 +- .../Response/SecretsSyncResponseModel.cs | 2 +- src/Api/Startup.cs | 17 +-- src/Api/Tools/Controllers/SendsController.cs | 2 +- .../OrganizationExportResponseModel.cs | 2 +- .../Vault/Controllers/CiphersController.cs | 2 +- .../Vault/Controllers/FoldersController.cs | 2 +- .../Controllers/SecurityTaskController.cs | 2 +- .../Models/Response/ListResponseModel.cs | 2 +- ...ganizationUserControllerBulkRevokeTests.cs | 2 +- .../OrganizationUserControllerTests.cs | 2 +- .../NotificationsControllerTests.cs | 2 +- .../AccessPoliciesControllerTests.cs | 2 +- .../Controllers/ProjectsControllerTests.cs | 2 +- .../SecretVersionsControllerTests.cs | 2 +- .../Controllers/SecretsControllerTests.cs | 2 +- .../ServiceAccountsControllerTests.cs | 2 +- .../OrganizationDomainControllerTests.cs | 2 +- .../AuthRequestsControllerTests.cs | 2 +- .../Controllers/DevicesControllerTests.cs | 2 +- .../EmergencyAccessControllerTests.cs | 2 +- .../Tools/Controllers/SendsControllerTests.cs | 2 +- .../AutoFixture/ControllerCustomization.cs | 6 +- 95 files changed, 966 insertions(+), 416 deletions(-) create mode 100644 bitwarden_license/src/Commercial.Pam/Api/Endpoints/AccessRequestEndpoints.cs create mode 100644 bitwarden_license/src/Commercial.Pam/Api/Endpoints/AccessRuleEndpoints.cs create mode 100644 bitwarden_license/src/Commercial.Pam/Api/Endpoints/CipherLeaseEndpoints.cs create mode 100644 bitwarden_license/src/Commercial.Pam/Api/Endpoints/Filters/PamExceptionHandlerEndpointFilter.cs create mode 100644 bitwarden_license/src/Commercial.Pam/Api/Endpoints/Filters/PamValidationEndpointFilter.cs create mode 100644 bitwarden_license/src/Commercial.Pam/Api/Endpoints/Filters/RequireFeatureEndpointFilter.cs create mode 100644 bitwarden_license/src/Commercial.Pam/Api/Endpoints/Handlers/AccessRequestEndpointsHandler.cs rename src/Api/Pam/Controllers/AccessRulesController.cs => bitwarden_license/src/Commercial.Pam/Api/Endpoints/Handlers/AccessRuleEndpointsHandler.cs (77%) create mode 100644 bitwarden_license/src/Commercial.Pam/Api/Endpoints/Handlers/CipherLeaseEndpointsHandler.cs create mode 100644 bitwarden_license/src/Commercial.Pam/Api/Endpoints/Handlers/LeaseEndpointsHandler.cs create mode 100644 bitwarden_license/src/Commercial.Pam/Api/Endpoints/LeaseEndpoints.cs create mode 100644 bitwarden_license/src/Commercial.Pam/Api/Endpoints/PamEndpointsExtensions.cs rename {src/Api/Pam => bitwarden_license/src/Commercial.Pam/Api}/Models/Request/AccessDecisionRequestModel.cs (93%) rename {src/Api/Pam => bitwarden_license/src/Commercial.Pam/Api}/Models/Request/AccessLeaseExtensionRequestModel.cs (93%) rename {src/Api/Pam => bitwarden_license/src/Commercial.Pam/Api}/Models/Request/AccessLeaseRevokeRequestModel.cs (81%) rename {src/Api/Pam => bitwarden_license/src/Commercial.Pam/Api}/Models/Request/AccessRequestCreateRequestModel.cs (94%) rename {src/Api/Pam => bitwarden_license/src/Commercial.Pam/Api}/Models/Request/AccessRuleRequestModel.cs (98%) rename {src/Api/Pam => bitwarden_license/src/Commercial.Pam/Api}/Models/Response/AccessDeciderKindNames.cs (93%) rename {src/Api/Pam => bitwarden_license/src/Commercial.Pam/Api}/Models/Response/AccessLeaseResponseModel.cs (97%) rename {src/Api/Pam => bitwarden_license/src/Commercial.Pam/Api}/Models/Response/AccessLeaseStatusNames.cs (93%) rename {src/Api/Pam => bitwarden_license/src/Commercial.Pam/Api}/Models/Response/AccessPreCheckResponseModel.cs (94%) rename {src/Api/Pam => bitwarden_license/src/Commercial.Pam/Api}/Models/Response/AccessRequestDecisionResponseModel.cs (96%) rename {src/Api/Pam => bitwarden_license/src/Commercial.Pam/Api}/Models/Response/AccessRequestDetailsResponseModel.cs (98%) rename {src/Api/Pam => bitwarden_license/src/Commercial.Pam/Api}/Models/Response/AccessRequestResponseModel.cs (95%) rename {src/Api/Pam => bitwarden_license/src/Commercial.Pam/Api}/Models/Response/AccessRequestResultResponseModel.cs (94%) rename {src/Api/Pam => bitwarden_license/src/Commercial.Pam/Api}/Models/Response/AccessRequestStatusNames.cs (95%) rename {src/Api/Pam => bitwarden_license/src/Commercial.Pam/Api}/Models/Response/AccessRuleResponseModel.cs (97%) rename {src/Api/Pam => bitwarden_license/src/Commercial.Pam/Api}/Models/Response/CipherAccessStateResponseModel.cs (97%) rename {src/Api/Pam => bitwarden_license/src/Commercial.Pam/Api}/Models/Response/PamDateTimeExtensions.cs (96%) create mode 100644 bitwarden_license/src/Commercial.Pam/Api/PamApiServiceCollectionExtensions.cs rename bitwarden_license/test/Commercial.Pam.Test/Api/{Controllers/AccessRequestsControllerTests.cs => Endpoints/AccessRequestEndpointsHandlerTests.cs} (70%) create mode 100644 bitwarden_license/test/Commercial.Pam.Test/Api/Endpoints/CipherLeaseEndpointsHandlerTests.cs create mode 100644 bitwarden_license/test/Commercial.Pam.Test/Api/Endpoints/Filters/PamExceptionHandlerEndpointFilterTests.cs create mode 100644 bitwarden_license/test/Commercial.Pam.Test/Api/Endpoints/Filters/PamValidationEndpointFilterTests.cs create mode 100644 bitwarden_license/test/Commercial.Pam.Test/Api/Endpoints/Filters/RequireFeatureEndpointFilterTests.cs rename bitwarden_license/test/Commercial.Pam.Test/Api/{Controllers/LeasesControllerTests.cs => Endpoints/LeaseEndpointsHandlerTests.cs} (70%) delete mode 100644 src/Api/Pam/Controllers/AccessRequestsController.cs delete mode 100644 src/Api/Pam/Controllers/LeasesController.cs rename src/{Api => SharedWeb}/Models/Response/ListResponseModel.cs (92%) diff --git a/bitwarden_license/src/Commercial.Pam/Api/Endpoints/AccessRequestEndpoints.cs b/bitwarden_license/src/Commercial.Pam/Api/Endpoints/AccessRequestEndpoints.cs new file mode 100644 index 000000000000..7f6cd2a210dd --- /dev/null +++ b/bitwarden_license/src/Commercial.Pam/Api/Endpoints/AccessRequestEndpoints.cs @@ -0,0 +1,43 @@ +using System.Security.Claims; +using Bit.Commercial.Pam.Api.Endpoints.Handlers; +using Bit.Commercial.Pam.Api.Models.Request; + +namespace Bit.Commercial.Pam.Api.Endpoints; + +/// +/// The access-requests resource: lease requests through their lifecycle (the requester's own queue plus the +/// approver's queue and decision). Mirrors the routes the former AccessRequestsController served. +/// +internal static class AccessRequestEndpoints +{ + public static RouteGroupBuilder MapAccessRequestEndpoints(this RouteGroupBuilder group) + { + group.MapGet("inbox", (AccessRequestEndpointsHandler handler, ClaimsPrincipal user) => handler.GetInbox(user)) + .WithName("Pam_AccessRequests_GetInbox"); + + group.MapGet("history", (AccessRequestEndpointsHandler handler, ClaimsPrincipal user) => handler.GetHistory(user)) + .WithName("Pam_AccessRequests_GetHistory"); + + group.MapGet("mine", (AccessRequestEndpointsHandler handler, ClaimsPrincipal user) => handler.GetMine(user)) + .WithName("Pam_AccessRequests_GetMine"); + + group.MapPost("{id:guid}/decision", + (Guid id, AccessDecisionRequestModel model, AccessRequestEndpointsHandler handler, ClaimsPrincipal user) => + handler.Decide(user, id, model)) + .WithName("Pam_AccessRequests_Decide"); + + group.MapPost("{id:guid}/activate", + (Guid id, AccessRequestEndpointsHandler handler, ClaimsPrincipal user) => handler.Activate(user, id)) + .WithName("Pam_AccessRequests_Activate"); + + group.MapPost("{id:guid}/revoke", + async (Guid id, AccessRequestEndpointsHandler handler, ClaimsPrincipal user) => + { + await handler.Revoke(user, id); + return TypedResults.NoContent(); + }) + .WithName("Pam_AccessRequests_Revoke"); + + return group; + } +} diff --git a/bitwarden_license/src/Commercial.Pam/Api/Endpoints/AccessRuleEndpoints.cs b/bitwarden_license/src/Commercial.Pam/Api/Endpoints/AccessRuleEndpoints.cs new file mode 100644 index 000000000000..05247c97f21f --- /dev/null +++ b/bitwarden_license/src/Commercial.Pam/Api/Endpoints/AccessRuleEndpoints.cs @@ -0,0 +1,36 @@ +using Bit.Commercial.Pam.Api.Endpoints.Handlers; +using Bit.Commercial.Pam.Api.Models.Request; + +namespace Bit.Commercial.Pam.Api.Endpoints; + +/// +/// The organizations/{orgId}/access-rules resource: rule CRUD scoped to an organization. Mirrors the routes +/// the former AccessRulesController served. orgId is bound from the group's route prefix. +/// +internal static class AccessRuleEndpoints +{ + public static RouteGroupBuilder MapAccessRuleEndpoints(this RouteGroupBuilder group) + { + group.MapGet("", (Guid orgId, AccessRuleEndpointsHandler handler) => handler.GetAll(orgId)) + .WithName("Pam_AccessRules_GetAll"); + + group.MapGet("{id:guid}", (Guid orgId, Guid id, AccessRuleEndpointsHandler handler) => handler.Get(orgId, id)) + .WithName("Pam_AccessRules_Get"); + + group.MapPost("", (Guid orgId, AccessRuleRequestModel model, AccessRuleEndpointsHandler handler) => handler.Post(orgId, model)) + .WithName("Pam_AccessRules_Post"); + + group.MapPut("{id:guid}", (Guid orgId, Guid id, AccessRuleRequestModel model, AccessRuleEndpointsHandler handler) => handler.Put(orgId, id, model)) + .WithName("Pam_AccessRules_Put"); + + group.MapDelete("{id:guid}", + async (Guid orgId, Guid id, AccessRuleEndpointsHandler handler) => + { + await handler.Delete(orgId, id); + return TypedResults.NoContent(); + }) + .WithName("Pam_AccessRules_Delete"); + + return group; + } +} diff --git a/bitwarden_license/src/Commercial.Pam/Api/Endpoints/CipherLeaseEndpoints.cs b/bitwarden_license/src/Commercial.Pam/Api/Endpoints/CipherLeaseEndpoints.cs new file mode 100644 index 000000000000..0e2c84b38f49 --- /dev/null +++ b/bitwarden_license/src/Commercial.Pam/Api/Endpoints/CipherLeaseEndpoints.cs @@ -0,0 +1,30 @@ +using System.Security.Claims; +using Bit.Commercial.Pam.Api.Endpoints.Handlers; +using Bit.Commercial.Pam.Api.Models.Request; + +namespace Bit.Commercial.Pam.Api.Endpoints; + +/// +/// The ciphers/{id}/lease resource: the per-cipher leasing entry points (pre-check, state, submit). +/// Mirrors the routes the former CipherLeaseController served. id is bound from the group's route +/// prefix. The deprecated GET …/lease/cipher read-back is hosted by a small MVC controller in the Api +/// project (it depends on the Api Vault response models). +/// +internal static class CipherLeaseEndpoints +{ + public static RouteGroupBuilder MapCipherLeaseEndpoints(this RouteGroupBuilder group) + { + group.MapGet("pre-check", (Guid id, CipherLeaseEndpointsHandler handler, ClaimsPrincipal user) => handler.PreCheck(user, id)) + .WithName("Pam_CipherLease_PreCheck"); + + group.MapGet("state", (Guid id, CipherLeaseEndpointsHandler handler, ClaimsPrincipal user) => handler.State(user, id)) + .WithName("Pam_CipherLease_State"); + + group.MapPost("", + (Guid id, AccessRequestCreateRequestModel model, CipherLeaseEndpointsHandler handler, ClaimsPrincipal user) => + handler.Post(user, id, model)) + .WithName("Pam_CipherLease_Post"); + + return group; + } +} diff --git a/bitwarden_license/src/Commercial.Pam/Api/Endpoints/Filters/PamExceptionHandlerEndpointFilter.cs b/bitwarden_license/src/Commercial.Pam/Api/Endpoints/Filters/PamExceptionHandlerEndpointFilter.cs new file mode 100644 index 000000000000..e3a110a9d257 --- /dev/null +++ b/bitwarden_license/src/Commercial.Pam/Api/Endpoints/Filters/PamExceptionHandlerEndpointFilter.cs @@ -0,0 +1,92 @@ +using Bit.Core.Exceptions; +using Bit.Core.Models.Api; +using Microsoft.IdentityModel.Tokens; + +namespace Bit.Commercial.Pam.Api.Endpoints.Filters; + +/// +/// Minimal API equivalent of the internal-API branch of Bit.Api.Utilities.ExceptionHandlerFilterAttribute. +/// Minimal API endpoints do not run MVC exception filters and src/Api has no exception-handling middleware, +/// so this filter translates thrown exceptions into Bitwarden's with the same +/// status codes the controllers produced (e.g. → 404 "Resource not found."). +/// +public class PamExceptionHandlerEndpointFilter : IEndpointFilter +{ + public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) + { + try + { + return await next(context); + } + catch (Exception exception) + { + return Handle(exception, context.HttpContext); + } + } + + private static IResult Handle(Exception exception, HttpContext httpContext) + { + var message = "An error has occurred."; + int statusCode; + ErrorResponseModel? validationModel = null; + + switch (exception) + { + case BadRequestException badRequestException: + statusCode = StatusCodes.Status400BadRequest; + if (badRequestException.ModelState != null) + { + validationModel = new ErrorResponseModel(badRequestException.ModelState); + } + else + { + message = badRequestException.Message; + } + break; + case NotSupportedException when !string.IsNullOrWhiteSpace(exception.Message): + message = exception.Message; + statusCode = StatusCodes.Status400BadRequest; + break; + case ApplicationException: + statusCode = StatusCodes.Status402PaymentRequired; + break; + case NotFoundException: + message = "Resource not found."; + statusCode = StatusCodes.Status404NotFound; + break; + case SecurityTokenValidationException: + message = "Invalid token."; + statusCode = StatusCodes.Status403Forbidden; + break; + case UnauthorizedAccessException: + message = "Unauthorized."; + statusCode = StatusCodes.Status401Unauthorized; + break; + case ConflictException: + message = exception.Message; + statusCode = StatusCodes.Status409Conflict; + break; + case AggregateException aggregateException: + statusCode = StatusCodes.Status400BadRequest; + validationModel = new ErrorResponseModel(message, aggregateException.InnerExceptions.Select(e => e.Message)); + break; + default: + httpContext.RequestServices.GetRequiredService>() + .LogError(0, exception, "Unhandled exception"); + message = "An unhandled server error has occurred."; + statusCode = StatusCodes.Status500InternalServerError; + break; + } + + var errorModel = validationModel ?? new ErrorResponseModel(message); + var environment = httpContext.RequestServices.GetRequiredService(); + if (environment.IsDevelopment()) + { + errorModel.ExceptionMessage = exception.Message; + errorModel.ExceptionStackTrace = exception.StackTrace; + errorModel.InnerExceptionMessage = exception.InnerException?.Message; + } + + return Results.Json(errorModel, statusCode: statusCode); + } +} diff --git a/bitwarden_license/src/Commercial.Pam/Api/Endpoints/Filters/PamValidationEndpointFilter.cs b/bitwarden_license/src/Commercial.Pam/Api/Endpoints/Filters/PamValidationEndpointFilter.cs new file mode 100644 index 000000000000..f2155c40714c --- /dev/null +++ b/bitwarden_license/src/Commercial.Pam/Api/Endpoints/Filters/PamValidationEndpointFilter.cs @@ -0,0 +1,44 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Core.Models.Api; + +namespace Bit.Commercial.Pam.Api.Endpoints.Filters; + +/// +/// Minimal API equivalent of the MVC ModelStateValidationFilterAttribute: runs DataAnnotations validation +/// (including ) over the request-model arguments and, on failure, short-circuits +/// with Bitwarden's internal 400 — the same body the controllers produced. +/// +public class PamValidationEndpointFilter : IEndpointFilter +{ + private const string RequestModelNamespace = "Bit.Commercial.Pam.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) + { + continue; + } + + var results = new List(); + if (Validator.TryValidateObject(argument, new ValidationContext(argument), results, validateAllProperties: true)) + { + continue; + } + + var validationErrors = results + .SelectMany( + result => result.MemberNames.Any() ? result.MemberNames : [string.Empty], + (result, member) => (member, message: result.ErrorMessage ?? string.Empty)) + .GroupBy(error => error.member) + .ToDictionary(group => group.Key, group => (IEnumerable)group.Select(error => error.message).ToArray()); + + return Results.Json( + new ErrorResponseModel("The model state is invalid.", validationErrors), + statusCode: StatusCodes.Status400BadRequest); + } + + return await next(context); + } +} diff --git a/bitwarden_license/src/Commercial.Pam/Api/Endpoints/Filters/RequireFeatureEndpointFilter.cs b/bitwarden_license/src/Commercial.Pam/Api/Endpoints/Filters/RequireFeatureEndpointFilter.cs new file mode 100644 index 000000000000..95448d9c43ca --- /dev/null +++ b/bitwarden_license/src/Commercial.Pam/Api/Endpoints/Filters/RequireFeatureEndpointFilter.cs @@ -0,0 +1,23 @@ +using Bit.Core.Exceptions; +using Bit.Core.Services; + +namespace Bit.Commercial.Pam.Api.Endpoints.Filters; + +/// +/// Minimal API equivalent of : gates an endpoint group +/// behind a boolean feature flag. When the flag is disabled a (a +/// ) is thrown, which renders as a 404. +/// +public class RequireFeatureEndpointFilter(string featureFlagKey) : IEndpointFilter +{ + public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) + { + var featureService = context.HttpContext.RequestServices.GetRequiredService(); + if (!featureService.IsEnabled(featureFlagKey)) + { + throw new FeatureUnavailableException(); + } + + return await next(context); + } +} diff --git a/bitwarden_license/src/Commercial.Pam/Api/Endpoints/Handlers/AccessRequestEndpointsHandler.cs b/bitwarden_license/src/Commercial.Pam/Api/Endpoints/Handlers/AccessRequestEndpointsHandler.cs new file mode 100644 index 000000000000..215f92cccb87 --- /dev/null +++ b/bitwarden_license/src/Commercial.Pam/Api/Endpoints/Handlers/AccessRequestEndpointsHandler.cs @@ -0,0 +1,67 @@ +using System.Security.Claims; +using Bit.Commercial.Pam.Api.Models.Request; +using Bit.Commercial.Pam.Api.Models.Response; +using Bit.Commercial.Pam.OrganizationFeatures.Commands.Interfaces; +using Bit.Commercial.Pam.OrganizationFeatures.Queries.Interfaces; +using Bit.Core.Services; +using Bit.SharedWeb.Models.Response; + +namespace Bit.Commercial.Pam.Api.Endpoints.Handlers; + +/// +/// Handler for the access-requests resource. Holds the logic the AccessRequestsController previously +/// hosted; the Minimal API endpoints (see AccessRequestEndpoints) resolve this handler from DI. +/// +public class AccessRequestEndpointsHandler( + IUserService userService, + IListInboxRequestsQuery listInboxRequestsQuery, + IListInboxHistoryQuery listInboxHistoryQuery, + IDecideAccessRequestCommand decideAccessRequestCommand, + IListMyAccessRequestsQuery listMyAccessRequestsQuery, + IActivateAccessRequestCommand activateAccessRequestCommand, + ICancelAccessRequestCommand cancelAccessRequestCommand) +{ + public async Task> GetInbox(ClaimsPrincipal user) + { + var userId = userService.GetProperUserId(user)!.Value; + var requests = await listInboxRequestsQuery.GetPendingAsync(userId); + return new ListResponseModel( + requests.Select(r => new AccessRequestDetailsResponseModel(r))); + } + + public async Task> GetHistory(ClaimsPrincipal user) + { + var userId = userService.GetProperUserId(user)!.Value; + var history = await listInboxHistoryQuery.GetHistoryAsync(userId); + return new ListResponseModel( + history.Select(r => new AccessRequestDetailsResponseModel(r))); + } + + public async Task> GetMine(ClaimsPrincipal user) + { + var userId = userService.GetProperUserId(user)!.Value; + var requests = await listMyAccessRequestsQuery.GetMineAsync(userId); + return new ListResponseModel( + requests.Select(r => new AccessRequestDetailsResponseModel(r))); + } + + public async Task Decide(ClaimsPrincipal user, Guid id, AccessDecisionRequestModel model) + { + var userId = userService.GetProperUserId(user)!.Value; + var result = await decideAccessRequestCommand.DecideAsync(userId, id, model.ToSubmission()); + return new AccessRequestDetailsResponseModel(result); + } + + public async Task Activate(ClaimsPrincipal user, Guid id) + { + var userId = userService.GetProperUserId(user)!.Value; + var lease = await activateAccessRequestCommand.ActivateAsync(userId, id); + return new AccessLeaseResponseModel(lease); + } + + public async Task Revoke(ClaimsPrincipal user, Guid id) + { + var userId = userService.GetProperUserId(user)!.Value; + await cancelAccessRequestCommand.CancelAsync(userId, id); + } +} diff --git a/src/Api/Pam/Controllers/AccessRulesController.cs b/bitwarden_license/src/Commercial.Pam/Api/Endpoints/Handlers/AccessRuleEndpointsHandler.cs similarity index 77% rename from src/Api/Pam/Controllers/AccessRulesController.cs rename to bitwarden_license/src/Commercial.Pam/Api/Endpoints/Handlers/AccessRuleEndpointsHandler.cs index 94cac656f03c..9783d2b20401 100644 --- a/src/Api/Pam/Controllers/AccessRulesController.cs +++ b/bitwarden_license/src/Commercial.Pam/Api/Endpoints/Handlers/AccessRuleEndpointsHandler.cs @@ -1,30 +1,25 @@ -using Bit.Api.Models.Response; -using Bit.Api.Pam.Models.Request; -using Bit.Api.Pam.Models.Response; +using Bit.SharedWeb.Models.Response; +using Bit.Commercial.Pam.Api.Models.Request; +using Bit.Commercial.Pam.Api.Models.Response; using Bit.Commercial.Pam.OrganizationFeatures.Commands.Interfaces; -using Bit.Core; using Bit.Core.Context; using Bit.Core.Exceptions; -using Bit.Core.Utilities; using Bit.Pam.Repositories; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -namespace Bit.Api.Pam.Controllers; +namespace Bit.Commercial.Pam.Api.Endpoints.Handlers; -[ApiController] -[Route("organizations/{orgId:guid}/access-rules")] -[Authorize("Application")] -[RequireFeature(FeatureFlagKeys.Pam)] -public class AccessRulesController( +/// +/// Handler for the organizations/{orgId}/access-rules resource. Holds the logic the +/// AccessRulesController previously hosted; the Minimal API endpoints (see AccessRuleEndpoints) +/// resolve this handler from DI. +/// +public class AccessRuleEndpointsHandler( ICurrentContext currentContext, IAccessRuleRepository repository, ICreateAccessRuleCommand createCommand, IUpdateAccessRuleCommand updateCommand, IDeleteAccessRuleCommand deleteCommand) - : ControllerBase { - [HttpGet("")] public async Task> GetAll(Guid orgId) { await EnsureMemberAsync(orgId); @@ -34,7 +29,6 @@ public async Task> GetAll(Guid orgId) rules.Select(rule => new AccessRuleResponseModel(rule))); } - [HttpGet("{id:guid}")] public async Task Get(Guid orgId, Guid id) { await EnsureMemberAsync(orgId); @@ -48,7 +42,6 @@ public async Task Get(Guid orgId, Guid id) return new AccessRuleResponseModel(rule); } - [HttpPost("")] public async Task Post(Guid orgId, AccessRuleRequestModel model) { await EnsureAdminAsync(orgId); @@ -57,7 +50,6 @@ public async Task Post(Guid orgId, AccessRuleRequestMod return new AccessRuleResponseModel(rule); } - [HttpPut("{id:guid}")] public async Task Put(Guid orgId, Guid id, AccessRuleRequestModel model) { await EnsureAdminAsync(orgId); @@ -66,13 +58,11 @@ public async Task Put(Guid orgId, Guid id, AccessRuleRe return new AccessRuleResponseModel(rule); } - [HttpDelete("{id:guid}")] - public async Task Delete(Guid orgId, Guid id) + public async Task Delete(Guid orgId, Guid id) { await EnsureAdminAsync(orgId); await deleteCommand.DeleteAsync(orgId, id); - return NoContent(); } private async Task EnsureMemberAsync(Guid orgId) diff --git a/bitwarden_license/src/Commercial.Pam/Api/Endpoints/Handlers/CipherLeaseEndpointsHandler.cs b/bitwarden_license/src/Commercial.Pam/Api/Endpoints/Handlers/CipherLeaseEndpointsHandler.cs new file mode 100644 index 000000000000..59ae5eafd769 --- /dev/null +++ b/bitwarden_license/src/Commercial.Pam/Api/Endpoints/Handlers/CipherLeaseEndpointsHandler.cs @@ -0,0 +1,41 @@ +using System.Security.Claims; +using Bit.Commercial.Pam.Api.Models.Request; +using Bit.Commercial.Pam.Api.Models.Response; +using Bit.Commercial.Pam.OrganizationFeatures.Commands.Interfaces; +using Bit.Commercial.Pam.OrganizationFeatures.Queries.Interfaces; +using Bit.Core.Services; + +namespace Bit.Commercial.Pam.Api.Endpoints.Handlers; + +/// +/// Handler for the ciphers/{id}/lease resource: the per-cipher leasing entry points (pre-check, state, +/// submit). The deprecated full-cipher read-back (GET …/lease/cipher) is hosted by a small MVC controller +/// in the Api project instead, since it depends on the Api Vault response models. +/// +public class CipherLeaseEndpointsHandler( + IUserService userService, + IAccessPreCheckQuery preCheckQuery, + IGetCipherAccessStateQuery cipherAccessStateQuery, + ISubmitAccessRequestCommand submitAccessRequestCommand) +{ + public async Task PreCheck(ClaimsPrincipal user, Guid id) + { + var userId = userService.GetProperUserId(user)!.Value; + var result = await preCheckQuery.PreCheckAsync(userId, id); + return new AccessPreCheckResponseModel(id, result); + } + + public async Task State(ClaimsPrincipal user, Guid id) + { + var userId = userService.GetProperUserId(user)!.Value; + var result = await cipherAccessStateQuery.GetStateAsync(userId, id); + return new CipherAccessStateResponseModel(result); + } + + public async Task Post(ClaimsPrincipal user, Guid id, AccessRequestCreateRequestModel model) + { + var userId = userService.GetProperUserId(user)!.Value; + var result = await submitAccessRequestCommand.SubmitAsync(userId, id, model.ToSubmission()); + return new AccessRequestResultResponseModel(result); + } +} diff --git a/bitwarden_license/src/Commercial.Pam/Api/Endpoints/Handlers/LeaseEndpointsHandler.cs b/bitwarden_license/src/Commercial.Pam/Api/Endpoints/Handlers/LeaseEndpointsHandler.cs new file mode 100644 index 000000000000..78c396b7aab0 --- /dev/null +++ b/bitwarden_license/src/Commercial.Pam/Api/Endpoints/Handlers/LeaseEndpointsHandler.cs @@ -0,0 +1,59 @@ +using System.Security.Claims; +using Bit.Commercial.Pam.Api.Models.Request; +using Bit.Commercial.Pam.Api.Models.Response; +using Bit.Commercial.Pam.OrganizationFeatures.Commands.Interfaces; +using Bit.Commercial.Pam.OrganizationFeatures.Queries.Interfaces; +using Bit.Core.Services; +using Bit.SharedWeb.Models.Response; + +namespace Bit.Commercial.Pam.Api.Endpoints.Handlers; + +/// +/// Handler for the leases resource. Holds the logic the LeasesController previously hosted; the +/// Minimal API endpoints (see LeaseEndpoints) are thin lambdas that resolve this handler from DI. +/// +public class LeaseEndpointsHandler( + IUserService userService, + IListActiveLeasesQuery listActiveLeasesQuery, + IListLeaseHistoryQuery listLeaseHistoryQuery, + IListMyActiveAccessLeasesQuery listMyActiveAccessLeasesQuery, + IRevokeAccessLeaseCommand revokeAccessLeaseCommand, + IRequestLeaseExtensionCommand requestLeaseExtensionCommand) +{ + public async Task> GetActive(ClaimsPrincipal user) + { + var userId = userService.GetProperUserId(user)!.Value; + var leases = await listActiveLeasesQuery.GetActiveAsync(userId); + return new ListResponseModel( + leases.Select(l => new AccessLeaseResponseModel(l))); + } + + public async Task> GetHistory(ClaimsPrincipal user) + { + var userId = userService.GetProperUserId(user)!.Value; + var leases = await listLeaseHistoryQuery.GetHistoryAsync(userId); + return new ListResponseModel( + leases.Select(l => new AccessLeaseResponseModel(l))); + } + + public async Task> GetMine(ClaimsPrincipal user) + { + var userId = userService.GetProperUserId(user)!.Value; + var leases = await listMyActiveAccessLeasesQuery.GetMineActiveAsync(userId); + return new ListResponseModel( + leases.Select(l => new AccessLeaseResponseModel(l))); + } + + public async Task Revoke(ClaimsPrincipal user, Guid id, AccessLeaseRevokeRequestModel model) + { + var userId = userService.GetProperUserId(user)!.Value; + await revokeAccessLeaseCommand.RevokeAsync(userId, id, model.Reason); + } + + public async Task Extend(ClaimsPrincipal user, Guid id, AccessLeaseExtensionRequestModel model) + { + var userId = userService.GetProperUserId(user)!.Value; + var details = await requestLeaseExtensionCommand.ExtendAsync(userId, model.ToSubmission(id)); + return new AccessRequestDetailsResponseModel(details); + } +} diff --git a/bitwarden_license/src/Commercial.Pam/Api/Endpoints/LeaseEndpoints.cs b/bitwarden_license/src/Commercial.Pam/Api/Endpoints/LeaseEndpoints.cs new file mode 100644 index 000000000000..c81cd70d8f23 --- /dev/null +++ b/bitwarden_license/src/Commercial.Pam/Api/Endpoints/LeaseEndpoints.cs @@ -0,0 +1,39 @@ +using System.Security.Claims; +using Bit.Commercial.Pam.Api.Endpoints.Handlers; +using Bit.Commercial.Pam.Api.Models.Request; + +namespace Bit.Commercial.Pam.Api.Endpoints; + +/// +/// The leases resource: the caller's own leases, the governance surface over manageable collections, and the +/// per-lease actions (revoke, extend). Mirrors the routes the former LeasesController served. +/// +internal static class LeaseEndpoints +{ + public static RouteGroupBuilder MapLeaseEndpoints(this RouteGroupBuilder group) + { + group.MapGet("active", (LeaseEndpointsHandler handler, ClaimsPrincipal user) => handler.GetActive(user)) + .WithName("Pam_Leases_GetActive"); + + group.MapGet("history", (LeaseEndpointsHandler handler, ClaimsPrincipal user) => handler.GetHistory(user)) + .WithName("Pam_Leases_GetHistory"); + + group.MapGet("mine", (LeaseEndpointsHandler handler, ClaimsPrincipal user) => handler.GetMine(user)) + .WithName("Pam_Leases_GetMine"); + + group.MapPost("{id:guid}/revoke", + async (Guid id, AccessLeaseRevokeRequestModel model, LeaseEndpointsHandler handler, ClaimsPrincipal user) => + { + await handler.Revoke(user, id, model); + return TypedResults.NoContent(); + }) + .WithName("Pam_Leases_Revoke"); + + group.MapPost("{id:guid}/extend", + (Guid id, AccessLeaseExtensionRequestModel model, LeaseEndpointsHandler handler, ClaimsPrincipal user) => + handler.Extend(user, id, model)) + .WithName("Pam_Leases_Extend"); + + return group; + } +} diff --git a/bitwarden_license/src/Commercial.Pam/Api/Endpoints/PamEndpointsExtensions.cs b/bitwarden_license/src/Commercial.Pam/Api/Endpoints/PamEndpointsExtensions.cs new file mode 100644 index 000000000000..c18eedf73cc5 --- /dev/null +++ b/bitwarden_license/src/Commercial.Pam/Api/Endpoints/PamEndpointsExtensions.cs @@ -0,0 +1,46 @@ +using Bit.Commercial.Pam.Api.Endpoints.Filters; +using Bit.Core; +using Bit.Core.Auth.Identity; + +namespace Bit.Commercial.Pam.Api.Endpoints; + +/// +/// Maps the PAM HTTP surface as Minimal API endpoint groups. Each resource group shares the same cross-cutting +/// chain — authorization, exception → ErrorResponseModel translation, the PAM feature gate, and request-model +/// validation — reproducing what the MVC controllers received from attributes and conventions. Routes are identical +/// to the controllers they replace. +/// +public static class PamEndpointsExtensions +{ + public static void MapPamEndpoints(this IEndpointRouteBuilder endpoints) + { + endpoints.MapGroup("/leases").WithPamDefaults().MapLeaseEndpoints(); + endpoints.MapGroup("/access-requests").WithPamDefaults().MapAccessRequestEndpoints(); + endpoints.MapGroup("/organizations/{orgId:guid}/access-rules").WithPamDefaults().MapAccessRuleEndpoints(); + endpoints.MapGroup("/ciphers/{id:guid}/lease").WithPamDefaults().MapCipherLeaseEndpoints(); + } + + /// + /// 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. + /// + private static RouteGroupBuilder WithPamDefaults(this RouteGroupBuilder group) + { + group.RequireAuthorization(Policies.Application); + group.AddEndpointFilter(); + group.RequireFeature(FeatureFlagKeys.Pam); + group.AddEndpointFilter(); + group.WithGroupName("internal"); + return group; + } + + /// + /// Minimal API equivalent of [RequireFeature(key)]: gates every endpoint in the group behind the flag. + /// + public static RouteGroupBuilder RequireFeature(this RouteGroupBuilder group, string featureFlagKey) + { + group.AddEndpointFilter(new RequireFeatureEndpointFilter(featureFlagKey)); + return group; + } +} diff --git a/src/Api/Pam/Models/Request/AccessDecisionRequestModel.cs b/bitwarden_license/src/Commercial.Pam/Api/Models/Request/AccessDecisionRequestModel.cs similarity index 93% rename from src/Api/Pam/Models/Request/AccessDecisionRequestModel.cs rename to bitwarden_license/src/Commercial.Pam/Api/Models/Request/AccessDecisionRequestModel.cs index 1773dd2307ee..d6b64b3b74ee 100644 --- a/src/Api/Pam/Models/Request/AccessDecisionRequestModel.cs +++ b/bitwarden_license/src/Commercial.Pam/Api/Models/Request/AccessDecisionRequestModel.cs @@ -2,7 +2,7 @@ using Bit.Commercial.Pam.Models; using Bit.Pam.Enums; -namespace Bit.Api.Pam.Models.Request; +namespace Bit.Commercial.Pam.Api.Models.Request; /// /// An approver's decision on a pending access request. is the diff --git a/src/Api/Pam/Models/Request/AccessLeaseExtensionRequestModel.cs b/bitwarden_license/src/Commercial.Pam/Api/Models/Request/AccessLeaseExtensionRequestModel.cs similarity index 93% rename from src/Api/Pam/Models/Request/AccessLeaseExtensionRequestModel.cs rename to bitwarden_license/src/Commercial.Pam/Api/Models/Request/AccessLeaseExtensionRequestModel.cs index 1a91a1e41f17..7839824cddd6 100644 --- a/src/Api/Pam/Models/Request/AccessLeaseExtensionRequestModel.cs +++ b/bitwarden_license/src/Commercial.Pam/Api/Models/Request/AccessLeaseExtensionRequestModel.cs @@ -1,5 +1,5 @@ using Bit.Commercial.Pam.Models; -namespace Bit.Api.Pam.Models.Request; +namespace Bit.Commercial.Pam.Api.Models.Request; /// /// A request to extend an active lease, identified by the route's lease id. The lease's end is pushed out by diff --git a/src/Api/Pam/Models/Request/AccessLeaseRevokeRequestModel.cs b/bitwarden_license/src/Commercial.Pam/Api/Models/Request/AccessLeaseRevokeRequestModel.cs similarity index 81% rename from src/Api/Pam/Models/Request/AccessLeaseRevokeRequestModel.cs rename to bitwarden_license/src/Commercial.Pam/Api/Models/Request/AccessLeaseRevokeRequestModel.cs index 6f63123255c3..1bf078f34c89 100644 --- a/src/Api/Pam/Models/Request/AccessLeaseRevokeRequestModel.cs +++ b/bitwarden_license/src/Commercial.Pam/Api/Models/Request/AccessLeaseRevokeRequestModel.cs @@ -1,4 +1,4 @@ -namespace Bit.Api.Pam.Models.Request; +namespace Bit.Commercial.Pam.Api.Models.Request; /// /// A request to revoke an active lease early. is optional and retained for the audit trail. diff --git a/src/Api/Pam/Models/Request/AccessRequestCreateRequestModel.cs b/bitwarden_license/src/Commercial.Pam/Api/Models/Request/AccessRequestCreateRequestModel.cs similarity index 94% rename from src/Api/Pam/Models/Request/AccessRequestCreateRequestModel.cs rename to bitwarden_license/src/Commercial.Pam/Api/Models/Request/AccessRequestCreateRequestModel.cs index 9fff5b254d36..8af9108c7cc1 100644 --- a/src/Api/Pam/Models/Request/AccessRequestCreateRequestModel.cs +++ b/bitwarden_license/src/Commercial.Pam/Api/Models/Request/AccessRequestCreateRequestModel.cs @@ -1,5 +1,5 @@ using Bit.Commercial.Pam.Models; -namespace Bit.Api.Pam.Models.Request; +namespace Bit.Commercial.Pam.Api.Models.Request; /// /// A request to lease a cipher. Supply for the automatic path, or diff --git a/src/Api/Pam/Models/Request/AccessRuleRequestModel.cs b/bitwarden_license/src/Commercial.Pam/Api/Models/Request/AccessRuleRequestModel.cs similarity index 98% rename from src/Api/Pam/Models/Request/AccessRuleRequestModel.cs rename to bitwarden_license/src/Commercial.Pam/Api/Models/Request/AccessRuleRequestModel.cs index 4826b900f1a2..2c5fd18fd521 100644 --- a/src/Api/Pam/Models/Request/AccessRuleRequestModel.cs +++ b/bitwarden_license/src/Commercial.Pam/Api/Models/Request/AccessRuleRequestModel.cs @@ -2,7 +2,7 @@ using System.Text.Json; using Bit.Pam.Entities; -namespace Bit.Api.Pam.Models.Request; +namespace Bit.Commercial.Pam.Api.Models.Request; public class AccessRuleRequestModel { diff --git a/src/Api/Pam/Models/Response/AccessDeciderKindNames.cs b/bitwarden_license/src/Commercial.Pam/Api/Models/Response/AccessDeciderKindNames.cs similarity index 93% rename from src/Api/Pam/Models/Response/AccessDeciderKindNames.cs rename to bitwarden_license/src/Commercial.Pam/Api/Models/Response/AccessDeciderKindNames.cs index 4940f267897a..33b2163a99dc 100644 --- a/src/Api/Pam/Models/Response/AccessDeciderKindNames.cs +++ b/bitwarden_license/src/Commercial.Pam/Api/Models/Response/AccessDeciderKindNames.cs @@ -1,6 +1,6 @@ using Bit.Pam.Enums; -namespace Bit.Api.Pam.Models.Response; +namespace Bit.Commercial.Pam.Api.Models.Response; /// /// Maps the backend to the vocabulary the client expects on a decision: diff --git a/src/Api/Pam/Models/Response/AccessLeaseResponseModel.cs b/bitwarden_license/src/Commercial.Pam/Api/Models/Response/AccessLeaseResponseModel.cs similarity index 97% rename from src/Api/Pam/Models/Response/AccessLeaseResponseModel.cs rename to bitwarden_license/src/Commercial.Pam/Api/Models/Response/AccessLeaseResponseModel.cs index 55a43f13479e..2efddaab6e29 100644 --- a/src/Api/Pam/Models/Response/AccessLeaseResponseModel.cs +++ b/bitwarden_license/src/Commercial.Pam/Api/Models/Response/AccessLeaseResponseModel.cs @@ -1,7 +1,7 @@ using Bit.Core.Models.Api; using Bit.Pam.Entities; -namespace Bit.Api.Pam.Models.Response; +namespace Bit.Commercial.Pam.Api.Models.Response; /// /// An access lease as its requester sees it: the originating request, string status vocabulary, and revocation diff --git a/src/Api/Pam/Models/Response/AccessLeaseStatusNames.cs b/bitwarden_license/src/Commercial.Pam/Api/Models/Response/AccessLeaseStatusNames.cs similarity index 93% rename from src/Api/Pam/Models/Response/AccessLeaseStatusNames.cs rename to bitwarden_license/src/Commercial.Pam/Api/Models/Response/AccessLeaseStatusNames.cs index aa1ecba07f6e..7d4ff8423cf3 100644 --- a/src/Api/Pam/Models/Response/AccessLeaseStatusNames.cs +++ b/bitwarden_license/src/Commercial.Pam/Api/Models/Response/AccessLeaseStatusNames.cs @@ -1,6 +1,6 @@ using Bit.Pam.Enums; -namespace Bit.Api.Pam.Models.Response; +namespace Bit.Commercial.Pam.Api.Models.Response; /// /// Maps the backend to the status vocabulary the leasing client expects: diff --git a/src/Api/Pam/Models/Response/AccessPreCheckResponseModel.cs b/bitwarden_license/src/Commercial.Pam/Api/Models/Response/AccessPreCheckResponseModel.cs similarity index 94% rename from src/Api/Pam/Models/Response/AccessPreCheckResponseModel.cs rename to bitwarden_license/src/Commercial.Pam/Api/Models/Response/AccessPreCheckResponseModel.cs index 7b115dc0e2e9..c15f5b0ad26f 100644 --- a/src/Api/Pam/Models/Response/AccessPreCheckResponseModel.cs +++ b/bitwarden_license/src/Commercial.Pam/Api/Models/Response/AccessPreCheckResponseModel.cs @@ -2,7 +2,7 @@ using Bit.Commercial.Pam.Models; using Bit.Core.Models.Api; -namespace Bit.Api.Pam.Models.Response; +namespace Bit.Commercial.Pam.Api.Models.Response; public class AccessPreCheckResponseModel : ResponseModel { diff --git a/src/Api/Pam/Models/Response/AccessRequestDecisionResponseModel.cs b/bitwarden_license/src/Commercial.Pam/Api/Models/Response/AccessRequestDecisionResponseModel.cs similarity index 96% rename from src/Api/Pam/Models/Response/AccessRequestDecisionResponseModel.cs rename to bitwarden_license/src/Commercial.Pam/Api/Models/Response/AccessRequestDecisionResponseModel.cs index 160266575719..f60b2de27386 100644 --- a/src/Api/Pam/Models/Response/AccessRequestDecisionResponseModel.cs +++ b/bitwarden_license/src/Commercial.Pam/Api/Models/Response/AccessRequestDecisionResponseModel.cs @@ -1,6 +1,6 @@ using Bit.Pam.Enums; -namespace Bit.Api.Pam.Models.Response; +namespace Bit.Commercial.Pam.Api.Models.Response; /// /// One decision on an access request: who decided (), their identity for a human decision, diff --git a/src/Api/Pam/Models/Response/AccessRequestDetailsResponseModel.cs b/bitwarden_license/src/Commercial.Pam/Api/Models/Response/AccessRequestDetailsResponseModel.cs similarity index 98% rename from src/Api/Pam/Models/Response/AccessRequestDetailsResponseModel.cs rename to bitwarden_license/src/Commercial.Pam/Api/Models/Response/AccessRequestDetailsResponseModel.cs index 37c972f77571..c0e1aa1cd8c7 100644 --- a/src/Api/Pam/Models/Response/AccessRequestDetailsResponseModel.cs +++ b/bitwarden_license/src/Commercial.Pam/Api/Models/Response/AccessRequestDetailsResponseModel.cs @@ -1,7 +1,7 @@ using Bit.Core.Models.Api; using Bit.Pam.Models; -namespace Bit.Api.Pam.Models.Response; +namespace Bit.Commercial.Pam.Api.Models.Response; /// /// An access request with its denormalized display fields (cipher/collection names, requester identity), serving the diff --git a/src/Api/Pam/Models/Response/AccessRequestResponseModel.cs b/bitwarden_license/src/Commercial.Pam/Api/Models/Response/AccessRequestResponseModel.cs similarity index 95% rename from src/Api/Pam/Models/Response/AccessRequestResponseModel.cs rename to bitwarden_license/src/Commercial.Pam/Api/Models/Response/AccessRequestResponseModel.cs index 37dfdc0ee753..fcb34799d55a 100644 --- a/src/Api/Pam/Models/Response/AccessRequestResponseModel.cs +++ b/bitwarden_license/src/Commercial.Pam/Api/Models/Response/AccessRequestResponseModel.cs @@ -1,7 +1,7 @@ using Bit.Core.Models.Api; using Bit.Pam.Entities; -namespace Bit.Api.Pam.Models.Response; +namespace Bit.Commercial.Pam.Api.Models.Response; public class AccessRequestResponseModel : ResponseModel { diff --git a/src/Api/Pam/Models/Response/AccessRequestResultResponseModel.cs b/bitwarden_license/src/Commercial.Pam/Api/Models/Response/AccessRequestResultResponseModel.cs similarity index 94% rename from src/Api/Pam/Models/Response/AccessRequestResultResponseModel.cs rename to bitwarden_license/src/Commercial.Pam/Api/Models/Response/AccessRequestResultResponseModel.cs index c6e9ea9bafc8..5e398e84f11d 100644 --- a/src/Api/Pam/Models/Response/AccessRequestResultResponseModel.cs +++ b/bitwarden_license/src/Commercial.Pam/Api/Models/Response/AccessRequestResultResponseModel.cs @@ -2,7 +2,7 @@ using Bit.Commercial.Pam.Models; using Bit.Core.Models.Api; -namespace Bit.Api.Pam.Models.Response; +namespace Bit.Commercial.Pam.Api.Models.Response; public class AccessRequestResultResponseModel : ResponseModel { diff --git a/src/Api/Pam/Models/Response/AccessRequestStatusNames.cs b/bitwarden_license/src/Commercial.Pam/Api/Models/Response/AccessRequestStatusNames.cs similarity index 95% rename from src/Api/Pam/Models/Response/AccessRequestStatusNames.cs rename to bitwarden_license/src/Commercial.Pam/Api/Models/Response/AccessRequestStatusNames.cs index 54bb626d97df..a5872777809a 100644 --- a/src/Api/Pam/Models/Response/AccessRequestStatusNames.cs +++ b/bitwarden_license/src/Commercial.Pam/Api/Models/Response/AccessRequestStatusNames.cs @@ -1,6 +1,6 @@ using Bit.Pam.Enums; -namespace Bit.Api.Pam.Models.Response; +namespace Bit.Commercial.Pam.Api.Models.Response; /// /// Maps the backend (plus whether the request has produced a lease) to the status diff --git a/src/Api/Pam/Models/Response/AccessRuleResponseModel.cs b/bitwarden_license/src/Commercial.Pam/Api/Models/Response/AccessRuleResponseModel.cs similarity index 97% rename from src/Api/Pam/Models/Response/AccessRuleResponseModel.cs rename to bitwarden_license/src/Commercial.Pam/Api/Models/Response/AccessRuleResponseModel.cs index f34796de2020..778aaddd1732 100644 --- a/src/Api/Pam/Models/Response/AccessRuleResponseModel.cs +++ b/bitwarden_license/src/Commercial.Pam/Api/Models/Response/AccessRuleResponseModel.cs @@ -2,7 +2,7 @@ using Bit.Core.Models.Api; using Bit.Pam.Models; -namespace Bit.Api.Pam.Models.Response; +namespace Bit.Commercial.Pam.Api.Models.Response; public class AccessRuleResponseModel : ResponseModel { diff --git a/src/Api/Pam/Models/Response/CipherAccessStateResponseModel.cs b/bitwarden_license/src/Commercial.Pam/Api/Models/Response/CipherAccessStateResponseModel.cs similarity index 97% rename from src/Api/Pam/Models/Response/CipherAccessStateResponseModel.cs rename to bitwarden_license/src/Commercial.Pam/Api/Models/Response/CipherAccessStateResponseModel.cs index 1d002a77ec15..8e3e124b2bae 100644 --- a/src/Api/Pam/Models/Response/CipherAccessStateResponseModel.cs +++ b/bitwarden_license/src/Commercial.Pam/Api/Models/Response/CipherAccessStateResponseModel.cs @@ -1,7 +1,7 @@ using Bit.Commercial.Pam.Models; using Bit.Core.Models.Api; -namespace Bit.Api.Pam.Models.Response; +namespace Bit.Commercial.Pam.Api.Models.Response; /// /// A single-snapshot read of the caller's access state for one cipher, powering the cipher-view banner and the diff --git a/src/Api/Pam/Models/Response/PamDateTimeExtensions.cs b/bitwarden_license/src/Commercial.Pam/Api/Models/Response/PamDateTimeExtensions.cs similarity index 96% rename from src/Api/Pam/Models/Response/PamDateTimeExtensions.cs rename to bitwarden_license/src/Commercial.Pam/Api/Models/Response/PamDateTimeExtensions.cs index 07e7bf4233a2..d170b50902cd 100644 --- a/src/Api/Pam/Models/Response/PamDateTimeExtensions.cs +++ b/bitwarden_license/src/Commercial.Pam/Api/Models/Response/PamDateTimeExtensions.cs @@ -1,4 +1,4 @@ -namespace Bit.Api.Pam.Models.Response; +namespace Bit.Commercial.Pam.Api.Models.Response; /// /// Marks PAM response timestamps as UTC for serialization. diff --git a/bitwarden_license/src/Commercial.Pam/Api/PamApiServiceCollectionExtensions.cs b/bitwarden_license/src/Commercial.Pam/Api/PamApiServiceCollectionExtensions.cs new file mode 100644 index 000000000000..fd4376aa8dc1 --- /dev/null +++ b/bitwarden_license/src/Commercial.Pam/Api/PamApiServiceCollectionExtensions.cs @@ -0,0 +1,19 @@ +using Bit.Commercial.Pam.Api.Endpoints.Handlers; + +namespace Bit.Commercial.Pam.Api; + +public static class PamApiServiceCollectionExtensions +{ + /// + /// Registers the PAM Minimal API endpoint handlers. The endpoints (see PamEndpointsExtensions) resolve + /// these from DI. PAM is a commercial feature, so this is only wired in non-OSS builds. + /// + public static IServiceCollection AddPamApiServices(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + return services; + } +} diff --git a/bitwarden_license/src/Commercial.Pam/Commercial.Pam.csproj b/bitwarden_license/src/Commercial.Pam/Commercial.Pam.csproj index 26f86c7d10be..53fe2115f558 100644 --- a/bitwarden_license/src/Commercial.Pam/Commercial.Pam.csproj +++ b/bitwarden_license/src/Commercial.Pam/Commercial.Pam.csproj @@ -4,9 +4,26 @@ Bit.Commercial.Pam + + + + + + + + + + + + + + + + diff --git a/bitwarden_license/test/Commercial.Pam.Test/Api/Controllers/CipherLeaseControllerTests.cs b/bitwarden_license/test/Commercial.Pam.Test/Api/Controllers/CipherLeaseControllerTests.cs index fca64b5b3147..d8f6de5dffb8 100644 --- a/bitwarden_license/test/Commercial.Pam.Test/Api/Controllers/CipherLeaseControllerTests.cs +++ b/bitwarden_license/test/Commercial.Pam.Test/Api/Controllers/CipherLeaseControllerTests.cs @@ -17,31 +17,14 @@ namespace Bit.Commercial.Pam.Test.Api.Controllers; +// The deprecated GET /ciphers/{id}/lease/cipher endpoint stays in the Api project (it depends on the Vault +// response models), so it is tested here as an MVC controller. The rest of the cipher-lease resource lives in +// the Commercial.Pam Minimal API handler (see CipherLeaseEndpointsHandlerTests). [ControllerCustomize(typeof(CipherLeaseController))] [SutProviderCustomize] [Bit.Api.Test.Vault.AutoFixture.CipherLeaseGateBypassCustomize] public class CipherLeaseControllerTests { - [Theory, BitAutoData] - public async Task State_ReturnsSnapshotFromQuery( - Guid id, Guid userId, Bit.Pam.Entities.AccessLease activeLease, SutProvider sutProvider) - { - sutProvider.GetDependency() - .GetProperUserId(Arg.Any()) - .Returns(userId); - sutProvider.GetDependency() - .GetStateAsync(userId, id) - .Returns(new Bit.Commercial.Pam.Models.CipherAccessState(id, activeLease, null, null)); - - var result = await sutProvider.Sut.State(id); - - Assert.Equal(id, result.CipherId); - Assert.NotNull(result.ActiveLease); - Assert.Equal(activeLease.Id, result.ActiveLease!.Id); - Assert.Null(result.PendingRequest); - Assert.Null(result.ApprovedRequest); - } - // GET /ciphers/{id}/lease/cipher is [Obsolete] (deprecated, scheduled for removal) but still fully functional, so // its behaviour stays under test; suppress the obsolete-usage warning for these deliberate calls. #pragma warning disable CS0618 diff --git a/bitwarden_license/test/Commercial.Pam.Test/Api/Controllers/AccessRequestsControllerTests.cs b/bitwarden_license/test/Commercial.Pam.Test/Api/Endpoints/AccessRequestEndpointsHandlerTests.cs similarity index 70% rename from bitwarden_license/test/Commercial.Pam.Test/Api/Controllers/AccessRequestsControllerTests.cs rename to bitwarden_license/test/Commercial.Pam.Test/Api/Endpoints/AccessRequestEndpointsHandlerTests.cs index 2c19545b7427..692c3ee7bc61 100644 --- a/bitwarden_license/test/Commercial.Pam.Test/Api/Controllers/AccessRequestsControllerTests.cs +++ b/bitwarden_license/test/Commercial.Pam.Test/Api/Endpoints/AccessRequestEndpointsHandlerTests.cs @@ -1,7 +1,7 @@ -using System.Security.Claims; -using Bit.Api.Pam.Controllers; -using Bit.Api.Pam.Models.Request; -using Bit.Api.Pam.Models.Response; +using System.Security.Claims; +using Bit.Commercial.Pam.Api.Endpoints.Handlers; +using Bit.Commercial.Pam.Api.Models.Request; +using Bit.Commercial.Pam.Api.Models.Response; using Bit.Commercial.Pam.Models; using Bit.Commercial.Pam.OrganizationFeatures.Commands.Interfaces; using Bit.Commercial.Pam.OrganizationFeatures.Queries.Interfaces; @@ -11,25 +11,25 @@ using Bit.Pam.Models; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; -using Microsoft.AspNetCore.Mvc; using NSubstitute; using Xunit; -namespace Bit.Commercial.Pam.Test.Api.Controllers; +namespace Bit.Commercial.Pam.Test.Api.Endpoints; -[ControllerCustomize(typeof(AccessRequestsController))] [SutProviderCustomize] -public class AccessRequestsControllerTests +public class AccessRequestEndpointsHandlerTests { + private static readonly ClaimsPrincipal _user = new(); + [Theory, BitAutoData] public async Task GetInbox_ReturnsMappedPendingRows( - Guid userId, AccessRequestDetails row, SutProvider sutProvider) + Guid userId, AccessRequestDetails row, SutProvider sutProvider) { SetupUser(sutProvider, userId); row.Status = AccessRequestStatus.Pending; sutProvider.GetDependency().GetPendingAsync(userId).Returns([row]); - var result = await sutProvider.Sut.GetInbox(); + var result = await sutProvider.Sut.GetInbox(_user); Assert.Single(result.Data); Assert.Equal(row.Id, result.Data.First().Id); @@ -37,26 +37,26 @@ public async Task GetInbox_ReturnsMappedPendingRows( [Theory, BitAutoData] public async Task GetHistory_ReturnsMappedHistoryRows( - Guid userId, AccessRequestDetails row, SutProvider sutProvider) + Guid userId, AccessRequestDetails row, SutProvider sutProvider) { SetupUser(sutProvider, userId); row.Status = AccessRequestStatus.Approved; sutProvider.GetDependency().GetHistoryAsync(userId).Returns([row]); - var result = await sutProvider.Sut.GetHistory(); + var result = await sutProvider.Sut.GetHistory(_user); Assert.Single(result.Data); } [Theory, BitAutoData] public async Task GetMine_ReturnsMappedRows( - Guid userId, AccessRequestDetails row, SutProvider sutProvider) + Guid userId, AccessRequestDetails row, SutProvider sutProvider) { SetupUser(sutProvider, userId); row.Status = AccessRequestStatus.Pending; sutProvider.GetDependency().GetMineAsync(userId).Returns([row]); - var result = (await sutProvider.Sut.GetMine()).Data.ToList(); + var result = (await sutProvider.Sut.GetMine(_user)).Data.ToList(); Assert.Single(result); Assert.Equal(row.Id, result[0].Id); @@ -65,19 +65,19 @@ public async Task GetMine_ReturnsMappedRows( [Theory, BitAutoData] public async Task GetMine_NoRows_ReturnsEmpty( - Guid userId, SutProvider sutProvider) + Guid userId, SutProvider sutProvider) { SetupUser(sutProvider, userId); sutProvider.GetDependency().GetMineAsync(userId).Returns([]); - var result = await sutProvider.Sut.GetMine(); + var result = await sutProvider.Sut.GetMine(_user); Assert.Empty(result.Data); } [Theory, BitAutoData] public async Task Decide_ReturnsUpdatedRow( - Guid userId, Guid requestId, AccessRequestDetails updated, SutProvider sutProvider) + Guid userId, Guid requestId, AccessRequestDetails updated, SutProvider sutProvider) { SetupUser(sutProvider, userId); updated.Status = AccessRequestStatus.Approved; @@ -86,7 +86,7 @@ public async Task Decide_ReturnsUpdatedRow( .DecideAsync(userId, requestId, Arg.Any()) .Returns(updated); - var result = await sutProvider.Sut.Decide(requestId, new AccessDecisionRequestModel { Verdict = AccessDecisionVerdict.Approve }); + var result = await sutProvider.Sut.Decide(_user, requestId, new AccessDecisionRequestModel { Verdict = AccessDecisionVerdict.Approve }); Assert.Equal(updated.Id, result.Id); Assert.Equal(AccessRequestStatusNames.Approved, result.Status); @@ -94,7 +94,7 @@ public async Task Decide_ReturnsUpdatedRow( [Theory, BitAutoData] public async Task Activate_ReturnsMintedLease( - Guid userId, Guid requestId, AccessLease lease, SutProvider sutProvider) + Guid userId, Guid requestId, AccessLease lease, SutProvider sutProvider) { SetupUser(sutProvider, userId); lease.Status = AccessLeaseStatus.Active; @@ -102,25 +102,24 @@ public async Task Activate_ReturnsMintedLease( .ActivateAsync(userId, requestId) .Returns(lease); - var result = await sutProvider.Sut.Activate(requestId); + var result = await sutProvider.Sut.Activate(_user, requestId); Assert.Equal(lease.Id, result.Id); Assert.Equal(AccessLeaseStatusNames.Active, result.Status); } [Theory, BitAutoData] - public async Task Revoke_RevokesCallersRequest_ReturnsNoContent( - Guid userId, Guid requestId, SutProvider sutProvider) + public async Task Revoke_InvokesCancelCommand( + Guid userId, Guid requestId, SutProvider sutProvider) { SetupUser(sutProvider, userId); - var result = await sutProvider.Sut.Revoke(requestId); + await sutProvider.Sut.Revoke(_user, requestId); - Assert.IsType(result); await sutProvider.GetDependency().Received(1).CancelAsync(userId, requestId); } - private static void SetupUser(SutProvider sutProvider, Guid userId) + private static void SetupUser(SutProvider sutProvider, Guid userId) { sutProvider.GetDependency() .GetProperUserId(Arg.Any()) diff --git a/bitwarden_license/test/Commercial.Pam.Test/Api/Endpoints/CipherLeaseEndpointsHandlerTests.cs b/bitwarden_license/test/Commercial.Pam.Test/Api/Endpoints/CipherLeaseEndpointsHandlerTests.cs new file mode 100644 index 000000000000..4a31a96ebaf6 --- /dev/null +++ b/bitwarden_license/test/Commercial.Pam.Test/Api/Endpoints/CipherLeaseEndpointsHandlerTests.cs @@ -0,0 +1,36 @@ +using System.Security.Claims; +using Bit.Commercial.Pam.Api.Endpoints.Handlers; +using Bit.Commercial.Pam.OrganizationFeatures.Queries.Interfaces; +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Commercial.Pam.Test.Api.Endpoints; + +[SutProviderCustomize] +public class CipherLeaseEndpointsHandlerTests +{ + private static readonly ClaimsPrincipal _user = new(); + + [Theory, BitAutoData] + public async Task State_ReturnsSnapshotFromQuery( + Guid id, Guid userId, Bit.Pam.Entities.AccessLease activeLease, SutProvider sutProvider) + { + sutProvider.GetDependency() + .GetProperUserId(Arg.Any()) + .Returns(userId); + sutProvider.GetDependency() + .GetStateAsync(userId, id) + .Returns(new Bit.Commercial.Pam.Models.CipherAccessState(id, activeLease, null, null)); + + var result = await sutProvider.Sut.State(_user, id); + + Assert.Equal(id, result.CipherId); + Assert.NotNull(result.ActiveLease); + Assert.Equal(activeLease.Id, result.ActiveLease!.Id); + Assert.Null(result.PendingRequest); + Assert.Null(result.ApprovedRequest); + } +} diff --git a/bitwarden_license/test/Commercial.Pam.Test/Api/Endpoints/Filters/PamExceptionHandlerEndpointFilterTests.cs b/bitwarden_license/test/Commercial.Pam.Test/Api/Endpoints/Filters/PamExceptionHandlerEndpointFilterTests.cs new file mode 100644 index 000000000000..26e2c7a46180 --- /dev/null +++ b/bitwarden_license/test/Commercial.Pam.Test/Api/Endpoints/Filters/PamExceptionHandlerEndpointFilterTests.cs @@ -0,0 +1,90 @@ +using Bit.Commercial.Pam.Api.Endpoints.Filters; +using Bit.Core.Exceptions; +using Bit.Core.Models.Api; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using Xunit; + +namespace Bit.Commercial.Pam.Test.Api.Endpoints.Filters; + +public class PamExceptionHandlerEndpointFilterTests +{ + [Fact] + public async Task InvokeAsync_NoException_PassesResultThrough() + { + var context = CreateContext(); + EndpointFilterDelegate next = _ => ValueTask.FromResult("ok"); + + var result = await new PamExceptionHandlerEndpointFilter().InvokeAsync(context, next); + + Assert.Equal("ok", result); + } + + [Fact] + public async Task InvokeAsync_NotFoundException_Returns404ErrorResponseModel() + { + var context = CreateContext(); + EndpointFilterDelegate next = _ => throw new NotFoundException(); + + var result = await new PamExceptionHandlerEndpointFilter().InvokeAsync(context, next); + + var jsonResult = Assert.IsType>(result); + Assert.Equal(StatusCodes.Status404NotFound, jsonResult.StatusCode); + Assert.Equal("Resource not found.", jsonResult.Value!.Message); + } + + [Fact] + public async Task InvokeAsync_FeatureUnavailableException_Returns404() + { + // FeatureUnavailableException derives from NotFoundException, so the feature gate maps to 404 like the rest. + var context = CreateContext(); + EndpointFilterDelegate next = _ => throw new FeatureUnavailableException(); + + var result = await new PamExceptionHandlerEndpointFilter().InvokeAsync(context, next); + + var jsonResult = Assert.IsType>(result); + Assert.Equal(StatusCodes.Status404NotFound, jsonResult.StatusCode); + } + + [Fact] + public async Task InvokeAsync_BadRequestExceptionWithModelState_Returns400WithValidationErrors() + { + var modelState = new ModelStateDictionary(); + modelState.AddModelError("Name", "Required."); + var context = CreateContext(); + EndpointFilterDelegate next = _ => throw new BadRequestException(modelState); + + var result = await new PamExceptionHandlerEndpointFilter().InvokeAsync(context, next); + + var jsonResult = Assert.IsType>(result); + Assert.Equal(StatusCodes.Status400BadRequest, jsonResult.StatusCode); + Assert.True(jsonResult.Value!.ValidationErrors!.ContainsKey("Name")); + } + + [Fact] + public async Task InvokeAsync_UnhandledException_Returns500() + { + var context = CreateContext(); + EndpointFilterDelegate next = _ => throw new InvalidOperationException("boom"); + + var result = await new PamExceptionHandlerEndpointFilter().InvokeAsync(context, next); + + var jsonResult = Assert.IsType>(result); + Assert.Equal(StatusCodes.Status500InternalServerError, jsonResult.StatusCode); + } + + private static EndpointFilterInvocationContext CreateContext() + { + var environment = Substitute.For(); + environment.EnvironmentName.Returns("Production"); + var services = new ServiceCollection(); + services.AddSingleton(environment); + services.AddLogging(); + var httpContext = new DefaultHttpContext { RequestServices = services.BuildServiceProvider() }; + return EndpointFilterInvocationContext.Create(httpContext); + } +} diff --git a/bitwarden_license/test/Commercial.Pam.Test/Api/Endpoints/Filters/PamValidationEndpointFilterTests.cs b/bitwarden_license/test/Commercial.Pam.Test/Api/Endpoints/Filters/PamValidationEndpointFilterTests.cs new file mode 100644 index 000000000000..62c16934109a --- /dev/null +++ b/bitwarden_license/test/Commercial.Pam.Test/Api/Endpoints/Filters/PamValidationEndpointFilterTests.cs @@ -0,0 +1,73 @@ +using Bit.Commercial.Pam.Api.Endpoints.Filters; +using Bit.Commercial.Pam.Api.Models.Request; +using Bit.Core.Models.Api; +using Bit.Pam.Enums; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using Xunit; + +namespace Bit.Commercial.Pam.Test.Api.Endpoints.Filters; + +public class PamValidationEndpointFilterTests +{ + [Fact] + public async Task InvokeAsync_InvalidRequestModel_ReturnsErrorResponseModel400AndSkipsNext() + { + // Verdict is [Required] and left null -> invalid. + var context = CreateContext(new AccessDecisionRequestModel()); + var nextCalled = false; + EndpointFilterDelegate next = _ => + { + nextCalled = true; + return ValueTask.FromResult("ok"); + }; + + var result = await new PamValidationEndpointFilter().InvokeAsync(context, next); + + Assert.False(nextCalled); + var jsonResult = Assert.IsType>(result); + Assert.Equal(StatusCodes.Status400BadRequest, jsonResult.StatusCode); + Assert.Equal("The model state is invalid.", jsonResult.Value!.Message); + Assert.True(jsonResult.Value.ValidationErrors!.ContainsKey(nameof(AccessDecisionRequestModel.Verdict))); + } + + [Fact] + public async Task InvokeAsync_ValidRequestModel_CallsNext() + { + var context = CreateContext(new AccessDecisionRequestModel { Verdict = AccessDecisionVerdict.Approve }); + var nextCalled = false; + EndpointFilterDelegate next = _ => + { + nextCalled = true; + return ValueTask.FromResult("ok"); + }; + + var result = await new PamValidationEndpointFilter().InvokeAsync(context, next); + + Assert.True(nextCalled); + Assert.Equal("ok", result); + } + + [Fact] + public async Task InvokeAsync_NonRequestModelArguments_AreIgnored() + { + // Route/service-style arguments (a Guid, a string) are not request models and must not be validated. + var context = CreateContext(Guid.NewGuid(), "not-a-model"); + var nextCalled = false; + EndpointFilterDelegate next = _ => + { + nextCalled = true; + return ValueTask.FromResult("ok"); + }; + + var result = await new PamValidationEndpointFilter().InvokeAsync(context, next); + + Assert.True(nextCalled); + Assert.Equal("ok", result); + } + + // Use DefaultEndpointFilterInvocationContext's params constructor rather than the static Create(...), whose + // generic overload would treat a passed object[] as one argument instead of spreading it. + private static EndpointFilterInvocationContext CreateContext(params object[] arguments) => + new DefaultEndpointFilterInvocationContext(new DefaultHttpContext(), arguments); +} diff --git a/bitwarden_license/test/Commercial.Pam.Test/Api/Endpoints/Filters/RequireFeatureEndpointFilterTests.cs b/bitwarden_license/test/Commercial.Pam.Test/Api/Endpoints/Filters/RequireFeatureEndpointFilterTests.cs new file mode 100644 index 000000000000..0d6adf788bc3 --- /dev/null +++ b/bitwarden_license/test/Commercial.Pam.Test/Api/Endpoints/Filters/RequireFeatureEndpointFilterTests.cs @@ -0,0 +1,59 @@ +using Bit.Commercial.Pam.Api.Endpoints.Filters; +using Bit.Core.Exceptions; +using Bit.Core.Services; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using Xunit; + +namespace Bit.Commercial.Pam.Test.Api.Endpoints.Filters; + +public class RequireFeatureEndpointFilterTests +{ + private const string Flag = "pm-test-flag"; + + [Fact] + public async Task InvokeAsync_FeatureEnabled_CallsNext() + { + var featureService = Substitute.For(); + featureService.IsEnabled(Flag).Returns(true); + var context = CreateContext(featureService); + var nextCalled = false; + EndpointFilterDelegate next = _ => + { + nextCalled = true; + return ValueTask.FromResult("ok"); + }; + + var result = await new RequireFeatureEndpointFilter(Flag).InvokeAsync(context, next); + + Assert.True(nextCalled); + Assert.Equal("ok", result); + } + + [Fact] + public async Task InvokeAsync_FeatureDisabled_ThrowsFeatureUnavailableAndSkipsNext() + { + var featureService = Substitute.For(); + featureService.IsEnabled(Flag).Returns(false); + var context = CreateContext(featureService); + var nextCalled = false; + EndpointFilterDelegate next = _ => + { + nextCalled = true; + return ValueTask.FromResult("ok"); + }; + + await Assert.ThrowsAsync( + async () => await new RequireFeatureEndpointFilter(Flag).InvokeAsync(context, next)); + Assert.False(nextCalled); + } + + private static EndpointFilterInvocationContext CreateContext(IFeatureService featureService) + { + var services = new ServiceCollection(); + services.AddSingleton(featureService); + var httpContext = new DefaultHttpContext { RequestServices = services.BuildServiceProvider() }; + return EndpointFilterInvocationContext.Create(httpContext); + } +} diff --git a/bitwarden_license/test/Commercial.Pam.Test/Api/Controllers/LeasesControllerTests.cs b/bitwarden_license/test/Commercial.Pam.Test/Api/Endpoints/LeaseEndpointsHandlerTests.cs similarity index 70% rename from bitwarden_license/test/Commercial.Pam.Test/Api/Controllers/LeasesControllerTests.cs rename to bitwarden_license/test/Commercial.Pam.Test/Api/Endpoints/LeaseEndpointsHandlerTests.cs index dbf871b95abb..476de4a9ce6f 100644 --- a/bitwarden_license/test/Commercial.Pam.Test/Api/Controllers/LeasesControllerTests.cs +++ b/bitwarden_license/test/Commercial.Pam.Test/Api/Endpoints/LeaseEndpointsHandlerTests.cs @@ -1,7 +1,7 @@ -using System.Security.Claims; -using Bit.Api.Pam.Controllers; -using Bit.Api.Pam.Models.Request; -using Bit.Api.Pam.Models.Response; +using System.Security.Claims; +using Bit.Commercial.Pam.Api.Endpoints.Handlers; +using Bit.Commercial.Pam.Api.Models.Request; +using Bit.Commercial.Pam.Api.Models.Response; using Bit.Commercial.Pam.Models; using Bit.Commercial.Pam.OrganizationFeatures.Commands.Interfaces; using Bit.Commercial.Pam.OrganizationFeatures.Queries.Interfaces; @@ -11,25 +11,25 @@ using Bit.Pam.Models; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; -using Microsoft.AspNetCore.Mvc; using NSubstitute; using Xunit; -namespace Bit.Commercial.Pam.Test.Api.Controllers; +namespace Bit.Commercial.Pam.Test.Api.Endpoints; -[ControllerCustomize(typeof(LeasesController))] [SutProviderCustomize] -public class LeasesControllerTests +public class LeaseEndpointsHandlerTests { + private static readonly ClaimsPrincipal _user = new(); + [Theory, BitAutoData] public async Task GetActive_ReturnsMappedLeases( - Guid userId, AccessLease lease, SutProvider sutProvider) + Guid userId, AccessLease lease, SutProvider sutProvider) { SetupUser(sutProvider, userId); lease.Status = AccessLeaseStatus.Active; sutProvider.GetDependency().GetActiveAsync(userId).Returns([lease]); - var result = (await sutProvider.Sut.GetActive()).Data.ToList(); + var result = (await sutProvider.Sut.GetActive(_user)).Data.ToList(); Assert.Single(result); Assert.Equal(lease.Id, result[0].Id); @@ -38,25 +38,25 @@ public async Task GetActive_ReturnsMappedLeases( [Theory, BitAutoData] public async Task GetActive_NoLeases_ReturnsEmpty( - Guid userId, SutProvider sutProvider) + Guid userId, SutProvider sutProvider) { SetupUser(sutProvider, userId); sutProvider.GetDependency().GetActiveAsync(userId).Returns([]); - var result = await sutProvider.Sut.GetActive(); + var result = await sutProvider.Sut.GetActive(_user); Assert.Empty(result.Data); } [Theory, BitAutoData] public async Task GetHistory_ReturnsMappedLeases( - Guid userId, AccessLease lease, SutProvider sutProvider) + Guid userId, AccessLease lease, SutProvider sutProvider) { SetupUser(sutProvider, userId); lease.Status = AccessLeaseStatus.Revoked; sutProvider.GetDependency().GetHistoryAsync(userId).Returns([lease]); - var result = (await sutProvider.Sut.GetHistory()).Data.ToList(); + var result = (await sutProvider.Sut.GetHistory(_user)).Data.ToList(); Assert.Single(result); Assert.Equal(lease.Id, result[0].Id); @@ -65,13 +65,13 @@ public async Task GetHistory_ReturnsMappedLeases( [Theory, BitAutoData] public async Task GetMine_ReturnsMappedLeases( - Guid userId, AccessLease lease, SutProvider sutProvider) + Guid userId, AccessLease lease, SutProvider sutProvider) { SetupUser(sutProvider, userId); lease.Status = AccessLeaseStatus.Active; sutProvider.GetDependency().GetMineActiveAsync(userId).Returns([lease]); - var result = (await sutProvider.Sut.GetMine()).Data.ToList(); + var result = (await sutProvider.Sut.GetMine(_user)).Data.ToList(); Assert.Single(result); Assert.Equal(lease.Id, result[0].Id); @@ -79,21 +79,20 @@ public async Task GetMine_ReturnsMappedLeases( } [Theory, BitAutoData] - public async Task Revoke_ReturnsNoContent( - Guid userId, Guid leaseId, SutProvider sutProvider) + public async Task Revoke_InvokesRevokeCommand( + Guid userId, Guid leaseId, SutProvider sutProvider) { SetupUser(sutProvider, userId); - var result = await sutProvider.Sut.Revoke(leaseId, new AccessLeaseRevokeRequestModel { Reason = "policy" }); + await sutProvider.Sut.Revoke(_user, leaseId, new AccessLeaseRevokeRequestModel { Reason = "policy" }); - Assert.IsType(result); await sutProvider.GetDependency().Received(1).RevokeAsync(userId, leaseId, "policy"); } [Theory, BitAutoData] public async Task Extend_ForwardsRouteLeaseId_ReturnsApprovedExtensionDetails( Guid userId, Guid leaseId, AccessLeaseExtensionRequestModel model, AccessRequestDetails details, - SutProvider sutProvider) + SutProvider sutProvider) { SetupUser(sutProvider, userId); details.Status = AccessRequestStatus.Approved; @@ -102,7 +101,7 @@ public async Task Extend_ForwardsRouteLeaseId_ReturnsApprovedExtensionDetails( .ExtendAsync(userId, Arg.Any()) .Returns(details); - var result = await sutProvider.Sut.Extend(leaseId, model); + var result = await sutProvider.Sut.Extend(_user, leaseId, model); Assert.Equal(details.Id, result.Id); Assert.Equal(AccessRequestStatusNames.Approved, result.Status); @@ -113,7 +112,7 @@ await sutProvider.GetDependency().Received(1).Ext s.LeaseId == leaseId && s.DurationSeconds == model.DurationSeconds && s.Reason == model.Reason)); } - private static void SetupUser(SutProvider sutProvider, Guid userId) + private static void SetupUser(SutProvider sutProvider, Guid userId) { sutProvider.GetDependency() .GetProperUserId(Arg.Any()) diff --git a/bitwarden_license/test/Commercial.Pam.Test/Api/Models/AccessDecisionRequestModelTests.cs b/bitwarden_license/test/Commercial.Pam.Test/Api/Models/AccessDecisionRequestModelTests.cs index 6dd285da73b4..d21f371a1517 100644 --- a/bitwarden_license/test/Commercial.Pam.Test/Api/Models/AccessDecisionRequestModelTests.cs +++ b/bitwarden_license/test/Commercial.Pam.Test/Api/Models/AccessDecisionRequestModelTests.cs @@ -1,6 +1,6 @@ using System.ComponentModel.DataAnnotations; using System.Text.Json; -using Bit.Api.Pam.Models.Request; +using Bit.Commercial.Pam.Api.Models.Request; using Bit.Pam.Enums; using Xunit; diff --git a/bitwarden_license/test/Commercial.Pam.Test/Api/Models/AccessLeaseResponseModelTests.cs b/bitwarden_license/test/Commercial.Pam.Test/Api/Models/AccessLeaseResponseModelTests.cs index 53eec12eaec7..aeb27124278b 100644 --- a/bitwarden_license/test/Commercial.Pam.Test/Api/Models/AccessLeaseResponseModelTests.cs +++ b/bitwarden_license/test/Commercial.Pam.Test/Api/Models/AccessLeaseResponseModelTests.cs @@ -1,4 +1,4 @@ -using Bit.Api.Pam.Models.Response; +using Bit.Commercial.Pam.Api.Models.Response; using Bit.Pam.Entities; using Bit.Pam.Enums; using Bit.Test.Common.AutoFixture.Attributes; diff --git a/bitwarden_license/test/Commercial.Pam.Test/Api/Models/AccessLeaseStatusNamesTests.cs b/bitwarden_license/test/Commercial.Pam.Test/Api/Models/AccessLeaseStatusNamesTests.cs index 1545847af7ac..4d7d62acb799 100644 --- a/bitwarden_license/test/Commercial.Pam.Test/Api/Models/AccessLeaseStatusNamesTests.cs +++ b/bitwarden_license/test/Commercial.Pam.Test/Api/Models/AccessLeaseStatusNamesTests.cs @@ -1,4 +1,4 @@ -using Bit.Api.Pam.Models.Response; +using Bit.Commercial.Pam.Api.Models.Response; using Bit.Pam.Enums; using Xunit; diff --git a/bitwarden_license/test/Commercial.Pam.Test/Api/Models/AccessRequestDetailsResponseModelTests.cs b/bitwarden_license/test/Commercial.Pam.Test/Api/Models/AccessRequestDetailsResponseModelTests.cs index 231b24644b31..387c2eee7a17 100644 --- a/bitwarden_license/test/Commercial.Pam.Test/Api/Models/AccessRequestDetailsResponseModelTests.cs +++ b/bitwarden_license/test/Commercial.Pam.Test/Api/Models/AccessRequestDetailsResponseModelTests.cs @@ -1,4 +1,4 @@ -using Bit.Api.Pam.Models.Response; +using Bit.Commercial.Pam.Api.Models.Response; using Bit.Pam.Enums; using Bit.Pam.Models; using Xunit; diff --git a/bitwarden_license/test/Commercial.Pam.Test/Api/Models/AccessRequestStatusNamesTests.cs b/bitwarden_license/test/Commercial.Pam.Test/Api/Models/AccessRequestStatusNamesTests.cs index cbe8c1c35436..aa0472e07bdb 100644 --- a/bitwarden_license/test/Commercial.Pam.Test/Api/Models/AccessRequestStatusNamesTests.cs +++ b/bitwarden_license/test/Commercial.Pam.Test/Api/Models/AccessRequestStatusNamesTests.cs @@ -1,4 +1,4 @@ -using Bit.Api.Pam.Models.Response; +using Bit.Commercial.Pam.Api.Models.Response; using Bit.Pam.Enums; using Xunit; diff --git a/src/Api/AdminConsole/Controllers/CollectionsController.cs b/src/Api/AdminConsole/Controllers/CollectionsController.cs index 873845dd4378..59da568e6273 100644 --- a/src/Api/AdminConsole/Controllers/CollectionsController.cs +++ b/src/Api/AdminConsole/Controllers/CollectionsController.cs @@ -14,6 +14,7 @@ using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Utilities; +using Bit.SharedWeb.Models.Response; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; diff --git a/src/Api/AdminConsole/Controllers/GroupsController.cs b/src/Api/AdminConsole/Controllers/GroupsController.cs index bf7551817fb7..14936d9353d6 100644 --- a/src/Api/AdminConsole/Controllers/GroupsController.cs +++ b/src/Api/AdminConsole/Controllers/GroupsController.cs @@ -6,7 +6,6 @@ using Bit.Api.AdminConsole.Authorization.Requirements; using Bit.Api.AdminConsole.Models.Request; using Bit.Api.AdminConsole.Models.Response; -using Bit.Api.Models.Response; using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; @@ -14,6 +13,7 @@ using Bit.Core.Exceptions; using Bit.Core.Repositories; using Bit.Core.Services; +using Bit.SharedWeb.Models.Response; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; diff --git a/src/Api/AdminConsole/Controllers/OrganizationAuthRequestsController.cs b/src/Api/AdminConsole/Controllers/OrganizationAuthRequestsController.cs index 915a17ff54f1..bd17609e5b3f 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationAuthRequestsController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationAuthRequestsController.cs @@ -1,12 +1,12 @@ using Bit.Api.AdminConsole.Models.Request; using Bit.Api.AdminConsole.Models.Response; -using Bit.Api.Models.Response; using Bit.Core.AdminConsole.OrganizationAuth.Interfaces; using Bit.Core.Auth.Models.Api.Request.AuthRequest; using Bit.Core.Auth.Services; using Bit.Core.Context; using Bit.Core.Exceptions; using Bit.Core.Repositories; +using Bit.SharedWeb.Models.Response; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; diff --git a/src/Api/AdminConsole/Controllers/OrganizationDomainController.cs b/src/Api/AdminConsole/Controllers/OrganizationDomainController.cs index 534e27121cdc..26fc75a616ab 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationDomainController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationDomainController.cs @@ -1,12 +1,12 @@ using Bit.Api.AdminConsole.Models.Request; using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Api.AdminConsole.Models.Response.Organizations; -using Bit.Api.Models.Response; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Exceptions; using Bit.Core.Repositories; +using Bit.SharedWeb.Models.Response; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; diff --git a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs index e604a22636d1..9a4963d4280e 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs @@ -8,7 +8,6 @@ using Bit.Api.AdminConsole.Authorization.Requirements; using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Api.AdminConsole.Models.Response.Organizations; -using Bit.Api.Models.Response; using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Models.Data; @@ -40,6 +39,7 @@ using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Utilities; +using Bit.SharedWeb.Models.Response; using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests; using Microsoft.AspNetCore.Authorization; diff --git a/src/Api/AdminConsole/Controllers/OrganizationsController.cs b/src/Api/AdminConsole/Controllers/OrganizationsController.cs index 96bce7cea609..004cddb6f8f7 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationsController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationsController.cs @@ -34,6 +34,7 @@ using Bit.Core.Settings; using Bit.Core.Tokens; using Bit.Core.Utilities; +using Bit.SharedWeb.Models.Response; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; diff --git a/src/Api/AdminConsole/Controllers/PoliciesController.cs b/src/Api/AdminConsole/Controllers/PoliciesController.cs index 8f0e4e8952f7..d5049af697ac 100644 --- a/src/Api/AdminConsole/Controllers/PoliciesController.cs +++ b/src/Api/AdminConsole/Controllers/PoliciesController.cs @@ -6,7 +6,6 @@ using Bit.Api.AdminConsole.Models.Request; using Bit.Api.AdminConsole.Models.Response.Helpers; using Bit.Api.AdminConsole.Models.Response.Organizations; -using Bit.Api.Models.Response; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; @@ -16,6 +15,7 @@ using Bit.Core.Exceptions; using Bit.Core.Repositories; using Bit.Core.Tokens; +using Bit.SharedWeb.Models.Response; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; diff --git a/src/Api/AdminConsole/Controllers/ProviderOrganizationsController.cs b/src/Api/AdminConsole/Controllers/ProviderOrganizationsController.cs index f18b2916e718..81204684fed6 100644 --- a/src/Api/AdminConsole/Controllers/ProviderOrganizationsController.cs +++ b/src/Api/AdminConsole/Controllers/ProviderOrganizationsController.cs @@ -5,7 +5,6 @@ using Bit.Api.AdminConsole.Authorization.Providers.Requirements; using Bit.Api.AdminConsole.Models.Request.Providers; using Bit.Api.AdminConsole.Models.Response.Providers; -using Bit.Api.Models.Response; using Bit.Core.AdminConsole.Providers.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; @@ -14,6 +13,7 @@ using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Utilities; +using Bit.SharedWeb.Models.Response; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; diff --git a/src/Api/AdminConsole/Controllers/ProviderUsersController.cs b/src/Api/AdminConsole/Controllers/ProviderUsersController.cs index 7746b5cf246f..cbc1c72af9c0 100644 --- a/src/Api/AdminConsole/Controllers/ProviderUsersController.cs +++ b/src/Api/AdminConsole/Controllers/ProviderUsersController.cs @@ -5,12 +5,12 @@ using Bit.Api.AdminConsole.Authorization.Providers.Requirements; using Bit.Api.AdminConsole.Models.Request.Providers; using Bit.Api.AdminConsole.Models.Response.Providers; -using Bit.Api.Models.Response; using Bit.Core.AdminConsole.Models.Business.Provider; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; using Bit.Core.Exceptions; using Bit.Core.Services; +using Bit.SharedWeb.Models.Response; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; diff --git a/src/Api/AdminConsole/Models/Response/Organizations/VerifiedOrganizationDomainSsoDetailsResponseModel.cs b/src/Api/AdminConsole/Models/Response/Organizations/VerifiedOrganizationDomainSsoDetailsResponseModel.cs index 178060d9b147..3fd5d343b7d5 100644 --- a/src/Api/AdminConsole/Models/Response/Organizations/VerifiedOrganizationDomainSsoDetailsResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/Organizations/VerifiedOrganizationDomainSsoDetailsResponseModel.cs @@ -1,7 +1,7 @@ // FIXME: Update this file to be null safe and then delete the line below #nullable disable -using Bit.Api.Models.Response; +using Bit.SharedWeb.Models.Response; namespace Bit.Api.AdminConsole.Models.Response.Organizations; diff --git a/src/Api/Auth/Controllers/AccountsController.cs b/src/Api/Auth/Controllers/AccountsController.cs index 95b83a8f259c..f38c825be5b9 100644 --- a/src/Api/Auth/Controllers/AccountsController.cs +++ b/src/Api/Auth/Controllers/AccountsController.cs @@ -28,6 +28,7 @@ using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Utilities; +using Bit.SharedWeb.Models.Response; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; diff --git a/src/Api/Auth/Controllers/AuthRequestsController.cs b/src/Api/Auth/Controllers/AuthRequestsController.cs index 7e7169463bf7..635076730002 100644 --- a/src/Api/Auth/Controllers/AuthRequestsController.cs +++ b/src/Api/Auth/Controllers/AuthRequestsController.cs @@ -2,7 +2,6 @@ #nullable disable using Bit.Api.Auth.Models.Response; -using Bit.Api.Models.Response; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Identity; using Bit.Core.Auth.Models.Api.Request.AuthRequest; @@ -11,6 +10,7 @@ using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; +using Bit.SharedWeb.Models.Response; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; diff --git a/src/Api/Auth/Controllers/EmergencyAccessController.cs b/src/Api/Auth/Controllers/EmergencyAccessController.cs index e5dd6013762f..9cc638cfd575 100644 --- a/src/Api/Auth/Controllers/EmergencyAccessController.cs +++ b/src/Api/Auth/Controllers/EmergencyAccessController.cs @@ -5,7 +5,6 @@ using Bit.Api.AdminConsole.Models.Response.Organizations; using Bit.Api.Auth.Models.Request; using Bit.Api.Auth.Models.Response; -using Bit.Api.Models.Response; using Bit.Api.Vault.Models.Response; using Bit.Core.Auth.UserFeatures.EmergencyAccess; using Bit.Core.Exceptions; @@ -13,6 +12,7 @@ using Bit.Core.Services; using Bit.Core.Settings; using Bit.Pam.Services; +using Bit.SharedWeb.Models.Response; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; diff --git a/src/Api/Auth/Controllers/TwoFactorController.cs b/src/Api/Auth/Controllers/TwoFactorController.cs index 113b46aacb9b..0d23ef5adc02 100644 --- a/src/Api/Auth/Controllers/TwoFactorController.cs +++ b/src/Api/Auth/Controllers/TwoFactorController.cs @@ -19,6 +19,7 @@ using Bit.Core.Services; using Bit.Core.Tokens; using Bit.Core.Utilities; +using Bit.SharedWeb.Models.Response; using Fido2NetLib; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; diff --git a/src/Api/Auth/Controllers/WebAuthnController.cs b/src/Api/Auth/Controllers/WebAuthnController.cs index babd28ff4020..d2be557d7a25 100644 --- a/src/Api/Auth/Controllers/WebAuthnController.cs +++ b/src/Api/Auth/Controllers/WebAuthnController.cs @@ -1,7 +1,6 @@ using Bit.Api.Auth.Models.Request.Accounts; using Bit.Api.Auth.Models.Request.WebAuthn; using Bit.Api.Auth.Models.Response.WebAuthn; -using Bit.Api.Models.Response; using Bit.Core; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.Auth.Enums; @@ -13,6 +12,7 @@ using Bit.Core.Exceptions; using Bit.Core.Services; using Bit.Core.Tokens; +using Bit.SharedWeb.Models.Response; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; diff --git a/src/Api/Billing/Controllers/OrganizationSponsorshipsController.cs b/src/Api/Billing/Controllers/OrganizationSponsorshipsController.cs index 1ab06377bd79..999092f873cc 100644 --- a/src/Api/Billing/Controllers/OrganizationSponsorshipsController.cs +++ b/src/Api/Billing/Controllers/OrganizationSponsorshipsController.cs @@ -4,7 +4,6 @@ using Bit.Api.AdminConsole.Authorization; using Bit.Api.AdminConsole.Authorization.Requirements; using Bit.Api.Models.Request.Organizations; -using Bit.Api.Models.Response; using Bit.Api.Models.Response.Organizations; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationConnections.Interfaces; @@ -19,6 +18,7 @@ using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Utilities; +using Bit.SharedWeb.Models.Response; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; diff --git a/src/Api/Billing/Controllers/PlansController.cs b/src/Api/Billing/Controllers/PlansController.cs index f9b52747808b..0d029ec63f3a 100644 --- a/src/Api/Billing/Controllers/PlansController.cs +++ b/src/Api/Billing/Controllers/PlansController.cs @@ -1,5 +1,6 @@ using Bit.Api.Models.Response; using Bit.Core.Billing.Pricing; +using Bit.SharedWeb.Models.Response; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; diff --git a/src/Api/Controllers/DevicesController.cs b/src/Api/Controllers/DevicesController.cs index d54b3c7b8c59..7d03da0adae3 100644 --- a/src/Api/Controllers/DevicesController.cs +++ b/src/Api/Controllers/DevicesController.cs @@ -13,6 +13,7 @@ using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Utilities; +using Bit.SharedWeb.Models.Response; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; diff --git a/src/Api/Controllers/SelfHosted/SelfHostedOrganizationSponsorshipsController.cs b/src/Api/Controllers/SelfHosted/SelfHostedOrganizationSponsorshipsController.cs index 67f21bdf37e0..35f63c8056c9 100644 --- a/src/Api/Controllers/SelfHosted/SelfHostedOrganizationSponsorshipsController.cs +++ b/src/Api/Controllers/SelfHosted/SelfHostedOrganizationSponsorshipsController.cs @@ -4,7 +4,6 @@ using Bit.Api.AdminConsole.Authorization; using Bit.Api.AdminConsole.Authorization.Requirements; using Bit.Api.Models.Request.Organizations; -using Bit.Api.Models.Response; using Bit.Core.Context; using Bit.Core.Exceptions; using Bit.Core.Models.Api.Response.OrganizationSponsorships; @@ -13,6 +12,7 @@ using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Utilities; +using Bit.SharedWeb.Models.Response; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; diff --git a/src/Api/Dirt/Controllers/EventsController.cs b/src/Api/Dirt/Controllers/EventsController.cs index f8de6334810a..a073bb68524c 100644 --- a/src/Api/Dirt/Controllers/EventsController.cs +++ b/src/Api/Dirt/Controllers/EventsController.cs @@ -2,7 +2,6 @@ #nullable disable using Bit.Api.Dirt.Models.Response; -using Bit.Api.Models.Response; using Bit.Api.Utilities; using Bit.Api.Utilities.DiagnosticTools; using Bit.Core.AdminConsole.Repositories; @@ -15,6 +14,7 @@ using Bit.Core.SecretsManager.Repositories; using Bit.Core.Services; using Bit.Core.Vault.Repositories; +using Bit.SharedWeb.Models.Response; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; diff --git a/src/Api/NotificationCenter/Controllers/NotificationsController.cs b/src/Api/NotificationCenter/Controllers/NotificationsController.cs index 9dc1505cb8d6..137355a7ad25 100644 --- a/src/Api/NotificationCenter/Controllers/NotificationsController.cs +++ b/src/Api/NotificationCenter/Controllers/NotificationsController.cs @@ -1,11 +1,11 @@ #nullable enable -using Bit.Api.Models.Response; using Bit.Api.NotificationCenter.Models.Request; using Bit.Api.NotificationCenter.Models.Response; using Bit.Core.Models.Data; using Bit.Core.NotificationCenter.Commands.Interfaces; using Bit.Core.NotificationCenter.Models.Filter; using Bit.Core.NotificationCenter.Queries.Interfaces; +using Bit.SharedWeb.Models.Response; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; diff --git a/src/Api/Pam/Controllers/AccessRequestsController.cs b/src/Api/Pam/Controllers/AccessRequestsController.cs deleted file mode 100644 index 6ed58a6b2ef8..000000000000 --- a/src/Api/Pam/Controllers/AccessRequestsController.cs +++ /dev/null @@ -1,111 +0,0 @@ -using Bit.Api.Models.Response; -using Bit.Api.Pam.Models.Request; -using Bit.Api.Pam.Models.Response; -using Bit.Commercial.Pam.OrganizationFeatures.Commands.Interfaces; -using Bit.Commercial.Pam.OrganizationFeatures.Queries.Interfaces; -using Bit.Core; -using Bit.Core.Services; -using Bit.Core.Utilities; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; - -namespace Bit.Api.Pam.Controllers; - -/// -/// The access-requests resource: lease requests through their lifecycle, before any lease is minted. Covers -/// both the requester's own queue (their requests across every organization, plus activation and withdrawal) and the -/// approver's queue (requests on collections the caller can Manage, plus the decision). Activating an approved request -/// mints a lease, which from then on lives under the leases resource. -/// -[ApiController] -[Route("access-requests")] -[Authorize("Application")] -[RequireFeature(FeatureFlagKeys.Pam)] -public class AccessRequestsController( - IUserService userService, - IListInboxRequestsQuery listInboxRequestsQuery, - IListInboxHistoryQuery listInboxHistoryQuery, - IDecideAccessRequestCommand decideAccessRequestCommand, - IListMyAccessRequestsQuery listMyAccessRequestsQuery, - IActivateAccessRequestCommand activateAccessRequestCommand, - ICancelAccessRequestCommand cancelAccessRequestCommand) - : ControllerBase -{ - /// - /// Returns the caller's pending approver queue: requests on collections the caller can Manage that are still - /// awaiting a decision. - /// - [HttpGet("inbox")] - public async Task> GetInbox() - { - var userId = userService.GetProperUserId(User)!.Value; - var requests = await listInboxRequestsQuery.GetPendingAsync(userId); - return new ListResponseModel( - requests.Select(r => new AccessRequestDetailsResponseModel(r))); - } - - /// - /// Returns the caller's resolved approver queue (decision history and lease outcomes) within the retention window. - /// - [HttpGet("history")] - public async Task> GetHistory() - { - var userId = userService.GetProperUserId(User)!.Value; - var history = await listInboxHistoryQuery.GetHistoryAsync(userId); - return new ListResponseModel( - history.Select(r => new AccessRequestDetailsResponseModel(r))); - } - - /// - /// Returns the caller's own access requests across all their organizations, regardless of status. The client - /// re-sorts and splits into pending/recent. - /// - [HttpGet("mine")] - public async Task> GetMine() - { - var userId = userService.GetProperUserId(User)!.Value; - var requests = await listMyAccessRequestsQuery.GetMineAsync(userId); - return new ListResponseModel( - requests.Select(r => new AccessRequestDetailsResponseModel(r))); - } - - /// - /// Approves or denies a pending lease request. The caller must be able to Manage the request's collection and may - /// not decide their own request. - /// - [HttpPost("{id:guid}/decision")] - public async Task Decide(Guid id, AccessDecisionRequestModel model) - { - var userId = userService.GetProperUserId(User)!.Value; - var result = await decideAccessRequestCommand.DecideAsync(userId, id, model.ToSubmission()); - return new AccessRequestDetailsResponseModel(result); - } - - /// - /// Activates the caller's approved access request: mints the lease that authorizes access, spanning the - /// request's approved window. Only the requester may activate, and only while the window is open. Repeat calls - /// while the produced lease is live return that lease. - /// - [HttpPost("{id:guid}/activate")] - public async Task Activate(Guid id) - { - var userId = userService.GetProperUserId(User)!.Value; - var lease = await activateAccessRequestCommand.ActivateAsync(userId, id); - return new AccessLeaseResponseModel(lease); - } - - /// - /// Revokes an access request that has not produced a lease, ending it without minting access. Caller-dependent: - /// the requester withdrawing their own request ends it as cancelled; a managing approver retracting a - /// request on a collection they manage ends it as denied. Either way the request must still be - /// pending or an unactivated approved request. A request that has produced a lease (revoke the lease - /// instead) or is otherwise resolved can no longer be revoked. - /// - [HttpPost("{id:guid}/revoke")] - public async Task Revoke(Guid id) - { - var userId = userService.GetProperUserId(User)!.Value; - await cancelAccessRequestCommand.CancelAsync(userId, id); - return NoContent(); - } -} diff --git a/src/Api/Pam/Controllers/CipherLeaseController.cs b/src/Api/Pam/Controllers/CipherLeaseController.cs index 75493154c8be..6472e772b861 100644 --- a/src/Api/Pam/Controllers/CipherLeaseController.cs +++ b/src/Api/Pam/Controllers/CipherLeaseController.cs @@ -1,7 +1,4 @@ -using Bit.Api.Pam.Models.Request; -using Bit.Api.Pam.Models.Response; -using Bit.Api.Vault.Models.Response; -using Bit.Commercial.Pam.OrganizationFeatures.Commands.Interfaces; +using Bit.Api.Vault.Models.Response; using Bit.Commercial.Pam.OrganizationFeatures.Queries.Interfaces; using Bit.Core; using Bit.Core.Exceptions; @@ -15,60 +12,24 @@ namespace Bit.Api.Pam.Controllers; -[ApiController] +/// +/// Hosts the single deprecated full-cipher read-back endpoint. The rest of the ciphers/{id}/lease resource +/// (pre-check, state, submit) is served as Minimal API endpoints from the Commercial.Pam library. This action stays +/// in the Api project because it depends on the Vault response models (), +/// which live here; it is scheduled for removal, after which the Api project carries no PAM code. +/// [Route("ciphers/{id:guid}/lease")] [Authorize("Application")] [RequireFeature(FeatureFlagKeys.Pam)] public class CipherLeaseController( IUserService userService, - IAccessPreCheckQuery preCheckQuery, - IGetCipherAccessStateQuery cipherAccessStateQuery, - ISubmitAccessRequestCommand submitAccessRequestCommand, IGetLeasedCipherQuery getLeasedCipherQuery, IApplicationCacheService applicationCacheService, ICollectionCipherRepository collectionCipherRepository, ICipherLeaseGate cipherLeaseGate, GlobalSettings globalSettings) - : ControllerBase + : Controller { - /// - /// Reports whether leasing this cipher would be approved automatically or require human approval, so the client - /// can present the appropriate workflow. No request is created. - /// - [HttpGet("pre-check")] - public async Task PreCheck(Guid id) - { - var userId = userService.GetProperUserId(User)!.Value; - var result = await preCheckQuery.PreCheckAsync(userId, id); - return new AccessPreCheckResponseModel(id, result); - } - - /// - /// Returns a single snapshot of the caller's lease state for this cipher — their active lease, pending request, - /// and approved-but-not-yet-activated request, if any — powering the cipher-view banner and the vault-row badge. - /// Side-effect free. - /// - [HttpGet("state")] - public async Task State(Guid id) - { - var userId = userService.GetProperUserId(User)!.Value; - var result = await cipherAccessStateQuery.GetStateAsync(userId, id); - return new CipherAccessStateResponseModel(result); - } - - /// - /// Submits a request to lease this cipher. The automatic path creates an already-approved request the requester - /// then activates to start the lease; the human path creates a pending request for an approver. Neither mints a - /// lease here — the requester activates the approved request (POST access-requests/{id}/activate). - /// - [HttpPost("")] - public async Task Post(Guid id, AccessRequestCreateRequestModel model) - { - var userId = userService.GetProperUserId(User)!.Value; - var result = await submitAccessRequestCommand.SubmitAsync(userId, id, model.ToSubmission()); - return new AccessRequestResultResponseModel(result); - } - /// /// Returns the cipher with its complete data, but only if the caller currently holds an active lease for it. /// This is the read-back counterpart to the partial data sync returns for leasing-gated ciphers. The data is diff --git a/src/Api/Pam/Controllers/LeasesController.cs b/src/Api/Pam/Controllers/LeasesController.cs deleted file mode 100644 index d4adb9b4ec12..000000000000 --- a/src/Api/Pam/Controllers/LeasesController.cs +++ /dev/null @@ -1,94 +0,0 @@ -using Bit.Api.Models.Response; -using Bit.Api.Pam.Models.Request; -using Bit.Api.Pam.Models.Response; -using Bit.Commercial.Pam.OrganizationFeatures.Commands.Interfaces; -using Bit.Commercial.Pam.OrganizationFeatures.Queries.Interfaces; -using Bit.Core; -using Bit.Core.Services; -using Bit.Core.Utilities; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; - -namespace Bit.Api.Pam.Controllers; - -/// -/// The leases resource: active and ended leases — the access a lease authorizes once an approved request is -/// activated. Covers the caller's own leases (across every organization), the governance surface over every lease on -/// collections the caller can Manage, and the actions on a single lease: ending it early (revoke) and extending it. -/// The governance scope mirrors the approver inbox — the caller's manageable collections — so an org admin or -/// collection manager sees all access in their scope. -/// -[ApiController] -[Route("leases")] -[Authorize("Application")] -[RequireFeature(FeatureFlagKeys.Pam)] -public class LeasesController( - IUserService userService, - IListActiveLeasesQuery listActiveLeasesQuery, - IListLeaseHistoryQuery listLeaseHistoryQuery, - IListMyActiveAccessLeasesQuery listMyActiveAccessLeasesQuery, - IRevokeAccessLeaseCommand revokeAccessLeaseCommand, - IRequestLeaseExtensionCommand requestLeaseExtensionCommand) - : ControllerBase -{ - /// - /// Returns every currently-active lease on collections the caller can Manage. - /// - [HttpGet("active")] - public async Task> GetActive() - { - var userId = userService.GetProperUserId(User)!.Value; - var leases = await listActiveLeasesQuery.GetActiveAsync(userId); - return new ListResponseModel( - leases.Select(l => new AccessLeaseResponseModel(l))); - } - - /// - /// Returns the ended leases (expired or revoked) on collections the caller can Manage, within the history window. - /// - [HttpGet("history")] - public async Task> GetHistory() - { - var userId = userService.GetProperUserId(User)!.Value; - var leases = await listLeaseHistoryQuery.GetHistoryAsync(userId); - return new ListResponseModel( - leases.Select(l => new AccessLeaseResponseModel(l))); - } - - /// - /// Returns the caller's currently-active leases across all their organizations. - /// - [HttpGet("mine")] - public async Task> GetMine() - { - var userId = userService.GetProperUserId(User)!.Value; - var leases = await listMyActiveAccessLeasesQuery.GetMineActiveAsync(userId); - return new ListResponseModel( - leases.Select(l => new AccessLeaseResponseModel(l))); - } - - /// - /// Ends an active lease early. The caller must be either the lease's holder (ending their own access) or able to - /// Manage the lease's collection. - /// - [HttpPost("{id:guid}/revoke")] - public async Task Revoke(Guid id, AccessLeaseRevokeRequestModel model) - { - var userId = userService.GetProperUserId(User)!.Value; - await revokeAccessLeaseCommand.RevokeAsync(userId, id, model.Reason); - return NoContent(); - } - - /// - /// Extends one of the caller's active leases by the requested duration. Extensions are always auto-approved, - /// subject to the governing rule allowing them and the per-lease maximum not being reached; the lease's end is - /// pushed out in place rather than minting a new lease. Only the lease's requester may extend it. - /// - [HttpPost("{id:guid}/extend")] - public async Task Extend(Guid id, AccessLeaseExtensionRequestModel model) - { - var userId = userService.GetProperUserId(User)!.Value; - var details = await requestLeaseExtensionCommand.ExtendAsync(userId, model.ToSubmission(id)); - return new AccessRequestDetailsResponseModel(details); - } -} diff --git a/src/Api/SecretsManager/Controllers/AccessPoliciesController.cs b/src/Api/SecretsManager/Controllers/AccessPoliciesController.cs index ad5d5e092b9a..e859d0ae578c 100644 --- a/src/Api/SecretsManager/Controllers/AccessPoliciesController.cs +++ b/src/Api/SecretsManager/Controllers/AccessPoliciesController.cs @@ -1,5 +1,4 @@ -using Bit.Api.Models.Response; -using Bit.Api.SecretsManager.Models.Request; +using Bit.Api.SecretsManager.Models.Request; using Bit.Api.SecretsManager.Models.Response; using Bit.Core.Context; using Bit.Core.Enums; @@ -11,6 +10,7 @@ using Bit.Core.SecretsManager.Queries.Interfaces; using Bit.Core.SecretsManager.Repositories; using Bit.Core.Services; +using Bit.SharedWeb.Models.Response; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; diff --git a/src/Api/SecretsManager/Controllers/ProjectsController.cs b/src/Api/SecretsManager/Controllers/ProjectsController.cs index 5dce032eceb4..a78358c36f90 100644 --- a/src/Api/SecretsManager/Controllers/ProjectsController.cs +++ b/src/Api/SecretsManager/Controllers/ProjectsController.cs @@ -1,7 +1,6 @@ // FIXME: Update this file to be null safe and then delete the line below #nullable disable -using Bit.Api.Models.Response; using Bit.Api.SecretsManager.Models.Request; using Bit.Api.SecretsManager.Models.Response; using Bit.Core.Auth.Identity; @@ -14,6 +13,7 @@ using Bit.Core.SecretsManager.Queries.Projects.Interfaces; using Bit.Core.SecretsManager.Repositories; using Bit.Core.Services; +using Bit.SharedWeb.Models.Response; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; diff --git a/src/Api/SecretsManager/Controllers/SecretVersionsController.cs b/src/Api/SecretsManager/Controllers/SecretVersionsController.cs index 86e2d1f7e9c1..a9cf47e92255 100644 --- a/src/Api/SecretsManager/Controllers/SecretVersionsController.cs +++ b/src/Api/SecretsManager/Controllers/SecretVersionsController.cs @@ -1,5 +1,4 @@ -using Bit.Api.Models.Response; -using Bit.Api.SecretsManager.Models.Request; +using Bit.Api.SecretsManager.Models.Request; using Bit.Api.SecretsManager.Models.Response; using Bit.Core.Auth.Identity; using Bit.Core.Context; @@ -8,6 +7,7 @@ using Bit.Core.Repositories; using Bit.Core.SecretsManager.Repositories; using Bit.Core.Services; +using Bit.SharedWeb.Models.Response; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; diff --git a/src/Api/SecretsManager/Controllers/SecretsController.cs b/src/Api/SecretsManager/Controllers/SecretsController.cs index dcfe1be11188..6e29c75bf2d9 100644 --- a/src/Api/SecretsManager/Controllers/SecretsController.cs +++ b/src/Api/SecretsManager/Controllers/SecretsController.cs @@ -1,7 +1,6 @@ // FIXME: Update this file to be null safe and then delete the line below #nullable disable -using Bit.Api.Models.Response; using Bit.Api.SecretsManager.Models.Request; using Bit.Api.SecretsManager.Models.Response; using Bit.Core.Auth.Identity; @@ -19,6 +18,7 @@ using Bit.Core.SecretsManager.Queries.Secrets.Interfaces; using Bit.Core.SecretsManager.Repositories; using Bit.Core.Services; +using Bit.SharedWeb.Models.Response; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; diff --git a/src/Api/SecretsManager/Controllers/SecretsManagerEventsController.cs b/src/Api/SecretsManager/Controllers/SecretsManagerEventsController.cs index 0f467a4c7881..ed8c7b9715ee 100644 --- a/src/Api/SecretsManager/Controllers/SecretsManagerEventsController.cs +++ b/src/Api/SecretsManager/Controllers/SecretsManagerEventsController.cs @@ -2,13 +2,13 @@ #nullable disable using Bit.Api.Dirt.Models.Response; -using Bit.Api.Models.Response; using Bit.Api.Utilities; using Bit.Core.Exceptions; using Bit.Core.Models.Data; using Bit.Core.Repositories; using Bit.Core.SecretsManager.AuthorizationRequirements; using Bit.Core.SecretsManager.Repositories; +using Bit.SharedWeb.Models.Response; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; diff --git a/src/Api/SecretsManager/Controllers/ServiceAccountsController.cs b/src/Api/SecretsManager/Controllers/ServiceAccountsController.cs index 0afdc3a1bf0f..b8affceca8bf 100644 --- a/src/Api/SecretsManager/Controllers/ServiceAccountsController.cs +++ b/src/Api/SecretsManager/Controllers/ServiceAccountsController.cs @@ -1,7 +1,6 @@ // FIXME: Update this file to be null safe and then delete the line below #nullable disable -using Bit.Api.Models.Response; using Bit.Api.SecretsManager.Models.Request; using Bit.Api.SecretsManager.Models.Response; using Bit.Core.Billing.Pricing; @@ -18,6 +17,7 @@ using Bit.Core.SecretsManager.Queries.ServiceAccounts.Interfaces; using Bit.Core.SecretsManager.Repositories; using Bit.Core.Services; +using Bit.SharedWeb.Models.Response; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; diff --git a/src/Api/SecretsManager/Models/Response/SecretsSyncResponseModel.cs b/src/Api/SecretsManager/Models/Response/SecretsSyncResponseModel.cs index 56b88113616d..926b93a8a678 100644 --- a/src/Api/SecretsManager/Models/Response/SecretsSyncResponseModel.cs +++ b/src/Api/SecretsManager/Models/Response/SecretsSyncResponseModel.cs @@ -1,7 +1,7 @@ #nullable enable -using Bit.Api.Models.Response; using Bit.Core.Models.Api; using Bit.Core.SecretsManager.Entities; +using Bit.SharedWeb.Models.Response; namespace Bit.Api.SecretsManager.Models.Response; diff --git a/src/Api/Startup.cs b/src/Api/Startup.cs index c322c31bd812..58a7008eed37 100644 --- a/src/Api/Startup.cs +++ b/src/Api/Startup.cs @@ -37,6 +37,8 @@ #if !OSS +using Bit.Commercial.Pam.Api; +using Bit.Commercial.Pam.Api.Endpoints; using Bit.Commercial.Core.SecretsManager; using Bit.Commercial.Core.Utilities; using Bit.Commercial.Infrastructure.EntityFramework.SecretsManager; @@ -206,6 +208,7 @@ public void ConfigureServices(IServiceCollection services) services.AddCommercialCoreServices(); services.AddCommercialSecretsManagerServices(); services.AddCommercialPamServices(); + services.AddPamApiServices(); services.AddSecretsManagerEfRepositories(); Jobs.JobsHostedService.AddCommercialSecretsManagerJobServices(services); #endif @@ -215,15 +218,10 @@ public void ConfigureServices(IServiceCollection services) { config.Conventions.Add(new ApiExplorerGroupConvention()); config.Conventions.Add(new PublicApiControllersModelConvention()); - }) - .ConfigureApiBehaviorOptions(options => - { - // The PAM controllers are [ApiController]; keep Bitwarden's ErrorResponseModel 400 contract - // (produced by ModelStateValidationFilterAttribute) instead of the framework's default - // ValidationProblemDetails. Only affects [ApiController] controllers. - options.SuppressModelStateInvalidFilter = true; }); + // Required for the PAM Minimal API endpoints to be discovered by ApiExplorer/Swagger. + services.AddEndpointsApiExplorer(); services.AddSwaggerGen(globalSettings, Environment); Jobs.JobsHostedService.AddJobsServices(services, globalSettings.SelfHosted); services.AddHostedService(); @@ -287,6 +285,11 @@ public void Configure( { endpoints.MapDefaultControllerRoute(); +#if !OSS + // PAM is a commercial feature; its Minimal API endpoints are only mapped in non-OSS builds. + endpoints.MapPamEndpoints(); +#endif + if (!globalSettings.SelfHosted) { endpoints.MapHealthChecks("/healthz"); diff --git a/src/Api/Tools/Controllers/SendsController.cs b/src/Api/Tools/Controllers/SendsController.cs index 99267e2b7ed0..9d2b0f9facea 100644 --- a/src/Api/Tools/Controllers/SendsController.cs +++ b/src/Api/Tools/Controllers/SendsController.cs @@ -1,6 +1,5 @@ using System.Text.Json; using Azure.Messaging.EventGrid; -using Bit.Api.Models.Response; using Bit.Api.Tools.Models.Request; using Bit.Api.Tools.Models.Response; using Bit.Api.Utilities; @@ -20,6 +19,7 @@ using Bit.Core.Tools.SendFeatures.Queries.Interfaces; using Bit.Core.Tools.Services; using Bit.Core.Utilities; +using Bit.SharedWeb.Models.Response; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; diff --git a/src/Api/Tools/Models/Response/OrganizationExportResponseModel.cs b/src/Api/Tools/Models/Response/OrganizationExportResponseModel.cs index 0469aa319252..ff0d1c3a9fa8 100644 --- a/src/Api/Tools/Models/Response/OrganizationExportResponseModel.cs +++ b/src/Api/Tools/Models/Response/OrganizationExportResponseModel.cs @@ -2,13 +2,13 @@ #nullable disable using Bit.Api.AdminConsole.Models.Response; -using Bit.Api.Models.Response; using Bit.Api.Vault.Models.Response; using Bit.Core.Entities; using Bit.Core.Models.Api; using Bit.Core.Settings; using Bit.Core.Vault.Authorization; using Bit.Core.Vault.Models.Data; +using Bit.SharedWeb.Models.Response; namespace Bit.Api.Tools.Models.Response; diff --git a/src/Api/Vault/Controllers/CiphersController.cs b/src/Api/Vault/Controllers/CiphersController.cs index 64a7dc19789c..c1b4c408ffba 100644 --- a/src/Api/Vault/Controllers/CiphersController.cs +++ b/src/Api/Vault/Controllers/CiphersController.cs @@ -5,7 +5,6 @@ using System.Text.Json; using Azure.Messaging.EventGrid; using Bit.Api.Auth.Models.Request.Accounts; -using Bit.Api.Models.Response; using Bit.Api.Utilities; using Bit.Api.Vault.Models.Request; using Bit.Api.Vault.Models.Response; @@ -29,6 +28,7 @@ using Bit.Core.Vault.Repositories; using Bit.Core.Vault.Services; using Bit.Pam.Services; +using Bit.SharedWeb.Models.Response; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; diff --git a/src/Api/Vault/Controllers/FoldersController.cs b/src/Api/Vault/Controllers/FoldersController.cs index 195931f60cbc..3da222437fc3 100644 --- a/src/Api/Vault/Controllers/FoldersController.cs +++ b/src/Api/Vault/Controllers/FoldersController.cs @@ -1,13 +1,13 @@ // FIXME: Update this file to be null safe and then delete the line below #nullable disable -using Bit.Api.Models.Response; using Bit.Api.Vault.Models.Request; using Bit.Api.Vault.Models.Response; using Bit.Core.Exceptions; using Bit.Core.Services; using Bit.Core.Vault.Repositories; using Bit.Core.Vault.Services; +using Bit.SharedWeb.Models.Response; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; diff --git a/src/Api/Vault/Controllers/SecurityTaskController.cs b/src/Api/Vault/Controllers/SecurityTaskController.cs index efff200e864a..24021747abf8 100644 --- a/src/Api/Vault/Controllers/SecurityTaskController.cs +++ b/src/Api/Vault/Controllers/SecurityTaskController.cs @@ -1,7 +1,6 @@ // FIXME: Update this file to be null safe and then delete the line below #nullable disable -using Bit.Api.Models.Response; using Bit.Api.Vault.Models.Request; using Bit.Api.Vault.Models.Response; using Bit.Core.Services; @@ -9,6 +8,7 @@ using Bit.Core.Vault.Entities; using Bit.Core.Vault.Enums; using Bit.Core.Vault.Queries; +using Bit.SharedWeb.Models.Response; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; diff --git a/src/Api/Models/Response/ListResponseModel.cs b/src/SharedWeb/Models/Response/ListResponseModel.cs similarity index 92% rename from src/Api/Models/Response/ListResponseModel.cs rename to src/SharedWeb/Models/Response/ListResponseModel.cs index 746e6c197b04..cf00271eb534 100644 --- a/src/Api/Models/Response/ListResponseModel.cs +++ b/src/SharedWeb/Models/Response/ListResponseModel.cs @@ -3,7 +3,7 @@ using Bit.Core.Models.Api; -namespace Bit.Api.Models.Response; +namespace Bit.SharedWeb.Models.Response; public class ListResponseModel : ResponseModel where T : ResponseModel { diff --git a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUserControllerBulkRevokeTests.cs b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUserControllerBulkRevokeTests.cs index 3af91a76b024..5e80755eb510 100644 --- a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUserControllerBulkRevokeTests.cs +++ b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUserControllerBulkRevokeTests.cs @@ -3,7 +3,6 @@ using Bit.Api.AdminConsole.Models.Response.Organizations; using Bit.Api.IntegrationTest.Factories; using Bit.Api.IntegrationTest.Helpers; -using Bit.Api.Models.Response; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; @@ -13,6 +12,7 @@ using Bit.Core.Enums; using Bit.Core.Models.Data; using Bit.Core.Repositories; +using Bit.SharedWeb.Models.Response; using Xunit; namespace Bit.Api.IntegrationTest.AdminConsole.Controllers; diff --git a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUserControllerTests.cs b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUserControllerTests.cs index 343178e7a22c..663f85c73e2b 100644 --- a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUserControllerTests.cs +++ b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUserControllerTests.cs @@ -4,7 +4,6 @@ using Bit.Api.IntegrationTest.Factories; using Bit.Api.IntegrationTest.Helpers; using Bit.Api.Models.Request; -using Bit.Api.Models.Response; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount; using Bit.Core.AdminConsole.Repositories; @@ -13,6 +12,7 @@ using Bit.Core.Enums; using Bit.Core.Models.Data; using Bit.Core.Repositories; +using Bit.SharedWeb.Models.Response; using Xunit; namespace Bit.Api.IntegrationTest.AdminConsole.Controllers; diff --git a/test/Api.IntegrationTest/NotificationCenter/Controllers/NotificationsControllerTests.cs b/test/Api.IntegrationTest/NotificationCenter/Controllers/NotificationsControllerTests.cs index ca04c9775d8e..1d540b82da25 100644 --- a/test/Api.IntegrationTest/NotificationCenter/Controllers/NotificationsControllerTests.cs +++ b/test/Api.IntegrationTest/NotificationCenter/Controllers/NotificationsControllerTests.cs @@ -1,7 +1,6 @@ using System.Net; using Bit.Api.IntegrationTest.Factories; using Bit.Api.IntegrationTest.Helpers; -using Bit.Api.Models.Response; using Bit.Api.NotificationCenter.Models.Response; using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Enums; @@ -12,6 +11,7 @@ using Bit.Core.NotificationCenter.Enums; using Bit.Core.NotificationCenter.Repositories; using Bit.Core.Repositories; +using Bit.SharedWeb.Models.Response; using Xunit; namespace Bit.Api.IntegrationTest.NotificationCenter.Controllers; diff --git a/test/Api.IntegrationTest/SecretsManager/Controllers/AccessPoliciesControllerTests.cs b/test/Api.IntegrationTest/SecretsManager/Controllers/AccessPoliciesControllerTests.cs index 77614574c178..415222cc6d2d 100644 --- a/test/Api.IntegrationTest/SecretsManager/Controllers/AccessPoliciesControllerTests.cs +++ b/test/Api.IntegrationTest/SecretsManager/Controllers/AccessPoliciesControllerTests.cs @@ -2,7 +2,6 @@ using Bit.Api.IntegrationTest.Factories; using Bit.Api.IntegrationTest.SecretsManager.Enums; using Bit.Api.IntegrationTest.SecretsManager.Helpers; -using Bit.Api.Models.Response; using Bit.Api.SecretsManager.Models.Request; using Bit.Api.SecretsManager.Models.Response; using Bit.Core.AdminConsole.Entities; @@ -11,6 +10,7 @@ using Bit.Core.Enums; using Bit.Core.SecretsManager.Entities; using Bit.Core.SecretsManager.Repositories; +using Bit.SharedWeb.Models.Response; using Xunit; namespace Bit.Api.IntegrationTest.SecretsManager.Controllers; diff --git a/test/Api.IntegrationTest/SecretsManager/Controllers/ProjectsControllerTests.cs b/test/Api.IntegrationTest/SecretsManager/Controllers/ProjectsControllerTests.cs index 099dde512770..67280b1e2df7 100644 --- a/test/Api.IntegrationTest/SecretsManager/Controllers/ProjectsControllerTests.cs +++ b/test/Api.IntegrationTest/SecretsManager/Controllers/ProjectsControllerTests.cs @@ -2,13 +2,13 @@ using Bit.Api.IntegrationTest.Factories; using Bit.Api.IntegrationTest.SecretsManager.Enums; using Bit.Api.IntegrationTest.SecretsManager.Helpers; -using Bit.Api.Models.Response; using Bit.Api.SecretsManager.Models.Request; using Bit.Api.SecretsManager.Models.Response; using Bit.Core.AdminConsole.Entities; using Bit.Core.Enums; using Bit.Core.SecretsManager.Entities; using Bit.Core.SecretsManager.Repositories; +using Bit.SharedWeb.Models.Response; using Bit.Test.Common.Helpers; using Xunit; diff --git a/test/Api.IntegrationTest/SecretsManager/Controllers/SecretVersionsControllerTests.cs b/test/Api.IntegrationTest/SecretsManager/Controllers/SecretVersionsControllerTests.cs index 9393795e551f..fe9e07b39176 100644 --- a/test/Api.IntegrationTest/SecretsManager/Controllers/SecretVersionsControllerTests.cs +++ b/test/Api.IntegrationTest/SecretsManager/Controllers/SecretVersionsControllerTests.cs @@ -2,12 +2,12 @@ using Bit.Api.IntegrationTest.Factories; using Bit.Api.IntegrationTest.SecretsManager.Enums; using Bit.Api.IntegrationTest.SecretsManager.Helpers; -using Bit.Api.Models.Response; using Bit.Api.SecretsManager.Models.Request; using Bit.Api.SecretsManager.Models.Response; using Bit.Core.Enums; using Bit.Core.SecretsManager.Entities; using Bit.Core.SecretsManager.Repositories; +using Bit.SharedWeb.Models.Response; using Xunit; namespace Bit.Api.IntegrationTest.SecretsManager.Controllers; diff --git a/test/Api.IntegrationTest/SecretsManager/Controllers/SecretsControllerTests.cs b/test/Api.IntegrationTest/SecretsManager/Controllers/SecretsControllerTests.cs index be95c0dc1ece..3450fa28351d 100644 --- a/test/Api.IntegrationTest/SecretsManager/Controllers/SecretsControllerTests.cs +++ b/test/Api.IntegrationTest/SecretsManager/Controllers/SecretsControllerTests.cs @@ -2,13 +2,13 @@ using Bit.Api.IntegrationTest.Factories; using Bit.Api.IntegrationTest.SecretsManager.Enums; using Bit.Api.IntegrationTest.SecretsManager.Helpers; -using Bit.Api.Models.Response; using Bit.Api.SecretsManager.Models.Request; using Bit.Api.SecretsManager.Models.Response; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.SecretsManager.Entities; using Bit.Core.SecretsManager.Repositories; +using Bit.SharedWeb.Models.Response; using Bit.Test.Common.Helpers; using Xunit; diff --git a/test/Api.IntegrationTest/SecretsManager/Controllers/ServiceAccountsControllerTests.cs b/test/Api.IntegrationTest/SecretsManager/Controllers/ServiceAccountsControllerTests.cs index 6884de5b2665..ffd9092e56c8 100644 --- a/test/Api.IntegrationTest/SecretsManager/Controllers/ServiceAccountsControllerTests.cs +++ b/test/Api.IntegrationTest/SecretsManager/Controllers/ServiceAccountsControllerTests.cs @@ -3,13 +3,13 @@ using Bit.Api.IntegrationTest.Factories; using Bit.Api.IntegrationTest.SecretsManager.Enums; using Bit.Api.IntegrationTest.SecretsManager.Helpers; -using Bit.Api.Models.Response; using Bit.Api.SecretsManager.Models.Request; using Bit.Api.SecretsManager.Models.Response; using Bit.Core.AdminConsole.Entities; using Bit.Core.Enums; using Bit.Core.SecretsManager.Entities; using Bit.Core.SecretsManager.Repositories; +using Bit.SharedWeb.Models.Response; using Bit.Test.Common.Helpers; using Xunit; diff --git a/test/Api.Test/AdminConsole/Controllers/OrganizationDomainControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/OrganizationDomainControllerTests.cs index 738224727ac6..32495c85cc48 100644 --- a/test/Api.Test/AdminConsole/Controllers/OrganizationDomainControllerTests.cs +++ b/test/Api.Test/AdminConsole/Controllers/OrganizationDomainControllerTests.cs @@ -2,7 +2,6 @@ using Bit.Api.AdminConsole.Models.Request; using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Api.AdminConsole.Models.Response.Organizations; -using Bit.Api.Models.Response; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces; using Bit.Core.Context; @@ -10,6 +9,7 @@ using Bit.Core.Exceptions; using Bit.Core.Models.Data.Organizations; using Bit.Core.Repositories; +using Bit.SharedWeb.Models.Response; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; diff --git a/test/Api.Test/Auth/Controllers/AuthRequestsControllerTests.cs b/test/Api.Test/Auth/Controllers/AuthRequestsControllerTests.cs index ca9613f09997..84d5b8b1b00b 100644 --- a/test/Api.Test/Auth/Controllers/AuthRequestsControllerTests.cs +++ b/test/Api.Test/Auth/Controllers/AuthRequestsControllerTests.cs @@ -1,7 +1,6 @@ using System.Security.Claims; using Bit.Api.Auth.Controllers; using Bit.Api.Auth.Models.Response; -using Bit.Api.Models.Response; using Bit.Core.Auth.Entities; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models.Api.Request.AuthRequest; @@ -12,6 +11,7 @@ using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; +using Bit.SharedWeb.Models.Response; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; diff --git a/test/Api.Test/Auth/Controllers/DevicesControllerTests.cs b/test/Api.Test/Auth/Controllers/DevicesControllerTests.cs index bed483f83af0..37c2a6eac1fb 100644 --- a/test/Api.Test/Auth/Controllers/DevicesControllerTests.cs +++ b/test/Api.Test/Auth/Controllers/DevicesControllerTests.cs @@ -1,5 +1,4 @@ using Bit.Api.Controllers; -using Bit.Api.Models.Response; using Bit.Core.Auth.Models.Api.Response; using Bit.Core.Auth.Models.Data; using Bit.Core.Auth.UserFeatures.DeviceTrust; @@ -8,6 +7,7 @@ using Bit.Core.Enums; using Bit.Core.Repositories; using Bit.Core.Services; +using Bit.SharedWeb.Models.Response; using Microsoft.Extensions.Logging; using NSubstitute; using Xunit; diff --git a/test/Api.Test/Auth/Controllers/EmergencyAccessControllerTests.cs b/test/Api.Test/Auth/Controllers/EmergencyAccessControllerTests.cs index 697858d0ec9c..283f7531af7d 100644 --- a/test/Api.Test/Auth/Controllers/EmergencyAccessControllerTests.cs +++ b/test/Api.Test/Auth/Controllers/EmergencyAccessControllerTests.cs @@ -2,7 +2,6 @@ using Bit.Api.AdminConsole.Models.Response.Organizations; using Bit.Api.Auth.Controllers; using Bit.Api.Auth.Models.Response; -using Bit.Api.Models.Response; using Bit.Api.Vault.Models.Response; using Bit.Core.AdminConsole.Entities; using Bit.Core.Auth.Entities; @@ -14,6 +13,7 @@ using Bit.Core.Services; using Bit.Core.Vault.Entities; using Bit.Core.Vault.Models.Data; +using Bit.SharedWeb.Models.Response; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; diff --git a/test/Api.Test/Tools/Controllers/SendsControllerTests.cs b/test/Api.Test/Tools/Controllers/SendsControllerTests.cs index 07c2d8a65602..d26b3ee14537 100644 --- a/test/Api.Test/Tools/Controllers/SendsControllerTests.cs +++ b/test/Api.Test/Tools/Controllers/SendsControllerTests.cs @@ -2,7 +2,6 @@ using System.Text; using System.Text.Json; using AutoFixture.Xunit2; -using Bit.Api.Models.Response; using Bit.Api.Tools.Controllers; using Bit.Api.Tools.Models; using Bit.Api.Tools.Models.Request; @@ -22,6 +21,7 @@ using Bit.Core.Tools.SendFeatures.Queries.Interfaces; using Bit.Core.Tools.Services; using Bit.Core.Utilities; +using Bit.SharedWeb.Models.Response; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; diff --git a/test/Common/AutoFixture/ControllerCustomization.cs b/test/Common/AutoFixture/ControllerCustomization.cs index 9a5a32dc2b3c..91fffbf09971 100644 --- a/test/Common/AutoFixture/ControllerCustomization.cs +++ b/test/Common/AutoFixture/ControllerCustomization.cs @@ -12,9 +12,9 @@ public class ControllerCustomization : ICustomization private readonly Type _controllerType; public ControllerCustomization(Type controllerType) { - if (!controllerType.IsAssignableTo(typeof(ControllerBase))) + if (!controllerType.IsAssignableTo(typeof(Controller))) { - throw new Exception($"{nameof(controllerType)} must derive from {typeof(ControllerBase).Name}"); + throw new Exception($"{nameof(controllerType)} must derive from {typeof(Controller).Name}"); } _controllerType = controllerType; @@ -25,7 +25,7 @@ public void Customize(IFixture fixture) fixture.Customizations.Add(new BuilderWithoutAutoProperties(_controllerType)); } } -public class ControllerCustomization : ICustomization where T : ControllerBase +public class ControllerCustomization : ICustomization where T : Controller { public void Customize(IFixture fixture) => new ControllerCustomization(typeof(T)).Customize(fixture); } From 2d130a1615aa4572be6ca31ee23f92e3ed633b1a Mon Sep 17 00:00:00 2001 From: Hinton Date: Mon, 22 Jun 2026 19:47:40 +0200 Subject: [PATCH 54/54] Fix build --- .../Controllers/OrganizationInviteLinksController.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Api/AdminConsole/Controllers/OrganizationInviteLinksController.cs b/src/Api/AdminConsole/Controllers/OrganizationInviteLinksController.cs index e3998d8d6f11..c378cdbd5494 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationInviteLinksController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationInviteLinksController.cs @@ -5,6 +5,7 @@ using Bit.Core; using Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks.Interfaces; using Bit.Core.Utilities; +using Bit.SharedWeb.Models.Response; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc;