consolidation of some of the modules, localization fixes, product advisories work, qa work
This commit is contained in:
@@ -2,6 +2,10 @@
|
||||
|
||||
## Working Directory
|
||||
- `src/Authority/**` (Authority service, libraries, plugins, tests).
|
||||
- `src/Authority/StellaOps.IssuerDirectory/**` (IssuerDirectory service, relocated by Sprint 216).
|
||||
- `src/Authority/__Libraries/StellaOps.IssuerDirectory.Client/` (shared client library).
|
||||
- `src/Authority/__Libraries/StellaOps.IssuerDirectory.Persistence/` (persistence layer, separate DbContext/schema).
|
||||
- `src/Authority/__Tests/StellaOps.IssuerDirectory.Persistence.Tests/` (persistence tests).
|
||||
|
||||
## Required Reading
|
||||
- `docs/README.md`
|
||||
@@ -16,8 +20,9 @@
|
||||
- No plaintext secrets in logs or storage.
|
||||
|
||||
## Testing & Verification
|
||||
- Tests live in `src/Authority/__Tests/**`.
|
||||
- Cover authz policies, error handling, and offline behavior.
|
||||
- Authority tests live in `src/Authority/__Tests/**`.
|
||||
- IssuerDirectory tests live in `src/Authority/__Tests/StellaOps.IssuerDirectory.Persistence.Tests/**` and `src/Authority/StellaOps.IssuerDirectory/StellaOps.IssuerDirectory.Core.Tests/**`.
|
||||
- Cover authz policies, error handling, issuer resolution, caching, and offline behavior.
|
||||
|
||||
## Sprint Discipline
|
||||
- Record decisions and risks for security-sensitive changes in the sprint file.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -66,12 +66,12 @@ public static class StellaOpsClaimTypes
|
||||
public const string IdentityProvider = "stellaops:idp";
|
||||
|
||||
/// <summary>
|
||||
/// Operator reason supplied when issuing orchestrator control tokens.
|
||||
/// Operator reason supplied when issuing jobengine control tokens.
|
||||
/// </summary>
|
||||
public const string OperatorReason = "stellaops:operator_reason";
|
||||
|
||||
/// <summary>
|
||||
/// Operator ticket supplied when issuing orchestrator control tokens.
|
||||
/// Operator ticket supplied when issuing jobengine control tokens.
|
||||
/// </summary>
|
||||
public const string OperatorTicket = "stellaops:operator_ticket";
|
||||
|
||||
@@ -86,12 +86,12 @@ public static class StellaOpsClaimTypes
|
||||
public const string QuotaTicket = "stellaops:quota_ticket";
|
||||
|
||||
/// <summary>
|
||||
/// Backfill activation reason supplied when issuing orchestrator backfill tokens.
|
||||
/// Backfill activation reason supplied when issuing jobengine backfill tokens.
|
||||
/// </summary>
|
||||
public const string BackfillReason = "stellaops:backfill_reason";
|
||||
|
||||
/// <summary>
|
||||
/// Backfill ticket/incident reference supplied when issuing orchestrator backfill tokens.
|
||||
/// Backfill ticket/incident reference supplied when issuing jobengine backfill tokens.
|
||||
/// </summary>
|
||||
public const string BackfillTicket = "stellaops:backfill_ticket";
|
||||
|
||||
|
||||
@@ -373,7 +373,7 @@ public static class StellaOpsScopes
|
||||
public const string OrchQuota = "orch:quota";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting permission to initiate orchestrator-controlled backfill runs.
|
||||
/// Scope granting permission to initiate jobengine-controlled backfill runs.
|
||||
/// </summary>
|
||||
public const string OrchBackfill = "orch:backfill";
|
||||
|
||||
@@ -597,6 +597,13 @@ public static class StellaOpsScopes
|
||||
/// </summary>
|
||||
public const string AnalyticsRead = "analytics.read";
|
||||
|
||||
// UI preferences scopes
|
||||
public const string UiPreferencesRead = "ui.preferences.read";
|
||||
public const string UiPreferencesWrite = "ui.preferences.write";
|
||||
|
||||
// Platform ops health scope
|
||||
public const string OpsHealth = "ops.health";
|
||||
|
||||
// Platform context scopes
|
||||
public const string PlatformContextRead = "platform.context.read";
|
||||
public const string PlatformContextWrite = "platform.context.write";
|
||||
|
||||
@@ -3,10 +3,12 @@ using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace StellaOps.Auth.ServerIntegration;
|
||||
@@ -90,8 +92,30 @@ public static class ServiceCollectionExtensions
|
||||
// Accept both "Bearer" and "DPoP" authorization schemes.
|
||||
// The StellaOps UI sends DPoP-bound access tokens with "Authorization: DPoP <token>".
|
||||
jwt.Events ??= new JwtBearerEvents();
|
||||
var bridgeLogger = provider.GetService<ILoggerFactory>()?.CreateLogger("StellaOps.Auth.EnvelopeBridge");
|
||||
jwt.Events.OnMessageReceived = context =>
|
||||
{
|
||||
// Bridge: accept Router-signed identity envelope as a valid StellaOpsBearer identity.
|
||||
// Valkey-transported requests carry no JWT; the gateway already validated the token
|
||||
// and signed the claims into an HMAC-SHA256 envelope that the Router SDK verified
|
||||
// and populated onto httpContext.User before the ASP.NET Core pipeline runs.
|
||||
var identity = context.HttpContext.User?.Identity;
|
||||
if (identity is ClaimsIdentity { IsAuthenticated: true, AuthenticationType: "StellaRouterEnvelope" } envelopeId)
|
||||
{
|
||||
bridgeLogger?.LogInformation(
|
||||
"Envelope bridge: accepting identity {Subject} with {ScopeCount} scopes as StellaOpsBearer",
|
||||
envelopeId.FindFirst("sub")?.Value ?? "(unknown)",
|
||||
envelopeId.FindAll("scope").Count());
|
||||
context.Principal = context.HttpContext.User;
|
||||
context.Success();
|
||||
return System.Threading.Tasks.Task.CompletedTask;
|
||||
}
|
||||
|
||||
bridgeLogger?.LogDebug(
|
||||
"Envelope bridge: no envelope identity (AuthType={AuthType}, IsAuth={IsAuth})",
|
||||
identity?.AuthenticationType ?? "(null)",
|
||||
identity?.IsAuthenticated ?? false);
|
||||
|
||||
if (!string.IsNullOrEmpty(context.Token))
|
||||
{
|
||||
return System.Threading.Tasks.Task.CompletedTask;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Auth.ServerIntegration;
|
||||
@@ -12,6 +13,9 @@ public static class StellaOpsAuthorizationPolicyBuilderExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Requires the specified scopes using the StellaOps scope requirement.
|
||||
/// Explicitly binds the policy to the StellaOpsBearer authentication scheme
|
||||
/// so that token validation uses the correct JWT handler regardless of the
|
||||
/// application's default authentication scheme.
|
||||
/// </summary>
|
||||
public static AuthorizationPolicyBuilder RequireStellaOpsScopes(
|
||||
this AuthorizationPolicyBuilder builder,
|
||||
@@ -19,6 +23,11 @@ public static class StellaOpsAuthorizationPolicyBuilderExtensions
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
|
||||
if (!builder.AuthenticationSchemes.Contains(StellaOpsAuthenticationDefaults.AuthenticationScheme))
|
||||
{
|
||||
builder.AuthenticationSchemes.Add(StellaOpsAuthenticationDefaults.AuthenticationScheme);
|
||||
}
|
||||
|
||||
var requirement = new StellaOpsScopeRequirement(scopes);
|
||||
builder.AddRequirements(requirement);
|
||||
return builder;
|
||||
@@ -37,6 +46,7 @@ public static class StellaOpsAuthorizationPolicyBuilderExtensions
|
||||
|
||||
options.AddPolicy(policyName, policy =>
|
||||
{
|
||||
policy.AuthenticationSchemes.Add(StellaOpsAuthenticationDefaults.AuthenticationScheme);
|
||||
policy.Requirements.Add(new StellaOpsScopeRequirement(scopes));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,7 +3,12 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Authority.Persistence.Postgres.Models;
|
||||
using StellaOps.Authority.Persistence.Postgres.Repositories;
|
||||
using StellaOps.Authority.Plugin.Standard.Storage;
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
@@ -11,6 +16,8 @@ namespace StellaOps.Authority.Plugin.Standard.Bootstrap;
|
||||
|
||||
internal sealed class StandardPluginBootstrapper : IHostedService
|
||||
{
|
||||
private const string DefaultTenantId = "default";
|
||||
|
||||
private readonly string pluginName;
|
||||
private readonly IServiceScopeFactory scopeFactory;
|
||||
private readonly ILogger<StandardPluginBootstrapper> logger;
|
||||
@@ -46,6 +53,122 @@ internal sealed class StandardPluginBootstrapper : IHostedService
|
||||
{
|
||||
logger.LogError(ex, "Standard Authority plugin '{PluginName}' failed to ensure bootstrap user.", pluginName);
|
||||
}
|
||||
|
||||
var tenantId = options.TenantId ?? DefaultTenantId;
|
||||
var bootstrapRoles = options.BootstrapUser.Roles ?? new[] { "admin" };
|
||||
|
||||
try
|
||||
{
|
||||
await EnsureAdminRoleAsync(scope.ServiceProvider, tenantId, options.BootstrapUser.Username!, bootstrapRoles, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Standard Authority plugin '{PluginName}' failed to seed admin role with scopes.", pluginName);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task EnsureAdminRoleAsync(
|
||||
IServiceProvider services,
|
||||
string tenantId,
|
||||
string bootstrapUsername,
|
||||
string[] roleNames,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var roleRepository = services.GetRequiredService<IRoleRepository>();
|
||||
var permissionRepository = services.GetRequiredService<IPermissionRepository>();
|
||||
var userRepository = services.GetRequiredService<IUserRepository>();
|
||||
|
||||
var allScopes = StellaOpsScopes.All;
|
||||
|
||||
foreach (var roleName in roleNames)
|
||||
{
|
||||
var existingRole = await roleRepository.GetByNameAsync(tenantId, roleName, cancellationToken).ConfigureAwait(false);
|
||||
Guid roleId;
|
||||
|
||||
if (existingRole is null)
|
||||
{
|
||||
logger.LogInformation("Standard Authority plugin '{PluginName}' creating system role '{RoleName}' with {ScopeCount} scopes.",
|
||||
pluginName, roleName, allScopes.Count);
|
||||
|
||||
roleId = await roleRepository.CreateAsync(tenantId, new RoleEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = tenantId,
|
||||
Name = roleName,
|
||||
DisplayName = roleName == "admin" ? "Administrator" : roleName,
|
||||
Description = roleName == "admin" ? "Full platform access. Auto-seeded by bootstrap." : $"System role '{roleName}'. Auto-seeded by bootstrap.",
|
||||
IsSystem = true,
|
||||
Metadata = "{}",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow,
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
roleId = existingRole.Id;
|
||||
logger.LogInformation("Standard Authority plugin '{PluginName}' role '{RoleName}' already exists (id={RoleId}). Ensuring scope assignments.",
|
||||
pluginName, roleName, roleId);
|
||||
}
|
||||
|
||||
// Ensure permissions exist for all scopes and are assigned to the role
|
||||
var existingPermissions = await permissionRepository.GetRolePermissionsAsync(tenantId, roleId, cancellationToken).ConfigureAwait(false);
|
||||
var existingPermissionNames = existingPermissions.Select(p => p.Name).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var scope in allScopes)
|
||||
{
|
||||
if (existingPermissionNames.Contains(scope))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse scope into resource:action (e.g. "release:read" -> resource="release", action="read")
|
||||
var separatorIndex = scope.IndexOfAny(new[] { ':', '.' });
|
||||
var resource = separatorIndex > 0 ? scope[..separatorIndex] : scope;
|
||||
var action = separatorIndex > 0 ? scope[(separatorIndex + 1)..] : "access";
|
||||
|
||||
// Check if the permission already exists globally
|
||||
var existingPermission = await permissionRepository.GetByNameAsync(tenantId, scope, cancellationToken).ConfigureAwait(false);
|
||||
Guid permissionId;
|
||||
|
||||
if (existingPermission is null)
|
||||
{
|
||||
permissionId = await permissionRepository.CreateAsync(tenantId, new PermissionEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = tenantId,
|
||||
Name = scope,
|
||||
Resource = resource,
|
||||
Action = action,
|
||||
Description = $"Auto-seeded permission for scope '{scope}'.",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
permissionId = existingPermission.Id;
|
||||
}
|
||||
|
||||
await permissionRepository.AssignToRoleAsync(tenantId, roleId, permissionId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
logger.LogInformation("Standard Authority plugin '{PluginName}' role '{RoleName}' now has {ScopeCount} scope permissions.",
|
||||
pluginName, roleName, allScopes.Count);
|
||||
|
||||
// Assign the role to the bootstrap user
|
||||
var normalizedUsername = bootstrapUsername.Trim().ToLowerInvariant();
|
||||
var bootstrapUser = await userRepository.GetByUsernameAsync(tenantId, normalizedUsername, cancellationToken).ConfigureAwait(false);
|
||||
if (bootstrapUser is not null)
|
||||
{
|
||||
await roleRepository.AssignToUserAsync(tenantId, bootstrapUser.Id, roleId, "bootstrap", expiresAt: null, cancellationToken).ConfigureAwait(false);
|
||||
logger.LogInformation("Standard Authority plugin '{PluginName}' assigned role '{RoleName}' to bootstrap user '{Username}'.",
|
||||
pluginName, roleName, normalizedUsername);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogWarning("Standard Authority plugin '{PluginName}' could not find bootstrap user '{Username}' to assign role '{RoleName}'.",
|
||||
pluginName, normalizedUsername, roleName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
@@ -52,6 +52,8 @@ internal sealed class BootstrapUserOptions
|
||||
|
||||
public bool RequirePasswordReset { get; set; } = true;
|
||||
|
||||
public string[]? Roles { get; set; }
|
||||
|
||||
public bool IsConfigured => !string.IsNullOrWhiteSpace(Username) && !string.IsNullOrWhiteSpace(Password);
|
||||
|
||||
public void Validate(string pluginName)
|
||||
|
||||
@@ -344,7 +344,7 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
|
||||
displayName: bootstrap.Username,
|
||||
email: null,
|
||||
requirePasswordReset: bootstrap.RequirePasswordReset,
|
||||
roles: Array.Empty<string>(),
|
||||
roles: bootstrap.Roles ?? new[] { "admin" },
|
||||
attributes: new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase));
|
||||
|
||||
var result = await UpsertUserAsync(registration, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
@@ -871,7 +871,7 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
|
||||
context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty] = invalidScope;
|
||||
context.Reject(OpenIddictConstants.Errors.InvalidClient, "Orchestrator scopes require a tenant assignment.");
|
||||
logger.LogWarning(
|
||||
"Client credentials validation failed for {ClientId}: orchestrator scopes require tenant assignment.",
|
||||
"Client credentials validation failed for {ClientId}: jobengine scopes require tenant assignment.",
|
||||
document.ClientId);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ internal static class OpenIddictGatewayBridgeEndpointExtensions
|
||||
.WithSummary("OAuth2 revocation endpoint.")
|
||||
.WithDescription("Bridges Gateway microservice `/connect/revoke` requests to Authority `/revoke`.");
|
||||
|
||||
endpoints.MapGet("/well-known/openid-configuration", (
|
||||
endpoints.MapGet("/.well-known/openid-configuration", (
|
||||
HttpContext context,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
CancellationToken cancellationToken) =>
|
||||
|
||||
@@ -319,7 +319,10 @@ builder.Services.AddOptions<StellaOpsResourceServerOptions>()
|
||||
.Configure(options =>
|
||||
{
|
||||
options.Authority = issuerUri.ToString();
|
||||
options.RequireHttpsMetadata = !issuerUri.IsLoopback;
|
||||
// Use loopback metadata endpoint so the Authority can fetch its own JWKS
|
||||
// without requiring external DNS resolution or TLS certificate trust.
|
||||
options.MetadataAddress = "http://127.0.0.1/.well-known/openid-configuration";
|
||||
options.RequireHttpsMetadata = false;
|
||||
})
|
||||
.PostConfigure(static options => options.Validate());
|
||||
|
||||
|
||||
31
src/Authority/StellaOps.IssuerDirectory/AGENTS.md
Normal file
31
src/Authority/StellaOps.IssuerDirectory/AGENTS.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# Issuer Directory Guild Charter (Epic 7)
|
||||
|
||||
## Mission
|
||||
Manage trusted VEX issuer metadata, keys, and trust overrides used by the VEX Lens, Policy Engine, and downstream services.
|
||||
|
||||
## Scope
|
||||
- Service `src/Authority/StellaOps.IssuerDirectory` providing REST APIs and admin tooling for issuers, keys, trust weights, audit logs (relocated from `src/IssuerDirectory/` by Sprint 216).
|
||||
- Integration with Excitor/VEX Lens/Policy Engine for signature verification and trust weighting.
|
||||
- Tenant overrides, import of CSAF publisher metadata, and compliance logging.
|
||||
|
||||
## Principles
|
||||
1. **Security first** – enforce least privilege, key expiry, rotation, and audit logs.
|
||||
2. **Tenant awareness** – global issuer defaults with per-tenant overrides.
|
||||
3. **Deterministic** – trust weights reproducible; changes logged.
|
||||
4. **Audit ready** – all modifications recorded with actor, reason, signature.
|
||||
5. **API-first** – CLI/Console/automation consume same endpoints.
|
||||
|
||||
## Definition of Done
|
||||
- APIs documented, RBAC enforced, audit logs persisted.
|
||||
- Key verification integrated with VEX Lens and Excitor; rotation tooling delivered.
|
||||
- Docs/runbooks updated with compliance checklist.
|
||||
|
||||
## Required Reading
|
||||
- `docs/modules/platform/architecture-overview.md`
|
||||
|
||||
## Working Agreement
|
||||
- 1. Update task status to `DOING`/`DONE` in both correspoding sprint file `/docs/implplan/SPRINT_*.md` and the local `TASKS.md` when you start or finish work.
|
||||
- 2. Review this charter and the Required Reading documents before coding; confirm prerequisites are met.
|
||||
- 3. Keep changes deterministic (stable ordering, timestamps, hashes) and align with offline/air-gap expectations.
|
||||
- 4. Coordinate doc updates, tests, and cross-guild communication whenever contracts or workflows change.
|
||||
- 5. Revert to `TODO` if you pause the task without shipping changes; leave notes in commit/PR descriptions for context.
|
||||
@@ -0,0 +1,24 @@
|
||||
# IssuerDirectory Core Tests Agent Charter
|
||||
|
||||
## Mission
|
||||
- Validate IssuerDirectory core domain and service behaviors with deterministic unit tests.
|
||||
|
||||
## Responsibilities
|
||||
- Keep tests offline-friendly with fake repositories and fixed time providers.
|
||||
- Exercise audit, validation, and error-path behavior consistently.
|
||||
|
||||
## Required Reading
|
||||
- docs/07_HIGH_LEVEL_ARCHITECTURE.md
|
||||
- docs/modules/issuer-directory/architecture.md
|
||||
|
||||
## Working Directory & Scope
|
||||
- Primary: src/IssuerDirectory/StellaOps.IssuerDirectory/StellaOps.IssuerDirectory.Core.Tests
|
||||
- Allowed shared projects: src/IssuerDirectory/StellaOps.IssuerDirectory/StellaOps.IssuerDirectory.Core
|
||||
|
||||
## Testing Expectations
|
||||
- Use TestKit or standard test SDK packages to ensure discovery in CI.
|
||||
- Prefer explicit assertions for audit metadata, cache behavior, and determinism.
|
||||
|
||||
## Working Agreement
|
||||
- Update sprint status in docs/implplan/SPRINT_*.md and local TASKS.md.
|
||||
- Keep outputs deterministic and avoid non-ASCII logs.
|
||||
@@ -0,0 +1,33 @@
|
||||
using FluentAssertions;
|
||||
|
||||
using StellaOps.IssuerDirectory.Core.Domain;
|
||||
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Core.Tests.Domain;
|
||||
|
||||
public class IssuerKeyRecordTests
|
||||
{
|
||||
[Fact]
|
||||
public void WithStatus_Retired_SetsTimestamps()
|
||||
{
|
||||
var record = IssuerKeyRecord.Create(
|
||||
id: "key-1",
|
||||
issuerId: "issuer-1",
|
||||
tenantId: "tenant-a",
|
||||
type: IssuerKeyType.Ed25519PublicKey,
|
||||
material: new IssuerKeyMaterial("base64", Convert.ToBase64String(new byte[32])),
|
||||
fingerprint: "fp-1",
|
||||
createdAtUtc: DateTimeOffset.Parse("2025-11-01T00:00:00Z"),
|
||||
createdBy: "seed",
|
||||
expiresAtUtc: null,
|
||||
replacesKeyId: null);
|
||||
|
||||
var retiredAt = DateTimeOffset.Parse("2025-11-02T00:00:00Z");
|
||||
var retired = record.WithStatus(IssuerKeyStatus.Retired, retiredAt, "editor");
|
||||
|
||||
retired.Status.Should().Be(IssuerKeyStatus.Retired);
|
||||
retired.RetiredAtUtc.Should().Be(retiredAt);
|
||||
retired.RevokedAtUtc.Should().BeNull();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using FluentAssertions;
|
||||
|
||||
using StellaOps.IssuerDirectory.Core.Domain;
|
||||
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Core.Tests.Domain;
|
||||
|
||||
public class IssuerRecordTests
|
||||
{
|
||||
[Fact]
|
||||
public void Create_NormalizesSlugAndTags()
|
||||
{
|
||||
var record = IssuerRecord.Create(
|
||||
id: "red-hat",
|
||||
tenantId: "tenant-a",
|
||||
displayName: "Red Hat",
|
||||
slug: " Red-Hat ",
|
||||
description: null,
|
||||
contact: new IssuerContact("sec@example.com", null, new Uri("https://example.com"), null),
|
||||
metadata: new IssuerMetadata("org", "publisher", new Uri("https://example.com/cve"), null, new[] { "en" }, null),
|
||||
endpoints: Array.Empty<IssuerEndpoint>(),
|
||||
tags: new[] { "Vendor", " vendor ", "Partner", " " },
|
||||
timestampUtc: DateTimeOffset.Parse("2025-11-01T00:00:00Z"),
|
||||
actor: "seed",
|
||||
isSystemSeed: false);
|
||||
|
||||
record.Slug.Should().Be("red-hat");
|
||||
record.Tags.Should().Equal("vendor", "partner");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithUpdated_NormalizesTagsAndDescription()
|
||||
{
|
||||
var record = IssuerRecord.Create(
|
||||
id: "red-hat",
|
||||
tenantId: "tenant-a",
|
||||
displayName: "Red Hat",
|
||||
slug: "red-hat",
|
||||
description: "Initial",
|
||||
contact: new IssuerContact("sec@example.com", null, new Uri("https://example.com"), null),
|
||||
metadata: new IssuerMetadata("org", "publisher", new Uri("https://example.com/cve"), null, new[] { "en" }, null),
|
||||
endpoints: Array.Empty<IssuerEndpoint>(),
|
||||
tags: new[] { "vendor" },
|
||||
timestampUtc: DateTimeOffset.Parse("2025-11-01T00:00:00Z"),
|
||||
actor: "seed",
|
||||
isSystemSeed: false);
|
||||
|
||||
var updated = record.WithUpdated(
|
||||
contact: new IssuerContact("sec@example.com", null, new Uri("https://example.com"), null),
|
||||
metadata: new IssuerMetadata("org", "publisher", new Uri("https://example.com/cve"), null, new[] { "en" }, null),
|
||||
endpoints: Array.Empty<IssuerEndpoint>(),
|
||||
tags: new[] { "Beta", "beta ", "Alpha" },
|
||||
displayName: "Red Hat Security",
|
||||
description: " Updated ",
|
||||
updatedAtUtc: DateTimeOffset.Parse("2025-11-02T00:00:00Z"),
|
||||
updatedBy: "editor");
|
||||
|
||||
updated.Description.Should().Be("Updated");
|
||||
updated.Tags.Should().Equal("beta", "alpha");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
using StellaOps.IssuerDirectory.Client;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Core.Tests;
|
||||
|
||||
public partial class IssuerDirectoryClientTests
|
||||
{
|
||||
private static IIssuerDirectoryClient CreateClient(
|
||||
RecordingHandler handler,
|
||||
IssuerDirectoryClientOptions? options = null)
|
||||
{
|
||||
var opts = options ?? DefaultOptions();
|
||||
var httpClient = new HttpClient(handler)
|
||||
{
|
||||
BaseAddress = opts.BaseAddress
|
||||
};
|
||||
|
||||
var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var clientOptions = Options.Create(opts);
|
||||
|
||||
var clientType = typeof(IssuerDirectoryClientOptions)
|
||||
.Assembly
|
||||
.GetType("StellaOps.IssuerDirectory.Client.IssuerDirectoryClient", throwOnError: true)!;
|
||||
|
||||
var loggerType = typeof(TestLogger<>).MakeGenericType(clientType);
|
||||
var logger = Activator.CreateInstance(loggerType)!;
|
||||
|
||||
var instance = Activator.CreateInstance(
|
||||
clientType,
|
||||
new object[] { httpClient, memoryCache, clientOptions, logger });
|
||||
|
||||
return (IIssuerDirectoryClient)instance!;
|
||||
}
|
||||
|
||||
private static IssuerDirectoryClientOptions DefaultOptions()
|
||||
{
|
||||
return new IssuerDirectoryClientOptions
|
||||
{
|
||||
BaseAddress = new Uri("https://issuer-directory.local/"),
|
||||
TenantHeader = "X-StellaOps-Tenant",
|
||||
AuditReasonHeader = "X-StellaOps-Reason"
|
||||
};
|
||||
}
|
||||
|
||||
private static HttpResponseMessage CreateJsonResponse(string json)
|
||||
{
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(json, Encoding.UTF8, "application/json")
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
|
||||
using FluentAssertions;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Core.Tests;
|
||||
|
||||
public partial class IssuerDirectoryClientTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetIssuerKeysAsync_SendsTenantHeaderAndCachesByIncludeGlobalAsync()
|
||||
{
|
||||
var handler = new RecordingHandler(
|
||||
CreateJsonResponse("""[{"id":"key-1","issuerId":"issuer-1","tenantId":"tenant-a","type":"ed25519","status":"active","materialFormat":"base64","materialValue":"AQ==","fingerprint":"fp-1","expiresAtUtc":null,"retiredAtUtc":null,"revokedAtUtc":null,"replacesKeyId":null}]"""),
|
||||
CreateJsonResponse("""[{"id":"key-2","issuerId":"issuer-1","tenantId":"tenant-a","type":"ed25519","status":"active","materialFormat":"base64","materialValue":"AQ==","fingerprint":"fp-2","expiresAtUtc":null,"retiredAtUtc":null,"revokedAtUtc":null,"replacesKeyId":null}]"""));
|
||||
|
||||
var client = CreateClient(handler);
|
||||
|
||||
var first = await client.GetIssuerKeysAsync(" tenant-a ", "issuer-1 ", includeGlobal: false, CancellationToken.None);
|
||||
first.Should().HaveCount(1);
|
||||
handler.Requests.Should().HaveCount(1);
|
||||
|
||||
var firstRequest = handler.Requests[0];
|
||||
firstRequest.Method.Should().Be(HttpMethod.Get);
|
||||
firstRequest.Uri.Should().Be(new Uri("https://issuer-directory.local/issuer-directory/issuers/issuer-1/keys?includeGlobal=false"));
|
||||
firstRequest.Headers.TryGetValue("X-StellaOps-Tenant", out var tenantValues).Should().BeTrue();
|
||||
tenantValues!.Should().Equal("tenant-a");
|
||||
|
||||
var cached = await client.GetIssuerKeysAsync("tenant-a", "issuer-1", includeGlobal: false, CancellationToken.None);
|
||||
cached.Should().HaveCount(1);
|
||||
handler.Requests.Should().HaveCount(1);
|
||||
|
||||
var global = await client.GetIssuerKeysAsync("tenant-a", "issuer-1", includeGlobal: true, CancellationToken.None);
|
||||
global.Should().HaveCount(1);
|
||||
handler.Requests.Should().HaveCount(2);
|
||||
handler.Requests[1].Uri.Should().Be(new Uri("https://issuer-directory.local/issuer-directory/issuers/issuer-1/keys?includeGlobal=true"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Core.Tests;
|
||||
|
||||
public partial class IssuerDirectoryClientTests
|
||||
{
|
||||
private sealed record RecordedRequest(HttpMethod Method, Uri Uri, IDictionary<string, string[]> Headers, string? Body);
|
||||
|
||||
private sealed class RecordingHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Queue<HttpResponseMessage> _responses;
|
||||
|
||||
public RecordingHandler(params HttpResponseMessage[] responses)
|
||||
{
|
||||
_responses = new Queue<HttpResponseMessage>(responses);
|
||||
}
|
||||
|
||||
public List<RecordedRequest> Requests { get; } = new();
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
string? body = null;
|
||||
if (request.Content is not null)
|
||||
{
|
||||
body = await request.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var headers = request.Headers.ToDictionary(pair => pair.Key, pair => pair.Value.ToArray());
|
||||
if (request.Content?.Headers is not null)
|
||||
{
|
||||
foreach (var header in request.Content.Headers)
|
||||
{
|
||||
headers[header.Key] = header.Value.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
Requests.Add(new RecordedRequest(request.Method, request.RequestUri!, headers, body));
|
||||
|
||||
if (_responses.Count == 0)
|
||||
{
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent("{}", Encoding.UTF8, "application/json")
|
||||
};
|
||||
}
|
||||
|
||||
return _responses.Dequeue();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Core.Tests;
|
||||
|
||||
public partial class IssuerDirectoryClientTests
|
||||
{
|
||||
private sealed class TestLogger<T> : ILogger<T>
|
||||
{
|
||||
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullDisposable.Instance;
|
||||
|
||||
public bool IsEnabled(LogLevel logLevel) => false;
|
||||
|
||||
public void Log<TState>(
|
||||
LogLevel logLevel,
|
||||
EventId eventId,
|
||||
TState state,
|
||||
Exception? exception,
|
||||
Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
}
|
||||
|
||||
private sealed class NullDisposable : IDisposable
|
||||
{
|
||||
public static readonly NullDisposable Instance = new();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
|
||||
using FluentAssertions;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Core.Tests;
|
||||
|
||||
public partial class IssuerDirectoryClientTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task DeleteIssuerTrustAsync_UsesDeleteVerbAndReasonHeaderWhenProvidedAsync()
|
||||
{
|
||||
var handler = new RecordingHandler(
|
||||
CreateJsonResponse("""{"tenantOverride":{"weight":2.0,"reason":"seed","updatedAtUtc":"2025-11-02T00:00:00Z","updatedBy":"actor","createdAtUtc":"2025-11-02T00:00:00Z","createdBy":"actor"},"globalOverride":null,"effectiveWeight":2.0}"""),
|
||||
new HttpResponseMessage(HttpStatusCode.NoContent),
|
||||
CreateJsonResponse("""{"tenantOverride":null,"globalOverride":null,"effectiveWeight":0}"""));
|
||||
|
||||
var client = CreateClient(handler);
|
||||
|
||||
await client.GetIssuerTrustAsync("tenant-b", "issuer-9", includeGlobal: true, CancellationToken.None);
|
||||
handler.Requests.Should().HaveCount(1);
|
||||
|
||||
await client.DeleteIssuerTrustAsync("tenant-b", "issuer-9", " cleanup ", CancellationToken.None);
|
||||
handler.Requests.Should().HaveCount(2);
|
||||
|
||||
var deleteRequest = handler.Requests[1];
|
||||
deleteRequest.Method.Should().Be(HttpMethod.Delete);
|
||||
deleteRequest.Uri.Should().Be(new Uri("https://issuer-directory.local/issuer-directory/issuers/issuer-9/trust"));
|
||||
deleteRequest.Headers.TryGetValue("X-StellaOps-Tenant", out var tenantValues).Should().BeTrue();
|
||||
tenantValues!.Should().Equal("tenant-b");
|
||||
deleteRequest.Headers.TryGetValue("X-StellaOps-Reason", out var reasonValues).Should().BeTrue();
|
||||
reasonValues!.Should().Equal("cleanup");
|
||||
deleteRequest.Body.Should().BeNull();
|
||||
|
||||
await client.GetIssuerTrustAsync("tenant-b", "issuer-9", includeGlobal: true, CancellationToken.None);
|
||||
handler.Requests.Should().HaveCount(3);
|
||||
handler.Requests[2].Method.Should().Be(HttpMethod.Get);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
|
||||
using FluentAssertions;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Core.Tests;
|
||||
|
||||
public partial class IssuerDirectoryClientTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task SetIssuerTrustAsync_PropagatesFailureAndDoesNotEvictCacheAsync()
|
||||
{
|
||||
var handler = new RecordingHandler(
|
||||
CreateJsonResponse("""{"tenantOverride":null,"globalOverride":null,"effectiveWeight":0}"""),
|
||||
new HttpResponseMessage(HttpStatusCode.InternalServerError)
|
||||
{
|
||||
Content = new StringContent("{}", Encoding.UTF8, "application/json")
|
||||
});
|
||||
|
||||
var client = CreateClient(handler);
|
||||
|
||||
var cached = await client.GetIssuerTrustAsync("tenant-c", "issuer-err", includeGlobal: false, CancellationToken.None);
|
||||
cached.EffectiveWeight.Should().Be(0m);
|
||||
handler.Requests.Should().HaveCount(1);
|
||||
|
||||
await FluentActions.Invoking(() => client.SetIssuerTrustAsync("tenant-c", "issuer-err", 0.5m, null, CancellationToken.None).AsTask())
|
||||
.Should().ThrowAsync<HttpRequestException>();
|
||||
handler.Requests.Should().HaveCount(2);
|
||||
|
||||
await client.GetIssuerTrustAsync("tenant-c", "issuer-err", includeGlobal: false, CancellationToken.None);
|
||||
handler.Requests.Should().HaveCount(2, "cache should remain warm after failure");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
|
||||
using FluentAssertions;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Core.Tests;
|
||||
|
||||
public partial class IssuerDirectoryClientTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task SetIssuerTrustAsync_SendsAuditMetadataAndInvalidatesCacheAsync()
|
||||
{
|
||||
var handler = new RecordingHandler(
|
||||
CreateJsonResponse("""{"tenantOverride":null,"globalOverride":null,"effectiveWeight":0}"""),
|
||||
CreateJsonResponse("""{"tenantOverride":{"weight":1.5,"reason":"rollout","updatedAtUtc":"2025-11-03T00:00:00Z","updatedBy":"actor","createdAtUtc":"2025-11-03T00:00:00Z","createdBy":"actor"},"globalOverride":null,"effectiveWeight":1.5}"""),
|
||||
CreateJsonResponse("""{"tenantOverride":{"weight":1.5,"reason":"rollout","updatedAtUtc":"2025-11-03T00:00:00Z","updatedBy":"actor","createdAtUtc":"2025-11-03T00:00:00Z","createdBy":"actor"},"globalOverride":null,"effectiveWeight":1.5}"""));
|
||||
|
||||
var client = CreateClient(handler);
|
||||
|
||||
await client.GetIssuerTrustAsync("tenant-a", "issuer-1", includeGlobal: false, CancellationToken.None);
|
||||
handler.Requests.Should().HaveCount(1);
|
||||
|
||||
var result = await client.SetIssuerTrustAsync(" tenant-a ", " issuer-1 ", 1.5m, " rollout ", CancellationToken.None);
|
||||
result.EffectiveWeight.Should().Be(1.5m);
|
||||
handler.Requests.Should().HaveCount(2);
|
||||
|
||||
var putRequest = handler.Requests[1];
|
||||
putRequest.Method.Should().Be(HttpMethod.Put);
|
||||
putRequest.Uri.Should().Be(new Uri("https://issuer-directory.local/issuer-directory/issuers/issuer-1/trust"));
|
||||
putRequest.Headers.TryGetValue("X-StellaOps-Tenant", out var tenantValues).Should().BeTrue();
|
||||
tenantValues!.Should().Equal("tenant-a");
|
||||
putRequest.Headers.TryGetValue("X-StellaOps-Reason", out var reasonValues).Should().BeTrue();
|
||||
reasonValues!.Should().Equal("rollout");
|
||||
|
||||
using var document = JsonDocument.Parse(putRequest.Body ?? string.Empty);
|
||||
var root = document.RootElement;
|
||||
root.GetProperty("weight").GetDecimal().Should().Be(1.5m);
|
||||
root.GetProperty("reason").GetString().Should().Be("rollout");
|
||||
|
||||
await client.GetIssuerTrustAsync("tenant-a", "issuer-1", includeGlobal: false, CancellationToken.None);
|
||||
handler.Requests.Should().HaveCount(3);
|
||||
handler.Requests[2].Method.Should().Be(HttpMethod.Get);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task SetIssuerTrustAsync_InvalidatesBothCacheVariantsAsync()
|
||||
{
|
||||
var handler = new RecordingHandler(
|
||||
CreateJsonResponse("""{"tenantOverride":null,"globalOverride":null,"effectiveWeight":0}"""),
|
||||
CreateJsonResponse("""{"tenantOverride":null,"globalOverride":null,"effectiveWeight":0}"""),
|
||||
CreateJsonResponse("""{"tenantOverride":null,"globalOverride":null,"effectiveWeight":0}"""),
|
||||
CreateJsonResponse("""{"tenantOverride":null,"globalOverride":null,"effectiveWeight":0}"""),
|
||||
CreateJsonResponse("""{"tenantOverride":null,"globalOverride":null,"effectiveWeight":0}"""));
|
||||
|
||||
var client = CreateClient(handler);
|
||||
|
||||
await client.GetIssuerTrustAsync("tenant-a", "issuer-1", includeGlobal: false, CancellationToken.None);
|
||||
await client.GetIssuerTrustAsync("tenant-a", "issuer-1", includeGlobal: true, CancellationToken.None);
|
||||
handler.Requests.Should().HaveCount(2);
|
||||
|
||||
await client.SetIssuerTrustAsync("tenant-a", "issuer-1", 1m, null, CancellationToken.None);
|
||||
handler.Requests.Should().HaveCount(3);
|
||||
|
||||
await client.GetIssuerTrustAsync("tenant-a", "issuer-1", includeGlobal: false, CancellationToken.None);
|
||||
await client.GetIssuerTrustAsync("tenant-a", "issuer-1", includeGlobal: true, CancellationToken.None);
|
||||
handler.Requests.Should().HaveCount(5);
|
||||
handler.Requests[3].Method.Should().Be(HttpMethod.Get);
|
||||
handler.Requests[4].Method.Should().Be(HttpMethod.Get);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using FluentAssertions;
|
||||
|
||||
using StellaOps.IssuerDirectory.Core.Domain;
|
||||
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Core.Tests.Services;
|
||||
|
||||
public partial class IssuerDirectoryServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task CreateAsync_PersistsIssuerAndAuditEntryAsync()
|
||||
{
|
||||
var issuer = await _service.CreateAsync(
|
||||
tenantId: "tenant-a",
|
||||
issuerId: "red-hat",
|
||||
displayName: "Red Hat",
|
||||
slug: "red-hat",
|
||||
description: "Vendor",
|
||||
contact: new IssuerContact("sec@example.com", null, new Uri("https://example.com"), null),
|
||||
metadata: new IssuerMetadata("org", "publisher", new Uri("https://example.com/cve"), null, new[] { "en" }, null),
|
||||
endpoints: new[] { new IssuerEndpoint("csaf", new Uri("https://example.com/csaf"), "csaf", false) },
|
||||
tags: new[] { "vendor" },
|
||||
actor: "tester",
|
||||
reason: "initial",
|
||||
cancellationToken: CancellationToken.None);
|
||||
|
||||
var stored = await _repository.GetAsync("tenant-a", "red-hat", CancellationToken.None);
|
||||
stored.Should().NotBeNull();
|
||||
stored!.DisplayName.Should().Be("Red Hat");
|
||||
stored.CreatedBy.Should().Be("tester");
|
||||
|
||||
_auditSink.Entries.Should().ContainSingle(entry => entry.Action == "created" && entry.TenantId == "tenant-a");
|
||||
issuer.CreatedAtUtc.Should().Be(_timeProvider.GetUtcNow());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_ReplacesMetadataAndRecordsAuditAsync()
|
||||
{
|
||||
await CreateSampleAsync();
|
||||
_timeProvider.Advance(TimeSpan.FromHours(1));
|
||||
|
||||
var updated = await _service.UpdateAsync(
|
||||
tenantId: "tenant-a",
|
||||
issuerId: "red-hat",
|
||||
displayName: "Red Hat Security",
|
||||
description: "Updated vendor",
|
||||
contact: new IssuerContact("sec@example.com", null, new Uri("https://example.com/security"), null),
|
||||
metadata: new IssuerMetadata("org", "publisher", new Uri("https://example.com/new"), null, new[] { "en", "de" }, null),
|
||||
endpoints: new[] { new IssuerEndpoint("csaf", new Uri("https://example.com/csaf"), "csaf", false) },
|
||||
tags: new[] { "vendor", "trusted" },
|
||||
actor: "editor",
|
||||
reason: "update",
|
||||
cancellationToken: CancellationToken.None);
|
||||
|
||||
updated.DisplayName.Should().Be("Red Hat Security");
|
||||
updated.Tags.Should().Contain(new[] { "vendor", "trusted" });
|
||||
updated.UpdatedBy.Should().Be("editor");
|
||||
updated.UpdatedAtUtc.Should().Be(_timeProvider.GetUtcNow());
|
||||
|
||||
_auditSink.Entries.Should().Contain(entry => entry.Action == "updated");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAsync_RemovesIssuerAndWritesAuditAsync()
|
||||
{
|
||||
await CreateSampleAsync();
|
||||
|
||||
await _service.DeleteAsync("tenant-a", "red-hat", "deleter", "cleanup", CancellationToken.None);
|
||||
|
||||
var stored = await _repository.GetAsync("tenant-a", "red-hat", CancellationToken.None);
|
||||
stored.Should().BeNull();
|
||||
|
||||
_auditSink.Entries.Should().Contain(entry => entry.Action == "deleted" && entry.Actor == "deleter");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Linq;
|
||||
|
||||
using StellaOps.IssuerDirectory.Core.Abstractions;
|
||||
using StellaOps.IssuerDirectory.Core.Domain;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Core.Tests.Services;
|
||||
|
||||
public partial class IssuerDirectoryServiceTests
|
||||
{
|
||||
private sealed class FakeIssuerRepository : IIssuerRepository
|
||||
{
|
||||
private readonly ConcurrentDictionary<(string Tenant, string Id), IssuerRecord> _store = new();
|
||||
|
||||
public Task<IssuerRecord?> GetAsync(string tenantId, string issuerId, CancellationToken cancellationToken)
|
||||
{
|
||||
_store.TryGetValue((tenantId, issuerId), out var record);
|
||||
return Task.FromResult(record);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyCollection<IssuerRecord>> ListAsync(string tenantId, CancellationToken cancellationToken)
|
||||
{
|
||||
var results = _store
|
||||
.Where(pair => pair.Key.Tenant.Equals(tenantId, StringComparison.Ordinal))
|
||||
.Select(pair => pair.Value)
|
||||
.ToArray();
|
||||
return Task.FromResult((IReadOnlyCollection<IssuerRecord>)results);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyCollection<IssuerRecord>> ListGlobalAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var results = _store
|
||||
.Where(pair => pair.Key.Tenant.Equals(IssuerTenants.Global, StringComparison.Ordinal))
|
||||
.Select(pair => pair.Value)
|
||||
.ToArray();
|
||||
return Task.FromResult((IReadOnlyCollection<IssuerRecord>)results);
|
||||
}
|
||||
|
||||
public Task UpsertAsync(IssuerRecord record, CancellationToken cancellationToken)
|
||||
{
|
||||
_store[(record.TenantId, record.Id)] = record;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task DeleteAsync(string tenantId, string issuerId, CancellationToken cancellationToken)
|
||||
{
|
||||
_store.TryRemove((tenantId, issuerId), out _);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeIssuerAuditSink : IIssuerAuditSink
|
||||
{
|
||||
private readonly ConcurrentBag<IssuerAuditEntry> _entries = new();
|
||||
|
||||
public IReadOnlyCollection<IssuerAuditEntry> Entries => _entries.ToArray();
|
||||
|
||||
public Task WriteAsync(IssuerAuditEntry entry, CancellationToken cancellationToken)
|
||||
{
|
||||
_entries.Add(entry);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void Clear() => _entries.Clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using FluentAssertions;
|
||||
|
||||
using StellaOps.IssuerDirectory.Core.Domain;
|
||||
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Core.Tests.Services;
|
||||
|
||||
public partial class IssuerDirectoryServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task SeedAsync_InsertsOnlyMissingSeedsAsync()
|
||||
{
|
||||
var seedRecord = IssuerRecord.Create(
|
||||
id: "red-hat",
|
||||
tenantId: IssuerTenants.Global,
|
||||
displayName: "Red Hat",
|
||||
slug: "red-hat",
|
||||
description: null,
|
||||
contact: new IssuerContact(null, null, null, null),
|
||||
metadata: new IssuerMetadata(null, null, null, null, Array.Empty<string>(), null),
|
||||
endpoints: Array.Empty<IssuerEndpoint>(),
|
||||
tags: Array.Empty<string>(),
|
||||
timestampUtc: _timeProvider.GetUtcNow(),
|
||||
actor: "seed",
|
||||
isSystemSeed: true);
|
||||
|
||||
await _service.SeedAsync(new[] { seedRecord }, CancellationToken.None);
|
||||
_auditSink.Entries.Should().Contain(entry => entry.Action == "seeded");
|
||||
|
||||
_auditSink.Clear();
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(10));
|
||||
|
||||
await _service.SeedAsync(new[] { seedRecord }, CancellationToken.None);
|
||||
_auditSink.Entries.Should().BeEmpty("existing seeds should not emit duplicate audit entries");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using System;
|
||||
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
|
||||
using StellaOps.IssuerDirectory.Core.Domain;
|
||||
using StellaOps.IssuerDirectory.Core.Services;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Core.Tests.Services;
|
||||
|
||||
public partial class IssuerDirectoryServiceTests
|
||||
{
|
||||
private readonly FakeIssuerRepository _repository = new();
|
||||
private readonly FakeIssuerAuditSink _auditSink = new();
|
||||
private readonly FakeTimeProvider _timeProvider = new(DateTimeOffset.Parse("2025-11-01T12:00:00Z"));
|
||||
private readonly IssuerDirectoryService _service;
|
||||
|
||||
public IssuerDirectoryServiceTests()
|
||||
{
|
||||
_service = new IssuerDirectoryService(_repository, _auditSink, _timeProvider, NullLogger<IssuerDirectoryService>.Instance);
|
||||
}
|
||||
|
||||
private async Task CreateSampleAsync()
|
||||
{
|
||||
await _service.CreateAsync(
|
||||
tenantId: "tenant-a",
|
||||
issuerId: "red-hat",
|
||||
displayName: "Red Hat",
|
||||
slug: "red-hat",
|
||||
description: "Vendor",
|
||||
contact: new IssuerContact("sec@example.com", null, new Uri("https://example.com"), null),
|
||||
metadata: new IssuerMetadata("org", "publisher", new Uri("https://example.com/cve"), null, new[] { "en" }, null),
|
||||
endpoints: new[] { new IssuerEndpoint("csaf", new Uri("https://example.com/csaf"), "csaf", false) },
|
||||
tags: new[] { "vendor" },
|
||||
actor: "tester",
|
||||
reason: "initial",
|
||||
cancellationToken: CancellationToken.None);
|
||||
|
||||
_auditSink.Clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Linq;
|
||||
|
||||
using StellaOps.IssuerDirectory.Core.Abstractions;
|
||||
using StellaOps.IssuerDirectory.Core.Domain;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Core.Tests.Services;
|
||||
|
||||
public partial class IssuerKeyServiceTests
|
||||
{
|
||||
private sealed class FakeIssuerRepository : IIssuerRepository
|
||||
{
|
||||
private readonly ConcurrentDictionary<(string Tenant, string Id), IssuerRecord> _store = new();
|
||||
|
||||
public void Add(IssuerRecord record)
|
||||
{
|
||||
_store[(record.TenantId, record.Id)] = record;
|
||||
}
|
||||
|
||||
public Task<IssuerRecord?> GetAsync(string tenantId, string issuerId, CancellationToken cancellationToken)
|
||||
{
|
||||
_store.TryGetValue((tenantId, issuerId), out var record);
|
||||
return Task.FromResult(record);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyCollection<IssuerRecord>> ListAsync(string tenantId, CancellationToken cancellationToken)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public Task<IReadOnlyCollection<IssuerRecord>> ListGlobalAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public Task UpsertAsync(IssuerRecord record, CancellationToken cancellationToken)
|
||||
{
|
||||
_store[(record.TenantId, record.Id)] = record;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task DeleteAsync(string tenantId, string issuerId, CancellationToken cancellationToken)
|
||||
{
|
||||
_store.TryRemove((tenantId, issuerId), out _);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeIssuerKeyRepository : IIssuerKeyRepository
|
||||
{
|
||||
private readonly ConcurrentDictionary<(string Tenant, string Issuer, string KeyId), IssuerKeyRecord> _store = new();
|
||||
|
||||
public Task<IssuerKeyRecord?> GetAsync(string tenantId, string issuerId, string keyId, CancellationToken cancellationToken)
|
||||
{
|
||||
_store.TryGetValue((tenantId, issuerId, keyId), out var value);
|
||||
return Task.FromResult(value);
|
||||
}
|
||||
|
||||
public Task<IssuerKeyRecord?> GetByFingerprintAsync(string tenantId, string issuerId, string fingerprint, CancellationToken cancellationToken)
|
||||
{
|
||||
var record = _store.Values.FirstOrDefault(key => key.TenantId == tenantId && key.IssuerId == issuerId && key.Fingerprint == fingerprint);
|
||||
return Task.FromResult(record);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyCollection<IssuerKeyRecord>> ListAsync(string tenantId, string issuerId, CancellationToken cancellationToken)
|
||||
{
|
||||
var records = _store
|
||||
.Where(pair => pair.Key.Tenant == tenantId && pair.Key.Issuer == issuerId)
|
||||
.Select(pair => pair.Value)
|
||||
.ToArray();
|
||||
|
||||
return Task.FromResult((IReadOnlyCollection<IssuerKeyRecord>)records);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyCollection<IssuerKeyRecord>> ListGlobalAsync(string issuerId, CancellationToken cancellationToken)
|
||||
{
|
||||
var records = _store
|
||||
.Where(pair => pair.Key.Tenant == IssuerTenants.Global && pair.Key.Issuer == issuerId)
|
||||
.Select(pair => pair.Value)
|
||||
.ToArray();
|
||||
|
||||
return Task.FromResult((IReadOnlyCollection<IssuerKeyRecord>)records);
|
||||
}
|
||||
|
||||
public Task UpsertAsync(IssuerKeyRecord record, CancellationToken cancellationToken)
|
||||
{
|
||||
_store[(record.TenantId, record.IssuerId, record.Id)] = record;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeIssuerAuditSink : IIssuerAuditSink
|
||||
{
|
||||
private readonly ConcurrentBag<IssuerAuditEntry> _entries = new();
|
||||
|
||||
public IReadOnlyCollection<IssuerAuditEntry> Entries => _entries.ToArray();
|
||||
|
||||
public Task WriteAsync(IssuerAuditEntry entry, CancellationToken cancellationToken)
|
||||
{
|
||||
_entries.Add(entry);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
using FluentAssertions;
|
||||
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
|
||||
using StellaOps.IssuerDirectory.Core.Domain;
|
||||
using StellaOps.IssuerDirectory.Core.Services;
|
||||
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Core.Tests.Services;
|
||||
|
||||
public partial class IssuerKeyServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task AddAsync_StoresKeyAndWritesAuditAsync()
|
||||
{
|
||||
var material = new IssuerKeyMaterial("base64", Convert.ToBase64String(new byte[32]));
|
||||
|
||||
var record = await _service.AddAsync(
|
||||
tenantId: "tenant-a",
|
||||
issuerId: "red-hat",
|
||||
type: IssuerKeyType.Ed25519PublicKey,
|
||||
material,
|
||||
expiresAtUtc: null,
|
||||
actor: "tester",
|
||||
reason: "initial",
|
||||
cancellationToken: CancellationToken.None);
|
||||
|
||||
record.Status.Should().Be(IssuerKeyStatus.Active);
|
||||
record.Fingerprint.Should().NotBeNullOrWhiteSpace();
|
||||
_auditSink.Entries.Should().Contain(entry => entry.Action == "key_created");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddAsync_DuplicateFingerprint_ThrowsAsync()
|
||||
{
|
||||
var material = new IssuerKeyMaterial("base64", Convert.ToBase64String(new byte[32]));
|
||||
|
||||
await _service.AddAsync("tenant-a", "red-hat", IssuerKeyType.Ed25519PublicKey, material, null, "tester", null, CancellationToken.None);
|
||||
|
||||
var action = async () => await _service.AddAsync("tenant-a", "red-hat", IssuerKeyType.Ed25519PublicKey, material, null, "tester", null, CancellationToken.None);
|
||||
|
||||
await action.Should().ThrowAsync<InvalidOperationException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddAsync_MissingIssuer_ThrowsAsync()
|
||||
{
|
||||
var issuerRepository = new FakeIssuerRepository();
|
||||
var keyRepository = new FakeIssuerKeyRepository();
|
||||
var auditSink = new FakeIssuerAuditSink();
|
||||
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-01T12:00:00Z"));
|
||||
var service = new IssuerKeyService(
|
||||
issuerRepository,
|
||||
keyRepository,
|
||||
auditSink,
|
||||
timeProvider,
|
||||
NullLogger<IssuerKeyService>.Instance);
|
||||
|
||||
var material = new IssuerKeyMaterial("base64", Convert.ToBase64String(new byte[32]));
|
||||
var action = async () => await service.AddAsync(
|
||||
"tenant-a",
|
||||
"missing",
|
||||
IssuerKeyType.Ed25519PublicKey,
|
||||
material,
|
||||
null,
|
||||
"tester",
|
||||
null,
|
||||
CancellationToken.None);
|
||||
|
||||
await action.Should().ThrowAsync<InvalidOperationException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RotateAsync_RetiresOldKeyAndCreatesReplacementAsync()
|
||||
{
|
||||
var originalMaterial = new IssuerKeyMaterial("base64", Convert.ToBase64String(new byte[32] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32 }));
|
||||
var original = await _service.AddAsync("tenant-a", "red-hat", IssuerKeyType.Ed25519PublicKey, originalMaterial, null, "tester", null, CancellationToken.None);
|
||||
|
||||
var newMaterial = new IssuerKeyMaterial("base64", Convert.ToBase64String(Enumerable.Repeat<byte>(99, 32).ToArray()));
|
||||
var replacement = await _service.RotateAsync("tenant-a", "red-hat", original.Id, IssuerKeyType.Ed25519PublicKey, newMaterial, null, "tester", "rotation", CancellationToken.None);
|
||||
|
||||
replacement.ReplacesKeyId.Should().Be(original.Id);
|
||||
var retired = await _keyRepository.GetAsync("tenant-a", "red-hat", original.Id, CancellationToken.None);
|
||||
retired!.Status.Should().Be(IssuerKeyStatus.Retired);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RevokeAsync_SetsStatusToRevokedAsync()
|
||||
{
|
||||
var material = new IssuerKeyMaterial("base64", Convert.ToBase64String(Enumerable.Repeat<byte>(77, 32).ToArray()));
|
||||
var key = await _service.AddAsync("tenant-a", "red-hat", IssuerKeyType.Ed25519PublicKey, material, null, "tester", null, CancellationToken.None);
|
||||
|
||||
await _service.RevokeAsync("tenant-a", "red-hat", key.Id, "tester", "compromised", CancellationToken.None);
|
||||
|
||||
var revoked = await _keyRepository.GetAsync("tenant-a", "red-hat", key.Id, CancellationToken.None);
|
||||
revoked!.Status.Should().Be(IssuerKeyStatus.Revoked);
|
||||
_auditSink.Entries.Should().Contain(entry => entry.Action == "key_revoked");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using System;
|
||||
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
|
||||
using StellaOps.IssuerDirectory.Core.Domain;
|
||||
using StellaOps.IssuerDirectory.Core.Services;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Core.Tests.Services;
|
||||
|
||||
public partial class IssuerKeyServiceTests
|
||||
{
|
||||
private readonly FakeIssuerRepository _issuerRepository = new();
|
||||
private readonly FakeIssuerKeyRepository _keyRepository = new();
|
||||
private readonly FakeIssuerAuditSink _auditSink = new();
|
||||
private readonly FakeTimeProvider _timeProvider = new(DateTimeOffset.Parse("2025-11-01T12:00:00Z"));
|
||||
private readonly IssuerKeyService _service;
|
||||
|
||||
public IssuerKeyServiceTests()
|
||||
{
|
||||
_service = new IssuerKeyService(
|
||||
_issuerRepository,
|
||||
_keyRepository,
|
||||
_auditSink,
|
||||
_timeProvider,
|
||||
NullLogger<IssuerKeyService>.Instance);
|
||||
|
||||
var issuer = IssuerRecord.Create(
|
||||
id: "red-hat",
|
||||
tenantId: "tenant-a",
|
||||
displayName: "Red Hat",
|
||||
slug: "red-hat",
|
||||
description: null,
|
||||
contact: new IssuerContact(null, null, null, null),
|
||||
metadata: new IssuerMetadata(null, null, null, null, Array.Empty<string>(), null),
|
||||
endpoints: Array.Empty<IssuerEndpoint>(),
|
||||
tags: Array.Empty<string>(),
|
||||
timestampUtc: _timeProvider.GetUtcNow(),
|
||||
actor: "seed",
|
||||
isSystemSeed: false);
|
||||
|
||||
_issuerRepository.Add(issuer);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
using StellaOps.IssuerDirectory.Core.Abstractions;
|
||||
using StellaOps.IssuerDirectory.Core.Domain;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Core.Tests.Services;
|
||||
|
||||
public partial class IssuerTrustServiceTests
|
||||
{
|
||||
private sealed class FakeIssuerRepository : IIssuerRepository
|
||||
{
|
||||
private readonly ConcurrentDictionary<(string Tenant, string Id), IssuerRecord> _store = new();
|
||||
|
||||
public void Add(IssuerRecord record) => _store[(record.TenantId, record.Id)] = record;
|
||||
|
||||
public Task<IssuerRecord?> GetAsync(string tenantId, string issuerId, CancellationToken cancellationToken)
|
||||
{
|
||||
_store.TryGetValue((tenantId, issuerId), out var record);
|
||||
return Task.FromResult(record);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyCollection<IssuerRecord>> ListAsync(string tenantId, CancellationToken cancellationToken)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public Task<IReadOnlyCollection<IssuerRecord>> ListGlobalAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public Task UpsertAsync(IssuerRecord record, CancellationToken cancellationToken)
|
||||
{
|
||||
_store[(record.TenantId, record.Id)] = record;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task DeleteAsync(string tenantId, string issuerId, CancellationToken cancellationToken)
|
||||
{
|
||||
_store.TryRemove((tenantId, issuerId), out _);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeIssuerTrustRepository : IIssuerTrustRepository
|
||||
{
|
||||
private readonly ConcurrentDictionary<(string Tenant, string Issuer), IssuerTrustOverrideRecord> _store = new();
|
||||
|
||||
public Task<IssuerTrustOverrideRecord?> GetAsync(string tenantId, string issuerId, CancellationToken cancellationToken)
|
||||
{
|
||||
_store.TryGetValue((tenantId, issuerId), out var record);
|
||||
return Task.FromResult(record);
|
||||
}
|
||||
|
||||
public Task UpsertAsync(IssuerTrustOverrideRecord record, CancellationToken cancellationToken)
|
||||
{
|
||||
_store[(record.TenantId, record.IssuerId)] = record;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task DeleteAsync(string tenantId, string issuerId, CancellationToken cancellationToken)
|
||||
{
|
||||
_store.TryRemove((tenantId, issuerId), out _);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeIssuerAuditSink : IIssuerAuditSink
|
||||
{
|
||||
private readonly ConcurrentBag<IssuerAuditEntry> _entries = new();
|
||||
|
||||
public IReadOnlyCollection<IssuerAuditEntry> Entries => _entries.ToArray();
|
||||
|
||||
public Task WriteAsync(IssuerAuditEntry entry, CancellationToken cancellationToken)
|
||||
{
|
||||
_entries.Add(entry);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using FluentAssertions;
|
||||
|
||||
using StellaOps.IssuerDirectory.Core.Domain;
|
||||
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Core.Tests.Services;
|
||||
|
||||
public partial class IssuerTrustServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task SetAsync_SavesOverrideWithinBoundsAsync()
|
||||
{
|
||||
var result = await _service.SetAsync("tenant-a", "issuer-1", 4.5m, "reason", "actor", CancellationToken.None);
|
||||
|
||||
result.Weight.Should().Be(4.5m);
|
||||
result.UpdatedBy.Should().Be("actor");
|
||||
|
||||
var view = await _service.GetAsync("tenant-a", "issuer-1", includeGlobal: true, CancellationToken.None);
|
||||
view.EffectiveWeight.Should().Be(4.5m);
|
||||
_auditSink.Entries.Should().Contain(entry => entry.Action == "trust_override_set");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetAsync_InvalidWeight_ThrowsAsync()
|
||||
{
|
||||
var action = async () => await _service.SetAsync("tenant-a", "issuer-1", 20m, null, "actor", CancellationToken.None);
|
||||
await action.Should().ThrowAsync<InvalidOperationException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_FallsBackToGlobalAsync()
|
||||
{
|
||||
await _service.SetAsync(IssuerTenants.Global, "issuer-1", -2m, null, "seed", CancellationToken.None);
|
||||
|
||||
var view = await _service.GetAsync("tenant-b", "issuer-1", includeGlobal: true, CancellationToken.None);
|
||||
view.EffectiveWeight.Should().Be(-2m);
|
||||
view.GlobalOverride.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAsync_RemovesOverrideAsync()
|
||||
{
|
||||
await _service.SetAsync("tenant-a", "issuer-1", 1m, null, "actor", CancellationToken.None);
|
||||
|
||||
await _service.DeleteAsync("tenant-a", "issuer-1", "actor", "clearing", CancellationToken.None);
|
||||
|
||||
var view = await _service.GetAsync("tenant-a", "issuer-1", includeGlobal: false, CancellationToken.None);
|
||||
view.TenantOverride.Should().BeNull();
|
||||
_auditSink.Entries.Should().Contain(entry => entry.Action == "trust_override_deleted");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using System;
|
||||
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
|
||||
using StellaOps.IssuerDirectory.Core.Domain;
|
||||
using StellaOps.IssuerDirectory.Core.Services;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Core.Tests.Services;
|
||||
|
||||
public partial class IssuerTrustServiceTests
|
||||
{
|
||||
private readonly FakeIssuerRepository _issuerRepository = new();
|
||||
private readonly FakeIssuerTrustRepository _trustRepository = new();
|
||||
private readonly FakeIssuerAuditSink _auditSink = new();
|
||||
private readonly FakeTimeProvider _timeProvider = new(DateTimeOffset.Parse("2025-11-01T00:00:00Z"));
|
||||
private readonly IssuerTrustService _service;
|
||||
|
||||
public IssuerTrustServiceTests()
|
||||
{
|
||||
_service = new IssuerTrustService(_issuerRepository, _trustRepository, _auditSink, _timeProvider);
|
||||
|
||||
var issuer = IssuerRecord.Create(
|
||||
id: "issuer-1",
|
||||
tenantId: "tenant-a",
|
||||
displayName: "Issuer",
|
||||
slug: "issuer",
|
||||
description: null,
|
||||
contact: new IssuerContact(null, null, null, null),
|
||||
metadata: new IssuerMetadata(null, null, null, null, Array.Empty<string>(), null),
|
||||
endpoints: Array.Empty<IssuerEndpoint>(),
|
||||
tags: Array.Empty<string>(),
|
||||
timestampUtc: _timeProvider.GetUtcNow(),
|
||||
actor: "seed",
|
||||
isSystemSeed: false);
|
||||
|
||||
_issuerRepository.Add(issuer);
|
||||
_issuerRepository.Add(issuer with { TenantId = IssuerTenants.Global, IsSystemSeed = true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<IsPackable>false</IsPackable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.IssuerDirectory.Core\StellaOps.IssuerDirectory.Core.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.IssuerDirectory.Client\StellaOps.IssuerDirectory.Client.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,12 @@
|
||||
# StellaOps.IssuerDirectory.Core.Tests Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0374-M | DONE | Revalidated 2026-01-07; maintainability audit for IssuerDirectory.Core.Tests. |
|
||||
| AUDIT-0374-T | DONE | Revalidated 2026-01-07; test coverage audit for IssuerDirectory.Core.Tests. |
|
||||
| AUDIT-0374-A | DONE | Waived (test project; revalidated 2026-01-07). |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
| REMED-07 | DONE | 2026-02-04: Split client/service test fixtures and added cache coverage (SPRINT_20260130_002). |
|
||||
@@ -0,0 +1,37 @@
|
||||
using FluentAssertions;
|
||||
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
|
||||
using StellaOps.IssuerDirectory.Core.Domain;
|
||||
using StellaOps.IssuerDirectory.Core.Validation;
|
||||
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Core.Tests.Validation;
|
||||
|
||||
public class IssuerKeyValidatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Validate_Ed25519RejectsInvalidBase64()
|
||||
{
|
||||
var material = new IssuerKeyMaterial("base64", "not-base64");
|
||||
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-01T00:00:00Z"));
|
||||
|
||||
var action = () => IssuerKeyValidator.Validate(IssuerKeyType.Ed25519PublicKey, material, null, timeProvider);
|
||||
|
||||
action.Should().Throw<InvalidOperationException>()
|
||||
.WithMessage("*base64*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_DsseRejectsInvalidLength()
|
||||
{
|
||||
var material = new IssuerKeyMaterial("base64", Convert.ToBase64String(new byte[10]));
|
||||
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-01T00:00:00Z"));
|
||||
|
||||
var action = () => IssuerKeyValidator.Validate(IssuerKeyType.DssePublicKey, material, null, timeProvider);
|
||||
|
||||
action.Should().Throw<InvalidOperationException>()
|
||||
.WithMessage("*DSSE*");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
# IssuerDirectory Core Agent Charter
|
||||
|
||||
## Mission
|
||||
- Provide core domain and service logic for issuer metadata, keys, and trust overrides.
|
||||
|
||||
## Responsibilities
|
||||
- Keep domain invariants explicit and consistently validated.
|
||||
- Preserve deterministic ordering where results are enumerated.
|
||||
- Ensure audit and metrics hooks stay consistent with write operations.
|
||||
|
||||
## Required Reading
|
||||
- docs/07_HIGH_LEVEL_ARCHITECTURE.md
|
||||
- docs/modules/issuer-directory/architecture.md
|
||||
- docs/modules/platform/architecture-overview.md
|
||||
|
||||
## Working Directory & Scope
|
||||
- Primary: src/IssuerDirectory/StellaOps.IssuerDirectory/StellaOps.IssuerDirectory.Core
|
||||
- Allowed shared projects: src/IssuerDirectory/__Libraries, src/IssuerDirectory/__Tests
|
||||
|
||||
## Testing Expectations
|
||||
- Cover domain validation and service failure paths.
|
||||
- Validate audit entry metadata for create/update/delete/rotate/revoke flows.
|
||||
|
||||
## Working Agreement
|
||||
- Update sprint status in docs/implplan/SPRINT_*.md and local TASKS.md.
|
||||
- Keep outputs deterministic and avoid non-ASCII logs.
|
||||
@@ -0,0 +1,12 @@
|
||||
using StellaOps.IssuerDirectory.Core.Domain;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Core.Abstractions;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Persists audit events describing issuer changes.
|
||||
/// </summary>
|
||||
public interface IIssuerAuditSink
|
||||
{
|
||||
Task WriteAsync(IssuerAuditEntry entry, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using StellaOps.IssuerDirectory.Core.Domain;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Core.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Repository abstraction for issuer key persistence.
|
||||
/// </summary>
|
||||
public interface IIssuerKeyRepository
|
||||
{
|
||||
Task<IssuerKeyRecord?> GetAsync(string tenantId, string issuerId, string keyId, CancellationToken cancellationToken);
|
||||
|
||||
Task<IssuerKeyRecord?> GetByFingerprintAsync(string tenantId, string issuerId, string fingerprint, CancellationToken cancellationToken);
|
||||
|
||||
Task<IReadOnlyCollection<IssuerKeyRecord>> ListAsync(string tenantId, string issuerId, CancellationToken cancellationToken);
|
||||
|
||||
Task<IReadOnlyCollection<IssuerKeyRecord>> ListGlobalAsync(string issuerId, CancellationToken cancellationToken);
|
||||
|
||||
Task UpsertAsync(IssuerKeyRecord record, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using StellaOps.IssuerDirectory.Core.Domain;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Core.Abstractions;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Repository abstraction for issuer directory persistence.
|
||||
/// </summary>
|
||||
public interface IIssuerRepository
|
||||
{
|
||||
Task<IssuerRecord?> GetAsync(string tenantId, string issuerId, CancellationToken cancellationToken);
|
||||
|
||||
Task<IReadOnlyCollection<IssuerRecord>> ListAsync(string tenantId, CancellationToken cancellationToken);
|
||||
|
||||
Task<IReadOnlyCollection<IssuerRecord>> ListGlobalAsync(CancellationToken cancellationToken);
|
||||
|
||||
Task UpsertAsync(IssuerRecord record, CancellationToken cancellationToken);
|
||||
|
||||
Task DeleteAsync(string tenantId, string issuerId, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using StellaOps.IssuerDirectory.Core.Domain;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Core.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Repository abstraction for trust weight overrides.
|
||||
/// </summary>
|
||||
public interface IIssuerTrustRepository
|
||||
{
|
||||
Task<IssuerTrustOverrideRecord?> GetAsync(string tenantId, string issuerId, CancellationToken cancellationToken);
|
||||
|
||||
Task UpsertAsync(IssuerTrustOverrideRecord record, CancellationToken cancellationToken);
|
||||
|
||||
Task DeleteAsync(string tenantId, string issuerId, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
namespace StellaOps.IssuerDirectory.Core.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an audit log describing an issuer change.
|
||||
/// </summary>
|
||||
public sealed class IssuerAuditEntry
|
||||
{
|
||||
public IssuerAuditEntry(
|
||||
string tenantId,
|
||||
string issuerId,
|
||||
string action,
|
||||
DateTimeOffset timestampUtc,
|
||||
string actor,
|
||||
string? reason,
|
||||
IReadOnlyDictionary<string, string>? metadata)
|
||||
{
|
||||
TenantId = Normalize(tenantId, nameof(tenantId));
|
||||
IssuerId = Normalize(issuerId, nameof(issuerId));
|
||||
Action = Normalize(action, nameof(action));
|
||||
TimestampUtc = timestampUtc.ToUniversalTime();
|
||||
Actor = Normalize(actor, nameof(actor));
|
||||
Reason = string.IsNullOrWhiteSpace(reason) ? null : reason.Trim();
|
||||
Metadata = metadata is null
|
||||
? new Dictionary<string, string>()
|
||||
: new Dictionary<string, string>(metadata, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public string TenantId { get; }
|
||||
|
||||
public string IssuerId { get; }
|
||||
|
||||
public string Action { get; }
|
||||
|
||||
public DateTimeOffset TimestampUtc { get; }
|
||||
|
||||
public string Actor { get; }
|
||||
|
||||
public string? Reason { get; }
|
||||
|
||||
public IReadOnlyDictionary<string, string> Metadata { get; }
|
||||
|
||||
private static string Normalize(string value, string argumentName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new ArgumentException("Value is required.", argumentName);
|
||||
}
|
||||
|
||||
return value.Trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace StellaOps.IssuerDirectory.Core.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// Contact information for a publisher or issuer.
|
||||
/// </summary>
|
||||
public sealed class IssuerContact
|
||||
{
|
||||
public IssuerContact(string? email, string? phone, Uri? website, string? timezone)
|
||||
{
|
||||
Email = Normalize(email);
|
||||
Phone = Normalize(phone);
|
||||
Website = website;
|
||||
Timezone = Normalize(timezone);
|
||||
}
|
||||
|
||||
public string? Email { get; }
|
||||
|
||||
public string? Phone { get; }
|
||||
|
||||
public Uri? Website { get; }
|
||||
|
||||
public string? Timezone { get; }
|
||||
|
||||
private static string? Normalize(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
namespace StellaOps.IssuerDirectory.Core.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an endpoint that exposes attestation or CSAF data for an issuer.
|
||||
/// </summary>
|
||||
public sealed class IssuerEndpoint
|
||||
{
|
||||
public IssuerEndpoint(string kind, Uri url, string? format, bool requiresAuthentication)
|
||||
{
|
||||
Kind = Normalize(kind, nameof(kind));
|
||||
Url = url ?? throw new ArgumentNullException(nameof(url));
|
||||
Format = string.IsNullOrWhiteSpace(format) ? null : format.Trim().ToLowerInvariant();
|
||||
RequiresAuthentication = requiresAuthentication;
|
||||
}
|
||||
|
||||
public string Kind { get; }
|
||||
|
||||
public Uri Url { get; }
|
||||
|
||||
public string? Format { get; }
|
||||
|
||||
public bool RequiresAuthentication { get; }
|
||||
|
||||
private static string Normalize(string value, string argumentName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new ArgumentException("Value is required.", argumentName);
|
||||
}
|
||||
|
||||
return value.Trim().ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace StellaOps.IssuerDirectory.Core.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the encoded key material.
|
||||
/// </summary>
|
||||
public sealed record IssuerKeyMaterial(string Format, string Value)
|
||||
{
|
||||
public string Format { get; } = Normalize(Format, nameof(Format));
|
||||
|
||||
public string Value { get; } = Normalize(Value, nameof(Value));
|
||||
|
||||
private static string Normalize(string value, string argumentName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new ArgumentException("Value must be provided.", argumentName);
|
||||
}
|
||||
|
||||
return value.Trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Core.Domain;
|
||||
|
||||
public sealed partial record IssuerKeyRecord
|
||||
{
|
||||
public static IssuerKeyRecord Create(
|
||||
string id,
|
||||
string issuerId,
|
||||
string tenantId,
|
||||
IssuerKeyType type,
|
||||
IssuerKeyMaterial material,
|
||||
string fingerprint,
|
||||
DateTimeOffset createdAtUtc,
|
||||
string createdBy,
|
||||
DateTimeOffset? expiresAtUtc,
|
||||
string? replacesKeyId)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(id);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(issuerId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentNullException.ThrowIfNull(material);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(fingerprint);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(createdBy);
|
||||
|
||||
return new IssuerKeyRecord
|
||||
{
|
||||
Id = id.Trim(),
|
||||
IssuerId = issuerId.Trim(),
|
||||
TenantId = tenantId.Trim(),
|
||||
Type = type,
|
||||
Status = IssuerKeyStatus.Active,
|
||||
Material = material,
|
||||
Fingerprint = fingerprint.Trim(),
|
||||
CreatedAtUtc = createdAtUtc,
|
||||
CreatedBy = createdBy.Trim(),
|
||||
UpdatedAtUtc = createdAtUtc,
|
||||
UpdatedBy = createdBy.Trim(),
|
||||
ExpiresAtUtc = expiresAtUtc?.ToUniversalTime(),
|
||||
RetiredAtUtc = null,
|
||||
RevokedAtUtc = null,
|
||||
ReplacesKeyId = string.IsNullOrWhiteSpace(replacesKeyId) ? null : replacesKeyId.Trim()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Core.Domain;
|
||||
|
||||
public sealed partial record IssuerKeyRecord
|
||||
{
|
||||
public IssuerKeyRecord WithStatus(
|
||||
IssuerKeyStatus status,
|
||||
DateTimeOffset timestampUtc,
|
||||
string updatedBy)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(updatedBy);
|
||||
|
||||
return status switch
|
||||
{
|
||||
IssuerKeyStatus.Active => this with
|
||||
{
|
||||
Status = status,
|
||||
UpdatedAtUtc = timestampUtc,
|
||||
UpdatedBy = updatedBy.Trim(),
|
||||
RetiredAtUtc = null,
|
||||
RevokedAtUtc = null
|
||||
},
|
||||
IssuerKeyStatus.Retired => this with
|
||||
{
|
||||
Status = status,
|
||||
UpdatedAtUtc = timestampUtc,
|
||||
UpdatedBy = updatedBy.Trim(),
|
||||
RetiredAtUtc = timestampUtc,
|
||||
RevokedAtUtc = null
|
||||
},
|
||||
IssuerKeyStatus.Revoked => this with
|
||||
{
|
||||
Status = status,
|
||||
UpdatedAtUtc = timestampUtc,
|
||||
UpdatedBy = updatedBy.Trim(),
|
||||
RevokedAtUtc = timestampUtc
|
||||
},
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(status), status, "Unsupported key status.")
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
namespace StellaOps.IssuerDirectory.Core.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an issuer signing key.
|
||||
/// </summary>
|
||||
public sealed partial record IssuerKeyRecord
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
|
||||
public required string IssuerId { get; init; }
|
||||
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
public required IssuerKeyType Type { get; init; }
|
||||
|
||||
public required IssuerKeyStatus Status { get; init; }
|
||||
|
||||
public required IssuerKeyMaterial Material { get; init; }
|
||||
|
||||
public required string Fingerprint { get; init; }
|
||||
|
||||
public required DateTimeOffset CreatedAtUtc { get; init; }
|
||||
|
||||
public required string CreatedBy { get; init; }
|
||||
|
||||
public required DateTimeOffset UpdatedAtUtc { get; init; }
|
||||
|
||||
public required string UpdatedBy { get; init; }
|
||||
|
||||
public DateTimeOffset? ExpiresAtUtc { get; init; }
|
||||
|
||||
public DateTimeOffset? RetiredAtUtc { get; init; }
|
||||
|
||||
public DateTimeOffset? RevokedAtUtc { get; init; }
|
||||
|
||||
public string? ReplacesKeyId { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace StellaOps.IssuerDirectory.Core.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// Lifecycle status for issuer keys.
|
||||
/// </summary>
|
||||
public enum IssuerKeyStatus
|
||||
{
|
||||
Active,
|
||||
Retired,
|
||||
Revoked
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace StellaOps.IssuerDirectory.Core.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// Supported issuer key kinds.
|
||||
/// </summary>
|
||||
public enum IssuerKeyType
|
||||
{
|
||||
Ed25519PublicKey,
|
||||
X509Certificate,
|
||||
DssePublicKey
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using System.Collections.ObjectModel;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Core.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// Domain metadata describing issuer provenance and publication capabilities.
|
||||
/// </summary>
|
||||
public sealed class IssuerMetadata
|
||||
{
|
||||
private readonly IReadOnlyCollection<string> _languages;
|
||||
|
||||
public IssuerMetadata(
|
||||
string? cveOrgId,
|
||||
string? csafPublisherId,
|
||||
Uri? securityAdvisoriesUrl,
|
||||
Uri? catalogUrl,
|
||||
IEnumerable<string>? supportedLanguages,
|
||||
IDictionary<string, string>? attributes)
|
||||
{
|
||||
CveOrgId = Normalize(cveOrgId);
|
||||
CsafPublisherId = Normalize(csafPublisherId);
|
||||
SecurityAdvisoriesUrl = securityAdvisoriesUrl;
|
||||
CatalogUrl = catalogUrl;
|
||||
_languages = BuildLanguages(supportedLanguages);
|
||||
Attributes = attributes is null
|
||||
? new ReadOnlyDictionary<string, string>(new Dictionary<string, string>())
|
||||
: new ReadOnlyDictionary<string, string>(
|
||||
attributes.ToDictionary(
|
||||
pair => pair.Key.Trim(),
|
||||
pair => pair.Value.Trim()));
|
||||
}
|
||||
|
||||
public string? CveOrgId { get; }
|
||||
|
||||
public string? CsafPublisherId { get; }
|
||||
|
||||
public Uri? SecurityAdvisoriesUrl { get; }
|
||||
|
||||
public Uri? CatalogUrl { get; }
|
||||
|
||||
public IReadOnlyCollection<string> SupportedLanguages => _languages;
|
||||
|
||||
public IReadOnlyDictionary<string, string> Attributes { get; }
|
||||
|
||||
private static string? Normalize(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
|
||||
private static IReadOnlyCollection<string> BuildLanguages(IEnumerable<string>? languages)
|
||||
{
|
||||
var normalized = languages?
|
||||
.Select(language => language?.Trim())
|
||||
.Where(language => !string.IsNullOrWhiteSpace(language))
|
||||
.Select(language => language!.ToLowerInvariant())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray() ?? Array.Empty<string>();
|
||||
|
||||
return new ReadOnlyCollection<string>(normalized);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Core.Domain;
|
||||
|
||||
public sealed partial record IssuerRecord
|
||||
{
|
||||
public static IssuerRecord Create(
|
||||
string id,
|
||||
string tenantId,
|
||||
string displayName,
|
||||
string slug,
|
||||
string? description,
|
||||
IssuerContact contact,
|
||||
IssuerMetadata metadata,
|
||||
IEnumerable<IssuerEndpoint>? endpoints,
|
||||
IEnumerable<string>? tags,
|
||||
DateTimeOffset timestampUtc,
|
||||
string actor,
|
||||
bool isSystemSeed)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(id))
|
||||
{
|
||||
throw new ArgumentException("Identifier is required.", nameof(id));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
throw new ArgumentException("Tenant must be provided.", nameof(tenantId));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(displayName))
|
||||
{
|
||||
throw new ArgumentException("Display name is required.", nameof(displayName));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(slug))
|
||||
{
|
||||
throw new ArgumentException("Slug is required.", nameof(slug));
|
||||
}
|
||||
|
||||
if (contact is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(contact));
|
||||
}
|
||||
|
||||
if (metadata is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(metadata));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(actor))
|
||||
{
|
||||
throw new ArgumentException("Actor is required.", nameof(actor));
|
||||
}
|
||||
|
||||
var normalizedTags = NormalizeTags(tags);
|
||||
|
||||
return new IssuerRecord
|
||||
{
|
||||
Id = id.Trim(),
|
||||
TenantId = tenantId.Trim(),
|
||||
DisplayName = displayName.Trim(),
|
||||
Slug = slug.Trim().ToLowerInvariant(),
|
||||
Description = string.IsNullOrWhiteSpace(description) ? null : description.Trim(),
|
||||
Contact = contact,
|
||||
Metadata = metadata,
|
||||
Endpoints = (endpoints ?? Array.Empty<IssuerEndpoint>()).ToArray(),
|
||||
Tags = normalizedTags,
|
||||
CreatedAtUtc = timestampUtc.ToUniversalTime(),
|
||||
CreatedBy = actor.Trim(),
|
||||
UpdatedAtUtc = timestampUtc.ToUniversalTime(),
|
||||
UpdatedBy = actor.Trim(),
|
||||
IsSystemSeed = isSystemSeed
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Core.Domain;
|
||||
|
||||
public sealed partial record IssuerRecord
|
||||
{
|
||||
private static readonly StringComparer _tagComparer = StringComparer.OrdinalIgnoreCase;
|
||||
|
||||
private static IReadOnlyCollection<string> NormalizeTags(IEnumerable<string>? tags)
|
||||
{
|
||||
return (tags ?? Array.Empty<string>())
|
||||
.Where(tag => !string.IsNullOrWhiteSpace(tag))
|
||||
.Select(tag => tag.Trim().ToLowerInvariant())
|
||||
.Distinct(_tagComparer)
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Core.Domain;
|
||||
|
||||
public sealed partial record IssuerRecord
|
||||
{
|
||||
public IssuerRecord WithUpdated(
|
||||
IssuerContact contact,
|
||||
IssuerMetadata metadata,
|
||||
IEnumerable<IssuerEndpoint>? endpoints,
|
||||
IEnumerable<string>? tags,
|
||||
string displayName,
|
||||
string? description,
|
||||
DateTimeOffset updatedAtUtc,
|
||||
string updatedBy)
|
||||
{
|
||||
if (contact is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(contact));
|
||||
}
|
||||
|
||||
if (metadata is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(metadata));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(displayName))
|
||||
{
|
||||
throw new ArgumentException("Display name is required.", nameof(displayName));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(updatedBy))
|
||||
{
|
||||
throw new ArgumentException("Actor is required.", nameof(updatedBy));
|
||||
}
|
||||
|
||||
var normalizedTags = NormalizeTags(tags);
|
||||
|
||||
return this with
|
||||
{
|
||||
DisplayName = displayName.Trim(),
|
||||
Description = string.IsNullOrWhiteSpace(description) ? null : description.Trim(),
|
||||
Contact = contact,
|
||||
Metadata = metadata,
|
||||
Endpoints = (endpoints ?? Array.Empty<IssuerEndpoint>()).ToArray(),
|
||||
Tags = normalizedTags,
|
||||
UpdatedAtUtc = updatedAtUtc.ToUniversalTime(),
|
||||
UpdatedBy = updatedBy.Trim()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
namespace StellaOps.IssuerDirectory.Core.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a VEX issuer or CSAF publisher entry managed by the Issuer Directory.
|
||||
/// </summary>
|
||||
public sealed partial record IssuerRecord
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
public required string DisplayName { get; init; }
|
||||
|
||||
public required string Slug { get; init; }
|
||||
|
||||
public string? Description { get; init; }
|
||||
|
||||
public required IssuerContact Contact { get; init; }
|
||||
|
||||
public required IssuerMetadata Metadata { get; init; }
|
||||
|
||||
public IReadOnlyCollection<IssuerEndpoint> Endpoints { get; init; } = Array.Empty<IssuerEndpoint>();
|
||||
|
||||
public IReadOnlyCollection<string> Tags { get; init; } = Array.Empty<string>();
|
||||
|
||||
public required DateTimeOffset CreatedAtUtc { get; init; }
|
||||
|
||||
public required string CreatedBy { get; init; }
|
||||
|
||||
public required DateTimeOffset UpdatedAtUtc { get; init; }
|
||||
|
||||
public required string UpdatedBy { get; init; }
|
||||
|
||||
public bool IsSystemSeed { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace StellaOps.IssuerDirectory.Core.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// Well-known tenant identifiers for issuer directory entries.
|
||||
/// </summary>
|
||||
public static class IssuerTenants
|
||||
{
|
||||
/// <summary>
|
||||
/// Global issuer used for system-wide CSAF publishers available to all tenants.
|
||||
/// </summary>
|
||||
public const string Global = "@global";
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
namespace StellaOps.IssuerDirectory.Core.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a tenant-specific trust weight override for an issuer.
|
||||
/// </summary>
|
||||
public sealed record IssuerTrustOverrideRecord
|
||||
{
|
||||
public required string IssuerId { get; init; }
|
||||
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
public required decimal Weight { get; init; }
|
||||
|
||||
public string? Reason { get; init; }
|
||||
|
||||
public required DateTimeOffset UpdatedAtUtc { get; init; }
|
||||
|
||||
public required string UpdatedBy { get; init; }
|
||||
|
||||
public required DateTimeOffset CreatedAtUtc { get; init; }
|
||||
|
||||
public required string CreatedBy { get; init; }
|
||||
|
||||
public static IssuerTrustOverrideRecord Create(
|
||||
string issuerId,
|
||||
string tenantId,
|
||||
decimal weight,
|
||||
string? reason,
|
||||
DateTimeOffset timestampUtc,
|
||||
string actor)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(issuerId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(actor);
|
||||
|
||||
ValidateWeight(weight);
|
||||
|
||||
return new IssuerTrustOverrideRecord
|
||||
{
|
||||
IssuerId = issuerId.Trim(),
|
||||
TenantId = tenantId.Trim(),
|
||||
Weight = weight,
|
||||
Reason = string.IsNullOrWhiteSpace(reason) ? null : reason.Trim(),
|
||||
CreatedAtUtc = timestampUtc,
|
||||
CreatedBy = actor.Trim(),
|
||||
UpdatedAtUtc = timestampUtc,
|
||||
UpdatedBy = actor.Trim()
|
||||
};
|
||||
}
|
||||
|
||||
public IssuerTrustOverrideRecord WithUpdated(decimal weight, string? reason, DateTimeOffset timestampUtc, string actor)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(actor);
|
||||
ValidateWeight(weight);
|
||||
|
||||
return this with
|
||||
{
|
||||
Weight = weight,
|
||||
Reason = string.IsNullOrWhiteSpace(reason) ? null : reason.Trim(),
|
||||
UpdatedAtUtc = timestampUtc,
|
||||
UpdatedBy = actor.Trim()
|
||||
};
|
||||
}
|
||||
|
||||
public static void ValidateWeight(decimal weight)
|
||||
{
|
||||
if (weight is < -10m or > 10m)
|
||||
{
|
||||
throw new InvalidOperationException("Trust weight must be between -10 and 10 inclusive.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Core.Observability;
|
||||
|
||||
internal static class IssuerDirectoryMetrics
|
||||
{
|
||||
private static readonly Meter _meter = new("StellaOps.IssuerDirectory", "1.0");
|
||||
|
||||
private static readonly Counter<long> _issuerChangeCounter = _meter.CreateCounter<long>(
|
||||
"issuer_directory_changes_total",
|
||||
description: "Counts issuer create/update/delete events.");
|
||||
|
||||
private static readonly Counter<long> _keyOperationCounter = _meter.CreateCounter<long>(
|
||||
"issuer_directory_key_operations_total",
|
||||
description: "Counts issuer key create/rotate/revoke operations.");
|
||||
|
||||
private static readonly Counter<long> _keyValidationFailureCounter = _meter.CreateCounter<long>(
|
||||
"issuer_directory_key_validation_failures_total",
|
||||
description: "Counts issuer key validation or verification failures.");
|
||||
|
||||
public static void RecordIssuerChange(string tenantId, string issuerId, string action)
|
||||
{
|
||||
_issuerChangeCounter.Add(
|
||||
1,
|
||||
new[]
|
||||
{
|
||||
new KeyValuePair<string, object?>("tenant", NormalizeTag(tenantId)),
|
||||
new KeyValuePair<string, object?>("issuer", NormalizeTag(issuerId)),
|
||||
new KeyValuePair<string, object?>("action", action)
|
||||
});
|
||||
}
|
||||
|
||||
public static void RecordKeyOperation(string tenantId, string issuerId, string operation, string keyType)
|
||||
{
|
||||
_keyOperationCounter.Add(
|
||||
1,
|
||||
new[]
|
||||
{
|
||||
new KeyValuePair<string, object?>("tenant", NormalizeTag(tenantId)),
|
||||
new KeyValuePair<string, object?>("issuer", NormalizeTag(issuerId)),
|
||||
new KeyValuePair<string, object?>("operation", operation),
|
||||
new KeyValuePair<string, object?>("key_type", keyType)
|
||||
});
|
||||
}
|
||||
|
||||
public static void RecordKeyValidationFailure(string tenantId, string issuerId, string reason)
|
||||
{
|
||||
_keyValidationFailureCounter.Add(
|
||||
1,
|
||||
new[]
|
||||
{
|
||||
new KeyValuePair<string, object?>("tenant", NormalizeTag(tenantId)),
|
||||
new KeyValuePair<string, object?>("issuer", NormalizeTag(issuerId)),
|
||||
new KeyValuePair<string, object?>("reason", reason)
|
||||
});
|
||||
}
|
||||
|
||||
private static string NormalizeTag(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? "unknown" : value.Trim();
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using StellaOps.IssuerDirectory.Core.Domain;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Core.Services;
|
||||
|
||||
public sealed partial class IssuerDirectoryService
|
||||
{
|
||||
private async Task WriteAuditAsync(
|
||||
IssuerRecord record,
|
||||
string action,
|
||||
string actor,
|
||||
string? reason,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var audit = new IssuerAuditEntry(
|
||||
record.TenantId,
|
||||
record.Id,
|
||||
action,
|
||||
_timeProvider.GetUtcNow(),
|
||||
actor,
|
||||
reason,
|
||||
metadata: new Dictionary<string, string>
|
||||
{
|
||||
["display_name"] = record.DisplayName,
|
||||
["slug"] = record.Slug,
|
||||
["is_system_seed"] = record.IsSystemSeed.ToString()
|
||||
});
|
||||
|
||||
await _auditSink.WriteAsync(audit, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
using StellaOps.IssuerDirectory.Core.Domain;
|
||||
using StellaOps.IssuerDirectory.Core.Observability;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Core.Services;
|
||||
|
||||
public sealed partial class IssuerDirectoryService
|
||||
{
|
||||
public async Task<IssuerRecord> CreateAsync(
|
||||
string tenantId,
|
||||
string issuerId,
|
||||
string displayName,
|
||||
string slug,
|
||||
string? description,
|
||||
IssuerContact contact,
|
||||
IssuerMetadata metadata,
|
||||
IEnumerable<IssuerEndpoint>? endpoints,
|
||||
IEnumerable<string>? tags,
|
||||
string actor,
|
||||
string? reason,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(issuerId);
|
||||
|
||||
var timestamp = _timeProvider.GetUtcNow();
|
||||
var record = IssuerRecord.Create(
|
||||
issuerId,
|
||||
tenantId,
|
||||
displayName,
|
||||
slug,
|
||||
description,
|
||||
contact,
|
||||
metadata,
|
||||
endpoints,
|
||||
tags,
|
||||
timestamp,
|
||||
actor,
|
||||
isSystemSeed: false);
|
||||
|
||||
await _repository.UpsertAsync(record, cancellationToken).ConfigureAwait(false);
|
||||
await WriteAuditAsync(record, "created", actor, reason, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
IssuerDirectoryMetrics.RecordIssuerChange(tenantId, issuerId, "created");
|
||||
_logger.LogInformation(
|
||||
"Issuer {IssuerId} created for tenant {TenantId} by {Actor}.",
|
||||
issuerId,
|
||||
tenantId,
|
||||
actor);
|
||||
|
||||
return record;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
using StellaOps.IssuerDirectory.Core.Domain;
|
||||
using StellaOps.IssuerDirectory.Core.Observability;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Core.Services;
|
||||
|
||||
public sealed partial class IssuerDirectoryService
|
||||
{
|
||||
public async Task DeleteAsync(
|
||||
string tenantId,
|
||||
string issuerId,
|
||||
string actor,
|
||||
string? reason,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(issuerId);
|
||||
|
||||
await _repository.DeleteAsync(tenantId, issuerId, cancellationToken).ConfigureAwait(false);
|
||||
var timestamp = _timeProvider.GetUtcNow();
|
||||
var audit = new IssuerAuditEntry(
|
||||
tenantId,
|
||||
issuerId,
|
||||
action: "deleted",
|
||||
timestampUtc: timestamp,
|
||||
actor: actor,
|
||||
reason: reason,
|
||||
metadata: null);
|
||||
await _auditSink.WriteAsync(audit, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
IssuerDirectoryMetrics.RecordIssuerChange(tenantId, issuerId, "deleted");
|
||||
_logger.LogInformation(
|
||||
"Issuer {IssuerId} deleted for tenant {TenantId} by {Actor}.",
|
||||
issuerId,
|
||||
tenantId,
|
||||
actor);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using StellaOps.IssuerDirectory.Core.Domain;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Core.Services;
|
||||
|
||||
public sealed partial class IssuerDirectoryService
|
||||
{
|
||||
public async Task<IReadOnlyCollection<IssuerRecord>> ListAsync(
|
||||
string tenantId,
|
||||
bool includeGlobal,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
var tenantIssuers = await _repository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
if (!includeGlobal)
|
||||
{
|
||||
return tenantIssuers.OrderBy(record => record.Slug, StringComparer.Ordinal).ToArray();
|
||||
}
|
||||
|
||||
var globalIssuers = await _repository.ListGlobalAsync(cancellationToken).ConfigureAwait(false);
|
||||
return tenantIssuers.Concat(globalIssuers)
|
||||
.DistinctBy(record => (record.TenantId, record.Id))
|
||||
.OrderBy(record => record.Slug, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
public async Task<IssuerRecord?> GetAsync(
|
||||
string tenantId,
|
||||
string issuerId,
|
||||
bool includeGlobal,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(issuerId);
|
||||
|
||||
var issuer = await _repository.GetAsync(tenantId, issuerId, cancellationToken).ConfigureAwait(false);
|
||||
if (issuer is not null || !includeGlobal)
|
||||
{
|
||||
return issuer;
|
||||
}
|
||||
|
||||
return await _repository.GetAsync(IssuerTenants.Global, issuerId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using StellaOps.IssuerDirectory.Core.Domain;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Core.Services;
|
||||
|
||||
public sealed partial class IssuerDirectoryService
|
||||
{
|
||||
public async Task SeedAsync(IEnumerable<IssuerRecord> seeds, CancellationToken cancellationToken)
|
||||
{
|
||||
if (seeds is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(seeds));
|
||||
}
|
||||
|
||||
foreach (var seed in seeds)
|
||||
{
|
||||
if (!seed.IsSystemSeed)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var existing = await _repository.GetAsync(seed.TenantId, seed.Id, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (existing is null)
|
||||
{
|
||||
await _repository.UpsertAsync(seed, cancellationToken).ConfigureAwait(false);
|
||||
await WriteAuditAsync(seed, "seeded", seed.UpdatedBy, "CSAF bootstrap import", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
var refreshed = existing.WithUpdated(
|
||||
seed.Contact,
|
||||
seed.Metadata,
|
||||
seed.Endpoints,
|
||||
seed.Tags,
|
||||
seed.DisplayName,
|
||||
seed.Description,
|
||||
_timeProvider.GetUtcNow(),
|
||||
seed.UpdatedBy)
|
||||
with
|
||||
{
|
||||
IsSystemSeed = true
|
||||
};
|
||||
|
||||
await _repository.UpsertAsync(refreshed, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
using StellaOps.IssuerDirectory.Core.Domain;
|
||||
using StellaOps.IssuerDirectory.Core.Observability;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Core.Services;
|
||||
|
||||
public sealed partial class IssuerDirectoryService
|
||||
{
|
||||
public async Task<IssuerRecord> UpdateAsync(
|
||||
string tenantId,
|
||||
string issuerId,
|
||||
string displayName,
|
||||
string? description,
|
||||
IssuerContact contact,
|
||||
IssuerMetadata metadata,
|
||||
IEnumerable<IssuerEndpoint>? endpoints,
|
||||
IEnumerable<string>? tags,
|
||||
string actor,
|
||||
string? reason,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(issuerId);
|
||||
|
||||
var existing = await _repository.GetAsync(tenantId, issuerId, cancellationToken).ConfigureAwait(false);
|
||||
if (existing is null)
|
||||
{
|
||||
throw new InvalidOperationException("Issuer does not exist.");
|
||||
}
|
||||
|
||||
var timestamp = _timeProvider.GetUtcNow();
|
||||
var updated = existing.WithUpdated(
|
||||
contact,
|
||||
metadata,
|
||||
endpoints,
|
||||
tags,
|
||||
displayName,
|
||||
description,
|
||||
timestamp,
|
||||
actor);
|
||||
|
||||
await _repository.UpsertAsync(updated, cancellationToken).ConfigureAwait(false);
|
||||
await WriteAuditAsync(updated, "updated", actor, reason, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
IssuerDirectoryMetrics.RecordIssuerChange(tenantId, issuerId, "updated");
|
||||
_logger.LogInformation(
|
||||
"Issuer {IssuerId} updated for tenant {TenantId} by {Actor}.",
|
||||
issuerId,
|
||||
tenantId,
|
||||
actor);
|
||||
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
using StellaOps.IssuerDirectory.Core.Abstractions;
|
||||
using StellaOps.IssuerDirectory.Core.Domain;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Coordinates issuer directory operations with persistence, validation, and auditing.
|
||||
/// </summary>
|
||||
public sealed partial class IssuerDirectoryService
|
||||
{
|
||||
private readonly IIssuerRepository _repository;
|
||||
private readonly IIssuerAuditSink _auditSink;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<IssuerDirectoryService> _logger;
|
||||
|
||||
public IssuerDirectoryService(
|
||||
IIssuerRepository repository,
|
||||
IIssuerAuditSink auditSink,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<IssuerDirectoryService> logger)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
_auditSink = auditSink ?? throw new ArgumentNullException(nameof(auditSink));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
using StellaOps.IssuerDirectory.Core.Domain;
|
||||
using StellaOps.IssuerDirectory.Core.Observability;
|
||||
using StellaOps.IssuerDirectory.Core.Validation;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Core.Services;
|
||||
|
||||
public sealed partial class IssuerKeyService
|
||||
{
|
||||
public async Task<IssuerKeyRecord> AddAsync(
|
||||
string tenantId,
|
||||
string issuerId,
|
||||
IssuerKeyType type,
|
||||
IssuerKeyMaterial material,
|
||||
DateTimeOffset? expiresAtUtc,
|
||||
string actor,
|
||||
string? reason,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(issuerId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(actor);
|
||||
|
||||
await EnsureIssuerExistsAsync(tenantId, issuerId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
IssuerKeyValidationResult validation;
|
||||
try
|
||||
{
|
||||
validation = IssuerKeyValidator.Validate(type, material, expiresAtUtc, _timeProvider);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
IssuerDirectoryMetrics.RecordKeyValidationFailure(tenantId, issuerId, ex.GetType().Name);
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Key validation failed for issuer {IssuerId} (tenant={TenantId}) during add.",
|
||||
issuerId,
|
||||
tenantId);
|
||||
throw;
|
||||
}
|
||||
|
||||
var fingerprint = ComputeFingerprint(validation.RawKeyBytes);
|
||||
|
||||
var existing = await _keyRepository.GetByFingerprintAsync(tenantId, issuerId, fingerprint, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (existing is not null && existing.Status == IssuerKeyStatus.Active)
|
||||
{
|
||||
IssuerDirectoryMetrics.RecordKeyValidationFailure(tenantId, issuerId, "duplicate_fingerprint");
|
||||
_logger.LogWarning(
|
||||
"Duplicate active key detected for issuer {IssuerId} (tenant={TenantId}).",
|
||||
issuerId,
|
||||
tenantId);
|
||||
throw new InvalidOperationException("An identical active key already exists for this issuer.");
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var record = IssuerKeyRecord.Create(
|
||||
_guidProvider.NewGuid().ToString("n"),
|
||||
issuerId,
|
||||
tenantId,
|
||||
type,
|
||||
validation.Material,
|
||||
fingerprint,
|
||||
now,
|
||||
actor,
|
||||
validation.ExpiresAtUtc,
|
||||
replacesKeyId: null);
|
||||
|
||||
await _keyRepository.UpsertAsync(record, cancellationToken).ConfigureAwait(false);
|
||||
await WriteAuditAsync(record, "key_created", actor, reason, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
IssuerDirectoryMetrics.RecordKeyOperation(tenantId, issuerId, "created", type.ToString());
|
||||
_logger.LogInformation(
|
||||
"Issuer key {KeyId} created for issuer {IssuerId} (tenant={TenantId}) by {Actor}.",
|
||||
record.Id,
|
||||
issuerId,
|
||||
tenantId,
|
||||
actor);
|
||||
|
||||
return record;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using System.Security.Cryptography;
|
||||
|
||||
using StellaOps.IssuerDirectory.Core.Domain;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Core.Services;
|
||||
|
||||
public sealed partial class IssuerKeyService
|
||||
{
|
||||
private async Task EnsureIssuerExistsAsync(string tenantId, string issuerId, CancellationToken cancellationToken)
|
||||
{
|
||||
var issuer = await _issuerRepository.GetAsync(tenantId, issuerId, cancellationToken).ConfigureAwait(false);
|
||||
if (issuer is null)
|
||||
{
|
||||
var global = await _issuerRepository.GetAsync(IssuerTenants.Global, issuerId, cancellationToken).ConfigureAwait(false);
|
||||
if (global is null)
|
||||
{
|
||||
throw new InvalidOperationException("Issuer does not exist.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task WriteAuditAsync(
|
||||
IssuerKeyRecord record,
|
||||
string action,
|
||||
string actor,
|
||||
string? reason,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var audit = new IssuerAuditEntry(
|
||||
record.TenantId,
|
||||
record.IssuerId,
|
||||
action,
|
||||
_timeProvider.GetUtcNow(),
|
||||
actor,
|
||||
reason,
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["key_id"] = record.Id,
|
||||
["key_type"] = record.Type.ToString(),
|
||||
["fingerprint"] = record.Fingerprint,
|
||||
["status"] = record.Status.ToString()
|
||||
});
|
||||
|
||||
await _auditSink.WriteAsync(audit, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static string ComputeFingerprint(byte[] rawKeyBytes)
|
||||
{
|
||||
var hash = SHA256.HashData(rawKeyBytes);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using StellaOps.IssuerDirectory.Core.Domain;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Core.Services;
|
||||
|
||||
public sealed partial class IssuerKeyService
|
||||
{
|
||||
public async Task<IReadOnlyCollection<IssuerKeyRecord>> ListAsync(
|
||||
string tenantId,
|
||||
string issuerId,
|
||||
bool includeGlobal,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(issuerId);
|
||||
|
||||
var tenantKeys = await _keyRepository.ListAsync(tenantId, issuerId, cancellationToken).ConfigureAwait(false);
|
||||
if (!includeGlobal)
|
||||
{
|
||||
return tenantKeys.OrderBy(key => key.CreatedAtUtc).ToArray();
|
||||
}
|
||||
|
||||
var globalKeys = await _keyRepository.ListGlobalAsync(issuerId, cancellationToken).ConfigureAwait(false);
|
||||
return tenantKeys.Concat(globalKeys)
|
||||
.DistinctBy(key => (key.TenantId, key.Id))
|
||||
.OrderBy(key => key.CreatedAtUtc)
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
using StellaOps.IssuerDirectory.Core.Domain;
|
||||
using StellaOps.IssuerDirectory.Core.Observability;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Core.Services;
|
||||
|
||||
public sealed partial class IssuerKeyService
|
||||
{
|
||||
public async Task RevokeAsync(
|
||||
string tenantId,
|
||||
string issuerId,
|
||||
string keyId,
|
||||
string actor,
|
||||
string? reason,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(issuerId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(actor);
|
||||
|
||||
var existing = await _keyRepository.GetAsync(tenantId, issuerId, keyId, cancellationToken).ConfigureAwait(false);
|
||||
if (existing is null)
|
||||
{
|
||||
IssuerDirectoryMetrics.RecordKeyValidationFailure(tenantId, issuerId, "key_not_found");
|
||||
_logger.LogWarning(
|
||||
"Attempted to revoke missing key {KeyId} for issuer {IssuerId} (tenant={TenantId}).",
|
||||
keyId,
|
||||
issuerId,
|
||||
tenantId);
|
||||
throw new InvalidOperationException("Key not found for revocation.");
|
||||
}
|
||||
|
||||
if (existing.Status == IssuerKeyStatus.Revoked)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var revoked = existing.WithStatus(IssuerKeyStatus.Revoked, now, actor);
|
||||
|
||||
await _keyRepository.UpsertAsync(revoked, cancellationToken).ConfigureAwait(false);
|
||||
await WriteAuditAsync(revoked, "key_revoked", actor, reason, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
IssuerDirectoryMetrics.RecordKeyOperation(tenantId, issuerId, "revoked", existing.Type.ToString());
|
||||
_logger.LogInformation(
|
||||
"Issuer key {KeyId} revoked for issuer {IssuerId} (tenant={TenantId}) by {Actor}.",
|
||||
keyId,
|
||||
issuerId,
|
||||
tenantId,
|
||||
actor);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
using StellaOps.IssuerDirectory.Core.Domain;
|
||||
using StellaOps.IssuerDirectory.Core.Observability;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Core.Services;
|
||||
|
||||
public sealed partial class IssuerKeyService
|
||||
{
|
||||
private async Task<IssuerKeyRecord> GetActiveKeyForRotationAsync(
|
||||
string tenantId,
|
||||
string issuerId,
|
||||
string keyId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var existing = await _keyRepository.GetAsync(tenantId, issuerId, keyId, cancellationToken).ConfigureAwait(false);
|
||||
if (existing is null)
|
||||
{
|
||||
IssuerDirectoryMetrics.RecordKeyValidationFailure(tenantId, issuerId, "key_not_found");
|
||||
_logger.LogWarning(
|
||||
"Attempted to rotate missing key {KeyId} for issuer {IssuerId} (tenant={TenantId}).",
|
||||
keyId,
|
||||
issuerId,
|
||||
tenantId);
|
||||
throw new InvalidOperationException("Key not found for rotation.");
|
||||
}
|
||||
|
||||
if (existing.Status != IssuerKeyStatus.Active)
|
||||
{
|
||||
IssuerDirectoryMetrics.RecordKeyValidationFailure(tenantId, issuerId, "key_not_active");
|
||||
_logger.LogWarning(
|
||||
"Attempted to rotate non-active key {KeyId} (status={Status}) for issuer {IssuerId} (tenant={TenantId}).",
|
||||
keyId,
|
||||
existing.Status,
|
||||
issuerId,
|
||||
tenantId);
|
||||
throw new InvalidOperationException("Only active keys can be rotated.");
|
||||
}
|
||||
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
using StellaOps.IssuerDirectory.Core.Domain;
|
||||
using StellaOps.IssuerDirectory.Core.Observability;
|
||||
using StellaOps.IssuerDirectory.Core.Validation;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Core.Services;
|
||||
|
||||
public sealed partial class IssuerKeyService
|
||||
{
|
||||
public async Task<IssuerKeyRecord> RotateAsync(
|
||||
string tenantId,
|
||||
string issuerId,
|
||||
string keyId,
|
||||
IssuerKeyType newType,
|
||||
IssuerKeyMaterial newMaterial,
|
||||
DateTimeOffset? expiresAtUtc,
|
||||
string actor,
|
||||
string? reason,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(issuerId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(actor);
|
||||
|
||||
var existing = await GetActiveKeyForRotationAsync(tenantId, issuerId, keyId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await EnsureIssuerExistsAsync(tenantId, issuerId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
IssuerKeyValidationResult validation;
|
||||
try
|
||||
{
|
||||
validation = IssuerKeyValidator.Validate(newType, newMaterial, expiresAtUtc, _timeProvider);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
IssuerDirectoryMetrics.RecordKeyValidationFailure(tenantId, issuerId, ex.GetType().Name);
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Key validation failed for issuer {IssuerId} (tenant={TenantId}) during rotation.",
|
||||
issuerId,
|
||||
tenantId);
|
||||
throw;
|
||||
}
|
||||
|
||||
var fingerprint = ComputeFingerprint(validation.RawKeyBytes);
|
||||
|
||||
var duplicate = await _keyRepository.GetByFingerprintAsync(tenantId, issuerId, fingerprint, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (duplicate is not null && duplicate.Status == IssuerKeyStatus.Active)
|
||||
{
|
||||
IssuerDirectoryMetrics.RecordKeyValidationFailure(tenantId, issuerId, "duplicate_fingerprint");
|
||||
_logger.LogWarning(
|
||||
"Duplicate active key detected during rotation for issuer {IssuerId} (tenant={TenantId}).",
|
||||
issuerId,
|
||||
tenantId);
|
||||
throw new InvalidOperationException("An identical active key already exists for this issuer.");
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
var retired = existing.WithStatus(IssuerKeyStatus.Retired, now, actor);
|
||||
await _keyRepository.UpsertAsync(retired, cancellationToken).ConfigureAwait(false);
|
||||
await WriteAuditAsync(retired, "key_retired", actor, reason ?? "rotation", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var replacement = IssuerKeyRecord.Create(
|
||||
_guidProvider.NewGuid().ToString("n"),
|
||||
issuerId,
|
||||
tenantId,
|
||||
newType,
|
||||
validation.Material,
|
||||
fingerprint,
|
||||
now,
|
||||
actor,
|
||||
validation.ExpiresAtUtc,
|
||||
replacesKeyId: existing.Id);
|
||||
|
||||
await _keyRepository.UpsertAsync(replacement, cancellationToken).ConfigureAwait(false);
|
||||
await WriteAuditAsync(replacement, "key_rotated", actor, reason, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
IssuerDirectoryMetrics.RecordKeyOperation(tenantId, issuerId, "rotated", newType.ToString());
|
||||
_logger.LogInformation(
|
||||
"Issuer key {OldKeyId} rotated for issuer {IssuerId} (tenant={TenantId}) by {Actor}; new key {NewKeyId}.",
|
||||
existing.Id,
|
||||
issuerId,
|
||||
tenantId,
|
||||
actor,
|
||||
replacement.Id);
|
||||
|
||||
return replacement;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.IssuerDirectory.Core.Abstractions;
|
||||
using StellaOps.IssuerDirectory.Core.Domain;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Manages issuer signing keys.
|
||||
/// </summary>
|
||||
public sealed partial class IssuerKeyService
|
||||
{
|
||||
private readonly IIssuerRepository _issuerRepository;
|
||||
private readonly IIssuerKeyRepository _keyRepository;
|
||||
private readonly IIssuerAuditSink _auditSink;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
private readonly ILogger<IssuerKeyService> _logger;
|
||||
|
||||
public IssuerKeyService(
|
||||
IIssuerRepository issuerRepository,
|
||||
IIssuerKeyRepository keyRepository,
|
||||
IIssuerAuditSink auditSink,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<IssuerKeyService> logger,
|
||||
IGuidProvider? guidProvider = null)
|
||||
{
|
||||
_issuerRepository = issuerRepository ?? throw new ArgumentNullException(nameof(issuerRepository));
|
||||
_keyRepository = keyRepository ?? throw new ArgumentNullException(nameof(keyRepository));
|
||||
_auditSink = auditSink ?? throw new ArgumentNullException(nameof(auditSink));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using StellaOps.IssuerDirectory.Core.Domain;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Core.Services;
|
||||
|
||||
public sealed partial class IssuerTrustService
|
||||
{
|
||||
public async Task DeleteAsync(
|
||||
string tenantId,
|
||||
string issuerId,
|
||||
string actor,
|
||||
string? reason,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(issuerId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(actor);
|
||||
|
||||
var existing = await _trustRepository.GetAsync(tenantId, issuerId, cancellationToken).ConfigureAwait(false);
|
||||
if (existing is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await _trustRepository.DeleteAsync(tenantId, issuerId, cancellationToken).ConfigureAwait(false);
|
||||
await WriteAuditAsync(existing, "trust_override_deleted", actor, reason, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using StellaOps.IssuerDirectory.Core.Domain;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Core.Services;
|
||||
|
||||
public sealed partial class IssuerTrustService
|
||||
{
|
||||
public async Task<IssuerTrustView> GetAsync(
|
||||
string tenantId,
|
||||
string issuerId,
|
||||
bool includeGlobal,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(issuerId);
|
||||
|
||||
var tenantOverride = await _trustRepository.GetAsync(tenantId, issuerId, cancellationToken).ConfigureAwait(false);
|
||||
IssuerTrustOverrideRecord? globalOverride = null;
|
||||
|
||||
if (includeGlobal && !string.Equals(tenantId, IssuerTenants.Global, StringComparison.Ordinal))
|
||||
{
|
||||
globalOverride = await _trustRepository.GetAsync(IssuerTenants.Global, issuerId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var effectiveWeight = tenantOverride?.Weight
|
||||
?? globalOverride?.Weight
|
||||
?? 0m;
|
||||
|
||||
return new IssuerTrustView(tenantOverride, globalOverride, effectiveWeight);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using StellaOps.IssuerDirectory.Core.Domain;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Core.Services;
|
||||
|
||||
public sealed partial class IssuerTrustService
|
||||
{
|
||||
private async Task EnsureIssuerExistsAsync(string tenantId, string issuerId, CancellationToken cancellationToken)
|
||||
{
|
||||
var issuer = await _issuerRepository.GetAsync(tenantId, issuerId, cancellationToken).ConfigureAwait(false)
|
||||
?? await _issuerRepository.GetAsync(IssuerTenants.Global, issuerId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (issuer is null)
|
||||
{
|
||||
throw new InvalidOperationException("Issuer does not exist.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task WriteAuditAsync(
|
||||
IssuerTrustOverrideRecord record,
|
||||
string action,
|
||||
string actor,
|
||||
string? reason,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var audit = new IssuerAuditEntry(
|
||||
record.TenantId,
|
||||
record.IssuerId,
|
||||
action,
|
||||
_timeProvider.GetUtcNow(),
|
||||
actor,
|
||||
reason,
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["weight"] = record.Weight.ToString("0.###", System.Globalization.CultureInfo.InvariantCulture)
|
||||
});
|
||||
|
||||
await _auditSink.WriteAsync(audit, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using StellaOps.IssuerDirectory.Core.Domain;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Core.Services;
|
||||
|
||||
public sealed partial class IssuerTrustService
|
||||
{
|
||||
public async Task<IssuerTrustOverrideRecord> SetAsync(
|
||||
string tenantId,
|
||||
string issuerId,
|
||||
decimal weight,
|
||||
string? reason,
|
||||
string actor,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(issuerId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(actor);
|
||||
|
||||
await EnsureIssuerExistsAsync(tenantId, issuerId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var existing = await _trustRepository.GetAsync(tenantId, issuerId, cancellationToken).ConfigureAwait(false);
|
||||
var timestamp = _timeProvider.GetUtcNow();
|
||||
|
||||
IssuerTrustOverrideRecord record = existing is null
|
||||
? IssuerTrustOverrideRecord.Create(issuerId, tenantId, weight, reason, timestamp, actor)
|
||||
: existing.WithUpdated(weight, reason, timestamp, actor);
|
||||
|
||||
await _trustRepository.UpsertAsync(record, cancellationToken).ConfigureAwait(false);
|
||||
await WriteAuditAsync(record, "trust_override_set", actor, reason, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return record;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using StellaOps.IssuerDirectory.Core.Abstractions;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Handles issuer trust weight overrides.
|
||||
/// </summary>
|
||||
public sealed partial class IssuerTrustService
|
||||
{
|
||||
private readonly IIssuerRepository _issuerRepository;
|
||||
private readonly IIssuerTrustRepository _trustRepository;
|
||||
private readonly IIssuerAuditSink _auditSink;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public IssuerTrustService(
|
||||
IIssuerRepository issuerRepository,
|
||||
IIssuerTrustRepository trustRepository,
|
||||
IIssuerAuditSink auditSink,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_issuerRepository = issuerRepository ?? throw new ArgumentNullException(nameof(issuerRepository));
|
||||
_trustRepository = trustRepository ?? throw new ArgumentNullException(nameof(trustRepository));
|
||||
_auditSink = auditSink ?? throw new ArgumentNullException(nameof(auditSink));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using StellaOps.IssuerDirectory.Core.Domain;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Core.Services;
|
||||
|
||||
public sealed record IssuerTrustView(
|
||||
IssuerTrustOverrideRecord? TenantOverride,
|
||||
IssuerTrustOverrideRecord? GlobalOverride,
|
||||
decimal EffectiveWeight);
|
||||
@@ -0,0 +1,15 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Determinism.Abstractions\StellaOps.Determinism.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,12 @@
|
||||
# StellaOps.IssuerDirectory.Core Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0373-M | DONE | Revalidated 2026-01-07; maintainability audit for IssuerDirectory.Core. |
|
||||
| AUDIT-0373-T | DONE | Revalidated 2026-01-07; test coverage audit for IssuerDirectory.Core. |
|
||||
| AUDIT-0373-A | TODO | Pending approval (revalidated 2026-01-07). |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
| REMED-07 | DONE | 2026-02-04: Split domain/services/validation into partials, normalized metrics fields, added domain/validator tests (SPRINT_20260130_002). |
|
||||
@@ -0,0 +1,25 @@
|
||||
using StellaOps.IssuerDirectory.Core.Domain;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Core.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Result of validating an issuer key request.
|
||||
/// </summary>
|
||||
public sealed class IssuerKeyValidationResult
|
||||
{
|
||||
public IssuerKeyValidationResult(
|
||||
IssuerKeyMaterial material,
|
||||
byte[] rawKeyBytes,
|
||||
DateTimeOffset? expiresAtUtc)
|
||||
{
|
||||
Material = material ?? throw new ArgumentNullException(nameof(material));
|
||||
RawKeyBytes = rawKeyBytes ?? throw new ArgumentNullException(nameof(rawKeyBytes));
|
||||
ExpiresAtUtc = expiresAtUtc?.ToUniversalTime();
|
||||
}
|
||||
|
||||
public IssuerKeyMaterial Material { get; }
|
||||
|
||||
public byte[] RawKeyBytes { get; }
|
||||
|
||||
public DateTimeOffset? ExpiresAtUtc { get; }
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
|
||||
using StellaOps.IssuerDirectory.Core.Domain;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Core.Validation;
|
||||
|
||||
public static partial class IssuerKeyValidator
|
||||
{
|
||||
private static byte[] ValidateCertificate(IssuerKeyMaterial material)
|
||||
{
|
||||
if (!string.Equals(material.Format, "pem", StringComparison.OrdinalIgnoreCase) &&
|
||||
!string.Equals(material.Format, "base64", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException("X.509 certificates must be provided as PEM or base64.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (string.Equals(material.Format, "pem", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
using var pemCertificate = X509Certificate2.CreateFromPem(material.Value);
|
||||
return pemCertificate.RawData;
|
||||
}
|
||||
|
||||
var raw = Convert.FromBase64String(material.Value);
|
||||
using var loadedCertificate = X509CertificateLoader.LoadCertificate(raw);
|
||||
return loadedCertificate.RawData;
|
||||
}
|
||||
catch (Exception ex) when (ex is CryptographicException || ex is FormatException)
|
||||
{
|
||||
throw new InvalidOperationException("Certificate material is invalid or unsupported.", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using StellaOps.IssuerDirectory.Core.Domain;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Core.Validation;
|
||||
|
||||
public static partial class IssuerKeyValidator
|
||||
{
|
||||
private static byte[] ValidateDsseKey(IssuerKeyMaterial material)
|
||||
{
|
||||
if (!string.Equals(material.Format, "base64", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException("DSSE keys must use base64 format.");
|
||||
}
|
||||
|
||||
byte[] rawBytes;
|
||||
try
|
||||
{
|
||||
rawBytes = Convert.FromBase64String(material.Value);
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
throw new InvalidOperationException("DSSE key material must be valid base64.", ex);
|
||||
}
|
||||
|
||||
if (rawBytes.Length is not (32 or 48 or 64))
|
||||
{
|
||||
throw new InvalidOperationException("DSSE keys must contain 32, 48, or 64 bytes of public key material.");
|
||||
}
|
||||
|
||||
return rawBytes;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using StellaOps.IssuerDirectory.Core.Domain;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Core.Validation;
|
||||
|
||||
public static partial class IssuerKeyValidator
|
||||
{
|
||||
private static byte[] ValidateEd25519(IssuerKeyMaterial material)
|
||||
{
|
||||
if (!string.Equals(material.Format, "base64", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException("Ed25519 keys must use base64 format.");
|
||||
}
|
||||
|
||||
byte[] rawBytes;
|
||||
try
|
||||
{
|
||||
rawBytes = Convert.FromBase64String(material.Value);
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
throw new InvalidOperationException("Ed25519 key material must be valid base64.", ex);
|
||||
}
|
||||
|
||||
if (rawBytes.Length != 32)
|
||||
{
|
||||
throw new InvalidOperationException("Ed25519 public keys must contain 32 bytes.");
|
||||
}
|
||||
|
||||
return rawBytes;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using StellaOps.IssuerDirectory.Core.Domain;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Core.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Performs validation and normalization of issuer key material.
|
||||
/// </summary>
|
||||
public static partial class IssuerKeyValidator
|
||||
{
|
||||
public static IssuerKeyValidationResult Validate(
|
||||
IssuerKeyType type,
|
||||
IssuerKeyMaterial material,
|
||||
DateTimeOffset? expiresAtUtc,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
if (material is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(material));
|
||||
}
|
||||
|
||||
ArgumentNullException.ThrowIfNull(timeProvider);
|
||||
|
||||
var normalizedMaterial = NormalizeMaterial(material);
|
||||
var rawKey = type switch
|
||||
{
|
||||
IssuerKeyType.Ed25519PublicKey => ValidateEd25519(normalizedMaterial),
|
||||
IssuerKeyType.X509Certificate => ValidateCertificate(normalizedMaterial),
|
||||
IssuerKeyType.DssePublicKey => ValidateDsseKey(normalizedMaterial),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(type), type, "Unsupported issuer key type.")
|
||||
};
|
||||
|
||||
if (expiresAtUtc is { } expiry)
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
if (expiry.ToUniversalTime() <= now)
|
||||
{
|
||||
throw new InvalidOperationException("Key expiry must be in the future.");
|
||||
}
|
||||
}
|
||||
|
||||
return new IssuerKeyValidationResult(normalizedMaterial, rawKey, expiresAtUtc);
|
||||
}
|
||||
|
||||
private static IssuerKeyMaterial NormalizeMaterial(IssuerKeyMaterial material)
|
||||
{
|
||||
return new IssuerKeyMaterial(material.Format.ToLowerInvariant(), material.Value.Trim());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
# IssuerDirectory Infrastructure Agent Charter
|
||||
|
||||
## Mission
|
||||
- Provide infrastructure bindings for IssuerDirectory persistence and seeding.
|
||||
|
||||
## Responsibilities
|
||||
- Keep in-memory implementations deterministic and safe for fallback use.
|
||||
- Ensure DI wiring matches core abstractions and default behaviors.
|
||||
- Validate seed parsing and enforce audit-friendly metadata.
|
||||
|
||||
## Required Reading
|
||||
- docs/07_HIGH_LEVEL_ARCHITECTURE.md
|
||||
- docs/modules/issuer-directory/architecture.md
|
||||
- docs/modules/platform/architecture-overview.md
|
||||
|
||||
## Working Directory & Scope
|
||||
- Primary: src/IssuerDirectory/StellaOps.IssuerDirectory/StellaOps.IssuerDirectory.Infrastructure
|
||||
- Allowed shared projects: src/IssuerDirectory/StellaOps.IssuerDirectory/StellaOps.IssuerDirectory.Core
|
||||
|
||||
## Testing Expectations
|
||||
- Cover seed loader failure paths and in-memory repository ordering.
|
||||
- Keep tests deterministic and offline-friendly.
|
||||
|
||||
## Working Agreement
|
||||
- Update sprint status in docs/implplan/SPRINT_*.md and local TASKS.md.
|
||||
- Keep outputs deterministic and avoid non-ASCII logs.
|
||||
@@ -0,0 +1,28 @@
|
||||
|
||||
using StellaOps.IssuerDirectory.Core.Abstractions;
|
||||
using StellaOps.IssuerDirectory.Core.Domain;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Infrastructure.InMemory;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory audit sink; retains last N entries for inspection/testing.
|
||||
/// </summary>
|
||||
internal sealed class InMemoryIssuerAuditSink : IIssuerAuditSink
|
||||
{
|
||||
private readonly ConcurrentQueue<IssuerAuditEntry> _entries = new();
|
||||
private const int MaxEntries = 1024;
|
||||
|
||||
public Task WriteAsync(IssuerAuditEntry entry, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entry);
|
||||
|
||||
_entries.Enqueue(entry);
|
||||
while (_entries.Count > MaxEntries && _entries.TryDequeue(out _))
|
||||
{
|
||||
// drop oldest to bound memory
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
|
||||
using StellaOps.IssuerDirectory.Core.Abstractions;
|
||||
using StellaOps.IssuerDirectory.Core.Domain;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Infrastructure.InMemory;
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic in-memory issuer key store for PostgreSQL fallback scenarios.
|
||||
/// </summary>
|
||||
internal sealed class InMemoryIssuerKeyRepository : IIssuerKeyRepository
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, IssuerKeyRecord>> _keys = new(StringComparer.Ordinal);
|
||||
|
||||
public Task<IssuerKeyRecord?> GetAsync(string tenantId, string issuerId, string keyId, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(issuerId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
|
||||
|
||||
var bucketKey = GetBucketKey(tenantId, issuerId);
|
||||
if (_keys.TryGetValue(bucketKey, out var map) && map.TryGetValue(keyId, out var record))
|
||||
{
|
||||
return Task.FromResult<IssuerKeyRecord?>(record);
|
||||
}
|
||||
|
||||
return Task.FromResult<IssuerKeyRecord?>(null);
|
||||
}
|
||||
|
||||
public Task<IssuerKeyRecord?> GetByFingerprintAsync(string tenantId, string issuerId, string fingerprint, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(issuerId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(fingerprint);
|
||||
|
||||
var bucketKey = GetBucketKey(tenantId, issuerId);
|
||||
if (_keys.TryGetValue(bucketKey, out var map))
|
||||
{
|
||||
var match = map.Values.FirstOrDefault(key => string.Equals(key.Fingerprint, fingerprint, StringComparison.Ordinal));
|
||||
return Task.FromResult<IssuerKeyRecord?>(match);
|
||||
}
|
||||
|
||||
return Task.FromResult<IssuerKeyRecord?>(null);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyCollection<IssuerKeyRecord>> ListAsync(string tenantId, string issuerId, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(issuerId);
|
||||
|
||||
var bucketKey = GetBucketKey(tenantId, issuerId);
|
||||
if (_keys.TryGetValue(bucketKey, out var map))
|
||||
{
|
||||
var ordered = map.Values.OrderBy(k => k.Id, StringComparer.Ordinal).ToArray();
|
||||
return Task.FromResult<IReadOnlyCollection<IssuerKeyRecord>>(ordered);
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyCollection<IssuerKeyRecord>>(Array.Empty<IssuerKeyRecord>());
|
||||
}
|
||||
|
||||
public Task<IReadOnlyCollection<IssuerKeyRecord>> ListGlobalAsync(string issuerId, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(issuerId);
|
||||
|
||||
var all = _keys.Values
|
||||
.SelectMany(dict => dict.Values)
|
||||
.Where(k => string.Equals(k.IssuerId, issuerId, StringComparison.Ordinal))
|
||||
.OrderBy(k => k.TenantId, StringComparer.Ordinal)
|
||||
.ThenBy(k => k.Id, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
return Task.FromResult<IReadOnlyCollection<IssuerKeyRecord>>(all);
|
||||
}
|
||||
|
||||
public Task UpsertAsync(IssuerKeyRecord record, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(record);
|
||||
|
||||
var bucketKey = GetBucketKey(record.TenantId, record.IssuerId);
|
||||
var map = _keys.GetOrAdd(bucketKey, _ => new ConcurrentDictionary<string, IssuerKeyRecord>(StringComparer.Ordinal));
|
||||
map.AddOrUpdate(record.Id, record, (_, _) => record);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static string GetBucketKey(string tenantId, string issuerId)
|
||||
{
|
||||
return $"{tenantId}|{issuerId}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
|
||||
using StellaOps.IssuerDirectory.Core.Abstractions;
|
||||
using StellaOps.IssuerDirectory.Core.Domain;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Infrastructure.InMemory;
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic in-memory issuer store for PostgreSQL fallback scenarios.
|
||||
/// </summary>
|
||||
internal sealed class InMemoryIssuerRepository : IIssuerRepository
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, IssuerRecord>> _issuers = new(StringComparer.Ordinal);
|
||||
|
||||
public Task<IssuerRecord?> GetAsync(string tenantId, string issuerId, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(issuerId);
|
||||
|
||||
if (_issuers.TryGetValue(tenantId, out var map) && map.TryGetValue(issuerId, out var record))
|
||||
{
|
||||
return Task.FromResult<IssuerRecord?>(record);
|
||||
}
|
||||
|
||||
return Task.FromResult<IssuerRecord?>(null);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyCollection<IssuerRecord>> ListAsync(string tenantId, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
if (_issuers.TryGetValue(tenantId, out var map))
|
||||
{
|
||||
var ordered = map.Values.OrderBy(r => r.Id, StringComparer.Ordinal).ToArray();
|
||||
return Task.FromResult<IReadOnlyCollection<IssuerRecord>>(ordered);
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyCollection<IssuerRecord>>(Array.Empty<IssuerRecord>());
|
||||
}
|
||||
|
||||
public Task<IReadOnlyCollection<IssuerRecord>> ListGlobalAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var ordered = _issuers.Values
|
||||
.SelectMany(dict => dict.Values)
|
||||
.OrderBy(r => r.TenantId, StringComparer.Ordinal)
|
||||
.ThenBy(r => r.Id, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
return Task.FromResult<IReadOnlyCollection<IssuerRecord>>(ordered);
|
||||
}
|
||||
|
||||
public Task UpsertAsync(IssuerRecord record, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(record);
|
||||
|
||||
var tenantMap = _issuers.GetOrAdd(record.TenantId, _ => new ConcurrentDictionary<string, IssuerRecord>(StringComparer.Ordinal));
|
||||
tenantMap.AddOrUpdate(record.Id, record, (_, _) => record);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task DeleteAsync(string tenantId, string issuerId, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(issuerId);
|
||||
|
||||
if (_issuers.TryGetValue(tenantId, out var map))
|
||||
{
|
||||
map.TryRemove(issuerId, out _);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
|
||||
using StellaOps.IssuerDirectory.Core.Abstractions;
|
||||
using StellaOps.IssuerDirectory.Core.Domain;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Infrastructure.InMemory;
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic in-memory trust override store for PostgreSQL fallback scenarios.
|
||||
/// </summary>
|
||||
internal sealed class InMemoryIssuerTrustRepository : IIssuerTrustRepository
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, IssuerTrustOverrideRecord> _trust = new(StringComparer.Ordinal);
|
||||
|
||||
public Task<IssuerTrustOverrideRecord?> GetAsync(string tenantId, string issuerId, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(issuerId);
|
||||
|
||||
var key = GetKey(tenantId, issuerId);
|
||||
return Task.FromResult(_trust.TryGetValue(key, out var record) ? record : null);
|
||||
}
|
||||
|
||||
public Task UpsertAsync(IssuerTrustOverrideRecord record, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(record);
|
||||
var key = GetKey(record.TenantId, record.IssuerId);
|
||||
_trust[key] = record;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task DeleteAsync(string tenantId, string issuerId, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(issuerId);
|
||||
|
||||
var key = GetKey(tenantId, issuerId);
|
||||
_trust.TryRemove(key, out _);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static string GetKey(string tenantId, string issuerId) => $"{tenantId}|{issuerId}";
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
|
||||
using StellaOps.IssuerDirectory.Core.Domain;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Infrastructure.Seed;
|
||||
|
||||
/// <summary>
|
||||
/// Loads CSAF publisher metadata into IssuerRecord instances for bootstrap seeding.
|
||||
/// </summary>
|
||||
public sealed class CsafPublisherSeedLoader
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip
|
||||
};
|
||||
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public CsafPublisherSeedLoader(TimeProvider timeProvider)
|
||||
{
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
public IReadOnlyCollection<IssuerRecord> Load(Stream stream, string actor)
|
||||
{
|
||||
if (stream is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(stream));
|
||||
}
|
||||
|
||||
if (!stream.CanRead)
|
||||
{
|
||||
throw new ArgumentException("Seed stream must be readable.", nameof(stream));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(actor))
|
||||
{
|
||||
throw new ArgumentException("Seed actor is required.", nameof(actor));
|
||||
}
|
||||
|
||||
var seeds = JsonSerializer.Deserialize<List<CsafPublisherSeed>>(stream, SerializerOptions)
|
||||
?? throw new InvalidOperationException("CSAF seed data could not be parsed.");
|
||||
|
||||
var timestamp = _timeProvider.GetUtcNow();
|
||||
return seeds.Select(seed => seed.ToIssuerRecord(timestamp, actor)).ToArray();
|
||||
}
|
||||
|
||||
private sealed class CsafPublisherSeed
|
||||
{
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
public string TenantId { get; set; } = IssuerTenants.Global;
|
||||
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
|
||||
public string Slug { get; set; } = string.Empty;
|
||||
|
||||
public string? Description { get; set; }
|
||||
|
||||
public SeedContact Contact { get; set; } = new();
|
||||
|
||||
public SeedMetadata Metadata { get; set; } = new();
|
||||
|
||||
public List<SeedEndpoint> Endpoints { get; set; } = new();
|
||||
|
||||
public List<string> Tags { get; set; } = new();
|
||||
|
||||
public IssuerRecord ToIssuerRecord(DateTimeOffset timestamp, string actor)
|
||||
{
|
||||
var contact = new IssuerContact(
|
||||
Contact.Email,
|
||||
Contact.Phone,
|
||||
string.IsNullOrWhiteSpace(Contact.Website) ? null : new Uri(Contact.Website),
|
||||
Contact.Timezone);
|
||||
|
||||
var metadata = new IssuerMetadata(
|
||||
Metadata.CveOrgId,
|
||||
Metadata.CsafPublisherId,
|
||||
string.IsNullOrWhiteSpace(Metadata.SecurityAdvisoriesUrl)
|
||||
? null
|
||||
: new Uri(Metadata.SecurityAdvisoriesUrl),
|
||||
string.IsNullOrWhiteSpace(Metadata.CatalogUrl)
|
||||
? null
|
||||
: new Uri(Metadata.CatalogUrl),
|
||||
Metadata.Languages,
|
||||
Metadata.Attributes);
|
||||
|
||||
var endpoints = Endpoints.Select(endpoint => new IssuerEndpoint(
|
||||
endpoint.Kind,
|
||||
new Uri(endpoint.Url),
|
||||
endpoint.Format,
|
||||
endpoint.RequiresAuth)).ToArray();
|
||||
|
||||
return IssuerRecord.Create(
|
||||
string.IsNullOrWhiteSpace(Id) ? Slug : Id,
|
||||
string.IsNullOrWhiteSpace(TenantId) ? IssuerTenants.Global : TenantId,
|
||||
DisplayName,
|
||||
string.IsNullOrWhiteSpace(Slug) ? Id : Slug,
|
||||
Description,
|
||||
contact,
|
||||
metadata,
|
||||
endpoints,
|
||||
Tags,
|
||||
timestamp,
|
||||
actor,
|
||||
isSystemSeed: true);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class SeedContact
|
||||
{
|
||||
public string? Email { get; set; }
|
||||
|
||||
public string? Phone { get; set; }
|
||||
|
||||
public string? Website { get; set; }
|
||||
|
||||
public string? Timezone { get; set; }
|
||||
}
|
||||
|
||||
private sealed class SeedMetadata
|
||||
{
|
||||
public string? CveOrgId { get; set; }
|
||||
|
||||
public string? CsafPublisherId { get; set; }
|
||||
|
||||
public string? SecurityAdvisoriesUrl { get; set; }
|
||||
|
||||
public string? CatalogUrl { get; set; }
|
||||
|
||||
public List<string> Languages { get; set; } = new();
|
||||
|
||||
public Dictionary<string, string> Attributes { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private sealed class SeedEndpoint
|
||||
{
|
||||
public string Kind { get; set; } = "csaf";
|
||||
|
||||
public string Url { get; set; } = string.Empty;
|
||||
|
||||
public string? Format { get; set; }
|
||||
|
||||
public bool RequiresAuth { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.IssuerDirectory.Core.Abstractions;
|
||||
using StellaOps.IssuerDirectory.Infrastructure.InMemory;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Infrastructure;
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddIssuerDirectoryInfrastructure(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
|
||||
services.AddSingleton<IIssuerRepository, InMemoryIssuerRepository>();
|
||||
services.AddSingleton<IIssuerKeyRepository, InMemoryIssuerKeyRepository>();
|
||||
services.AddSingleton<IIssuerTrustRepository, InMemoryIssuerTrustRepository>();
|
||||
services.AddSingleton<IIssuerAuditSink, InMemoryIssuerAuditSink>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\\StellaOps.IssuerDirectory.Core\\StellaOps.IssuerDirectory.Core.csproj" />
|
||||
<ProjectReference Include="..\\..\\..\\__Libraries\\StellaOps.Configuration\\StellaOps.Configuration.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,11 @@
|
||||
# StellaOps.IssuerDirectory.Infrastructure Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0375-M | DONE | Revalidated 2026-01-07; maintainability audit for IssuerDirectory.Infrastructure. |
|
||||
| AUDIT-0375-T | DONE | Revalidated 2026-01-07; test coverage audit for IssuerDirectory.Infrastructure. |
|
||||
| AUDIT-0375-A | TODO | Pending approval (revalidated 2026-01-07). |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
@@ -0,0 +1,26 @@
|
||||
# IssuerDirectory WebService Agent Charter
|
||||
|
||||
## Mission
|
||||
- Deliver HTTP APIs for issuer, key, and trust management with strong auth and audit trails.
|
||||
|
||||
## Responsibilities
|
||||
- Enforce tenant scoping and authorization consistently on all endpoints.
|
||||
- Keep bootstrap/seed workflows deterministic and explicit.
|
||||
- Preserve offline-friendly and minimal dependency behavior.
|
||||
|
||||
## Required Reading
|
||||
- docs/07_HIGH_LEVEL_ARCHITECTURE.md
|
||||
- docs/modules/issuer-directory/architecture.md
|
||||
- docs/modules/platform/architecture-overview.md
|
||||
|
||||
## Working Directory & Scope
|
||||
- Primary: src/IssuerDirectory/StellaOps.IssuerDirectory/StellaOps.IssuerDirectory.WebService
|
||||
- Allowed shared projects: src/IssuerDirectory/StellaOps.IssuerDirectory/StellaOps.IssuerDirectory.Core
|
||||
|
||||
## Testing Expectations
|
||||
- Add API tests for auth scopes, tenant header enforcement, and error handling.
|
||||
- Ensure seed bootstrapping has deterministic test coverage.
|
||||
|
||||
## Working Agreement
|
||||
- Update sprint status in docs/implplan/SPRINT_*.md and local TASKS.md.
|
||||
- Keep outputs deterministic and avoid non-ASCII logs.
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace StellaOps.IssuerDirectory.WebService.Constants;
|
||||
|
||||
internal static class IssuerDirectoryHeaders
|
||||
{
|
||||
public const string AuditReason = "X-StellaOps-Reason";
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
|
||||
using StellaOps.IssuerDirectory.Core.Domain;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.WebService.Contracts;
|
||||
|
||||
public sealed record IssuerResponse(
|
||||
string Id,
|
||||
string TenantId,
|
||||
string DisplayName,
|
||||
string Slug,
|
||||
string? Description,
|
||||
IssuerContactResponse Contact,
|
||||
IssuerMetadataResponse Metadata,
|
||||
IReadOnlyCollection<IssuerEndpointResponse> Endpoints,
|
||||
IReadOnlyCollection<string> Tags,
|
||||
DateTimeOffset CreatedAtUtc,
|
||||
string CreatedBy,
|
||||
DateTimeOffset UpdatedAtUtc,
|
||||
string UpdatedBy,
|
||||
bool IsSystemSeed)
|
||||
{
|
||||
public static IssuerResponse FromDomain(IssuerRecord record)
|
||||
{
|
||||
return new IssuerResponse(
|
||||
record.Id,
|
||||
record.TenantId,
|
||||
record.DisplayName,
|
||||
record.Slug,
|
||||
record.Description,
|
||||
IssuerContactResponse.FromDomain(record.Contact),
|
||||
IssuerMetadataResponse.FromDomain(record.Metadata),
|
||||
record.Endpoints.Select(IssuerEndpointResponse.FromDomain).ToArray(),
|
||||
record.Tags,
|
||||
record.CreatedAtUtc,
|
||||
record.CreatedBy,
|
||||
record.UpdatedAtUtc,
|
||||
record.UpdatedBy,
|
||||
record.IsSystemSeed);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record IssuerContactResponse(string? Email, string? Phone, string? Website, string? Timezone)
|
||||
{
|
||||
public static IssuerContactResponse FromDomain(IssuerContact contact)
|
||||
{
|
||||
return new IssuerContactResponse(
|
||||
contact.Email,
|
||||
contact.Phone,
|
||||
contact.Website?.ToString(),
|
||||
contact.Timezone);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record IssuerMetadataResponse(
|
||||
string? CveOrgId,
|
||||
string? CsafPublisherId,
|
||||
string? SecurityAdvisoriesUrl,
|
||||
string? CatalogUrl,
|
||||
IReadOnlyCollection<string> Languages,
|
||||
IReadOnlyDictionary<string, string> Attributes)
|
||||
{
|
||||
public static IssuerMetadataResponse FromDomain(IssuerMetadata metadata)
|
||||
{
|
||||
return new IssuerMetadataResponse(
|
||||
metadata.CveOrgId,
|
||||
metadata.CsafPublisherId,
|
||||
metadata.SecurityAdvisoriesUrl?.ToString(),
|
||||
metadata.CatalogUrl?.ToString(),
|
||||
metadata.SupportedLanguages,
|
||||
metadata.Attributes);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record IssuerEndpointResponse(string Kind, string Url, string? Format, bool RequiresAuthentication)
|
||||
{
|
||||
public static IssuerEndpointResponse FromDomain(IssuerEndpoint endpoint)
|
||||
{
|
||||
return new IssuerEndpointResponse(
|
||||
endpoint.Kind,
|
||||
endpoint.Url.ToString(),
|
||||
endpoint.Format,
|
||||
endpoint.RequiresAuthentication);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record IssuerUpsertRequest
|
||||
{
|
||||
[Required]
|
||||
public string Id { get; init; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
public string DisplayName { get; init; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
public string Slug { get; init; } = string.Empty;
|
||||
|
||||
public string? Description { get; init; }
|
||||
|
||||
public IssuerContactRequest Contact { get; init; } = new();
|
||||
|
||||
public IssuerMetadataRequest Metadata { get; init; } = new();
|
||||
|
||||
public List<IssuerEndpointRequest> Endpoints { get; init; } = new();
|
||||
|
||||
public List<string> Tags { get; init; } = new();
|
||||
|
||||
public IssuerContact ToDomainContact()
|
||||
{
|
||||
return new IssuerContact(
|
||||
Contact.Email,
|
||||
Contact.Phone,
|
||||
string.IsNullOrWhiteSpace(Contact.Website) ? null : new Uri(Contact.Website),
|
||||
Contact.Timezone);
|
||||
}
|
||||
|
||||
public IssuerMetadata ToDomainMetadata()
|
||||
{
|
||||
return new IssuerMetadata(
|
||||
Metadata.CveOrgId,
|
||||
Metadata.CsafPublisherId,
|
||||
string.IsNullOrWhiteSpace(Metadata.SecurityAdvisoriesUrl)
|
||||
? null
|
||||
: new Uri(Metadata.SecurityAdvisoriesUrl),
|
||||
string.IsNullOrWhiteSpace(Metadata.CatalogUrl)
|
||||
? null
|
||||
: new Uri(Metadata.CatalogUrl),
|
||||
Metadata.Languages,
|
||||
Metadata.Attributes);
|
||||
}
|
||||
|
||||
public IReadOnlyCollection<IssuerEndpoint> ToDomainEndpoints()
|
||||
{
|
||||
return Endpoints.Select(endpoint => new IssuerEndpoint(
|
||||
endpoint.Kind,
|
||||
new Uri(endpoint.Url),
|
||||
endpoint.Format,
|
||||
endpoint.RequiresAuth)).ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record IssuerContactRequest
|
||||
{
|
||||
public string? Email { get; init; }
|
||||
|
||||
public string? Phone { get; init; }
|
||||
|
||||
public string? Website { get; init; }
|
||||
|
||||
public string? Timezone { get; init; }
|
||||
}
|
||||
|
||||
public sealed record IssuerMetadataRequest
|
||||
{
|
||||
public string? CveOrgId { get; init; }
|
||||
|
||||
public string? CsafPublisherId { get; init; }
|
||||
|
||||
public string? SecurityAdvisoriesUrl { get; init; }
|
||||
|
||||
public string? CatalogUrl { get; init; }
|
||||
|
||||
public List<string> Languages { get; init; } = new();
|
||||
|
||||
public Dictionary<string, string> Attributes { get; init; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public sealed record IssuerEndpointRequest
|
||||
{
|
||||
[Required]
|
||||
public string Kind { get; init; } = "csaf";
|
||||
|
||||
[Required]
|
||||
public string Url { get; init; } = string.Empty;
|
||||
|
||||
public string? Format { get; init; }
|
||||
|
||||
public bool RequiresAuth { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using StellaOps.IssuerDirectory.Core.Domain;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.WebService.Contracts;
|
||||
|
||||
public sealed record IssuerKeyResponse(
|
||||
string Id,
|
||||
string IssuerId,
|
||||
string TenantId,
|
||||
string Type,
|
||||
string Status,
|
||||
string MaterialFormat,
|
||||
string Fingerprint,
|
||||
DateTimeOffset CreatedAtUtc,
|
||||
string CreatedBy,
|
||||
DateTimeOffset UpdatedAtUtc,
|
||||
string UpdatedBy,
|
||||
DateTimeOffset? ExpiresAtUtc,
|
||||
DateTimeOffset? RetiredAtUtc,
|
||||
DateTimeOffset? RevokedAtUtc,
|
||||
string? ReplacesKeyId)
|
||||
{
|
||||
public static IssuerKeyResponse FromDomain(IssuerKeyRecord record)
|
||||
{
|
||||
return new IssuerKeyResponse(
|
||||
record.Id,
|
||||
record.IssuerId,
|
||||
record.TenantId,
|
||||
record.Type.ToString(),
|
||||
record.Status.ToString(),
|
||||
record.Material.Format,
|
||||
record.Fingerprint,
|
||||
record.CreatedAtUtc,
|
||||
record.CreatedBy,
|
||||
record.UpdatedAtUtc,
|
||||
record.UpdatedBy,
|
||||
record.ExpiresAtUtc,
|
||||
record.RetiredAtUtc,
|
||||
record.RevokedAtUtc,
|
||||
record.ReplacesKeyId);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record IssuerKeyCreateRequest
|
||||
{
|
||||
public string Type { get; init; } = string.Empty;
|
||||
|
||||
public string Format { get; init; } = string.Empty;
|
||||
|
||||
public string Value { get; init; } = string.Empty;
|
||||
|
||||
public DateTimeOffset? ExpiresAtUtc { get; init; }
|
||||
}
|
||||
|
||||
public sealed record IssuerKeyRotateRequest
|
||||
{
|
||||
public string Type { get; init; } = string.Empty;
|
||||
|
||||
public string Format { get; init; } = string.Empty;
|
||||
|
||||
public string Value { get; init; } = string.Empty;
|
||||
|
||||
public DateTimeOffset? ExpiresAtUtc { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using StellaOps.IssuerDirectory.Core.Services;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.WebService.Contracts;
|
||||
|
||||
public sealed record IssuerTrustResponse(
|
||||
TrustOverrideSummary? TenantOverride,
|
||||
TrustOverrideSummary? GlobalOverride,
|
||||
decimal EffectiveWeight)
|
||||
{
|
||||
public static IssuerTrustResponse FromView(IssuerTrustView view)
|
||||
{
|
||||
return new IssuerTrustResponse(
|
||||
view.TenantOverride is null ? null : TrustOverrideSummary.FromRecord(view.TenantOverride),
|
||||
view.GlobalOverride is null ? null : TrustOverrideSummary.FromRecord(view.GlobalOverride),
|
||||
view.EffectiveWeight);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record TrustOverrideSummary(
|
||||
decimal Weight,
|
||||
string? Reason,
|
||||
DateTimeOffset UpdatedAtUtc,
|
||||
string UpdatedBy,
|
||||
DateTimeOffset CreatedAtUtc,
|
||||
string CreatedBy)
|
||||
{
|
||||
public static TrustOverrideSummary FromRecord(Core.Domain.IssuerTrustOverrideRecord record)
|
||||
{
|
||||
return new TrustOverrideSummary(
|
||||
record.Weight,
|
||||
record.Reason,
|
||||
record.UpdatedAtUtc,
|
||||
record.UpdatedBy,
|
||||
record.CreatedAtUtc,
|
||||
record.CreatedBy);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record IssuerTrustSetRequest
|
||||
{
|
||||
public decimal Weight { get; init; }
|
||||
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
using static StellaOps.Localization.T;
|
||||
using StellaOps.IssuerDirectory.Core.Services;
|
||||
using StellaOps.IssuerDirectory.WebService.Constants;
|
||||
using StellaOps.IssuerDirectory.WebService.Contracts;
|
||||
using StellaOps.IssuerDirectory.WebService.Options;
|
||||
using StellaOps.IssuerDirectory.WebService.Security;
|
||||
using StellaOps.IssuerDirectory.WebService.Services;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.WebService.Endpoints;
|
||||
|
||||
public static class IssuerEndpoints
|
||||
{
|
||||
public static RouteGroupBuilder MapIssuerEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/issuer-directory/issuers")
|
||||
.WithTags("Issuer Directory")
|
||||
.RequireTenant();
|
||||
|
||||
group.MapGet(string.Empty, ListIssuers)
|
||||
.RequireAuthorization(IssuerDirectoryPolicies.Reader)
|
||||
.WithName("IssuerDirectory_ListIssuers")
|
||||
.WithDescription(_t("issuerdirectory.issuer.list_description"));
|
||||
|
||||
group.MapGet("{id}", GetIssuer)
|
||||
.RequireAuthorization(IssuerDirectoryPolicies.Reader)
|
||||
.WithName("IssuerDirectory_GetIssuer")
|
||||
.WithDescription(_t("issuerdirectory.issuer.get_description"));
|
||||
|
||||
group.MapPost(string.Empty, CreateIssuer)
|
||||
.RequireAuthorization(IssuerDirectoryPolicies.Writer)
|
||||
.WithName("IssuerDirectory_CreateIssuer")
|
||||
.WithDescription(_t("issuerdirectory.issuer.create_description"));
|
||||
|
||||
group.MapPut("{id}", UpdateIssuer)
|
||||
.RequireAuthorization(IssuerDirectoryPolicies.Writer)
|
||||
.WithName("IssuerDirectory_UpdateIssuer")
|
||||
.WithDescription(_t("issuerdirectory.issuer.update_description"));
|
||||
|
||||
group.MapDelete("{id}", DeleteIssuer)
|
||||
.RequireAuthorization(IssuerDirectoryPolicies.Admin)
|
||||
.WithName("IssuerDirectory_DeleteIssuer")
|
||||
.WithDescription(_t("issuerdirectory.issuer.delete_description"));
|
||||
|
||||
group.MapIssuerKeyEndpoints();
|
||||
group.MapIssuerTrustEndpoints();
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListIssuers(
|
||||
HttpContext context,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IssuerDirectoryService service,
|
||||
[FromQuery] bool includeGlobal = true,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!tenantResolver.TryResolve(context, out var tenantId, out var tenantError))
|
||||
{
|
||||
return TenantRequired(tenantError);
|
||||
}
|
||||
|
||||
var issuers = await service.ListAsync(tenantId, includeGlobal, cancellationToken).ConfigureAwait(false);
|
||||
var response = issuers.Select(IssuerResponse.FromDomain).ToArray();
|
||||
return Results.Ok(response);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetIssuer(
|
||||
HttpContext context,
|
||||
[FromRoute] string id,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IssuerDirectoryService service,
|
||||
[FromQuery] bool includeGlobal = true,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!tenantResolver.TryResolve(context, out var tenantId, out var tenantError))
|
||||
{
|
||||
return TenantRequired(tenantError);
|
||||
}
|
||||
|
||||
var issuer = await service.GetAsync(tenantId, id, includeGlobal, cancellationToken).ConfigureAwait(false);
|
||||
if (issuer is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Ok(IssuerResponse.FromDomain(issuer));
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateIssuer(
|
||||
HttpContext context,
|
||||
[FromBody] IssuerUpsertRequest request,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IssuerDirectoryService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!tenantResolver.TryResolve(context, out var tenantId, out var tenantError))
|
||||
{
|
||||
return TenantRequired(tenantError);
|
||||
}
|
||||
|
||||
var actor = ActorResolver.Resolve(context);
|
||||
var reason = ResolveAuditReason(context);
|
||||
|
||||
var issuer = await service.CreateAsync(
|
||||
tenantId,
|
||||
request.Id,
|
||||
request.DisplayName,
|
||||
request.Slug,
|
||||
request.Description,
|
||||
request.ToDomainContact(),
|
||||
request.ToDomainMetadata(),
|
||||
request.ToDomainEndpoints(),
|
||||
request.Tags,
|
||||
actor,
|
||||
reason,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Created($"/issuer-directory/issuers/{issuer.Id}", IssuerResponse.FromDomain(issuer));
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateIssuer(
|
||||
HttpContext context,
|
||||
[FromRoute] string id,
|
||||
[FromBody] IssuerUpsertRequest request,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IssuerDirectoryService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!string.Equals(id, request.Id, StringComparison.Ordinal))
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Identifier mismatch",
|
||||
Detail = _t("issuerdirectory.error.id_mismatch")
|
||||
});
|
||||
}
|
||||
|
||||
if (!tenantResolver.TryResolve(context, out var tenantId, out var tenantError))
|
||||
{
|
||||
return TenantRequired(tenantError);
|
||||
}
|
||||
|
||||
var actor = ActorResolver.Resolve(context);
|
||||
var reason = ResolveAuditReason(context);
|
||||
|
||||
var issuer = await service.UpdateAsync(
|
||||
tenantId,
|
||||
id,
|
||||
request.DisplayName,
|
||||
request.Description,
|
||||
request.ToDomainContact(),
|
||||
request.ToDomainMetadata(),
|
||||
request.ToDomainEndpoints(),
|
||||
request.Tags,
|
||||
actor,
|
||||
reason,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(IssuerResponse.FromDomain(issuer));
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeleteIssuer(
|
||||
HttpContext context,
|
||||
[FromRoute] string id,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IssuerDirectoryService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!tenantResolver.TryResolve(context, out var tenantId, out var tenantError))
|
||||
{
|
||||
return TenantRequired(tenantError);
|
||||
}
|
||||
|
||||
var actor = ActorResolver.Resolve(context);
|
||||
var reason = ResolveAuditReason(context);
|
||||
|
||||
await service.DeleteAsync(tenantId, id, actor, reason, cancellationToken).ConfigureAwait(false);
|
||||
return Results.NoContent();
|
||||
}
|
||||
|
||||
private static IResult TenantRequired(string detail)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = _t("issuerdirectory.error.tenant_required"),
|
||||
Detail = detail
|
||||
});
|
||||
}
|
||||
|
||||
private static string? ResolveAuditReason(HttpContext context)
|
||||
{
|
||||
if (context.Request.Headers.TryGetValue(IssuerDirectoryHeaders.AuditReason, out var value))
|
||||
{
|
||||
var reason = value.ToString();
|
||||
return string.IsNullOrWhiteSpace(reason) ? null : reason.Trim();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user