consolidation of some of the modules, localization fixes, product advisories work, qa work

This commit is contained in:
master
2026-03-05 03:54:22 +02:00
parent 7bafcc3eef
commit 8e1cb9448d
3878 changed files with 72600 additions and 46861 deletions

View File

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

View File

@@ -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";

View File

@@ -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";

View File

@@ -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;

View File

@@ -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));
});
}

View File

@@ -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;

View File

@@ -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)

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -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) =>

View File

@@ -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());

View 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.

View File

@@ -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.

View File

@@ -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();
}
}

View File

@@ -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");
}
}

View File

@@ -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")
};
}
}

View File

@@ -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"));
}
}

View File

@@ -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();
}
}
}

View File

@@ -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()
{
}
}
}
}

View File

@@ -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);
}
}

View File

@@ -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");
}
}

View File

@@ -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);
}
}

View File

@@ -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");
}
}

View File

@@ -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();
}
}

View File

@@ -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");
}
}

View File

@@ -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();
}
}

View File

@@ -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;
}
}
}

View File

@@ -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");
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}
}

View File

@@ -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");
}
}

View File

@@ -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 });
}
}

View File

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

View File

@@ -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). |

View File

@@ -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*");
}
}

View File

@@ -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.

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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()
};
}
}

View File

@@ -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.")
};
}
}

View File

@@ -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; }
}

View File

@@ -0,0 +1,11 @@
namespace StellaOps.IssuerDirectory.Core.Domain;
/// <summary>
/// Lifecycle status for issuer keys.
/// </summary>
public enum IssuerKeyStatus
{
Active,
Retired,
Revoked
}

View File

@@ -0,0 +1,11 @@
namespace StellaOps.IssuerDirectory.Core.Domain;
/// <summary>
/// Supported issuer key kinds.
/// </summary>
public enum IssuerKeyType
{
Ed25519PublicKey,
X509Certificate,
DssePublicKey
}

View File

@@ -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);
}
}

View File

@@ -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
};
}
}

View File

@@ -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();
}
}

View File

@@ -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()
};
}
}

View File

@@ -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; }
}

View File

@@ -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";
}

View File

@@ -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.");
}
}
}

View File

@@ -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();
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}
}
}

View File

@@ -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;
}
}

View File

@@ -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));
}
}

View File

@@ -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;
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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));
}
}

View File

@@ -0,0 +1,8 @@
using StellaOps.IssuerDirectory.Core.Domain;
namespace StellaOps.IssuerDirectory.Core.Services;
public sealed record IssuerTrustView(
IssuerTrustOverrideRecord? TenantOverride,
IssuerTrustOverrideRecord? GlobalOverride,
decimal EffectiveWeight);

View File

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

View File

@@ -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). |

View File

@@ -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; }
}

View File

@@ -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);
}
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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());
}
}

View File

@@ -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.

View File

@@ -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;
}
}

View File

@@ -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}";
}
}

View File

@@ -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;
}
}

View File

@@ -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}";
}

View File

@@ -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; }
}
}

View File

@@ -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;
}
}

View File

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

View File

@@ -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. |

View File

@@ -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.

View File

@@ -0,0 +1,6 @@
namespace StellaOps.IssuerDirectory.WebService.Constants;
internal static class IssuerDirectoryHeaders
{
public const string AuditReason = "X-StellaOps-Reason";
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

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