Close admin trust audit gaps and stabilize live sweeps
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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 ==========
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user