Close admin trust audit gaps and stabilize live sweeps

This commit is contained in:
master
2026-03-12 10:14:00 +02:00
parent a00efb7ab2
commit 6964a046a5
50 changed files with 5968 additions and 2850 deletions

View File

@@ -0,0 +1,76 @@
# Sprint 20260311_014 - Platform Scratch Iteration 003 Full Route Action Audit
## Topic & Scope
- Wipe the Stella-only local runtime again and rerun the documented setup path from zero state.
- Re-test the rebuilt stack as a first-time operator with Playwright route and action coverage across the core release, security, ops, integration, and setup surfaces.
- If this fresh-stack pass exposes real failures, trace root cause, choose clean fixes, implement them, redeploy, and reverify before the iteration is closed.
- Working directory: `.`.
- Expected evidence: Stella-only wipe log, documented setup execution proof, fresh Playwright route/action results, root-cause notes for any failures, and a local commit for the iteration.
## Dependencies & Concurrency
- Depends on the clean iteration record in `a00efb7ab`.
- Safe parallelism: none during the wipe/rebuild and live sweeps because the environment reset is global to the machine.
## Documentation Prerequisites
- `AGENTS.md`
- `docs/INSTALL_GUIDE.md`
- `docs/dev/DEV_ENVIRONMENT_SETUP.md`
- `docs/qa/feature-checks/FLOW.md`
## Delivery Tracker
### PLATFORM-SCRATCH-ITER3-001 - Wipe Stella-only runtime state and rerun documented setup
Status: DONE
Dependency: none
Owners: QA, 3rd line support
Task description:
- Remove Stella-only containers, images, volumes, and networks, then rerun the documented setup path from the same first-time operator entrypoint.
Completion criteria:
- [x] Stella-only Docker state is removed without touching unrelated local assets.
- [x] `scripts/setup.ps1` is rerun from zero Stella state.
- [x] The bootstrap outcome is captured with concrete evidence.
### PLATFORM-SCRATCH-ITER3-002 - Re-run live route and action sweeps on the fresh stack
Status: DONE
Dependency: PLATFORM-SCRATCH-ITER3-001
Owners: QA
Task description:
- Re-authenticate on the rebuilt stack and rerun the route and action sweeps needed to validate page loads and user actions on the fresh deployment.
Completion criteria:
- [x] Fresh route sweep evidence is captured.
- [x] Fresh action sweep evidence is captured for the covered surface families.
- [x] Any newly exposed failures are enumerated before fixes begin.
### PLATFORM-SCRATCH-ITER3-003 - Root-cause and repair the next live failures
Status: DONE
Dependency: PLATFORM-SCRATCH-ITER3-002
Owners: 3rd line support, Product Manager, Architect, Developer
Task description:
- Diagnose and fix any fresh-stack defects surfaced by the iteration. If no new defect is exposed, record the clean pass explicitly and close the iteration with a local commit.
Completion criteria:
- [x] Each exposed failure has a documented root cause, or the clean pass is explicitly recorded.
- [x] Any required fix favors clean ownership/contracts over temporary fallbacks.
- [x] The iteration is committed locally after re-verification.
## Execution Log
| Date (UTC) | Update | Owner |
| --- | --- | --- |
| 2026-03-11 | Sprint created to start scratch iteration 003 immediately after the previous clean zero-state pass. | QA |
| 2026-03-12 | Cleansed Playwright output, reran the setup-topology and uncovered-surface sweeps, and verified both clean (`0` failed actions, `0` runtime issues) to remove stale harness noise before the aggregate pass. | QA |
| 2026-03-12 | Ran `live-full-core-audit.mjs` across all 19 suites. First pass isolated one failing action in `ops-policy-action-sweep` (`/ops/policy/simulation -> button:View Results`) while every other route/page/action suite passed. | QA |
| 2026-03-12 | Root-caused the policy simulation miss to a harness defect: multiple shadow-mode enable buttons exist during async load, and the sweep was selecting the first disabled control instead of an enabled action target. | 3rd line support / Architect |
| 2026-03-12 | Updated `live-ops-policy-action-sweep.mjs` to wait for an enabled shadow-mode control and for `View Results` to become interactable, then reran the targeted policy sweep cleanly (`0` failed actions, `0` runtime issues). | Developer |
| 2026-03-12 | Reran `live-full-core-audit.mjs`; final aggregate result was `19/19` suites passed with `failedSuiteCount=0`, including `111/111` canonical routes and all covered action families. | QA |
## Decisions & Risks
- Decision: keep iterating from true zero Stella state even after a clean pass so regressions that appear only intermittently still have a chance to surface.
- Risk: the documented setup path is expensive by design; correctness under wipe-and-rebuild remains the priority over speed.
- Decision: treat broad Playwright harness reliability as part of the product verification contract. False negatives that stem from stale readiness assumptions or disabled-control races are fixed before declaring a route family broken.
- Decision: the policy simulation `View Results` failure was not a product regression. The clean fix was to make the QA harness wait for the first enabled shadow-mode control rather than clicking the first matching label during async load.
## Next Checkpoints
- Start the next zero-state iteration and repeat the full route/page/action pass before any new fixes.
- Expand search-specific user journeys beyond the current 4-route matrix if fresh user reports expose ranking or handoff gaps.

View File

