Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions bitwarden-server.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
<Project Path="src/Infrastructure.Dapper/Infrastructure.Dapper.csproj" />
<Project Path="src/Infrastructure.EntityFramework/Infrastructure.EntityFramework.csproj" />
<Project Path="src/Notifications/Notifications.csproj" />
<Project Path="src/Pam.Domain/Pam.Domain.csproj" />
<Project Path="src/SharedWeb/SharedWeb.csproj" />
<Project Path="src/Sql/Sql.sqlproj">
<Build />
Expand Down
6 changes: 6 additions & 0 deletions src/Core/AdminConsole/Entities/Collection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@ public class Collection : ITableObject<Guid>
/// </summary>
public string? DefaultUserCollectionEmail { get; set; }

/// <summary>
/// Reference to the <c>AccessRule</c> that gates PAM credential leasing for this
/// collection. Null means leasing is disabled for the collection.
/// </summary>
public Guid? AccessRuleId { get; set; }

/// <summary>
/// Initializes <see cref="Id"/> to a new COMB GUID.
/// </summary>
Expand Down
6 changes: 6 additions & 0 deletions src/Core/packages.lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -1192,6 +1192,12 @@
},
"data": {
"type": "Project"
},
"pam.domain": {
"type": "Project",
"dependencies": {
"Data": "[2026.6.2, )"
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -54,6 +55,7 @@ public static void AddDapperRepositories(this IServiceCollection services, bool
services.AddSingleton<IOrganizationSponsorshipRepository, OrganizationSponsorshipRepository>();
services.AddSingleton<IOrganizationUserRepository, OrganizationUserRepository>();
services.AddSingleton<IOrganizationInviteLinkRepository, OrganizationInviteLinkRepository>();
services.AddSingleton<IAccessRuleRepository, Pam.Repositories.AccessRuleRepository>();
services.AddSingleton<IPlayItemRepository, PlayItemRepository>();
services.AddSingleton<IPolicyRepository, PolicyRepository>();
services.AddSingleton<IProviderOrganizationRepository, ProviderOrganizationRepository>();
Expand Down
1 change: 1 addition & 0 deletions src/Infrastructure.Dapper/Infrastructure.Dapper.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

<ItemGroup>
<ProjectReference Include="..\Core\Core.csproj" />
<ProjectReference Include="..\Pam.Domain\Pam.Domain.csproj" />
</ItemGroup>

<ItemGroup>
Expand Down
98 changes: 98 additions & 0 deletions src/Infrastructure.Dapper/Pam/Repositories/AccessRuleRepository.cs
Original file line number Diff line number Diff line change
@@ -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<AccessRule, Guid>, IAccessRuleRepository
{
public AccessRuleRepository(GlobalSettings globalSettings)
: this(globalSettings.SqlServer.ConnectionString, globalSettings.SqlServer.ReadOnlyConnectionString)
{ }

public AccessRuleRepository(string connectionString, string readOnlyConnectionString)
: base(connectionString, readOnlyConnectionString)
{ }

public async Task<ICollection<AccessRule>> GetManyByOrganizationIdAsync(Guid organizationId)
{
using var connection = new SqlConnection(ConnectionString);
var results = await connection.QueryAsync<AccessRule>(
$"[{Schema}].[AccessRule_ReadByOrganizationId]",
new { OrganizationId = organizationId },
commandType: CommandType.StoredProcedure);

return results.ToList();
}

public async Task<AccessRuleDetails?> 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<AccessRuleDetails>();
if (rule is null)
{
return null;
}

rule.CollectionIds = (await results.ReadAsync<Guid>()).ToList();
return rule;
}

public async Task<ICollection<AccessRuleDetails>> 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<AccessRuleDetails>()).ToList();
var collectionIdsByRule = (await results.ReadAsync<CollectionAccessRuleMapping>())
.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;
}
}
Comment thread
Hinton marked this conversation as resolved.
Dismissed

return rules;
}

public async Task SetCollectionAssociationsAsync(Guid organizationId, Guid accessRuleId,
IEnumerable<Guid> collectionIdsToAssign, IEnumerable<Guid> 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; }
}
}
11 changes: 11 additions & 0 deletions src/Infrastructure.Dapper/packages.lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -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]",
Expand All @@ -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]",
Expand All @@ -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, )"
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -102,6 +103,7 @@ public static void AddPasswordManagerEFRepositories(this IServiceCollection serv
services.AddSingleton<ITransactionRepository, TransactionRepository>();
services.AddSingleton<IUserRepository, UserRepository>();
services.AddSingleton<IOrganizationDomainRepository, OrganizationDomainRepository>();
services.AddSingleton<IAccessRuleRepository, Pam.Repositories.AccessRuleRepository>();
services.AddSingleton<IWebAuthnCredentialRepository, WebAuthnCredentialRepository>();
services.AddSingleton<IProviderPlanRepository, ProviderPlanRepository>();
services.AddSingleton<IProviderInvoiceItemRepository, ProviderInvoiceItemRepository>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,6 @@

<ItemGroup>
<ProjectReference Include="..\Core\Core.csproj" />
<ProjectReference Include="..\Pam.Domain\Pam.Domain.csproj" />
</ItemGroup>
</Project>
21 changes: 21 additions & 0 deletions src/Infrastructure.EntityFramework/Pam/Models/AccessRule.cs
Original file line number Diff line number Diff line change
@@ -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
Comment thread
Hinton marked this conversation as resolved.
Dismissed
{
public virtual Organization Organization { get; set; }
}

public class AccessRuleMapperProfile : Profile
{
public AccessRuleMapperProfile()
{
CreateMap<Bit.Pam.Entities.AccessRule, AccessRule>().ReverseMap();
CreateMap<AccessRule, Bit.Pam.Models.AccessRuleDetails>();
}
}
Original file line number Diff line number Diff line change
@@ -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<CoreEntity, EfModel, Guid>, IAccessRuleRepository
{
public AccessRuleRepository(IServiceScopeFactory serviceScopeFactory, IMapper mapper)
: base(serviceScopeFactory, mapper, context => context.AccessRules)
{ }

public async Task<ICollection<CoreEntity>> 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<List<CoreEntity>>(rules);
}

public async Task<AccessRuleDetails?> 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<AccessRuleDetails>(rule);
details.CollectionIds = await dbContext.Collections
.Where(c => c.AccessRuleId == id)
.Select(c => c.Id)
.ToListAsync();
return details;
}

public async Task<ICollection<AccessRuleDetails>> 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<AccessRuleDetails>(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<Guid> collectionIdsToAssign, IEnumerable<Guid> 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();
}
}
Loading
Loading