From 49f84226c736d8f136136f7d2244791823bf452d Mon Sep 17 00:00:00 2001 From: Nick Beaugeard Date: Wed, 29 Apr 2026 14:01:22 +1000 Subject: [PATCH 1/2] Build secret management pages --- README.md | 2 + docs/feature_guides.md | 2 + docs/user_guide.md | 6 +- .../Components/Pages/Secrets.razor | 448 ++++++++++++++---- src/Conductor.Host/wwwroot/app.css | 214 +++++++++ .../SecretManagementPageTests.cs | 172 ++++++- 6 files changed, 740 insertions(+), 104 deletions(-) diff --git a/README.md b/README.md index 48dce70..502859e 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,8 @@ In `Development`, startup applies the current EF Core migration set and inserts Set `Conductor:BootstrapDevelopmentDatabase` to `false` to skip the development database bootstrap in test hosts or other controlled startup scenarios. +Secret descriptors are managed at `/settings/secrets`. The page supports creating, listing, rotating, and deleting GitHub PAT and OpenAI API key descriptors while masking saved values after entry. + ## Persistence Configuration The host registers `ConductorDbContext` from `src/Conductor.Infrastructure.Persistence.Sqlite` using the `ConnectionStrings:Conductor` value. The default is: diff --git a/docs/feature_guides.md b/docs/feature_guides.md index 11d36dd..7ca2225 100644 --- a/docs/feature_guides.md +++ b/docs/feature_guides.md @@ -33,3 +33,5 @@ When no repository projection data exists, the dashboard renders an empty state. The secret descriptor list supports GitHub PAT and OpenAI API key credentials as separate types. Descriptor rows show the credential name, scope, target environment variable, and a masked value only. OpenAI API key descriptors map to `OPENAI_API_KEY`; GitHub PAT descriptors map to `GITHUB_TOKEN`. Descriptors may include validation status, validation timestamp, a short validation message, and validation metadata JSON such as accepted token prefixes and the runtime environment variable used for injection. Plaintext token values are only accepted during create or rotate workflows and must not be returned in descriptor responses. + +Use the secret management page at `/settings/secrets` to add credential descriptors before wiring repositories or Symphony instances to credentials. Enter the value only when creating or rotating the descriptor; after the operation succeeds, Conductor stores the protected payload separately from descriptor metadata and renders only masked placeholders. diff --git a/docs/user_guide.md b/docs/user_guide.md index 8d7cf5a..5c14b21 100644 --- a/docs/user_guide.md +++ b/docs/user_guide.md @@ -10,9 +10,11 @@ Initial user workflows will cover dashboard review, repository import, instance The dashboard includes a needs-attention panel for active critical and warning items. Each row shows the affected repository or Symphony instance, the current severity, the reason it needs attention, and a link to the source area for follow-up. -## Secret Review +## Secret Management -The Secrets page lists saved credential descriptors for orchestration. GitHub PAT and OpenAI API key descriptors are shown independently, and saved values are rendered only as masked placeholders. +Open `/settings/secrets` to create and maintain credential descriptors. The page supports GitHub PAT, OpenAI API key, Codex home, and other secret descriptors scoped globally, by project, by repository, or by Symphony instance. + +Saved secret values are masked in the descriptor list. Operators can rotate or delete a descriptor from the list, but stored values are not shown again after creation or rotation. ## Manual Instance Registration diff --git a/src/Conductor.Host/Components/Pages/Secrets.razor b/src/Conductor.Host/Components/Pages/Secrets.razor index c4809d2..99198f4 100644 --- a/src/Conductor.Host/Components/Pages/Secrets.razor +++ b/src/Conductor.Host/Components/Pages/Secrets.razor @@ -1,9 +1,9 @@ @page "/settings/secrets" -@using System.Data.Common +@using System.Globalization @using Conductor.Core.Abstractions.Secrets -@using Conductor.Core.Application.Secrets +@using Conductor.Core.Domain.Ids @using Conductor.Core.Domain.Secrets -@inject ISecretDescriptorQueryService SecretDescriptorQueryService +@inject ISecretStore SecretStore Secrets - Conductor @@ -12,14 +12,11 @@