@@ -72,7 +72,7 @@ Role bundles are grouped by module and map to existing Authority scopes unless n
| Module | Role bundle | Scopes |
| --- | --- | --- |
| Console | `role/console-viewer` | `ui.read` |
| Console | `role/console-admin` | `ui.read`, `ui.admin`, `authority:tenants.read`, `authority:users.read`, `authority:roles.read`, `authority:clients.read`, `authority:tokens.read`, `authority:audit.read`, `authority:branding.read` |
| Console | `role/console-admin` | `ui.read`, `ui.admin`, `authority:tenants.read`, `authority:tenants.write`, `authority:users.read`, `authority:users.write`, `authority:roles.read`, `authority:roles.write`, `authority:clients.read`, `authority:clients.write`, `authority:tokens.read`, `authority:tokens.revoke`, `authority.audit.read`, `authority:branding.read`, `authority:branding.write` |
| Console | `role/console-superadmin` | `ui.read`, `ui.admin`, `authority:tenants.*`, `authority:users.*`, `authority:roles.*`, `authority:clients.*`, `authority:tokens.*`, `authority:audit.read`, `authority:branding.*` |
| Scanner | `role/scanner-viewer` | `scanner:read`, `findings:read`, `aoc:verify` |
| Scanner | `role/scanner-operator` | `scanner:read`, `scanner:scan`, `scanner:export`, `findings:read`, `aoc:verify` |
@@ -155,7 +155,7 @@ Scanner scopes are not yet defined in Authority. They are proposed as `scanner:r
Scheduler scopes are not yet defined in Authority. They are proposed as `scheduler:read`, `scheduler:operate`, and `scheduler:admin` and must be added to Authority constants, discovery metadata, and gateway enforcement.
Authority admin scopes (partial): `authority:tenants.read` exists. Must add: `authority:tenants.write`, `authority:users.read`, `authority:users.write`, `authority:roles.read`, `authority:roles.write`, `authority:clients.read`, `authority:clients.write`, `authority:tokens.read`, `authority:tokens.revoke`, `authority:branding.read`, `authority:branding.write`.
Authority admin scopes are now part of the local bootstrap console client and the seeded console-admin bundle: `authority:tenants.read|write`, `authority:users.read|write`, `authority:roles.read|write`, `authority:clients.read|write`, `authority:tokens.read|revoke`, `authority:branding.read|write`, and `authority.audit.read`.
UI admin scope: `ui.admin` must be added to Authority constants.

View File

@@ -31,6 +31,11 @@ Constraints:
- Only `image/svg+xml`, `image/png`, or `image/jpeg` accepted.
- Theme tokens restricted to a whitelist (no arbitrary CSS).
Persistence contract:
- Authority persists branding under `authority.tenants.settings.consoleBranding`.
- The stored record contains `displayName`, `logoUri`, `faviconUri`, `themeTokens`, `updatedAtUtc`, `updatedBy`, and `hash`.
- `GET /console/branding` and `GET /console/admin/branding` must read the persisted record first and only fall back to static defaults when no tenant record exists yet.
## 4. Configuration Layering
1. **Static defaults** from `/config.json`.
2. **Tenant branding** from Authority after login.

View File

@@ -24,6 +24,7 @@ using StellaOps.Authority.Persistence.InMemory.Stores;
using StellaOps.Authority.Persistence.Postgres.Models;
using StellaOps.Authority.Persistence.Postgres.Repositories;
using StellaOps.Authority.Persistence.Sessions;
using StellaOps.Authority.Tenants;
using StellaOps.Cryptography.Audit;
using Xunit;
@@ -119,6 +120,250 @@ public sealed class ConsoleAdminEndpointsTests
Assert.Contains(listed!.Users, static user => user.Username == "alice");
}
[Fact]
public async Task CreateUser_WithInvalidEmail_ReturnsBadRequest()
{
var now = new DateTimeOffset(2026, 2, 20, 13, 30, 0, TimeSpan.Zero);
var timeProvider = new FakeTimeProvider(now);
var sink = new RecordingAuthEventSink();
var users = new InMemoryUserRepository();
await using var app = await CreateApplicationAsync(timeProvider, sink, users);
var principalAccessor = app.Services.GetRequiredService<AdminTestPrincipalAccessor>();
principalAccessor.Principal = CreatePrincipal(
tenant: "default",
scopes: new[] { StellaOpsScopes.UiAdmin, StellaOpsScopes.AuthorityUsersWrite },
expiresAt: now.AddMinutes(10));
using var client = CreateTestClient(app);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(AdminAuthenticationDefaults.AuthenticationScheme);
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "default");
var response = await client.PostAsJsonAsync(
"/console/admin/users",
new
{
username = "qa-invalid-email",
email = "not-an-email",
displayName = "QA Invalid Email",
roles = new[] { "role/console-admin" }
});
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var payload = await response.Content.ReadFromJsonAsync<ErrorPayload>();
Assert.NotNull(payload);
Assert.Equal("invalid_email", payload!.Error);
}
[Fact]
public async Task RolesList_ExposesNamedDefaults_AndCreateRolePersists()
{
var now = new DateTimeOffset(2026, 2, 20, 13, 45, 0, TimeSpan.Zero);
var timeProvider = new FakeTimeProvider(now);
var sink = new RecordingAuthEventSink();
var users = new InMemoryUserRepository();
var roles = new InMemoryRoleRepository();
var permissions = new InMemoryPermissionRepository();
await using var app = await CreateApplicationAsync(
timeProvider,
sink,
users,
roleRepository: roles,
permissionRepository: permissions);
var principalAccessor = app.Services.GetRequiredService<AdminTestPrincipalAccessor>();
principalAccessor.Principal = CreatePrincipal(
tenant: "default",
scopes: new[]
{
StellaOpsScopes.UiAdmin,
StellaOpsScopes.AuthorityRolesRead,
StellaOpsScopes.AuthorityRolesWrite,
},
expiresAt: now.AddMinutes(10));
using var client = CreateTestClient(app);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(AdminAuthenticationDefaults.AuthenticationScheme);
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "default");
var listResponse = await client.GetAsync("/console/admin/roles");
Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode);
var listed = await listResponse.Content.ReadFromJsonAsync<RoleListPayload>();
Assert.NotNull(listed);
Assert.Contains(listed!.Roles, role => role.Name == "role/console-admin");
var createResponse = await client.PostAsJsonAsync(
"/console/admin/roles",
new
{
roleId = "security-analyst",
displayName = "Security Analyst",
scopes = new[] { "findings:read", "vex:read" }
});
Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode);
var created = await createResponse.Content.ReadFromJsonAsync<RoleSummary>();
Assert.NotNull(created);
Assert.Equal("security-analyst", created!.Name);
Assert.Equal(2, created.Permissions.Count);
var reloadResponse = await client.GetAsync("/console/admin/roles");
Assert.Equal(HttpStatusCode.OK, reloadResponse.StatusCode);
var reloaded = await reloadResponse.Content.ReadFromJsonAsync<RoleListPayload>();
Assert.NotNull(reloaded);
Assert.Contains(reloaded!.Roles, role => role.Name == "security-analyst");
}
[Fact]
public async Task TenantsList_MergesCatalog_AndCreateTenantPersists()
{
var now = new DateTimeOffset(2026, 2, 20, 14, 15, 0, TimeSpan.Zero);
var timeProvider = new FakeTimeProvider(now);
var sink = new RecordingAuthEventSink();
var users = new InMemoryUserRepository();
var tenants = new InMemoryTenantRepository();
var tenantCatalog = new FakeTenantCatalog(
[
new AuthorityTenantView("default", "Default", "active", "shared", Array.Empty<string>(), Array.Empty<string>())
]);
await using var app = await CreateApplicationAsync(
timeProvider,
sink,
users,
tenantRepository: tenants,
tenantCatalog: tenantCatalog);
var principalAccessor = app.Services.GetRequiredService<AdminTestPrincipalAccessor>();
principalAccessor.Principal = CreatePrincipal(
tenant: "default",
scopes: new[]
{
StellaOpsScopes.UiAdmin,
StellaOpsScopes.AuthorityTenantsRead,
StellaOpsScopes.AuthorityTenantsWrite,
},
expiresAt: now.AddMinutes(10));
using var client = CreateTestClient(app);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(AdminAuthenticationDefaults.AuthenticationScheme);
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "default");
var listResponse = await client.GetAsync("/console/admin/tenants");
Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode);
var listed = await listResponse.Content.ReadFromJsonAsync<TenantListPayload>();
Assert.NotNull(listed);
Assert.Contains(listed!.Tenants, tenant => tenant.Id == "default");
var createResponse = await client.PostAsJsonAsync(
"/console/admin/tenants",
new
{
id = "customer-stage",
displayName = "Customer Stage",
isolationMode = "dedicated"
});
Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode);
var created = await createResponse.Content.ReadFromJsonAsync<TenantSummary>();
Assert.NotNull(created);
Assert.Equal("customer-stage", created!.Id);
Assert.Equal("dedicated", created.IsolationMode);
var reloadResponse = await client.GetAsync("/console/admin/tenants");
Assert.Equal(HttpStatusCode.OK, reloadResponse.StatusCode);
var reloaded = await reloadResponse.Content.ReadFromJsonAsync<TenantListPayload>();
Assert.NotNull(reloaded);
Assert.Contains(reloaded!.Tenants, tenant => tenant.Id == "customer-stage");
}
[Fact]
public async Task BrandingEndpoints_PersistTenantBrandingInTenantSettings()
{
var now = new DateTimeOffset(2026, 2, 20, 14, 45, 0, TimeSpan.Zero);
var timeProvider = new FakeTimeProvider(now);
var sink = new RecordingAuthEventSink();
var users = new InMemoryUserRepository();
var tenants = new InMemoryTenantRepository();
await tenants.CreateAsync(new TenantEntity
{
Id = Guid.Parse("3ad551dc-e826-4ee0-81f7-83d77e0d4903"),
Slug = "default",
Name = "Default",
Description = "Default tenant",
ContactEmail = "admin@stella-ops.local",
Enabled = true,
Settings = "{}",
Metadata = "{}",
CreatedAt = now.AddDays(-10),
UpdatedAt = now.AddDays(-1),
CreatedBy = "seed",
});
await using var app = await CreateApplicationAsync(
timeProvider,
sink,
users,
tenantRepository: tenants,
tenantCatalog: new FakeTenantCatalog([
new AuthorityTenantView("default", "Default", "active", "shared", Array.Empty<string>(), Array.Empty<string>())
]));
var principalAccessor = app.Services.GetRequiredService<AdminTestPrincipalAccessor>();
principalAccessor.Principal = CreatePrincipal(
tenant: "default",
scopes: new[]
{
StellaOpsScopes.UiAdmin,
StellaOpsScopes.AuthorityBrandingRead,
StellaOpsScopes.AuthorityBrandingWrite,
},
expiresAt: now.AddMinutes(10));
using var client = CreateTestClient(app);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(AdminAuthenticationDefaults.AuthenticationScheme);
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "default");
var updateResponse = await client.PutAsJsonAsync(
"/console/admin/branding",
new
{
displayName = "Acme Ops",
logoUri = "data:image/svg+xml;base64,PHN2Zy8+",
faviconUri = (string?)null,
themeTokens = new Dictionary<string, string>
{
["--theme-brand-primary"] = "#123456"
}
});
Assert.Equal(HttpStatusCode.OK, updateResponse.StatusCode);
var updatePayload = await updateResponse.Content.ReadFromJsonAsync<AdminBrandingPayload>();
Assert.NotNull(updatePayload);
Assert.Equal("Acme Ops", updatePayload!.Branding.DisplayName);
Assert.Equal("#123456", updatePayload.Branding.ThemeTokens["--theme-brand-primary"]);
Assert.False(string.IsNullOrWhiteSpace(updatePayload.Metadata?.Hash));
var adminReadResponse = await client.GetAsync("/console/admin/branding");
Assert.Equal(HttpStatusCode.OK, adminReadResponse.StatusCode);
var adminReadPayload = await adminReadResponse.Content.ReadFromJsonAsync<AdminBrandingPayload>();
Assert.NotNull(adminReadPayload);
Assert.Equal("Acme Ops", adminReadPayload!.Branding.DisplayName);
var publicReadResponse = await client.GetAsync("/console/branding?tenantId=default");
Assert.Equal(HttpStatusCode.OK, publicReadResponse.StatusCode);
var publicBranding = await publicReadResponse.Content.ReadFromJsonAsync<BrandingSummary>();
Assert.NotNull(publicBranding);
Assert.Equal("Acme Ops", publicBranding!.DisplayName);
var persistedTenant = await tenants.GetBySlugAsync("default");
Assert.NotNull(persistedTenant);
Assert.Contains("consoleBranding", persistedTenant!.Settings);
Assert.Contains("Acme Ops", persistedTenant.Settings);
}
[Fact]
public async Task LegacyApiAlias_UsersListAndCreate_WorkForApiAdminPath()
{
@@ -410,7 +655,11 @@ public sealed class ConsoleAdminEndpointsTests
FakeTimeProvider timeProvider,
RecordingAuthEventSink sink,
IUserRepository userRepository,
IAuthorityClientStore? clientStore = null)
IAuthorityClientStore? clientStore = null,
IRoleRepository? roleRepository = null,
IPermissionRepository? permissionRepository = null,
ITenantRepository? tenantRepository = null,
IAuthorityTenantCatalog? tenantCatalog = null)
{
var builder = WebApplication.CreateBuilder(new WebApplicationOptions
{
@@ -423,6 +672,10 @@ public sealed class ConsoleAdminEndpointsTests
builder.Services.AddSingleton<IAuthEventSink>(sink);
builder.Services.AddSingleton(userRepository);
builder.Services.AddSingleton<IAuthorityClientStore>(clientStore ?? new InMemoryClientStore());
builder.Services.AddSingleton<IRoleRepository>(roleRepository ?? new InMemoryRoleRepository());
builder.Services.AddSingleton<IPermissionRepository>(permissionRepository ?? new InMemoryPermissionRepository());
builder.Services.AddSingleton<ITenantRepository>(tenantRepository ?? new InMemoryTenantRepository());
builder.Services.AddSingleton<IAuthorityTenantCatalog>(tenantCatalog ?? new FakeTenantCatalog([]));
builder.Services.AddSingleton<AdminTestPrincipalAccessor>();
builder.Services.AddHttpContextAccessor();
builder.Services.AddSingleton<StellaOpsBypassEvaluator>();
@@ -454,6 +707,7 @@ public sealed class ConsoleAdminEndpointsTests
app.UseAuthentication();
app.UseAuthorization();
app.MapConsoleAdminEndpoints();
app.MapConsoleBrandingEndpoints();
await app.StartAsync();
return app;
}
@@ -691,7 +945,296 @@ public sealed class ConsoleAdminEndpointsTests
}
}
private sealed class InMemoryRoleRepository : IRoleRepository
{
private readonly object sync = new();
private readonly List<RoleEntity> roles = new();
private readonly List<UserRoleEntity> assignments = new();
public Task<RoleEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
lock (sync)
{
return Task.FromResult(roles.FirstOrDefault(role => role.TenantId == tenantId && role.Id == id));
}
}
public Task<RoleEntity?> GetByNameAsync(string tenantId, string name, CancellationToken cancellationToken = default)
{
lock (sync)
{
return Task.FromResult(roles.FirstOrDefault(role =>
role.TenantId == tenantId &&
string.Equals(role.Name, name, StringComparison.OrdinalIgnoreCase)));
}
}
public Task<IReadOnlyList<RoleEntity>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
{
lock (sync)
{
return Task.FromResult<IReadOnlyList<RoleEntity>>(roles.Where(role => role.TenantId == tenantId).ToList());
}
}
public Task<IReadOnlyList<RoleEntity>> GetUserRolesAsync(string tenantId, Guid userId, CancellationToken cancellationToken = default)
{
lock (sync)
{
var assignedIds = assignments
.Where(assignment => assignment.UserId == userId && (!assignment.ExpiresAt.HasValue || assignment.ExpiresAt > DateTimeOffset.UtcNow))
.Select(assignment => assignment.RoleId)
.ToHashSet();
return Task.FromResult<IReadOnlyList<RoleEntity>>(roles
.Where(role => role.TenantId == tenantId && assignedIds.Contains(role.Id))
.ToList());
}
}
public Task<Guid> CreateAsync(string tenantId, RoleEntity role, CancellationToken cancellationToken = default)
{
lock (sync)
{
roles.Add(role);
return Task.FromResult(role.Id);
}
}
public Task UpdateAsync(string tenantId, RoleEntity role, CancellationToken cancellationToken = default)
{
lock (sync)
{
var index = roles.FindIndex(existing => existing.TenantId == tenantId && existing.Id == role.Id);
if (index >= 0)
{
roles[index] = role;
}
return Task.CompletedTask;
}
}
public Task DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
lock (sync)
{
roles.RemoveAll(role => role.TenantId == tenantId && role.Id == id);
assignments.RemoveAll(assignment => assignment.RoleId == id);
return Task.CompletedTask;
}
}
public Task AssignToUserAsync(string tenantId, Guid userId, Guid roleId, string? grantedBy, DateTimeOffset? expiresAt, CancellationToken cancellationToken = default)
{
lock (sync)
{
assignments.RemoveAll(assignment => assignment.UserId == userId && assignment.RoleId == roleId);
assignments.Add(new UserRoleEntity
{
UserId = userId,
RoleId = roleId,
GrantedAt = DateTimeOffset.UtcNow,
GrantedBy = grantedBy,
ExpiresAt = expiresAt,
});
return Task.CompletedTask;
}
}
public Task RemoveFromUserAsync(string tenantId, Guid userId, Guid roleId, CancellationToken cancellationToken = default)
{
lock (sync)
{
assignments.RemoveAll(assignment => assignment.UserId == userId && assignment.RoleId == roleId);
return Task.CompletedTask;
}
}
}
private sealed class InMemoryPermissionRepository : IPermissionRepository
{
private readonly object sync = new();
private readonly List<PermissionEntity> permissions = new();
private readonly List<RolePermissionEntity> assignments = new();
public Task<PermissionEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
lock (sync)
{
return Task.FromResult(permissions.FirstOrDefault(permission => permission.TenantId == tenantId && permission.Id == id));
}
}
public Task<PermissionEntity?> GetByNameAsync(string tenantId, string name, CancellationToken cancellationToken = default)
{
lock (sync)
{
return Task.FromResult(permissions.FirstOrDefault(permission =>
permission.TenantId == tenantId &&
string.Equals(permission.Name, name, StringComparison.OrdinalIgnoreCase)));
}
}
public Task<IReadOnlyList<PermissionEntity>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
{
lock (sync)
{
return Task.FromResult<IReadOnlyList<PermissionEntity>>(permissions.Where(permission => permission.TenantId == tenantId).ToList());
}
}
public Task<IReadOnlyList<PermissionEntity>> GetByResourceAsync(string tenantId, string resource, CancellationToken cancellationToken = default)
{
lock (sync)
{
return Task.FromResult<IReadOnlyList<PermissionEntity>>(permissions
.Where(permission => permission.TenantId == tenantId && permission.Resource == resource)
.ToList());
}
}
public Task<IReadOnlyList<PermissionEntity>> GetRolePermissionsAsync(string tenantId, Guid roleId, CancellationToken cancellationToken = default)
{
lock (sync)
{
var permissionIds = assignments.Where(assignment => assignment.RoleId == roleId).Select(assignment => assignment.PermissionId).ToHashSet();
return Task.FromResult<IReadOnlyList<PermissionEntity>>(permissions
.Where(permission => permission.TenantId == tenantId && permissionIds.Contains(permission.Id))
.ToList());
}
}
public Task<IReadOnlyList<PermissionEntity>> GetUserPermissionsAsync(string tenantId, Guid userId, CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<PermissionEntity>>(Array.Empty<PermissionEntity>());
public Task<Guid> CreateAsync(string tenantId, PermissionEntity permission, CancellationToken cancellationToken = default)
{
lock (sync)
{
permissions.Add(permission);
return Task.FromResult(permission.Id);
}
}
public Task DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
lock (sync)
{
permissions.RemoveAll(permission => permission.TenantId == tenantId && permission.Id == id);
assignments.RemoveAll(assignment => assignment.PermissionId == id);
return Task.CompletedTask;
}
}
public Task AssignToRoleAsync(string tenantId, Guid roleId, Guid permissionId, CancellationToken cancellationToken = default)
{
lock (sync)
{
assignments.RemoveAll(assignment => assignment.RoleId == roleId && assignment.PermissionId == permissionId);
assignments.Add(new RolePermissionEntity
{
RoleId = roleId,
PermissionId = permissionId,
CreatedAt = DateTimeOffset.UtcNow,
});
return Task.CompletedTask;
}
}
public Task RemoveFromRoleAsync(string tenantId, Guid roleId, Guid permissionId, CancellationToken cancellationToken = default)
{
lock (sync)
{
assignments.RemoveAll(assignment => assignment.RoleId == roleId && assignment.PermissionId == permissionId);
return Task.CompletedTask;
}
}
}
private sealed class InMemoryTenantRepository : ITenantRepository
{
private readonly object sync = new();
private readonly List<TenantEntity> tenants = new();
public Task<TenantEntity> CreateAsync(TenantEntity tenant, CancellationToken cancellationToken = default)
{
lock (sync)
{
tenants.Add(tenant);
return Task.FromResult(tenant);
}
}
public Task<TenantEntity?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
{
lock (sync)
{
return Task.FromResult(tenants.FirstOrDefault(tenant => tenant.Id == id));
}
}
public Task<TenantEntity?> GetBySlugAsync(string slug, CancellationToken cancellationToken = default)
{
lock (sync)
{
return Task.FromResult(tenants.FirstOrDefault(tenant => string.Equals(tenant.Slug, slug, StringComparison.OrdinalIgnoreCase)));
}
}
public Task<IReadOnlyList<TenantEntity>> GetAllAsync(bool? enabled = null, int limit = 100, int offset = 0, CancellationToken cancellationToken = default)
{
lock (sync)
{
IEnumerable<TenantEntity> query = tenants;
if (enabled.HasValue)
{
query = query.Where(tenant => tenant.Enabled == enabled.Value);
}
return Task.FromResult<IReadOnlyList<TenantEntity>>(query.Skip(offset).Take(limit).ToList());
}
}
public Task<bool> UpdateAsync(TenantEntity tenant, CancellationToken cancellationToken = default)
{
lock (sync)
{
var index = tenants.FindIndex(existing => existing.Id == tenant.Id);
if (index < 0)
{
return Task.FromResult(false);
}
tenants[index] = tenant;
return Task.FromResult(true);
}
}
public Task<bool> DeleteAsync(Guid id, CancellationToken cancellationToken = default)
{
lock (sync)
{
return Task.FromResult(tenants.RemoveAll(tenant => tenant.Id == id) > 0);
}
}
public Task<bool> SlugExistsAsync(string slug, CancellationToken cancellationToken = default)
{
lock (sync)
{
return Task.FromResult(tenants.Any(tenant => string.Equals(tenant.Slug, slug, StringComparison.OrdinalIgnoreCase)));
}
}
}
private sealed class FakeTenantCatalog(params IReadOnlyList<AuthorityTenantView> tenants) : IAuthorityTenantCatalog
{
public IReadOnlyList<AuthorityTenantView> GetTenants() => tenants;
}
private sealed record UserListPayload(IReadOnlyList<UserSummary> Users, int Count);
private sealed record RoleListPayload(IReadOnlyList<RoleSummary> Roles, int Count);
private sealed record TenantListPayload(IReadOnlyList<TenantSummary> Tenants, int Count);
private sealed record ClientListPayload(IReadOnlyList<ClientSummary> Clients, int Count, string SelectedTenant);
private sealed record ClientSummary(
string ClientId,
@@ -703,6 +1246,34 @@ public sealed class ConsoleAdminEndpointsTests
IReadOnlyList<string> AllowedScopes,
DateTimeOffset UpdatedAt);
private sealed record ErrorPayload(string Error, string? Message);
private sealed record RoleSummary(
string Id,
string Name,
string Description,
IReadOnlyList<string> Permissions,
int UserCount,
bool IsBuiltIn);
private sealed record TenantSummary(
string Id,
string DisplayName,
string Status,
string IsolationMode,
int UserCount,
DateTimeOffset CreatedAt);
private sealed record BrandingSummary(
string TenantId,
string DisplayName,
string? LogoUri,
string? FaviconUri,
IReadOnlyDictionary<string, string> ThemeTokens);
private sealed record BrandingMetadataPayload(
string TenantId,
DateTimeOffset UpdatedAtUtc,
string UpdatedBy,
string Hash);
private sealed record AdminBrandingPayload(
BrandingSummary Branding,
BrandingMetadataPayload? Metadata);
private sealed record UserSummary(
string Id,

View File

@@ -14,6 +14,7 @@ using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Tenants;
using StellaOps.Cryptography.Audit;
using System;
using System.ComponentModel.DataAnnotations;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
@@ -26,6 +27,7 @@ namespace StellaOps.Authority.Console.Admin;
internal static class ConsoleAdminEndpointExtensions
{
private static readonly EmailAddressAttribute EmailValidator = new();
private static readonly Regex TenantIdPattern = new(
"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
@@ -202,11 +204,45 @@ internal static class ConsoleAdminEndpointExtensions
private static async Task<IResult> ListTenants(
HttpContext httpContext,
[FromServices] IAuthorityTenantCatalog tenantCatalog,
ITenantRepository tenantRepository,
IUserRepository userRepository,
IAuthEventSink auditSink,
TimeProvider timeProvider,
CancellationToken cancellationToken)
{
var tenants = tenantCatalog.GetTenants();
var configuredTenants = tenantCatalog.GetTenants()
.ToDictionary(static tenant => tenant.Id, StringComparer.OrdinalIgnoreCase);
var persistedTenants = await tenantRepository.GetAllAsync(
enabled: null,
limit: 500,
offset: 0,
cancellationToken).ConfigureAwait(false);
var tenantIds = configuredTenants.Keys
.Concat(persistedTenants.Select(static tenant => tenant.Slug))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(static tenantId => tenantId, StringComparer.OrdinalIgnoreCase)
.ToArray();
var summaries = new List<AdminTenantSummary>(tenantIds.Length);
foreach (var tenantId in tenantIds)
{
configuredTenants.TryGetValue(tenantId, out var configuredTenant);
var persistedTenant = persistedTenants.FirstOrDefault(tenant =>
string.Equals(tenant.Slug, tenantId, StringComparison.OrdinalIgnoreCase));
var users = await userRepository.GetAllAsync(
tenantId,
enabled: null,
limit: 500,
offset: 0,
cancellationToken).ConfigureAwait(false);
summaries.Add(ToAdminTenantSummary(
tenantId,
configuredTenant,
persistedTenant,
users.Count));
}
await WriteAdminAuditAsync(
httpContext,
@@ -215,16 +251,16 @@ internal static class ConsoleAdminEndpointExtensions
"authority.admin.tenants.list",
AuthEventOutcome.Success,
null,
BuildProperties(("count", tenants.Count.ToString(CultureInfo.InvariantCulture))),
BuildProperties(("count", summaries.Count.ToString(CultureInfo.InvariantCulture))),
cancellationToken).ConfigureAwait(false);
return Results.Ok(new { tenants });
return Results.Ok(new { tenants = summaries, count = summaries.Count });
}
private static async Task<IResult> CreateTenant(
HttpContext httpContext,
CreateTenantRequest request,
[FromServices] IAuthorityTenantCatalog tenantCatalog,
ITenantRepository tenantRepository,
IAuthEventSink auditSink,
TimeProvider timeProvider,
CancellationToken cancellationToken)
@@ -234,7 +270,43 @@ internal static class ConsoleAdminEndpointExtensions
return Results.BadRequest(new { error = "invalid_request", message = "Tenant ID is required." });
}
// Placeholder: actual implementation would create tenant in storage
var normalizedTenantId = request.Id.Trim().ToLowerInvariant();
if (!TenantIdPattern.IsMatch(normalizedTenantId))
{
return Results.BadRequest(new { error = "invalid_tenant_id", message = "Tenant ID must use lowercase letters, digits, and hyphens only." });
}
var existing = await tenantRepository.GetBySlugAsync(normalizedTenantId, cancellationToken).ConfigureAwait(false);
if (existing is not null)
{
return Results.Conflict(new { error = "tenant_already_exists", tenantId = normalizedTenantId });
}
var createdBy = httpContext.User.FindFirstValue(OpenIddictConstants.Claims.PreferredUsername)
?? httpContext.User.FindFirstValue(StellaOpsClaimTypes.Subject)
?? "console-admin";
var now = timeProvider.GetUtcNow();
var created = await tenantRepository.CreateAsync(new TenantEntity
{
Id = Guid.NewGuid(),
Slug = normalizedTenantId,
Name = string.IsNullOrWhiteSpace(request.DisplayName) ? normalizedTenantId : request.DisplayName.Trim(),
Description = string.IsNullOrWhiteSpace(request.DisplayName) ? normalizedTenantId : request.DisplayName.Trim(),
Enabled = true,
Metadata = JsonSerializer.Serialize(new Dictionary<string, object?>
{
["isolationMode"] = NormalizeIsolationMode(request.IsolationMode),
}),
CreatedAt = now,
UpdatedAt = now,
CreatedBy = createdBy,
}, cancellationToken).ConfigureAwait(false);
var summary = ToAdminTenantSummary(
normalizedTenantId,
configuredTenant: null,
created,
userCount: 0);
await WriteAdminAuditAsync(
httpContext,
auditSink,
@@ -242,20 +314,44 @@ internal static class ConsoleAdminEndpointExtensions
"authority.admin.tenants.create",
AuthEventOutcome.Success,
null,
BuildProperties(("tenant.id", request.Id)),
BuildProperties(("tenant.id", normalizedTenantId)),
cancellationToken).ConfigureAwait(false);
return Results.Created($"/console/admin/tenants/{request.Id}", new { tenantId = request.Id, message = "Tenant creation: implementation pending" });
return Results.Created($"/console/admin/tenants/{normalizedTenantId}", summary);
}
private static async Task<IResult> UpdateTenant(
HttpContext httpContext,
string tenantId,
UpdateTenantRequest request,
ITenantRepository tenantRepository,
IAuthEventSink auditSink,
TimeProvider timeProvider,
CancellationToken cancellationToken)
{
var existing = await tenantRepository.GetBySlugAsync(tenantId, cancellationToken).ConfigureAwait(false);
if (existing is null)
{
return Results.NotFound(new { error = "tenant_not_found", tenantId });
}
var updated = new TenantEntity
{
Id = existing.Id,
Slug = existing.Slug,
Name = string.IsNullOrWhiteSpace(request.DisplayName) ? existing.Name : request.DisplayName.Trim(),
Description = string.IsNullOrWhiteSpace(request.DisplayName) ? existing.Description : request.DisplayName.Trim(),
ContactEmail = existing.ContactEmail,
Enabled = existing.Enabled,
Settings = existing.Settings,
Metadata = UpdateTenantMetadata(existing.Metadata, request.IsolationMode),
CreatedAt = existing.CreatedAt,
UpdatedAt = timeProvider.GetUtcNow(),
CreatedBy = existing.CreatedBy,
};
await tenantRepository.UpdateAsync(updated, cancellationToken).ConfigureAwait(false);
await WriteAdminAuditAsync(
httpContext,
auditSink,
@@ -266,16 +362,39 @@ internal static class ConsoleAdminEndpointExtensions
BuildProperties(("tenant.id", tenantId)),
cancellationToken).ConfigureAwait(false);
return Results.Ok(new { message = "Tenant update: implementation pending" });
return Results.Ok(ToAdminTenantSummary(tenantId, configuredTenant: null, updated, userCount: 0));
}
private static async Task<IResult> SuspendTenant(
HttpContext httpContext,
string tenantId,
ITenantRepository tenantRepository,
IAuthEventSink auditSink,
TimeProvider timeProvider,
CancellationToken cancellationToken)
{
var existing = await tenantRepository.GetBySlugAsync(tenantId, cancellationToken).ConfigureAwait(false);
if (existing is null)
{
return Results.NotFound(new { error = "tenant_not_found", tenantId });
}
var updated = new TenantEntity
{
Id = existing.Id,
Slug = existing.Slug,
Name = existing.Name,
Description = existing.Description,
ContactEmail = existing.ContactEmail,
Enabled = false,
Settings = existing.Settings,
Metadata = existing.Metadata,
CreatedAt = existing.CreatedAt,
UpdatedAt = timeProvider.GetUtcNow(),
CreatedBy = existing.CreatedBy,
};
await tenantRepository.UpdateAsync(updated, cancellationToken).ConfigureAwait(false);
await WriteAdminAuditAsync(
httpContext,
auditSink,
@@ -286,16 +405,39 @@ internal static class ConsoleAdminEndpointExtensions
BuildProperties(("tenant.id", tenantId)),
cancellationToken).ConfigureAwait(false);
return Results.Ok(new { message = "Tenant suspension: implementation pending" });
return Results.Ok(ToAdminTenantSummary(tenantId, configuredTenant: null, updated, userCount: 0));
}
private static async Task<IResult> ResumeTenant(
HttpContext httpContext,
string tenantId,
ITenantRepository tenantRepository,
IAuthEventSink auditSink,
TimeProvider timeProvider,
CancellationToken cancellationToken)
{
var existing = await tenantRepository.GetBySlugAsync(tenantId, cancellationToken).ConfigureAwait(false);
if (existing is null)
{
return Results.NotFound(new { error = "tenant_not_found", tenantId });
}
var updated = new TenantEntity
{
Id = existing.Id,
Slug = existing.Slug,
Name = existing.Name,
Description = existing.Description,
ContactEmail = existing.ContactEmail,
Enabled = true,
Settings = existing.Settings,
Metadata = existing.Metadata,
CreatedAt = existing.CreatedAt,
UpdatedAt = timeProvider.GetUtcNow(),
CreatedBy = existing.CreatedBy,
};
await tenantRepository.UpdateAsync(updated, cancellationToken).ConfigureAwait(false);
await WriteAdminAuditAsync(
httpContext,
auditSink,
@@ -306,7 +448,7 @@ internal static class ConsoleAdminEndpointExtensions
BuildProperties(("tenant.id", tenantId)),
cancellationToken).ConfigureAwait(false);
return Results.Ok(new { message = "Tenant resume: implementation pending" });
return Results.Ok(ToAdminTenantSummary(tenantId, configuredTenant: null, updated, userCount: 0));
}
// ========== USER ENDPOINTS ==========
@@ -314,6 +456,7 @@ internal static class ConsoleAdminEndpointExtensions
private static async Task<IResult> ListUsers(
HttpContext httpContext,
IUserRepository userRepository,
IRoleRepository roleRepository,
IAuthEventSink auditSink,
TimeProvider timeProvider,
CancellationToken cancellationToken)
@@ -326,10 +469,12 @@ internal static class ConsoleAdminEndpointExtensions
offset: 0,
cancellationToken).ConfigureAwait(false);
var userSummaries = users
.OrderBy(static user => user.Username, StringComparer.OrdinalIgnoreCase)
.Select(user => ToAdminUserSummary(user, timeProvider.GetUtcNow()))
.ToList();
var userSummaries = new List<AdminUserSummary>(users.Count);
foreach (var user in users.OrderBy(static user => user.Username, StringComparer.OrdinalIgnoreCase))
{
var roles = await ResolveUserRolesAsync(user, roleRepository, cancellationToken).ConfigureAwait(false);
userSummaries.Add(ToAdminUserSummary(user, timeProvider.GetUtcNow(), roles));
}
await WriteAdminAuditAsync(
httpContext,
@@ -348,6 +493,7 @@ internal static class ConsoleAdminEndpointExtensions
HttpContext httpContext,
CreateUserRequest request,
IUserRepository userRepository,
IRoleRepository roleRepository,
IAuthEventSink auditSink,
TimeProvider timeProvider,
CancellationToken cancellationToken)
@@ -363,6 +509,11 @@ internal static class ConsoleAdminEndpointExtensions
var normalizedUsername = request.Username.Trim().ToLowerInvariant();
var normalizedEmail = request.Email.Trim();
if (!EmailValidator.IsValid(normalizedEmail))
{
return Results.BadRequest(new { error = "invalid_email", email = normalizedEmail });
}
var existing = await userRepository.GetByUsernameAsync(tenantId, normalizedUsername, cancellationToken)
.ConfigureAwait(false);
if (existing is not null)
@@ -370,6 +521,13 @@ internal static class ConsoleAdminEndpointExtensions
return Results.Conflict(new { error = "user_already_exists", username = normalizedUsername });
}
var existingEmail = await userRepository.GetByEmailAsync(tenantId, normalizedEmail, cancellationToken)
.ConfigureAwait(false);
if (existingEmail is not null)
{
return Results.Conflict(new { error = "email_already_exists", email = normalizedEmail });
}
var normalizedRoles = NormalizeRoles(request.Roles);
var metadata = new Dictionary<string, object?>
{
@@ -399,7 +557,16 @@ internal static class ConsoleAdminEndpointExtensions
};
var created = await userRepository.CreateAsync(newUser, cancellationToken).ConfigureAwait(false);
var createdSummary = ToAdminUserSummary(created, timeProvider.GetUtcNow());
foreach (var roleName in normalizedRoles)
{
var role = await roleRepository.GetByNameAsync(tenantId, roleName, cancellationToken).ConfigureAwait(false);
if (role is not null)
{
await roleRepository.AssignToUserAsync(tenantId, created.Id, role.Id, createdBy, expiresAt: null, cancellationToken).ConfigureAwait(false);
}
}
var createdSummary = ToAdminUserSummary(created, timeProvider.GetUtcNow(), normalizedRoles);
await WriteAdminAuditAsync(
httpContext,
@@ -487,10 +654,50 @@ internal static class ConsoleAdminEndpointExtensions
private static async Task<IResult> ListRoles(
HttpContext httpContext,
IRoleRepository roleRepository,
IPermissionRepository permissionRepository,
IUserRepository userRepository,
IAuthEventSink auditSink,
TimeProvider timeProvider,
CancellationToken cancellationToken)
{
var tenantId = ResolveTenantId(httpContext);
var roles = await roleRepository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false);
var users = await userRepository.GetAllAsync(
tenantId,
enabled: null,
limit: 500,
offset: 0,
cancellationToken).ConfigureAwait(false);
var roleCounts = await BuildRoleCountMapAsync(tenantId, users, roleRepository, cancellationToken).ConfigureAwait(false);
var summaries = new List<AdminRoleSummary>();
foreach (var role in roles.OrderBy(static role => role.Name, StringComparer.OrdinalIgnoreCase))
{
var permissions = await permissionRepository.GetRolePermissionsAsync(tenantId, role.Id, cancellationToken).ConfigureAwait(false);
var roleName = string.IsNullOrWhiteSpace(role.Name)
? role.DisplayName ?? role.Id.ToString("N")
: role.Name;
summaries.Add(new AdminRoleSummary(
Id: role.Id.ToString("N"),
Name: roleName,
Description: string.IsNullOrWhiteSpace(role.Description) ? role.DisplayName ?? roleName : role.Description!,
Permissions: permissions.Select(static permission => permission.Name).OrderBy(static scope => scope, StringComparer.OrdinalIgnoreCase).ToArray(),
UserCount: roleCounts.TryGetValue(roleName, out var count) ? count : 0,
IsBuiltIn: role.IsSystem));
}
if (summaries.Count == 0)
{
summaries.AddRange(GetDefaultRoles().Select(static role => new AdminRoleSummary(
Id: role.RoleId,
Name: role.RoleId,
Description: role.DisplayName,
Permissions: role.Scopes,
UserCount: 0,
IsBuiltIn: true)));
}
await WriteAdminAuditAsync(
httpContext,
auditSink,
@@ -498,19 +705,80 @@ internal static class ConsoleAdminEndpointExtensions
"authority.admin.roles.list",
AuthEventOutcome.Success,
null,
Array.Empty<AuthEventProperty>(),
BuildProperties(("tenant.id", tenantId), ("roles.count", summaries.Count.ToString(CultureInfo.InvariantCulture))),
cancellationToken).ConfigureAwait(false);
return Results.Ok(new { roles = GetDefaultRoles(), message = "Role list: using default catalog" });
return Results.Ok(new { roles = summaries, count = summaries.Count });
}
private static async Task<IResult> CreateRole(
HttpContext httpContext,
CreateRoleRequest request,
IRoleRepository roleRepository,
IPermissionRepository permissionRepository,
IAuthEventSink auditSink,
TimeProvider timeProvider,
CancellationToken cancellationToken)
{
if (request is null || string.IsNullOrWhiteSpace(request.RoleId))
{
return Results.BadRequest(new { error = "role_id_required" });
}
var tenantId = ResolveTenantId(httpContext);
var normalizedRoleId = request.RoleId.Trim().ToLowerInvariant();
var existing = await roleRepository.GetByNameAsync(tenantId, normalizedRoleId, cancellationToken).ConfigureAwait(false);
if (existing is not null)
{
return Results.Conflict(new { error = "role_already_exists", roleId = normalizedRoleId });
}
var normalizedScopes = NormalizeScopes(request.Scopes);
if (normalizedScopes.Count == 0)
{
return Results.BadRequest(new { error = "scopes_required" });
}
var now = timeProvider.GetUtcNow();
var roleId = Guid.NewGuid();
await roleRepository.CreateAsync(tenantId, new RoleEntity
{
Id = roleId,
TenantId = tenantId,
Name = normalizedRoleId,
DisplayName = request.DisplayName?.Trim(),
Description = request.DisplayName?.Trim(),
IsSystem = false,
Metadata = "{}",
CreatedAt = now,
UpdatedAt = now,
}, cancellationToken).ConfigureAwait(false);
foreach (var scope in normalizedScopes)
{
var permission = await permissionRepository.GetByNameAsync(tenantId, scope, cancellationToken).ConfigureAwait(false);
var permissionId = permission?.Id ?? await permissionRepository.CreateAsync(tenantId, new PermissionEntity
{
Id = Guid.NewGuid(),
TenantId = tenantId,
Name = scope,
Resource = ExtractPermissionResource(scope),
Action = ExtractPermissionAction(scope),
Description = $"Console-admin created scope '{scope}'.",
CreatedAt = now,
}, cancellationToken).ConfigureAwait(false);
await permissionRepository.AssignToRoleAsync(tenantId, roleId, permissionId, cancellationToken).ConfigureAwait(false);
}
var summary = new AdminRoleSummary(
Id: roleId.ToString("N"),
Name: normalizedRoleId,
Description: string.IsNullOrWhiteSpace(request.DisplayName) ? normalizedRoleId : request.DisplayName.Trim(),
Permissions: normalizedScopes,
UserCount: 0,
IsBuiltIn: false);
await WriteAdminAuditAsync(
httpContext,
auditSink,
@@ -518,10 +786,10 @@ internal static class ConsoleAdminEndpointExtensions
"authority.admin.roles.create",
AuthEventOutcome.Success,
null,
BuildProperties(("role.id", request?.RoleId ?? "unknown")),
BuildProperties(("tenant.id", tenantId), ("role.id", normalizedRoleId)),
cancellationToken).ConfigureAwait(false);
return Results.Created("/console/admin/roles/new", new { message = "Role creation: implementation pending" });
return Results.Created($"/console/admin/roles/{normalizedRoleId}", summary);
}
private static async Task<IResult> UpdateRole(
@@ -1007,6 +1275,9 @@ internal static class ConsoleAdminEndpointExtensions
.ToList();
}
private static IReadOnlyList<string> NormalizeScopes(IReadOnlyList<string>? scopes)
=> NormalizeValues(scopes);
private static IReadOnlyList<string> NormalizeValues(IReadOnlyList<string>? values)
{
if (values is null || values.Count == 0)
@@ -1294,7 +1565,119 @@ internal static class ConsoleAdminEndpointExtensions
private static string? NormalizeOptional(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static string NormalizeIsolationMode(string? isolationMode)
{
var normalized = isolationMode?.Trim().ToLowerInvariant();
return normalized switch
{
null or "" => "shared",
"shared" => "shared",
"dedicated" => "dedicated",
_ => normalized!,
};
}
private static string UpdateTenantMetadata(string? existingMetadata, string? isolationMode)
{
var metadata = ParseMetadata(existingMetadata);
if (!string.IsNullOrWhiteSpace(isolationMode))
{
metadata["isolationMode"] = NormalizeIsolationMode(isolationMode);
}
else if (!metadata.ContainsKey("isolationMode"))
{
metadata["isolationMode"] = "shared";
}
return JsonSerializer.Serialize(metadata);
}
private static AdminTenantSummary ToAdminTenantSummary(
string tenantId,
AuthorityTenantView? configuredTenant,
TenantEntity? persistedTenant,
int userCount)
{
var metadata = ParseMetadata(persistedTenant?.Metadata);
var persistedIsolationMode = metadata.TryGetValue("isolationMode", out var rawIsolationMode)
? rawIsolationMode?.ToString()
: null;
var isolationMode = configuredTenant?.IsolationMode
?? persistedIsolationMode
?? "shared";
var normalizedStatus = configuredTenant?.Status;
if (string.IsNullOrWhiteSpace(normalizedStatus))
{
normalizedStatus = persistedTenant is null || persistedTenant.Enabled ? "active" : "disabled";
}
return new AdminTenantSummary(
Id: tenantId,
DisplayName: configuredTenant?.DisplayName
?? persistedTenant?.Name
?? tenantId,
Status: string.Equals(normalizedStatus, "active", StringComparison.OrdinalIgnoreCase) ? "active" : "disabled",
IsolationMode: NormalizeIsolationMode(isolationMode),
UserCount: userCount,
CreatedAt: persistedTenant?.CreatedAt ?? DateTimeOffset.UnixEpoch);
}
private static async Task<IReadOnlyList<string>> ResolveUserRolesAsync(
UserEntity user,
IRoleRepository roleRepository,
CancellationToken cancellationToken)
{
var resolvedRoles = new HashSet<string>(ReadRoles(ParseMetadata(user.Metadata)), StringComparer.OrdinalIgnoreCase);
var assignedRoles = await roleRepository.GetUserRolesAsync(user.TenantId, user.Id, cancellationToken).ConfigureAwait(false);
foreach (var role in assignedRoles)
{
var roleName = string.IsNullOrWhiteSpace(role.Name) ? role.DisplayName : role.Name;
if (!string.IsNullOrWhiteSpace(roleName))
{
resolvedRoles.Add(roleName.Trim());
}
}
return resolvedRoles
.OrderBy(static role => role, StringComparer.OrdinalIgnoreCase)
.ToArray();
}
private static async Task<IReadOnlyDictionary<string, int>> BuildRoleCountMapAsync(
string tenantId,
IReadOnlyList<UserEntity> users,
IRoleRepository roleRepository,
CancellationToken cancellationToken)
{
var roleCounts = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
foreach (var user in users.Where(user => string.Equals(user.TenantId, tenantId, StringComparison.OrdinalIgnoreCase)))
{
var roles = await ResolveUserRolesAsync(user, roleRepository, cancellationToken).ConfigureAwait(false);
foreach (var role in roles)
{
roleCounts[role] = roleCounts.TryGetValue(role, out var count) ? count + 1 : 1;
}
}
return roleCounts;
}
private static string ExtractPermissionResource(string scope)
{
var parts = scope.Split(':', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
return parts.Length == 0 ? "custom" : parts[0];
}
private static string ExtractPermissionAction(string scope)
{
var parts = scope.Split(':', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
return parts.Length <= 1 ? "read" : parts[^1];
}
private static AdminUserSummary ToAdminUserSummary(UserEntity user, DateTimeOffset now)
=> ToAdminUserSummary(user, now, ReadRoles(ParseMetadata(user.Metadata)));
private static AdminUserSummary ToAdminUserSummary(UserEntity user, DateTimeOffset now, IReadOnlyList<string> roles)
{
var metadata = ParseMetadata(user.Metadata);
var subjectId = metadata.TryGetValue("subjectId", out var parsedSubjectId)
@@ -1312,7 +1695,7 @@ internal static class ConsoleAdminEndpointExtensions
Username: user.Username,
Email: user.Email,
DisplayName: user.DisplayName ?? user.Username,
Roles: ReadRoles(metadata),
Roles: roles.Count == 0 ? ReadRoles(metadata) : roles,
Status: status,
CreatedAt: user.CreatedAt,
LastLoginAt: user.LastLoginAt);
@@ -1390,9 +1773,13 @@ internal static class ConsoleAdminEndpointExtensions
new RoleBundle("role/console-admin", "Console Admin", new[]
{
StellaOpsScopes.UiRead, StellaOpsScopes.UiAdmin,
StellaOpsScopes.AuthorityTenantsRead, StellaOpsScopes.AuthorityUsersRead,
StellaOpsScopes.AuthorityRolesRead, StellaOpsScopes.AuthorityClientsRead,
StellaOpsScopes.AuthorityTokensRead, StellaOpsScopes.AuthorityAuditRead
StellaOpsScopes.AuthorityTenantsRead, StellaOpsScopes.AuthorityTenantsWrite,
StellaOpsScopes.AuthorityUsersRead, StellaOpsScopes.AuthorityUsersWrite,
StellaOpsScopes.AuthorityRolesRead, StellaOpsScopes.AuthorityRolesWrite,
StellaOpsScopes.AuthorityClientsRead, StellaOpsScopes.AuthorityClientsWrite,
StellaOpsScopes.AuthorityTokensRead, StellaOpsScopes.AuthorityTokensRevoke,
StellaOpsScopes.AuthorityBrandingRead, StellaOpsScopes.AuthorityBrandingWrite,
StellaOpsScopes.AuthorityAuditRead
}),
new RoleBundle("role/scanner-viewer", "Scanner Viewer", new[] { StellaOpsScopes.ScannerRead }),
new RoleBundle("role/scanner-operator", "Scanner Operator", new[]
@@ -1457,6 +1844,20 @@ internal sealed record AdminUserSummary(
string Status,
DateTimeOffset CreatedAt,
DateTimeOffset? LastLoginAt);
internal sealed record AdminRoleSummary(
string Id,
string Name,
string Description,
IReadOnlyList<string> Permissions,
int UserCount,
bool IsBuiltIn);
internal sealed record AdminTenantSummary(
string Id,
string DisplayName,
string Status,
string IsolationMode,
int UserCount,
DateTimeOffset CreatedAt);
// ========== FILTERS ==========

View File

@@ -5,6 +5,8 @@ using Microsoft.AspNetCore.Mvc;
using OpenIddict.Abstractions;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Authority.Persistence.Postgres.Models;
using StellaOps.Authority.Persistence.Postgres.Repositories;
using StellaOps.Authority.Tenants;
using StellaOps.Cryptography.Audit;
using System;
@@ -16,11 +18,14 @@ using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace StellaOps.Authority.Console.Admin;
internal static class ConsoleBrandingEndpointExtensions
{
private const string BrandingSettingsKey = "consoleBranding";
public static void MapConsoleBrandingEndpoints(this WebApplication app)
{
ArgumentNullException.ThrowIfNull(app);
@@ -62,7 +67,7 @@ internal static class ConsoleBrandingEndpointExtensions
private static async Task<IResult> GetBranding(
HttpContext httpContext,
[FromServices] IAuthorityTenantCatalog tenantCatalog,
ITenantRepository tenantRepository,
IAuthEventSink auditSink,
TimeProvider timeProvider,
CancellationToken cancellationToken)
@@ -76,8 +81,7 @@ internal static class ConsoleBrandingEndpointExtensions
return Results.BadRequest(new { error = "tenant_required", message = "tenantId query parameter is required." });
}
// Placeholder: load from storage
var branding = GetDefaultBranding(tenantId);
var branding = await ResolveBrandingAsync(tenantId, tenantRepository, cancellationToken).ConfigureAwait(false);
try
{
@@ -105,7 +109,7 @@ internal static class ConsoleBrandingEndpointExtensions
private static async Task<IResult> GetBrandingAdmin(
HttpContext httpContext,
[FromServices] IAuthorityTenantCatalog tenantCatalog,
ITenantRepository tenantRepository,
IAuthEventSink auditSink,
TimeProvider timeProvider,
CancellationToken cancellationToken)
@@ -116,14 +120,11 @@ internal static class ConsoleBrandingEndpointExtensions
return Results.BadRequest(new { error = "tenant_header_missing", message = $"Header '{AuthorityHttpHeaders.Tenant}' is required." });
}
// Placeholder: load from storage with edit metadata
var branding = GetDefaultBranding(tenant);
var metadata = new BrandingMetadata(
var (branding, metadata) = await ResolveBrandingWithMetadataAsync(
tenant,
DateTimeOffset.UtcNow,
"system",
ComputeHash(branding)
);
tenantRepository,
timeProvider,
cancellationToken).ConfigureAwait(false);
await WriteAuditAsync(
httpContext,
@@ -141,7 +142,7 @@ internal static class ConsoleBrandingEndpointExtensions
private static async Task<IResult> UpdateBranding(
HttpContext httpContext,
UpdateBrandingRequest request,
[FromServices] IAuthorityTenantCatalog tenantCatalog,
ITenantRepository tenantRepository,
IAuthEventSink auditSink,
TimeProvider timeProvider,
CancellationToken cancellationToken)
@@ -177,6 +178,12 @@ internal static class ConsoleBrandingEndpointExtensions
// Sanitize theme tokens (whitelist allowed keys)
var sanitizedTokens = SanitizeThemeTokens(request.ThemeTokens);
var existingTenant = await tenantRepository.GetBySlugAsync(tenant, cancellationToken).ConfigureAwait(false);
if (existingTenant is null)
{
return Results.NotFound(new { error = "tenant_not_found", tenant });
}
var branding = new TenantBranding(
tenant,
request.DisplayName ?? tenant,
@@ -185,7 +192,36 @@ internal static class ConsoleBrandingEndpointExtensions
sanitizedTokens
);
// Placeholder: persist to storage
var now = timeProvider.GetUtcNow();
var updatedBy = httpContext.User.FindFirstValue(OpenIddictConstants.Claims.PreferredUsername)
?? httpContext.User.FindFirstValue(StellaOpsClaimTypes.Subject)
?? "system";
var metadata = new BrandingMetadata(
tenant,
now,
updatedBy,
ComputeHash(branding));
var updatedTenant = new TenantEntity
{
Id = existingTenant.Id,
Slug = existingTenant.Slug,
Name = existingTenant.Name,
Description = existingTenant.Description,
ContactEmail = existingTenant.ContactEmail,
Enabled = existingTenant.Enabled,
Settings = UpdateBrandingSettings(existingTenant.Settings, branding, metadata),
Metadata = existingTenant.Metadata,
CreatedAt = existingTenant.CreatedAt,
UpdatedAt = now,
CreatedBy = existingTenant.CreatedBy,
};
var updated = await tenantRepository.UpdateAsync(updatedTenant, cancellationToken).ConfigureAwait(false);
if (!updated)
{
return Results.NotFound(new { error = "tenant_not_found", tenant });
}
await WriteAuditAsync(
httpContext,
@@ -196,10 +232,10 @@ internal static class ConsoleBrandingEndpointExtensions
null,
BuildProperties(
("tenant.id", tenant),
("branding.hash", ComputeHash(branding))),
("branding.hash", metadata.Hash)),
cancellationToken).ConfigureAwait(false);
return Results.Ok(new { message = "Branding updated successfully", branding });
return Results.Ok(new { message = "Branding updated successfully", branding, metadata });
}
private static async Task<IResult> PreviewBranding(
@@ -244,6 +280,55 @@ internal static class ConsoleBrandingEndpointExtensions
// ========== HELPER METHODS ==========
private static async Task<TenantBranding> ResolveBrandingAsync(
string tenantId,
ITenantRepository tenantRepository,
CancellationToken cancellationToken)
{
var tenant = await tenantRepository.GetBySlugAsync(tenantId, cancellationToken).ConfigureAwait(false);
if (tenant is null)
{
return GetDefaultBranding(tenantId);
}
return ReadStoredBranding(tenant)?.Branding ?? GetDefaultBranding(tenantId);
}
private static async Task<(TenantBranding Branding, BrandingMetadata Metadata)> ResolveBrandingWithMetadataAsync(
string tenantId,
ITenantRepository tenantRepository,
TimeProvider timeProvider,
CancellationToken cancellationToken)
{
var tenant = await tenantRepository.GetBySlugAsync(tenantId, cancellationToken).ConfigureAwait(false);
if (tenant is not null)
{
var stored = ReadStoredBranding(tenant);
if (stored is not null)
{
return stored.Value;
}
var defaultBranding = GetDefaultBranding(tenantId);
return (
defaultBranding,
new BrandingMetadata(
tenantId,
tenant.UpdatedAt == default ? timeProvider.GetUtcNow() : tenant.UpdatedAt,
"system",
ComputeHash(defaultBranding)));
}
var fallbackBranding = GetDefaultBranding(tenantId);
return (
fallbackBranding,
new BrandingMetadata(
tenantId,
timeProvider.GetUtcNow(),
"system",
ComputeHash(fallbackBranding)));
}
private static TenantBranding GetDefaultBranding(string tenantId)
{
return new TenantBranding(
@@ -260,6 +345,82 @@ internal static class ConsoleBrandingEndpointExtensions
);
}
private static (TenantBranding Branding, BrandingMetadata Metadata)? ReadStoredBranding(TenantEntity tenant)
{
if (string.IsNullOrWhiteSpace(tenant.Settings))
{
return null;
}
try
{
var root = JsonNode.Parse(tenant.Settings) as JsonObject;
var node = root?[BrandingSettingsKey];
if (node is null)
{
return null;
}
var stored = node.Deserialize<StoredBrandingSettings>();
if (stored is null)
{
return null;
}
var branding = new TenantBranding(
tenant.Slug,
string.IsNullOrWhiteSpace(stored.DisplayName) ? "StellaOps" : stored.DisplayName.Trim(),
string.IsNullOrWhiteSpace(stored.LogoUri) ? null : stored.LogoUri,
string.IsNullOrWhiteSpace(stored.FaviconUri) ? null : stored.FaviconUri,
SanitizeThemeTokens(stored.ThemeTokens));
var metadata = new BrandingMetadata(
tenant.Slug,
ParseTimestamp(stored.UpdatedAtUtc) ?? tenant.UpdatedAt,
string.IsNullOrWhiteSpace(stored.UpdatedBy) ? "system" : stored.UpdatedBy.Trim(),
string.IsNullOrWhiteSpace(stored.Hash) ? ComputeHash(branding) : stored.Hash.Trim());
return (branding, metadata);
}
catch (JsonException)
{
return null;
}
}
private static string UpdateBrandingSettings(
string existingSettings,
TenantBranding branding,
BrandingMetadata metadata)
{
JsonObject root;
try
{
root = JsonNode.Parse(string.IsNullOrWhiteSpace(existingSettings) ? "{}" : existingSettings) as JsonObject
?? new JsonObject();
}
catch (JsonException)
{
root = new JsonObject();
}
root[BrandingSettingsKey] = JsonSerializer.SerializeToNode(new StoredBrandingSettings(
branding.DisplayName,
branding.LogoUri,
branding.FaviconUri,
new Dictionary<string, string>(branding.ThemeTokens, StringComparer.OrdinalIgnoreCase),
metadata.UpdatedAtUtc,
metadata.UpdatedBy,
metadata.Hash));
return root.ToJsonString();
}
private static DateTimeOffset? ParseTimestamp(DateTimeOffset? value)
{
return value;
}
private static IReadOnlyDictionary<string, string> SanitizeThemeTokens(IReadOnlyDictionary<string, string>? tokens)
{
if (tokens is null || tokens.Count == 0)
@@ -405,7 +566,17 @@ internal sealed record TenantBranding(
internal sealed record BrandingMetadata(
string TenantId,
DateTimeOffset UpdatedAt,
DateTimeOffset UpdatedAtUtc,
string UpdatedBy,
string Hash
);
internal sealed record StoredBrandingSettings(
string DisplayName,
string? LogoUri,
string? FaviconUri,
IReadOnlyDictionary<string, string>? ThemeTokens,
DateTimeOffset? UpdatedAtUtc,
string? UpdatedBy,
string? Hash
);

View File

@@ -82,8 +82,12 @@ VALUES
ARRAY['https://stella-ops.local/auth/callback', 'https://stella-ops.local/auth/silent-refresh', 'https://127.1.0.1/auth/callback', 'https://127.1.0.1/auth/silent-refresh'],
ARRAY['openid', 'profile', 'email', 'offline_access',
'ui.read', 'ui.admin',
'authority:tenants.read', 'authority:users.read', 'authority:roles.read',
'authority:clients.read', 'authority:tokens.read', 'authority:branding.read',
'authority:tenants.read', 'authority:tenants.write',
'authority:users.read', 'authority:users.write',
'authority:roles.read', 'authority:roles.write',
'authority:clients.read', 'authority:clients.write',
'authority:tokens.read', 'authority:tokens.revoke',
'authority:branding.read', 'authority:branding.write',
'authority.audit.read',
'graph:read', 'sbom:read', 'scanner:read',
'policy:read', 'policy:simulate', 'policy:author', 'policy:review', 'policy:approve',

View File

@@ -223,11 +223,29 @@ public static class PackAdapterEndpoints
.WithSummary("Pack v2 administration A5 policy governance projection.")
.RequireAuthorization(PlatformPolicies.SetupRead);
administration.MapGet("/trust-signing", (
administration.MapGet("/trust-signing", async (
HttpContext context,
PlatformRequestContextResolver resolver) =>
PlatformRequestContextResolver resolver,
IAdministrationTrustSigningStore trustSigningStore,
CancellationToken cancellationToken) =>
{
return BuildAdministrationItem(context, resolver, BuildAdministrationTrustSigning);
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
{
return failure!;
}
var payload = await BuildAdministrationTrustSigningAsync(
requestContext!.TenantId,
trustSigningStore,
cancellationToken).ConfigureAwait(false);
return Results.Ok(new PlatformItemResponse<AdministrationTrustSigningDto>(
requestContext.TenantId,
requestContext.ActorId,
SnapshotAt,
Cached: false,
CacheTtlSeconds: 0,
payload));
})
.WithName("GetAdministrationTrustSigning")
.WithSummary("Pack v2 administration A6 trust and signing projection.")
@@ -540,14 +558,41 @@ public static class PackAdapterEndpoints
]);
}
private static AdministrationTrustSigningDto BuildAdministrationTrustSigning()
private static async Task<AdministrationTrustSigningDto> BuildAdministrationTrustSigningAsync(
string tenantId,
IAdministrationTrustSigningStore trustSigningStore,
CancellationToken cancellationToken)
{
var keys = await trustSigningStore.ListKeysAsync(tenantId, 500, 0, cancellationToken).ConfigureAwait(false);
var issuers = await trustSigningStore.ListIssuersAsync(tenantId, 500, 0, cancellationToken).ConfigureAwait(false);
var certificates = await trustSigningStore.ListCertificatesAsync(tenantId, 500, 0, cancellationToken).ConfigureAwait(false);
var transparencyConfig = await trustSigningStore.GetTransparencyLogConfigAsync(tenantId, cancellationToken).ConfigureAwait(false);
var expiringCertificateCount = certificates.Count(certificate =>
!string.Equals(certificate.Status, "revoked", StringComparison.OrdinalIgnoreCase)
&& certificate.NotAfter <= SnapshotAt.AddDays(10));
var signals = new[]
{
new AdministrationTrustSignalDto("audit-log", "healthy", "Audit log ingestion is current."),
new AdministrationTrustSignalDto("certificate-expiry", "warning", "1 certificate expires within 10 days."),
new AdministrationTrustSignalDto("transparency-log", "healthy", "Rekor witness is reachable."),
new AdministrationTrustSignalDto("trust-scoring", "healthy", "Issuer trust score recalculation completed."),
new AdministrationTrustSignalDto("audit-log", "healthy", "Trust-signing configuration changes are being recorded."),
new AdministrationTrustSignalDto(
"certificate-expiry",
expiringCertificateCount > 0 ? "warning" : "healthy",
expiringCertificateCount > 0
? $"{expiringCertificateCount} certificate{(expiringCertificateCount == 1 ? string.Empty : "s")} expire within 10 days."
: "No certificate expirations are due in the next 10 days."),
new AdministrationTrustSignalDto(
"transparency-log",
transparencyConfig is null ? "warning" : "healthy",
transparencyConfig is null
? "Transparency log is not configured for this tenant."
: $"Transparency log witness points to {transparencyConfig.LogUrl}."),
new AdministrationTrustSignalDto(
"trust-scoring",
issuers.Count == 0 ? "warning" : "healthy",
issuers.Count == 0
? "No trusted issuers are registered yet."
: "Issuer trust inventory is available for scoring and review."),
}.OrderBy(signal => signal.SignalId, StringComparer.Ordinal).ToList();
var aliases = BuildAdministrationAliases(
@@ -558,7 +603,7 @@ public static class PackAdapterEndpoints
]);
return new AdministrationTrustSigningDto(
Inventory: new AdministrationTrustInventoryDto(Keys: 14, Issuers: 7, Certificates: 23),
Inventory: new AdministrationTrustInventoryDto(Keys: keys.Count, Issuers: issuers.Count, Certificates: certificates.Count),
Signals: signals,
LegacyAliases: aliases,
EvidenceConsumerPath: "/evidence-audit/proofs");

View File

@@ -32,7 +32,7 @@ public sealed class PostgresAdministrationTrustSigningStore : IAdministrationTru
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var tenantGuid = ParseTenantId(tenantId);
var tenantGuid = TenantStorageKey.ParseTenantGuid(tenantId);
var normalizedLimit = NormalizeLimit(limit);
var normalizedOffset = NormalizeOffset(offset);
@@ -83,7 +83,7 @@ public sealed class PostgresAdministrationTrustSigningStore : IAdministrationTru
var actor = NormalizeActor(actorId);
var now = _timeProvider.GetUtcNow();
var keyId = Guid.NewGuid();
var tenantGuid = ParseTenantId(tenantId);
var tenantGuid = TenantStorageKey.ParseTenantGuid(tenantId);
await using var connection = await OpenTenantConnectionAsync(tenantGuid, cancellationToken).ConfigureAwait(false);
try
@@ -164,7 +164,7 @@ public sealed class PostgresAdministrationTrustSigningStore : IAdministrationTru
var actor = NormalizeActor(actorId);
var now = _timeProvider.GetUtcNow();
var tenantGuid = ParseTenantId(tenantId);
var tenantGuid = TenantStorageKey.ParseTenantGuid(tenantId);
await using var connection = await OpenTenantConnectionAsync(tenantGuid, cancellationToken).ConfigureAwait(false);
var existingStatus = await GetKeyStatusAsync(connection, tenantGuid, keyId, cancellationToken).ConfigureAwait(false);
@@ -226,7 +226,7 @@ public sealed class PostgresAdministrationTrustSigningStore : IAdministrationTru
_ = NormalizeRequired(request.Reason, "reason_required");
var actor = NormalizeActor(actorId);
var now = _timeProvider.GetUtcNow();
var tenantGuid = ParseTenantId(tenantId);
var tenantGuid = TenantStorageKey.ParseTenantGuid(tenantId);
await using var connection = await OpenTenantConnectionAsync(tenantGuid, cancellationToken).ConfigureAwait(false);
await using var command = new NpgsqlCommand(
@@ -270,7 +270,7 @@ public sealed class PostgresAdministrationTrustSigningStore : IAdministrationTru
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var tenantGuid = ParseTenantId(tenantId);
var tenantGuid = TenantStorageKey.ParseTenantGuid(tenantId);
var normalizedLimit = NormalizeLimit(limit);
var normalizedOffset = NormalizeOffset(offset);
@@ -323,7 +323,7 @@ public sealed class PostgresAdministrationTrustSigningStore : IAdministrationTru
var actor = NormalizeActor(actorId);
var now = _timeProvider.GetUtcNow();
var issuerId = Guid.NewGuid();
var tenantGuid = ParseTenantId(tenantId);
var tenantGuid = TenantStorageKey.ParseTenantGuid(tenantId);
await using var connection = await OpenTenantConnectionAsync(tenantGuid, cancellationToken).ConfigureAwait(false);
try
@@ -397,7 +397,7 @@ public sealed class PostgresAdministrationTrustSigningStore : IAdministrationTru
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var tenantGuid = ParseTenantId(tenantId);
var tenantGuid = TenantStorageKey.ParseTenantGuid(tenantId);
var normalizedLimit = NormalizeLimit(limit);
var normalizedOffset = NormalizeOffset(offset);
@@ -454,7 +454,7 @@ public sealed class PostgresAdministrationTrustSigningStore : IAdministrationTru
var actor = NormalizeActor(actorId);
var now = _timeProvider.GetUtcNow();
var certificateId = Guid.NewGuid();
var tenantGuid = ParseTenantId(tenantId);
var tenantGuid = TenantStorageKey.ParseTenantGuid(tenantId);
await using var connection = await OpenTenantConnectionAsync(tenantGuid, cancellationToken).ConfigureAwait(false);
@@ -571,7 +571,7 @@ public sealed class PostgresAdministrationTrustSigningStore : IAdministrationTru
_ = NormalizeRequired(request.Reason, "reason_required");
var actor = NormalizeActor(actorId);
var now = _timeProvider.GetUtcNow();
var tenantGuid = ParseTenantId(tenantId);
var tenantGuid = TenantStorageKey.ParseTenantGuid(tenantId);
await using var connection = await OpenTenantConnectionAsync(tenantGuid, cancellationToken).ConfigureAwait(false);
await using var command = new NpgsqlCommand(
@@ -615,7 +615,7 @@ public sealed class PostgresAdministrationTrustSigningStore : IAdministrationTru
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var tenantGuid = ParseTenantId(tenantId);
var tenantGuid = TenantStorageKey.ParseTenantGuid(tenantId);
await using var connection = await OpenTenantConnectionAsync(tenantGuid, cancellationToken).ConfigureAwait(false);
await using var command = new NpgsqlCommand(
@@ -660,7 +660,7 @@ public sealed class PostgresAdministrationTrustSigningStore : IAdministrationTru
var actor = NormalizeActor(actorId);
var now = _timeProvider.GetUtcNow();
var tenantGuid = ParseTenantId(tenantId);
var tenantGuid = TenantStorageKey.ParseTenantGuid(tenantId);
await using var connection = await OpenTenantConnectionAsync(tenantGuid, cancellationToken).ConfigureAwait(false);
await using var command = new NpgsqlCommand(
@@ -762,21 +762,6 @@ public sealed class PostgresAdministrationTrustSigningStore : IAdministrationTru
return connection;
}
private static Guid ParseTenantId(string tenantId)
{
if (string.IsNullOrWhiteSpace(tenantId))
{
throw new InvalidOperationException("tenant_required");
}
if (!Guid.TryParse(tenantId, out var tenantGuid))
{
throw new InvalidOperationException("tenant_id_invalid");
}
return tenantGuid;
}
private static int NormalizeLimit(int limit) => limit < 1 ? 1 : limit;
private static int NormalizeOffset(int offset) => offset < 0 ? 0 : offset;

View File

@@ -70,7 +70,7 @@ public sealed class PostgresReleaseControlBundleStore : IReleaseControlBundleSto
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var tenantGuid = ParseTenantId(tenantId);
var tenantGuid = TenantStorageKey.ParseTenantGuid(tenantId);
await using var connection = await OpenTenantConnectionAsync(tenantGuid, cancellationToken).ConfigureAwait(false);
await using var command = new NpgsqlCommand(
@@ -137,7 +137,7 @@ public sealed class PostgresReleaseControlBundleStore : IReleaseControlBundleSto
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var tenantGuid = ParseTenantId(tenantId);
var tenantGuid = TenantStorageKey.ParseTenantGuid(tenantId);
await using var connection = await OpenTenantConnectionAsync(tenantGuid, cancellationToken).ConfigureAwait(false);
await using var command = new NpgsqlCommand(
@@ -220,7 +220,7 @@ public sealed class PostgresReleaseControlBundleStore : IReleaseControlBundleSto
throw new InvalidOperationException("bundle_name_required");
}
var tenantGuid = ParseTenantId(tenantId);
var tenantGuid = TenantStorageKey.ParseTenantGuid(tenantId);
var now = _timeProvider.GetUtcNow();
var bundleId = Guid.NewGuid();
var createdBy = NormalizeActor(actorId);
@@ -273,7 +273,7 @@ public sealed class PostgresReleaseControlBundleStore : IReleaseControlBundleSto
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var tenantGuid = ParseTenantId(tenantId);
var tenantGuid = TenantStorageKey.ParseTenantGuid(tenantId);
await using var connection = await OpenTenantConnectionAsync(tenantGuid, cancellationToken).ConfigureAwait(false);
@@ -325,7 +325,7 @@ public sealed class PostgresReleaseControlBundleStore : IReleaseControlBundleSto
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var tenantGuid = ParseTenantId(tenantId);
var tenantGuid = TenantStorageKey.ParseTenantGuid(tenantId);
await using var connection = await OpenTenantConnectionAsync(tenantGuid, cancellationToken).ConfigureAwait(false);
@@ -395,7 +395,7 @@ public sealed class PostgresReleaseControlBundleStore : IReleaseControlBundleSto
throw new InvalidOperationException("request_required");
}
var tenantGuid = ParseTenantId(tenantId);
var tenantGuid = TenantStorageKey.ParseTenantGuid(tenantId);
var createdBy = NormalizeActor(actorId);
var now = _timeProvider.GetUtcNow();
var normalizedComponents = ReleaseControlBundleDigest.NormalizeComponents(request.Components);
@@ -544,7 +544,7 @@ public sealed class PostgresReleaseControlBundleStore : IReleaseControlBundleSto
throw new InvalidOperationException("request_required");
}
var tenantGuid = ParseTenantId(tenantId);
var tenantGuid = TenantStorageKey.ParseTenantGuid(tenantId);
var requestedBy = NormalizeActor(actorId);
var now = _timeProvider.GetUtcNow();
var normalizedIdempotencyKey = NormalizeIdempotencyKey(request.IdempotencyKey);
@@ -656,7 +656,7 @@ public sealed class PostgresReleaseControlBundleStore : IReleaseControlBundleSto
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var tenantGuid = ParseTenantId(tenantId);
var tenantGuid = TenantStorageKey.ParseTenantGuid(tenantId);
await using var connection = await OpenTenantConnectionAsync(tenantGuid, cancellationToken).ConfigureAwait(false);
await using var command = new NpgsqlCommand(ListMaterializationRunsSql, connection);
@@ -682,7 +682,7 @@ public sealed class PostgresReleaseControlBundleStore : IReleaseControlBundleSto
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var tenantGuid = ParseTenantId(tenantId);
var tenantGuid = TenantStorageKey.ParseTenantGuid(tenantId);
await using var connection = await OpenTenantConnectionAsync(tenantGuid, cancellationToken).ConfigureAwait(false);
await using var command = new NpgsqlCommand(ListMaterializationRunsByBundleSql, connection);
@@ -707,7 +707,7 @@ public sealed class PostgresReleaseControlBundleStore : IReleaseControlBundleSto
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var tenantGuid = ParseTenantId(tenantId);
var tenantGuid = TenantStorageKey.ParseTenantGuid(tenantId);
await using var connection = await OpenTenantConnectionAsync(tenantGuid, cancellationToken).ConfigureAwait(false);
await using var command = new NpgsqlCommand(
@@ -772,24 +772,6 @@ public sealed class PostgresReleaseControlBundleStore : IReleaseControlBundleSto
UpdatedAt: reader.GetFieldValue<DateTimeOffset>(9));
}
private static Guid ParseTenantId(string tenantId)
{
if (string.IsNullOrWhiteSpace(tenantId))
{
throw new InvalidOperationException("tenant_required");
}
if (Guid.TryParse(tenantId, out var tenantGuid))
{
return tenantGuid;
}
// Derive deterministic GUID from string tenant identifier (e.g. "default", "demo-prod")
var hash = System.Security.Cryptography.SHA256.HashData(
System.Text.Encoding.UTF8.GetBytes(tenantId));
return new Guid(hash.AsSpan(0, 16));
}
private async Task<NpgsqlConnection> OpenTenantConnectionAsync(Guid tenantId, CancellationToken cancellationToken)
{
var connection = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);

View File

@@ -0,0 +1,24 @@
using System.Security.Cryptography;
using System.Text;
namespace StellaOps.Platform.WebService.Services;
internal static class TenantStorageKey
{
public static Guid ParseTenantGuid(string tenantId)
{
if (string.IsNullOrWhiteSpace(tenantId))
{
throw new InvalidOperationException("tenant_required");
}
var normalizedTenantId = tenantId.Trim();
if (Guid.TryParse(normalizedTenantId, out var tenantGuid))
{
return tenantGuid;
}
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(normalizedTenantId));
return new Guid(hash.AsSpan(0, 16));
}
}

View File

@@ -43,6 +43,10 @@
<EmbeddedResource Include="Translations\*.json" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="StellaOps.Platform.WebService.Tests" />
</ItemGroup>
<PropertyGroup Label="StellaOpsReleaseVersion">
<Version>1.0.0-alpha1</Version>
<InformationalVersion>1.0.0-alpha1</InformationalVersion>

View File

@@ -0,0 +1,242 @@
-- Scratch-install trust-signing seed for canonical demo tenants.
-- Tenant GUIDs are derived with the same deterministic SHA-256 -> Guid mapping used in Platform stores.
INSERT INTO release.trust_keys (
id,
tenant_id,
key_alias,
algorithm,
status,
current_version,
metadata_json,
created_at,
updated_at,
created_by,
updated_by
)
SELECT
'a9adf36f-2f4d-4f31-9b0b-138e3f6f1f61'::uuid,
'3a5e72b6-ae6a-f8a4-2b6a-df2960d63016'::uuid,
'demo-prod-core-signing',
'ed25519',
'active',
3,
'{"owner":"secops","region":"us-east"}'::jsonb,
'2026-03-01T00:00:00Z'::timestamptz,
'2026-03-09T00:00:00Z'::timestamptz,
'system',
'system'
WHERE NOT EXISTS (
SELECT 1
FROM release.trust_keys
WHERE tenant_id = '3a5e72b6-ae6a-f8a4-2b6a-df2960d63016'::uuid
AND lower(key_alias) = lower('demo-prod-core-signing')
);
INSERT INTO release.trust_issuers (
id,
tenant_id,
issuer_name,
issuer_uri,
trust_level,
status,
created_at,
updated_at,
created_by,
updated_by
)
SELECT
'4ac7e1d4-7a2e-4b4d-9e12-5d42e3168a91'::uuid,
'3a5e72b6-ae6a-f8a4-2b6a-df2960d63016'::uuid,
'Demo Prod Root CA',
'https://issuer.demo-prod.stella-ops.local/root',
'high',
'active',
'2026-03-01T00:00:00Z'::timestamptz,
'2026-03-09T00:00:00Z'::timestamptz,
'system',
'system'
WHERE NOT EXISTS (
SELECT 1
FROM release.trust_issuers
WHERE tenant_id = '3a5e72b6-ae6a-f8a4-2b6a-df2960d63016'::uuid
AND lower(issuer_uri) = lower('https://issuer.demo-prod.stella-ops.local/root')
);
INSERT INTO release.trust_certificates (
id,
tenant_id,
key_id,
issuer_id,
serial_number,
status,
not_before,
not_after,
created_at,
updated_at,
created_by,
updated_by
)
SELECT
'8d1f0b75-3d56-4d8f-a40b-52f5e52e7fb1'::uuid,
'3a5e72b6-ae6a-f8a4-2b6a-df2960d63016'::uuid,
'a9adf36f-2f4d-4f31-9b0b-138e3f6f1f61'::uuid,
'4ac7e1d4-7a2e-4b4d-9e12-5d42e3168a91'::uuid,
'DEMO-PROD-SER-0001',
'active',
'2026-03-01T00:00:00Z'::timestamptz,
'2026-03-18T00:00:00Z'::timestamptz,
'2026-03-01T00:00:00Z'::timestamptz,
'2026-03-09T00:00:00Z'::timestamptz,
'system',
'system'
WHERE NOT EXISTS (
SELECT 1
FROM release.trust_certificates
WHERE tenant_id = '3a5e72b6-ae6a-f8a4-2b6a-df2960d63016'::uuid
AND lower(serial_number) = lower('DEMO-PROD-SER-0001')
);
INSERT INTO release.trust_transparency_configs (
tenant_id,
log_url,
witness_url,
enforce_inclusion,
updated_at,
updated_by
)
VALUES (
'3a5e72b6-ae6a-f8a4-2b6a-df2960d63016'::uuid,
'https://rekor.demo-prod.stella-ops.local',
'https://rekor-witness.demo-prod.stella-ops.local',
true,
'2026-03-09T00:00:00Z'::timestamptz,
'system'
)
ON CONFLICT (tenant_id) DO UPDATE
SET
log_url = EXCLUDED.log_url,
witness_url = EXCLUDED.witness_url,
enforce_inclusion = EXCLUDED.enforce_inclusion,
updated_at = EXCLUDED.updated_at,
updated_by = EXCLUDED.updated_by;
INSERT INTO release.trust_keys (
id,
tenant_id,
key_alias,
algorithm,
status,
current_version,
metadata_json,
created_at,
updated_at,
created_by,
updated_by
)
SELECT
'5fdf7b2d-9d32-4b69-874f-9b22a4d22f21'::uuid,
'c1eea837-19ce-7d68-132f-e29051dca629'::uuid,
'default-core-signing',
'ed25519',
'active',
1,
'{"owner":"bootstrap","region":"global"}'::jsonb,
'2026-03-01T00:00:00Z'::timestamptz,
'2026-03-01T00:00:00Z'::timestamptz,
'system',
'system'
WHERE NOT EXISTS (
SELECT 1
FROM release.trust_keys
WHERE tenant_id = 'c1eea837-19ce-7d68-132f-e29051dca629'::uuid
AND lower(key_alias) = lower('default-core-signing')
);
INSERT INTO release.trust_issuers (
id,
tenant_id,
issuer_name,
issuer_uri,
trust_level,
status,
created_at,
updated_at,
created_by,
updated_by
)
SELECT
'f7d0505e-4a94-4688-a046-87f7a9c7cf76'::uuid,
'c1eea837-19ce-7d68-132f-e29051dca629'::uuid,
'Default Root CA',
'https://issuer.default.stella-ops.local/root',
'high',
'active',
'2026-03-01T00:00:00Z'::timestamptz,
'2026-03-01T00:00:00Z'::timestamptz,
'system',
'system'
WHERE NOT EXISTS (
SELECT 1
FROM release.trust_issuers
WHERE tenant_id = 'c1eea837-19ce-7d68-132f-e29051dca629'::uuid
AND lower(issuer_uri) = lower('https://issuer.default.stella-ops.local/root')
);
INSERT INTO release.trust_certificates (
id,
tenant_id,
key_id,
issuer_id,
serial_number,
status,
not_before,
not_after,
created_at,
updated_at,
created_by,
updated_by
)
SELECT
'c0d9c7db-a0c8-41e9-a7f4-e8f03e4b31e3'::uuid,
'c1eea837-19ce-7d68-132f-e29051dca629'::uuid,
'5fdf7b2d-9d32-4b69-874f-9b22a4d22f21'::uuid,
'f7d0505e-4a94-4688-a046-87f7a9c7cf76'::uuid,
'DEFAULT-SER-0001',
'active',
'2026-03-01T00:00:00Z'::timestamptz,
'2026-09-01T00:00:00Z'::timestamptz,
'2026-03-01T00:00:00Z'::timestamptz,
'2026-03-01T00:00:00Z'::timestamptz,
'system',
'system'
WHERE NOT EXISTS (
SELECT 1
FROM release.trust_certificates
WHERE tenant_id = 'c1eea837-19ce-7d68-132f-e29051dca629'::uuid
AND lower(serial_number) = lower('DEFAULT-SER-0001')
);
INSERT INTO release.trust_transparency_configs (
tenant_id,
log_url,
witness_url,
enforce_inclusion,
updated_at,
updated_by
)
VALUES (
'c1eea837-19ce-7d68-132f-e29051dca629'::uuid,
'https://rekor.default.stella-ops.local',
'https://rekor-witness.default.stella-ops.local',
true,
'2026-03-01T00:00:00Z'::timestamptz,
'system'
)
ON CONFLICT (tenant_id) DO UPDATE
SET
log_url = EXCLUDED.log_url,
witness_url = EXCLUDED.witness_url,
enforce_inclusion = EXCLUDED.enforce_inclusion,
updated_at = EXCLUDED.updated_at,
updated_by = EXCLUDED.updated_by;

View File

@@ -1,8 +1,10 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Platform.WebService.Contracts;
using System.Linq;
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using StellaOps.Platform.WebService.Constants;
using StellaOps.TestKit;
@@ -146,6 +148,75 @@ public sealed class PackAdapterEndpointsTests : IClassFixture<PlatformWebApplica
Assert.DoesNotContain(PlatformPolicies.SetupRead, policies);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task TrustSigningOverview_Uses_live_inventory_counts_for_selected_tenant()
{
using var client = CreateTenantClient("demo-prod");
var keyResponse = await client.PostAsJsonAsync(
"/api/v1/administration/trust-signing/keys",
new CreateAdministrationTrustKeyRequest(
Alias: "tenant-live-key",
Algorithm: "ed25519",
MetadataJson: "{\"owner\":\"secops\"}"),
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Created, keyResponse.StatusCode);
var key = await keyResponse.Content.ReadFromJsonAsync<AdministrationTrustKeySummary>(TestContext.Current.CancellationToken);
Assert.NotNull(key);
var issuerResponse = await client.PostAsJsonAsync(
"/api/v1/administration/trust-signing/issuers",
new RegisterAdministrationTrustIssuerRequest(
Name: "Tenant Live Root CA",
IssuerUri: "https://issuer.demo-prod.stella-ops.local/live",
TrustLevel: "high"),
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Created, issuerResponse.StatusCode);
var issuer = await issuerResponse.Content.ReadFromJsonAsync<AdministrationTrustIssuerSummary>(TestContext.Current.CancellationToken);
Assert.NotNull(issuer);
var certificateResponse = await client.PostAsJsonAsync(
"/api/v1/administration/trust-signing/certificates",
new RegisterAdministrationTrustCertificateRequest(
KeyId: key!.KeyId,
IssuerId: issuer!.IssuerId,
SerialNumber: "TENANT-LIVE-SER-0001",
NotBefore: DateTimeOffset.Parse("2026-02-01T00:00:00Z"),
NotAfter: DateTimeOffset.Parse("2026-02-25T00:00:00Z")),
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Created, certificateResponse.StatusCode);
var configureResponse = await client.PutAsJsonAsync(
"/api/v1/administration/trust-signing/transparency-log",
new ConfigureAdministrationTransparencyLogRequest(
LogUrl: "https://rekor.demo-prod.stella-ops.local",
WitnessUrl: "https://rekor-witness.demo-prod.stella-ops.local",
EnforceInclusion: true),
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, configureResponse.StatusCode);
var overviewResponse = await client.GetAsync("/api/v1/administration/trust-signing", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, overviewResponse.StatusCode);
using var document = JsonDocument.Parse(await overviewResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
var item = document.RootElement.GetProperty("item");
Assert.Equal(1, item.GetProperty("inventory").GetProperty("keys").GetInt32());
Assert.Equal(1, item.GetProperty("inventory").GetProperty("issuers").GetInt32());
Assert.Equal(1, item.GetProperty("inventory").GetProperty("certificates").GetInt32());
var signals = item
.GetProperty("signals")
.EnumerateArray()
.ToDictionary(
signal => signal.GetProperty("signalId").GetString()!,
signal => signal.GetProperty("status").GetString()!,
StringComparer.Ordinal);
Assert.Equal("warning", signals["certificate-expiry"]);
Assert.Equal("healthy", signals["transparency-log"]);
}
private HttpClient CreateTenantClient(string tenantId)
{
var client = _factory.CreateClient();

View File

@@ -0,0 +1,26 @@
using FluentAssertions;
using StellaOps.Platform.WebService.Services;
using StellaOps.TestKit;
namespace StellaOps.Platform.WebService.Tests;
public sealed class TenantStorageKeyTests
{
[Trait("Category", TestCategories.Unit)]
[Theory]
[InlineData("demo-prod", "3a5e72b6-ae6a-f8a4-2b6a-df2960d63016")]
[InlineData("default", "c1eea837-19ce-7d68-132f-e29051dca629")]
public void ParseTenantGuid_derives_deterministic_guid_for_slug_tenants(string tenantId, string expectedGuid)
{
TenantStorageKey.ParseTenantGuid(tenantId).Should().Be(Guid.Parse(expectedGuid));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void ParseTenantGuid_returns_existing_guid_without_rehashing()
{
var tenantGuid = Guid.NewGuid();
TenantStorageKey.ParseTenantGuid(tenantGuid.ToString("D")).Should().Be(tenantGuid);
}
}

View File

@@ -26,6 +26,7 @@ public sealed class RouteDispatchMiddleware
// ReverseProxy paths that are legitimate browser navigation targets (e.g. OIDC flows)
// and must NOT be redirected to the SPA fallback.
private static readonly string[] BrowserProxyPaths = ["/connect", "/.well-known"];
private static readonly string[] SpaRoutesWithDocumentExtensions = ["/docs", "/docs/"];
public RouteDispatchMiddleware(
RequestDelegate next,
@@ -134,7 +135,7 @@ public sealed class RouteDispatchMiddleware
var spaFallback = route.Headers.TryGetValue("x-spa-fallback", out var spaValue) &&
string.Equals(spaValue, "true", StringComparison.OrdinalIgnoreCase);
if (spaFallback && !System.IO.Path.HasExtension(relativePath))
if (spaFallback && ShouldServeSpaFallback(relativePath))
{
var indexFile = fileProvider.GetFileInfo("/index.html");
if (indexFile.Exists && !indexFile.IsDirectory)
@@ -646,4 +647,22 @@ public sealed class RouteDispatchMiddleware
var accept = request.Headers.Accept.ToString();
return accept.Contains("text/html", StringComparison.OrdinalIgnoreCase);
}
private static bool ShouldServeSpaFallback(string relativePath)
{
if (!System.IO.Path.HasExtension(relativePath))
{
return true;
}
foreach (var prefix in SpaRoutesWithDocumentExtensions)
{
if (relativePath.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
}

View File

@@ -259,6 +259,59 @@ public sealed class RouteDispatchMiddlewareMicroserviceTests
}
}
[Fact]
public async Task InvokeAsync_StaticFilesRoute_DocsMarkdownPath_ServesSpaFallbackIndex()
{
var tempDir = Path.Combine(Path.GetTempPath(), $"stella-router-docs-spa-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempDir);
try
{
await File.WriteAllTextAsync(
Path.Combine(tempDir, "index.html"),
"<!DOCTYPE html><html><body><h1>SPA Root</h1></body></html>");
var resolver = new StellaOpsRouteResolver(
[
new StellaOpsRoute
{
Type = StellaOpsRouteType.StaticFiles,
Path = "/",
TranslatesTo = tempDir,
Headers = new Dictionary<string, string> { ["x-spa-fallback"] = "true" }
}
]);
var httpClientFactory = new Mock<IHttpClientFactory>();
httpClientFactory.Setup(factory => factory.CreateClient(It.IsAny<string>())).Returns(new HttpClient());
var middleware = new RouteDispatchMiddleware(
_ => Task.CompletedTask,
resolver,
httpClientFactory.Object,
NullLogger<RouteDispatchMiddleware>.Instance);
var context = new DefaultHttpContext();
context.Request.Method = HttpMethods.Get;
context.Request.Path = "/docs/modules/platform/architecture-overview.md";
context.Response.Body = new MemoryStream();
await middleware.InvokeAsync(context);
Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode);
context.Response.Body.Position = 0;
var body = await new StreamReader(context.Response.Body).ReadToEndAsync();
Assert.Contains("SPA Root", body, StringComparison.Ordinal);
}
finally
{
if (Directory.Exists(tempDir))
{
Directory.Delete(tempDir, recursive: true);
}
}
}
[Fact]
public async Task InvokeAsync_RegexCatchAll_CaptureGroupSubstitution_ResolvesServiceAndPath()
{

View File

@@ -30,15 +30,20 @@
"src/styles"
]
},
"assets": [
"src/favicon.ico",
"src/assets",
"src/manifest.webmanifest",
{
"glob": "config.json",
"input": "src/config",
"output": "."
}
"assets": [
"src/favicon.ico",
"src/assets",
"src/manifest.webmanifest",
{
"glob": "**/*",
"input": "../../../docs",
"output": "docs-content"
},
{
"glob": "config.json",
"input": "src/config",
"output": "."
}
],
"styles": [
"src/styles.scss"

View File

@@ -0,0 +1,252 @@
#!/usr/bin/env node
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const webRoot = path.resolve(__dirname, '..');
const outputDir = path.join(webRoot, 'output', 'playwright');
const resultPath = path.join(outputDir, 'live-full-core-audit.json');
const suites = [
{
name: 'frontdoor-canonical-route-sweep',
script: 'live-frontdoor-canonical-route-sweep.mjs',
reportPath: path.join(outputDir, 'live-frontdoor-canonical-route-sweep.json'),
},
{
name: 'mission-control-action-sweep',
script: 'live-mission-control-action-sweep.mjs',
reportPath: path.join(outputDir, 'live-mission-control-action-sweep.json'),
},
{
name: 'ops-policy-action-sweep',
script: 'live-ops-policy-action-sweep.mjs',
reportPath: path.join(outputDir, 'live-ops-policy-action-sweep.json'),
},
{
name: 'integrations-action-sweep',
script: 'live-integrations-action-sweep.mjs',
reportPath: path.join(outputDir, 'live-integrations-action-sweep.json'),
},
{
name: 'setup-topology-action-sweep',
script: 'live-setup-topology-action-sweep.mjs',
reportPath: path.join(outputDir, 'live-setup-topology-action-sweep.json'),
},
{
name: 'setup-admin-action-sweep',
script: 'live-setup-admin-action-sweep.mjs',
reportPath: path.join(outputDir, 'live-setup-admin-action-sweep.json'),
},
{
name: 'user-reported-admin-trust-check',
script: 'live-user-reported-admin-trust-check.mjs',
reportPath: path.join(outputDir, 'live-user-reported-admin-trust-check.json'),
},
{
name: 'jobs-queues-action-sweep',
script: 'live-jobs-queues-action-sweep.mjs',
reportPath: path.join(outputDir, 'live-jobs-queues-action-sweep.json'),
},
{
name: 'triage-artifacts-scope-compat',
script: 'live-triage-artifacts-scope-compat.mjs',
reportPath: path.join(outputDir, 'live-triage-artifacts-scope-compat.json'),
},
{
name: 'releases-deployments-check',
script: 'live-releases-deployments-check.mjs',
reportPath: path.join(outputDir, 'live-releases-deployments-check.json'),
},
{
name: 'release-promotion-submit-check',
script: 'live-release-promotion-submit-check.mjs',
reportPath: path.join(outputDir, 'live-release-promotion-submit-check.json'),
},
{
name: 'hotfix-action-check',
script: 'live-hotfix-action-check.mjs',
reportPath: path.join(outputDir, 'live-hotfix-action-check.json'),
},
{
name: 'registry-admin-audit-check',
script: 'live-registry-admin-audit-check.mjs',
reportPath: path.join(outputDir, 'live-registry-admin-audit-check.json'),
},
{
name: 'evidence-export-action-sweep',
script: 'live-evidence-export-action-sweep.mjs',
reportPath: path.join(outputDir, 'live-evidence-export-action-sweep.json'),
},
{
name: 'watchlist-action-sweep',
script: 'live-watchlist-action-sweep.mjs',
reportPath: path.join(outputDir, 'live-watchlist-action-sweep.json'),
},
{
name: 'notifications-watchlist-recheck',
script: 'live-notifications-watchlist-recheck.mjs',
reportPath: path.join(outputDir, 'live-notifications-watchlist-recheck.json'),
},
{
name: 'policy-simulation-direct-routes',
script: 'live-policy-simulation-direct-routes.mjs',
reportPath: path.join(outputDir, 'live-policy-simulation-direct-routes.json'),
},
{
name: 'uncovered-surface-action-sweep',
script: 'live-uncovered-surface-action-sweep.mjs',
reportPath: path.join(outputDir, 'live-uncovered-surface-action-sweep.json'),
},
{
name: 'unified-search-route-matrix',
script: 'live-frontdoor-unified-search-route-matrix.mjs',
reportPath: path.join(outputDir, 'live-frontdoor-unified-search-route-matrix.json'),
},
];
const failureCountKeys = new Set([
'failedRouteCount',
'failedCheckCount',
'failedChecks',
'failedActionCount',
'failedCount',
'failureCount',
'errorCount',
'runtimeIssueCount',
'issueCount',
'unexpectedErrorCount',
]);
const arrayFailureKeys = new Set([
'failures',
'runtimeIssues',
'runtimeErrors',
'errors',
'warnings',
]);
function collectFailureSignals(value) {
const signals = [];
function visit(node, trail = []) {
if (Array.isArray(node)) {
if (trail.at(-1) && arrayFailureKeys.has(trail.at(-1)) && node.length > 0) {
signals.push({ path: trail.join('.'), count: node.length });
}
const failingEntries = node.filter((entry) => entry && typeof entry === 'object' && entry.ok === false);
if (failingEntries.length > 0) {
signals.push({ path: trail.join('.'), count: failingEntries.length });
}
node.forEach((entry, index) => visit(entry, [...trail, String(index)]));
return;
}
if (!node || typeof node !== 'object') {
return;
}
for (const [key, child] of Object.entries(node)) {
if (typeof child === 'number' && failureCountKeys.has(key) && child > 0) {
signals.push({ path: [...trail, key].join('.'), count: child });
}
visit(child, [...trail, key]);
}
}
visit(value);
return signals;
}
async function readReport(reportPath) {
try {
const content = await readFile(reportPath, 'utf8');
return JSON.parse(content);
} catch (error) {
return {
reportReadFailed: true,
error: error instanceof Error ? error.message : String(error),
};
}
}
function runSuite({ name, script }) {
return new Promise((resolve) => {
const startedAt = Date.now();
const child = spawn(process.execPath, [path.join(__dirname, script)], {
cwd: webRoot,
env: process.env,
stdio: 'inherit',
});
child.on('exit', (code, signal) => {
resolve({
name,
script,
exitCode: code ?? null,
signal: signal ?? null,
durationMs: Date.now() - startedAt,
});
});
});
}
async function main() {
await mkdir(outputDir, { recursive: true });
const summary = {
generatedAtUtc: new Date().toISOString(),
baseUrl: process.env.STELLAOPS_FRONTDOOR_BASE_URL?.trim() || 'https://stella-ops.local',
suiteCount: suites.length,
suites: [],
};
for (const suite of suites) {
process.stdout.write(`[live-full-core-audit] START ${suite.name}\n`);
const execution = await runSuite(suite);
const report = await readReport(suite.reportPath);
const failureSignals = collectFailureSignals(report);
const ok = execution.exitCode === 0 && failureSignals.length === 0 && !report.reportReadFailed;
const result = {
...execution,
reportPath: suite.reportPath,
ok,
failureSignals,
report,
};
summary.suites.push(result);
process.stdout.write(
`[live-full-core-audit] DONE ${suite.name} ok=${ok} exitCode=${execution.exitCode ?? 'null'} ` +
`signals=${failureSignals.length} durationMs=${execution.durationMs}\n`,
);
}
summary.failedSuiteCount = summary.suites.filter((suite) => !suite.ok).length;
summary.passedSuiteCount = summary.suiteCount - summary.failedSuiteCount;
summary.failedSuites = summary.suites
.filter((suite) => !suite.ok)
.map((suite) => ({
name: suite.name,
exitCode: suite.exitCode,
signal: suite.signal,
failureSignals: suite.failureSignals,
reportPath: suite.reportPath,
}));
await writeFile(resultPath, `${JSON.stringify(summary, null, 2)}\n`, 'utf8');
if (summary.failedSuiteCount > 0) {
process.exitCode = 1;
}
}
await main();

View File

@@ -239,6 +239,28 @@ async function waitForAnyButton(page, names, timeoutMs = ELEMENT_WAIT_MS) {
return null;
}
async function waitForEnabledButton(page, names, timeoutMs = ELEMENT_WAIT_MS) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
for (const name of names) {
const locator = page.getByRole('button', { name });
const count = await locator.count();
for (let index = 0; index < count; index += 1) {
const candidate = locator.nth(index);
const disabled = await candidate.isDisabled().catch(() => true);
if (!disabled) {
return { name, locator: candidate };
}
}
}
await page.waitForTimeout(250);
}
return null;
}
async function clickLink(context, page, route, name, index = 0) {
await navigate(page, route);
const target = await waitForNavigationTarget(page, name, index);
@@ -380,8 +402,8 @@ async function exerciseShadowResults(page) {
let restoredDisabledState = false;
if (initiallyDisabled) {
const enableButton = page.getByRole('button', { name: 'Enable' }).first();
if ((await enableButton.count()) === 0) {
const enableTarget = await waitForEnabledButton(page, ['Enable Shadow Mode', /^Enable$/], 12_000);
if (!enableTarget) {
return {
action: 'button:View Results',
ok: false,
@@ -391,16 +413,13 @@ async function exerciseShadowResults(page) {
};
}
await enableButton.click({ timeout: 10_000 });
await enableTarget.locator.click({ timeout: 10_000 });
enabledInFlow = true;
await Promise.race([
page.waitForFunction(() => {
const buttons = Array.from(document.querySelectorAll('button'));
const button = buttons.find((candidate) => (candidate.textContent || '').trim() === 'View Results');
return button instanceof HTMLButtonElement && !button.disabled;
}, null, { timeout: 12_000 }).catch(() => {}),
page.waitForTimeout(2_000),
]);
await page.waitForFunction(() => {
const buttons = Array.from(document.querySelectorAll('button'));
const button = buttons.find((candidate) => (candidate.textContent || '').trim() === 'View Results');
return button instanceof HTMLButtonElement && !button.disabled;
}, null, { timeout: 12_000 }).catch(() => {});
steps.push({
step: 'enable-shadow-mode',
snapshot: await captureSnapshot(page, 'policy-simulation:enabled-shadow-mode'),

View File

@@ -20,7 +20,7 @@ const topologyScope = {
timeWindow: '7d',
};
const topologyScopeQuery = new URLSearchParams(topologyScope).toString();
const STEP_TIMEOUT_MS = 30_000;
const STEP_TIMEOUT_MS = 60_000;
const GENERIC_TITLES = new Set(['StellaOps', 'Stella Ops Dashboard']);
const ROUTE_READINESS = [
{
@@ -41,17 +41,17 @@ const ROUTE_READINESS = [
{
path: '/setup/topology/targets',
title: 'Targets - StellaOps',
markers: ['No targets for current filters.', 'Select a target row to view its topology mapping details.'],
markers: ['Targets', 'Selected Target', 'No targets for current filters.'],
},
{
path: '/setup/topology/hosts',
title: 'Hosts - StellaOps',
markers: ['No hosts for current filters.', 'Select a host row to inspect runtime drift and impact.'],
markers: ['Hosts', 'Selected Host', 'No hosts for current filters.'],
},
{
path: '/setup/topology/agents',
title: 'Agent Fleet - StellaOps',
markers: ['No groups for current filters.', 'All Agents', 'View Targets'],
markers: ['Agent Groups', 'All Agents', 'No groups for current filters.'],
},
];
@@ -135,6 +135,10 @@ function routeReadiness(pathname) {
return ROUTE_READINESS.find((entry) => entry.path === pathname) ?? null;
}
function normalizeReadyTitle(value) {
return value.toLowerCase().replace(/[^a-z0-9]+/g, '');
}
async function waitForRouteReady(page, routeOrPath) {
const expectedPath = routePath(routeOrPath);
const readiness = routeReadiness(expectedPath);
@@ -165,7 +169,12 @@ async function waitForRouteReady(page, routeOrPath) {
}
const title = document.title.trim();
if (title.length === 0 || genericTitles.includes(title) || title !== expectedTitle) {
const normalizedTitle = title.toLowerCase().replace(/[^a-z0-9]+/g, '');
if (
title.length === 0 ||
genericTitles.includes(title) ||
!normalizedTitle.includes(expectedTitle)
) {
return false;
}
@@ -174,7 +183,7 @@ async function waitForRouteReady(page, routeOrPath) {
},
{
expectedPathValue: expectedPath,
expectedTitle: readiness.title,
expectedTitle: normalizeReadyTitle(readiness.title),
markers: readiness.markers,
genericTitles: [...GENERIC_TITLES],
},

View File

@@ -0,0 +1,423 @@
#!/usr/bin/env node
import { mkdir, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { chromium } from 'playwright';
import { authenticateFrontdoor, createAuthenticatedContext } from './live-frontdoor-auth.mjs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const webRoot = path.resolve(__dirname, '..');
const outputDir = path.join(webRoot, 'output', 'playwright');
const outputPath = path.join(outputDir, 'live-uncovered-surface-action-sweep.json');
const authStatePath = path.join(outputDir, 'live-uncovered-surface-action-sweep.state.json');
const authReportPath = path.join(outputDir, 'live-uncovered-surface-action-sweep.auth.json');
const baseUrl = process.env.STELLAOPS_FRONTDOOR_BASE_URL?.trim() || 'https://stella-ops.local';
const scopeQuery = 'tenant=demo-prod&regions=us-east&environments=stage&timeWindow=7d';
const linkChecks = [
['/releases/overview', 'Release Versions', '/releases/versions'],
['/releases/overview', 'Release Runs', '/releases/runs'],
['/releases/overview', 'Approvals Queue', '/releases/approvals'],
['/releases/overview', 'Hotfixes', '/releases/hotfixes'],
['/releases/overview', 'Promotions', '/releases/promotions'],
['/releases/overview', 'Deployment History', '/releases/deployments'],
['/releases/runs', 'Timeline', '/releases/runs?view=timeline'],
['/releases/runs', 'Table', '/releases/runs?view=table'],
['/releases/runs', 'Correlations', '/releases/runs?view=correlations'],
['/releases/approvals', 'Pending', '/releases/approvals?tab=pending'],
['/releases/approvals', 'Approved', '/releases/approvals?tab=approved'],
['/releases/approvals', 'Rejected', '/releases/approvals?tab=rejected'],
['/releases/approvals', 'Expiring', '/releases/approvals?tab=expiring'],
['/releases/approvals', 'My Team', '/releases/approvals?tab=my-team'],
['/releases/environments', 'Open Environment', '/setup/topology/environments/stage/posture'],
['/releases/environments', 'Open Targets', '/setup/topology/targets'],
['/releases/environments', 'Open Agents', '/setup/topology/agents'],
['/releases/environments', 'Open Runs', '/releases/runs'],
['/releases/investigation/deploy-diff', 'Open Deployments', '/releases/deployments'],
['/releases/investigation/deploy-diff', 'Open Releases Overview', '/releases/overview'],
['/releases/investigation/change-trace', 'Open Deployments', '/releases/deployments'],
['/security/posture', 'Open triage', '/security/triage'],
['/security/posture', 'Disposition', '/security/disposition'],
['/security/posture', 'Configure sources', '/ops/integrations/advisory-vex-sources'],
['/security/posture', 'Open reachability coverage board', '/security/reachability'],
['/security/advisories-vex', 'Providers', '/security/advisories-vex?tab=providers'],
['/security/advisories-vex', 'VEX Library', '/security/advisories-vex?tab=vex-library'],
['/security/advisories-vex', 'Issuer Trust', '/security/advisories-vex?tab=issuer-trust'],
['/security/disposition', 'Conflicts', '/security/disposition?tab=conflicts'],
['/security/supply-chain-data', 'SBOM Graph', '/security/supply-chain-data/graph'],
['/security/supply-chain-data', 'Reachability', '/security/reachability'],
['/evidence/overview', 'Audit Log', '/evidence/audit-log'],
['/evidence/overview', 'Export Center', '/evidence/exports'],
['/evidence/overview', 'Replay & Verify', '/evidence/verify-replay'],
['/evidence/audit-log', 'View All Events', '/evidence/audit-log/events'],
['/evidence/audit-log', 'Export', '/evidence/exports'],
['/evidence/audit-log', /Policy Audit/i, '/evidence/audit-log/policy'],
['/ops/operations/health-slo', 'View Full Timeline', '/ops/operations/health-slo/incidents'],
['/ops/operations/feeds-airgap', 'Configure Sources', '/ops/integrations/advisory-vex-sources'],
['/ops/operations/feeds-airgap', 'Open Offline Bundles', '/ops/operations/offline-kit/bundles'],
['/ops/operations/feeds-airgap', 'Version Locks', '/ops/operations/feeds-airgap?tenant=demo-prod&regions=us-east&environments=stage&timeWindow=7d&tab=version-locks'],
['/ops/operations/data-integrity', 'Feeds Freshness WARN Impact: BLOCKING NVD feed stale by 3h 12m', '/ops/operations/data-integrity/feeds-freshness'],
['/ops/operations/data-integrity', 'Hotfix 1.2.4', '/releases/approvals?releaseId=rel-hotfix-124'],
['/ops/operations/jobengine', 'Scheduler Runs', '/ops/operations/scheduler/runs'],
['/ops/operations/jobengine', /Execution Quotas/i, '/ops/operations/jobengine/quotas'],
['/ops/operations/offline-kit', 'Bundles', '/ops/operations/offline-kit/bundles'],
['/ops/operations/offline-kit', 'JWKS', '/ops/operations/offline-kit/jwks'],
];
const buttonChecks = [
['/releases/versions', 'Create Release Version', '/releases/versions/new'],
['/releases/versions', 'Create Hotfix Run', '/releases/versions/new'],
['/releases/versions', 'Search'],
['/releases/versions', 'Clear'],
['/releases/investigation/timeline', 'Export'],
['/releases/investigation/change-trace', 'Export'],
['/security/sbom-lake', 'Refresh'],
['/security/sbom-lake', 'Clear'],
['/security/reachability', 'Witnesses'],
['/security/reachability', /PoE|Proof of Exposure/i],
['/evidence/capsules', 'Search'],
['/evidence/threads', 'Search'],
['/evidence/proofs', 'Search'],
['/ops/operations/system-health', 'Services'],
['/ops/operations/system-health', 'Incidents'],
['/ops/operations/system-health', 'Quick Diagnostics'],
['/ops/operations/scheduler', 'Manage Schedules', '/ops/operations/scheduler/schedules'],
['/ops/operations/scheduler', 'Worker Fleet', '/ops/operations/scheduler/workers'],
['/ops/operations/doctor', 'Quick Check'],
['/ops/operations/signals', 'Refresh'],
['/ops/operations/packs', 'Refresh'],
['/ops/operations/status', 'Refresh'],
];
function buildUrl(route) {
const separator = route.includes('?') ? '&' : '?';
return `${baseUrl}${route}${separator}${scopeQuery}`;
}
async function settle(page) {
await page.waitForLoadState('domcontentloaded', { timeout: 15_000 }).catch(() => {});
await page.waitForTimeout(1_500);
}
async function captureSnapshot(page, label) {
const alerts = await page
.locator('[role="alert"], .mat-mdc-snack-bar-container, .toast, .notification, .error-banner, .warning-banner')
.evaluateAll((nodes) =>
nodes
.map((node) => (node.textContent || '').trim().replace(/\s+/g, ' '))
.filter(Boolean)
.slice(0, 5),
)
.catch(() => []);
return {
label,
url: page.url(),
title: await page.title().catch(() => ''),
heading: await page.locator('h1, h2, [data-testid="page-title"], .page-title').first().innerText().catch(() => ''),
alerts,
};
}
async function navigate(page, route) {
await page.goto(buildUrl(route), { waitUntil: 'domcontentloaded', timeout: 30_000 });
await settle(page);
}
async function findLink(page, name, timeoutMs = 10_000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const link = page.getByRole('link', { name }).first();
if (await link.count()) {
return link;
}
await page.waitForTimeout(250);
}
return null;
}
function matchesLocatorName(name, text) {
if (!text) {
return false;
}
const normalizedText = text.trim();
if (!normalizedText) {
return false;
}
if (name instanceof RegExp) {
return name.test(normalizedText);
}
return normalizedText.includes(name);
}
async function findScopedLink(page, scopeSelector, name) {
const scope = page.locator(scopeSelector).first();
if (!(await scope.count())) {
return null;
}
const descendantLink = scope.getByRole('link', { name }).first();
if (await descendantLink.count()) {
return descendantLink;
}
const [tagName, href, text] = await Promise.all([
scope.evaluate((element) => element.tagName.toLowerCase()).catch(() => ''),
scope.getAttribute('href').catch(() => null),
scope.textContent().catch(() => ''),
]);
if (tagName === 'a' && href && matchesLocatorName(name, text ?? '')) {
return scope;
}
return null;
}
async function findLinkForRoute(page, route, name, timeoutMs = 10_000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
let locator = null;
if (route === '/releases/environments' && name === 'Open Environment') {
locator = page.locator('.actions').getByRole('link', { name }).first();
} else if (route === '/ops/operations/jobengine' && name instanceof RegExp && String(name) === String(/Execution Quotas/i)) {
locator = await findScopedLink(page, '[data-testid="jobengine-quotas-card"]', name);
} else if (route === '/ops/operations/offline-kit' && name === 'Bundles') {
locator = page.locator('.tab-nav').getByRole('link', { name }).first();
} else {
locator = page.getByRole('link', { name }).first();
}
if (await locator.count()) {
return locator;
}
await page.waitForTimeout(250);
}
return null;
}
async function findButton(page, name, timeoutMs = 10_000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
for (const role of ['button', 'tab']) {
const button = page.getByRole(role, { name }).first();
if (await button.count()) {
return button;
}
}
await page.waitForTimeout(250);
}
return null;
}
function normalizeUrl(url) {
return decodeURIComponent(url);
}
function shouldIgnoreConsoleError(message) {
return message === 'Failed to load resource: the server responded with a status of 401 ()';
}
function shouldIgnoreRequestFailure(request) {
return request.failure === 'net::ERR_ABORTED';
}
function shouldIgnoreResponseError(response) {
return response.status === 401 && /\/doctor\/api\/v1\/doctor\/run\/[^/]+\/stream$/i.test(response.url);
}
async function runLinkCheck(page, route, name, expectedPath) {
const action = `${route} -> link:${name}`;
try {
await navigate(page, route);
const link = await findLinkForRoute(page, route, name);
if (
!link &&
route === '/ops/operations/jobengine' &&
name instanceof RegExp &&
String(name) === String(/Execution Quotas/i)
) {
const quotasCardText = await page.locator('[data-testid="jobengine-quotas-card"]').textContent().catch(() => '');
const snapshot = await captureSnapshot(page, action);
return {
action,
ok: /access required to manage quotas/i.test(quotasCardText ?? ''),
expectedPath,
finalUrl: normalizeUrl(snapshot.url),
snapshot,
reason: 'restricted-card',
};
}
if (!link) {
return { action, ok: false, reason: 'missing-link', snapshot: await captureSnapshot(page, action) };
}
await link.click({ timeout: 10_000 });
await settle(page);
const snapshot = await captureSnapshot(page, action);
const finalUrl = normalizeUrl(snapshot.url);
return {
action,
ok: finalUrl.includes(expectedPath),
expectedPath,
finalUrl,
snapshot,
};
} catch (error) {
return {
action,
ok: false,
reason: 'exception',
error: error instanceof Error ? error.message : String(error),
snapshot: await captureSnapshot(page, action),
};
}
}
async function runButtonCheck(page, route, name, expectedPath = null) {
const action = `${route} -> button:${name}`;
try {
await navigate(page, route);
const button = await findButton(page, name);
if (!button) {
return { action, ok: false, reason: 'missing-button', snapshot: await captureSnapshot(page, action) };
}
const disabled = await button.isDisabled().catch(() => false);
if (disabled) {
const snapshot = await captureSnapshot(page, action);
return {
action,
ok: true,
reason: 'disabled-by-design',
expectedPath,
finalUrl: normalizeUrl(snapshot.url),
snapshot,
};
}
await button.click({ timeout: 10_000 });
await settle(page);
const snapshot = await captureSnapshot(page, action);
const finalUrl = normalizeUrl(snapshot.url);
const hasRuntimeAlert = snapshot.alerts.some((text) => /(error|failed|unable|timed out|unavailable)/i.test(text));
return {
action,
ok: expectedPath ? finalUrl.includes(expectedPath) : snapshot.heading.trim().length > 0 && !hasRuntimeAlert,
expectedPath,
finalUrl,
snapshot,
};
} catch (error) {
return {
action,
ok: false,
reason: 'exception',
error: error instanceof Error ? error.message : String(error),
snapshot: await captureSnapshot(page, action),
};
}
}
async function main() {
await mkdir(outputDir, { recursive: true });
const browser = await chromium.launch({
headless: process.env.PLAYWRIGHT_HEADLESS !== 'false',
});
const auth = await authenticateFrontdoor({ statePath: authStatePath, reportPath: authReportPath });
const context = await createAuthenticatedContext(browser, auth, { statePath: auth.statePath });
const page = await context.newPage();
const results = [];
const runtime = {
consoleErrors: [],
pageErrors: [],
requestFailures: [],
responseErrors: [],
};
page.on('console', (message) => {
if (message.type() === 'error' && !shouldIgnoreConsoleError(message.text())) {
runtime.consoleErrors.push(message.text());
}
});
page.on('pageerror', (error) => runtime.pageErrors.push(error.message));
page.on('requestfailed', (request) => {
const failure = request.failure()?.errorText ?? 'unknown';
if (!shouldIgnoreRequestFailure({ url: request.url(), failure })) {
runtime.requestFailures.push({ url: request.url(), failure });
}
});
page.on('response', (response) => {
if (response.status() >= 400) {
const candidate = { url: response.url(), status: response.status() };
if (!shouldIgnoreResponseError(candidate)) {
runtime.responseErrors.push(candidate);
}
}
});
try {
for (const [route, name, expectedPath] of linkChecks) {
process.stdout.write(`[live-uncovered-surface-action-sweep] START ${route} -> link:${name}\n`);
const result = await runLinkCheck(page, route, name, expectedPath);
process.stdout.write(
`[live-uncovered-surface-action-sweep] DONE ${route} -> link:${name} ok=${result.ok}\n`,
);
results.push(result);
}
for (const [route, name, expectedPath] of buttonChecks) {
process.stdout.write(`[live-uncovered-surface-action-sweep] START ${route} -> button:${name}\n`);
const result = await runButtonCheck(page, route, name, expectedPath);
process.stdout.write(
`[live-uncovered-surface-action-sweep] DONE ${route} -> button:${name} ok=${result.ok}\n`,
);
results.push(result);
}
} finally {
await page.close().catch(() => {});
await context.close().catch(() => {});
await browser.close().catch(() => {});
}
const failedActions = results.filter((result) => !result.ok);
const report = {
generatedAtUtc: new Date().toISOString(),
baseUrl,
actionCount: results.length,
passedActionCount: results.length - failedActions.length,
failedActionCount: failedActions.length,
failedActions,
results,
runtime,
runtimeIssueCount:
runtime.consoleErrors.length + runtime.pageErrors.length + runtime.requestFailures.length + runtime.responseErrors.length,
};
await writeFile(outputPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
if (failedActions.length > 0 || report.runtimeIssueCount > 0) {
process.exitCode = 1;
}
}
await main();

View File

@@ -0,0 +1,548 @@
#!/usr/bin/env node
import { mkdir, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { chromium } from 'playwright';
import { authenticateFrontdoor, createAuthenticatedContext } from './live-frontdoor-auth.mjs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const webRoot = path.resolve(__dirname, '..');
const outputDir = path.join(webRoot, 'output', 'playwright');
const outputPath = path.join(outputDir, 'live-user-reported-admin-trust-check.json');
const authStatePath = path.join(outputDir, 'live-user-reported-admin-trust-check.state.json');
const authReportPath = path.join(outputDir, 'live-user-reported-admin-trust-check.auth.json');
const baseUrl = process.env.STELLAOPS_FRONTDOOR_BASE_URL?.trim() || 'https://stella-ops.local';
const scopeQuery = 'tenant=demo-prod&regions=us-east&environments=stage&timeWindow=7d';
function buildUrl(route) {
const separator = route.includes('?') ? '&' : '?';
return `${baseUrl}${route}${separator}${scopeQuery}`;
}
async function settle(page, ms = 1500) {
await page.waitForLoadState('domcontentloaded', { timeout: 15_000 }).catch(() => {});
await page.waitForTimeout(ms);
}
async function navigate(page, route) {
await page.goto(buildUrl(route), { waitUntil: 'domcontentloaded', timeout: 30_000 });
await settle(page);
}
async function snapshot(page, label) {
const heading = await page.locator('h1, h2, [data-testid="page-title"], .page-title').first().innerText().catch(() => '');
const alerts = await page
.locator('[role="alert"], .alert, .error-banner, .success-banner, .loading-text, .trust-admin__error, .trust-admin__loading')
.evaluateAll((nodes) => nodes.map((node) => (node.textContent || '').replace(/\s+/g, ' ').trim()).filter(Boolean).slice(0, 8))
.catch(() => []);
return {
label,
url: page.url(),
title: await page.title().catch(() => ''),
heading,
alerts,
};
}
function boxesOverlap(left, right) {
if (!left || !right) {
return null;
}
return !(
left.x + left.width <= right.x ||
right.x + right.width <= left.x ||
left.y + left.height <= right.y ||
right.y + right.height <= left.y
);
}
async function collectTrustTabState(page, tab) {
const tableRowCount = await page.locator('tbody tr').count().catch(() => 0);
const eventCardCount = await page.locator('.event-card').count().catch(() => 0);
const emptyTexts = await page
.locator('.key-dashboard__empty, .issuer-trust__empty, .certificate-inventory__empty, .trust-audit-log__empty, .empty-state')
.evaluateAll((nodes) => nodes.map((node) => (node.textContent || '').replace(/\s+/g, ' ').trim()).filter(Boolean).slice(0, 6))
.catch(() => []);
const loadingTexts = await page
.locator('.key-dashboard__loading, .trust-admin__loading, .issuer-trust__loading, .certificate-inventory__loading, .trust-audit-log__loading, .loading-text')
.evaluateAll((nodes) => nodes.map((node) => (node.textContent || '').replace(/\s+/g, ' ').trim()).filter(Boolean).slice(0, 6))
.catch(() => []);
const primaryButtons = await page
.locator('button')
.evaluateAll((nodes) =>
nodes
.map((node) => (node.textContent || '').replace(/\s+/g, ' ').trim())
.filter(Boolean)
.slice(0, 8),
)
.catch(() => []);
return {
tab,
tableRowCount,
eventCardCount,
emptyTexts,
loadingTexts,
primaryButtons,
};
}
async function createRoleCheck(page) {
const roleName = `qa-role-${Date.now()}`;
await page.getByRole('button', { name: '+ Create Role' }).click();
await settle(page, 750);
await page.locator('input[placeholder="security-analyst"]').fill(roleName);
await page.locator('input[placeholder="Security analyst with triage access"]').fill('QA-created role');
await page.locator('textarea[placeholder*="findings:read"]').fill('findings:read, vex:read');
await page.getByRole('button', { name: 'Create Role', exact: true }).click();
await settle(page, 1250);
const successText = await page.locator('.success-banner').first().textContent().then((text) => text?.trim() || '').catch(() => '');
const tableContainsRole = await page.locator('tbody tr td:first-child').evaluateAll(
(cells, expected) => cells.some((cell) => (cell.textContent || '').replace(/\s+/g, ' ').trim() === expected),
roleName,
).catch(() => false);
return {
roleName,
successText,
tableContainsRole,
};
}
async function createTenantCheck(page) {
const tenantId = `qa-tenant-${Date.now()}`;
const displayName = `QA Tenant ${Date.now()}`;
await page.getByRole('button', { name: '+ Add Tenant' }).click();
await settle(page, 750);
await page.locator('input[placeholder="customer-stage"]').fill(tenantId);
await page.locator('input[placeholder="Customer Stage"]').fill(displayName);
await page.locator('select').last().selectOption('shared').catch(() => {});
await page.getByRole('button', { name: 'Create Tenant', exact: true }).click();
await settle(page, 1250);
const successText = await page.locator('.success-banner').first().textContent().then((text) => text?.trim() || '').catch(() => '');
const tableContainsTenant = await page.locator('tbody tr td:first-child').evaluateAll(
(cells, expected) => cells.some((cell) => (cell.textContent || '').replace(/\s+/g, ' ').trim() === expected),
displayName,
).catch(() => false);
return {
tenantId,
displayName,
successText,
tableContainsTenant,
};
}
async function collectReportsTabState(page, tab) {
await page.getByRole('tab', { name: tab }).click();
await settle(page, 1000);
return {
tab,
url: page.url(),
headings: await page.locator('h1, h2, h3').evaluateAll((nodes) =>
nodes.map((node) => (node.textContent || '').replace(/\s+/g, ' ').trim()).filter(Boolean).slice(0, 16)
).catch(() => []),
primaryButtons: await page.locator('main button').evaluateAll((nodes) =>
nodes.map((node) => (node.textContent || '').replace(/\s+/g, ' ').trim()).filter(Boolean).slice(0, 12)
).catch(() => []),
};
}
async function runSearchQueryCheck(page, query) {
const searchInput = page.locator('input[aria-label="Global search"]').first();
const responses = [];
const responseListener = async (response) => {
if (!response.url().includes('/api/v1/search/query')) {
return;
}
try {
responses.push({
status: response.status(),
url: response.url(),
body: await response.json(),
});
} catch {
responses.push({
status: response.status(),
url: response.url(),
body: null,
});
}
};
page.on('response', responseListener);
try {
await searchInput.click();
await searchInput.fill(query);
await settle(page, 2500);
const cards = await page.locator('.entity-card').evaluateAll((nodes) =>
nodes.slice(0, 5).map((node) => {
const title = node.querySelector('.entity-card__title')?.textContent?.trim() || '';
const domain = node.querySelector('.entity-card__badge')?.textContent?.trim() || '';
const snippet = node.querySelector('.entity-card__snippet')?.textContent?.replace(/\s+/g, ' ').trim() || '';
const actions = Array.from(node.querySelectorAll('.entity-card__action'))
.map((button) => button.textContent?.replace(/\s+/g, ' ').trim() || '')
.filter(Boolean);
return { title, domain, snippet, actions };
}),
).catch(() => []);
const firstPrimaryAction = page.locator('.entity-card').first().locator('.entity-card__action--primary').first();
const primaryActionLabel = await firstPrimaryAction.textContent().then((text) => text?.replace(/\s+/g, ' ').trim() || '').catch(() => '');
await firstPrimaryAction.click({ timeout: 10000 }).catch(() => {});
await settle(page, 1500);
const latestResponse = responses.at(-1)?.body;
return {
query,
cards,
primaryActionLabel,
latestDiagnostics: latestResponse?.diagnostics ?? null,
latestCardActions: (latestResponse?.cards ?? []).slice(0, 3).map((card) => ({
title: card.title ?? '',
domain: card.domain ?? '',
actions: (card.actions ?? []).map((action) => ({
label: action.label ?? '',
actionType: action.actionType ?? '',
route: action.route ?? '',
})),
})),
finalUrl: page.url(),
snapshot: await snapshot(page, `search:${query}`),
};
} finally {
page.off('response', responseListener);
}
}
async function main() {
await mkdir(outputDir, { recursive: true });
const browser = await chromium.launch({ headless: process.env.PLAYWRIGHT_HEADLESS !== 'false' });
const auth = await authenticateFrontdoor({ statePath: authStatePath, reportPath: authReportPath });
const context = await createAuthenticatedContext(browser, auth, { statePath: auth.statePath });
const page = await context.newPage();
const results = [];
try {
console.log('[live-user-reported-admin-trust-check] sidebar');
await navigate(page, '/setup/identity-access');
const setupGroupContainsDiagnostics = await page
.locator('[role="group"][aria-label="Platform & Setup"]')
.getByText('Diagnostics', { exact: true })
.count()
.then((count) => count > 0)
.catch(() => false);
results.push({
action: 'sidebar:diagnostics-under-setup',
setupGroupContainsDiagnostics,
snapshot: await snapshot(page, 'sidebar:diagnostics-under-setup'),
});
console.log('[live-user-reported-admin-trust-check] invalid-email');
await page.getByRole('button', { name: 'Users' }).click();
await settle(page, 500);
await page.getByRole('button', { name: '+ Add User' }).click();
await settle(page, 500);
const emailInput = page.locator('input[type="email"]').first();
await page.locator('input[placeholder="e.g. jane.doe"]').fill(`qa-user-${Date.now()}`);
await emailInput.fill('not-an-email');
await page.locator('input[placeholder="Jane Doe"]').fill('QA Invalid Email');
const emailValidity = await emailInput.evaluate((input) => ({
valid: input.checkValidity(),
validationMessage: input.validationMessage,
}));
await page.getByRole('button', { name: 'Create User' }).click();
await settle(page, 1000);
results.push({
action: 'identity-access:create-user-invalid-email',
emailValidity,
snapshot: await snapshot(page, 'identity-access:create-user-invalid-email'),
});
console.log('[live-user-reported-admin-trust-check] roles');
await page.getByRole('button', { name: 'Roles' }).click();
await settle(page, 1000);
const roleNames = await page.locator('tbody tr td:first-child').evaluateAll((cells) =>
cells.map((cell) => (cell.textContent || '').replace(/\s+/g, ' ').trim()).filter((value) => value.length > 0),
).catch(() => []);
const roleCreate = await createRoleCheck(page);
results.push({
action: 'identity-access:roles-tab',
roleNames,
roleCreate,
snapshot: await snapshot(page, 'identity-access:roles-tab'),
});
console.log('[live-user-reported-admin-trust-check] tenants');
await page.getByRole('button', { name: 'Tenants' }).click();
await settle(page, 1000);
const tenantCreate = await createTenantCheck(page);
results.push({
action: 'identity-access:tenants-tab',
tenantCreate,
snapshot: await snapshot(page, 'identity-access:tenants-tab'),
});
console.log('[live-user-reported-admin-trust-check] trust-tabs');
await navigate(page, '/setup/trust-signing');
for (const tab of ['Signing Keys', 'Trusted Issuers', 'Certificates', 'Audit Log']) {
await page.getByRole('tab', { name: tab }).click();
await settle(page, 1500);
results.push({
action: `trust-signing:${tab}`,
detail: await collectTrustTabState(page, tab),
snapshot: await snapshot(page, `trust-signing:${tab}`),
});
}
console.log('[live-user-reported-admin-trust-check] reports-tabs');
await navigate(page, '/security/reports');
results.push({
action: 'security-reports:tabs-embedded',
detail: [
await collectReportsTabState(page, 'Risk Report'),
await collectReportsTabState(page, 'VEX Ledger'),
await collectReportsTabState(page, 'Evidence Export'),
],
snapshot: await snapshot(page, 'security-reports:tabs-embedded'),
});
console.log('[live-user-reported-admin-trust-check] triage');
await navigate(page, '/security/triage');
const triageRawSvgTextVisible = await page.locator('main').innerText().then((text) => /<svg|stroke-width|viewBox=/.test(text)).catch(() => false);
results.push({
action: 'security-triage:raw-svg-visible',
triageRawSvgTextVisible,
snapshot: await snapshot(page, 'security-triage:raw-svg-visible'),
});
console.log('[live-user-reported-admin-trust-check] decision-capsules');
await navigate(page, '/evidence/capsules');
const capsulesSearchInput = page.locator('.filter-bar__search-input').first();
const capsulesSearchButton = page.locator('.filter-bar__search-btn').first();
const capsulesSearchInputBox = await capsulesSearchInput.boundingBox().catch(() => null);
const capsulesSearchButtonBox = await capsulesSearchButton.boundingBox().catch(() => null);
results.push({
action: 'decision-capsules:search-layout',
overlaps: boxesOverlap(capsulesSearchInputBox, capsulesSearchButtonBox),
inputBox: capsulesSearchInputBox,
buttonBox: capsulesSearchButtonBox,
snapshot: await snapshot(page, 'decision-capsules:search-layout'),
});
console.log('[live-user-reported-admin-trust-check] global-search');
await navigate(page, '/mission-control/board');
results.push({
action: 'global-search:cve',
detail: await runSearchQueryCheck(page, 'cve'),
});
console.log('[live-user-reported-admin-trust-check] docs');
await navigate(page, '/docs/modules/platform/architecture-overview.md');
results.push({
action: 'docs:architecture-overview',
snapshot: await snapshot(page, 'docs:architecture-overview'),
bodyPreview: await page.locator('.docs-viewer__content').innerText().then((text) => text.slice(0, 240)).catch(() => ''),
});
console.log('[live-user-reported-admin-trust-check] branding');
await navigate(page, '/setup/tenant-branding');
const titleInput = page.locator('#title');
const applyButton = page.getByRole('button', { name: 'Apply Changes' });
const tokenKeyInput = page.locator('input[placeholder="--theme-custom-color"]').first();
const tokenValueInput = page.locator('input[placeholder="var(--color-text-heading)"]').first();
const updatedTitle = `Stella Ops QA ${Date.now()}`;
await titleInput.fill(updatedTitle).catch(() => {});
await settle(page, 300);
const applyDisabled = await applyButton.isDisabled().catch(() => null);
await tokenKeyInput.fill(`--theme-qa-${Date.now()}`).catch(() => {});
await tokenValueInput.fill('#123456').catch(() => {});
await settle(page, 200);
const addTokenButton = page.getByRole('button', { name: 'Add Token' });
const addTokenDisabled = await addTokenButton.isDisabled().catch(() => null);
await addTokenButton.click().catch(() => {});
await settle(page, 500);
const tokenFormCleared =
(await tokenKeyInput.inputValue().catch(() => '')) === '' &&
(await tokenValueInput.inputValue().catch(() => '')) === '';
let applyResult = {
successText: '',
errorText: '',
url: page.url(),
freshAuthPrompt: '',
};
let persistedBranding = {
titleValue: '',
matchesUpdatedTitle: false,
};
if (applyDisabled === false) {
console.log('[live-user-reported-admin-trust-check] branding-apply');
page.once('dialog', async (dialog) => {
applyResult.freshAuthPrompt = dialog.message();
await dialog.accept().catch(() => {});
});
await applyButton.click().catch(() => {});
await settle(page, 2500);
applyResult = {
successText: await page.locator('.success, .success-banner, .alert-success').first().textContent().then((text) => text?.trim() || '').catch(() => ''),
errorText: await page.locator('.error, .error-banner, .alert-error').first().textContent().then((text) => text?.trim() || '').catch(() => ''),
url: page.url(),
freshAuthPrompt: applyResult.freshAuthPrompt,
};
await page.waitForTimeout(2500);
await navigate(page, '/setup/tenant-branding');
persistedBranding = {
titleValue: await page.locator('#title').inputValue().catch(() => ''),
matchesUpdatedTitle: (await page.locator('#title').inputValue().catch(() => '')) === updatedTitle,
};
}
results.push({
action: 'tenant-branding:action-state',
applyDisabled,
addTokenDisabled,
tokenFormCleared,
applyResult,
persistedBranding,
snapshot: await snapshot(page, 'tenant-branding:action-state'),
});
} finally {
console.log('[live-user-reported-admin-trust-check] cleanup');
await page.close().catch(() => {});
await context.close().catch(() => {});
await browser.close().catch(() => {});
}
const report = {
generatedAtUtc: new Date().toISOString(),
baseUrl,
results,
};
const failures = [];
const byAction = new Map(results.map((entry) => [entry.action, entry]));
if (!byAction.get('sidebar:diagnostics-under-setup')?.setupGroupContainsDiagnostics) {
failures.push('Diagnostics is not grouped under Platform & Setup in the sidebar.');
}
if (byAction.get('identity-access:create-user-invalid-email')?.emailValidity?.valid !== false) {
failures.push('Identity & Access user creation did not reject an invalid email address.');
}
if ((byAction.get('identity-access:roles-tab')?.roleNames?.length ?? 0) === 0) {
failures.push('Identity & Access roles table still shows empty role names.');
}
if (!byAction.get('identity-access:roles-tab')?.roleCreate?.tableContainsRole) {
failures.push('Identity & Access role creation did not persist a new role in the table.');
}
if (!byAction.get('identity-access:tenants-tab')?.tenantCreate?.tableContainsTenant) {
failures.push('Identity & Access tenant creation did not persist a new tenant in the table.');
}
for (const tab of ['Signing Keys', 'Trusted Issuers', 'Certificates', 'Audit Log']) {
const detail = byAction.get(`trust-signing:${tab}`)?.detail;
const resolved =
(detail?.tableRowCount ?? 0) > 0 ||
(detail?.eventCardCount ?? 0) > 0 ||
(detail?.emptyTexts?.length ?? 0) > 0;
const stillLoading = (detail?.loadingTexts?.length ?? 0) > 0;
if (!resolved || stillLoading) {
failures.push(`Trust & Signing tab "${tab}" did not resolve cleanly.`);
}
}
for (const tabState of byAction.get('security-reports:tabs-embedded')?.detail ?? []) {
if (!tabState.url.includes('/security/reports')) {
failures.push(`Security Reports tab "${tabState.tab}" still navigates away instead of embedding its workspace.`);
}
}
if (byAction.get('security-triage:raw-svg-visible')?.triageRawSvgTextVisible) {
failures.push('Triage still renders SVG markup as raw code.');
}
if (byAction.get('decision-capsules:search-layout')?.overlaps !== false) {
failures.push('Decision Capsules search input still overlaps the search button.');
}
const searchDetail = byAction.get('global-search:cve')?.detail;
const topCardDomain = (searchDetail?.cards?.[0]?.domain ?? '').toLowerCase();
const topCardTitle = searchDetail?.cards?.[0]?.title ?? '';
const topCardActionLabels = (searchDetail?.latestCardActions?.[0]?.actions ?? []).map((action) => action.label);
const topCardRoute = (searchDetail?.latestCardActions?.[0]?.actions ?? [])[0]?.route ?? '';
const topCardHasDeadNavigation = (searchDetail?.latestCardActions?.[0]?.actions ?? [])
.some((action) => action.actionType === 'navigate' && !action.route);
const searchLandedOnDashboardFallback =
searchDetail?.snapshot?.title === 'Dashboard - StellaOps' ||
searchDetail?.snapshot?.heading === 'Dashboard';
const searchLandedOnBlankDocs =
searchDetail?.finalUrl?.includes('/docs/') &&
!(searchDetail?.snapshot?.heading ?? '').trim();
if (
topCardDomain.includes('api') ||
topCardDomain.includes('knowledge') ||
topCardActionLabels.includes('Copy Curl') ||
topCardHasDeadNavigation ||
searchLandedOnDashboardFallback ||
searchLandedOnBlankDocs
) {
failures.push(`Global search still misranks generic CVE queries or routes them into dead docs/dashboard fallback (top card: ${topCardTitle || 'unknown'}).`);
}
const docsRoute = byAction.get('docs:architecture-overview');
if (!(docsRoute?.snapshot?.heading ?? '').trim() || !(docsRoute?.bodyPreview ?? '').trim()) {
failures.push('Direct /docs navigation still renders an empty or blank documentation page.');
}
if (byAction.get('tenant-branding:action-state')?.applyDisabled !== false) {
failures.push('Tenant & Branding apply action did not become available after editing.');
}
if (byAction.get('tenant-branding:action-state')?.tokenFormCleared !== true) {
failures.push('Tenant & Branding add-token action did not complete cleanly.');
}
const brandingApplyResult = byAction.get('tenant-branding:action-state')?.applyResult;
if (
byAction.get('tenant-branding:action-state')?.applyDisabled === false &&
!brandingApplyResult?.successText &&
!brandingApplyResult?.errorText
) {
failures.push('Tenant & Branding apply action did not produce a visible success or error outcome.');
}
if (
byAction.get('tenant-branding:action-state')?.applyDisabled === false &&
byAction.get('tenant-branding:action-state')?.persistedBranding?.matchesUpdatedTitle !== true
) {
failures.push('Tenant & Branding apply action did not persist the updated title after reload.');
}
report.failures = failures;
report.failedCheckCount = failures.length;
report.ok = failures.length === 0;
await writeFile(outputPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
if (failures.length > 0) {
process.exitCode = 1;
}
}
await main();

View File

@@ -166,6 +166,21 @@ export const routes: Routes = [
data: { breadcrumb: 'Settings' },
loadChildren: () => import('./features/settings/settings.routes').then((m) => m.SETTINGS_ROUTES),
},
{
path: 'docs',
title: 'Documentation',
children: [
{
path: '',
pathMatch: 'full',
loadComponent: () => import('./features/docs/docs-viewer.component').then((m) => m.DocsViewerComponent),
},
{
path: '**',
loadComponent: () => import('./features/docs/docs-viewer.component').then((m) => m.DocsViewerComponent),
},
],
},
{
path: 'welcome',
title: 'Welcome',

View File

@@ -40,6 +40,9 @@ export interface AdminClient {
scopes: string[];
status: 'active' | 'disabled';
createdAt: string;
updatedAt?: string;
defaultTenant?: string;
tenants?: string[];
}
export interface AdminToken {
@@ -73,6 +76,18 @@ export interface CreateUserRequest {
roles: string[];
}
export interface CreateRoleRequest {
name: string;
description: string;
permissions: string[];
}
export interface CreateTenantRequest {
id: string;
displayName: string;
isolationMode: string;
}
export interface AuthorityAdminApi {
listUsers(tenantId?: string): Observable<AdminUser[]>;
listRoles(tenantId?: string): Observable<AdminRole[]>;
@@ -80,11 +95,89 @@ export interface AuthorityAdminApi {
listTokens(tenantId?: string): Observable<AdminToken[]>;
listTenants(): Observable<AdminTenant[]>;
createUser(request: CreateUserRequest): Observable<AdminUser>;
createRole(request: CreateRoleRequest): Observable<AdminRole>;
createTenant(request: CreateTenantRequest): Observable<AdminTenant>;
}
export const AUTHORITY_ADMIN_API = new InjectionToken<AuthorityAdminApi>('AUTHORITY_ADMIN_API');
export const AUTHORITY_ADMIN_API_BASE_URL = new InjectionToken<string>('AUTHORITY_ADMIN_API_BASE_URL');
interface AdminUsersResponseDto {
users?: AdminUserDto[];
}
interface AdminRolesResponseDto {
roles?: AdminRoleDto[];
}
interface AdminClientsResponseDto {
clients?: AdminClientDto[];
}
interface AdminTokensResponseDto {
tokens?: AdminTokenDto[];
}
interface AdminTenantsResponseDto {
tenants?: AdminTenantDto[];
}
interface AdminUserDto {
id?: string;
username?: string;
email?: string;
displayName?: string;
roles?: string[];
status?: string;
createdAt?: string;
lastLoginAt?: string;
}
interface AdminRoleDto {
id?: string;
name?: string;
description?: string;
permissions?: string[];
userCount?: number;
isBuiltIn?: boolean;
}
interface AdminClientDto {
id?: string;
clientId?: string;
displayName?: string;
grantTypes?: string[];
scopes?: string[];
status?: string;
createdAt?: string;
enabled?: boolean;
allowedGrantTypes?: string[];
allowedScopes?: string[];
updatedAt?: string;
defaultTenant?: string | null;
tenants?: string[];
}
interface AdminTokenDto {
id?: string;
name?: string;
clientId?: string;
scopes?: string[];
expiresAt?: string;
createdAt?: string;
lastUsedAt?: string;
status?: string;
}
interface AdminTenantDto {
id?: string;
displayName?: string;
status?: string;
isolationMode?: string;
userCount?: number;
createdAt?: string;
}
// ============================================================================
// HTTP Implementation
// ============================================================================
@@ -98,39 +191,59 @@ export class AuthorityAdminHttpClient implements AuthorityAdminApi {
) {}
listUsers(tenantId?: string): Observable<AdminUser[]> {
return this.http.get<{ users: AdminUser[] }>(`${this.baseUrl}/users`, {
return this.http.get<AdminUsersResponseDto>(`${this.baseUrl}/users`, {
headers: this.buildHeaders(tenantId),
}).pipe(map(r => r.users ?? []));
}).pipe(map((response) => (response.users ?? []).map((user) => this.mapUser(user))));
}
listRoles(tenantId?: string): Observable<AdminRole[]> {
return this.http.get<{ roles: AdminRole[] }>(`${this.baseUrl}/roles`, {
return this.http.get<AdminRolesResponseDto>(`${this.baseUrl}/roles`, {
headers: this.buildHeaders(tenantId),
}).pipe(map(r => r.roles ?? []));
}).pipe(map((response) => (response.roles ?? []).map((role) => this.mapRole(role))));
}
listClients(tenantId?: string): Observable<AdminClient[]> {
return this.http.get<{ clients: AdminClient[] }>(`${this.baseUrl}/clients`, {
return this.http.get<AdminClientsResponseDto>(`${this.baseUrl}/clients`, {
headers: this.buildHeaders(tenantId),
}).pipe(map(r => r.clients ?? []));
}).pipe(map((response) => (response.clients ?? []).map((client) => this.mapClient(client))));
}
listTokens(tenantId?: string): Observable<AdminToken[]> {
return this.http.get<{ tokens: AdminToken[] }>(`${this.baseUrl}/tokens`, {
return this.http.get<AdminTokensResponseDto>(`${this.baseUrl}/tokens`, {
headers: this.buildHeaders(tenantId),
}).pipe(map(r => r.tokens ?? []));
}).pipe(map((response) => (response.tokens ?? []).map((token) => this.mapToken(token))));
}
listTenants(): Observable<AdminTenant[]> {
return this.http.get<{ tenants: AdminTenant[] }>(`${this.baseUrl}/tenants`, {
return this.http.get<AdminTenantsResponseDto>(`${this.baseUrl}/tenants`, {
headers: this.buildHeaders(),
}).pipe(map(r => r.tenants ?? []));
}).pipe(map((response) => (response.tenants ?? []).map((tenant) => this.mapTenant(tenant))));
}
createUser(request: CreateUserRequest): Observable<AdminUser> {
return this.http.post<AdminUser>(`${this.baseUrl}/users`, request, {
return this.http.post<AdminUserDto>(`${this.baseUrl}/users`, request, {
headers: this.buildHeaders(),
});
}).pipe(map((user) => this.mapUser(user)));
}
createRole(request: CreateRoleRequest): Observable<AdminRole> {
return this.http.post<AdminRoleDto>(`${this.baseUrl}/roles`, {
roleId: request.name,
displayName: request.description,
scopes: request.permissions,
}, {
headers: this.buildHeaders(),
}).pipe(map((role) => this.mapRole(role)));
}
createTenant(request: CreateTenantRequest): Observable<AdminTenant> {
return this.http.post<AdminTenantDto>(`${this.baseUrl}/tenants`, {
id: request.id,
displayName: request.displayName,
isolationMode: request.isolationMode,
}, {
headers: this.buildHeaders(),
}).pipe(map((tenant) => this.mapTenant(tenant)));
}
private buildHeaders(tenantOverride?: string): HttpHeaders {
@@ -142,6 +255,86 @@ export class AuthorityAdminHttpClient implements AuthorityAdminApi {
[StellaOpsHeaders.Tenant]: tenantId,
});
}
private mapUser(dto: AdminUserDto): AdminUser {
const username = dto.username?.trim() || dto.email?.trim() || 'unknown-user';
return {
id: dto.id?.trim() || username,
username,
email: dto.email?.trim() || '',
displayName: dto.displayName?.trim() || username,
roles: (dto.roles ?? []).map((role) => role.trim()).filter((role) => role.length > 0),
status: this.normalizeStatus(dto.status, ['active', 'disabled', 'locked'], 'active'),
createdAt: dto.createdAt?.trim() || new Date(0).toISOString(),
lastLoginAt: dto.lastLoginAt?.trim() || undefined,
};
}
private mapRole(dto: AdminRoleDto): AdminRole {
const name = dto.name?.trim() || dto.id?.trim() || 'unnamed-role';
return {
id: dto.id?.trim() || name,
name,
description: dto.description?.trim() || '',
permissions: (dto.permissions ?? []).map((permission) => permission.trim()).filter((permission) => permission.length > 0),
userCount: dto.userCount ?? 0,
isBuiltIn: dto.isBuiltIn ?? false,
};
}
private mapClient(dto: AdminClientDto): AdminClient {
const clientId = dto.clientId?.trim() || dto.id?.trim() || 'unknown-client';
return {
id: dto.id?.trim() || clientId,
clientId,
displayName: dto.displayName?.trim() || clientId,
grantTypes: this.normalizeValues(dto.grantTypes ?? dto.allowedGrantTypes),
scopes: this.normalizeValues(dto.scopes ?? dto.allowedScopes),
status: this.normalizeStatus(
dto.status ?? (dto.enabled === false ? 'disabled' : 'active'),
['active', 'disabled'],
'active',
),
createdAt: dto.createdAt?.trim() || dto.updatedAt?.trim() || new Date(0).toISOString(),
updatedAt: dto.updatedAt?.trim() || undefined,
defaultTenant: dto.defaultTenant?.trim() || undefined,
tenants: this.normalizeValues(dto.tenants),
};
}
private mapToken(dto: AdminTokenDto): AdminToken {
return {
id: dto.id?.trim() || dto.name?.trim() || 'unknown-token',
name: dto.name?.trim() || 'Unnamed token',
clientId: dto.clientId?.trim() || 'unknown-client',
scopes: this.normalizeValues(dto.scopes),
expiresAt: dto.expiresAt?.trim() || '',
createdAt: dto.createdAt?.trim() || new Date(0).toISOString(),
lastUsedAt: dto.lastUsedAt?.trim() || undefined,
status: this.normalizeStatus(dto.status, ['active', 'expired', 'revoked'], 'active'),
};
}
private mapTenant(dto: AdminTenantDto): AdminTenant {
const tenantId = dto.id?.trim() || 'unknown-tenant';
return {
id: tenantId,
displayName: dto.displayName?.trim() || tenantId,
status: this.normalizeStatus(dto.status, ['active', 'disabled'], 'active'),
isolationMode: dto.isolationMode?.trim() || 'shared',
userCount: dto.userCount ?? 0,
createdAt: dto.createdAt?.trim() || new Date(0).toISOString(),
};
}
private normalizeValues(values?: string[]): string[] {
return (values ?? []).map((value) => value.trim()).filter((value) => value.length > 0);
}
private normalizeStatus<T extends string>(status: string | undefined, allowed: readonly T[], fallback: T): T {
const normalizedStatus = status?.trim().toLowerCase();
return allowed.includes(normalizedStatus as T) ? normalizedStatus as T : fallback;
}
}
// ============================================================================
@@ -210,4 +403,28 @@ export class MockAuthorityAdminClient implements AuthorityAdminApi {
};
return of(user).pipe(delay(400));
}
createRole(request: CreateRoleRequest): Observable<AdminRole> {
const role: AdminRole = {
id: `r-${Date.now()}`,
name: request.name,
description: request.description,
permissions: request.permissions,
userCount: 0,
isBuiltIn: false,
};
return of(role).pipe(delay(400));
}
createTenant(request: CreateTenantRequest): Observable<AdminTenant> {
const tenant: AdminTenant = {
id: request.id,
displayName: request.displayName,
isolationMode: request.isolationMode,
status: 'active',
userCount: 0,
createdAt: new Date().toISOString(),
};
return of(tenant).pipe(delay(400));
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -188,6 +188,8 @@ describe('BrandingService', () => {
}).subscribe((response) => {
expect(response.branding.title).toBe('Demo Production');
expect(response.branding.logoUrl).toBe('data:image/png;base64,AAAA');
expect(response.message).toBe('Branding saved.');
expect(response.metadata?.hash).toBe('hash-456');
});
const req = httpMock.expectOne('/console/admin/branding');
@@ -202,6 +204,7 @@ describe('BrandingService', () => {
},
});
req.flush({
message: 'Branding saved.',
branding: {
tenantId: 'demo-prod',
displayName: 'Demo Production',
@@ -211,6 +214,10 @@ describe('BrandingService', () => {
'--theme-brand-primary': '#112233',
},
},
metadata: {
tenantId: 'demo-prod',
hash: 'hash-456',
},
});
});

View File

@@ -19,6 +19,8 @@ export interface BrandingConfiguration {
export interface BrandingResponse {
branding: BrandingConfiguration;
message?: string;
metadata?: BrandingMetadata;
}
export interface BrandingMetadata {
@@ -50,6 +52,7 @@ interface AuthorityBrandingDto {
interface AuthorityAdminBrandingEnvelopeDto {
branding: AuthorityBrandingDto;
message?: string;
metadata?: BrandingMetadata;
}
@@ -135,14 +138,18 @@ export class BrandingService {
themeTokens: request.themeTokens ?? {},
};
return this.http.put<{ branding: AuthorityBrandingDto }>(
return this.http.put<AuthorityAdminBrandingEnvelopeDto>(
'/console/admin/branding',
payload,
{
headers: this.buildTenantHeaders(resolvedTenantId),
}
).pipe(
map((response) => this.mapBrandingResponse(response.branding)),
map((response) => ({
...this.mapBrandingResponse(response.branding),
message: response.message,
metadata: response.metadata,
})),
tap((response) => {
this.applyBranding(response.branding);
})

View File

@@ -0,0 +1,43 @@
import {
buildDocsAssetCandidates,
buildDocsRoute,
normalizeDocsPath,
parseDocsUrl,
resolveDocsLink,
} from './docs-route';
describe('docs-route helpers', () => {
it('builds canonical docs routes without collapsing path segments', () => {
expect(buildDocsRoute('modules/platform/architecture-overview.md', 'release-flow'))
.toBe('/docs/modules/platform/architecture-overview.md#release-flow');
});
it('normalizes already-encoded full doc paths', () => {
expect(normalizeDocsPath('/docs/modules%2Fplatform%2Farchitecture-overview.md'))
.toBe('modules/platform/architecture-overview.md');
});
it('falls back to the docs index when the requested slug is empty', () => {
expect(buildDocsAssetCandidates('/docs')).toEqual(['/docs-content/README.md']);
});
it('tries markdown and README fallbacks for friendly slugs', () => {
expect(buildDocsAssetCandidates('getting-started')).toEqual([
'/docs-content/getting-started.md',
'/docs-content/getting-started/README.md',
'/docs-content/README.md',
]);
});
it('parses full docs urls into path and anchor', () => {
expect(parseDocsUrl('https://stella-ops.local/docs/modules/platform/architecture-overview.md#release-flow')).toEqual({
path: 'modules/platform/architecture-overview.md',
anchor: 'release-flow',
});
});
it('resolves relative markdown links against the current document path', () => {
expect(resolveDocsLink('../signals/architecture.md#telemetry', 'modules/platform/architecture-overview.md'))
.toBe('/docs/modules/signals/architecture.md#telemetry');
});
});

View File

@@ -0,0 +1,149 @@
const DOCS_ROUTE_PREFIX = '/docs';
const DOCS_ASSET_PREFIX = '/docs-content';
const DEFAULT_DOCS_PATH = 'README.md';
export interface ParsedDocsUrl {
path: string;
anchor: string | null;
}
function safeDecode(value: string): string {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function hasExplicitExtension(path: string): boolean {
const lastSegment = path.split('/').at(-1) ?? '';
return /\.[a-z0-9]+$/i.test(lastSegment);
}
export function normalizeDocsAnchor(anchor: string | null | undefined): string | null {
const normalized = safeDecode((anchor ?? '').trim()).replace(/^#+/, '').trim();
return normalized.length > 0 ? normalized : null;
}
export function normalizeDocsPath(path: string | null | undefined): string {
const normalized = safeDecode((path ?? '').trim())
.replace(/^https?:\/\/[^/]+/i, '')
.split('#', 1)[0]
.split('?', 1)[0]
.replace(/\\/g, '/')
.replace(/^\/?docs\/?/, '')
.replace(/^\/+/, '')
.replace(/\/{2,}/g, '/')
.trim();
if (!normalized) {
return DEFAULT_DOCS_PATH;
}
if (normalized.endsWith('/')) {
return `${normalized}README.md`;
}
return normalized;
}
export function encodeDocsPath(path: string | null | undefined): string {
return normalizeDocsPath(path)
.split('/')
.map((segment) => encodeURIComponent(segment))
.join('/');
}
export function buildDocsRoute(path: string | null | undefined, anchor?: string | null): string {
const route = `${DOCS_ROUTE_PREFIX}/${encodeDocsPath(path)}`;
const normalizedAnchor = normalizeDocsAnchor(anchor);
return normalizedAnchor ? `${route}#${encodeURIComponent(normalizedAnchor)}` : route;
}
export function buildDocsAssetCandidates(path: string | null | undefined): string[] {
const normalizedPath = normalizeDocsPath(path);
const candidates = new Set<string>();
const addCandidate = (candidate: string) => {
candidates.add(`${DOCS_ASSET_PREFIX}/${candidate}`);
};
if (hasExplicitExtension(normalizedPath)) {
addCandidate(normalizedPath);
} else {
addCandidate(`${normalizedPath}.md`);
addCandidate(`${normalizedPath}/README.md`);
}
if (normalizedPath !== DEFAULT_DOCS_PATH) {
addCandidate(DEFAULT_DOCS_PATH);
}
return Array.from(candidates);
}
function resolveRelativeDocsPath(targetPath: string, currentDocPath: string): string {
const baseSegments = normalizeDocsPath(currentDocPath).split('/');
if (baseSegments.length > 0) {
baseSegments.pop();
}
for (const segment of targetPath.split('/')) {
const normalizedSegment = safeDecode(segment).trim();
if (!normalizedSegment || normalizedSegment === '.') {
continue;
}
if (normalizedSegment === '..') {
if (baseSegments.length > 0) {
baseSegments.pop();
}
continue;
}
baseSegments.push(normalizedSegment);
}
return baseSegments.join('/') || DEFAULT_DOCS_PATH;
}
export function resolveDocsLink(target: string, currentDocPath: string): string | null {
const normalizedTarget = target.trim();
if (!normalizedTarget) {
return null;
}
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(normalizedTarget)) {
return normalizedTarget;
}
if (normalizedTarget.startsWith('#')) {
const anchor = normalizeDocsAnchor(normalizedTarget);
return anchor ? `#${encodeURIComponent(anchor)}` : null;
}
const [pathPart, anchorPart] = normalizedTarget.split('#', 2);
if (pathPart.startsWith('/docs') || pathPart.startsWith('docs/')) {
return buildDocsRoute(pathPart, anchorPart);
}
if (pathPart.startsWith('/')) {
return buildDocsRoute(pathPart, anchorPart);
}
return buildDocsRoute(resolveRelativeDocsPath(pathPart, currentDocPath), anchorPart);
}
export function parseDocsUrl(url: string): ParsedDocsUrl {
const decodedUrl = safeDecode(url);
const hashIndex = decodedUrl.indexOf('#');
const beforeHash = hashIndex >= 0 ? decodedUrl.slice(0, hashIndex) : decodedUrl;
const rawAnchor = hashIndex >= 0 ? decodedUrl.slice(hashIndex + 1) : '';
const pathWithoutOrigin = beforeHash.replace(/^https?:\/\/[^/]+/i, '');
return {
path: normalizeDocsPath(pathWithoutOrigin),
anchor: normalizeDocsAnchor(rawAnchor),
};
}

