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

@@ -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',