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
388 changes: 388 additions & 0 deletions test/SeederApi.IntegrationTest/Scenes/SecretsManagerSceneTests.cs

Large diffs are not rendered by default.

9 changes: 9 additions & 0 deletions test/SeederApi.IntegrationTest/packages.lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -1312,6 +1312,14 @@
"ZiggyCreatures.FusionCache": "2.0.2"
}
},
"commercial.infrastructure.entityframework": {
"type": "Project",
"dependencies": {
"AutoMapper": "[14.0.0, 14.0.0]",
"Core": "[2026.8.1, )",
"Infrastructure.EntityFramework": "[2026.8.1, )"
}
},
"common": {
"type": "Project",
"dependencies": {
Expand Down Expand Up @@ -1477,6 +1485,7 @@
"Bitwarden.Server.Sdk.Environment": "[0.1.0, )",
"Bitwarden.Server.Sdk.Features": "[1.4.0, )",
"Bitwarden.Server.Sdk.WebEssentials": "[0.5.0, )",
"Commercial.Infrastructure.EntityFramework": "[2026.8.1, )",
"Core": "[2026.8.1, )",
"OpenTelemetry.Exporter.OpenTelemetryProtocol": "[1.15.3, )",
"OpenTelemetry.Extensions.Hosting": "[1.15.3, )",
Expand Down
106 changes: 106 additions & 0 deletions util/Seeder/Extensions/SeederRepositoryGuardExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
ο»Ώusing Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Repositories;
using Bit.Core.SecretsManager.Repositories;

namespace Bit.Seeder.Extensions;

internal static class SeederRepositoryGuardExtensions
{
public static async Task<Organization> GetSecretsManagerOrganizationOrThrowAsync(
this IOrganizationRepository organizationRepository, Guid organizationId)
{
var organization = await organizationRepository.GetByIdAsync(organizationId);
if (organization == null)
{
throw new InvalidOperationException($"Organization {organizationId} not found.");
}

if (!organization.UseSecretsManager)
{
throw new InvalidOperationException(
$"Organization {organizationId} does not have Secrets Manager enabled.");
}

return organization;
}

public static async Task ThrowIfProjectsNotInOrganizationAsync(
this IProjectRepository projectRepository, IEnumerable<Guid>? projectIds, Guid organizationId)
{
var ids = projectIds?.ToList();
if (ids is not { Count: > 0 })
{
return;
}

if (!await projectRepository.ProjectsAreInOrganization(ids, organizationId))
{
throw new InvalidOperationException(
$"One or more projects are not in organization {organizationId}.");
}
}

public static async Task ThrowIfServiceAccountsNotInOrganizationAsync(
this IServiceAccountRepository serviceAccountRepository, IEnumerable<Guid>? serviceAccountIds, Guid organizationId)
{
var ids = serviceAccountIds?.ToList();
if (ids is not { Count: > 0 })
{
return;
}

if (!await serviceAccountRepository.ServiceAccountsAreInOrganizationAsync(ids, organizationId))
{
throw new InvalidOperationException(
$"One or more service accounts are not in organization {organizationId}.");
}
}

public static async Task ThrowIfGroupsNotInOrganizationAsync(
this IGroupRepository groupRepository, IEnumerable<Guid>? groupIds, Guid organizationId)
{
var ids = groupIds?.ToList();
if (ids is not { Count: > 0 })
{
return;
}

var groups = await groupRepository.GetManyByManyIds(ids);
if (!AllResolvedInOrganization(ids, groups.Select(g => (g.Id, g.OrganizationId)), organizationId))
{
throw new InvalidOperationException(
$"One or more groups are not in organization {organizationId}.");
}
}

public static async Task ThrowIfOrganizationUsersNotInOrganizationAsync(
this IOrganizationUserRepository organizationUserRepository, IEnumerable<Guid>? organizationUserIds, Guid organizationId)
{
var ids = organizationUserIds?.ToList();
if (ids is not { Count: > 0 })
{
return;
}

var users = await organizationUserRepository.GetManyAsync(ids);
if (!AllResolvedInOrganization(ids, users.Select(u => (u.Id, u.OrganizationId)), organizationId))
{
throw new InvalidOperationException(
$"One or more organization users are not in organization {organizationId}.");
}
}

private static bool AllResolvedInOrganization(
IReadOnlyCollection<Guid> requestedIds,
IEnumerable<(Guid Id, Guid OrganizationId)> resolved,
Guid organizationId)
{
var matched = resolved
.Where(r => r.OrganizationId == organizationId)
.Select(r => r.Id)
.ToHashSet();

return requestedIds.All(matched.Contains);
}
}
67 changes: 67 additions & 0 deletions util/Seeder/Factories/AccessPolicySeeder.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
ο»Ώusing Bit.Core.SecretsManager.Entities;

namespace Bit.Seeder.Factories;

public static class AccessPolicySeeder
{
public enum GranteeType
{
OrganizationUser,
Group,
ServiceAccount
}

public enum GrantableType
{
Project,
ServiceAccount
}

public static BaseAccessPolicy Create(
GranteeType granteeType,
Guid granteeId,
GrantableType grantableType,
Guid grantableId,
bool read,
bool write) =>
(granteeType, grantableType) switch
{
(GranteeType.OrganizationUser, GrantableType.Project) => new UserProjectAccessPolicy
{
OrganizationUserId = granteeId,
GrantedProjectId = grantableId,
Read = read,
Write = write
},
(GranteeType.OrganizationUser, GrantableType.ServiceAccount) => new UserServiceAccountAccessPolicy
{
OrganizationUserId = granteeId,
GrantedServiceAccountId = grantableId,
Read = read,
Write = write
},
(GranteeType.Group, GrantableType.Project) => new GroupProjectAccessPolicy
{
GroupId = granteeId,
GrantedProjectId = grantableId,
Read = read,
Write = write
},
(GranteeType.Group, GrantableType.ServiceAccount) => new GroupServiceAccountAccessPolicy
{
GroupId = granteeId,
GrantedServiceAccountId = grantableId,
Read = read,
Write = write
},
(GranteeType.ServiceAccount, GrantableType.Project) => new ServiceAccountProjectAccessPolicy
{
ServiceAccountId = granteeId,
GrantedProjectId = grantableId,
Read = read,
Write = write
},
_ => throw new InvalidOperationException(
$"Unsupported access policy: {granteeType} granted to {grantableType}.")
};
}
18 changes: 18 additions & 0 deletions util/Seeder/Factories/ProjectSeeder.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
ο»Ώusing Bit.Core.SecretsManager.Entities;
using Bit.Core.Utilities;
using Bit.RustSDK;

namespace Bit.Seeder.Factories;

internal static class ProjectSeeder
{
internal static Project Create(Guid organizationId, string orgKey, string name)
{
return new Project
{
Id = CombGuid.Generate(),
OrganizationId = organizationId,
Name = RustSdkService.EncryptString(name, orgKey)
};
}
}
29 changes: 29 additions & 0 deletions util/Seeder/Factories/SecretSeeder.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
ο»Ώusing Bit.Core.SecretsManager.Entities;
using Bit.Core.Utilities;
using Bit.RustSDK;

namespace Bit.Seeder.Factories;

internal static class SecretSeeder
{
internal static Secret Create(
Guid organizationId,
string orgKey,
string key,
string? value,
string? note,
IEnumerable<Guid>? projectIds)
{
return new Secret
{
Id = CombGuid.Generate(),
OrganizationId = organizationId,
Key = RustSdkService.EncryptString(key, orgKey),
Value = RustSdkService.EncryptString(value ?? string.Empty, orgKey),
Note = RustSdkService.EncryptString(note ?? string.Empty, orgKey),
Projects = projectIds?
.Select(id => new Project { Id = id, OrganizationId = organizationId })
.ToList()
};
}
}
18 changes: 18 additions & 0 deletions util/Seeder/Factories/ServiceAccountSeeder.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
ο»Ώusing Bit.Core.SecretsManager.Entities;
using Bit.Core.Utilities;
using Bit.RustSDK;

namespace Bit.Seeder.Factories;

internal static class ServiceAccountSeeder
{
internal static ServiceAccount Create(Guid organizationId, string orgKey, string name)
{
return new ServiceAccount
{
Id = CombGuid.Generate(),
OrganizationId = organizationId,
Name = RustSdkService.EncryptString(name, orgKey)
};
}
}
91 changes: 91 additions & 0 deletions util/Seeder/Scenes/OrganizationAccessPolicyScene.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
ο»Ώusing System.ComponentModel.DataAnnotations;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Repositories;
using Bit.Core.SecretsManager.Repositories;
using Bit.Seeder.Extensions;
using Bit.Seeder.Factories;
using Bit.Seeder.Services;

namespace Bit.Seeder.Scenes;

/// <summary>
/// Grants Secrets Manager access policies: each grant links a grantee (organization user, group, or
/// service account) to a grantable resource (project or service account) with read/write permissions.
/// </summary>
public class OrganizationAccessPolicyScene(
IOrganizationRepository organizationRepository,
IAccessPolicyRepository accessPolicyRepository,
IProjectRepository projectRepository,
IServiceAccountRepository serviceAccountRepository,
IGroupRepository groupRepository,
IOrganizationUserRepository organizationUserRepository,
IManglerService manglerService) : IScene<OrganizationAccessPolicyScene.Request, OrganizationAccessPolicyScene.Result>
{
public class Request
{
[Required]
public required Guid OrganizationId { get; set; }
[Required]
[MinLength(1)]
public required IEnumerable<Grant> Grants { get; set; }
}

public class Grant
{
[Required]
public required AccessPolicySeeder.GranteeType GranteeType { get; set; }
[Required]
public required Guid GranteeId { get; set; }
[Required]
public required AccessPolicySeeder.GrantableType GrantableType { get; set; }
[Required]
public required Guid GrantableId { get; set; }
public bool Read { get; set; } = true;
public bool Write { get; set; }
}

public class Result
{
public required int Count { get; init; }
public required IEnumerable<Guid> AccessPolicyIds { get; init; }
}

public async Task<SceneResult<Result>> SeedAsync(Request request)
{
await organizationRepository.GetSecretsManagerOrganizationOrThrowAsync(request.OrganizationId);

var grants = request.Grants.ToList();

var projectIds = GrantableIds(grants, AccessPolicySeeder.GrantableType.Project);
var serviceAccountIds = GrantableIds(grants, AccessPolicySeeder.GrantableType.ServiceAccount)
.Concat(GranteeIds(grants, AccessPolicySeeder.GranteeType.ServiceAccount))
.Distinct();
var groupIds = GranteeIds(grants, AccessPolicySeeder.GranteeType.Group);
var organizationUserIds = GranteeIds(grants, AccessPolicySeeder.GranteeType.OrganizationUser);

await projectRepository.ThrowIfProjectsNotInOrganizationAsync(projectIds, request.OrganizationId);
await serviceAccountRepository.ThrowIfServiceAccountsNotInOrganizationAsync(serviceAccountIds, request.OrganizationId);
await groupRepository.ThrowIfGroupsNotInOrganizationAsync(groupIds, request.OrganizationId);
await organizationUserRepository.ThrowIfOrganizationUsersNotInOrganizationAsync(organizationUserIds, request.OrganizationId);

var policies = grants
.Select(g => AccessPolicySeeder.Create(g.GranteeType, g.GranteeId, g.GrantableType, g.GrantableId, g.Read, g.Write))
.ToList();

var created = await accessPolicyRepository.CreateManyAsync(policies);

return new SceneResult<Result>(
result: new Result
{
Count = created.Count,
AccessPolicyIds = created.Select(p => p.Id).ToList()
},
mangleMap: manglerService.GetMangleMap());
}

private static IEnumerable<Guid> GrantableIds(IEnumerable<Grant> grants, AccessPolicySeeder.GrantableType type) =>
grants.Where(g => g.GrantableType == type).Select(g => g.GrantableId).Distinct();

private static IEnumerable<Guid> GranteeIds(IEnumerable<Grant> grants, AccessPolicySeeder.GranteeType type) =>
grants.Where(g => g.GranteeType == type).Select(g => g.GranteeId).Distinct();
}
Loading
Loading