View File

@@ -318,11 +318,36 @@ describe('Topology scope-preserving links', () => {
expect(component.selectedRegionId()).toBe('us-east');
expect(component.selectedEnvironmentId()).toBe('stage');
expect(links.find((item) => item.text === 'Open Environment')?.link.queryParams).toEqual({
environment: 'stage',
environments: 'stage',
});
expect(links.find((item) => item.text === 'Open Targets')?.link.queryParams).toEqual({ environment: 'stage' });
expect(links.find((item) => item.text === 'Open Agents')?.link.queryParams).toEqual({ environment: 'stage' });
expect(links.find((item) => item.text === 'Open Runs')?.link.queryParams).toEqual({ environment: 'stage' });
});
it('prefers the explicit route environment over broader hydrated context selections', () => {
const originalSelectedEnvironments = mockContextStore.selectedEnvironments;
mockContextStore.selectedEnvironments = () => ['dev', 'stage'];
try {
configureTestingModule(TopologyRegionsEnvironmentsPageComponent);
routeData$.next({ defaultView: 'region-first' });
queryParamMap$.next(convertToParamMap({ tenant: 'demo-prod', regions: 'us-east', environments: 'stage' }));
const fixture = TestBed.createComponent(TopologyRegionsEnvironmentsPageComponent);
fixture.detectChanges();
fixture.detectChanges();
const component = fixture.componentInstance;
expect(component.selectedRegionId()).toBe('us-east');
expect(component.selectedEnvironmentId()).toBe('stage');
} finally {
mockContextStore.selectedEnvironments = originalSelectedEnvironments;
}
});
it('marks promotion inventory links to merge the active query scope', () => {
configureTestingModule(TopologyPromotionPathsPageComponent);

View File

@@ -755,15 +755,9 @@ export class BrandingEditorComponent implements OnInit {
};
this.brandingService.updateBranding(payload).subscribe({
next: () => {
this.success.set('Branding applied successfully! Refreshing page...');
next: (response) => {
this.success.set(response.message || 'Branding applied successfully.');
this.hasChanges.set(false);
// Reload page after 2 seconds to ensure all components reflect the changes
setTimeout(() => {
window.location.reload();
}, 2000);
this.isSaving.set(false);
},
error: (err) => {

View File

@@ -0,0 +1,531 @@
import { ChangeDetectionStrategy, Component, DestroyRef, inject, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { HttpClient } from '@angular/common/http';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { NavigationEnd, Router } from '@angular/router';
import { filter, firstValueFrom } from 'rxjs';
import {
buildDocsAssetCandidates,
normalizeDocsAnchor,
parseDocsUrl,
resolveDocsLink,
} from '../../core/navigation/docs-route';
interface DocsHeading {
level: number;
text: string;
id: string;
}
interface RenderedDocument {
title: string;
headings: DocsHeading[];
html: string;
}
function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function slugifyHeading(value: string): string {
return value
.trim()
.toLowerCase()
.replace(/[`*_~]/g, '')
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-');
}
function renderInlineMarkdown(value: string, currentDocPath: string): string {
let rendered = escapeHtml(value);
rendered = rendered.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_match, label: string, target: string) => {
const resolvedHref = resolveDocsLink(target, currentDocPath) ?? '#';
const isExternal = /^[a-z][a-z0-9+.-]*:\/\//i.test(resolvedHref);
const attributes = isExternal ? ' target="_blank" rel="noopener"' : '';
return `<a href="${escapeHtml(resolvedHref)}"${attributes}>${escapeHtml(label)}</a>`;
});
rendered = rendered.replace(/`([^`]+)`/g, '<code>$1</code>');
rendered = rendered.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
rendered = rendered.replace(/\*([^*]+)\*/g, '<em>$1</em>');
return rendered;
}
function renderMarkdownDocument(markdown: string, currentDocPath: string): RenderedDocument {
const lines = markdown.replace(/\r\n/g, '\n').split('\n');
const parts: string[] = [];
const headings: DocsHeading[] = [];
let title = 'Documentation';
let index = 0;
while (index < lines.length) {
const line = lines[index];
if (!line.trim()) {
index++;
continue;
}
const codeFenceMatch = line.match(/^```(\w+)?\s*$/);
if (codeFenceMatch) {
const language = codeFenceMatch[1]?.trim() ?? '';
index++;
const codeLines: string[] = [];
while (index < lines.length && !/^```/.test(lines[index])) {
codeLines.push(lines[index]);
index++;
}
if (index < lines.length) {
index++;
}
parts.push(
`<pre class="docs-viewer__code"><code class="language-${escapeHtml(language)}">${escapeHtml(codeLines.join('\n'))}</code></pre>`,
);
continue;
}
const headingMatch = line.match(/^(#{1,6})\s+(.*)$/);
if (headingMatch) {
const level = headingMatch[1].length;
const text = headingMatch[2].trim();
const id = slugifyHeading(text);
if (headings.length === 0 && text) {
title = text;
}
headings.push({ level, text, id });
parts.push(`<h${level} id="${id}">${renderInlineMarkdown(text, currentDocPath)}</h${level}>`);
index++;
continue;
}
if (/^\s*>\s?/.test(line)) {
const quoteLines: string[] = [];
while (index < lines.length && /^\s*>\s?/.test(lines[index])) {
quoteLines.push(lines[index].replace(/^\s*>\s?/, '').trim());
index++;
}
parts.push(
`<blockquote>${quoteLines.map((entry) => `<p>${renderInlineMarkdown(entry, currentDocPath)}</p>`).join('')}</blockquote>`,
);
continue;
}
if (/^\s*[-*]\s+/.test(line)) {
const items: string[] = [];
while (index < lines.length && /^\s*[-*]\s+/.test(lines[index])) {
items.push(lines[index].replace(/^\s*[-*]\s+/, '').trim());
index++;
}
parts.push(`<ul>${items.map((item) => `<li>${renderInlineMarkdown(item, currentDocPath)}</li>`).join('')}</ul>`);
continue;
}
if (/^\s*\d+\.\s+/.test(line)) {
const items: string[] = [];
while (index < lines.length && /^\s*\d+\.\s+/.test(lines[index])) {
items.push(lines[index].replace(/^\s*\d+\.\s+/, '').trim());
index++;
}
parts.push(`<ol>${items.map((item) => `<li>${renderInlineMarkdown(item, currentDocPath)}</li>`).join('')}</ol>`);
continue;
}
if (/^\|/.test(line)) {
const tableLines: string[] = [];
while (index < lines.length && /^\|/.test(lines[index])) {
tableLines.push(lines[index]);
index++;
}
parts.push(`<pre class="docs-viewer__table-fallback">${escapeHtml(tableLines.join('\n'))}</pre>`);
continue;
}
const paragraphLines: string[] = [];
while (
index < lines.length &&
lines[index].trim() &&
!/^(#{1,6})\s+/.test(lines[index]) &&
!/^```/.test(lines[index]) &&
!/^\s*>\s?/.test(lines[index]) &&
!/^\s*[-*]\s+/.test(lines[index]) &&
!/^\s*\d+\.\s+/.test(lines[index]) &&
!/^\|/.test(lines[index])
) {
paragraphLines.push(lines[index].trim());
index++;
}
parts.push(`<p>${renderInlineMarkdown(paragraphLines.join(' '), currentDocPath)}</p>`);
}
if (parts.length === 0) {
parts.push('<p>No rendered documentation content is available for this entry.</p>');
}
return {
title,
headings,
html: parts.join('\n'),
};
}
@Component({
selector: 'app-docs-viewer',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="docs-viewer">
<header class="docs-viewer__header">
<div>
<p class="docs-viewer__eyebrow">Documentation</p>
<h1>{{ title() }}</h1>
<p class="docs-viewer__path">{{ requestedPath() }}</p>
</div>
<div class="docs-viewer__actions">
<a href="/docs/README.md">Docs home</a>
@if (resolvedAssetPath(); as assetPath) {
<a [href]="assetPath" target="_blank" rel="noopener">Open raw</a>
}
</div>
</header>
@if (loading()) {
<div class="docs-viewer__banner">Loading documentation...</div>
} @else if (error(); as errorMessage) {
<div class="docs-viewer__banner docs-viewer__banner--error" role="alert">
<strong>Documentation entry not found.</strong>
<span>{{ errorMessage }}</span>
</div>
}
<div class="docs-viewer__layout">
@if (headings().length > 1) {
<nav class="docs-viewer__toc" aria-label="Documentation outline">
<strong>On this page</strong>
<ul>
@for (heading of headings(); track heading.id) {
<li [class]="'docs-viewer__toc-level-' + heading.level">
<a [href]="'#' + heading.id">{{ heading.text }}</a>
</li>
}
</ul>
</nav>
}
<article class="docs-viewer__content" [innerHTML]="renderedHtml()"></article>
</div>
</section>
`,
styles: [`
.docs-viewer {
display: grid;
gap: 1rem;
max-width: 1200px;
margin: 0 auto;
padding: 1.25rem;
}
.docs-viewer__header {
display: flex;
justify-content: space-between;
gap: 1rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
background: linear-gradient(135deg, rgba(15, 23, 42, 0.03), rgba(191, 219, 254, 0.28));
padding: 1rem 1.1rem;
}
.docs-viewer__eyebrow {
margin: 0 0 0.35rem;
font-size: 0.72rem;
font-weight: var(--font-weight-semibold);
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--color-text-secondary);
}
.docs-viewer__header h1 {
margin: 0;
font-size: 1.6rem;
color: var(--color-text-heading);
}
.docs-viewer__path {
margin: 0.35rem 0 0;
font-size: 0.8rem;
color: var(--color-text-secondary);
font-family: var(--font-mono, monospace);
word-break: break-word;
}
.docs-viewer__actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
align-items: flex-start;
}
.docs-viewer__actions a {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-full);
background: var(--color-surface-primary);
color: var(--color-brand-primary);
text-decoration: none;
padding: 0.3rem 0.65rem;
font-size: 0.78rem;
white-space: nowrap;
}
.docs-viewer__banner {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
color: var(--color-text-secondary);
padding: 0.8rem 0.9rem;
display: grid;
gap: 0.25rem;
}
.docs-viewer__banner--error {
border-color: var(--color-status-error-border);
background: var(--color-status-error-bg);
color: var(--color-status-error-text);
}
.docs-viewer__layout {
display: grid;
grid-template-columns: minmax(0, 220px) minmax(0, 1fr);
gap: 1rem;
align-items: start;
}
.docs-viewer__toc {
position: sticky;
top: 1rem;
display: grid;
gap: 0.55rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
background: var(--color-surface-primary);
padding: 0.9rem;
}
.docs-viewer__toc strong {
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--color-text-secondary);
}
.docs-viewer__toc ul {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.4rem;
}
.docs-viewer__toc li {
margin: 0;
}
.docs-viewer__toc a {
color: var(--color-text-secondary);
text-decoration: none;
font-size: 0.82rem;
line-height: 1.4;
}
.docs-viewer__toc-level-3,
.docs-viewer__toc-level-4,
.docs-viewer__toc-level-5,
.docs-viewer__toc-level-6 {
padding-left: 0.75rem;
}
.docs-viewer__content {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
background: var(--color-surface-primary);
padding: 1.2rem 1.3rem;
min-width: 0;
}
.docs-viewer__content :is(h1, h2, h3, h4, h5, h6) {
color: var(--color-text-heading);
scroll-margin-top: 1rem;
}
.docs-viewer__content h1 {
font-size: 1.7rem;
margin-top: 0;
}
.docs-viewer__content h2 {
font-size: 1.3rem;
margin-top: 1.5rem;
}
.docs-viewer__content h3 {
font-size: 1.05rem;
margin-top: 1.2rem;
}
.docs-viewer__content p,
.docs-viewer__content li,
.docs-viewer__content blockquote {
color: var(--color-text-primary);
line-height: 1.65;
font-size: 0.95rem;
}
.docs-viewer__content ul,
.docs-viewer__content ol {
padding-left: 1.25rem;
}
.docs-viewer__content a {
color: var(--color-brand-primary);
}
.docs-viewer__content code {
font-family: var(--font-mono, monospace);
font-size: 0.88em;
background: var(--color-surface-secondary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-sm);
padding: 0.08rem 0.28rem;
}
.docs-viewer__content pre {
overflow: auto;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-secondary);
padding: 0.9rem;
font-family: var(--font-mono, monospace);
font-size: 0.82rem;
line-height: 1.55;
}
.docs-viewer__content blockquote {
margin: 0;
border-left: 3px solid var(--color-brand-primary);
background: var(--color-brand-primary-10);
padding: 0.2rem 0.9rem;
}
@media (max-width: 960px) {
.docs-viewer__layout {
grid-template-columns: 1fr;
}
.docs-viewer__toc {
position: static;
}
.docs-viewer__header {
flex-direction: column;
}
}
`],
})
export class DocsViewerComponent {
private readonly destroyRef = inject(DestroyRef);
private readonly http = inject(HttpClient);
private readonly router = inject(Router);
private readonly sanitizer = inject(DomSanitizer);
private requestVersion = 0;
readonly title = signal('Documentation');
readonly requestedPath = signal('README.md');
readonly resolvedAssetPath = signal<string | null>(null);
readonly headings = signal<DocsHeading[]>([]);
readonly loading = signal(true);
readonly error = signal<string | null>(null);
readonly renderedHtml = signal<SafeHtml | string>('');
constructor() {
this.loadFromUrl(this.router.url);
this.router.events
.pipe(
filter((event): event is NavigationEnd => event instanceof NavigationEnd),
takeUntilDestroyed(this.destroyRef),
)
.subscribe((event) => {
this.loadFromUrl(event.urlAfterRedirects);
});
}
private loadFromUrl(url: string): void {
const { path, anchor } = parseDocsUrl(url);
this.requestedPath.set(path);
void this.loadDocument(path, anchor);
}
private async loadDocument(path: string, anchor: string | null): Promise<void> {
const requestVersion = ++this.requestVersion;
this.loading.set(true);
this.error.set(null);
this.resolvedAssetPath.set(null);
this.headings.set([]);
for (const candidate of buildDocsAssetCandidates(path)) {
try {
const markdown = await firstValueFrom(this.http.get(candidate, { responseType: 'text' }));
if (requestVersion !== this.requestVersion) {
return;
}
const rendered = renderMarkdownDocument(markdown, path);
this.title.set(rendered.title);
this.headings.set(rendered.headings);
this.resolvedAssetPath.set(candidate);
this.renderedHtml.set(this.sanitizer.bypassSecurityTrustHtml(rendered.html));
this.loading.set(false);
queueMicrotask(() => this.scrollToAnchor(anchor));
return;
} catch {
continue;
}
}
if (requestVersion !== this.requestVersion) {
return;
}
this.title.set('Documentation');
this.renderedHtml.set(this.sanitizer.bypassSecurityTrustHtml(''));
this.error.set(`No documentation asset matched ${path}.`);
this.loading.set(false);
}
private scrollToAnchor(anchor: string | null): void {
const normalizedAnchor = normalizeDocsAnchor(anchor);
if (!normalizedAnchor) {
return;
}
const target =
document.getElementById(normalizedAnchor) ??
document.getElementById(slugifyHeading(normalizedAnchor));
target?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}

View File

@@ -0,0 +1,72 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { of } from 'rxjs';
import { AUTH_SERVICE } from '../../core/auth';
import { ORCHESTRATOR_CONTROL_API } from '../../core/api/jobengine-control.client';
import { JobEngineDashboardComponent } from './jobengine-dashboard.component';
describe('JobEngineDashboardComponent', () => {
function configure(canManageJobEngineQuotas: boolean) {
TestBed.configureTestingModule({
imports: [JobEngineDashboardComponent],
providers: [
provideRouter([]),
{
provide: AUTH_SERVICE,
useValue: {
canViewOrchestrator: () => true,
canOperateOrchestrator: () => true,
canManageJobEngineQuotas: () => canManageJobEngineQuotas,
canInitiateBackfill: () => false,
},
},
{
provide: ORCHESTRATOR_CONTROL_API,
useValue: {
getJobSummary: () => of({
totalJobs: 12,
leasedJobs: 2,
failedJobs: 1,
pendingJobs: 3,
scheduledJobs: 4,
succeededJobs: 5,
}),
getQuotaSummary: () => of({
totalQuotas: 3,
pausedQuotas: 1,
averageTokenUtilization: 0.42,
averageConcurrencyUtilization: 0.33,
}),
getDeadLetterStats: () => of({
totalEntries: 4,
retryableEntries: 2,
replayedEntries: 1,
resolvedEntries: 1,
}),
},
},
],
});
}
it('renders the quotas card as read-only when the user lacks quota scope', () => {
configure(false);
const fixture = TestBed.createComponent(JobEngineDashboardComponent);
fixture.detectChanges();
const quotasCard = fixture.nativeElement.querySelector('[data-testid="jobengine-quotas-card"]') as HTMLElement;
expect(quotasCard.tagName).toBe('ARTICLE');
expect(quotasCard.textContent).toContain('Access required to manage quotas.');
});
it('renders the quotas card as a link when the user can manage quotas', () => {
configure(true);
const fixture = TestBed.createComponent(JobEngineDashboardComponent);
fixture.detectChanges();
const quotasCard = fixture.nativeElement.querySelector('[data-testid="jobengine-quotas-card"]') as HTMLElement;
expect(quotasCard.tagName).toBe('A');
expect(quotasCard.getAttribute('href')).toContain('/ops/operations/jobengine/quotas');
});
});

View File

@@ -78,24 +78,46 @@ import { OPERATIONS_PATHS } from '../platform/ops/operations-paths';
</dl>
</a>
<a class="surface" [routerLink]="OPERATIONS_PATHS.jobEngineQuotas">
<h2>Execution Quotas</h2>
<p>Manage per-job-type concurrency, refill rate, and pause state.</p>
<dl>
<div>
<dt>Average Token Usage</dt>
<dd>{{ quotaPercent(quotaSummary()?.averageTokenUtilization) }}</dd>
</div>
<div>
<dt>Average Concurrency Usage</dt>
<dd>{{ quotaPercent(quotaSummary()?.averageConcurrencyUtilization) }}</dd>
</div>
<div>
<dt>Paused Quotas</dt>
<dd>{{ quotaSummary()?.pausedQuotas ?? 0 }}</dd>
</div>
</dl>
</a>
@if (authService.canManageJobEngineQuotas()) {
<a class="surface" data-testid="jobengine-quotas-card" [routerLink]="OPERATIONS_PATHS.jobEngineQuotas">
<h2>Execution Quotas</h2>
<p>Manage per-job-type concurrency, refill rate, and pause state.</p>
<dl>
<div>
<dt>Average Token Usage</dt>
<dd>{{ quotaPercent(quotaSummary()?.averageTokenUtilization) }}</dd>
</div>
<div>
<dt>Average Concurrency Usage</dt>
<dd>{{ quotaPercent(quotaSummary()?.averageConcurrencyUtilization) }}</dd>
</div>
<div>
<dt>Paused Quotas</dt>
<dd>{{ quotaSummary()?.pausedQuotas ?? 0 }}</dd>
</div>
</dl>
</a>
} @else {
<article class="surface surface--restricted" data-testid="jobengine-quotas-card" aria-disabled="true">
<h2>Execution Quotas</h2>
<p>Quota metrics are visible, but management stays locked until the session has quota-admin scope.</p>
<dl>
<div>
<dt>Average Token Usage</dt>
<dd>{{ quotaPercent(quotaSummary()?.averageTokenUtilization) }}</dd>
</div>
<div>
<dt>Average Concurrency Usage</dt>
<dd>{{ quotaPercent(quotaSummary()?.averageConcurrencyUtilization) }}</dd>
</div>
<div>
<dt>Paused Quotas</dt>
<dd>{{ quotaSummary()?.pausedQuotas ?? 0 }}</dd>
</div>
</dl>
<span class="surface__notice">Access required to manage quotas.</span>
</article>
}
<a class="surface" [routerLink]="OPERATIONS_PATHS.deadLetter">
<h2>Dead-Letter Recovery</h2>
@@ -234,6 +256,10 @@ import { OPERATIONS_PATHS } from '../platform/ops/operations-paths';
color: var(--color-text-heading);
}
.surface--restricted {
cursor: default;
}
.surface p {
margin: 0;
color: var(--color-text-secondary);
@@ -263,6 +289,15 @@ import { OPERATIONS_PATHS } from '../platform/ops/operations-paths';
font-weight: var(--font-weight-semibold);
}
.surface__notice {
display: inline-flex;
align-items: center;
gap: 0.35rem;
color: var(--color-status-warning-border);
font-size: 0.8rem;
font-weight: var(--font-weight-medium);
}
.jobengine-dashboard__access ul {
margin: 0;
padding-left: 1.2rem;

View File

@@ -1,18 +1,21 @@
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { RouterLink } from '@angular/router';
import { ExportCenterComponent } from '../evidence-export/export-center.component';
import { TriageWorkspaceComponent } from '../triage/triage-workspace.component';
import { SecurityDispositionPageComponent } from './security-disposition-page.component';
type ReportTab = 'risk' | 'vex' | 'evidence';
@Component({
selector: 'app-security-reports-page',
standalone: true,
imports: [RouterLink],
imports: [TriageWorkspaceComponent, SecurityDispositionPageComponent, ExportCenterComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="security-reports">
<header>
<h1>Security Reports</h1>
<p>Export posture snapshots, waiver ledgers, and evidence-linked risk summaries.</p>
<p>Review risk posture, VEX decisions, and evidence exports without leaving the reports workspace.</p>
</header>
<nav class="tabs" role="tablist">
@@ -42,31 +45,19 @@ type ReportTab = 'risk' | 'vex' | 'evidence';
<div class="tab-content" role="tabpanel">
@switch (activeTab()) {
@case ('risk') {
<article class="report-card">
<h2>Risk Report</h2>
<p>Aggregated risk posture across all scanned artifacts, environments, and triage dispositions.</p>
<div class="report-actions">
<a routerLink="/security/triage" class="btn btn--primary">View Full Triage</a>
</div>
</article>
<section class="report-panel">
<app-triage-workspace></app-triage-workspace>
</section>
}
@case ('vex') {
<article class="report-card">
<h2>VEX and Waiver Ledger</h2>
<p>Active VEX statements, exception waivers, and disposition history with expiration tracking.</p>
<div class="report-actions">
<a routerLink="/security/disposition" class="btn btn--primary">View Dispositions</a>
</div>
</article>
<section class="report-panel">
<app-security-disposition-page></app-security-disposition-page>
</section>
}
@case ('evidence') {
<article class="report-card">
<h2>Evidence Export Bundle</h2>
<p>Export signed evidence bundles for audit, compliance, and offline verification workflows.</p>
<div class="report-actions">
<a routerLink="/evidence/exports" class="btn btn--primary">Open Export Center</a>
</div>
</article>
<section class="report-panel">
<app-export-center></app-export-center>
</section>
}
}
</div>
@@ -105,49 +96,11 @@ type ReportTab = 'risk' | 'vex' | 'evidence';
border-bottom-color: var(--color-brand-primary);
}
.report-card {
padding: 1rem;
.report-panel {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
}
.report-card h2 {
margin: 0 0 0.35rem;
font-size: 1rem;
}
.report-card p {
margin: 0 0 0.75rem;
color: var(--color-text-secondary);
font-size: 0.82rem;
line-height: 1.5;
}
.report-actions {
display: flex;
gap: 0.5rem;
}
.btn {
display: inline-flex;
align-items: center;
padding: 0.4rem 0.75rem;
border-radius: var(--radius-md);
font-size: 0.78rem;
font-weight: 500;
text-decoration: none;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.btn--primary {
background: var(--color-brand-primary);
color: #fff;
}
.btn--primary:hover {
opacity: 0.9;
overflow: hidden;
}
`,
],

View File

@@ -43,6 +43,9 @@ import {
@if (error()) {
<div class="error-banner">{{ error() }}</div>
}
@if (successMessage()) {
<div class="success-banner">{{ successMessage() }}</div>
}
<div class="admin-content">
@switch (activeTab()) {
@@ -71,9 +74,11 @@ import {
<div class="form-field">
<label class="form-label">Role</label>
<select class="form-input" #addUserRole>
<option value="viewer">Viewer</option>
<option value="operator">Operator</option>
<option value="admin">Admin</option>
@for (role of availableUserRoles(); track role.id) {
<option [value]="role.name">{{ role.name }}</option>
} @empty {
<option value="admin">admin</option>
}
</select>
</div>
</div>
@@ -96,7 +101,7 @@ import {
<th>Email</th>
<th>Role</th>
<th>Status</th>
<th>Actions</th>
<th>Directory</th>
</tr>
</thead>
<tbody>
@@ -106,7 +111,7 @@ import {
<td>{{ user.email }}</td>
<td>{{ user.roles.join(', ') }}</td>
<td><span class="badge" [class]="'badge--' + user.status">{{ user.status }}</span></td>
<td><button class="btn btn--sm">Edit</button></td>
<td class="muted-cell">Managed by Authority directory</td>
</tr>
} @empty {
<tr><td colspan="5" class="empty-cell">No users found</td></tr>
@@ -122,6 +127,34 @@ import {
<h2>Roles</h2>
<button type="button" class="btn btn--primary" (click)="showAddForm('roles')">+ Create Role</button>
</div>
@if (addFormVisible() === 'roles') {
<div class="add-form">
<h3 class="add-form__title">New Role</h3>
<div class="add-form__fields">
<div class="form-field">
<label class="form-label">Role Name</label>
<input type="text" class="form-input" placeholder="security-analyst" #addRoleName />
</div>
<div class="form-field">
<label class="form-label">Description</label>
<input type="text" class="form-input" placeholder="Security analyst with triage access" #addRoleDescription />
</div>
<div class="form-field form-field--full">
<label class="form-label">Permissions</label>
<textarea class="form-input form-input--textarea" placeholder="findings:read, vex:read, vuln:investigate" #addRolePermissions></textarea>
</div>
</div>
<div class="add-form__actions">
<button type="button" class="btn btn--secondary" (click)="hideAddForm()">Cancel</button>
<button
type="button"
class="btn btn--primary"
(click)="createRole(addRoleName.value, addRoleDescription.value, addRolePermissions.value)">
Create Role
</button>
</div>
</div>
}
@if (loading()) {
<p class="loading-text">Loading roles...</p>
} @else {
@@ -132,7 +165,6 @@ import {
<th>Description</th>
<th>Users</th>
<th>Built-in</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@@ -142,10 +174,9 @@ import {
<td>{{ role.description }}</td>
<td>{{ role.userCount }}</td>
<td>{{ role.isBuiltIn ? 'Yes' : 'No' }}</td>
<td><button class="btn btn--sm" [disabled]="role.isBuiltIn">Edit</button></td>
</tr>
} @empty {
<tr><td colspan="5" class="empty-cell">No roles found</td></tr>
<tr><td colspan="4" class="empty-cell">No roles found</td></tr>
}
</tbody>
</table>
@@ -156,8 +187,8 @@ import {
<div class="content-section">
<div class="section-header">
<h2>OAuth Clients</h2>
<button type="button" class="btn btn--primary" (click)="showAddForm('clients')">+ Register Client</button>
</div>
<p class="section-note">OAuth clients are visible here, but registration and secret rotation remain outside this setup tab until the full guided flow is shipped.</p>
@if (loading()) {
<p class="loading-text">Loading clients...</p>
} @else {
@@ -167,8 +198,8 @@ import {
<th>Client ID</th>
<th>Display Name</th>
<th>Grant Types</th>
<th>Tenant Scope</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@@ -177,8 +208,8 @@ import {
<td><code>{{ client.clientId }}</code></td>
<td>{{ client.displayName }}</td>
<td>{{ client.grantTypes.join(', ') }}</td>
<td>{{ describeClientTenants(client) }}</td>
<td><span class="badge" [class]="'badge--' + client.status">{{ client.status }}</span></td>
<td><button class="btn btn--sm">Edit</button></td>
</tr>
} @empty {
<tr><td colspan="5" class="empty-cell">No OAuth clients found</td></tr>
@@ -192,8 +223,8 @@ import {
<div class="content-section">
<div class="section-header">
<h2>API Tokens</h2>
<button type="button" class="btn btn--primary" (click)="showAddForm('tokens')">+ Generate Token</button>
</div>
<p class="section-note">Token issuance and revocation are not exposed on this setup route yet, so this view is intentionally read-only.</p>
@if (loading()) {
<p class="loading-text">Loading tokens...</p>
} @else {
@@ -205,7 +236,6 @@ import {
<th>Scopes</th>
<th>Expires</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@@ -216,10 +246,9 @@ import {
<td>{{ token.scopes.join(', ') }}</td>
<td>{{ token.expiresAt }}</td>
<td><span class="badge" [class]="'badge--' + token.status">{{ token.status }}</span></td>
<td><button class="btn btn--sm">Revoke</button></td>
</tr>
} @empty {
<tr><td colspan="6" class="empty-cell">No API tokens found</td></tr>
<tr><td colspan="5" class="empty-cell">No API tokens found</td></tr>
}
</tbody>
</table>
@@ -232,6 +261,37 @@ import {
<h2>Tenants</h2>
<button type="button" class="btn btn--primary" (click)="showAddForm('tenants')">+ Add Tenant</button>
</div>
@if (addFormVisible() === 'tenants') {
<div class="add-form">
<h3 class="add-form__title">New Tenant</h3>
<div class="add-form__fields">
<div class="form-field">
<label class="form-label">Tenant ID</label>
<input type="text" class="form-input" placeholder="customer-stage" #addTenantId />
</div>
<div class="form-field">
<label class="form-label">Display Name</label>
<input type="text" class="form-input" placeholder="Customer Stage" #addTenantDisplayName />
</div>
<div class="form-field">
<label class="form-label">Isolation Mode</label>
<select class="form-input" #addTenantIsolation>
<option value="shared">Shared</option>
<option value="dedicated">Dedicated</option>
</select>
</div>
</div>
<div class="add-form__actions">
<button type="button" class="btn btn--secondary" (click)="hideAddForm()">Cancel</button>
<button
type="button"
class="btn btn--primary"
(click)="createTenant(addTenantId.value, addTenantDisplayName.value, addTenantIsolation.value)">
Create Tenant
</button>
</div>
</div>
}
@if (loading()) {
<p class="loading-text">Loading tenants...</p>
} @else {
@@ -242,7 +302,7 @@ import {
<th>Status</th>
<th>Isolation</th>
<th>Users</th>
<th>Actions</th>
<th>Lifecycle</th>
</tr>
</thead>
<tbody>
@@ -252,7 +312,7 @@ import {
<td><span class="badge" [class]="'badge--' + tenant.status">{{ tenant.status }}</span></td>
<td>{{ tenant.isolationMode }}</td>
<td>{{ tenant.userCount }}</td>
<td><button class="btn btn--sm">Edit</button></td>
<td class="muted-cell">Branding and policies are managed from the canonical setup surfaces.</td>
</tr>
} @empty {
<tr><td colspan="5" class="empty-cell">No tenants found</td></tr>
@@ -336,7 +396,13 @@ import {
.btn--sm { padding: 0.25rem 0.5rem; font-size: 0.75rem; background: var(--color-surface-secondary); border: 1px solid var(--color-border-primary); }
.btn--sm:disabled { opacity: 0.5; cursor: not-allowed; }
.loading-text { color: var(--color-text-secondary); font-size: 0.875rem; }
.section-note {
margin: 0 0 1rem;
color: var(--color-text-secondary);
font-size: 0.8125rem;
}
.empty-cell { text-align: center; color: var(--color-text-secondary); padding: 2rem !important; }
.muted-cell { color: var(--color-text-secondary); font-size: 0.8125rem; }
.error-banner {
padding: 1rem;
margin-bottom: 1rem;
@@ -367,6 +433,7 @@ import {
.add-form__title { margin: 0 0 1rem; font-size: 1rem; font-weight: var(--font-weight-semibold); }
.add-form__fields { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; margin-bottom: 1rem; }
.form-field { display: flex; flex-direction: column; gap: 0.25rem; }
.form-field--full { grid-column: 1 / -1; }
.form-label { font-size: 0.75rem; font-weight: var(--font-weight-medium); color: var(--color-text-secondary); text-transform: uppercase; }
.form-input {
padding: 0.5rem 0.75rem;
@@ -376,6 +443,7 @@ import {
background: var(--color-surface-primary);
color: var(--color-text-primary);
}
.form-input--textarea { min-height: 6rem; resize: vertical; }
.form-input:focus { outline: none; border-color: var(--color-brand-primary); box-shadow: 0 0 0 2px rgba(245,166,35,0.15); }
.add-form__actions { display: flex; justify-content: flex-end; gap: 0.5rem; }
.btn--secondary {
@@ -401,6 +469,8 @@ import {
})
export class AdminSettingsPageComponent implements OnInit {
private readonly api = inject(AUTHORITY_ADMIN_API);
private static readonly emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
private static readonly tenantIdPattern = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
tabs = [
{ id: 'users', label: 'Users' },
@@ -424,6 +494,7 @@ export class AdminSettingsPageComponent implements OnInit {
ngOnInit(): void {
this.loadTab('users');
this.ensureRolesLoaded();
}
setTab(tabId: string): void {
@@ -434,6 +505,12 @@ export class AdminSettingsPageComponent implements OnInit {
showAddForm(formId: string): void {
this.addFormVisible.set(this.addFormVisible() === formId ? null : formId);
if (formId === 'users') {
this.ensureRolesLoaded();
}
if (this.addFormVisible()) {
this.error.set(null);
}
this.successMessage.set(null);
}
@@ -446,22 +523,138 @@ export class AdminSettingsPageComponent implements OnInit {
this.error.set('Username and email are required.');
return;
}
if (!AdminSettingsPageComponent.emailPattern.test(email.trim())) {
this.error.set('Enter a valid email address before creating the user.');
return;
}
this.error.set(null);
this.loading.set(true);
this.api.createUser({ username: username.trim(), email: email.trim(), displayName: displayName.trim(), roles: [role] }).pipe(
catchError((err) => {
this.error.set('Failed to create user. The backend may be unavailable.');
this.error.set(err?.status === 403
? 'Your current session is missing the write scopes required to create users.'
: 'Failed to create user. The backend may be unavailable.');
return of(null);
})
).subscribe((result) => {
this.loading.set(false);
if (result) {
this.addFormVisible.set(null);
this.successMessage.set(`Created user ${result.username}.`);
this.loadTab('users');
}
});
}
createRole(name: string, description: string, permissionsText: string): void {
const normalizedName = name.trim().toLowerCase();
const normalizedDescription = description.trim();
const permissions = permissionsText
.split(/[,\n]/)
.map((value) => value.trim())
.filter((value) => value.length > 0);
if (!normalizedName) {
this.error.set('Role name is required.');
return;
}
if (!normalizedDescription) {
this.error.set('Role description is required.');
return;
}
if (permissions.length === 0) {
this.error.set('At least one permission is required.');
return;
}
this.error.set(null);
this.loading.set(true);
this.api.createRole({
name: normalizedName,
description: normalizedDescription,
permissions,
}).pipe(
catchError((err) => {
this.error.set(err?.status === 403
? 'Your current session is missing the write scopes required to create roles.'
: 'Failed to create role.');
return of(null);
})
).subscribe((result) => {
this.loading.set(false);
if (result) {
this.addFormVisible.set(null);
this.successMessage.set(`Created role ${result.name}.`);
this.roles.update((roles) => [...roles, result].sort((left, right) => left.name.localeCompare(right.name)));
if (this.activeTab() === 'roles') {
this.loadTab('roles');
}
}
});
}
createTenant(id: string, displayName: string, isolationMode: string): void {
const normalizedId = id.trim().toLowerCase();
const normalizedDisplayName = displayName.trim();
if (!normalizedId) {
this.error.set('Tenant ID is required.');
return;
}
if (!AdminSettingsPageComponent.tenantIdPattern.test(normalizedId)) {
this.error.set('Tenant ID must use lowercase letters, digits, and hyphens only.');
return;
}
if (!normalizedDisplayName) {
this.error.set('Tenant display name is required.');
return;
}
this.error.set(null);
this.loading.set(true);
this.api.createTenant({
id: normalizedId,
displayName: normalizedDisplayName,
isolationMode: isolationMode === 'dedicated' ? 'dedicated' : 'shared',
}).pipe(
catchError((err) => {
this.error.set(err?.status === 403
? 'Your current session is missing the write scopes required to create tenants.'
: 'Failed to create tenant.');
return of(null);
})
).subscribe((result) => {
this.loading.set(false);
if (result) {
this.addFormVisible.set(null);
this.successMessage.set(`Created tenant ${result.displayName}.`);
this.loadTab('tenants');
}
});
}
availableUserRoles(): AdminRole[] {
return this.roles().length > 0
? this.roles()
: [{ id: 'admin', name: 'admin', description: 'Administrator', permissions: [], userCount: 0, isBuiltIn: true }];
}
describeClientTenants(client: AdminClient): string {
if (client.defaultTenant) {
return client.defaultTenant;
}
if (client.tenants && client.tenants.length > 0) {
return client.tenants.join(', ');
}
return 'All tenants';
}
private loadTab(tabId: string): void {
this.loading.set(true);
this.error.set(null);
@@ -494,4 +687,14 @@ export class AdminSettingsPageComponent implements OnInit {
})
).subscribe(() => this.loading.set(false));
}
private ensureRolesLoaded(): void {
if (this.roles().length > 0) {
return;
}
this.api.listRoles().pipe(
catchError(() => of([]))
).subscribe((roles) => this.roles.set(roles));
}
}

View File

@@ -92,7 +92,11 @@ type RegionsView = 'region-first' | 'flat' | 'graph';
<td>{{ environmentHealthLabel(env.environmentId) }}</td>
<td>{{ env.targetCount }}</td>
<td>
<a [routerLink]="['/setup/topology/environments', env.environmentId, 'posture']" queryParamsHandling="merge">Open</a>
<a
[routerLink]="['/setup/topology/environments', env.environmentId, 'posture']"
[queryParams]="{ environment: env.environmentId, environments: env.environmentId }"
queryParamsHandling="merge"
>Open</a>
</td>
</tr>
} @empty {
@@ -126,7 +130,13 @@ type RegionsView = 'region-first' | 'flat' | 'graph';
<td>{{ env.environmentType }}</td>
<td>{{ environmentHealthLabel(env.environmentId) }}</td>
<td>{{ env.targetCount }}</td>
<td><a [routerLink]="['/setup/topology/environments', env.environmentId, 'posture']" queryParamsHandling="merge">Open</a></td>
<td>
<a
[routerLink]="['/setup/topology/environments', env.environmentId, 'posture']"
[queryParams]="{ environment: env.environmentId, environments: env.environmentId }"
queryParamsHandling="merge"
>Open</a>
</td>
</tr>
} @empty {
<tr>
@@ -158,7 +168,11 @@ type RegionsView = 'region-first' | 'flat' | 'graph';
· targets {{ selectedEnvironmentTargetCount() }}
</p>
<div class="actions">
<a [routerLink]="['/setup/topology/environments', selectedEnvironmentId(), 'posture']" queryParamsHandling="merge">Open Environment</a>
<a
[routerLink]="['/setup/topology/environments', selectedEnvironmentId(), 'posture']"
[queryParams]="{ environment: selectedEnvironmentId(), environments: selectedEnvironmentId() }"
queryParamsHandling="merge"
>Open Environment</a>
<a [routerLink]="['/setup/topology/targets']" [queryParams]="{ environment: selectedEnvironmentId() }" queryParamsHandling="merge">Open Targets</a>
<a [routerLink]="['/setup/topology/agents']" [queryParams]="{ environment: selectedEnvironmentId() }" queryParamsHandling="merge">Open Agents</a>
<a [routerLink]="['/releases/runs']" [queryParams]="{ environment: selectedEnvironmentId() }" queryParamsHandling="merge">Open Runs</a>
@@ -395,6 +409,7 @@ export class TopologyRegionsEnvironmentsPageComponent {
readonly error = signal<string | null>(null);
readonly searchQuery = signal('');
readonly viewMode = signal<RegionsView>('region-first');
readonly requestedEnvironmentId = signal('');
readonly selectedRegionId = signal('');
readonly selectedEnvironmentId = signal('');
@@ -475,8 +490,13 @@ export class TopologyRegionsEnvironmentsPageComponent {
this.viewMode.set(defaultView);
});
this.route.queryParamMap.subscribe((queryParamMap) => {
this.requestedEnvironmentId.set(this.resolveRequestedEnvironmentId(queryParamMap));
});
effect(() => {
this.context.contextVersion();
this.requestedEnvironmentId();
this.load();
});
}
@@ -558,9 +578,13 @@ export class TopologyRegionsEnvironmentsPageComponent {
const current = this.selectedRegionId();
const scopedEnvironments = this.context.selectedEnvironments();
const scopedRegions = this.context.selectedRegions();
const requestedEnvironmentId = this.requestedEnvironmentId();
const regionFromRequestedEnvironment =
environments.find((item) => item.environmentId === requestedEnvironmentId)?.regionId ?? '';
const regionFromScopedEnvironment = environments.find((item) => scopedEnvironments.includes(item.environmentId))?.regionId ?? '';
const preferredScopedRegion =
regionFromScopedEnvironment
regionFromRequestedEnvironment
|| regionFromScopedEnvironment
|| scopedRegions.find((regionId) => regions.some((item) => item.regionId === regionId))
|| '';
@@ -581,9 +605,18 @@ export class TopologyRegionsEnvironmentsPageComponent {
): string {
const current = this.selectedEnvironmentId();
const scopedEnvironments = this.context.selectedEnvironments();
const requestedEnvironmentId = this.requestedEnvironmentId();
const environmentsInRegion = selectedRegionId
? environments.filter((item) => item.regionId === selectedRegionId)
: environments;
const preferredRequestedEnvironment =
environmentsInRegion.find((item) => item.environmentId === requestedEnvironmentId)?.environmentId
?? environments.find((item) => item.environmentId === requestedEnvironmentId)?.environmentId
?? '';
if (preferredRequestedEnvironment) {
return preferredRequestedEnvironment;
}
const preferredScopedEnvironment =
scopedEnvironments.find((environmentId) =>
@@ -604,6 +637,23 @@ export class TopologyRegionsEnvironmentsPageComponent {
return environmentsInRegion[0]?.environmentId ?? environments[0]?.environmentId ?? '';
}
private resolveRequestedEnvironmentId(queryParamMap: Pick<import('@angular/router').ParamMap, 'get'>): string {
const explicitEnvironment = queryParamMap.get('environment')?.trim();
if (explicitEnvironment) {
return explicitEnvironment;
}
const environmentsValue = queryParamMap.get('environments')?.trim();
if (!environmentsValue) {
return '';
}
return environmentsValue
.split(',')
.map((value) => value.trim())
.find((value) => value.length > 0) ?? '';
}
}

View File

@@ -105,6 +105,13 @@ describe('GatedBucketsComponent', () => {
expect(chip.textContent).toContain('+23');
});
it('renders bucket icons as SVG markup instead of escaped text', () => {
const compiled = fixture.nativeElement;
const icon = compiled.querySelector('.bucket-chip.unreachable .icon');
expect(icon.innerHTML).toContain('<svg');
expect(icon.textContent?.trim()).not.toContain('<svg');
});
it('should render policy-dismissed chip', () => {
const compiled = fixture.nativeElement;
const chip = compiled.querySelector('.bucket-chip.policy-dismissed');

View File

@@ -41,7 +41,7 @@ export interface BucketExpandEvent {
(click)="toggleBucket('unreachable')"
[attr.aria-expanded]="expandedBucket() === 'unreachable'"
attr.aria-label="Show {{ unreachableCount() }} unreachable findings">
<span class="icon">{{ getIcon('unreachable') }}</span>
<span class="icon" [innerHTML]="getIcon('unreachable')"></span>
<span class="count">+{{ unreachableCount() }}</span>
<span class="label">unreachable</span>
</button>
@@ -53,7 +53,7 @@ export interface BucketExpandEvent {
(click)="toggleBucket('policy_dismissed')"
[attr.aria-expanded]="expandedBucket() === 'policy_dismissed'"
attr.aria-label="Show {{ policyDismissedCount() }} policy-dismissed findings">
<span class="icon">{{ getIcon('policy_dismissed') }}</span>
<span class="icon" [innerHTML]="getIcon('policy_dismissed')"></span>
<span class="count">+{{ policyDismissedCount() }}</span>
<span class="label">policy</span>
</button>
@@ -65,7 +65,7 @@ export interface BucketExpandEvent {
(click)="toggleBucket('backported')"
[attr.aria-expanded]="expandedBucket() === 'backported'"
attr.aria-label="Show {{ backportedCount() }} backported findings">
<span class="icon">{{ getIcon('backported') }}</span>
<span class="icon" [innerHTML]="getIcon('backported')"></span>
<span class="count">+{{ backportedCount() }}</span>
<span class="label">backported</span>
</button>
@@ -77,7 +77,7 @@ export interface BucketExpandEvent {
(click)="toggleBucket('vex_not_affected')"
[attr.aria-expanded]="expandedBucket() === 'vex_not_affected'"
attr.aria-label="Show {{ vexNotAffectedCount() }} VEX not-affected findings">
<span class="icon">{{ getIcon('vex_not_affected') }}</span>
<span class="icon" [innerHTML]="getIcon('vex_not_affected')"></span>
<span class="count">+{{ vexNotAffectedCount() }}</span>
<span class="label">VEX</span>
</button>
@@ -89,7 +89,7 @@ export interface BucketExpandEvent {
(click)="toggleBucket('superseded')"
[attr.aria-expanded]="expandedBucket() === 'superseded'"
attr.aria-label="Show {{ supersededCount() }} superseded findings">
<span class="icon">{{ getIcon('superseded') }}</span>
<span class="icon" [innerHTML]="getIcon('superseded')"></span>
<span class="count">+{{ supersededCount() }}</span>
<span class="label">superseded</span>
</button>
@@ -101,7 +101,7 @@ export interface BucketExpandEvent {
(click)="toggleBucket('user_muted')"
[attr.aria-expanded]="expandedBucket() === 'user_muted'"
attr.aria-label="Show {{ userMutedCount() }} user-muted findings">
<span class="icon">{{ getIcon('user_muted') }}</span>
<span class="icon" [innerHTML]="getIcon('user_muted')"></span>
<span class="count">+{{ userMutedCount() }}</span>
<span class="label">muted</span>
</button>
@@ -190,6 +190,9 @@ export interface BucketExpandEvent {
}
.bucket-chip .icon {
display: inline-flex;
align-items: center;
justify-content: center;
font-size: var(--font-size-sm);
}

View File

@@ -381,7 +381,7 @@ export function getGatingReasonClass(reason: GatingReason): string {
* Format trust score for display.
*/
export function formatTrustScore(score?: number): string {
if (score === undefined || score === null) return '';
if (score === undefined || score === null) return '--';
return (score * 100).toFixed(0) + '%';
}

View File

@@ -1,738 +1,230 @@
/**
* @file issuer-trust-list.component.ts
* @sprint SPRINT_20251229_018c_FE
* @description Trusted issuers list with trust scores and management
* @description Live trusted issuer inventory aligned to the administration trust API
*/
import { Component, ChangeDetectionStrategy, signal, computed, inject, OnInit } from '@angular/core';
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { TRUST_API, TrustApi } from '../../core/api/trust.client';
import {
TrustedIssuer,
IssuerType,
IssuerTrustLevel,
ListIssuersParams,
} from '../../core/api/trust.models';
import { TrustScoreConfigComponent } from './trust-score-config.component';
import { TrustedIssuer, IssuerTrustLevel } from '../../core/api/trust.models';
@Component({
selector: 'app-issuer-trust-list',
imports: [CommonModule, FormsModule, TrustScoreConfigComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="issuer-list">
<!-- Filters -->
<div class="issuer-list__filters">
<div class="filter-group">
<label for="search">Search</label>
<input
id="search"
type="text"
placeholder="Search issuers..."
[ngModel]="searchQuery()"
(ngModelChange)="searchQuery.set($event); onSearch()"
/>
selector: 'app-issuer-trust-list',
imports: [CommonModule, FormsModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="issuer-list">
<header class="issuer-list__header">
<div>
<h2>Trusted Issuers</h2>
<p>
This view is bound to the live administration contract: issuer name, issuer URI, trust level, status, and update ownership.
</p>
</div>
<button type="button" class="btn-secondary" (click)="loadIssuers()" [disabled]="loading()">
{{ loading() ? 'Refreshing...' : 'Refresh' }}
</button>
</header>
<div class="filter-group">
<label for="trustLevel">Trust Level</label>
<select
id="trustLevel"
[ngModel]="selectedTrustLevel()"
(ngModelChange)="selectedTrustLevel.set($event); onFilterChange()"
>
<option value="all">All Levels</option>
<option value="full">Full Trust</option>
<option value="partial">Partial Trust</option>
<option value="minimal">Minimal Trust</option>
<div class="issuer-list__filters">
<label class="filter-field">
<span>Search</span>
<input
type="text"
[ngModel]="searchQuery()"
(ngModelChange)="searchQuery.set($event); applyFilters()"
placeholder="Search issuer name or URI"
/>
</label>
<label class="filter-field">
<span>Trust Level</span>
<select [ngModel]="selectedTrustLevel()" (ngModelChange)="selectedTrustLevel.set($event); applyFilters()">
<option value="all">All trust levels</option>
<option value="full">Full</option>
<option value="partial">Partial</option>
<option value="minimal">Minimal</option>
<option value="untrusted">Untrusted</option>
<option value="blocked">Blocked</option>
</select>
</div>
<div class="filter-group">
<label for="issuerType">Type</label>
<select
id="issuerType"
[ngModel]="selectedType()"
(ngModelChange)="selectedType.set($event); onFilterChange()"
>
<option value="all">All Types</option>
<option value="csaf_publisher">CSAF Publisher</option>
<option value="vex_issuer">VEX Issuer</option>
<option value="sbom_producer">SBOM Producer</option>
<option value="attestation_authority">Attestation Authority</option>
</select>
</div>
<button
type="button"
class="btn-config"
(click)="showConfig.set(!showConfig())"
>
Configure Scoring
</button>
</label>
@if (hasFilters()) {
<button type="button" class="btn-link" (click)="clearFilters()">
Clear filters
</button>
<button type="button" class="btn-link" (click)="clearFilters()">Clear filters</button>
}
</div>
<!-- Trust Score Config Panel -->
@if (showConfig()) {
<app-trust-score-config
[selectedIssuer]="selectedIssuer()"
(configSaved)="onConfigSaved()"
(close)="showConfig.set(false)"
></app-trust-score-config>
}
<!-- Summary Stats -->
<div class="issuer-list__stats">
<div class="stat">
<span class="stat__value">{{ issuers().length }}</span>
<span class="stat__label">Total Issuers</span>
</div>
<div class="stat">
<span class="stat__value stat__value--full">{{ countByLevel('full') }}</span>
<span class="stat__label">Full Trust</span>
</div>
<div class="stat">
<span class="stat__value stat__value--partial">{{ countByLevel('partial') }}</span>
<span class="stat__label">Partial</span>
</div>
<div class="stat">
<span class="stat__value stat__value--blocked">{{ countByLevel('blocked') }}</span>
<span class="stat__label">Blocked</span>
</div>
<div class="stat">
<span class="stat__value">{{ averageScore() | number:'1.1-1' }}</span>
<span class="stat__label">Avg Score</span>
</div>
<div class="contract-note">
Score tuning, issuer blocking, and document-volume analytics are not exposed by the current backend contract, so they are intentionally omitted here.
</div>
<!-- Issuers Table -->
<div class="issuer-list__table-container">
@if (loading()) {
<div class="issuer-list__loading">Loading issuers...</div>
} @else if (error()) {
<div class="issuer-list__error">{{ error() }}</div>
} @else if (issuers().length === 0) {
<div class="issuer-list__empty">
No issuers found.
@if (hasFilters()) {
<button type="button" class="btn-link" (click)="clearFilters()">
Clear filters
</button>
}
</div>
} @else {
<table class="issuer-table">
<thead>
@if (loading()) {
<div class="state state--loading">Loading issuers...</div>
} @else if (error()) {
<div class="state state--error">{{ error() }}</div>
} @else if (issuers().length === 0) {
<div class="state">No issuers found.</div>
} @else {
<table class="issuer-table">
<thead>
<tr>
<th>Name</th>
<th>Issuer URI</th>
<th>Trust Level</th>
<th>Status</th>
<th>Created</th>
<th>Updated</th>
<th>Updated By</th>
</tr>
</thead>
<tbody>
@for (issuer of issuers(); track issuer.issuerId) {
<tr>
<th class="sortable" (click)="onSort('name')">
Issuer
@if (sortBy() === 'name') {
<span class="sort-indicator">{{ sortDirection() === 'asc' ? '+' : '-' }}</span>
}
</th>
<th>Type</th>
<th class="sortable" (click)="onSort('trustScore')">
Score
@if (sortBy() === 'trustScore') {
<span class="sort-indicator">{{ sortDirection() === 'asc' ? '+' : '-' }}</span>
}
</th>
<th>Trust Level</th>
<th>Documents</th>
<th class="sortable" (click)="onSort('lastVerifiedAt')">
Last Verified
@if (sortBy() === 'lastVerifiedAt') {
<span class="sort-indicator">{{ sortDirection() === 'asc' ? '+' : '-' }}</span>
}
</th>
<th>Actions</th>
<td>{{ issuer.displayName }}</td>
<td><a [href]="issuer.url" target="_blank" rel="noopener">{{ issuer.url }}</a></td>
<td><span class="badge" [class]="'badge--' + issuer.trustLevel">{{ formatTrustLevel(issuer.trustLevel) }}</span></td>
<td>{{ issuer.metadata?.['status'] || (issuer.isActive ? 'active' : 'inactive') }}</td>
<td>{{ issuer.createdAt | date:'medium' }}</td>
<td>{{ issuer.updatedAt | date:'medium' }}</td>
<td>{{ issuer.metadata?.['updatedBy'] || 'system' }}</td>
</tr>
</thead>
<tbody>
@for (issuer of issuers(); track issuer.issuerId) {
<tr [class.row-blocked]="issuer.trustLevel === 'blocked'">
<td>
<div class="issuer-info">
<strong>{{ issuer.displayName }}</strong>
<span class="issuer-name">{{ issuer.name }}</span>
@if (issuer.description) {
<span class="issuer-desc">{{ issuer.description }}</span>
}
</div>
</td>
<td>
<span class="type-badge" [class]="'type-' + issuer.issuerType">
{{ formatType(issuer.issuerType) }}
</span>
</td>
<td>
<div class="score-cell">
<div class="score-bar">
<div
class="score-fill"
[style.width.%]="issuer.trustScore"
[class]="'score-fill--' + issuer.trustLevel"
></div>
</div>
<span class="score-value">{{ issuer.trustScore }}</span>
</div>
</td>
<td>
<span class="trust-badge" [class]="'trust-' + issuer.trustLevel">
{{ formatTrustLevel(issuer.trustLevel) }}
</span>
</td>
<td>
<span class="doc-count">{{ issuer.documentCount | number }}</span>
</td>
<td>
@if (issuer.lastVerifiedAt) {
{{ issuer.lastVerifiedAt | date:'short' }}
} @else {
<span class="never-verified">Never</span>
}
</td>
<td class="actions-cell">
<button
type="button"
class="btn-action"
title="Configure Weights"
(click)="selectIssuer(issuer); showConfig.set(true)"
>
Configure
</button>
@if (issuer.trustLevel !== 'blocked') {
<button
type="button"
class="btn-action btn-action--danger"
title="Block Issuer"
(click)="onBlockIssuer(issuer)"
>
Block
</button>
} @else {
<button
type="button"
class="btn-action btn-action--success"
title="Unblock Issuer"
(click)="onUnblockIssuer(issuer)"
>
Unblock
</button>
}
</td>
</tr>
}
</tbody>
</table>
<!-- Pagination -->
@if (totalPages() > 1) {
<div class="issuer-list__pagination">
<button
type="button"
[disabled]="pageNumber() <= 1"
(click)="onPageChange(pageNumber() - 1)"
>
Previous
</button>
<span class="page-info">
Page {{ pageNumber() }} of {{ totalPages() }}
({{ totalCount() }} issuers)
</span>
<button
type="button"
[disabled]="pageNumber() >= totalPages()"
(click)="onPageChange(pageNumber() + 1)"
>
Next
</button>
</div>
}
}
</div>
</div>
}
</tbody>
</table>
}
</section>
`,
styles: [`
.issuer-list {
padding: 1.5rem;
}
.issuer-list__filters {
display: flex;
flex-wrap: wrap;
gap: 1rem;
align-items: flex-end;
margin-bottom: 1.5rem;
}
.filter-group {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.filter-group label {
font-size: 0.8rem;
color: var(--color-text-muted);
}
.filter-group input,
.filter-group select {
background: var(--color-surface-primary);
styles: [`
.issuer-list { padding: 1.5rem; display: grid; gap: 1rem; }
.issuer-list__header { display: flex; justify-content: space-between; gap: 1rem; align-items: flex-start; }
.issuer-list__header h2 { margin: 0 0 0.35rem; }
.issuer-list__header p { margin: 0; color: var(--color-text-secondary); max-width: 52rem; }
.issuer-list__filters { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: end; }
.filter-field { display: grid; gap: 0.25rem; min-width: 15rem; }
.filter-field span { font-size: 0.78rem; color: var(--color-text-secondary); text-transform: uppercase; }
.filter-field input, .filter-field select {
padding: 0.55rem 0.75rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
color: var(--color-text-primary);
padding: 0.5rem 0.75rem;
min-width: 160px;
}
.filter-group input:focus,
.filter-group select:focus {
outline: none;
border-color: var(--color-status-info);
}
.btn-config {
background: var(--color-status-excepted-border);
border: none;
color: var(--color-surface-inverse);
border-radius: var(--radius-md);
padding: 0.5rem 1rem;
font-weight: var(--font-weight-medium);
cursor: pointer;
}
.btn-config:hover {
background: var(--color-status-excepted);
}
.btn-link {
background: none;
border: none;
color: var(--color-status-info);
cursor: pointer;
padding: 0.5rem;
font-size: 0.9rem;
}
.btn-link:hover {
text-decoration: underline;
}
.issuer-list__stats {
display: flex;
gap: 1.5rem;
margin-bottom: 1.5rem;
padding: 1rem;
background: var(--color-surface-primary);
border-radius: var(--radius-lg);
color: var(--color-text-primary);
}
.stat {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.stat__value {
font-size: 1.5rem;
font-weight: var(--font-weight-bold);
font-variant-numeric: tabular-nums;
}
.stat__value--full { color: var(--color-status-success-border); }
.stat__value--partial { color: var(--color-status-warning-border); }
.stat__value--blocked { color: var(--color-status-error); }
.stat__label {
font-size: 0.75rem;
.contract-note {
padding: 0.9rem 1rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
color: var(--color-text-secondary);
text-transform: uppercase;
font-size: 0.88rem;
}
.issuer-list__table-container {
overflow-x: auto;
}
.issuer-list__loading,
.issuer-list__error,
.issuer-list__empty {
padding: 3rem;
.state {
padding: 2rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
text-align: center;
color: var(--color-text-muted);
}
.issuer-list__error {
color: var(--color-status-error);
}
.issuer-table {
width: 100%;
border-collapse: collapse;
}
.issuer-table th,
.issuer-table td {
padding: 0.75rem 1rem;
text-align: left;
border-bottom: 1px solid var(--color-border-primary);
}
.issuer-table th {
color: var(--color-text-secondary);
background: var(--color-surface-primary);
color: var(--color-text-muted);
font-weight: var(--font-weight-medium);
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.issuer-table th.sortable {
cursor: pointer;
user-select: none;
}
.issuer-table th.sortable:hover {
color: var(--color-status-info);
}
.sort-indicator {
margin-left: 0.25rem;
}
.issuer-table tbody tr {
transition: background-color 0.15s;
}
.issuer-table tbody tr:hover {
background: rgba(34, 211, 238, 0.05);
}
.issuer-table tbody tr.row-blocked {
opacity: 0.6;
}
.issuer-info {
display: flex;
flex-direction: column;
}
.issuer-info strong {
color: var(--color-text-primary);
}
.issuer-name {
font-size: 0.8rem;
color: var(--color-text-secondary);
font-family: monospace;
}
.issuer-desc {
font-size: 0.8rem;
color: var(--color-text-muted);
margin-top: 0.15rem;
}
.type-badge {
display: inline-block;
padding: 0.2rem 0.5rem;
border-radius: var(--radius-sm);
font-size: 0.7rem;
font-weight: var(--font-weight-medium);
}
.type-csaf_publisher { background: rgba(34, 211, 238, 0.15); color: var(--color-status-info); }
.type-vex_issuer { background: rgba(74, 222, 128, 0.15); color: var(--color-status-success-border); }
.type-sbom_producer { background: rgba(167, 139, 250, 0.15); color: var(--color-status-excepted-border); }
.type-attestation_authority { background: rgba(251, 191, 36, 0.15); color: var(--color-status-warning-border); }
.score-cell {
display: flex;
align-items: center;
gap: 0.5rem;
}
.score-bar {
width: 60px;
height: 6px;
background: var(--color-surface-tertiary);
border-radius: var(--radius-sm);
overflow: hidden;
}
.score-fill {
height: 100%;
transition: width 0.3s;
}
.score-fill--full { background: var(--color-status-success-border); }
.score-fill--partial { background: var(--color-status-warning-border); }
.score-fill--minimal { background: var(--color-severity-high-border); }
.score-fill--untrusted { background: var(--color-text-muted); }
.score-fill--blocked { background: var(--color-status-error); }
.score-value {
font-variant-numeric: tabular-nums;
font-weight: var(--font-weight-medium);
min-width: 2rem;
}
.trust-badge {
display: inline-block;
padding: 0.2rem 0.5rem;
border-radius: var(--radius-sm);
font-size: 0.75rem;
font-weight: var(--font-weight-medium);
}
.trust-full { background: rgba(74, 222, 128, 0.15); color: var(--color-status-success-border); }
.trust-partial { background: rgba(251, 191, 36, 0.15); color: var(--color-status-warning-border); }
.trust-minimal { background: rgba(251, 146, 60, 0.15); color: var(--color-severity-high-border); }
.trust-untrusted { background: rgba(148, 163, 184, 0.15); color: var(--color-text-muted); }
.trust-blocked { background: rgba(239, 68, 68, 0.15); color: var(--color-status-error); }
.doc-count {
font-variant-numeric: tabular-nums;
}
.never-verified {
color: var(--color-text-secondary);
font-style: italic;
}
.actions-cell {
white-space: nowrap;
}
.btn-action {
background: transparent;
border: 1px solid var(--color-border-primary);
color: var(--color-text-primary);
border-radius: var(--radius-sm);
padding: 0.25rem 0.5rem;
font-size: 0.8rem;
cursor: pointer;
margin-right: 0.25rem;
transition: all 0.15s;
}
.btn-action:hover {
border-color: var(--color-status-info);
color: var(--color-status-info);
}
.btn-action--danger:hover {
border-color: var(--color-status-error);
.state--error {
color: var(--color-status-error);
border-color: rgba(239, 68, 68, 0.35);
background: rgba(239, 68, 68, 0.08);
}
.btn-action--success:hover {
border-color: var(--color-status-success-border);
color: var(--color-status-success-border);
.issuer-table { width: 100%; border-collapse: collapse; }
.issuer-table th, .issuer-table td {
padding: 0.75rem 0.9rem;
border-bottom: 1px solid var(--color-border-primary);
text-align: left;
vertical-align: top;
}
.issuer-list__pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 1rem;
padding: 1rem;
border-top: 1px solid var(--color-border-primary);
.issuer-table th {
font-size: 0.78rem;
text-transform: uppercase;
color: var(--color-text-secondary);
background: var(--color-surface-primary);
}
.issuer-list__pagination button {
background: transparent;
border: 1px solid var(--color-border-primary);
color: var(--color-text-primary);
border-radius: var(--radius-md);
padding: 0.5rem 1rem;
.issuer-table a { color: var(--color-status-info); word-break: break-word; }
.btn-secondary, .btn-link {
cursor: pointer;
border-radius: var(--radius-sm);
}
.issuer-list__pagination button:disabled {
opacity: 0.5;
cursor: not-allowed;
.btn-secondary {
padding: 0.4rem 0.7rem;
border: 1px solid var(--color-border-primary);
background: transparent;
color: var(--color-text-primary);
}
.issuer-list__pagination button:hover:not(:disabled) {
border-color: var(--color-status-info);
.btn-link {
border: none;
background: transparent;
color: var(--color-status-info);
padding: 0.35rem 0.5rem;
}
.page-info {
color: var(--color-text-muted);
font-size: 0.9rem;
.badge {
display: inline-flex;
padding: 0.2rem 0.5rem;
border-radius: var(--radius-full);
font-size: 0.74rem;
font-weight: var(--font-weight-medium);
}
`]
.badge--full { background: rgba(74, 222, 128, 0.15); color: var(--color-status-success-border); }
.badge--partial { background: rgba(251, 191, 36, 0.16); color: var(--color-status-warning-border); }
.badge--minimal { background: rgba(251, 146, 60, 0.12); color: var(--color-severity-high-border); }
.badge--untrusted, .badge--blocked { background: rgba(239, 68, 68, 0.12); color: var(--color-status-error); }
`],
})
export class IssuerTrustListComponent implements OnInit {
export class IssuerTrustListComponent {
private readonly trustApi = inject(TRUST_API);
// State
readonly issuers = signal<TrustedIssuer[]>([]);
readonly loading = signal(false);
readonly loading = signal(true);
readonly error = signal<string | null>(null);
readonly selectedIssuer = signal<TrustedIssuer | null>(null);
readonly showConfig = signal(false);
// Pagination
readonly pageNumber = signal(1);
readonly pageSize = signal(20);
readonly totalCount = signal(0);
readonly totalPages = computed(() => Math.ceil(this.totalCount() / this.pageSize()));
// Filters
readonly searchQuery = signal('');
readonly selectedTrustLevel = signal<IssuerTrustLevel | 'all'>('all');
readonly selectedType = signal<IssuerType | 'all'>('all');
readonly sortBy = signal<'name' | 'trustScore' | 'createdAt' | 'lastVerifiedAt'>('trustScore');
readonly sortDirection = signal<'asc' | 'desc'>('desc');
// Computed
readonly hasFilters = computed(() =>
this.searchQuery() !== '' ||
this.selectedTrustLevel() !== 'all' ||
this.selectedType() !== 'all'
);
readonly hasFilters = computed(() => this.searchQuery().trim().length > 0 || this.selectedTrustLevel() !== 'all');
readonly averageScore = computed(() => {
const list = this.issuers();
if (list.length === 0) return 0;
return list.reduce((sum, i) => sum + i.trustScore, 0) / list.length;
});
ngOnInit(): void {
constructor() {
this.loadIssuers();
}
private loadIssuers(): void {
loadIssuers(): void {
this.loading.set(true);
this.error.set(null);
const params: ListIssuersParams = {
pageNumber: this.pageNumber(),
pageSize: this.pageSize(),
search: this.searchQuery() || undefined,
trustLevel: this.selectedTrustLevel() !== 'all' ? this.selectedTrustLevel() as IssuerTrustLevel : undefined,
issuerType: this.selectedType() !== 'all' ? this.selectedType() as IssuerType : undefined,
sortBy: this.sortBy(),
sortDirection: this.sortDirection(),
};
this.trustApi.listIssuers(params).subscribe({
const trustLevel = this.selectedTrustLevel();
this.trustApi.listIssuers({
pageNumber: 1,
pageSize: 200,
search: this.searchQuery().trim() || undefined,
trustLevel: trustLevel === 'all' ? undefined : trustLevel,
sortBy: 'name',
sortDirection: 'asc',
}).subscribe({
next: (result) => {
this.issuers.set([...result.items]);
this.totalCount.set(result.totalCount);
this.loading.set(false);
},
error: (err) => {
this.error.set(err.message || 'Failed to load issuers');
this.error.set(err?.error?.error || err?.message || 'Failed to load issuers.');
this.loading.set(false);
},
});
}
onSearch(): void {
this.pageNumber.set(1);
this.loadIssuers();
}
onFilterChange(): void {
this.pageNumber.set(1);
this.loadIssuers();
}
onSort(column: 'name' | 'trustScore' | 'createdAt' | 'lastVerifiedAt'): void {
if (this.sortBy() === column) {
this.sortDirection.set(this.sortDirection() === 'asc' ? 'desc' : 'asc');
} else {
this.sortBy.set(column);
this.sortDirection.set(column === 'trustScore' ? 'desc' : 'asc');
}
this.loadIssuers();
}
onPageChange(page: number): void {
this.pageNumber.set(page);
applyFilters(): void {
this.loadIssuers();
}
clearFilters(): void {
this.searchQuery.set('');
this.selectedTrustLevel.set('all');
this.selectedType.set('all');
this.pageNumber.set(1);
this.loadIssuers();
}
selectIssuer(issuer: TrustedIssuer): void {
this.selectedIssuer.set(issuer);
}
countByLevel(level: IssuerTrustLevel): number {
return this.issuers().filter(i => i.trustLevel === level).length;
}
onBlockIssuer(issuer: TrustedIssuer): void {
const reason = prompt(`Enter reason for blocking "${issuer.displayName}":`);
if (!reason) return;
this.trustApi.blockIssuer(issuer.issuerId, reason).subscribe({
next: () => {
this.loadIssuers();
},
error: (err) => {
this.error.set(`Failed to block issuer: ${err.message}`);
},
});
}
onUnblockIssuer(issuer: TrustedIssuer): void {
if (!confirm(`Unblock "${issuer.displayName}"?`)) return;
this.trustApi.unblockIssuer(issuer.issuerId).subscribe({
next: () => {
this.loadIssuers();
},
error: (err) => {
this.error.set(`Failed to unblock issuer: ${err.message}`);
},
});
}
onConfigSaved(): void {
this.loadIssuers();
this.selectedIssuer.set(null);
}
formatType(type: IssuerType): string {
const labels: Record<IssuerType, string> = {
csaf_publisher: 'CSAF Publisher',
vex_issuer: 'VEX Issuer',
sbom_producer: 'SBOM Producer',
attestation_authority: 'Attestation Authority',
};
return labels[type] || type;
}
formatTrustLevel(level: IssuerTrustLevel): string {
const labels: Record<IssuerTrustLevel, string> = {
full: 'Full Trust',
partial: 'Partial',
minimal: 'Minimal',
untrusted: 'Untrusted',
blocked: 'Blocked',
};
return labels[level] || level;
return level.charAt(0).toUpperCase() + level.slice(1);
}
}

View File

@@ -426,7 +426,7 @@ export class TrustAdminComponent implements OnInit {
private readonly destroyRef = inject(DestroyRef);
// State
readonly loading = signal(false);
readonly loading = signal(true);
readonly refreshing = signal(false);
readonly error = signal<string | null>(null);
readonly overview = signal<TrustAdministrationOverview | null>(null);

View File

@@ -153,7 +153,7 @@ describe('AppSidebarComponent', () => {
expect(text).toContain('Bundles');
});
it('shows Operations children: Scheduled Jobs, Diagnostics, Signals, Offline Kit, Environments', () => {
it('shows Operations children: Scheduled Jobs, Signals, Offline Kit, Environments', () => {
setScopes([
StellaOpsScopes.UI_ADMIN,
StellaOpsScopes.ORCH_READ,
@@ -167,12 +167,26 @@ describe('AppSidebarComponent', () => {
const hrefs = links.map((link) => link.getAttribute('href'));
expect(hrefs).toContain('/ops/operations/jobengine');
expect(hrefs).toContain('/ops/operations/doctor');
expect(hrefs).toContain('/ops/operations/signals');
expect(hrefs).toContain('/ops/operations/offline-kit');
expect(hrefs).toContain('/ops/operations/environments');
});
it('shows Diagnostics under Setup', () => {
setScopes([
StellaOpsScopes.UI_ADMIN,
StellaOpsScopes.RELEASE_READ,
StellaOpsScopes.ORCH_READ,
StellaOpsScopes.ORCH_OPERATE,
StellaOpsScopes.HEALTH_READ,
]);
const fixture = createComponent();
const links = Array.from(fixture.nativeElement.querySelectorAll('a')) as HTMLAnchorElement[];
const hrefs = links.map((link) => link.getAttribute('href'));
expect(hrefs).toContain('/ops/operations/doctor');
});
it('does not show Notifications under Setup', () => {
setScopes([
StellaOpsScopes.UI_ADMIN,

View File

@@ -713,7 +713,6 @@ export class AppSidebarComponent implements AfterViewInit {
],
children: [
{ id: 'ops-jobs', label: 'Scheduled Jobs', route: '/ops/operations/jobengine', icon: 'clock' },
{ id: 'ops-diagnostics', label: 'Diagnostics', route: '/ops/operations/doctor', icon: 'stethoscope' },
{ id: 'ops-signals', label: 'Signals', route: '/ops/operations/signals', icon: 'radio' },
{ id: 'ops-offline-kit', label: 'Offline Kit', route: '/ops/operations/offline-kit', icon: 'download-cloud' },
{ id: 'ops-environments', label: 'Environments', route: '/ops/operations/environments', icon: 'globe' },
@@ -788,6 +787,13 @@ export class AppSidebarComponent implements AfterViewInit {
],
children: [
{ id: 'setup-topology', label: 'Topology', route: '/setup/topology/overview', icon: 'globe' },
{
id: 'setup-diagnostics',
label: 'Diagnostics',
route: '/ops/operations/doctor',
icon: 'stethoscope',
requireAnyScope: [StellaOpsScopes.HEALTH_READ, StellaOpsScopes.UI_ADMIN],
},
{ id: 'setup-integrations', label: 'Integrations', route: '/setup/integrations', icon: 'plug' },
{ id: 'setup-iam', label: 'Identity & Access', route: '/setup/identity-access', icon: 'user' },
{ id: 'setup-trust-signing', label: 'Trust & Signing', route: '/setup/trust-signing', icon: 'shield' },

View File

@@ -140,12 +140,12 @@ describe('FilterBarComponent', () => {
});
it('should show clear-all button only when active filters exist', () => {
component.activeFilters = [];
fixture.componentRef.setInput('activeFilters', []);
fixture.detectChanges();
let clearBtn = fixture.nativeElement.querySelector('.filter-bar__clear');
expect(clearBtn).toBeNull();
component.activeFilters = [{ key: 'severity', value: 'high', label: 'Severity: High' }];
fixture.componentRef.setInput('activeFilters', [{ key: 'severity', value: 'high', label: 'Severity: High' }]);
fixture.detectChanges();
clearBtn = fixture.nativeElement.querySelector('.filter-bar__clear');
expect(clearBtn).toBeTruthy();

View File

@@ -92,18 +92,21 @@ export interface ActiveFilter {
}
.filter-bar__top-row {
display: flex;
align-items: center;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: start;
gap: 0.5rem;
}
.filter-bar__search {
position: relative;
flex: 1;
min-width: 200px;
min-width: 0;
width: 100%;
}
.filter-bar__search-btn {
align-self: stretch;
min-height: 2.5rem;
padding: 0.5rem 1rem;
border: 1px solid var(--color-brand-primary);
border-radius: var(--radius-md);
@@ -120,6 +123,29 @@ export interface ActiveFilter {
opacity: 0.9;
}
@media (max-width: 720px) {
.filter-bar__top-row {
grid-template-columns: 1fr;
}
.filter-bar__search {
width: 100%;
}
.filter-bar__search-btn {
width: 100%;
justify-content: center;
}
.filter-bar__active {
flex-wrap: wrap;
}
.filter-bar__clear {
margin-left: 0;
}
}
.filter-bar__search-icon {
position: absolute;
left: 0.75rem;
@@ -129,7 +155,9 @@ export interface ActiveFilter {
}
.filter-bar__search-input {
box-sizing: border-box;
width: 100%;
min-width: 0;
padding: 0.5rem 0.75rem 0.5rem 2.25rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);

View File

@@ -21,6 +21,7 @@
"src/app/features/evidence-export/provenance-visualization.component.spec.ts",
"src/app/features/evidence-export/replay-controls.component.spec.ts",
"src/app/features/evidence-export/stella-bundle-export-button/stella-bundle-export-button.component.spec.ts",
"src/app/features/jobengine/jobengine-dashboard.component.spec.ts",
"src/app/features/platform/ops/platform-jobs-queues-page.component.spec.ts",
"src/app/features/policy-simulation/policy-simulation-direct-route-defaults.spec.ts",
"src/app/features/policy-simulation/policy-simulation-defaults.spec.ts",
@@ -29,6 +30,7 @@
"src/app/features/registry-admin/registry-admin.component.spec.ts",
"src/app/features/triage/services/ttfs-telemetry.service.spec.ts",
"src/app/features/triage/triage-workspace.component.spec.ts",
"src/app/shared/ui/filter-bar/filter-bar.component.spec.ts",
"src/app/features/watchlist/watchlist-page.component.spec.ts",
"src/app/features/evidence-thread/__tests__/evidence-thread-view.component.spec.ts"
]