diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index 5e6d9d1c363f..285fdb27c655 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -104,8 +104,9 @@ src/Admin/Views/Tools @bitwarden/team-billing-dev
src/Core/Platform/Push/PushType.cs
# PAM
-bitwarden_license/src/Services/Pam @bitwarden/team-pam-dev
-bitwarden_license/test/Services/Pam.Test @bitwarden/team-pam-dev
+**/Pam* @bitwarden/team-pam-dev
+# PAM DB schema stays with DBOps, consistent with src/Sql/** above
+src/Sql/dbo/Pam @bitwarden/dept-dbops
# SDK
util/RustSdk @bitwarden/team-sdk-sme
diff --git a/bitwarden-server.slnx b/bitwarden-server.slnx
index efda5c789072..8f81580ce094 100644
--- a/bitwarden-server.slnx
+++ b/bitwarden-server.slnx
@@ -28,6 +28,7 @@
+
diff --git a/src/Core/AdminConsole/Entities/Collection.cs b/src/Core/AdminConsole/Entities/Collection.cs
index 15e8f9cfa0fe..1dc5dc7374de 100644
--- a/src/Core/AdminConsole/Entities/Collection.cs
+++ b/src/Core/AdminConsole/Entities/Collection.cs
@@ -47,6 +47,12 @@ public class Collection : ITableObject
///
public string? DefaultUserCollectionEmail { get; set; }
+ ///
+ /// Reference to the AccessRule that gates PAM credential leasing for this
+ /// collection. Null means leasing is disabled for the collection.
+ ///
+ public Guid? AccessRuleId { get; set; }
+
///
/// Initializes to a new COMB GUID.
///
diff --git a/src/Core/packages.lock.json b/src/Core/packages.lock.json
index c63571345f9f..f840c3090cb8 100644
--- a/src/Core/packages.lock.json
+++ b/src/Core/packages.lock.json
@@ -1192,6 +1192,12 @@
},
"data": {
"type": "Project"
+ },
+ "pam.domain": {
+ "type": "Project",
+ "dependencies": {
+ "Data": "[2026.6.2, )"
+ }
}
}
}
diff --git a/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs b/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs
index d31970869163..0357bdc3bf1b 100644
--- a/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs
+++ b/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs
@@ -25,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;
@@ -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/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.Dapper/Pam/Repositories/AccessRuleRepository.cs b/src/Infrastructure.Dapper/Pam/Repositories/AccessRuleRepository.cs
new file mode 100644
index 000000000000..6cc942533a61
--- /dev/null
+++ b/src/Infrastructure.Dapper/Pam/Repositories/AccessRuleRepository.cs
@@ -0,0 +1,98 @@
+using System.Data;
+using Bit.Core.Settings;
+using Bit.Infrastructure.Dapper.Repositories;
+using Bit.Pam.Entities;
+using Bit.Pam.Models;
+using Bit.Pam.Repositories;
+using Dapper;
+using Microsoft.Data.SqlClient;
+
+#nullable enable
+
+namespace Bit.Infrastructure.Dapper.Pam.Repositories;
+
+public class AccessRuleRepository : Repository, IAccessRuleRepository
+{
+ public AccessRuleRepository(GlobalSettings globalSettings)
+ : this(globalSettings.SqlServer.ConnectionString, globalSettings.SqlServer.ReadOnlyConnectionString)
+ { }
+
+ public AccessRuleRepository(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}].[AccessRule_ReadByOrganizationId]",
+ new { OrganizationId = organizationId },
+ commandType: CommandType.StoredProcedure);
+
+ 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.ReadFirstOrDefaultAsync();
+ 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; set; }
+ public Guid CollectionId { get; set; }
+ }
+}
diff --git a/src/Infrastructure.Dapper/packages.lock.json b/src/Infrastructure.Dapper/packages.lock.json
index af8afe0a7ca9..feb0646a4b20 100644
--- a/src/Infrastructure.Dapper/packages.lock.json
+++ b/src/Infrastructure.Dapper/packages.lock.json
@@ -1164,6 +1164,7 @@
"BitPay.Light": "[1.0.1907, 1.0.1907]",
"Braintree": "[5.36.0, 5.36.0]",
"CsvHelper": "[33.1.0, 33.1.0]",
+ "Data": "[2026.6.2, )",
"DnsClient": "[1.8.0, 1.8.0]",
"Duende.IdentityServer": "[7.4.6, 7.4.6]",
"DuoUniversal": "[1.3.1, 1.3.1]",
@@ -1188,6 +1189,7 @@
"Newtonsoft.Json": "[13.0.3, 13.0.3]",
"OneOf": "[3.0.271, 3.0.271]",
"Otp.NET": "[1.4.0, 1.4.0]",
+ "Pam.Domain": "[2026.6.2, )",
"Quartz": "[3.15.1, 3.15.1]",
"Quartz.Extensions.DependencyInjection": "[3.15.1, 3.15.1]",
"Quartz.Extensions.Hosting": "[3.15.1, 3.15.1]",
@@ -1200,6 +1202,15 @@
"ZiggyCreatures.FusionCache.Backplane.StackExchangeRedis": "[2.0.2, 2.0.2]",
"ZiggyCreatures.FusionCache.Serialization.SystemTextJson": "[2.0.2, 2.0.2]"
}
+ },
+ "data": {
+ "type": "Project"
+ },
+ "pam.domain": {
+ "type": "Project",
+ "dependencies": {
+ "Data": "[2026.6.2, )"
+ }
}
}
}
diff --git a/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs b/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs
index cd0d1feb4dfd..1d4c7474ca56 100644
--- a/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs
+++ b/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs
@@ -26,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;
@@ -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/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/Infrastructure.EntityFramework/Pam/Models/AccessRule.cs b/src/Infrastructure.EntityFramework/Pam/Models/AccessRule.cs
new file mode 100644
index 000000000000..8fb27d2990bd
--- /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 : Bit.Pam.Entities.AccessRule
+{
+ public virtual Organization Organization { get; set; }
+}
+
+public class AccessRuleMapperProfile : Profile
+{
+ public AccessRuleMapperProfile()
+ {
+ CreateMap().ReverseMap();
+ CreateMap();
+ }
+}
diff --git a/src/Infrastructure.EntityFramework/Pam/Repositories/AccessRuleRepository.cs b/src/Infrastructure.EntityFramework/Pam/Repositories/AccessRuleRepository.cs
new file mode 100644
index 000000000000..c9a049d00f39
--- /dev/null
+++ b/src/Infrastructure.EntityFramework/Pam/Repositories/AccessRuleRepository.cs
@@ -0,0 +1,141 @@
+using AutoMapper;
+using Bit.Infrastructure.EntityFramework.Repositories;
+using Bit.Pam.Models;
+using Bit.Pam.Repositories;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using CoreEntity = Bit.Pam.Entities.AccessRule;
+using EfModel = Bit.Infrastructure.EntityFramework.Pam.Models.AccessRule;
+
+#nullable enable
+
+namespace Bit.Infrastructure.EntityFramework.Pam.Repositories;
+
+public class AccessRuleRepository : Repository, IAccessRuleRepository
+{
+ 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 rules = await dbContext.AccessRules
+ .Where(p => p.OrganizationId == organizationId)
+ .AsNoTracking()
+ .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);
+
+ await using var transaction = await dbContext.Database.BeginTransactionAsync();
+
+ // Clear the collection links before deleting the rule: the FK Collection.AccessRuleId -> AccessRule is
+ // ON DELETE NO ACTION, so the delete fails while any collection still points at it.
+ await dbContext.Collections
+ .Where(c => c.AccessRuleId == accessRule.Id)
+ .ExecuteUpdateAsync(s => s
+ .SetProperty(c => c.AccessRuleId, (Guid?)null)
+ .SetProperty(c => c.RevisionDate, DateTime.UtcNow));
+
+ 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/Infrastructure.EntityFramework/Repositories/DatabaseContext.cs b/src/Infrastructure.EntityFramework/Repositories/DatabaseContext.cs
index 67b459ea377f..c29fab3cc926 100644
--- a/src/Infrastructure.EntityFramework/Repositories/DatabaseContext.cs
+++ b/src/Infrastructure.EntityFramework/Repositories/DatabaseContext.cs
@@ -8,6 +8,7 @@
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.SecretsManager.Models;
using Bit.Infrastructure.EntityFramework.Vault.Models;
@@ -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 AccessRules { 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 eAccessRule = builder.Entity();
var eEmergencyAccess = builder.Entity();
var eFolder = builder.Entity();
var eGroup = builder.Entity();
@@ -145,6 +148,14 @@ protected override void OnModelCreating(ModelBuilder builder)
eCollectionGroup.HasKey(cg => new { cg.CollectionId, cg.GroupId });
eGroupUser.HasKey(gu => new { gu.GroupId, gu.OrganizationUserId });
+ eAccessRule.Property(p => p.Id).ValueGeneratedNever();
+ eAccessRule.HasIndex(p => new { p.OrganizationId, p.Name }).IsUnique();
+ eCollection
+ .HasOne()
+ .WithMany()
+ .HasForeignKey(c => c.AccessRuleId)
+ .OnDelete(DeleteBehavior.Restrict);
+
eOrganizationMemberBaseDetail.HasNoKey();
var dataProtector = this.GetService().CreateProtector(
@@ -167,6 +178,7 @@ protected override void OnModelCreating(ModelBuilder builder)
eCipher.ToTable(nameof(Cipher));
eCollection.ToTable(nameof(Collection));
eCollectionCipher.ToTable(nameof(CollectionCipher));
+ eAccessRule.ToTable(nameof(AccessRule));
eEmergencyAccess.ToTable(nameof(EmergencyAccess));
eFolder.ToTable(nameof(Folder));
eGroup.ToTable(nameof(Group));
diff --git a/src/Infrastructure.EntityFramework/packages.lock.json b/src/Infrastructure.EntityFramework/packages.lock.json
index 605e234bed58..315ad1f5aab1 100644
--- a/src/Infrastructure.EntityFramework/packages.lock.json
+++ b/src/Infrastructure.EntityFramework/packages.lock.json
@@ -1322,6 +1322,7 @@
"BitPay.Light": "[1.0.1907, 1.0.1907]",
"Braintree": "[5.36.0, 5.36.0]",
"CsvHelper": "[33.1.0, 33.1.0]",
+ "Data": "[2026.6.2, )",
"DnsClient": "[1.8.0, 1.8.0]",
"Duende.IdentityServer": "[7.4.6, 7.4.6]",
"DuoUniversal": "[1.3.1, 1.3.1]",
@@ -1346,6 +1347,7 @@
"Newtonsoft.Json": "[13.0.3, 13.0.3]",
"OneOf": "[3.0.271, 3.0.271]",
"Otp.NET": "[1.4.0, 1.4.0]",
+ "Pam.Domain": "[2026.6.2, )",
"Quartz": "[3.15.1, 3.15.1]",
"Quartz.Extensions.DependencyInjection": "[3.15.1, 3.15.1]",
"Quartz.Extensions.Hosting": "[3.15.1, 3.15.1]",
@@ -1358,6 +1360,15 @@
"ZiggyCreatures.FusionCache.Backplane.StackExchangeRedis": "[2.0.2, 2.0.2]",
"ZiggyCreatures.FusionCache.Serialization.SystemTextJson": "[2.0.2, 2.0.2]"
}
+ },
+ "data": {
+ "type": "Project"
+ },
+ "pam.domain": {
+ "type": "Project",
+ "dependencies": {
+ "Data": "[2026.6.2, )"
+ }
}
}
}
diff --git a/src/Pam.Domain/Entities/AccessRule.cs b/src/Pam.Domain/Entities/AccessRule.cs
new file mode 100644
index 000000000000..915da9736886
--- /dev/null
+++ b/src/Pam.Domain/Entities/AccessRule.cs
@@ -0,0 +1,77 @@
+using System.ComponentModel.DataAnnotations;
+using Bit.Core.Entities;
+using Bit.Core.Utilities;
+
+namespace Bit.Pam.Entities;
+
+///
+/// A reusable, org-scoped PAM access rule. Referenced by collections (and eventually Secrets Manager
+/// entities) via FK to govern credential lease decisions.
+///
+public class AccessRule : 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 conditions document (an AccessCondition tree). Validated by AccessRuleValidator before
+ /// being persisted.
+ ///
+ 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; }
+
+ ///
+ /// 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; }
+
+ ///
+ /// 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;
+
+ ///
+ /// 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 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? MaxExtensionDurationSeconds { get; set; }
+
+ public DateTime CreationDate { get; set; } = DateTime.UtcNow;
+ public DateTime RevisionDate { get; set; } = DateTime.UtcNow;
+
+ ///
+ /// The user who created or last updated the rule (the latest editor).
+ ///
+ public Guid? LastEditedBy { get; set; }
+
+ public void SetNewId()
+ {
+ Id = CombGuid.Generate();
+ }
+}
diff --git a/src/Pam.Domain/Models/AccessRuleDetails.cs b/src/Pam.Domain/Models/AccessRuleDetails.cs
new file mode 100644
index 000000000000..fc5b6f1857e0
--- /dev/null
+++ b/src/Pam.Domain/Models/AccessRuleDetails.cs
@@ -0,0 +1,30 @@
+using Bit.Pam.Entities;
+
+namespace Bit.Pam.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,
+ Conditions = rule.Conditions,
+ SingleActiveLease = rule.SingleActiveLease,
+ DefaultLeaseDurationSeconds = rule.DefaultLeaseDurationSeconds,
+ MaxLeaseDurationSeconds = rule.MaxLeaseDurationSeconds,
+ Enabled = rule.Enabled,
+ AllowsExtensions = rule.AllowsExtensions,
+ MaxExtensionDurationSeconds = rule.MaxExtensionDurationSeconds,
+ CreationDate = rule.CreationDate,
+ RevisionDate = rule.RevisionDate,
+ LastEditedBy = rule.LastEditedBy,
+ CollectionIds = collectionIds,
+ };
+}
diff --git a/src/Pam.Domain/Pam.Domain.csproj b/src/Pam.Domain/Pam.Domain.csproj
new file mode 100644
index 000000000000..ae369da73fae
--- /dev/null
+++ b/src/Pam.Domain/Pam.Domain.csproj
@@ -0,0 +1,11 @@
+
+
+
+ Bit.Pam
+
+
+
+
+
+
+
diff --git a/src/Pam.Domain/Repositories/IAccessRuleRepository.cs b/src/Pam.Domain/Repositories/IAccessRuleRepository.cs
new file mode 100644
index 000000000000..f7aa426ca486
--- /dev/null
+++ b/src/Pam.Domain/Repositories/IAccessRuleRepository.cs
@@ -0,0 +1,31 @@
+using Bit.Core.Repositories;
+using Bit.Pam.Entities;
+using Bit.Pam.Models;
+
+namespace Bit.Pam.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/Pam.Domain/packages.lock.json b/src/Pam.Domain/packages.lock.json
new file mode 100644
index 000000000000..322ce177390b
--- /dev/null
+++ b/src/Pam.Domain/packages.lock.json
@@ -0,0 +1,10 @@
+{
+ "version": 1,
+ "dependencies": {
+ "net10.0": {
+ "data": {
+ "type": "Project"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_Create.sql b/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_Create.sql
index 2b3b14fd6bd9..a5cdefd64201 100644
--- a/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_Create.sql
+++ b/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_Create.sql
@@ -6,7 +6,8 @@
@CreationDate DATETIME2(7),
@RevisionDate DATETIME2(7),
@DefaultUserCollectionEmail NVARCHAR(256) = NULL,
- @Type TINYINT = 0
+ @Type TINYINT = 0,
+ @AccessRuleId UNIQUEIDENTIFIER = NULL
AS
BEGIN
SET NOCOUNT ON
@@ -20,7 +21,8 @@ BEGIN
[CreationDate],
[RevisionDate],
[DefaultUserCollectionEmail],
- [Type]
+ [Type],
+ [AccessRuleId]
)
VALUES
(
@@ -31,7 +33,8 @@ BEGIN
@CreationDate,
@RevisionDate,
@DefaultUserCollectionEmail,
- @Type
+ @Type,
+ @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 92ffd366e69e..cfd80ae9136e 100644
--- a/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_CreateWithGroupsAndUsers.sql
+++ b/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_CreateWithGroupsAndUsers.sql
@@ -8,12 +8,13 @@ CREATE PROCEDURE [dbo].[Collection_CreateWithGroupsAndUsers]
@Groups AS [dbo].[CollectionAccessSelectionType] READONLY,
@Users AS [dbo].[CollectionAccessSelectionType] READONLY,
@DefaultUserCollectionEmail NVARCHAR(256) = NULL,
- @Type TINYINT = 0
+ @Type TINYINT = 0,
+ @AccessRuleId UNIQUEIDENTIFIER = NULL
AS
BEGIN
SET NOCOUNT ON
- EXEC [dbo].[Collection_Create] @Id, @OrganizationId, @Name, @ExternalId, @CreationDate, @RevisionDate, @DefaultUserCollectionEmail, @Type
+ 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 9f2caeb87f81..cb23b2cb4d9e 100644
--- a/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_ReadByIdWithPermissions.sql
+++ b/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_ReadByIdWithPermissions.sql
@@ -75,7 +75,8 @@ BEGIN
C.[RevisionDate],
C.[ExternalId],
C.[DefaultUserCollectionEmail],
- C.[Type]
+ C.[Type],
+ 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 4180dc6909ed..c9bd7e96bee1 100644
--- a/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_ReadByUserId.sql
+++ b/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_ReadByUserId.sql
@@ -15,7 +15,8 @@ BEGIN
MIN([HidePasswords]) AS [HidePasswords],
MAX([Manage]) AS [Manage],
[DefaultUserCollectionEmail],
- [Type]
+ [Type],
+ [AccessRuleId]
FROM
[dbo].[UserCollectionDetails](@UserId)
GROUP BY
@@ -26,5 +27,6 @@ BEGIN
RevisionDate,
ExternalId,
[DefaultUserCollectionEmail],
- [Type]
+ [Type],
+ [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 52120fe28af2..9310c102e023 100644
--- a/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_ReadSharedCollectionsByOrganizationIdWithPermissions.sql
+++ b/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_ReadSharedCollectionsByOrganizationIdWithPermissions.sql
@@ -76,7 +76,8 @@ BEGIN
C.[RevisionDate],
C.[ExternalId],
C.[DefaultUserCollectionEmail],
- C.[Type]
+ C.[Type],
+ C.[AccessRuleId]
IF (@IncludeAccessRelationships = 1)
BEGIN
diff --git a/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_SetAccessRuleAssociations.sql b/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_SetAccessRuleAssociations.sql
new file mode 100644
index 000000000000..9a52130f076b
--- /dev/null
+++ b/src/Sql/dbo/AdminConsole/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/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_Update.sql b/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_Update.sql
index 69a009e27a4e..5bf3566a51af 100644
--- a/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_Update.sql
+++ b/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_Update.sql
@@ -6,7 +6,8 @@
@CreationDate DATETIME2(7),
@RevisionDate DATETIME2(7),
@DefaultUserCollectionEmail NVARCHAR(256) = NULL,
- @Type TINYINT = 0
+ @Type TINYINT = 0,
+ @AccessRuleId UNIQUEIDENTIFIER = NULL
AS
BEGIN
SET NOCOUNT ON
@@ -20,7 +21,8 @@ BEGIN
[CreationDate] = @CreationDate,
[RevisionDate] = @RevisionDate,
[DefaultUserCollectionEmail] = @DefaultUserCollectionEmail,
- [Type] = @Type
+ [Type] = @Type,
+ [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 13b03e8d98a4..d0380f899fc6 100644
--- a/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_UpdateWithGroups.sql
+++ b/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_UpdateWithGroups.sql
@@ -7,12 +7,13 @@ CREATE PROCEDURE [dbo].[Collection_UpdateWithGroups]
@RevisionDate DATETIME2(7),
@Groups AS [dbo].[CollectionAccessSelectionType] READONLY,
@DefaultUserCollectionEmail NVARCHAR(256) = NULL,
- @Type TINYINT = 0
+ @Type TINYINT = 0,
+ @AccessRuleId UNIQUEIDENTIFIER = NULL
AS
BEGIN
SET NOCOUNT ON
- EXEC [dbo].[Collection_Update] @Id, @OrganizationId, @Name, @ExternalId, @CreationDate, @RevisionDate, @DefaultUserCollectionEmail, @Type
+ 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 80e980019da4..3be2d74dc61b 100644
--- a/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_UpdateWithGroupsAndUsers.sql
+++ b/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_UpdateWithGroupsAndUsers.sql
@@ -8,12 +8,13 @@
@Groups AS [dbo].[CollectionAccessSelectionType] READONLY,
@Users AS [dbo].[CollectionAccessSelectionType] READONLY,
@DefaultUserCollectionEmail NVARCHAR(256) = NULL,
- @Type TINYINT = 0
+ @Type TINYINT = 0,
+ @AccessRuleId UNIQUEIDENTIFIER = NULL
AS
BEGIN
SET NOCOUNT ON
- EXEC [dbo].[Collection_Update] @Id, @OrganizationId, @Name, @ExternalId, @CreationDate, @RevisionDate, @DefaultUserCollectionEmail, @Type
+ 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 60fccc51d558..ae51a43fabb0 100644
--- a/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_UpdateWithUsers.sql
+++ b/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_UpdateWithUsers.sql
@@ -7,12 +7,13 @@ CREATE PROCEDURE [dbo].[Collection_UpdateWithUsers]
@RevisionDate DATETIME2(7),
@Users AS [dbo].[CollectionAccessSelectionType] READONLY,
@DefaultUserCollectionEmail NVARCHAR(256) = NULL,
- @Type TINYINT = 0
+ @Type TINYINT = 0,
+ @AccessRuleId UNIQUEIDENTIFIER = NULL
AS
BEGIN
SET NOCOUNT ON
- EXEC [dbo].[Collection_Update] @Id, @OrganizationId, @Name, @ExternalId, @CreationDate, @RevisionDate, @DefaultUserCollectionEmail, @Type
+ 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/AdminConsole/Tables/Collection.sql b/src/Sql/dbo/AdminConsole/Tables/Collection.sql
index 2f0d3b943bdb..b5bef98062a0 100644
--- a/src/Sql/dbo/AdminConsole/Tables/Collection.sql
+++ b/src/Sql/dbo/AdminConsole/Tables/Collection.sql
@@ -7,8 +7,10 @@
[RevisionDate] DATETIME2 (7) NOT NULL,
[DefaultUserCollectionEmail] NVARCHAR(256) NULL,
[Type] TINYINT NOT NULL DEFAULT(0),
+ [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_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]) ON DELETE CASCADE,
+ CONSTRAINT [FK_Collection_AccessRule] FOREIGN KEY ([AccessRuleId]) REFERENCES [dbo].[AccessRule] ([Id]) ON DELETE NO ACTION
);
GO
@@ -17,3 +19,7 @@ CREATE NONCLUSTERED INDEX [IX_Collection_OrganizationId_IncludeAll]
INCLUDE([CreationDate], [Name], [RevisionDate], [Type]);
GO
+CREATE NONCLUSTERED INDEX [IX_Collection_AccessRuleId]
+ ON [dbo].[Collection]([AccessRuleId] ASC);
+GO
+
diff --git a/src/Sql/dbo/Pam/Stored Procedures/AccessRule_Create.sql b/src/Sql/dbo/Pam/Stored Procedures/AccessRule_Create.sql
new file mode 100644
index 000000000000..f87464708316
--- /dev/null
+++ b/src/Sql/dbo/Pam/Stored Procedures/AccessRule_Create.sql
@@ -0,0 +1,54 @@
+CREATE 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),
+ @LastEditedBy UNIQUEIDENTIFIER = NULL
+AS
+BEGIN
+ SET NOCOUNT ON
+
+ INSERT INTO [dbo].[AccessRule]
+ (
+ [Id],
+ [OrganizationId],
+ [Name],
+ [Description],
+ [Conditions],
+ [SingleActiveLease],
+ [DefaultLeaseDurationSeconds],
+ [MaxLeaseDurationSeconds],
+ [Enabled],
+ [AllowsExtensions],
+ [MaxExtensionDurationSeconds],
+ [CreationDate],
+ [RevisionDate],
+ [LastEditedBy]
+ )
+ VALUES
+ (
+ @Id,
+ @OrganizationId,
+ @Name,
+ @Description,
+ @Conditions,
+ @SingleActiveLease,
+ @DefaultLeaseDurationSeconds,
+ @MaxLeaseDurationSeconds,
+ @Enabled,
+ @AllowsExtensions,
+ @MaxExtensionDurationSeconds,
+ @CreationDate,
+ @RevisionDate,
+ @LastEditedBy
+ )
+END
diff --git a/src/Sql/dbo/Pam/Stored Procedures/AccessRule_DeleteById.sql b/src/Sql/dbo/Pam/Stored Procedures/AccessRule_DeleteById.sql
new file mode 100644
index 000000000000..6df7aef0d4a3
--- /dev/null
+++ b/src/Sql/dbo/Pam/Stored Procedures/AccessRule_DeleteById.sql
@@ -0,0 +1,30 @@
+CREATE PROCEDURE [dbo].[AccessRule_DeleteById]
+ @Id UNIQUEIDENTIFIER
+AS
+BEGIN
+ SET NOCOUNT ON
+
+ DECLARE @OrganizationId UNIQUEIDENTIFIER
+
+ SELECT @OrganizationId = [OrganizationId]
+ FROM [dbo].[AccessRule]
+ WHERE [Id] = @Id
+
+ IF @OrganizationId IS NULL
+ BEGIN
+ -- Already gone: idempotent no-op.
+ RETURN
+ END
+
+ -- Clear the collection links first: the FK Collection.AccessRuleId -> AccessRule is ON DELETE NO ACTION, so the
+ -- referencing rows must be detached before the rule can be removed. A cleared collection is simply ungoverned.
+ UPDATE [dbo].[Collection]
+ SET [AccessRuleId] = NULL,
+ [RevisionDate] = SYSUTCDATETIME()
+ WHERE [AccessRuleId] = @Id
+
+ DELETE FROM [dbo].[AccessRule]
+ WHERE [Id] = @Id
+
+ EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationId] @OrganizationId
+END
diff --git a/src/Sql/dbo/Pam/Stored Procedures/AccessRule_ReadById.sql b/src/Sql/dbo/Pam/Stored Procedures/AccessRule_ReadById.sql
new file mode 100644
index 000000000000..1851baead921
--- /dev/null
+++ b/src/Sql/dbo/Pam/Stored Procedures/AccessRule_ReadById.sql
@@ -0,0 +1,10 @@
+CREATE PROCEDURE [dbo].[AccessRule_ReadById]
+ @Id UNIQUEIDENTIFIER
+AS
+BEGIN
+ SET NOCOUNT ON
+
+ SELECT *
+ FROM [dbo].[AccessRule]
+ WHERE [Id] = @Id
+END
diff --git a/src/Sql/dbo/Pam/Stored Procedures/AccessRule_ReadByOrganizationId.sql b/src/Sql/dbo/Pam/Stored Procedures/AccessRule_ReadByOrganizationId.sql
new file mode 100644
index 000000000000..60c001940650
--- /dev/null
+++ b/src/Sql/dbo/Pam/Stored Procedures/AccessRule_ReadByOrganizationId.sql
@@ -0,0 +1,10 @@
+CREATE PROCEDURE [dbo].[AccessRule_ReadByOrganizationId]
+ @OrganizationId UNIQUEIDENTIFIER
+AS
+BEGIN
+ SET NOCOUNT ON
+
+ SELECT *
+ FROM [dbo].[AccessRule]
+ WHERE [OrganizationId] = @OrganizationId
+END
diff --git a/src/Sql/dbo/Pam/Stored Procedures/AccessRule_ReadDetailsById.sql b/src/Sql/dbo/Pam/Stored Procedures/AccessRule_ReadDetailsById.sql
new file mode 100644
index 000000000000..f9ee5eec5992
--- /dev/null
+++ b/src/Sql/dbo/Pam/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/Pam/Stored Procedures/AccessRule_ReadDetailsByOrganizationId.sql b/src/Sql/dbo/Pam/Stored Procedures/AccessRule_ReadDetailsByOrganizationId.sql
new file mode 100644
index 000000000000..6a4cb6d4bd40
--- /dev/null
+++ b/src/Sql/dbo/Pam/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/Pam/Stored Procedures/AccessRule_Update.sql b/src/Sql/dbo/Pam/Stored Procedures/AccessRule_Update.sql
new file mode 100644
index 000000000000..6f478c02db41
--- /dev/null
+++ b/src/Sql/dbo/Pam/Stored Procedures/AccessRule_Update.sql
@@ -0,0 +1,38 @@
+CREATE 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),
+ @LastEditedBy UNIQUEIDENTIFIER = NULL
+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,
+ [LastEditedBy] = @LastEditedBy
+ WHERE
+ [Id] = @Id
+END
diff --git a/src/Sql/dbo/Pam/Tables/AccessRule.sql b/src/Sql/dbo/Pam/Tables/AccessRule.sql
new file mode 100644
index 000000000000..ab32aa6bdc60
--- /dev/null
+++ b/src/Sql/dbo/Pam/Tables/AccessRule.sql
@@ -0,0 +1,25 @@
+CREATE TABLE [dbo].[AccessRule] (
+ [Id] UNIQUEIDENTIFIER NOT NULL,
+ [OrganizationId] UNIQUEIDENTIFIER NOT NULL,
+ [Name] NVARCHAR(256) NOT NULL,
+ [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,
+ [Enabled] BIT NOT NULL CONSTRAINT [DF_AccessRule_Enabled] DEFAULT (1),
+ [AllowsExtensions] BIT NOT NULL CONSTRAINT [DF_AccessRule_AllowsExtensions] DEFAULT (0),
+ [MaxExtensionDurationSeconds] INT NULL,
+ [CreationDate] DATETIME2(7) NOT NULL,
+ [RevisionDate] DATETIME2(7) NOT NULL,
+ [LastEditedBy] UNIQUEIDENTIFIER NULL,
+ CONSTRAINT [PK_AccessRule] PRIMARY KEY CLUSTERED ([Id] ASC),
+ CONSTRAINT [FK_AccessRule_Organization] FOREIGN KEY ([OrganizationId])
+ REFERENCES [dbo].[Organization] ([Id]) ON DELETE CASCADE
+);
+GO
+
+-- A rule's name is unique per organization; a hard delete frees the name naturally.
+CREATE UNIQUE NONCLUSTERED INDEX [IX_AccessRule_OrganizationId_Name]
+ ON [dbo].[AccessRule] ([OrganizationId] ASC, [Name] ASC);
+GO
diff --git a/test/Infrastructure.IntegrationTest/Pam/Repositories/AccessRuleRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Pam/Repositories/AccessRuleRepositoryTests.cs
new file mode 100644
index 000000000000..0fe34deafcdd
--- /dev/null
+++ b/test/Infrastructure.IntegrationTest/Pam/Repositories/AccessRuleRepositoryTests.cs
@@ -0,0 +1,88 @@
+using Bit.Core.Entities;
+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;
+
+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",
+ Conditions = """{"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);
+ }
+
+ [DatabaseTheory, DatabaseData]
+ public async Task CreateAsync_ReusingNameOfDeletedRule_Succeeds(
+ IOrganizationRepository organizationRepository,
+ IAccessRuleRepository accessRuleRepository)
+ {
+ // Arrange
+ var organization = await organizationRepository.CreateTestOrganizationAsync();
+
+ var original = await accessRuleRepository.CreateAsync(new AccessRule
+ {
+ OrganizationId = organization.Id,
+ Name = "Reusable Name",
+ Conditions = """{"kind":"human_approval"}""",
+ });
+
+ // Act: delete the rule, then create a new one reusing its name. A hard delete removes the row, so the unique
+ // index on (OrganizationId, Name) no longer reserves the name.
+ await accessRuleRepository.DeleteAsync(original);
+
+ var recreated = await accessRuleRepository.CreateAsync(new AccessRule
+ {
+ OrganizationId = organization.Id,
+ Name = "Reusable Name",
+ Conditions = """{"kind":"human_approval"}""",
+ });
+
+ // Assert: a distinct, live rule owns the name and the original stays gone.
+ Assert.NotEqual(original.Id, recreated.Id);
+ Assert.Null(await accessRuleRepository.GetByIdAsync(original.Id));
+
+ var live = await accessRuleRepository.GetByIdAsync(recreated.Id);
+ Assert.NotNull(live);
+ Assert.Equal("Reusable Name", live.Name);
+ }
+}
diff --git a/util/Migrator/DbScripts/2026-07-14_01_AddAccessRule.sql b/util/Migrator/DbScripts/2026-07-14_01_AddAccessRule.sql
new file mode 100644
index 000000000000..570e4547d12c
--- /dev/null
+++ b/util/Migrator/DbScripts/2026-07-14_01_AddAccessRule.sql
@@ -0,0 +1,999 @@
+-- Add the PAM AccessRule feature: the AccessRule table, its stored procedures, the
+-- Collection.AccessRuleId association (column + FK + index + the Collection procs that carry it),
+-- and Collection_SetAccessRuleAssociations. Consolidated net-new migration (the feature has not shipped).
+
+-- AccessRule table
+IF OBJECT_ID('[dbo].[AccessRule]') IS NULL
+BEGIN
+ CREATE TABLE [dbo].[AccessRule] (
+ [Id] UNIQUEIDENTIFIER NOT NULL,
+ [OrganizationId] UNIQUEIDENTIFIER NOT NULL,
+ [Name] NVARCHAR(256) NOT NULL,
+ [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,
+ [Enabled] BIT NOT NULL CONSTRAINT [DF_AccessRule_Enabled] DEFAULT (1),
+ [AllowsExtensions] BIT NOT NULL CONSTRAINT [DF_AccessRule_AllowsExtensions] DEFAULT (0),
+ [MaxExtensionDurationSeconds] INT NULL,
+ [CreationDate] DATETIME2(7) NOT NULL,
+ [RevisionDate] DATETIME2(7) NOT NULL,
+ [LastEditedBy] UNIQUEIDENTIFIER NULL,
+ CONSTRAINT [PK_AccessRule] PRIMARY KEY CLUSTERED ([Id] ASC),
+ CONSTRAINT [FK_AccessRule_Organization] FOREIGN KEY ([OrganizationId])
+ REFERENCES [dbo].[Organization] ([Id]) ON DELETE CASCADE
+ );
+END
+GO
+
+IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE [name] = 'IX_AccessRule_OrganizationId_Name' AND object_id = OBJECT_ID('[dbo].[AccessRule]'))
+BEGIN
+ CREATE UNIQUE NONCLUSTERED INDEX [IX_AccessRule_OrganizationId_Name]
+ ON [dbo].[AccessRule] ([OrganizationId] ASC, [Name] ASC);
+END
+GO
+
+-- Collection.AccessRuleId association
+IF COL_LENGTH('[dbo].[Collection]', 'AccessRuleId') IS NULL
+BEGIN
+ ALTER TABLE [dbo].[Collection]
+ ADD [AccessRuleId] UNIQUEIDENTIFIER NULL;
+END
+GO
+
+IF NOT EXISTS (SELECT 1 FROM sys.foreign_keys WHERE [name] = 'FK_Collection_AccessRule')
+BEGIN
+ ALTER TABLE [dbo].[Collection]
+ ADD CONSTRAINT [FK_Collection_AccessRule] FOREIGN KEY ([AccessRuleId])
+ REFERENCES [dbo].[AccessRule] ([Id]) ON DELETE NO ACTION;
+END
+GO
+
+IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE [name] = 'IX_Collection_AccessRuleId' AND object_id = OBJECT_ID('[dbo].[Collection]'))
+BEGIN
+ CREATE NONCLUSTERED INDEX [IX_Collection_AccessRuleId]
+ ON [dbo].[Collection]([AccessRuleId] ASC);
+END
+GO
+
+-- Refresh CollectionView and the UserCollectionDetails function so they surface the new
+-- AccessRuleId column before the read procedures below reference it. A SELECT * view/function
+-- does not pick up new base-table columns until refreshed, and UserCollectionDetails
+-- (which selects C.* from CollectionView) must be refreshed after the view it depends on.
+IF OBJECT_ID('[dbo].[CollectionView]') IS NOT NULL
+ BEGIN
+ EXECUTE sp_refreshsqlmodule N'[dbo].[CollectionView]';
+ END
+GO
+
+IF OBJECT_ID('[dbo].[UserCollectionDetails]') IS NOT NULL
+ BEGIN
+ EXECUTE sp_refreshsqlmodule N'[dbo].[UserCollectionDetails]';
+ END
+GO
+
+-- AccessRule_Create
+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),
+ @LastEditedBy UNIQUEIDENTIFIER = NULL
+AS
+BEGIN
+ SET NOCOUNT ON
+
+ INSERT INTO [dbo].[AccessRule]
+ (
+ [Id],
+ [OrganizationId],
+ [Name],
+ [Description],
+ [Conditions],
+ [SingleActiveLease],
+ [DefaultLeaseDurationSeconds],
+ [MaxLeaseDurationSeconds],
+ [Enabled],
+ [AllowsExtensions],
+ [MaxExtensionDurationSeconds],
+ [CreationDate],
+ [RevisionDate],
+ [LastEditedBy]
+ )
+ VALUES
+ (
+ @Id,
+ @OrganizationId,
+ @Name,
+ @Description,
+ @Conditions,
+ @SingleActiveLease,
+ @DefaultLeaseDurationSeconds,
+ @MaxLeaseDurationSeconds,
+ @Enabled,
+ @AllowsExtensions,
+ @MaxExtensionDurationSeconds,
+ @CreationDate,
+ @RevisionDate,
+ @LastEditedBy
+ )
+END
+GO
+
+-- AccessRule_Update
+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),
+ @LastEditedBy UNIQUEIDENTIFIER = NULL
+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,
+ [LastEditedBy] = @LastEditedBy
+ WHERE
+ [Id] = @Id
+END
+GO
+
+-- AccessRule_DeleteById
+CREATE OR ALTER PROCEDURE [dbo].[AccessRule_DeleteById]
+ @Id UNIQUEIDENTIFIER
+AS
+BEGIN
+ SET NOCOUNT ON
+
+ DECLARE @OrganizationId UNIQUEIDENTIFIER
+
+ SELECT @OrganizationId = [OrganizationId]
+ FROM [dbo].[AccessRule]
+ WHERE [Id] = @Id
+
+ IF @OrganizationId IS NULL
+ BEGIN
+ -- Already gone: idempotent no-op.
+ RETURN
+ END
+
+ -- Clear the collection links first: the FK Collection.AccessRuleId -> AccessRule is ON DELETE NO ACTION, so the
+ -- referencing rows must be detached before the rule can be removed. A cleared collection is simply ungoverned.
+ UPDATE [dbo].[Collection]
+ SET [AccessRuleId] = NULL,
+ [RevisionDate] = SYSUTCDATETIME()
+ WHERE [AccessRuleId] = @Id
+
+ DELETE FROM [dbo].[AccessRule]
+ WHERE [Id] = @Id
+
+ EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationId] @OrganizationId
+END
+GO
+
+-- AccessRule_ReadById
+CREATE OR ALTER PROCEDURE [dbo].[AccessRule_ReadById]
+ @Id UNIQUEIDENTIFIER
+AS
+BEGIN
+ SET NOCOUNT ON
+
+ SELECT *
+ FROM [dbo].[AccessRule]
+ WHERE [Id] = @Id
+END
+GO
+
+-- AccessRule_ReadByOrganizationId
+CREATE OR ALTER PROCEDURE [dbo].[AccessRule_ReadByOrganizationId]
+ @OrganizationId UNIQUEIDENTIFIER
+AS
+BEGIN
+ SET NOCOUNT ON
+
+ SELECT *
+ FROM [dbo].[AccessRule]
+ WHERE [OrganizationId] = @OrganizationId
+END
+GO
+
+-- AccessRule_ReadDetailsById
+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
+
+-- AccessRule_ReadDetailsByOrganizationId
+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
+
+-- Collection_SetAccessRuleAssociations
+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
+
+-- Collection_Create
+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,
+ @AccessRuleId UNIQUEIDENTIFIER = NULL
+AS
+BEGIN
+ SET NOCOUNT ON
+
+ INSERT INTO [dbo].[Collection]
+ (
+ [Id],
+ [OrganizationId],
+ [Name],
+ [ExternalId],
+ [CreationDate],
+ [RevisionDate],
+ [DefaultUserCollectionEmail],
+ [Type],
+ [AccessRuleId]
+ )
+ VALUES
+ (
+ @Id,
+ @OrganizationId,
+ @Name,
+ @ExternalId,
+ @CreationDate,
+ @RevisionDate,
+ @DefaultUserCollectionEmail,
+ @Type,
+ @AccessRuleId
+ )
+
+ EXEC [dbo].[User_BumpAccountRevisionDateByCollectionId] @Id, @OrganizationId
+END
+GO
+
+-- Collection_CreateWithGroupsAndUsers
+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,
+ @AccessRuleId UNIQUEIDENTIFIER = NULL
+AS
+BEGIN
+ SET NOCOUNT ON
+
+ EXEC [dbo].[Collection_Create] @Id, @OrganizationId, @Name, @ExternalId, @CreationDate, @RevisionDate, @DefaultUserCollectionEmail, @Type, @AccessRuleId
+
+ -- 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
+
+-- Collection_Update
+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,
+ @AccessRuleId UNIQUEIDENTIFIER = NULL
+AS
+BEGIN
+ SET NOCOUNT ON
+
+ UPDATE
+ [dbo].[Collection]
+ SET
+ [OrganizationId] = @OrganizationId,
+ [Name] = @Name,
+ [ExternalId] = @ExternalId,
+ [CreationDate] = @CreationDate,
+ [RevisionDate] = @RevisionDate,
+ [DefaultUserCollectionEmail] = @DefaultUserCollectionEmail,
+ [Type] = @Type,
+ [AccessRuleId] = @AccessRuleId
+ WHERE
+ [Id] = @Id
+
+ EXEC [dbo].[User_BumpAccountRevisionDateByCollectionId] @Id, @OrganizationId
+END
+GO
+
+-- Collection_UpdateWithGroups
+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,
+ @AccessRuleId UNIQUEIDENTIFIER = NULL
+AS
+BEGIN
+ SET NOCOUNT ON
+
+ 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 (
+ 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
+
+-- Collection_UpdateWithGroupsAndUsers
+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,
+ @AccessRuleId UNIQUEIDENTIFIER = NULL
+AS
+BEGIN
+ SET NOCOUNT ON
+
+ 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 (
+ 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
+
+-- Collection_UpdateWithUsers
+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,
+ @AccessRuleId UNIQUEIDENTIFIER = NULL
+AS
+BEGIN
+ SET NOCOUNT ON
+
+ EXEC [dbo].[Collection_Update] @Id, @OrganizationId, @Name, @ExternalId, @CreationDate, @RevisionDate, @DefaultUserCollectionEmail, @Type, @AccessRuleId
+
+ -- 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
+
+-- Collection_ReadByIdWithPermissions
+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.[AccessRuleId]
+
+ IF (@IncludeAccessRelationships = 1)
+ BEGIN
+ EXEC [dbo].[CollectionGroup_ReadByCollectionId] @CollectionId
+ EXEC [dbo].[CollectionUser_ReadByCollectionId] @CollectionId
+ END
+END
+GO
+
+-- Collection_ReadByUserId
+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],
+ [AccessRuleId]
+ FROM
+ [dbo].[UserCollectionDetails](@UserId)
+ GROUP BY
+ Id,
+ OrganizationId,
+ [Name],
+ CreationDate,
+ RevisionDate,
+ ExternalId,
+ [DefaultUserCollectionEmail],
+ [Type],
+ [AccessRuleId]
+END
+GO
+
+-- Collection_ReadSharedCollectionsByOrganizationIdWithPermissions
+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.[AccessRuleId]
+
+ IF (@IncludeAccessRelationships = 1)
+ BEGIN
+ EXEC [dbo].[CollectionGroup_ReadByOrganizationId] @OrganizationId
+ EXEC [dbo].[CollectionUser_ReadByOrganizationId] @OrganizationId
+ END
+END
+GO
+
diff --git a/util/MySqlMigrations/Migrations/20260714113756_AddAccessRule.Designer.cs b/util/MySqlMigrations/Migrations/20260714113756_AddAccessRule.Designer.cs
new file mode 100644
index 000000000000..06f91fb10e6c
--- /dev/null
+++ b/util/MySqlMigrations/Migrations/20260714113756_AddAccessRule.Designer.cs
@@ -0,0 +1,3848 @@
+//
+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("20260714113756_AddAccessRule")]
+ partial class AddAccessRule
+ {
+ ///
+ 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("Invite")
+ .IsRequired()
+ .HasColumnType("longtext");
+
+ b.Property("OrganizationId")
+ .HasColumnType("char(36)");
+
+ b.Property("RevisionDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("SupportsConfirmation")
+ .HasColumnType("tinyint(1)");
+
+ 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