Credential vault

Secrets

-

GitHub and OpenAI credentials available to repository orchestration.

+

Create and maintain orchestration credentials while keeping saved values out of the UI.

- @if (descriptors is not null) - { - @descriptors.Count descriptors - } + @descriptors.Count descriptors
@@ -33,99 +30,354 @@ } - @if (loadError is not null) + @if (errorMessage is not null) { + Title="Secret operation failed." + Message="Review the requested change and try again." + Detail="@errorMessage" /> } - else if (descriptors is null) + + @if (successMessage is not null) { - + } - else if (descriptors.Count == 0) + +
+
+
+

New credential

+

Create Descriptor

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

Stored descriptors

+

Credentials

+
+ +
+ + @if (isLoading) + { + + } + else if (descriptors.Count == 0) + { + + } + else + { +
+ + + + + + + + + + + + + @foreach (SecretDescriptor descriptor in descriptors) + { + + + + + + + + + } + +
NameTypeScopeValueLast UpdatedActions
+ @descriptor.Name + @descriptor.RuntimeEnvironmentVariable + + @descriptor.TypeDisplayName + + @GetScopeLabel(descriptor) + + + @descriptor.MaskedDisplay + + + @FormatInstant(descriptor.RotatedAtUtc ?? descriptor.CreatedAtUtc) + @GetUpdatedLabel(descriptor) + +
+ + + +
+
+
+ } +
+
+ + +@code { + private static readonly SecretType[] AvailableSecretTypes = + [ + SecretType.GitHubToken, + SecretType.OpenAiApiKey, + SecretType.CodexHome, + SecretType.Other, + ]; + + private static readonly SecretScopeType[] AvailableScopeTypes = + [ + SecretScopeType.Global, + SecretScopeType.Project, + SecretScopeType.Repository, + SecretScopeType.SymphonyInstance, + ]; + + private readonly CreateSecretFormModel createModel = new(); + private readonly Dictionary rotationValues = []; + private List descriptors = []; + private bool isLoading = true; + private string? errorMessage; + private string? successMessage; + + private bool CanCreateSecret => + !string.IsNullOrWhiteSpace(createModel.Name) + && !string.IsNullOrWhiteSpace(createModel.Value) + && (createModel.ScopeType == SecretScopeType.Global || !string.IsNullOrWhiteSpace(createModel.ScopeId)); + + protected override async Task OnInitializedAsync() { - + await LoadSecretsAsync(); } - else + + private async Task RefreshSecretsAsync() { -
- - - - - - - - - - - - - @foreach (SecretDescriptorView descriptor in descriptors) - { - - - - - - - - - } - -
NameTypeScopeValueCreatedRotated
- @descriptor.Name - @descriptor.EnvironmentVariableName - - @descriptor.SecretTypeLabel - - @descriptor.ScopeLabel - - - @descriptor.MaskedValue - - - - - @if (descriptor.RotatedAtUtc is { } rotatedAtUtc) - { - - } - else - { - Not rotated - } -
-
+ await LoadSecretsAsync(); } - -@code { - private IReadOnlyList? descriptors; - private string? loadError; + private async Task CreateSecretAsync() + { + ClearMessages(); - protected override async Task OnInitializedAsync() + if (!CanCreateSecret) + { + errorMessage = "Name, value, and a non-global scope ID are required."; + return; + } + + try + { + string? scopeId = createModel.ScopeType == SecretScopeType.Global + ? null + : createModel.ScopeId.Trim(); + + await SecretStore.CreateAsync( + new CreateSecretRequest( + createModel.Name.Trim(), + createModel.SecretType, + createModel.ScopeType, + scopeId, + createModel.Value), + CancellationToken.None); + + createModel.Reset(); + successMessage = "Descriptor created. The saved value is masked and cannot be viewed from this page."; + await LoadSecretsAsync(clearMessages: false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + errorMessage = ex.Message; + } + } + + private async Task RotateSecretAsync(SecretId secretId) { + ClearMessages(); + string value = GetRotationValue(secretId); + + if (string.IsNullOrWhiteSpace(value)) + { + errorMessage = "A replacement value is required before rotating a descriptor."; + return; + } + try { - descriptors = await SecretDescriptorQueryService.ListAsync(new SecretQuery()); + await SecretStore.RotateAsync(secretId, new RotateSecretRequest(value), CancellationToken.None); + rotationValues.Remove(secretId); + successMessage = "Descriptor rotated. The replacement value is masked and cannot be viewed from this page."; + await LoadSecretsAsync(clearMessages: false); } - catch (Exception ex) when (ex is DbException or InvalidOperationException or IOException or UnauthorizedAccessException) + catch (Exception ex) when (ex is not OperationCanceledException) { - loadError = ex.Message; + errorMessage = ex.Message; } } + private async Task DeleteSecretAsync(SecretId secretId) + { + ClearMessages(); + + try + { + await SecretStore.DeleteAsync(secretId, CancellationToken.None); + rotationValues.Remove(secretId); + successMessage = "Descriptor deleted."; + await LoadSecretsAsync(clearMessages: false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + errorMessage = ex.Message; + } + } + + private async Task LoadSecretsAsync(bool clearMessages = true) + { + if (clearMessages) + { + ClearMessages(); + } + + isLoading = true; + + try + { + descriptors = (await SecretStore.ListAsync(new SecretQuery(), CancellationToken.None)).ToList(); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + errorMessage = ex.Message; + } + finally + { + isLoading = false; + } + } + + private void ClearMessages() + { + errorMessage = null; + successMessage = null; + } + + private string GetRotationValue(SecretId secretId) => + rotationValues.TryGetValue(secretId, out string? value) ? value : string.Empty; + + private void SetRotationValue(SecretId secretId, string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + rotationValues.Remove(secretId); + return; + } + + rotationValues[secretId] = value; + } + + private static string GetRotationInputName(SecretId secretId) => + $"secret-rotate-{secretId}"; + + private static string FormatInstant(DateTimeOffset instant) => + instant.ToLocalTime().ToString("MMM d, yyyy HH:mm", CultureInfo.CurrentCulture); + + private static string GetUpdatedLabel(SecretDescriptor descriptor) => + descriptor.RotatedAtUtc is null ? "Created" : "Rotated"; + + private static string GetScopeLabel(SecretDescriptor descriptor) => + string.IsNullOrWhiteSpace(descriptor.ScopeId) + ? GetScopeTypeLabel(descriptor.ScopeType) + : $"{GetScopeTypeLabel(descriptor.ScopeType)}: {descriptor.ScopeId}"; + private static string GetSecretTypeClass(SecretType secretType) { return secretType switch @@ -135,4 +387,36 @@ _ => "secret-type-pill", }; } + + private static string GetScopeTypeLabel(SecretScopeType scopeType) => + scopeType switch + { + SecretScopeType.Global => "Global", + SecretScopeType.Project => "Project", + SecretScopeType.Repository => "Repository", + SecretScopeType.SymphonyInstance => "Symphony instance", + _ => "Global", + }; + + private sealed class CreateSecretFormModel + { + public string Name { get; set; } = string.Empty; + + public SecretType SecretType { get; set; } = SecretType.GitHubToken; + + public SecretScopeType ScopeType { get; set; } = SecretScopeType.Global; + + public string ScopeId { get; set; } = string.Empty; + + public string Value { get; set; } = string.Empty; + + public void Reset() + { + Name = string.Empty; + SecretType = SecretType.GitHubToken; + ScopeType = SecretScopeType.Global; + ScopeId = string.Empty; + Value = string.Empty; + } + } } diff --git a/src/Conductor.Host/wwwroot/app.css b/src/Conductor.Host/wwwroot/app.css index 9509183..c5f2c4c 100644 --- a/src/Conductor.Host/wwwroot/app.css +++ b/src/Conductor.Host/wwwroot/app.css @@ -1718,6 +1718,208 @@ h2 { background: #14261f; } +.secret-management { + display: grid; + gap: 24px; + max-width: 1240px; +} + +.secret-management-header, +.secret-list-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.secret-management-summary { + margin: 8px 0 0; + color: #aeb9c5; +} + +.secret-management-body { + display: grid; + grid-template-columns: minmax(280px, 360px) minmax(0, 1fr); + gap: 18px; + align-items: start; +} + +.secret-form-panel, +.secret-list-panel { + display: grid; + gap: 18px; + padding: 24px; + border: 1px solid #2b3138; + border-radius: 8px; + background: #151a1f; +} + +.secret-form { + display: grid; + gap: 14px; +} + +.secret-form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.secret-form label { + display: grid; + gap: 7px; +} + +.secret-form label span { + color: #aeb9c5; + font-size: 0.82rem; + font-weight: 800; + text-transform: uppercase; +} + +.secret-form input, +.secret-form select, +.secret-actions input { + width: 100%; + min-height: 42px; + border: 1px solid #3b4652; + border-radius: 6px; + background: #111417; + color: #e6edf3; + font: inherit; +} + +.secret-form input, +.secret-actions input { + padding: 0 12px; +} + +.secret-form select { + padding: 0 10px; +} + +.secret-form input:disabled { + color: #74808d; + background: #1b2228; +} + +.secret-form input:focus, +.secret-form select:focus, +.secret-actions input:focus { + outline: 2px solid #9cc2ff; + outline-offset: 2px; +} + +.secret-primary-action, +.secret-secondary-action, +.secret-danger-action { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 40px; + padding: 0 14px; + border: 1px solid transparent; + border-radius: 6px; + font: inherit; + font-size: 0.88rem; + font-weight: 800; + cursor: pointer; +} + +.secret-primary-action { + background: #f2c14e; + color: #171b20; +} + +.secret-secondary-action { + border-color: #3b4652; + background: #232a31; + color: #dbe6ee; +} + +.secret-danger-action { + border-color: #904247; + background: #452022; + color: #ffb4ab; +} + +.secret-primary-action:disabled, +.secret-secondary-action:disabled, +.secret-danger-action:disabled { + cursor: not-allowed; + opacity: 0.55; +} + +.secret-table-wrap { + overflow-x: auto; +} + +.secret-table { + width: 100%; + min-width: 940px; + border-collapse: collapse; +} + +.secret-table th, +.secret-table td { + padding: 16px 14px; + border-top: 1px solid #2b3138; + vertical-align: top; + text-align: left; +} + +.secret-table thead th { + border-top: 0; + color: #aeb9c5; + font-size: 0.8rem; + font-weight: 800; + text-transform: uppercase; +} + +.secret-name, +.secret-scope, +.secret-masked-value { + display: block; + color: #ffffff; + font-weight: 800; + overflow-wrap: anywhere; +} + +.secret-masked-value { + width: fit-content; + max-width: 100%; + padding: 6px 9px; + border: 1px solid #3b4652; + border-radius: 6px; + background: #111417; + letter-spacing: 0.08em; +} + +.secret-table code, +.secret-table small { + display: block; + margin-top: 6px; + color: #aeb9c5; + font-size: 0.82rem; + overflow-wrap: anywhere; +} + +.secret-table code { + width: fit-content; + max-width: 100%; + padding: 4px 6px; + border-radius: 4px; + background: #232a31; + color: #f2c14e; +} + +.secret-actions { + display: grid; + grid-template-columns: minmax(160px, 1fr) auto auto; + gap: 8px; + align-items: center; +} + @keyframes ui-state-spin { to { transform: rotate(360deg); @@ -1755,7 +1957,10 @@ h2 { .form-fields, .repository-metadata-grid, .import-form-grid, + .secret-management-body, .live-activity-header, + .secret-management-header, + .secret-list-header, .startup-panel dl { grid-template-columns: 1fr; } @@ -1780,6 +1985,15 @@ h2 { min-width: 760px; } + .secret-form-grid, + .secret-actions { + grid-template-columns: 1fr; + } + + .secret-table { + min-width: 820px; + } + .metric-grid { grid-template-columns: 1fr; } diff --git a/tests/Conductor.Blazor.Tests/SecretManagementPageTests.cs b/tests/Conductor.Blazor.Tests/SecretManagementPageTests.cs index 6d6715f..456d125 100644 --- a/tests/Conductor.Blazor.Tests/SecretManagementPageTests.cs +++ b/tests/Conductor.Blazor.Tests/SecretManagementPageTests.cs @@ -1,6 +1,6 @@ using Bunit; using Conductor.Core.Abstractions.Secrets; -using Conductor.Core.Application.Secrets; +using Conductor.Core.Domain; using Conductor.Core.Domain.Ids; using Conductor.Core.Domain.Secrets; using Conductor.Host.Components.Pages; @@ -14,17 +14,16 @@ public sealed class SecretManagementPageTests public void Secrets_Renders_OpenAi_Key_Descriptor_With_Masked_Value() { using BunitContext context = new(); - DateTimeOffset createdAtUtc = DateTimeOffset.Parse("2026-04-29T00:10:00Z"); - SecretDescriptor descriptor = new( - SecretId.New(), - "Production OpenAI key", - SecretType.OpenAiApiKey, - SecretScopeType.Global, - scopeId: null, - createdAtUtc); - - context.Services.AddSingleton( - new StaticSecretDescriptorQueryService([SecretDescriptorView.FromDescriptor(descriptor)])); + RecordingSecretStore store = new( + new SecretDescriptor( + SecretId.New(), + "Production OpenAI key", + SecretType.OpenAiApiKey, + SecretScopeType.Global, + scopeId: null, + DateTimeOffset.Parse("2026-04-29T00:10:00Z"))); + store.SetRawValue("Production OpenAI key", "sk-existing-secret-value"); + context.Services.AddSingleton(store); IRenderedComponent page = context.Render(); @@ -33,15 +32,14 @@ public void Secrets_Renders_OpenAi_Key_Descriptor_With_Masked_Value() Assert.Contains("OpenAI API key", page.Markup, StringComparison.Ordinal); Assert.Contains("OPENAI_API_KEY", page.Markup, StringComparison.Ordinal); Assert.Contains(SecretTypeMetadata.MaskedDisplayValue, page.Markup, StringComparison.Ordinal); - Assert.DoesNotContain("sk-", page.Markup, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("sk-existing-secret-value", page.Markup, StringComparison.Ordinal); } [Fact] public void Secrets_Renders_Empty_State_When_No_Descriptors_Exist() { using BunitContext context = new(); - context.Services.AddSingleton( - new StaticSecretDescriptorQueryService([])); + context.Services.AddSingleton(new RecordingSecretStore()); IRenderedComponent page = context.Render(); @@ -50,14 +48,148 @@ public void Secrets_Renders_Empty_State_When_No_Descriptors_Exist() Assert.Contains("OpenAI API key", page.Markup, StringComparison.Ordinal); } - private sealed class StaticSecretDescriptorQueryService(IReadOnlyList descriptors) - : ISecretDescriptorQueryService + [Fact] + public void Secrets_Creates_Rotates_And_Deletes_Without_Rendering_Values() + { + using BunitContext context = new(); + RecordingSecretStore store = new(); + context.Services.AddSingleton(store); + + IRenderedComponent page = context.Render(); + + page.Find("input[name='secret-name']").Input("Release Portal PAT"); + page.Find("input[name='secret-value']").Input("ghp_created-secret-value"); + page.Find("form[data-secret-create]").Submit(); + + page.WaitForAssertion(() => + { + CreateSecretRequest createdRequest = Assert.IsType(store.CreatedRequest); + Assert.Equal("ghp_created-secret-value", createdRequest.Value); + Assert.Contains("Release Portal PAT", page.Markup, StringComparison.Ordinal); + Assert.DoesNotContain("ghp_created-secret-value", page.Markup, StringComparison.Ordinal); + }); + + SecretId createdId = store.CreatedSecretId; + page.Find($"input[name='secret-rotate-{createdId}']").Input("ghp_rotated-secret-value"); + page.FindAll("button") + .Single(button => button.TextContent.Contains("Rotate", StringComparison.Ordinal)) + .Click(); + + page.WaitForAssertion(() => + { + Assert.Equal(createdId, store.RotatedSecretId); + RotateSecretRequest rotatedRequest = Assert.IsType(store.RotatedRequest); + Assert.Equal("ghp_rotated-secret-value", rotatedRequest.Value); + Assert.DoesNotContain("ghp_rotated-secret-value", page.Markup, StringComparison.Ordinal); + }); + + page.FindAll("button") + .Single(button => button.TextContent.Contains("Delete", StringComparison.Ordinal)) + .Click(); + + page.WaitForAssertion(() => + { + Assert.Equal(createdId, store.DeletedSecretId); + Assert.Contains("No secret descriptors yet.", page.Markup, StringComparison.Ordinal); + Assert.DoesNotContain("Release Portal PAT", page.Markup, StringComparison.Ordinal); + }); + } + + private sealed class RecordingSecretStore : ISecretStore { - public Task> ListAsync( + private readonly List descriptors; + private readonly Dictionary rawValues = []; + + public RecordingSecretStore(params SecretDescriptor[] descriptors) + { + this.descriptors = descriptors.ToList(); + } + + public CreateSecretRequest? CreatedRequest { get; private set; } + + public SecretId CreatedSecretId { get; private set; } + + public RotateSecretRequest? RotatedRequest { get; private set; } + + public SecretId RotatedSecretId { get; private set; } + + public SecretId DeletedSecretId { get; private set; } + + public Task CreateAsync(CreateSecretRequest request, CancellationToken cancellationToken) + { + CreatedRequest = request; + CreatedSecretId = SecretId.New(); + var descriptor = new SecretDescriptor( + CreatedSecretId, + request.Name, + request.SecretType, + request.ScopeType, + request.ScopeId, + DateTimeOffset.Parse("2026-04-29T01:20:00Z")); + + descriptors.Add(descriptor); + rawValues[descriptor.Id] = request.Value; + + return Task.FromResult(descriptor); + } + + public Task RotateAsync( + SecretId secretId, + RotateSecretRequest request, + CancellationToken cancellationToken) + { + RotatedSecretId = secretId; + RotatedRequest = request; + rawValues[secretId] = request.Value; + descriptors.Single(descriptor => descriptor.Id == secretId) + .MarkRotated(DateTimeOffset.Parse("2026-04-29T01:30:00Z")); + + return Task.CompletedTask; + } + + public Task ResolveAsync( + SecretReference reference, + CancellationToken cancellationToken) + { + return Task.FromResult(new ResolvedSecret(reference.SecretId, rawValues[reference.SecretId])); + } + + public Task ResolveAsync( + SecretResolutionRequest request, + CancellationToken cancellationToken) + { + SecretDescriptor? descriptor = descriptors.FirstOrDefault(secret => secret.SecretType == request.SecretType); + + return Task.FromResult(descriptor is null + ? null + : new ResolvedSecret(descriptor.Id, rawValues[descriptor.Id])); + } + + public Task> ListAsync( SecretQuery query, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken) + { + return Task.FromResult>( + descriptors + .Where(descriptor => query.SecretType is null || descriptor.SecretType == query.SecretType) + .Where(descriptor => query.ScopeType is null || descriptor.ScopeType == query.ScopeType) + .OrderBy(descriptor => descriptor.Name, StringComparer.Ordinal) + .ToList()); + } + + public Task DeleteAsync(SecretId secretId, CancellationToken cancellationToken) + { + DeletedSecretId = secretId; + descriptors.RemoveAll(descriptor => descriptor.Id == secretId); + rawValues.Remove(secretId); + + return Task.CompletedTask; + } + + public void SetRawValue(string name, string value) { - return Task.FromResult(descriptors); + SecretDescriptor descriptor = descriptors.Single(secret => secret.Name == name); + rawValues[descriptor.Id] = value; } } } From 4eeb960d7ab350159d2b980aae4433d56afdb1ce Mon Sep 17 00:00:00 2001 From: Nick Beaugeard Date: Wed, 29 Apr 2026 15:58:08 +1000 Subject: [PATCH 2/2] Refresh documentation validation