From c5966ebedea4f687e901d978ae5af8c8131653f5 Mon Sep 17 00:00:00 2001 From: NedThompson Date: Tue, 25 Aug 2026 10:06:42 -0400 Subject: [PATCH 1/9] Add Secrets Manager seeder scenes to SeederApi Add four SM seeder scenes (project, secret, service account, access policy) that persist real rows via the commercial EF repositories, and wire AddSecretsManagerEfRepositories into SeederApi Startup so the scenes override the OSS Noop registrations. Scenes mirror OrganizationCollectionScene: inject repositories and IManglerService, load and validate the org, encrypt fields under the org key, and return the mangle map. Adds an end-to-end integration test exercising all four scenes through POST /seed and asserting real DB rows with round-tripped encryption. --- .../Scenes/SecretsManagerSceneTests.cs | 179 ++++++++++++++++++ .../packages.lock.json | 17 ++ .../Scenes/OrganizationAccessPolicyScene.cs | 122 ++++++++++++ .../Seeder/Scenes/OrganizationProjectScene.cs | 57 ++++++ util/Seeder/Scenes/OrganizationSecretScene.cs | 65 +++++++ .../Scenes/OrganizationServiceAccountScene.cs | 57 ++++++ util/Seeder/Seeds/docs/regression.md | 2 + util/SeederApi/SeederApi.csproj | 2 + util/SeederApi/Startup.cs | 2 + util/SeederApi/packages.lock.json | 15 ++ 10 files changed, 518 insertions(+) create mode 100644 test/SeederApi.IntegrationTest/Scenes/SecretsManagerSceneTests.cs create mode 100644 util/Seeder/Scenes/OrganizationAccessPolicyScene.cs create mode 100644 util/Seeder/Scenes/OrganizationProjectScene.cs create mode 100644 util/Seeder/Scenes/OrganizationSecretScene.cs create mode 100644 util/Seeder/Scenes/OrganizationServiceAccountScene.cs diff --git a/test/SeederApi.IntegrationTest/Scenes/SecretsManagerSceneTests.cs b/test/SeederApi.IntegrationTest/Scenes/SecretsManagerSceneTests.cs new file mode 100644 index 000000000000..8cdd2b1c2557 --- /dev/null +++ b/test/SeederApi.IntegrationTest/Scenes/SecretsManagerSceneTests.cs @@ -0,0 +1,179 @@ +using System.Text.Json; +using Bit.Core.Billing.Enums; +using Bit.Infrastructure.EntityFramework.Repositories; +using Bit.RustSDK; +using Bit.Seeder.Scenes; +using Bit.SeederApi.Models.Request; +using Bit.SeederApi.Models.Response; +using Duende.IdentityModel.Client; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace Bit.SeederApi.IntegrationTest.Scenes; + +/// +/// Exercises the Secrets Manager seeder scenes end-to-end through POST /seed: seed an SM-enabled org, +/// then create a project, a secret linked to that project, a service account, and access policies. Verifies +/// real rows persist (via the real commercial EF repositories, not the Noops) and that encrypted fields +/// round-trip under the organization key. +/// +public class SecretsManagerSceneTests : IClassFixture, IAsyncLifetime +{ + private readonly HttpClient _client; + private readonly InPlaySeederApiApplicationFactory _factory; + + public SecretsManagerSceneTests(InPlaySeederApiApplicationFactory factory) + { + _factory = factory; + _client = _factory.CreateClient(); + _client.SetBasicAuthentication(_factory.Username, _factory.Password); + } + + public Task InitializeAsync() => Task.CompletedTask; + + public async Task DisposeAsync() + { + await _client.DeleteAsync("/seed"); + _client.Dispose(); + } + + [Fact] + public async Task SecretsManagerScenes_SeedProjectSecretServiceAccountAndAccessPolicies() + { + var playId = Guid.NewGuid().ToString(); + + var ownerUserId = await SeedUserAsync(playId); + var (organizationId, orgUserId, organizationKeyB64) = await SeedSmOrganizationAsync(playId, ownerUserId); + + var projectResult = await PostSceneAsync(playId, nameof(OrganizationProjectScene), new OrganizationProjectScene.Request + { + OrganizationId = organizationId, + OrganizationKeyB64 = organizationKeyB64, + Name = "Production" + }); + var projectId = projectResult.GetProperty("projectId").GetGuid(); + + var secretResult = await PostSceneAsync(playId, nameof(OrganizationSecretScene), new OrganizationSecretScene.Request + { + OrganizationId = organizationId, + OrganizationKeyB64 = organizationKeyB64, + Key = "DB_PASSWORD", + Value = "s3cret", + Note = "primary database", + ProjectIds = [projectId] + }); + var secretId = secretResult.GetProperty("secretId").GetGuid(); + + var serviceAccountResult = await PostSceneAsync(playId, nameof(OrganizationServiceAccountScene), new OrganizationServiceAccountScene.Request + { + OrganizationId = organizationId, + OrganizationKeyB64 = organizationKeyB64, + Name = "CI Runner" + }); + var serviceAccountId = serviceAccountResult.GetProperty("serviceAccountId").GetGuid(); + + var accessPolicyResult = await PostSceneAsync(playId, nameof(OrganizationAccessPolicyScene), new OrganizationAccessPolicyScene.Request + { + OrganizationId = organizationId, + Grants = + [ + new OrganizationAccessPolicyScene.Grant + { + GranteeType = OrganizationAccessPolicyScene.GranteeType.OrganizationUser, + GranteeId = orgUserId, + GrantableType = OrganizationAccessPolicyScene.GrantableType.Project, + GrantableId = projectId, + Read = true, + Write = true + }, + new OrganizationAccessPolicyScene.Grant + { + GranteeType = OrganizationAccessPolicyScene.GranteeType.ServiceAccount, + GranteeId = serviceAccountId, + GrantableType = OrganizationAccessPolicyScene.GrantableType.Project, + GrantableId = projectId, + Read = true, + Write = false + } + ] + }); + Assert.Equal(2, accessPolicyResult.GetProperty("count").GetInt32()); + Assert.Equal(2, accessPolicyResult.GetProperty("accessPolicyIds").EnumerateArray().Count()); + + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var project = await db.Project.SingleAsync(p => p.Id == projectId); + Assert.Equal(organizationId, project.OrganizationId); + Assert.Equal("Production", RustSdkService.DecryptString(project.Name!, organizationKeyB64)); + + var secret = await db.Secret.Include(s => s.Projects).SingleAsync(s => s.Id == secretId); + Assert.Equal(organizationId, secret.OrganizationId); + Assert.Equal("DB_PASSWORD", RustSdkService.DecryptString(secret.Key!, organizationKeyB64)); + Assert.Equal("s3cret", RustSdkService.DecryptString(secret.Value!, organizationKeyB64)); + Assert.Equal("primary database", RustSdkService.DecryptString(secret.Note!, organizationKeyB64)); + Assert.Contains(secret.Projects!, p => p.Id == projectId); + + var serviceAccount = await db.ServiceAccount.SingleAsync(sa => sa.Id == serviceAccountId); + Assert.Equal(organizationId, serviceAccount.OrganizationId); + Assert.Equal("CI Runner", RustSdkService.DecryptString(serviceAccount.Name!, organizationKeyB64)); + + var userPolicy = await db.UserProjectAccessPolicy.SingleAsync(ap => + ap.OrganizationUserId == orgUserId && ap.GrantedProjectId == projectId); + Assert.True(userPolicy.Read); + Assert.True(userPolicy.Write); + + var serviceAccountPolicy = await db.ServiceAccountProjectAccessPolicy.SingleAsync(ap => + ap.ServiceAccountId == serviceAccountId && ap.GrantedProjectId == projectId); + Assert.True(serviceAccountPolicy.Read); + Assert.False(serviceAccountPolicy.Write); + } + + private async Task SeedUserAsync(string playId) + { + var result = await PostSceneAsync(playId, "SingleUserScene", new SingleUserScene.Request + { + Email = $"user-{Guid.NewGuid():N}@example.com", + Password = "asdfasdfasdf", + EmailVerified = true + }); + + return result.GetProperty("userId").GetGuid(); + } + + private async Task<(Guid OrganizationId, Guid OrgUserId, string OrganizationKeyB64)> SeedSmOrganizationAsync( + string playId, Guid ownerUserId) + { + var result = await PostSceneAsync(playId, "SingleOrganizationScene", new SingleOrganizationScene.Request + { + OwnerUserId = ownerUserId, + PlanType = PlanType.EnterpriseAnnually, + Name = "SM Org", + Domain = $"sm-{Guid.NewGuid():N}.example.com", + Seats = 10, + EnableSecretsManager = true, + SmSeats = 10, + SmServiceAccounts = 10 + }); + + return (result.GetProperty("organizationId").GetGuid(), + result.GetProperty("organizationUserId").GetGuid(), + result.GetProperty("organizationKeyB64").GetString()!); + } + + private async Task PostSceneAsync(string playId, string template, TRequest arguments) + { + var response = await _client.PostAsJsonAsync("/seed", new SeedRequestModel + { + Template = template, + Arguments = JsonSerializer.SerializeToElement(arguments) + }, playId); + + response.EnsureSuccessStatusCode(); + + var model = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(model); + Assert.NotNull(model!.Result); + return (JsonElement)model.Result!; + } +} diff --git a/test/SeederApi.IntegrationTest/packages.lock.json b/test/SeederApi.IntegrationTest/packages.lock.json index 572512b299c6..62ada7003bb5 100644 --- a/test/SeederApi.IntegrationTest/packages.lock.json +++ b/test/SeederApi.IntegrationTest/packages.lock.json @@ -1310,6 +1310,21 @@ "ZiggyCreatures.FusionCache": "2.0.2" } }, + "commercial.core": { + "type": "Project", + "dependencies": { + "Core": "[2026.8.1, )", + "CsvHelper": "[33.1.0, 33.1.0]" + } + }, + "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": { @@ -1475,6 +1490,8 @@ "Bitwarden.Server.Sdk.Environment": "[0.1.0, )", "Bitwarden.Server.Sdk.Features": "[1.4.0, )", "Bitwarden.Server.Sdk.WebEssentials": "[0.5.0, )", + "Commercial.Core": "[2026.8.1, )", + "Commercial.Infrastructure.EntityFramework": "[2026.8.1, )", "Core": "[2026.8.1, )", "OpenTelemetry.Exporter.OpenTelemetryProtocol": "[1.15.3, )", "OpenTelemetry.Extensions.Hosting": "[1.15.3, )", diff --git a/util/Seeder/Scenes/OrganizationAccessPolicyScene.cs b/util/Seeder/Scenes/OrganizationAccessPolicyScene.cs new file mode 100644 index 000000000000..99bd8cae4116 --- /dev/null +++ b/util/Seeder/Scenes/OrganizationAccessPolicyScene.cs @@ -0,0 +1,122 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Core.Repositories; +using Bit.Core.SecretsManager.Entities; +using Bit.Core.SecretsManager.Repositories; +using Bit.Seeder.Services; + +namespace Bit.Seeder.Scenes; + +/// +/// 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. +/// +public class OrganizationAccessPolicyScene( + IOrganizationRepository organizationRepository, + IAccessPolicyRepository accessPolicyRepository, + IManglerService manglerService) : IScene +{ + public enum GranteeType + { + OrganizationUser, + Group, + ServiceAccount + } + + public enum GrantableType + { + Project, + ServiceAccount + } + + public class Request + { + [Required] + public required Guid OrganizationId { get; set; } + [Required] + [MinLength(1)] + public required IEnumerable Grants { get; set; } + } + + public class Grant + { + [Required] + public required GranteeType GranteeType { get; set; } + [Required] + public required Guid GranteeId { get; set; } + [Required] + public required 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 AccessPolicyIds { get; init; } + } + + public async Task> SeedAsync(Request request) + { + var organization = await organizationRepository.GetByIdAsync(request.OrganizationId); + if (organization == null) + { + throw new InvalidOperationException($"Organization {request.OrganizationId} not found."); + } + + var policies = request.Grants.Select(BuildPolicy).ToList(); + + var created = await accessPolicyRepository.CreateManyAsync(policies); + + return new SceneResult( + result: new Result + { + Count = created.Count, + AccessPolicyIds = created.Select(p => p.Id).ToList() + }, + mangleMap: manglerService.GetMangleMap()); + } + + private static BaseAccessPolicy BuildPolicy(Grant grant) => + (grant.GranteeType, grant.GrantableType) switch + { + (GranteeType.OrganizationUser, GrantableType.Project) => new UserProjectAccessPolicy + { + OrganizationUserId = grant.GranteeId, + GrantedProjectId = grant.GrantableId, + Read = grant.Read, + Write = grant.Write + }, + (GranteeType.OrganizationUser, GrantableType.ServiceAccount) => new UserServiceAccountAccessPolicy + { + OrganizationUserId = grant.GranteeId, + GrantedServiceAccountId = grant.GrantableId, + Read = grant.Read, + Write = grant.Write + }, + (GranteeType.Group, GrantableType.Project) => new GroupProjectAccessPolicy + { + GroupId = grant.GranteeId, + GrantedProjectId = grant.GrantableId, + Read = grant.Read, + Write = grant.Write + }, + (GranteeType.Group, GrantableType.ServiceAccount) => new GroupServiceAccountAccessPolicy + { + GroupId = grant.GranteeId, + GrantedServiceAccountId = grant.GrantableId, + Read = grant.Read, + Write = grant.Write + }, + (GranteeType.ServiceAccount, GrantableType.Project) => new ServiceAccountProjectAccessPolicy + { + ServiceAccountId = grant.GranteeId, + GrantedProjectId = grant.GrantableId, + Read = grant.Read, + Write = grant.Write + }, + _ => throw new InvalidOperationException( + $"Unsupported access policy: {grant.GranteeType} granted to {grant.GrantableType}.") + }; +} diff --git a/util/Seeder/Scenes/OrganizationProjectScene.cs b/util/Seeder/Scenes/OrganizationProjectScene.cs new file mode 100644 index 000000000000..b620f1e91f97 --- /dev/null +++ b/util/Seeder/Scenes/OrganizationProjectScene.cs @@ -0,0 +1,57 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Core.Repositories; +using Bit.Core.SecretsManager.Entities; +using Bit.Core.SecretsManager.Repositories; +using Bit.RustSDK; +using Bit.Seeder.Services; + +namespace Bit.Seeder.Scenes; + +/// +/// Creates a Secrets Manager project (name encrypted with the organization's symmetric key) for an +/// existing Secrets Manager-enabled organization. +/// +public class OrganizationProjectScene( + IOrganizationRepository organizationRepository, + IProjectRepository projectRepository, + IManglerService manglerService) : IScene +{ + public class Request + { + [Required] + public required Guid OrganizationId { get; set; } + [Required] + public required string OrganizationKeyB64 { get; set; } + [Required] + public required string Name { get; set; } + } + + public class Result + { + public required Guid ProjectId { get; init; } + } + + public async Task> SeedAsync(Request request) + { + var organization = await organizationRepository.GetByIdAsync(request.OrganizationId); + if (organization == null) + { + throw new InvalidOperationException($"Organization {request.OrganizationId} not found."); + } + + var project = new Project + { + OrganizationId = organization.Id, + Name = RustSdkService.EncryptString(request.Name, request.OrganizationKeyB64) + }; + + var created = await projectRepository.CreateAsync(project); + + return new SceneResult( + result: new Result + { + ProjectId = created.Id + }, + mangleMap: manglerService.GetMangleMap()); + } +} diff --git a/util/Seeder/Scenes/OrganizationSecretScene.cs b/util/Seeder/Scenes/OrganizationSecretScene.cs new file mode 100644 index 000000000000..2c4e1f5a7ddc --- /dev/null +++ b/util/Seeder/Scenes/OrganizationSecretScene.cs @@ -0,0 +1,65 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Core.Repositories; +using Bit.Core.SecretsManager.Entities; +using Bit.Core.SecretsManager.Repositories; +using Bit.RustSDK; +using Bit.Seeder.Services; + +namespace Bit.Seeder.Scenes; + +/// +/// Creates a Secrets Manager secret (key/value/note encrypted with the organization's symmetric key) +/// for an existing Secrets Manager-enabled organization, optionally associating it with projects. +/// +public class OrganizationSecretScene( + IOrganizationRepository organizationRepository, + ISecretRepository secretRepository, + IManglerService manglerService) : IScene +{ + public class Request + { + [Required] + public required Guid OrganizationId { get; set; } + [Required] + public required string OrganizationKeyB64 { get; set; } + [Required] + public required string Key { get; set; } + public string? Value { get; set; } + public string? Note { get; set; } + public IEnumerable? ProjectIds { get; set; } + } + + public class Result + { + public required Guid SecretId { get; init; } + } + + public async Task> SeedAsync(Request request) + { + var organization = await organizationRepository.GetByIdAsync(request.OrganizationId); + if (organization == null) + { + throw new InvalidOperationException($"Organization {request.OrganizationId} not found."); + } + + var secret = new Secret + { + OrganizationId = organization.Id, + Key = RustSdkService.EncryptString(request.Key, request.OrganizationKeyB64), + Value = RustSdkService.EncryptString(request.Value ?? string.Empty, request.OrganizationKeyB64), + Note = RustSdkService.EncryptString(request.Note ?? string.Empty, request.OrganizationKeyB64), + Projects = request.ProjectIds? + .Select(id => new Project { Id = id, OrganizationId = organization.Id }) + .ToList() + }; + + var created = await secretRepository.CreateAsync(secret); + + return new SceneResult( + result: new Result + { + SecretId = created.Id + }, + mangleMap: manglerService.GetMangleMap()); + } +} diff --git a/util/Seeder/Scenes/OrganizationServiceAccountScene.cs b/util/Seeder/Scenes/OrganizationServiceAccountScene.cs new file mode 100644 index 000000000000..290ea1894af1 --- /dev/null +++ b/util/Seeder/Scenes/OrganizationServiceAccountScene.cs @@ -0,0 +1,57 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Core.Repositories; +using Bit.Core.SecretsManager.Entities; +using Bit.Core.SecretsManager.Repositories; +using Bit.RustSDK; +using Bit.Seeder.Services; + +namespace Bit.Seeder.Scenes; + +/// +/// Creates a Secrets Manager service account (name encrypted with the organization's symmetric key) +/// for an existing Secrets Manager-enabled organization. +/// +public class OrganizationServiceAccountScene( + IOrganizationRepository organizationRepository, + IServiceAccountRepository serviceAccountRepository, + IManglerService manglerService) : IScene +{ + public class Request + { + [Required] + public required Guid OrganizationId { get; set; } + [Required] + public required string OrganizationKeyB64 { get; set; } + [Required] + public required string Name { get; set; } + } + + public class Result + { + public required Guid ServiceAccountId { get; init; } + } + + public async Task> SeedAsync(Request request) + { + var organization = await organizationRepository.GetByIdAsync(request.OrganizationId); + if (organization == null) + { + throw new InvalidOperationException($"Organization {request.OrganizationId} not found."); + } + + var serviceAccount = new ServiceAccount + { + OrganizationId = organization.Id, + Name = RustSdkService.EncryptString(request.Name, request.OrganizationKeyB64) + }; + + var created = await serviceAccountRepository.CreateAsync(serviceAccount); + + return new SceneResult( + result: new Result + { + ServiceAccountId = created.Id + }, + mangleMap: manglerService.GetMangleMap()); + } +} diff --git a/util/Seeder/Seeds/docs/regression.md b/util/Seeder/Seeds/docs/regression.md index bd624d288a88..35ca18ba0140 100644 --- a/util/Seeder/Seeds/docs/regression.md +++ b/util/Seeder/Seeds/docs/regression.md @@ -39,6 +39,7 @@ ASPNETCORE_ENVIRONMENT=Development dotnet run -- preset --name --mangle | `OrganizationSeeder` plans | `qa.stark-free-basic`, `qa.paper-trail-partners-team`, `qa.enterprise-basic` | `Plan`, `PlanType`, `Seats`, feature flags per tier | | Plan overrides | `SingleOrganizationScene` + `overrides` | An override wins over the plan default — proves overrides still apply after `PlanFeatures.Apply` | | `ProviderSeeder` | `SingleProviderScene` (API only) | `Gateway=0` when the caller supplies none | +| SM `Organization*Scene`s | `SecretsManagerSceneTests` (API only) | Project/Secret/ServiceAccount rows persist via the real commercial EF repos (not Noop); encrypted name/key/value/note round-trip under the org key; access policies link grantee↔grantable | | `SsoConfigSeeder` | `features.local-sso` | One `SsoConfig` row, `ConfigType=2`, non-empty `idpX509PublicCert` | | SSO provider guard | `features.sso-enterprise` | **Zero** `SsoConfig` rows — OIDC is skipped by design | | Attachment steps | `individual.encryption-modes` | `Cipher.Attachments` populated; every ID resolves to a blob | @@ -53,6 +54,7 @@ Attachments only exist in the `enterprise-basic` and `encryption-modes` cipher f | Surface | Reached only by | | ------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | | `SingleUserScene`, `SingleOrganizationScene`, `SingleProviderScene` | `POST :5047/seed` | +| `OrganizationProjectScene`, `OrganizationSecretScene`, `OrganizationServiceAccountScene`, `OrganizationAccessPolicyScene` | `POST :5047/seed` (requires the commercial SM EF repos registered in `SeederApi/Startup.cs`) | | `OrganizationWithUsersRecipe` | `test/Api.IntegrationTest/**/*PerformanceTests.cs`, all `[Theory(Skip = "…")]` — compile-time only | ## Checks the density queries don't cover diff --git a/util/SeederApi/SeederApi.csproj b/util/SeederApi/SeederApi.csproj index a7569218774c..f2cf72f0cd2d 100644 --- a/util/SeederApi/SeederApi.csproj +++ b/util/SeederApi/SeederApi.csproj @@ -12,6 +12,8 @@ + + diff --git a/util/SeederApi/Startup.cs b/util/SeederApi/Startup.cs index 07239dc000e6..a36050a59772 100644 --- a/util/SeederApi/Startup.cs +++ b/util/SeederApi/Startup.cs @@ -1,4 +1,5 @@ using System.Globalization; +using Bit.Commercial.Infrastructure.EntityFramework.SecretsManager; using Bit.Core.Billing.Licenses.Extensions; using Bit.Core.Billing.Services; using Bit.Core.Services; @@ -35,6 +36,7 @@ public void ConfigureServices(IServiceCollection services) services.AddTokenizers(); services.AddDatabaseRepositories(globalSettings); + services.AddSecretsManagerEfRepositories(); services.AddTestPlayIdTracking(globalSettings); services.AddManglerService(globalSettings); diff --git a/util/SeederApi/packages.lock.json b/util/SeederApi/packages.lock.json index 932b48dc3054..1c55e44cf7f0 100644 --- a/util/SeederApi/packages.lock.json +++ b/util/SeederApi/packages.lock.json @@ -1111,6 +1111,21 @@ "ZiggyCreatures.FusionCache": "2.0.2" } }, + "commercial.core": { + "type": "Project", + "dependencies": { + "Core": "[2026.8.1, )", + "CsvHelper": "[33.1.0, 33.1.0]" + } + }, + "commercial.infrastructure.entityframework": { + "type": "Project", + "dependencies": { + "AutoMapper": "[14.0.0, 14.0.0]", + "Core": "[2026.8.1, )", + "Infrastructure.EntityFramework": "[2026.8.1, )" + } + }, "core": { "type": "Project", "dependencies": { From e1dfba771a2ef97f6b0bcc18ef5212acb386426f Mon Sep 17 00:00:00 2001 From: NedThompson Date: Tue, 25 Aug 2026 11:37:17 -0400 Subject: [PATCH 2/9] Guard SM seeder scenes against non-Secrets-Manager orgs The Secrets Manager seeder scenes claimed to target an SM-enabled org but never checked Organization.UseSecretsManager, so they could seed impossible fixtures (SM data on an SM-off org), and bad refs surfaced as raw FK 500s. Add a UseSecretsManager guard to OrganizationProjectScene, OrganizationSecretScene, OrganizationServiceAccountScene, and OrganizationAccessPolicyScene. The guard throws InvalidOperationException, which SceneExecutor/SeedController surface as a 400 with a clean message. Add an integration test seeding a non-SM Enterprise org (via OrganizationOverrides.UseSecretsManager=false) and asserting the project scene returns 400 and writes no row. --- .../Scenes/SecretsManagerSceneTests.cs | 52 ++++++++++++++++++- .../Scenes/OrganizationAccessPolicyScene.cs | 6 +++ .../Seeder/Scenes/OrganizationProjectScene.cs | 6 +++ util/Seeder/Scenes/OrganizationSecretScene.cs | 6 +++ .../Scenes/OrganizationServiceAccountScene.cs | 6 +++ 5 files changed, 75 insertions(+), 1 deletion(-) diff --git a/test/SeederApi.IntegrationTest/Scenes/SecretsManagerSceneTests.cs b/test/SeederApi.IntegrationTest/Scenes/SecretsManagerSceneTests.cs index 8cdd2b1c2557..aa06a5423040 100644 --- a/test/SeederApi.IntegrationTest/Scenes/SecretsManagerSceneTests.cs +++ b/test/SeederApi.IntegrationTest/Scenes/SecretsManagerSceneTests.cs @@ -1,7 +1,9 @@ -using System.Text.Json; +using System.Net; +using System.Text.Json; using Bit.Core.Billing.Enums; using Bit.Infrastructure.EntityFramework.Repositories; using Bit.RustSDK; +using Bit.Seeder.Options; using Bit.Seeder.Scenes; using Bit.SeederApi.Models.Request; using Bit.SeederApi.Models.Response; @@ -129,6 +131,35 @@ public async Task SecretsManagerScenes_SeedProjectSecretServiceAccountAndAccessP Assert.False(serviceAccountPolicy.Write); } + [Fact] + public async Task OrganizationProjectScene_OrganizationWithoutSecretsManager_ReturnsBadRequest() + { + var playId = Guid.NewGuid().ToString(); + + var ownerUserId = await SeedUserAsync(playId); + var (organizationId, _, organizationKeyB64) = await SeedNonSmOrganizationAsync(playId, ownerUserId); + + var response = await _client.PostAsJsonAsync("/seed", new SeedRequestModel + { + Template = nameof(OrganizationProjectScene), + Arguments = JsonSerializer.SerializeToElement(new OrganizationProjectScene.Request + { + OrganizationId = organizationId, + OrganizationKeyB64 = organizationKeyB64, + Name = "Production" + }) + }, playId); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + Assert.Contains("does not have Secrets Manager enabled", body); + + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + Assert.False(await db.Project.AnyAsync(p => p.OrganizationId == organizationId)); + } + private async Task SeedUserAsync(string playId) { var result = await PostSceneAsync(playId, "SingleUserScene", new SingleUserScene.Request @@ -161,6 +192,25 @@ private async Task SeedUserAsync(string playId) result.GetProperty("organizationKeyB64").GetString()!); } + private async Task<(Guid OrganizationId, Guid OrgUserId, string OrganizationKeyB64)> SeedNonSmOrganizationAsync( + string playId, Guid ownerUserId) + { + var result = await PostSceneAsync(playId, "SingleOrganizationScene", new SingleOrganizationScene.Request + { + OwnerUserId = ownerUserId, + PlanType = PlanType.EnterpriseAnnually, + Name = "No SM Org", + Domain = $"nosm-{Guid.NewGuid():N}.example.com", + Seats = 10, + EnableSecretsManager = false, + Overrides = new OrganizationOverrides { UseSecretsManager = false } + }); + + return (result.GetProperty("organizationId").GetGuid(), + result.GetProperty("organizationUserId").GetGuid(), + result.GetProperty("organizationKeyB64").GetString()!); + } + private async Task PostSceneAsync(string playId, string template, TRequest arguments) { var response = await _client.PostAsJsonAsync("/seed", new SeedRequestModel diff --git a/util/Seeder/Scenes/OrganizationAccessPolicyScene.cs b/util/Seeder/Scenes/OrganizationAccessPolicyScene.cs index 99bd8cae4116..d55b666b8520 100644 --- a/util/Seeder/Scenes/OrganizationAccessPolicyScene.cs +++ b/util/Seeder/Scenes/OrganizationAccessPolicyScene.cs @@ -65,6 +65,12 @@ public async Task> SeedAsync(Request request) throw new InvalidOperationException($"Organization {request.OrganizationId} not found."); } + if (!organization.UseSecretsManager) + { + throw new InvalidOperationException( + $"Organization {request.OrganizationId} does not have Secrets Manager enabled."); + } + var policies = request.Grants.Select(BuildPolicy).ToList(); var created = await accessPolicyRepository.CreateManyAsync(policies); diff --git a/util/Seeder/Scenes/OrganizationProjectScene.cs b/util/Seeder/Scenes/OrganizationProjectScene.cs index b620f1e91f97..ec1bd04f1301 100644 --- a/util/Seeder/Scenes/OrganizationProjectScene.cs +++ b/util/Seeder/Scenes/OrganizationProjectScene.cs @@ -39,6 +39,12 @@ public async Task> SeedAsync(Request request) throw new InvalidOperationException($"Organization {request.OrganizationId} not found."); } + if (!organization.UseSecretsManager) + { + throw new InvalidOperationException( + $"Organization {request.OrganizationId} does not have Secrets Manager enabled."); + } + var project = new Project { OrganizationId = organization.Id, diff --git a/util/Seeder/Scenes/OrganizationSecretScene.cs b/util/Seeder/Scenes/OrganizationSecretScene.cs index 2c4e1f5a7ddc..7b9a52bbe9b8 100644 --- a/util/Seeder/Scenes/OrganizationSecretScene.cs +++ b/util/Seeder/Scenes/OrganizationSecretScene.cs @@ -42,6 +42,12 @@ public async Task> SeedAsync(Request request) throw new InvalidOperationException($"Organization {request.OrganizationId} not found."); } + if (!organization.UseSecretsManager) + { + throw new InvalidOperationException( + $"Organization {request.OrganizationId} does not have Secrets Manager enabled."); + } + var secret = new Secret { OrganizationId = organization.Id, diff --git a/util/Seeder/Scenes/OrganizationServiceAccountScene.cs b/util/Seeder/Scenes/OrganizationServiceAccountScene.cs index 290ea1894af1..b78730bf1adb 100644 --- a/util/Seeder/Scenes/OrganizationServiceAccountScene.cs +++ b/util/Seeder/Scenes/OrganizationServiceAccountScene.cs @@ -39,6 +39,12 @@ public async Task> SeedAsync(Request request) throw new InvalidOperationException($"Organization {request.OrganizationId} not found."); } + if (!organization.UseSecretsManager) + { + throw new InvalidOperationException( + $"Organization {request.OrganizationId} does not have Secrets Manager enabled."); + } + var serviceAccount = new ServiceAccount { OrganizationId = organization.Id, From 941716799948c66e4b44dca4f1817474d95cce32 Mon Sep 17 00:00:00 2001 From: NedThompson Date: Tue, 25 Aug 2026 12:11:43 -0400 Subject: [PATCH 3/9] Single-source SM org-lookup guard across seeder scenes Extract the duplicated org-load + not-found + Secrets Manager-enabled guard into a GetSecretsManagerOrganizationOrThrowAsync extension on IOrganizationRepository, and call it from the four SM scenes. Keeps the exception messages byte-identical so the BadRequest integration test and HTTP 400 mapping are unaffected. --- .../Scenes/OrganizationAccessPolicyScene.cs | 12 +-------- .../Seeder/Scenes/OrganizationProjectScene.cs | 12 +-------- .../OrganizationRepositoryExtensions.cs | 25 +++++++++++++++++++ util/Seeder/Scenes/OrganizationSecretScene.cs | 12 +-------- .../Scenes/OrganizationServiceAccountScene.cs | 12 +-------- 5 files changed, 29 insertions(+), 44 deletions(-) create mode 100644 util/Seeder/Scenes/OrganizationRepositoryExtensions.cs diff --git a/util/Seeder/Scenes/OrganizationAccessPolicyScene.cs b/util/Seeder/Scenes/OrganizationAccessPolicyScene.cs index d55b666b8520..72bbf5f823db 100644 --- a/util/Seeder/Scenes/OrganizationAccessPolicyScene.cs +++ b/util/Seeder/Scenes/OrganizationAccessPolicyScene.cs @@ -59,17 +59,7 @@ public class Result public async Task> SeedAsync(Request request) { - var organization = await organizationRepository.GetByIdAsync(request.OrganizationId); - if (organization == null) - { - throw new InvalidOperationException($"Organization {request.OrganizationId} not found."); - } - - if (!organization.UseSecretsManager) - { - throw new InvalidOperationException( - $"Organization {request.OrganizationId} does not have Secrets Manager enabled."); - } + await organizationRepository.GetSecretsManagerOrganizationOrThrowAsync(request.OrganizationId); var policies = request.Grants.Select(BuildPolicy).ToList(); diff --git a/util/Seeder/Scenes/OrganizationProjectScene.cs b/util/Seeder/Scenes/OrganizationProjectScene.cs index ec1bd04f1301..f4160a67e649 100644 --- a/util/Seeder/Scenes/OrganizationProjectScene.cs +++ b/util/Seeder/Scenes/OrganizationProjectScene.cs @@ -33,17 +33,7 @@ public class Result public async Task> SeedAsync(Request request) { - var organization = await organizationRepository.GetByIdAsync(request.OrganizationId); - if (organization == null) - { - throw new InvalidOperationException($"Organization {request.OrganizationId} not found."); - } - - if (!organization.UseSecretsManager) - { - throw new InvalidOperationException( - $"Organization {request.OrganizationId} does not have Secrets Manager enabled."); - } + var organization = await organizationRepository.GetSecretsManagerOrganizationOrThrowAsync(request.OrganizationId); var project = new Project { diff --git a/util/Seeder/Scenes/OrganizationRepositoryExtensions.cs b/util/Seeder/Scenes/OrganizationRepositoryExtensions.cs new file mode 100644 index 000000000000..9f39ee1cbbb6 --- /dev/null +++ b/util/Seeder/Scenes/OrganizationRepositoryExtensions.cs @@ -0,0 +1,25 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Repositories; + +namespace Bit.Seeder.Scenes; + +public static class OrganizationRepositoryExtensions +{ + public static async Task 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; + } +} diff --git a/util/Seeder/Scenes/OrganizationSecretScene.cs b/util/Seeder/Scenes/OrganizationSecretScene.cs index 7b9a52bbe9b8..acb808c9ea99 100644 --- a/util/Seeder/Scenes/OrganizationSecretScene.cs +++ b/util/Seeder/Scenes/OrganizationSecretScene.cs @@ -36,17 +36,7 @@ public class Result public async Task> SeedAsync(Request request) { - var organization = await organizationRepository.GetByIdAsync(request.OrganizationId); - if (organization == null) - { - throw new InvalidOperationException($"Organization {request.OrganizationId} not found."); - } - - if (!organization.UseSecretsManager) - { - throw new InvalidOperationException( - $"Organization {request.OrganizationId} does not have Secrets Manager enabled."); - } + var organization = await organizationRepository.GetSecretsManagerOrganizationOrThrowAsync(request.OrganizationId); var secret = new Secret { diff --git a/util/Seeder/Scenes/OrganizationServiceAccountScene.cs b/util/Seeder/Scenes/OrganizationServiceAccountScene.cs index b78730bf1adb..a3dc182077d5 100644 --- a/util/Seeder/Scenes/OrganizationServiceAccountScene.cs +++ b/util/Seeder/Scenes/OrganizationServiceAccountScene.cs @@ -33,17 +33,7 @@ public class Result public async Task> SeedAsync(Request request) { - var organization = await organizationRepository.GetByIdAsync(request.OrganizationId); - if (organization == null) - { - throw new InvalidOperationException($"Organization {request.OrganizationId} not found."); - } - - if (!organization.UseSecretsManager) - { - throw new InvalidOperationException( - $"Organization {request.OrganizationId} does not have Secrets Manager enabled."); - } + var organization = await organizationRepository.GetSecretsManagerOrganizationOrThrowAsync(request.OrganizationId); var serviceAccount = new ServiceAccount { From 7d984335821726cbb8fb48880574e6dc2dd93fc0 Mon Sep 17 00:00:00 2001 From: NedThompson Date: Tue, 25 Aug 2026 16:26:24 -0400 Subject: [PATCH 4/9] Drop unused Commercial.Core reference from SeederApi SeederApi only calls AddSecretsManagerEfRepositories(), which lives in Commercial.Infrastructure.EntityFramework. Nothing in util/SeederApi or test/SeederApi.IntegrationTest uses Bit.Commercial.Core, so the reference only pulled Commercial.Core (and CsvHelper transitively) into the build and container image. Regenerated both packages.lock.json files. --- test/SeederApi.IntegrationTest/packages.lock.json | 8 -------- util/SeederApi/SeederApi.csproj | 1 - util/SeederApi/packages.lock.json | 7 ------- 3 files changed, 16 deletions(-) diff --git a/test/SeederApi.IntegrationTest/packages.lock.json b/test/SeederApi.IntegrationTest/packages.lock.json index 62ada7003bb5..a2f4a0d7459a 100644 --- a/test/SeederApi.IntegrationTest/packages.lock.json +++ b/test/SeederApi.IntegrationTest/packages.lock.json @@ -1310,13 +1310,6 @@ "ZiggyCreatures.FusionCache": "2.0.2" } }, - "commercial.core": { - "type": "Project", - "dependencies": { - "Core": "[2026.8.1, )", - "CsvHelper": "[33.1.0, 33.1.0]" - } - }, "commercial.infrastructure.entityframework": { "type": "Project", "dependencies": { @@ -1490,7 +1483,6 @@ "Bitwarden.Server.Sdk.Environment": "[0.1.0, )", "Bitwarden.Server.Sdk.Features": "[1.4.0, )", "Bitwarden.Server.Sdk.WebEssentials": "[0.5.0, )", - "Commercial.Core": "[2026.8.1, )", "Commercial.Infrastructure.EntityFramework": "[2026.8.1, )", "Core": "[2026.8.1, )", "OpenTelemetry.Exporter.OpenTelemetryProtocol": "[1.15.3, )", diff --git a/util/SeederApi/SeederApi.csproj b/util/SeederApi/SeederApi.csproj index f2cf72f0cd2d..794e5e4dccc3 100644 --- a/util/SeederApi/SeederApi.csproj +++ b/util/SeederApi/SeederApi.csproj @@ -12,7 +12,6 @@ - diff --git a/util/SeederApi/packages.lock.json b/util/SeederApi/packages.lock.json index 1c55e44cf7f0..0e61ed2121eb 100644 --- a/util/SeederApi/packages.lock.json +++ b/util/SeederApi/packages.lock.json @@ -1111,13 +1111,6 @@ "ZiggyCreatures.FusionCache": "2.0.2" } }, - "commercial.core": { - "type": "Project", - "dependencies": { - "Core": "[2026.8.1, )", - "CsvHelper": "[33.1.0, 33.1.0]" - } - }, "commercial.infrastructure.entityframework": { "type": "Project", "dependencies": { From 003369c0348f4876cc332ff7e134abd546c75713 Mon Sep 17 00:00:00 2001 From: NedThompson Date: Wed, 26 Aug 2026 09:46:09 -0400 Subject: [PATCH 5/9] Extract SM seeder scene creation into factories Move entity creation out of the Secrets Manager seeder scenes into new Factories classes so scenes only orchestrate. Adds ProjectSeeder, ServiceAccountSeeder, SecretSeeder, and AccessPolicySeeder (which owns the grantee/grantable enums and policy switch), enabling reuse by the CLI and future scenes. --- .../Scenes/SecretsManagerSceneTests.cs | 9 +-- util/Seeder/Factories/AccessPolicySeeder.cs | 67 +++++++++++++++++++ util/Seeder/Factories/ProjectSeeder.cs | 18 +++++ util/Seeder/Factories/SecretSeeder.cs | 29 ++++++++ util/Seeder/Factories/ServiceAccountSeeder.cs | 18 +++++ .../Scenes/OrganizationAccessPolicyScene.cs | 65 ++---------------- .../Seeder/Scenes/OrganizationProjectScene.cs | 9 +-- util/Seeder/Scenes/OrganizationSecretScene.cs | 20 +++--- .../Scenes/OrganizationServiceAccountScene.cs | 9 +-- 9 files changed, 155 insertions(+), 89 deletions(-) create mode 100644 util/Seeder/Factories/AccessPolicySeeder.cs create mode 100644 util/Seeder/Factories/ProjectSeeder.cs create mode 100644 util/Seeder/Factories/SecretSeeder.cs create mode 100644 util/Seeder/Factories/ServiceAccountSeeder.cs diff --git a/test/SeederApi.IntegrationTest/Scenes/SecretsManagerSceneTests.cs b/test/SeederApi.IntegrationTest/Scenes/SecretsManagerSceneTests.cs index aa06a5423040..e40683409051 100644 --- a/test/SeederApi.IntegrationTest/Scenes/SecretsManagerSceneTests.cs +++ b/test/SeederApi.IntegrationTest/Scenes/SecretsManagerSceneTests.cs @@ -3,6 +3,7 @@ using Bit.Core.Billing.Enums; using Bit.Infrastructure.EntityFramework.Repositories; using Bit.RustSDK; +using Bit.Seeder.Factories; using Bit.Seeder.Options; using Bit.Seeder.Scenes; using Bit.SeederApi.Models.Request; @@ -81,18 +82,18 @@ public async Task SecretsManagerScenes_SeedProjectSecretServiceAccountAndAccessP [ new OrganizationAccessPolicyScene.Grant { - GranteeType = OrganizationAccessPolicyScene.GranteeType.OrganizationUser, + GranteeType = AccessPolicySeeder.GranteeType.OrganizationUser, GranteeId = orgUserId, - GrantableType = OrganizationAccessPolicyScene.GrantableType.Project, + GrantableType = AccessPolicySeeder.GrantableType.Project, GrantableId = projectId, Read = true, Write = true }, new OrganizationAccessPolicyScene.Grant { - GranteeType = OrganizationAccessPolicyScene.GranteeType.ServiceAccount, + GranteeType = AccessPolicySeeder.GranteeType.ServiceAccount, GranteeId = serviceAccountId, - GrantableType = OrganizationAccessPolicyScene.GrantableType.Project, + GrantableType = AccessPolicySeeder.GrantableType.Project, GrantableId = projectId, Read = true, Write = false diff --git a/util/Seeder/Factories/AccessPolicySeeder.cs b/util/Seeder/Factories/AccessPolicySeeder.cs new file mode 100644 index 000000000000..35869f37ece8 --- /dev/null +++ b/util/Seeder/Factories/AccessPolicySeeder.cs @@ -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}.") + }; +} diff --git a/util/Seeder/Factories/ProjectSeeder.cs b/util/Seeder/Factories/ProjectSeeder.cs new file mode 100644 index 000000000000..861183cb6b11 --- /dev/null +++ b/util/Seeder/Factories/ProjectSeeder.cs @@ -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) + }; + } +} diff --git a/util/Seeder/Factories/SecretSeeder.cs b/util/Seeder/Factories/SecretSeeder.cs new file mode 100644 index 000000000000..33b06c7599db --- /dev/null +++ b/util/Seeder/Factories/SecretSeeder.cs @@ -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? 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() + }; + } +} diff --git a/util/Seeder/Factories/ServiceAccountSeeder.cs b/util/Seeder/Factories/ServiceAccountSeeder.cs new file mode 100644 index 000000000000..bfc9463b4045 --- /dev/null +++ b/util/Seeder/Factories/ServiceAccountSeeder.cs @@ -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) + }; + } +} diff --git a/util/Seeder/Scenes/OrganizationAccessPolicyScene.cs b/util/Seeder/Scenes/OrganizationAccessPolicyScene.cs index 72bbf5f823db..9ad174d43ff1 100644 --- a/util/Seeder/Scenes/OrganizationAccessPolicyScene.cs +++ b/util/Seeder/Scenes/OrganizationAccessPolicyScene.cs @@ -1,7 +1,7 @@ using System.ComponentModel.DataAnnotations; using Bit.Core.Repositories; -using Bit.Core.SecretsManager.Entities; using Bit.Core.SecretsManager.Repositories; +using Bit.Seeder.Factories; using Bit.Seeder.Services; namespace Bit.Seeder.Scenes; @@ -15,19 +15,6 @@ public class OrganizationAccessPolicyScene( IAccessPolicyRepository accessPolicyRepository, IManglerService manglerService) : IScene { - public enum GranteeType - { - OrganizationUser, - Group, - ServiceAccount - } - - public enum GrantableType - { - Project, - ServiceAccount - } - public class Request { [Required] @@ -40,11 +27,11 @@ public class Request public class Grant { [Required] - public required GranteeType GranteeType { get; set; } + public required AccessPolicySeeder.GranteeType GranteeType { get; set; } [Required] public required Guid GranteeId { get; set; } [Required] - public required GrantableType GrantableType { get; set; } + public required AccessPolicySeeder.GrantableType GrantableType { get; set; } [Required] public required Guid GrantableId { get; set; } public bool Read { get; set; } = true; @@ -61,7 +48,9 @@ public async Task> SeedAsync(Request request) { await organizationRepository.GetSecretsManagerOrganizationOrThrowAsync(request.OrganizationId); - var policies = request.Grants.Select(BuildPolicy).ToList(); + var policies = request.Grants + .Select(g => AccessPolicySeeder.Create(g.GranteeType, g.GranteeId, g.GrantableType, g.GrantableId, g.Read, g.Write)) + .ToList(); var created = await accessPolicyRepository.CreateManyAsync(policies); @@ -73,46 +62,4 @@ public async Task> SeedAsync(Request request) }, mangleMap: manglerService.GetMangleMap()); } - - private static BaseAccessPolicy BuildPolicy(Grant grant) => - (grant.GranteeType, grant.GrantableType) switch - { - (GranteeType.OrganizationUser, GrantableType.Project) => new UserProjectAccessPolicy - { - OrganizationUserId = grant.GranteeId, - GrantedProjectId = grant.GrantableId, - Read = grant.Read, - Write = grant.Write - }, - (GranteeType.OrganizationUser, GrantableType.ServiceAccount) => new UserServiceAccountAccessPolicy - { - OrganizationUserId = grant.GranteeId, - GrantedServiceAccountId = grant.GrantableId, - Read = grant.Read, - Write = grant.Write - }, - (GranteeType.Group, GrantableType.Project) => new GroupProjectAccessPolicy - { - GroupId = grant.GranteeId, - GrantedProjectId = grant.GrantableId, - Read = grant.Read, - Write = grant.Write - }, - (GranteeType.Group, GrantableType.ServiceAccount) => new GroupServiceAccountAccessPolicy - { - GroupId = grant.GranteeId, - GrantedServiceAccountId = grant.GrantableId, - Read = grant.Read, - Write = grant.Write - }, - (GranteeType.ServiceAccount, GrantableType.Project) => new ServiceAccountProjectAccessPolicy - { - ServiceAccountId = grant.GranteeId, - GrantedProjectId = grant.GrantableId, - Read = grant.Read, - Write = grant.Write - }, - _ => throw new InvalidOperationException( - $"Unsupported access policy: {grant.GranteeType} granted to {grant.GrantableType}.") - }; } diff --git a/util/Seeder/Scenes/OrganizationProjectScene.cs b/util/Seeder/Scenes/OrganizationProjectScene.cs index f4160a67e649..98f277970dea 100644 --- a/util/Seeder/Scenes/OrganizationProjectScene.cs +++ b/util/Seeder/Scenes/OrganizationProjectScene.cs @@ -1,8 +1,7 @@ using System.ComponentModel.DataAnnotations; using Bit.Core.Repositories; -using Bit.Core.SecretsManager.Entities; using Bit.Core.SecretsManager.Repositories; -using Bit.RustSDK; +using Bit.Seeder.Factories; using Bit.Seeder.Services; namespace Bit.Seeder.Scenes; @@ -35,11 +34,7 @@ public async Task> SeedAsync(Request request) { var organization = await organizationRepository.GetSecretsManagerOrganizationOrThrowAsync(request.OrganizationId); - var project = new Project - { - OrganizationId = organization.Id, - Name = RustSdkService.EncryptString(request.Name, request.OrganizationKeyB64) - }; + var project = ProjectSeeder.Create(organization.Id, request.OrganizationKeyB64, request.Name); var created = await projectRepository.CreateAsync(project); diff --git a/util/Seeder/Scenes/OrganizationSecretScene.cs b/util/Seeder/Scenes/OrganizationSecretScene.cs index acb808c9ea99..e8b4ac5a82db 100644 --- a/util/Seeder/Scenes/OrganizationSecretScene.cs +++ b/util/Seeder/Scenes/OrganizationSecretScene.cs @@ -1,8 +1,7 @@ using System.ComponentModel.DataAnnotations; using Bit.Core.Repositories; -using Bit.Core.SecretsManager.Entities; using Bit.Core.SecretsManager.Repositories; -using Bit.RustSDK; +using Bit.Seeder.Factories; using Bit.Seeder.Services; namespace Bit.Seeder.Scenes; @@ -38,16 +37,13 @@ public async Task> SeedAsync(Request request) { var organization = await organizationRepository.GetSecretsManagerOrganizationOrThrowAsync(request.OrganizationId); - var secret = new Secret - { - OrganizationId = organization.Id, - Key = RustSdkService.EncryptString(request.Key, request.OrganizationKeyB64), - Value = RustSdkService.EncryptString(request.Value ?? string.Empty, request.OrganizationKeyB64), - Note = RustSdkService.EncryptString(request.Note ?? string.Empty, request.OrganizationKeyB64), - Projects = request.ProjectIds? - .Select(id => new Project { Id = id, OrganizationId = organization.Id }) - .ToList() - }; + var secret = SecretSeeder.Create( + organization.Id, + request.OrganizationKeyB64, + request.Key, + request.Value, + request.Note, + request.ProjectIds); var created = await secretRepository.CreateAsync(secret); diff --git a/util/Seeder/Scenes/OrganizationServiceAccountScene.cs b/util/Seeder/Scenes/OrganizationServiceAccountScene.cs index a3dc182077d5..ac94c1461b7b 100644 --- a/util/Seeder/Scenes/OrganizationServiceAccountScene.cs +++ b/util/Seeder/Scenes/OrganizationServiceAccountScene.cs @@ -1,8 +1,7 @@ using System.ComponentModel.DataAnnotations; using Bit.Core.Repositories; -using Bit.Core.SecretsManager.Entities; using Bit.Core.SecretsManager.Repositories; -using Bit.RustSDK; +using Bit.Seeder.Factories; using Bit.Seeder.Services; namespace Bit.Seeder.Scenes; @@ -35,11 +34,7 @@ public async Task> SeedAsync(Request request) { var organization = await organizationRepository.GetSecretsManagerOrganizationOrThrowAsync(request.OrganizationId); - var serviceAccount = new ServiceAccount - { - OrganizationId = organization.Id, - Name = RustSdkService.EncryptString(request.Name, request.OrganizationKeyB64) - }; + var serviceAccount = ServiceAccountSeeder.Create(organization.Id, request.OrganizationKeyB64, request.Name); var created = await serviceAccountRepository.CreateAsync(serviceAccount); From b2711e2bcc62544809c7d4485ee368554d255de7 Mon Sep 17 00:00:00 2001 From: NedThompson Date: Wed, 26 Aug 2026 11:36:17 -0400 Subject: [PATCH 6/9] Move seeder org repository extensions out of Scenes namespace Relocate OrganizationRepositoryExtensions to a dedicated Extensions folder and mark it internal, addressing PR review feedback that a helper class did not belong in the Scenes namespace and need not be public. --- .../OrganizationRepositoryExtensions.cs | 6 +++--- util/Seeder/Scenes/OrganizationAccessPolicyScene.cs | 1 + util/Seeder/Scenes/OrganizationProjectScene.cs | 1 + util/Seeder/Scenes/OrganizationSecretScene.cs | 1 + util/Seeder/Scenes/OrganizationServiceAccountScene.cs | 1 + 5 files changed, 7 insertions(+), 3 deletions(-) rename util/Seeder/{Scenes => Extensions}/OrganizationRepositoryExtensions.cs (84%) diff --git a/util/Seeder/Scenes/OrganizationRepositoryExtensions.cs b/util/Seeder/Extensions/OrganizationRepositoryExtensions.cs similarity index 84% rename from util/Seeder/Scenes/OrganizationRepositoryExtensions.cs rename to util/Seeder/Extensions/OrganizationRepositoryExtensions.cs index 9f39ee1cbbb6..f12d430888aa 100644 --- a/util/Seeder/Scenes/OrganizationRepositoryExtensions.cs +++ b/util/Seeder/Extensions/OrganizationRepositoryExtensions.cs @@ -1,9 +1,9 @@ -using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities; using Bit.Core.Repositories; -namespace Bit.Seeder.Scenes; +namespace Bit.Seeder.Extensions; -public static class OrganizationRepositoryExtensions +internal static class OrganizationRepositoryExtensions { public static async Task GetSecretsManagerOrganizationOrThrowAsync( this IOrganizationRepository organizationRepository, Guid organizationId) diff --git a/util/Seeder/Scenes/OrganizationAccessPolicyScene.cs b/util/Seeder/Scenes/OrganizationAccessPolicyScene.cs index 9ad174d43ff1..1768b00097b2 100644 --- a/util/Seeder/Scenes/OrganizationAccessPolicyScene.cs +++ b/util/Seeder/Scenes/OrganizationAccessPolicyScene.cs @@ -1,6 +1,7 @@ using System.ComponentModel.DataAnnotations; using Bit.Core.Repositories; using Bit.Core.SecretsManager.Repositories; +using Bit.Seeder.Extensions; using Bit.Seeder.Factories; using Bit.Seeder.Services; diff --git a/util/Seeder/Scenes/OrganizationProjectScene.cs b/util/Seeder/Scenes/OrganizationProjectScene.cs index 98f277970dea..f6ca63aa9ce4 100644 --- a/util/Seeder/Scenes/OrganizationProjectScene.cs +++ b/util/Seeder/Scenes/OrganizationProjectScene.cs @@ -1,6 +1,7 @@ using System.ComponentModel.DataAnnotations; using Bit.Core.Repositories; using Bit.Core.SecretsManager.Repositories; +using Bit.Seeder.Extensions; using Bit.Seeder.Factories; using Bit.Seeder.Services; diff --git a/util/Seeder/Scenes/OrganizationSecretScene.cs b/util/Seeder/Scenes/OrganizationSecretScene.cs index e8b4ac5a82db..e3c049c993ed 100644 --- a/util/Seeder/Scenes/OrganizationSecretScene.cs +++ b/util/Seeder/Scenes/OrganizationSecretScene.cs @@ -1,6 +1,7 @@ using System.ComponentModel.DataAnnotations; using Bit.Core.Repositories; using Bit.Core.SecretsManager.Repositories; +using Bit.Seeder.Extensions; using Bit.Seeder.Factories; using Bit.Seeder.Services; diff --git a/util/Seeder/Scenes/OrganizationServiceAccountScene.cs b/util/Seeder/Scenes/OrganizationServiceAccountScene.cs index ac94c1461b7b..814676e0a695 100644 --- a/util/Seeder/Scenes/OrganizationServiceAccountScene.cs +++ b/util/Seeder/Scenes/OrganizationServiceAccountScene.cs @@ -1,6 +1,7 @@ using System.ComponentModel.DataAnnotations; using Bit.Core.Repositories; using Bit.Core.SecretsManager.Repositories; +using Bit.Seeder.Extensions; using Bit.Seeder.Factories; using Bit.Seeder.Services; From 3c02a60c3941e66ac7a18f592cfe466f32323b93 Mon Sep 17 00:00:00 2001 From: NedThompson Date: Wed, 26 Aug 2026 12:00:49 -0400 Subject: [PATCH 7/9] Guard SM secret seeder scene against cross-org project IDs OrganizationSecretScene trusted caller-supplied ProjectIds on faith, so a stale or cross-org project ID silently seeded a broken secret-project link. Inject IProjectRepository and validate ownership via ProjectsAreInOrganization before creating the secret, mirroring production SM authorization and the existing org-lookup guard pattern. --- .../Scenes/SecretsManagerSceneTests.cs | 31 +++++++++++++++++++ .../Extensions/ProjectRepositoryExtensions.cs | 22 +++++++++++++ util/Seeder/Scenes/OrganizationSecretScene.cs | 3 ++ 3 files changed, 56 insertions(+) create mode 100644 util/Seeder/Extensions/ProjectRepositoryExtensions.cs diff --git a/test/SeederApi.IntegrationTest/Scenes/SecretsManagerSceneTests.cs b/test/SeederApi.IntegrationTest/Scenes/SecretsManagerSceneTests.cs index e40683409051..d0af256e89aa 100644 --- a/test/SeederApi.IntegrationTest/Scenes/SecretsManagerSceneTests.cs +++ b/test/SeederApi.IntegrationTest/Scenes/SecretsManagerSceneTests.cs @@ -161,6 +161,37 @@ public async Task OrganizationProjectScene_OrganizationWithoutSecretsManager_Ret Assert.False(await db.Project.AnyAsync(p => p.OrganizationId == organizationId)); } + [Fact] + public async Task OrganizationSecretScene_ProjectNotInOrganization_ReturnsBadRequest() + { + var playId = Guid.NewGuid().ToString(); + + var ownerUserId = await SeedUserAsync(playId); + var (organizationId, _, organizationKeyB64) = await SeedSmOrganizationAsync(playId, ownerUserId); + + var response = await _client.PostAsJsonAsync("/seed", new SeedRequestModel + { + Template = nameof(OrganizationSecretScene), + Arguments = JsonSerializer.SerializeToElement(new OrganizationSecretScene.Request + { + OrganizationId = organizationId, + OrganizationKeyB64 = organizationKeyB64, + Key = "DB_PASSWORD", + Value = "s3cret", + ProjectIds = [Guid.NewGuid()] + }) + }, playId); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + Assert.Contains("not in organization", body); + + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + Assert.False(await db.Secret.AnyAsync(s => s.OrganizationId == organizationId)); + } + private async Task SeedUserAsync(string playId) { var result = await PostSceneAsync(playId, "SingleUserScene", new SingleUserScene.Request diff --git a/util/Seeder/Extensions/ProjectRepositoryExtensions.cs b/util/Seeder/Extensions/ProjectRepositoryExtensions.cs new file mode 100644 index 000000000000..f067b005ac30 --- /dev/null +++ b/util/Seeder/Extensions/ProjectRepositoryExtensions.cs @@ -0,0 +1,22 @@ +using Bit.Core.SecretsManager.Repositories; + +namespace Bit.Seeder.Extensions; + +internal static class ProjectRepositoryExtensions +{ + public static async Task ThrowIfProjectsNotInOrganizationAsync( + this IProjectRepository projectRepository, IEnumerable? 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}."); + } + } +} diff --git a/util/Seeder/Scenes/OrganizationSecretScene.cs b/util/Seeder/Scenes/OrganizationSecretScene.cs index e3c049c993ed..d00607746bb5 100644 --- a/util/Seeder/Scenes/OrganizationSecretScene.cs +++ b/util/Seeder/Scenes/OrganizationSecretScene.cs @@ -14,6 +14,7 @@ namespace Bit.Seeder.Scenes; public class OrganizationSecretScene( IOrganizationRepository organizationRepository, ISecretRepository secretRepository, + IProjectRepository projectRepository, IManglerService manglerService) : IScene { public class Request @@ -38,6 +39,8 @@ public async Task> SeedAsync(Request request) { var organization = await organizationRepository.GetSecretsManagerOrganizationOrThrowAsync(request.OrganizationId); + await projectRepository.ThrowIfProjectsNotInOrganizationAsync(request.ProjectIds, organization.Id); + var secret = SecretSeeder.Create( organization.Id, request.OrganizationKeyB64, From 672a5ecb430ba1aed241868b963debc0e8903c1c Mon Sep 17 00:00:00 2001 From: NedThompson Date: Thu, 27 Aug 2026 12:14:57 -0400 Subject: [PATCH 8/9] Guard SM access policy seeder scene against cross-org grants OrganizationAccessPolicyScene only verified the org had Secrets Manager enabled; it never checked that a grant grantee/grantable belonged to the target org, so it would persist cross-org access policies. Validate both grantee and grantable across all valid combos before creating policies, mirroring the OrganizationSecretScene project guard. Consolidate the branch-new per-repository guard extensions into a single SeederRepositoryGuardExtensions class covering organization, project, service account, group, and organization user membership checks. Add negative integration tests for cross-org grantable and grantee ids and update the seeder regression doc. --- .../Scenes/SecretsManagerSceneTests.cs | 82 ++++++++++++++ .../OrganizationRepositoryExtensions.cs | 25 ----- .../Extensions/ProjectRepositoryExtensions.cs | 22 ---- .../SeederRepositoryGuardExtensions.cs | 106 ++++++++++++++++++ .../Scenes/OrganizationAccessPolicyScene.cs | 27 ++++- util/Seeder/Seeds/docs/regression.md | 2 +- 6 files changed, 215 insertions(+), 49 deletions(-) delete mode 100644 util/Seeder/Extensions/OrganizationRepositoryExtensions.cs delete mode 100644 util/Seeder/Extensions/ProjectRepositoryExtensions.cs create mode 100644 util/Seeder/Extensions/SeederRepositoryGuardExtensions.cs diff --git a/test/SeederApi.IntegrationTest/Scenes/SecretsManagerSceneTests.cs b/test/SeederApi.IntegrationTest/Scenes/SecretsManagerSceneTests.cs index d0af256e89aa..a425e3cc706b 100644 --- a/test/SeederApi.IntegrationTest/Scenes/SecretsManagerSceneTests.cs +++ b/test/SeederApi.IntegrationTest/Scenes/SecretsManagerSceneTests.cs @@ -192,6 +192,88 @@ public async Task OrganizationSecretScene_ProjectNotInOrganization_ReturnsBadReq Assert.False(await db.Secret.AnyAsync(s => s.OrganizationId == organizationId)); } + [Fact] + public async Task OrganizationAccessPolicyScene_GrantableNotInOrganization_ReturnsBadRequest() + { + var playId = Guid.NewGuid().ToString(); + + var ownerUserId = await SeedUserAsync(playId); + var (organizationId, orgUserId, _) = await SeedSmOrganizationAsync(playId, ownerUserId); + + var response = await _client.PostAsJsonAsync("/seed", new SeedRequestModel + { + Template = nameof(OrganizationAccessPolicyScene), + Arguments = JsonSerializer.SerializeToElement(new OrganizationAccessPolicyScene.Request + { + OrganizationId = organizationId, + Grants = + [ + new OrganizationAccessPolicyScene.Grant + { + GranteeType = AccessPolicySeeder.GranteeType.OrganizationUser, + GranteeId = orgUserId, + GrantableType = AccessPolicySeeder.GrantableType.Project, + GrantableId = Guid.NewGuid() + } + ] + }) + }, playId); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + Assert.Contains("not in organization", body); + + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + Assert.False(await db.UserProjectAccessPolicy.AnyAsync(ap => ap.OrganizationUserId == orgUserId)); + } + + [Fact] + public async Task OrganizationAccessPolicyScene_GranteeNotInOrganization_ReturnsBadRequest() + { + var playId = Guid.NewGuid().ToString(); + + var ownerUserId = await SeedUserAsync(playId); + var (organizationId, _, organizationKeyB64) = await SeedSmOrganizationAsync(playId, ownerUserId); + + var projectResult = await PostSceneAsync(playId, nameof(OrganizationProjectScene), new OrganizationProjectScene.Request + { + OrganizationId = organizationId, + OrganizationKeyB64 = organizationKeyB64, + Name = "Production" + }); + var projectId = projectResult.GetProperty("projectId").GetGuid(); + + var response = await _client.PostAsJsonAsync("/seed", new SeedRequestModel + { + Template = nameof(OrganizationAccessPolicyScene), + Arguments = JsonSerializer.SerializeToElement(new OrganizationAccessPolicyScene.Request + { + OrganizationId = organizationId, + Grants = + [ + new OrganizationAccessPolicyScene.Grant + { + GranteeType = AccessPolicySeeder.GranteeType.OrganizationUser, + GranteeId = Guid.NewGuid(), + GrantableType = AccessPolicySeeder.GrantableType.Project, + GrantableId = projectId + } + ] + }) + }, playId); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + Assert.Contains("not in organization", body); + + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + Assert.False(await db.UserProjectAccessPolicy.AnyAsync(ap => ap.GrantedProjectId == projectId)); + } + private async Task SeedUserAsync(string playId) { var result = await PostSceneAsync(playId, "SingleUserScene", new SingleUserScene.Request diff --git a/util/Seeder/Extensions/OrganizationRepositoryExtensions.cs b/util/Seeder/Extensions/OrganizationRepositoryExtensions.cs deleted file mode 100644 index f12d430888aa..000000000000 --- a/util/Seeder/Extensions/OrganizationRepositoryExtensions.cs +++ /dev/null @@ -1,25 +0,0 @@ -using Bit.Core.AdminConsole.Entities; -using Bit.Core.Repositories; - -namespace Bit.Seeder.Extensions; - -internal static class OrganizationRepositoryExtensions -{ - public static async Task 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; - } -} diff --git a/util/Seeder/Extensions/ProjectRepositoryExtensions.cs b/util/Seeder/Extensions/ProjectRepositoryExtensions.cs deleted file mode 100644 index f067b005ac30..000000000000 --- a/util/Seeder/Extensions/ProjectRepositoryExtensions.cs +++ /dev/null @@ -1,22 +0,0 @@ -using Bit.Core.SecretsManager.Repositories; - -namespace Bit.Seeder.Extensions; - -internal static class ProjectRepositoryExtensions -{ - public static async Task ThrowIfProjectsNotInOrganizationAsync( - this IProjectRepository projectRepository, IEnumerable? 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}."); - } - } -} diff --git a/util/Seeder/Extensions/SeederRepositoryGuardExtensions.cs b/util/Seeder/Extensions/SeederRepositoryGuardExtensions.cs new file mode 100644 index 000000000000..76dc06767621 --- /dev/null +++ b/util/Seeder/Extensions/SeederRepositoryGuardExtensions.cs @@ -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 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? 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? 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? 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? 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 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); + } +} diff --git a/util/Seeder/Scenes/OrganizationAccessPolicyScene.cs b/util/Seeder/Scenes/OrganizationAccessPolicyScene.cs index 1768b00097b2..5b24fd7675ab 100644 --- a/util/Seeder/Scenes/OrganizationAccessPolicyScene.cs +++ b/util/Seeder/Scenes/OrganizationAccessPolicyScene.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations; +using Bit.Core.AdminConsole.Repositories; using Bit.Core.Repositories; using Bit.Core.SecretsManager.Repositories; using Bit.Seeder.Extensions; @@ -14,6 +15,10 @@ namespace Bit.Seeder.Scenes; public class OrganizationAccessPolicyScene( IOrganizationRepository organizationRepository, IAccessPolicyRepository accessPolicyRepository, + IProjectRepository projectRepository, + IServiceAccountRepository serviceAccountRepository, + IGroupRepository groupRepository, + IOrganizationUserRepository organizationUserRepository, IManglerService manglerService) : IScene { public class Request @@ -49,7 +54,21 @@ public async Task> SeedAsync(Request request) { await organizationRepository.GetSecretsManagerOrganizationOrThrowAsync(request.OrganizationId); - var policies = request.Grants + 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(); @@ -63,4 +82,10 @@ public async Task> SeedAsync(Request request) }, mangleMap: manglerService.GetMangleMap()); } + + private static IEnumerable GrantableIds(IEnumerable grants, AccessPolicySeeder.GrantableType type) => + grants.Where(g => g.GrantableType == type).Select(g => g.GrantableId).Distinct(); + + private static IEnumerable GranteeIds(IEnumerable grants, AccessPolicySeeder.GranteeType type) => + grants.Where(g => g.GranteeType == type).Select(g => g.GranteeId).Distinct(); } diff --git a/util/Seeder/Seeds/docs/regression.md b/util/Seeder/Seeds/docs/regression.md index 85fff56454f3..4655994cceb2 100644 --- a/util/Seeder/Seeds/docs/regression.md +++ b/util/Seeder/Seeds/docs/regression.md @@ -41,7 +41,7 @@ ASPNETCORE_ENVIRONMENT=Development dotnet run -- preset --name --mangle | `OrganizationSeeder` plans | `qa.stark-free-basic`, `qa.paper-trail-partners-team`, `qa.enterprise-basic` | `Plan`, `PlanType`, `Seats`, feature flags per tier | | Plan overrides | `SingleOrganizationScene` + `overrides` | An override wins over the plan default — proves overrides still apply after `PlanFeatures.Apply` | | `ProviderSeeder` | `SingleProviderScene` (API only) | `Gateway=0` when the caller supplies none | -| SM `Organization*Scene`s | `SecretsManagerSceneTests` (API only) | Project/Secret/ServiceAccount rows persist via the real commercial EF repos (not Noop); encrypted name/key/value/note round-trip under the org key; access policies link grantee↔grantable | +| SM `Organization*Scene`s | `SecretsManagerSceneTests` (API only) | Project/Secret/ServiceAccount rows persist via the real commercial EF repos (not Noop); encrypted name/key/value/note round-trip under the org key; access policies link grantee↔grantable; a grant whose grantee or grantable belongs to another org returns HTTP 400 and persists no access-policy rows | | `SsoConfigSeeder` | `features.local-sso` | One `SsoConfig` row, `ConfigType=2`, non-empty `idpX509PublicCert` | | SSO provider guard | `features.sso-enterprise` | **Zero** `SsoConfig` rows — OIDC is skipped by design | | Attachment steps | `individual.encryption-modes` | `Cipher.Attachments` populated; every ID resolves to a blob | From 235c42ab4f9c615cb7db1a445a965d8d3f36de5c Mon Sep 17 00:00:00 2001 From: NedThompson Date: Thu, 27 Aug 2026 16:38:35 -0400 Subject: [PATCH 9/9] Cover group grantee cross-org guard in SM access policy scene --- .../Scenes/SecretsManagerSceneTests.cs | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/test/SeederApi.IntegrationTest/Scenes/SecretsManagerSceneTests.cs b/test/SeederApi.IntegrationTest/Scenes/SecretsManagerSceneTests.cs index a425e3cc706b..fc6dc463c619 100644 --- a/test/SeederApi.IntegrationTest/Scenes/SecretsManagerSceneTests.cs +++ b/test/SeederApi.IntegrationTest/Scenes/SecretsManagerSceneTests.cs @@ -274,6 +274,51 @@ public async Task OrganizationAccessPolicyScene_GranteeNotInOrganization_Returns Assert.False(await db.UserProjectAccessPolicy.AnyAsync(ap => ap.GrantedProjectId == projectId)); } + [Fact] + public async Task OrganizationAccessPolicyScene_GroupGranteeNotInOrganization_ReturnsBadRequest() + { + var playId = Guid.NewGuid().ToString(); + + var ownerUserId = await SeedUserAsync(playId); + var (organizationId, _, organizationKeyB64) = await SeedSmOrganizationAsync(playId, ownerUserId); + + var projectResult = await PostSceneAsync(playId, nameof(OrganizationProjectScene), new OrganizationProjectScene.Request + { + OrganizationId = organizationId, + OrganizationKeyB64 = organizationKeyB64, + Name = "Production" + }); + var projectId = projectResult.GetProperty("projectId").GetGuid(); + + var response = await _client.PostAsJsonAsync("/seed", new SeedRequestModel + { + Template = nameof(OrganizationAccessPolicyScene), + Arguments = JsonSerializer.SerializeToElement(new OrganizationAccessPolicyScene.Request + { + OrganizationId = organizationId, + Grants = + [ + new OrganizationAccessPolicyScene.Grant + { + GranteeType = AccessPolicySeeder.GranteeType.Group, + GranteeId = Guid.NewGuid(), + GrantableType = AccessPolicySeeder.GrantableType.Project, + GrantableId = projectId + } + ] + }) + }, playId); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + Assert.Contains("not in organization", body); + + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + Assert.False(await db.GroupProjectAccessPolicy.AnyAsync(ap => ap.GrantedProjectId == projectId)); + } + private async Task SeedUserAsync(string playId) { var result = await PostSceneAsync(playId, "SingleUserScene", new SingleUserScene.Request