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

@@ -76,10 +76,10 @@ ON CONFLICT (tenant_id, name) DO NOTHING;
-- OAuth Clients
-- ============================================================================
INSERT INTO authority.clients (id, client_id, display_name, description, enabled, redirect_uris, allowed_scopes, allowed_grant_types, require_client_secret, require_pkce)
INSERT INTO authority.clients (id, client_id, display_name, description, enabled, redirect_uris, allowed_scopes, allowed_grant_types, require_client_secret, require_pkce, properties)
VALUES
('demo-client-ui', 'stella-ops-ui', 'Stella Ops Console', 'Web UI application', true,
ARRAY['https://stella-ops.local/auth/callback', 'https://stella-ops.local/auth/silent-refresh'],
ARRAY['https://stella-ops.local/auth/callback', 'https://stella-ops.local/auth/silent-refresh', 'https://127.1.0.1/auth/callback', 'https://127.1.0.1/auth/silent-refresh'],
ARRAY['openid', 'profile', 'email', 'offline_access',
'ui.read', 'ui.admin',
'authority:tenants.read', 'authority:users.read', 'authority:roles.read',
@@ -87,7 +87,9 @@ VALUES
'authority.audit.read',
'graph:read', 'sbom:read', 'scanner:read',
'policy:read', 'policy:simulate', 'policy:author', 'policy:review', 'policy:approve',
'orch:read', 'analytics.read', 'advisory:read', 'vex:read',
'policy:run', 'policy:activate', 'policy:audit', 'policy:edit', 'policy:operate', 'policy:publish',
'airgap:seal', 'airgap:status:read',
'orch:read', 'analytics.read', 'advisory:read', 'vex:read', 'vexhub:read',
'exceptions:read', 'exceptions:approve', 'aoc:verify', 'findings:read',
'release:read', 'scheduler:read', 'scheduler:operate',
'notify.viewer', 'notify.operator', 'notify.admin', 'notify.escalate',
@@ -95,14 +97,19 @@ VALUES
'export.viewer', 'export.operator', 'export.admin',
'vuln:view', 'vuln:investigate', 'vuln:operate', 'vuln:audit',
'platform.context.read', 'platform.context.write',
'doctor:run', 'doctor:admin'],
'ui.preferences.read', 'ui.preferences.write',
'doctor:run', 'doctor:admin',
'ops.health',
'integration:read', 'integration:write', 'integration:operate',
'advisory-ai:view', 'advisory-ai:operate',
'timeline:read', 'timeline:write'],
ARRAY['authorization_code', 'refresh_token'],
false, true),
false, true, '{"tenant": "demo-prod"}'::jsonb),
('demo-client-cli', 'stellaops-cli', 'Stella Ops CLI', 'Command-line client', true,
ARRAY['http://localhost:8400/callback'],
ARRAY['openid', 'profile', 'stellaops.api', 'stellaops.admin'],
ARRAY['authorization_code', 'device_code'],
false, true)
false, true, '{"tenant": "demo-prod"}'::jsonb)
ON CONFLICT (client_id) DO NOTHING;
-- ============================================================================

View File

@@ -66,17 +66,17 @@ public sealed class ClientRepository : IClientRepository
var propertiesJson = JsonSerializer.Serialize(entity.Properties, SerializerOptions);
var certificateBindingsJson = JsonSerializer.Serialize(entity.CertificateBindings, SerializerOptions);
await dbContext.Database.ExecuteSqlRawAsync("""
await dbContext.Database.ExecuteSqlAsync($"""
INSERT INTO authority.clients
(id, client_id, client_secret, secret_hash, display_name, description, plugin, sender_constraint,
enabled, redirect_uris, post_logout_redirect_uris, allowed_scopes, allowed_grant_types,
require_client_secret, require_pkce, allow_plain_text_pkce, client_type, properties, certificate_bindings,
created_at, updated_at)
VALUES
({0}, {1}, {2}, {3}, {4}, {5}, {6}, {7},
{8}, {9}, {10}, {11}, {12},
{13}, {14}, {15}, {16}, {17}::jsonb, {18}::jsonb,
{19}, {20})
({entity.Id}, {entity.ClientId}, {entity.ClientSecret}, {entity.SecretHash}, {entity.DisplayName}, {entity.Description}, {entity.Plugin}, {entity.SenderConstraint},
{entity.Enabled}, {entity.RedirectUris.ToArray()}, {entity.PostLogoutRedirectUris.ToArray()}, {entity.AllowedScopes.ToArray()}, {entity.AllowedGrantTypes.ToArray()},
{entity.RequireClientSecret}, {entity.RequirePkce}, {entity.AllowPlainTextPkce}, {entity.ClientType}, {propertiesJson}::jsonb, {certificateBindingsJson}::jsonb,
{entity.CreatedAt}, {entity.UpdatedAt})
ON CONFLICT (client_id) DO UPDATE
SET client_secret = EXCLUDED.client_secret,
secret_hash = EXCLUDED.secret_hash,
@@ -96,28 +96,7 @@ public sealed class ClientRepository : IClientRepository
properties = EXCLUDED.properties,
certificate_bindings = EXCLUDED.certificate_bindings,
updated_at = EXCLUDED.updated_at
""",
entity.Id, entity.ClientId,
(object?)entity.ClientSecret ?? DBNull.Value,
(object?)entity.SecretHash ?? DBNull.Value,
(object?)entity.DisplayName ?? DBNull.Value,
(object?)entity.Description ?? DBNull.Value,
(object?)entity.Plugin ?? DBNull.Value,
(object?)entity.SenderConstraint ?? DBNull.Value,
entity.Enabled,
entity.RedirectUris.ToArray(),
entity.PostLogoutRedirectUris.ToArray(),
entity.AllowedScopes.ToArray(),
entity.AllowedGrantTypes.ToArray(),
entity.RequireClientSecret,
entity.RequirePkce,
entity.AllowPlainTextPkce,
(object?)entity.ClientType ?? DBNull.Value,
propertiesJson,
certificateBindingsJson,
entity.CreatedAt,
entity.UpdatedAt,
cancellationToken).ConfigureAwait(false);
""", cancellationToken).ConfigureAwait(false);
}
public async Task<bool> DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken = default)

View File

@@ -90,21 +90,18 @@ public sealed class OidcTokenRepository : IOidcTokenRepository
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
// Use raw SQL for JSONB property access and string search to preserve exact SQL semantics.
// Use FormattableString overload (FromSql) so nullable parameters are handled correctly.
var entities = await dbContext.OidcTokens
.FromSqlRaw(
"""
.FromSql(
$"""
SELECT *
FROM authority.oidc_tokens
WHERE (properties->>'tenant') = {0}
AND position(' ' || {1} || ' ' IN ' ' || COALESCE(properties->>'scope', '') || ' ') > 0
AND ({2} IS NULL OR created_at >= {2})
WHERE (properties->>'tenant') = {tenant}
AND position(' ' || {scope} || ' ' IN ' ' || COALESCE(properties->>'scope', '') || ' ') > 0
AND ({issuedAfter} IS NULL OR created_at >= {issuedAfter})
ORDER BY created_at DESC, id DESC
LIMIT {3}
""",
tenant, scope,
(object?)issuedAfter ?? DBNull.Value,
limit)
LIMIT {limit}
""")
.AsNoTracking()
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
@@ -117,18 +114,17 @@ public sealed class OidcTokenRepository : IOidcTokenRepository
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
// Use raw SQL for JSONB property access to preserve exact SQL semantics.
// Use FormattableString overload (FromSql) so nullable parameters are handled correctly.
var entities = await dbContext.OidcTokens
.FromSqlRaw(
"""
.FromSql(
$"""
SELECT *
FROM authority.oidc_tokens
WHERE lower(COALESCE(properties->>'status', 'valid')) = 'revoked'
AND ({0} IS NULL OR (properties->>'tenant') = {0})
AND ({tenant} IS NULL OR (properties->>'tenant') = {tenant})
ORDER BY token_id ASC, id ASC
LIMIT {1}
""",
(object?)tenant ?? DBNull.Value, limit)
LIMIT {limit}
""")
.AsNoTracking()
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
@@ -141,20 +137,17 @@ public sealed class OidcTokenRepository : IOidcTokenRepository
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
// Use raw SQL for JSONB property access to preserve exact SQL semantics.
// Use FormattableString overload (SqlQuery) so nullable parameters are handled correctly.
var results = await dbContext.Database
.SqlQueryRaw<long>(
"""
.SqlQuery<long>(
$"""
SELECT COUNT(*)::bigint AS "Value"
FROM authority.oidc_tokens
WHERE (properties->>'tenant') = {0}
AND ({1} IS NULL OR (properties->>'service_account_id') = {1})
WHERE (properties->>'tenant') = {tenant}
AND ({serviceAccountId} IS NULL OR (properties->>'service_account_id') = {serviceAccountId})
AND lower(COALESCE(properties->>'status', 'valid')) <> 'revoked'
AND (expires_at IS NULL OR expires_at > {2})
""",
tenant,
(object?)serviceAccountId ?? DBNull.Value,
now)
AND (expires_at IS NULL OR expires_at > {now})
""")
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
@@ -166,22 +159,19 @@ public sealed class OidcTokenRepository : IOidcTokenRepository
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
// Use raw SQL for JSONB property access to preserve exact SQL semantics.
// Use FormattableString overload (FromSql) so nullable parameters are handled correctly.
var entities = await dbContext.OidcTokens
.FromSqlRaw(
"""
.FromSql(
$"""
SELECT *
FROM authority.oidc_tokens
WHERE (properties->>'tenant') = {0}
AND ({1} IS NULL OR (properties->>'service_account_id') = {1})
WHERE (properties->>'tenant') = {tenant}
AND ({serviceAccountId} IS NULL OR (properties->>'service_account_id') = {serviceAccountId})
AND lower(COALESCE(properties->>'status', 'valid')) <> 'revoked'
AND (expires_at IS NULL OR expires_at > {2})
AND (expires_at IS NULL OR expires_at > {now})
ORDER BY created_at DESC, id DESC
LIMIT {3}
""",
tenant,
(object?)serviceAccountId ?? DBNull.Value,
now, limit)
LIMIT {limit}
""")
.AsNoTracking()
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
@@ -209,12 +199,15 @@ public sealed class OidcTokenRepository : IOidcTokenRepository
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
// Use raw SQL for ON CONFLICT DO UPDATE to preserve exact SQL behavior.
await dbContext.Database.ExecuteSqlRawAsync(
"""
var propertiesJson = JsonSerializer.Serialize(entity.Properties, SerializerOptions);
// Use FormattableString overload (ExecuteSqlAsync) so nullable parameters are handled
// correctly by EF Core without DBNull.Value type-mapping failures.
await dbContext.Database.ExecuteSqlAsync(
$"""
INSERT INTO authority.oidc_tokens
(id, token_id, subject_id, client_id, token_type, reference_id, created_at, expires_at, redeemed_at, payload, properties)
VALUES ({0}, {1}, {2}, {3}, {4}, {5}, {6}, {7}, {8}, {9}, {10}::jsonb)
VALUES ({entity.Id}, {entity.TokenId}, {entity.SubjectId}, {entity.ClientId}, {entity.TokenType}, {entity.ReferenceId}, {entity.CreatedAt}, {entity.ExpiresAt}, {entity.RedeemedAt}, {entity.Payload}, {propertiesJson}::jsonb)
ON CONFLICT (token_id) DO UPDATE
SET subject_id = EXCLUDED.subject_id,
client_id = EXCLUDED.client_id,
@@ -226,16 +219,6 @@ public sealed class OidcTokenRepository : IOidcTokenRepository
payload = EXCLUDED.payload,
properties = EXCLUDED.properties
""",
entity.Id, entity.TokenId,
(object?)entity.SubjectId ?? DBNull.Value,
(object?)entity.ClientId ?? DBNull.Value,
entity.TokenType,
(object?)entity.ReferenceId ?? DBNull.Value,
entity.CreatedAt,
(object?)entity.ExpiresAt ?? DBNull.Value,
(object?)entity.RedeemedAt ?? DBNull.Value,
(object?)entity.Payload ?? DBNull.Value,
JsonSerializer.Serialize(entity.Properties, SerializerOptions),
cancellationToken).ConfigureAwait(false);
}
@@ -305,12 +288,13 @@ public sealed class OidcTokenRepository : IOidcTokenRepository
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
// Use raw SQL for ON CONFLICT DO UPDATE to preserve exact SQL behavior.
await dbContext.Database.ExecuteSqlRawAsync(
"""
// Use FormattableString overload (ExecuteSqlAsync) so nullable parameters are handled
// correctly by EF Core without DBNull.Value type-mapping failures.
await dbContext.Database.ExecuteSqlAsync(
$"""
INSERT INTO authority.oidc_refresh_tokens
(id, token_id, subject_id, client_id, handle, created_at, expires_at, consumed_at, payload)
VALUES ({0}, {1}, {2}, {3}, {4}, {5}, {6}, {7}, {8})
VALUES ({entity.Id}, {entity.TokenId}, {entity.SubjectId}, {entity.ClientId}, {entity.Handle}, {entity.CreatedAt}, {entity.ExpiresAt}, {entity.ConsumedAt}, {entity.Payload})
ON CONFLICT (token_id) DO UPDATE
SET subject_id = EXCLUDED.subject_id,
client_id = EXCLUDED.client_id,
@@ -320,14 +304,6 @@ public sealed class OidcTokenRepository : IOidcTokenRepository
consumed_at = EXCLUDED.consumed_at,
payload = EXCLUDED.payload
""",
entity.Id, entity.TokenId,
(object?)entity.SubjectId ?? DBNull.Value,
(object?)entity.ClientId ?? DBNull.Value,
(object?)entity.Handle ?? DBNull.Value,
entity.CreatedAt,
(object?)entity.ExpiresAt ?? DBNull.Value,
(object?)entity.ConsumedAt ?? DBNull.Value,
(object?)entity.Payload ?? DBNull.Value,
cancellationToken).ConfigureAwait(false);
}
@@ -338,13 +314,12 @@ public sealed class OidcTokenRepository : IOidcTokenRepository
// Use app-side timestamp via TimeProvider for consumed_at.
var now = _timeProvider.GetUtcNow();
var rows = await dbContext.Database.ExecuteSqlRawAsync(
"""
var rows = await dbContext.Database.ExecuteSqlAsync(
$"""
UPDATE authority.oidc_refresh_tokens
SET consumed_at = {0}
WHERE token_id = {1} AND consumed_at IS NULL
SET consumed_at = {now}
WHERE token_id = {tokenId} AND consumed_at IS NULL
""",
now, tokenId,
cancellationToken).ConfigureAwait(false);
return rows > 0;

View File

@@ -173,7 +173,7 @@ public sealed class PermissionRepository : IPermissionRepository
VALUES ({0}, {1})
ON CONFLICT (role_id, permission_id) DO NOTHING
""",
roleId, permissionId,
new object[] { roleId, permissionId },
cancellationToken).ConfigureAwait(false);
}

View File

@@ -41,21 +41,15 @@ public sealed class RevocationExportStateRepository
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
// Use raw SQL for ON CONFLICT with optimistic WHERE clause to preserve exact SQL behavior.
var affected = await dbContext.Database.ExecuteSqlRawAsync(
"""
var affected = await dbContext.Database.ExecuteSqlAsync($"""
INSERT INTO authority.revocation_export_state (id, sequence, bundle_id, issued_at)
VALUES (1, {0}, {1}, {2})
VALUES (1, {entity.Sequence}, {entity.BundleId}, {entity.IssuedAt})
ON CONFLICT (id) DO UPDATE
SET sequence = EXCLUDED.sequence,
bundle_id = EXCLUDED.bundle_id,
issued_at = EXCLUDED.issued_at
WHERE authority.revocation_export_state.sequence = {3}
""",
entity.Sequence,
(object?)entity.BundleId ?? DBNull.Value,
(object?)entity.IssuedAt ?? DBNull.Value,
expectedSequence,
cancellationToken).ConfigureAwait(false);
WHERE authority.revocation_export_state.sequence = {expectedSequence}
""", cancellationToken).ConfigureAwait(false);
if (affected == 0)
{

View File

@@ -29,11 +29,11 @@ public sealed class RevocationRepository : IRevocationRepository
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
// Use raw SQL for ON CONFLICT DO UPDATE to preserve exact SQL behavior.
await dbContext.Database.ExecuteSqlRawAsync(
"""
var metadataJson = JsonSerializer.Serialize(entity.Metadata, SerializerOptions);
await dbContext.Database.ExecuteSqlAsync($"""
INSERT INTO authority.revocations
(id, category, revocation_id, subject_id, client_id, token_id, reason, reason_description, revoked_at, effective_at, expires_at, metadata)
VALUES ({0}, {1}, {2}, {3}, {4}, {5}, {6}, {7}, {8}, {9}, {10}, {11}::jsonb)
VALUES ({entity.Id}, {entity.Category}, {entity.RevocationId}, {entity.SubjectId}, {entity.ClientId}, {entity.TokenId}, {entity.Reason}, {entity.ReasonDescription}, {entity.RevokedAt}, {entity.EffectiveAt}, {entity.ExpiresAt}, {metadataJson}::jsonb)
ON CONFLICT (category, revocation_id) DO UPDATE
SET subject_id = EXCLUDED.subject_id,
client_id = EXCLUDED.client_id,
@@ -44,17 +44,7 @@ public sealed class RevocationRepository : IRevocationRepository
effective_at = EXCLUDED.effective_at,
expires_at = EXCLUDED.expires_at,
metadata = EXCLUDED.metadata
""",
entity.Id, entity.Category, entity.RevocationId,
(object?)entity.SubjectId ?? DBNull.Value,
(object?)entity.ClientId ?? DBNull.Value,
(object?)entity.TokenId ?? DBNull.Value,
entity.Reason,
(object?)entity.ReasonDescription ?? DBNull.Value,
entity.RevokedAt, entity.EffectiveAt,
(object?)entity.ExpiresAt ?? DBNull.Value,
JsonSerializer.Serialize(entity.Metadata, SerializerOptions),
cancellationToken).ConfigureAwait(false);
""", cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<RevocationEntity>> GetActiveAsync(DateTimeOffset asOf, CancellationToken cancellationToken = default)

View File

@@ -149,17 +149,13 @@ public sealed class RoleRepository : IRoleRepository
// Use app-side timestamp via TimeProvider for granted_at on conflict.
var now = _timeProvider.GetUtcNow();
await dbContext.Database.ExecuteSqlRawAsync(
"""
await dbContext.Database.ExecuteSqlAsync(
$"""
INSERT INTO authority.user_roles (user_id, role_id, granted_by, expires_at)
VALUES ({0}, {1}, {2}, {3})
VALUES ({userId}, {roleId}, {grantedBy}, {expiresAt})
ON CONFLICT (user_id, role_id) DO UPDATE SET
granted_at = {4}, granted_by = EXCLUDED.granted_by, expires_at = EXCLUDED.expires_at
granted_at = {now}, granted_by = EXCLUDED.granted_by, expires_at = EXCLUDED.expires_at
""",
userId, roleId,
(object?)grantedBy ?? DBNull.Value,
(object?)expiresAt ?? DBNull.Value,
now,
cancellationToken).ConfigureAwait(false);
}

View File

@@ -61,11 +61,11 @@ public sealed class ServiceAccountRepository : IServiceAccountRepository
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
// Use raw SQL for ON CONFLICT DO UPDATE to preserve exact SQL behavior.
await dbContext.Database.ExecuteSqlRawAsync(
"""
var attributesJson = JsonSerializer.Serialize(entity.Attributes, SerializerOptions);
await dbContext.Database.ExecuteSqlAsync($"""
INSERT INTO authority.service_accounts
(id, account_id, tenant, display_name, description, enabled, allowed_scopes, authorized_clients, attributes, created_at, updated_at)
VALUES ({0}, {1}, {2}, {3}, {4}, {5}, {6}, {7}, {8}::jsonb, {9}, {10})
VALUES ({entity.Id}, {entity.AccountId}, {entity.Tenant}, {entity.DisplayName}, {entity.Description}, {entity.Enabled}, {entity.AllowedScopes.ToArray()}, {entity.AuthorizedClients.ToArray()}, {attributesJson}::jsonb, {entity.CreatedAt}, {entity.UpdatedAt})
ON CONFLICT (account_id) DO UPDATE
SET tenant = EXCLUDED.tenant,
display_name = EXCLUDED.display_name,
@@ -75,14 +75,7 @@ public sealed class ServiceAccountRepository : IServiceAccountRepository
authorized_clients = EXCLUDED.authorized_clients,
attributes = EXCLUDED.attributes,
updated_at = EXCLUDED.updated_at
""",
entity.Id, entity.AccountId, entity.Tenant, entity.DisplayName,
(object?)entity.Description ?? DBNull.Value,
entity.Enabled,
entity.AllowedScopes.ToArray(), entity.AuthorizedClients.ToArray(),
JsonSerializer.Serialize(entity.Attributes, SerializerOptions),
entity.CreatedAt, entity.UpdatedAt,
cancellationToken).ConfigureAwait(false);
""", cancellationToken).ConfigureAwait(false);
}
public async Task<bool> DeleteAsync(string accountId, CancellationToken cancellationToken = default)

View File

@@ -44,12 +44,11 @@ public sealed class TokenRepository : ITokenRepository
// Use app-side timestamp via TimeProvider for consistent clock behavior.
var now = _timeProvider.GetUtcNow();
var entities = await dbContext.Tokens
.FromSqlRaw(
"""
.FromSql(
$"""
SELECT * FROM authority.tokens
WHERE token_hash = {0} AND revoked_at IS NULL AND expires_at > {1}
""",
tokenHash, now)
WHERE token_hash = {tokenHash} AND revoked_at IS NULL AND expires_at > {now}
""")
.AsNoTracking()
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
@@ -105,13 +104,11 @@ public sealed class TokenRepository : ITokenRepository
// Use app-side timestamp via TimeProvider for revoked_at.
var now = _timeProvider.GetUtcNow();
await dbContext.Database.ExecuteSqlRawAsync(
"""
UPDATE authority.tokens SET revoked_at = {0}, revoked_by = {1}
WHERE tenant_id = {2} AND id = {3} AND revoked_at IS NULL
""",
[now, revokedBy, tenantId, id],
cancellationToken).ConfigureAwait(false);
await dbContext.Database.ExecuteSqlAsync(
$"""
UPDATE authority.tokens SET revoked_at = {now}, revoked_by = {revokedBy}
WHERE tenant_id = {tenantId} AND id = {id} AND revoked_at IS NULL
""", cancellationToken).ConfigureAwait(false);
}
public async Task RevokeByUserIdAsync(string tenantId, Guid userId, string revokedBy, CancellationToken cancellationToken = default)
@@ -120,13 +117,11 @@ public sealed class TokenRepository : ITokenRepository
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var now = _timeProvider.GetUtcNow();
await dbContext.Database.ExecuteSqlRawAsync(
"""
UPDATE authority.tokens SET revoked_at = {0}, revoked_by = {1}
WHERE tenant_id = {2} AND user_id = {3} AND revoked_at IS NULL
""",
[now, revokedBy, tenantId, userId],
cancellationToken).ConfigureAwait(false);
await dbContext.Database.ExecuteSqlAsync(
$"""
UPDATE authority.tokens SET revoked_at = {now}, revoked_by = {revokedBy}
WHERE tenant_id = {tenantId} AND user_id = {userId} AND revoked_at IS NULL
""", cancellationToken).ConfigureAwait(false);
}
public async Task DeleteExpiredAsync(CancellationToken cancellationToken = default)
@@ -135,9 +130,8 @@ public sealed class TokenRepository : ITokenRepository
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var cutoff = _timeProvider.GetUtcNow().AddDays(-7);
await dbContext.Database.ExecuteSqlRawAsync(
"DELETE FROM authority.tokens WHERE expires_at < {0}",
[cutoff],
await dbContext.Database.ExecuteSqlAsync(
$"DELETE FROM authority.tokens WHERE expires_at < {cutoff}",
cancellationToken).ConfigureAwait(false);
}
@@ -198,12 +192,11 @@ public sealed class RefreshTokenRepository : IRefreshTokenRepository
var now = _timeProvider.GetUtcNow();
var entities = await dbContext.RefreshTokens
.FromSqlRaw(
"""
.FromSql(
$"""
SELECT * FROM authority.refresh_tokens
WHERE token_hash = {0} AND revoked_at IS NULL AND expires_at > {1}
""",
tokenHash, now)
WHERE token_hash = {tokenHash} AND revoked_at IS NULL AND expires_at > {now}
""")
.AsNoTracking()
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
@@ -257,13 +250,11 @@ public sealed class RefreshTokenRepository : IRefreshTokenRepository
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var now = _timeProvider.GetUtcNow();
await dbContext.Database.ExecuteSqlRawAsync(
"""
UPDATE authority.refresh_tokens SET revoked_at = {0}, revoked_by = {1}, replaced_by = {2}
WHERE tenant_id = {3} AND id = {4} AND revoked_at IS NULL
""",
[now, revokedBy, (object?)replacedBy ?? DBNull.Value, tenantId, id],
cancellationToken).ConfigureAwait(false);
await dbContext.Database.ExecuteSqlAsync(
$"""
UPDATE authority.refresh_tokens SET revoked_at = {now}, revoked_by = {revokedBy}, replaced_by = {replacedBy}
WHERE tenant_id = {tenantId} AND id = {id} AND revoked_at IS NULL
""", cancellationToken).ConfigureAwait(false);
}
public async Task RevokeByUserIdAsync(string tenantId, Guid userId, string revokedBy, CancellationToken cancellationToken = default)
@@ -272,13 +263,11 @@ public sealed class RefreshTokenRepository : IRefreshTokenRepository
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var now = _timeProvider.GetUtcNow();
await dbContext.Database.ExecuteSqlRawAsync(
"""
UPDATE authority.refresh_tokens SET revoked_at = {0}, revoked_by = {1}
WHERE tenant_id = {2} AND user_id = {3} AND revoked_at IS NULL
""",
[now, revokedBy, tenantId, userId],
cancellationToken).ConfigureAwait(false);
await dbContext.Database.ExecuteSqlAsync(
$"""
UPDATE authority.refresh_tokens SET revoked_at = {now}, revoked_by = {revokedBy}
WHERE tenant_id = {tenantId} AND user_id = {userId} AND revoked_at IS NULL
""", cancellationToken).ConfigureAwait(false);
}
public async Task DeleteExpiredAsync(CancellationToken cancellationToken = default)
@@ -287,9 +276,8 @@ public sealed class RefreshTokenRepository : IRefreshTokenRepository
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var cutoff = _timeProvider.GetUtcNow().AddDays(-30);
await dbContext.Database.ExecuteSqlRawAsync(
"DELETE FROM authority.refresh_tokens WHERE expires_at < {0}",
[cutoff],
await dbContext.Database.ExecuteSqlAsync(
$"DELETE FROM authority.refresh_tokens WHERE expires_at < {cutoff}",
cancellationToken).ConfigureAwait(false);
}

View File

@@ -210,19 +210,19 @@ public sealed class UserRepository : IUserRepository
.ConfigureAwait(false);
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
// Use raw SQL for atomic increment + RETURNING pattern.
var result = await dbContext.Database.SqlQueryRaw<int>(
"""
// Use SqlQuery (FormattableString) for atomic increment + RETURNING pattern.
// ToListAsync avoids composability issue with UPDATE...RETURNING (non-composable SQL).
var results = await dbContext.Database.SqlQuery<int>(
$"""
UPDATE authority.users
SET failed_login_attempts = failed_login_attempts + 1, locked_until = {0}
WHERE tenant_id = {1} AND id = {2}
SET failed_login_attempts = failed_login_attempts + 1, locked_until = {lockUntil}
WHERE tenant_id = {tenantId} AND id = {userId}
RETURNING failed_login_attempts
""",
(object?)lockUntil ?? DBNull.Value, tenantId, userId)
.FirstOrDefaultAsync(cancellationToken)
""")
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return result;
return results.FirstOrDefault();
}
public async Task RecordSuccessfulLoginAsync(

View File

@@ -0,0 +1,26 @@
# IssuerDirectory Client Agent Charter
## Mission
- Provide a reliable HTTP client for issuer key and trust lookups with deterministic caching.
## Responsibilities
- Validate options early and normalize tenant/issuer identifiers consistently.
- Keep cache keys stable and invalidation behavior correct.
- Emit actionable error context for remote failures.
## 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/Authority/__Libraries/StellaOps.IssuerDirectory.Client (relocated from src/__Libraries/ by Sprint 216)
- Allowed shared projects: src/Authority/StellaOps.IssuerDirectory
## Testing Expectations
- Add unit tests using stubbed HttpMessageHandler to validate headers and paths.
- Cover cache key normalization and invalidation across includeGlobal variants.
## 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,29 @@
namespace StellaOps.IssuerDirectory.Client;
public interface IIssuerDirectoryClient
{
ValueTask<IReadOnlyList<IssuerKeyModel>> GetIssuerKeysAsync(
string tenantId,
string issuerId,
bool includeGlobal,
CancellationToken cancellationToken);
ValueTask<IssuerTrustResponseModel> GetIssuerTrustAsync(
string tenantId,
string issuerId,
bool includeGlobal,
CancellationToken cancellationToken);
ValueTask<IssuerTrustResponseModel> SetIssuerTrustAsync(
string tenantId,
string issuerId,
decimal weight,
string? reason,
CancellationToken cancellationToken);
ValueTask DeleteIssuerTrustAsync(
string tenantId,
string issuerId,
string? reason,
CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,8 @@
namespace StellaOps.IssuerDirectory.Client;
public sealed class IssuerDirectoryCacheOptions
{
public TimeSpan Keys { get; set; } = TimeSpan.FromMinutes(5);
public TimeSpan Trust { get; set; } = TimeSpan.FromMinutes(5);
}

View File

@@ -0,0 +1,25 @@
using System;
namespace StellaOps.IssuerDirectory.Client;
internal sealed partial class IssuerDirectoryClient
{
private static string CacheKey(string prefix, params string[] parts)
{
if (parts is null || parts.Length == 0)
{
return prefix;
}
var segments = new string[1 + parts.Length];
segments[0] = prefix;
Array.Copy(parts, 0, segments, 1, parts.Length);
return string.Join('|', segments);
}
private void InvalidateTrustCache(string tenantId, string issuerId)
{
_cache.Remove(CacheKey("trust", tenantId, issuerId, bool.FalseString));
_cache.Remove(CacheKey("trust", tenantId, issuerId, bool.TrueString));
}
}

View File

@@ -0,0 +1,55 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
namespace StellaOps.IssuerDirectory.Client;
internal sealed partial class IssuerDirectoryClient
{
public async ValueTask<IReadOnlyList<IssuerKeyModel>> GetIssuerKeysAsync(
string tenantId,
string issuerId,
bool includeGlobal,
CancellationToken cancellationToken)
{
var normalizedTenant = NormalizeRequired(tenantId, nameof(tenantId));
var normalizedIssuer = NormalizeRequired(issuerId, nameof(issuerId));
var includeGlobalValue = includeGlobal.ToString(CultureInfo.InvariantCulture);
var cacheKey = CacheKey("keys", normalizedTenant, normalizedIssuer, includeGlobalValue);
if (_cache.TryGetValue(cacheKey, out IReadOnlyList<IssuerKeyModel>? cached) && cached is not null)
{
return cached;
}
var requestUri =
$"issuer-directory/issuers/{Uri.EscapeDataString(normalizedIssuer)}/keys?includeGlobal={includeGlobal.ToString().ToLowerInvariant()}";
using var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
request.Headers.TryAddWithoutValidation(_options.TenantHeader, normalizedTenant);
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning(
"Issuer Directory key lookup failed for {IssuerId} (tenant={TenantId}) {StatusCode}",
normalizedIssuer,
normalizedTenant,
response.StatusCode);
response.EnsureSuccessStatusCode();
}
var payload = await response.Content.ReadFromJsonAsync<List<IssuerKeyModel>>(cancellationToken: cancellationToken)
.ConfigureAwait(false);
IReadOnlyList<IssuerKeyModel> result = payload?.ToArray() ?? Array.Empty<IssuerKeyModel>();
_cache.Set(cacheKey, result, _options.Cache.Keys);
return result;
}
}

View File

@@ -0,0 +1,42 @@
using System;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace StellaOps.IssuerDirectory.Client;
internal sealed partial class IssuerDirectoryClient
{
public async ValueTask DeleteIssuerTrustAsync(
string tenantId,
string issuerId,
string? reason,
CancellationToken cancellationToken)
{
var normalizedTenant = NormalizeRequired(tenantId, nameof(tenantId));
var normalizedIssuer = NormalizeRequired(issuerId, nameof(issuerId));
var normalizedReason = NormalizeOptional(reason);
var requestUri = $"issuer-directory/issuers/{Uri.EscapeDataString(normalizedIssuer)}/trust";
using var request = new HttpRequestMessage(HttpMethod.Delete, requestUri);
request.Headers.TryAddWithoutValidation(_options.TenantHeader, normalizedTenant);
if (!string.IsNullOrWhiteSpace(normalizedReason))
{
request.Headers.TryAddWithoutValidation(_options.AuditReasonHeader, normalizedReason);
}
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning(
"Issuer Directory trust delete failed for {IssuerId} (tenant={TenantId}) {StatusCode}",
normalizedIssuer,
normalizedTenant,
response.StatusCode);
response.EnsureSuccessStatusCode();
}
InvalidateTrustCache(normalizedTenant, normalizedIssuer);
}
}

View File

@@ -0,0 +1,52 @@
using System;
using System.Globalization;
using System.Net.Http;
using System.Net.Http.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
namespace StellaOps.IssuerDirectory.Client;
internal sealed partial class IssuerDirectoryClient
{
public async ValueTask<IssuerTrustResponseModel> GetIssuerTrustAsync(
string tenantId,
string issuerId,
bool includeGlobal,
CancellationToken cancellationToken)
{
var normalizedTenant = NormalizeRequired(tenantId, nameof(tenantId));
var normalizedIssuer = NormalizeRequired(issuerId, nameof(issuerId));
var includeGlobalValue = includeGlobal.ToString(CultureInfo.InvariantCulture);
var cacheKey = CacheKey("trust", normalizedTenant, normalizedIssuer, includeGlobalValue);
if (_cache.TryGetValue(cacheKey, out IssuerTrustResponseModel? cached) && cached is not null)
{
return cached;
}
var requestUri =
$"issuer-directory/issuers/{Uri.EscapeDataString(normalizedIssuer)}/trust?includeGlobal={includeGlobal.ToString().ToLowerInvariant()}";
using var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
request.Headers.TryAddWithoutValidation(_options.TenantHeader, normalizedTenant);
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning(
"Issuer Directory trust lookup failed for {IssuerId} (tenant={TenantId}) {StatusCode}",
normalizedIssuer,
normalizedTenant,
response.StatusCode);
response.EnsureSuccessStatusCode();
}
var payload = await response.Content.ReadFromJsonAsync<IssuerTrustResponseModel>(cancellationToken: cancellationToken)
.ConfigureAwait(false) ?? new IssuerTrustResponseModel(null, null, 0m);
_cache.Set(cacheKey, payload, _options.Cache.Trust);
return payload;
}
}

View File

@@ -0,0 +1,53 @@
using System;
using System.Net.Http;
using System.Net.Http.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace StellaOps.IssuerDirectory.Client;
internal sealed partial class IssuerDirectoryClient
{
public async ValueTask<IssuerTrustResponseModel> SetIssuerTrustAsync(
string tenantId,
string issuerId,
decimal weight,
string? reason,
CancellationToken cancellationToken)
{
var normalizedTenant = NormalizeRequired(tenantId, nameof(tenantId));
var normalizedIssuer = NormalizeRequired(issuerId, nameof(issuerId));
var normalizedReason = NormalizeOptional(reason);
var requestUri = $"issuer-directory/issuers/{Uri.EscapeDataString(normalizedIssuer)}/trust";
using var request = new HttpRequestMessage(HttpMethod.Put, requestUri)
{
Content = JsonContent.Create(new IssuerTrustSetRequestModel(weight, normalizedReason))
};
request.Headers.TryAddWithoutValidation(_options.TenantHeader, normalizedTenant);
if (!string.IsNullOrWhiteSpace(normalizedReason))
{
request.Headers.TryAddWithoutValidation(_options.AuditReasonHeader, normalizedReason);
}
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning(
"Issuer Directory trust update failed for {IssuerId} (tenant={TenantId}) {StatusCode}",
normalizedIssuer,
normalizedTenant,
response.StatusCode);
response.EnsureSuccessStatusCode();
}
InvalidateTrustCache(normalizedTenant, normalizedIssuer);
var payload = await response.Content.ReadFromJsonAsync<IssuerTrustResponseModel>(cancellationToken: cancellationToken)
.ConfigureAwait(false) ?? new IssuerTrustResponseModel(null, null, 0m);
return payload;
}
}

View File

@@ -0,0 +1,45 @@
using System;
using System.Net.Http;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.IssuerDirectory.Client;
internal sealed partial class IssuerDirectoryClient : IIssuerDirectoryClient
{
private readonly HttpClient _httpClient;
private readonly IMemoryCache _cache;
private readonly IssuerDirectoryClientOptions _options;
private readonly ILogger<IssuerDirectoryClient> _logger;
public IssuerDirectoryClient(
HttpClient httpClient,
IMemoryCache cache,
IOptions<IssuerDirectoryClientOptions> options,
ILogger<IssuerDirectoryClient> logger)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
ArgumentNullException.ThrowIfNull(options);
_options = options.Value;
_options.Validate();
_httpClient.BaseAddress = _options.BaseAddress;
_httpClient.Timeout = _options.HttpTimeout;
}
private static string NormalizeRequired(string value, string paramName)
{
ArgumentException.ThrowIfNullOrWhiteSpace(value, paramName);
return value.Trim();
}
private static string? NormalizeOptional(string? value)
{
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
}

View File

@@ -0,0 +1,44 @@
namespace StellaOps.IssuerDirectory.Client;
public sealed class IssuerDirectoryClientOptions
{
public const string SectionName = "IssuerDirectory:Client";
public Uri? BaseAddress { get; set; }
public TimeSpan HttpTimeout { get; set; } = TimeSpan.FromSeconds(10);
public string TenantHeader { get; set; } = "X-StellaOps-Tenant";
public string AuditReasonHeader { get; set; } = "X-StellaOps-Reason";
public IssuerDirectoryCacheOptions Cache { get; set; } = new();
internal void Validate()
{
if (BaseAddress is null)
{
throw new InvalidOperationException("IssuerDirectory client base address must be configured.");
}
if (!BaseAddress.IsAbsoluteUri)
{
throw new InvalidOperationException("IssuerDirectory client base address must be absolute.");
}
if (HttpTimeout <= TimeSpan.Zero)
{
throw new InvalidOperationException("IssuerDirectory client timeout must be positive.");
}
if (string.IsNullOrWhiteSpace(TenantHeader))
{
throw new InvalidOperationException("IssuerDirectory tenant header must be configured.");
}
if (string.IsNullOrWhiteSpace(AuditReasonHeader))
{
throw new InvalidOperationException("IssuerDirectory audit reason header must be configured.");
}
}
}

View File

@@ -0,0 +1,17 @@
using System.Text.Json.Serialization;
namespace StellaOps.IssuerDirectory.Client;
public sealed record IssuerKeyModel(
[property: JsonPropertyName("id")] string Id,
[property: JsonPropertyName("issuerId")] string IssuerId,
[property: JsonPropertyName("tenantId")] string TenantId,
[property: JsonPropertyName("type")] string Type,
[property: JsonPropertyName("status")] string Status,
[property: JsonPropertyName("materialFormat")] string MaterialFormat,
[property: JsonPropertyName("materialValue")] string MaterialValue,
[property: JsonPropertyName("fingerprint")] string Fingerprint,
[property: JsonPropertyName("expiresAtUtc")] DateTimeOffset? ExpiresAtUtc,
[property: JsonPropertyName("retiredAtUtc")] DateTimeOffset? RetiredAtUtc,
[property: JsonPropertyName("revokedAtUtc")] DateTimeOffset? RevokedAtUtc,
[property: JsonPropertyName("replacesKeyId")] string? ReplacesKeyId);

View File

@@ -0,0 +1,11 @@
using System.Text.Json.Serialization;
namespace StellaOps.IssuerDirectory.Client;
public sealed record IssuerTrustOverrideModel(
[property: JsonPropertyName("weight")] decimal Weight,
[property: JsonPropertyName("reason")] string? Reason,
[property: JsonPropertyName("updatedAtUtc")] DateTimeOffset UpdatedAtUtc,
[property: JsonPropertyName("updatedBy")] string UpdatedBy,
[property: JsonPropertyName("createdAtUtc")] DateTimeOffset CreatedAtUtc,
[property: JsonPropertyName("createdBy")] string CreatedBy);

View File

@@ -0,0 +1,8 @@
using System.Text.Json.Serialization;
namespace StellaOps.IssuerDirectory.Client;
public sealed record IssuerTrustResponseModel(
[property: JsonPropertyName("tenantOverride")] IssuerTrustOverrideModel? TenantOverride,
[property: JsonPropertyName("globalOverride")] IssuerTrustOverrideModel? GlobalOverride,
[property: JsonPropertyName("effectiveWeight")] decimal EffectiveWeight);

View File

@@ -0,0 +1,7 @@
using System.Text.Json.Serialization;
namespace StellaOps.IssuerDirectory.Client;
public sealed record IssuerTrustSetRequestModel(
[property: JsonPropertyName("weight")] decimal Weight,
[property: JsonPropertyName("reason")] string? Reason);

View File

@@ -0,0 +1,51 @@
using System;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace StellaOps.IssuerDirectory.Client;
public static class IssuerDirectoryClientServiceCollectionExtensions
{
public static IServiceCollection AddIssuerDirectoryClient(
this IServiceCollection services,
IConfiguration configuration,
Action<IssuerDirectoryClientOptions>? configure = null)
{
ArgumentNullException.ThrowIfNull(configuration);
return services.AddIssuerDirectoryClient(configuration.GetSection(IssuerDirectoryClientOptions.SectionName), configure);
}
public static IServiceCollection AddIssuerDirectoryClient(
this IServiceCollection services,
IConfigurationSection configurationSection,
Action<IssuerDirectoryClientOptions>? configure = null)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configurationSection);
services.AddMemoryCache();
services.AddOptions<IssuerDirectoryClientOptions>()
.Bind(configurationSection)
.PostConfigure(options => configure?.Invoke(options))
.Validate(options =>
{
try
{
options.Validate();
return true;
}
catch
{
return false;
}
})
.ValidateOnStart();
services.AddHttpClient<IIssuerDirectoryClient, IssuerDirectoryClient>();
return services;
}
}

View File

@@ -0,0 +1,14 @@
<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.Caching.Memory" />
<PackageReference Include="Microsoft.Extensions.Http" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,12 @@
# StellaOps.IssuerDirectory.Client 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-0092-M | DONE | Revalidated 2026-01-08; maintainability audit for IssuerDirectory.Client. |
| AUDIT-0092-T | DONE | Revalidated 2026-01-08; test coverage audit for IssuerDirectory.Client. |
| AUDIT-0092-A | TODO | Pending approval (revalidated 2026-01-08). |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
| REMED-07 | DONE | 2026-02-04: Split client/model/options files, removed service locator, added cache normalization tests (SPRINT_20260130_002). |

View File

@@ -0,0 +1,26 @@
# IssuerDirectory Persistence Agent Charter
## Mission
- Provide deterministic PostgreSQL persistence for IssuerDirectory entities.
## Responsibilities
- Keep schema mappings consistent with domain invariants.
- Ensure JSON serialization/deserialization remains stable and validated.
- Surface clear errors for invalid identifiers and schema mismatches.
## 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/__Libraries/StellaOps.IssuerDirectory.Persistence
- Allowed shared projects: src/IssuerDirectory/StellaOps.IssuerDirectory/StellaOps.IssuerDirectory.Core
## Testing Expectations
- Add repository mapping tests for IDs, key material formats, and key types.
- 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,161 @@
// <auto-generated />
using System;
using System.Reflection;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using StellaOps.IssuerDirectory.Persistence.EfCore.Models;
#pragma warning disable 219, 612, 618
#nullable disable
namespace StellaOps.IssuerDirectory.Persistence.EfCore.CompiledModels
{
[EntityFrameworkInternal]
public partial class AuditEntryEntityType
{
public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null)
{
var runtimeEntityType = model.AddEntityType(
"StellaOps.IssuerDirectory.Persistence.EfCore.Models.AuditEntry",
typeof(AuditEntry),
baseEntityType,
propertyCount: 11,
namedIndexCount: 2,
keyCount: 1);
var id = runtimeEntityType.AddProperty(
"Id",
typeof(long),
propertyInfo: typeof(AuditEntry).GetProperty("Id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(AuditEntry).GetField("<Id>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd,
afterSaveBehavior: PropertySaveBehavior.Throw,
sentinel: 0L);
id.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
id.AddAnnotation("Relational:ColumnName", "id");
var tenantId = runtimeEntityType.AddProperty(
"TenantId",
typeof(Guid),
propertyInfo: typeof(AuditEntry).GetProperty("TenantId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(AuditEntry).GetField("<TenantId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
sentinel: new Guid("00000000-0000-0000-0000-000000000000"));
tenantId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
tenantId.AddAnnotation("Relational:ColumnName", "tenant_id");
var actor = runtimeEntityType.AddProperty(
"Actor",
typeof(string),
propertyInfo: typeof(AuditEntry).GetProperty("Actor", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(AuditEntry).GetField("<Actor>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
actor.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
actor.AddAnnotation("Relational:ColumnName", "actor");
var action = runtimeEntityType.AddProperty(
"Action",
typeof(string),
propertyInfo: typeof(AuditEntry).GetProperty("Action", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(AuditEntry).GetField("<Action>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
action.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
action.AddAnnotation("Relational:ColumnName", "action");
var issuerId = runtimeEntityType.AddProperty(
"IssuerId",
typeof(Guid?),
propertyInfo: typeof(AuditEntry).GetProperty("IssuerId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(AuditEntry).GetField("<IssuerId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
issuerId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
issuerId.AddAnnotation("Relational:ColumnName", "issuer_id");
var keyId = runtimeEntityType.AddProperty(
"KeyId",
typeof(string),
propertyInfo: typeof(AuditEntry).GetProperty("KeyId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(AuditEntry).GetField("<KeyId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
keyId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
keyId.AddAnnotation("Relational:ColumnName", "key_id");
var trustOverrideId = runtimeEntityType.AddProperty(
"TrustOverrideId",
typeof(Guid?),
propertyInfo: typeof(AuditEntry).GetProperty("TrustOverrideId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(AuditEntry).GetField("<TrustOverrideId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
trustOverrideId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
trustOverrideId.AddAnnotation("Relational:ColumnName", "trust_override_id");
var reason = runtimeEntityType.AddProperty(
"Reason",
typeof(string),
propertyInfo: typeof(AuditEntry).GetProperty("Reason", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(AuditEntry).GetField("<Reason>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
reason.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
reason.AddAnnotation("Relational:ColumnName", "reason");
var details = runtimeEntityType.AddProperty(
"Details",
typeof(string),
propertyInfo: typeof(AuditEntry).GetProperty("Details", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(AuditEntry).GetField("<Details>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd);
details.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
details.AddAnnotation("Relational:ColumnName", "details");
details.AddAnnotation("Relational:ColumnType", "jsonb");
details.AddAnnotation("Relational:DefaultValueSql", "'{}'::jsonb");
var correlationId = runtimeEntityType.AddProperty(
"CorrelationId",
typeof(string),
propertyInfo: typeof(AuditEntry).GetProperty("CorrelationId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(AuditEntry).GetField("<CorrelationId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
correlationId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
correlationId.AddAnnotation("Relational:ColumnName", "correlation_id");
var occurredAt = runtimeEntityType.AddProperty(
"OccurredAt",
typeof(DateTime),
propertyInfo: typeof(AuditEntry).GetProperty("OccurredAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(AuditEntry).GetField("<OccurredAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd,
sentinel: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
occurredAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
occurredAt.AddAnnotation("Relational:ColumnName", "occurred_at");
occurredAt.AddAnnotation("Relational:DefaultValueSql", "now()");
var key = runtimeEntityType.AddKey(
new[] { id });
runtimeEntityType.SetPrimaryKey(key);
key.AddAnnotation("Relational:Name", "audit_pkey");
var idx_audit_tenant_time = runtimeEntityType.AddIndex(
new[] { tenantId, occurredAt },
name: "idx_audit_tenant_time");
var idx_audit_issuer = runtimeEntityType.AddIndex(
new[] { issuerId },
name: "idx_audit_issuer");
return runtimeEntityType;
}
public static void CreateAnnotations(RuntimeEntityType runtimeEntityType)
{
runtimeEntityType.AddAnnotation("Relational:FunctionName", null);
runtimeEntityType.AddAnnotation("Relational:Schema", "issuer");
runtimeEntityType.AddAnnotation("Relational:SqlQuery", null);
runtimeEntityType.AddAnnotation("Relational:TableName", "audit");
runtimeEntityType.AddAnnotation("Relational:ViewName", null);
runtimeEntityType.AddAnnotation("Relational:ViewSchema", null);
Customize(runtimeEntityType);
}
static partial void Customize(RuntimeEntityType runtimeEntityType);
}
}

View File

@@ -0,0 +1,6 @@
// <auto-generated />
using Microsoft.EntityFrameworkCore;
using StellaOps.IssuerDirectory.Persistence.EfCore.CompiledModels;
using StellaOps.IssuerDirectory.Persistence.EfCore.Context;
[assembly: DbContext(typeof(IssuerDirectoryDbContext), optimizedModel: typeof(IssuerDirectoryDbContextModel))]

View File

@@ -0,0 +1,48 @@
// <auto-generated />
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using StellaOps.IssuerDirectory.Persistence.EfCore.Context;
#pragma warning disable 219, 612, 618
#nullable disable
namespace StellaOps.IssuerDirectory.Persistence.EfCore.CompiledModels
{
[DbContext(typeof(IssuerDirectoryDbContext))]
public partial class IssuerDirectoryDbContextModel : RuntimeModel
{
private static readonly bool _useOldBehavior31751 =
System.AppContext.TryGetSwitch("Microsoft.EntityFrameworkCore.Issue31751", out var enabled31751) && enabled31751;
static IssuerDirectoryDbContextModel()
{
var model = new IssuerDirectoryDbContextModel();
if (_useOldBehavior31751)
{
model.Initialize();
}
else
{
var thread = new System.Threading.Thread(RunInitialization, 10 * 1024 * 1024);
thread.Start();
thread.Join();
void RunInitialization()
{
model.Initialize();
}
}
model.Customize();
_instance = (IssuerDirectoryDbContextModel)model.FinalizeModel();
}
private static IssuerDirectoryDbContextModel _instance;
public static IModel Instance => _instance;
partial void Initialize();
partial void Customize();
}
}

View File

@@ -0,0 +1,36 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#pragma warning disable 219, 612, 618
#nullable disable
namespace StellaOps.IssuerDirectory.Persistence.EfCore.CompiledModels
{
public partial class IssuerDirectoryDbContextModel
{
private IssuerDirectoryDbContextModel()
: base(skipDetectChanges: false, modelId: new Guid("a7c1e3d4-2f9b-4a8e-b6d0-1c5e7f3a9b2d"), entityTypeCount: 4)
{
}
partial void Initialize()
{
var issuer = IssuerEntityType.Create(this);
var issuerKey = IssuerKeyEntityType.Create(this);
var trustOverride = TrustOverrideEntityType.Create(this);
var auditEntry = AuditEntryEntityType.Create(this);
IssuerEntityType.CreateAnnotations(issuer);
IssuerKeyEntityType.CreateAnnotations(issuerKey);
TrustOverrideEntityType.CreateAnnotations(trustOverride);
AuditEntryEntityType.CreateAnnotations(auditEntry);
AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
AddAnnotation("ProductVersion", "10.0.0");
AddAnnotation("Relational:MaxIdentifierLength", 63);
}
}
}

View File

@@ -0,0 +1,214 @@
// <auto-generated />
using System;
using System.Reflection;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using StellaOps.IssuerDirectory.Persistence.EfCore.Models;
#pragma warning disable 219, 612, 618
#nullable disable
namespace StellaOps.IssuerDirectory.Persistence.EfCore.CompiledModels
{
[EntityFrameworkInternal]
public partial class IssuerEntityType
{
public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null)
{
var runtimeEntityType = model.AddEntityType(
"StellaOps.IssuerDirectory.Persistence.EfCore.Models.Issuer",
typeof(Issuer),
baseEntityType,
propertyCount: 15,
namedIndexCount: 4,
keyCount: 1);
var id = runtimeEntityType.AddProperty(
"Id",
typeof(Guid),
propertyInfo: typeof(Issuer).GetProperty("Id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(Issuer).GetField("<Id>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd,
afterSaveBehavior: PropertySaveBehavior.Throw,
sentinel: new Guid("00000000-0000-0000-0000-000000000000"));
id.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
id.AddAnnotation("Relational:ColumnName", "id");
id.AddAnnotation("Relational:DefaultValueSql", "gen_random_uuid()");
var tenantId = runtimeEntityType.AddProperty(
"TenantId",
typeof(Guid),
propertyInfo: typeof(Issuer).GetProperty("TenantId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(Issuer).GetField("<TenantId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
sentinel: new Guid("00000000-0000-0000-0000-000000000000"));
tenantId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
tenantId.AddAnnotation("Relational:ColumnName", "tenant_id");
var name = runtimeEntityType.AddProperty(
"Name",
typeof(string),
propertyInfo: typeof(Issuer).GetProperty("Name", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(Issuer).GetField("<Name>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
name.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
name.AddAnnotation("Relational:ColumnName", "name");
var displayName = runtimeEntityType.AddProperty(
"DisplayName",
typeof(string),
propertyInfo: typeof(Issuer).GetProperty("DisplayName", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(Issuer).GetField("<DisplayName>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
displayName.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
displayName.AddAnnotation("Relational:ColumnName", "display_name");
var description = runtimeEntityType.AddProperty(
"Description",
typeof(string),
propertyInfo: typeof(Issuer).GetProperty("Description", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(Issuer).GetField("<Description>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
description.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
description.AddAnnotation("Relational:ColumnName", "description");
var endpoints = runtimeEntityType.AddProperty(
"Endpoints",
typeof(string),
propertyInfo: typeof(Issuer).GetProperty("Endpoints", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(Issuer).GetField("<Endpoints>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd);
endpoints.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
endpoints.AddAnnotation("Relational:ColumnName", "endpoints");
endpoints.AddAnnotation("Relational:ColumnType", "jsonb");
endpoints.AddAnnotation("Relational:DefaultValueSql", "'{}'::jsonb");
var contact = runtimeEntityType.AddProperty(
"Contact",
typeof(string),
propertyInfo: typeof(Issuer).GetProperty("Contact", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(Issuer).GetField("<Contact>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd);
contact.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
contact.AddAnnotation("Relational:ColumnName", "contact");
contact.AddAnnotation("Relational:ColumnType", "jsonb");
contact.AddAnnotation("Relational:DefaultValueSql", "'{}'::jsonb");
var metadata = runtimeEntityType.AddProperty(
"Metadata",
typeof(string),
propertyInfo: typeof(Issuer).GetProperty("Metadata", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(Issuer).GetField("<Metadata>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd);
metadata.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
metadata.AddAnnotation("Relational:ColumnName", "metadata");
metadata.AddAnnotation("Relational:ColumnType", "jsonb");
metadata.AddAnnotation("Relational:DefaultValueSql", "'{}'::jsonb");
var tags = runtimeEntityType.AddProperty(
"Tags",
typeof(string[]),
propertyInfo: typeof(Issuer).GetProperty("Tags", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(Issuer).GetField("<Tags>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd);
tags.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
tags.AddAnnotation("Relational:ColumnName", "tags");
tags.AddAnnotation("Relational:DefaultValueSql", "'{}'");
var status = runtimeEntityType.AddProperty(
"Status",
typeof(string),
propertyInfo: typeof(Issuer).GetProperty("Status", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(Issuer).GetField("<Status>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd);
status.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
status.AddAnnotation("Relational:ColumnName", "status");
status.AddAnnotation("Relational:DefaultValueSql", "'active'");
var isSystemSeed = runtimeEntityType.AddProperty(
"IsSystemSeed",
typeof(bool),
propertyInfo: typeof(Issuer).GetProperty("IsSystemSeed", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(Issuer).GetField("<IsSystemSeed>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
sentinel: false);
isSystemSeed.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
isSystemSeed.AddAnnotation("Relational:ColumnName", "is_system_seed");
isSystemSeed.AddAnnotation("Relational:DefaultValue", false);
var createdAt = runtimeEntityType.AddProperty(
"CreatedAt",
typeof(DateTime),
propertyInfo: typeof(Issuer).GetProperty("CreatedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(Issuer).GetField("<CreatedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd,
sentinel: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
createdAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
createdAt.AddAnnotation("Relational:ColumnName", "created_at");
createdAt.AddAnnotation("Relational:DefaultValueSql", "now()");
var createdBy = runtimeEntityType.AddProperty(
"CreatedBy",
typeof(string),
propertyInfo: typeof(Issuer).GetProperty("CreatedBy", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(Issuer).GetField("<CreatedBy>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
createdBy.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
createdBy.AddAnnotation("Relational:ColumnName", "created_by");
var updatedAt = runtimeEntityType.AddProperty(
"UpdatedAt",
typeof(DateTime),
propertyInfo: typeof(Issuer).GetProperty("UpdatedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(Issuer).GetField("<UpdatedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd,
sentinel: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
updatedAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
updatedAt.AddAnnotation("Relational:ColumnName", "updated_at");
updatedAt.AddAnnotation("Relational:DefaultValueSql", "now()");
var updatedBy = runtimeEntityType.AddProperty(
"UpdatedBy",
typeof(string),
propertyInfo: typeof(Issuer).GetProperty("UpdatedBy", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(Issuer).GetField("<UpdatedBy>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
updatedBy.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
updatedBy.AddAnnotation("Relational:ColumnName", "updated_by");
var key = runtimeEntityType.AddKey(
new[] { id });
runtimeEntityType.SetPrimaryKey(key);
key.AddAnnotation("Relational:Name", "issuers_pkey");
var idx_issuers_tenant = runtimeEntityType.AddIndex(
new[] { tenantId },
name: "idx_issuers_tenant");
var idx_issuers_status = runtimeEntityType.AddIndex(
new[] { status },
name: "idx_issuers_status");
var idx_issuers_slug = runtimeEntityType.AddIndex(
new[] { name },
name: "idx_issuers_slug");
var ix_issuers_tenant_name = runtimeEntityType.AddIndex(
new[] { tenantId, name },
unique: true);
return runtimeEntityType;
}
public static void CreateAnnotations(RuntimeEntityType runtimeEntityType)
{
runtimeEntityType.AddAnnotation("Relational:FunctionName", null);
runtimeEntityType.AddAnnotation("Relational:Schema", "issuer");
runtimeEntityType.AddAnnotation("Relational:SqlQuery", null);
runtimeEntityType.AddAnnotation("Relational:TableName", "issuers");
runtimeEntityType.AddAnnotation("Relational:ViewName", null);
runtimeEntityType.AddAnnotation("Relational:ViewSchema", null);
Customize(runtimeEntityType);
}
static partial void Customize(RuntimeEntityType runtimeEntityType);
}
}

View File

@@ -0,0 +1,246 @@
// <auto-generated />
using System;
using System.Reflection;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using StellaOps.IssuerDirectory.Persistence.EfCore.Models;
#pragma warning disable 219, 612, 618
#nullable disable
namespace StellaOps.IssuerDirectory.Persistence.EfCore.CompiledModels
{
[EntityFrameworkInternal]
public partial class IssuerKeyEntityType
{
public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null)
{
var runtimeEntityType = model.AddEntityType(
"StellaOps.IssuerDirectory.Persistence.EfCore.Models.IssuerKey",
typeof(IssuerKey),
baseEntityType,
propertyCount: 19,
namedIndexCount: 5,
keyCount: 1);
var id = runtimeEntityType.AddProperty(
"Id",
typeof(Guid),
propertyInfo: typeof(IssuerKey).GetProperty("Id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(IssuerKey).GetField("<Id>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd,
afterSaveBehavior: PropertySaveBehavior.Throw,
sentinel: new Guid("00000000-0000-0000-0000-000000000000"));
id.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
id.AddAnnotation("Relational:ColumnName", "id");
id.AddAnnotation("Relational:DefaultValueSql", "gen_random_uuid()");
var issuerId = runtimeEntityType.AddProperty(
"IssuerId",
typeof(Guid),
propertyInfo: typeof(IssuerKey).GetProperty("IssuerId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(IssuerKey).GetField("<IssuerId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
sentinel: new Guid("00000000-0000-0000-0000-000000000000"));
issuerId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
issuerId.AddAnnotation("Relational:ColumnName", "issuer_id");
var tenantId = runtimeEntityType.AddProperty(
"TenantId",
typeof(Guid),
propertyInfo: typeof(IssuerKey).GetProperty("TenantId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(IssuerKey).GetField("<TenantId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
sentinel: new Guid("00000000-0000-0000-0000-000000000000"));
tenantId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
tenantId.AddAnnotation("Relational:ColumnName", "tenant_id");
var keyId = runtimeEntityType.AddProperty(
"KeyId",
typeof(string),
propertyInfo: typeof(IssuerKey).GetProperty("KeyId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(IssuerKey).GetField("<KeyId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
keyId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
keyId.AddAnnotation("Relational:ColumnName", "key_id");
var keyType = runtimeEntityType.AddProperty(
"KeyType",
typeof(string),
propertyInfo: typeof(IssuerKey).GetProperty("KeyType", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(IssuerKey).GetField("<KeyType>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
keyType.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
keyType.AddAnnotation("Relational:ColumnName", "key_type");
var publicKey = runtimeEntityType.AddProperty(
"PublicKey",
typeof(string),
propertyInfo: typeof(IssuerKey).GetProperty("PublicKey", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(IssuerKey).GetField("<PublicKey>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
publicKey.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
publicKey.AddAnnotation("Relational:ColumnName", "public_key");
var fingerprint = runtimeEntityType.AddProperty(
"Fingerprint",
typeof(string),
propertyInfo: typeof(IssuerKey).GetProperty("Fingerprint", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(IssuerKey).GetField("<Fingerprint>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
fingerprint.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
fingerprint.AddAnnotation("Relational:ColumnName", "fingerprint");
var notBefore = runtimeEntityType.AddProperty(
"NotBefore",
typeof(DateTime?),
propertyInfo: typeof(IssuerKey).GetProperty("NotBefore", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(IssuerKey).GetField("<NotBefore>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
notBefore.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
notBefore.AddAnnotation("Relational:ColumnName", "not_before");
var notAfter = runtimeEntityType.AddProperty(
"NotAfter",
typeof(DateTime?),
propertyInfo: typeof(IssuerKey).GetProperty("NotAfter", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(IssuerKey).GetField("<NotAfter>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
notAfter.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
notAfter.AddAnnotation("Relational:ColumnName", "not_after");
var status = runtimeEntityType.AddProperty(
"Status",
typeof(string),
propertyInfo: typeof(IssuerKey).GetProperty("Status", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(IssuerKey).GetField("<Status>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd);
status.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
status.AddAnnotation("Relational:ColumnName", "status");
status.AddAnnotation("Relational:DefaultValueSql", "'active'");
var replacesKeyId = runtimeEntityType.AddProperty(
"ReplacesKeyId",
typeof(string),
propertyInfo: typeof(IssuerKey).GetProperty("ReplacesKeyId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(IssuerKey).GetField("<ReplacesKeyId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
replacesKeyId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
replacesKeyId.AddAnnotation("Relational:ColumnName", "replaces_key_id");
var createdAt = runtimeEntityType.AddProperty(
"CreatedAt",
typeof(DateTime),
propertyInfo: typeof(IssuerKey).GetProperty("CreatedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(IssuerKey).GetField("<CreatedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd,
sentinel: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
createdAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
createdAt.AddAnnotation("Relational:ColumnName", "created_at");
createdAt.AddAnnotation("Relational:DefaultValueSql", "now()");
var createdBy = runtimeEntityType.AddProperty(
"CreatedBy",
typeof(string),
propertyInfo: typeof(IssuerKey).GetProperty("CreatedBy", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(IssuerKey).GetField("<CreatedBy>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
createdBy.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
createdBy.AddAnnotation("Relational:ColumnName", "created_by");
var updatedAt = runtimeEntityType.AddProperty(
"UpdatedAt",
typeof(DateTime),
propertyInfo: typeof(IssuerKey).GetProperty("UpdatedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(IssuerKey).GetField("<UpdatedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd,
sentinel: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
updatedAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
updatedAt.AddAnnotation("Relational:ColumnName", "updated_at");
updatedAt.AddAnnotation("Relational:DefaultValueSql", "now()");
var updatedBy = runtimeEntityType.AddProperty(
"UpdatedBy",
typeof(string),
propertyInfo: typeof(IssuerKey).GetProperty("UpdatedBy", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(IssuerKey).GetField("<UpdatedBy>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
updatedBy.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
updatedBy.AddAnnotation("Relational:ColumnName", "updated_by");
var retiredAt = runtimeEntityType.AddProperty(
"RetiredAt",
typeof(DateTime?),
propertyInfo: typeof(IssuerKey).GetProperty("RetiredAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(IssuerKey).GetField("<RetiredAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
retiredAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
retiredAt.AddAnnotation("Relational:ColumnName", "retired_at");
var revokedAt = runtimeEntityType.AddProperty(
"RevokedAt",
typeof(DateTime?),
propertyInfo: typeof(IssuerKey).GetProperty("RevokedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(IssuerKey).GetField("<RevokedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
revokedAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
revokedAt.AddAnnotation("Relational:ColumnName", "revoked_at");
var revokeReason = runtimeEntityType.AddProperty(
"RevokeReason",
typeof(string),
propertyInfo: typeof(IssuerKey).GetProperty("RevokeReason", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(IssuerKey).GetField("<RevokeReason>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
revokeReason.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
revokeReason.AddAnnotation("Relational:ColumnName", "revoke_reason");
var metadata = runtimeEntityType.AddProperty(
"Metadata",
typeof(string),
propertyInfo: typeof(IssuerKey).GetProperty("Metadata", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(IssuerKey).GetField("<Metadata>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd);
metadata.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
metadata.AddAnnotation("Relational:ColumnName", "metadata");
metadata.AddAnnotation("Relational:ColumnType", "jsonb");
metadata.AddAnnotation("Relational:DefaultValueSql", "'{}'::jsonb");
var key = runtimeEntityType.AddKey(
new[] { id });
runtimeEntityType.SetPrimaryKey(key);
key.AddAnnotation("Relational:Name", "issuer_keys_pkey");
var idx_keys_issuer = runtimeEntityType.AddIndex(
new[] { issuerId },
name: "idx_keys_issuer");
var idx_keys_status = runtimeEntityType.AddIndex(
new[] { status },
name: "idx_keys_status");
var idx_keys_tenant = runtimeEntityType.AddIndex(
new[] { tenantId },
name: "idx_keys_tenant");
var ix_issuer_keys_issuer_id_key_id = runtimeEntityType.AddIndex(
new[] { issuerId, keyId },
unique: true);
var ix_issuer_keys_fingerprint = runtimeEntityType.AddIndex(
new[] { fingerprint },
unique: true);
return runtimeEntityType;
}
public static void CreateAnnotations(RuntimeEntityType runtimeEntityType)
{
runtimeEntityType.AddAnnotation("Relational:FunctionName", null);
runtimeEntityType.AddAnnotation("Relational:Schema", "issuer");
runtimeEntityType.AddAnnotation("Relational:SqlQuery", null);
runtimeEntityType.AddAnnotation("Relational:TableName", "issuer_keys");
runtimeEntityType.AddAnnotation("Relational:ViewName", null);
runtimeEntityType.AddAnnotation("Relational:ViewSchema", null);
Customize(runtimeEntityType);
}
static partial void Customize(RuntimeEntityType runtimeEntityType);
}
}

View File

@@ -0,0 +1,155 @@
// <auto-generated />
using System;
using System.Reflection;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using StellaOps.IssuerDirectory.Persistence.EfCore.Models;
#pragma warning disable 219, 612, 618
#nullable disable
namespace StellaOps.IssuerDirectory.Persistence.EfCore.CompiledModels
{
[EntityFrameworkInternal]
public partial class TrustOverrideEntityType
{
public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null)
{
var runtimeEntityType = model.AddEntityType(
"StellaOps.IssuerDirectory.Persistence.EfCore.Models.TrustOverride",
typeof(TrustOverride),
baseEntityType,
propertyCount: 10,
namedIndexCount: 2,
keyCount: 1);
var id = runtimeEntityType.AddProperty(
"Id",
typeof(Guid),
propertyInfo: typeof(TrustOverride).GetProperty("Id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(TrustOverride).GetField("<Id>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd,
afterSaveBehavior: PropertySaveBehavior.Throw,
sentinel: new Guid("00000000-0000-0000-0000-000000000000"));
id.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
id.AddAnnotation("Relational:ColumnName", "id");
id.AddAnnotation("Relational:DefaultValueSql", "gen_random_uuid()");
var issuerId = runtimeEntityType.AddProperty(
"IssuerId",
typeof(Guid),
propertyInfo: typeof(TrustOverride).GetProperty("IssuerId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(TrustOverride).GetField("<IssuerId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
sentinel: new Guid("00000000-0000-0000-0000-000000000000"));
issuerId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
issuerId.AddAnnotation("Relational:ColumnName", "issuer_id");
var tenantId = runtimeEntityType.AddProperty(
"TenantId",
typeof(Guid),
propertyInfo: typeof(TrustOverride).GetProperty("TenantId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(TrustOverride).GetField("<TenantId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
sentinel: new Guid("00000000-0000-0000-0000-000000000000"));
tenantId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
tenantId.AddAnnotation("Relational:ColumnName", "tenant_id");
var weight = runtimeEntityType.AddProperty(
"Weight",
typeof(decimal),
propertyInfo: typeof(TrustOverride).GetProperty("Weight", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(TrustOverride).GetField("<Weight>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
sentinel: 0m);
weight.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
weight.AddAnnotation("Relational:ColumnName", "weight");
weight.AddAnnotation("Relational:ColumnType", "numeric(5,2)");
var rationale = runtimeEntityType.AddProperty(
"Rationale",
typeof(string),
propertyInfo: typeof(TrustOverride).GetProperty("Rationale", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(TrustOverride).GetField("<Rationale>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
rationale.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
rationale.AddAnnotation("Relational:ColumnName", "rationale");
var expiresAt = runtimeEntityType.AddProperty(
"ExpiresAt",
typeof(DateTime?),
propertyInfo: typeof(TrustOverride).GetProperty("ExpiresAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(TrustOverride).GetField("<ExpiresAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
expiresAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
expiresAt.AddAnnotation("Relational:ColumnName", "expires_at");
var createdAt = runtimeEntityType.AddProperty(
"CreatedAt",
typeof(DateTime),
propertyInfo: typeof(TrustOverride).GetProperty("CreatedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(TrustOverride).GetField("<CreatedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd,
sentinel: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
createdAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
createdAt.AddAnnotation("Relational:ColumnName", "created_at");
createdAt.AddAnnotation("Relational:DefaultValueSql", "now()");
var createdBy = runtimeEntityType.AddProperty(
"CreatedBy",
typeof(string),
propertyInfo: typeof(TrustOverride).GetProperty("CreatedBy", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(TrustOverride).GetField("<CreatedBy>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
createdBy.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
createdBy.AddAnnotation("Relational:ColumnName", "created_by");
var updatedAt = runtimeEntityType.AddProperty(
"UpdatedAt",
typeof(DateTime),
propertyInfo: typeof(TrustOverride).GetProperty("UpdatedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(TrustOverride).GetField("<UpdatedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd,
sentinel: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
updatedAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
updatedAt.AddAnnotation("Relational:ColumnName", "updated_at");
updatedAt.AddAnnotation("Relational:DefaultValueSql", "now()");
var updatedBy = runtimeEntityType.AddProperty(
"UpdatedBy",
typeof(string),
propertyInfo: typeof(TrustOverride).GetProperty("UpdatedBy", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(TrustOverride).GetField("<UpdatedBy>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
updatedBy.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
updatedBy.AddAnnotation("Relational:ColumnName", "updated_by");
var key = runtimeEntityType.AddKey(
new[] { id });
runtimeEntityType.SetPrimaryKey(key);
key.AddAnnotation("Relational:Name", "trust_overrides_pkey");
var idx_trust_tenant = runtimeEntityType.AddIndex(
new[] { tenantId },
name: "idx_trust_tenant");
var ix_trust_overrides_issuer_tenant = runtimeEntityType.AddIndex(
new[] { issuerId, tenantId },
unique: true);
return runtimeEntityType;
}
public static void CreateAnnotations(RuntimeEntityType runtimeEntityType)
{
runtimeEntityType.AddAnnotation("Relational:FunctionName", null);
runtimeEntityType.AddAnnotation("Relational:Schema", "issuer");
runtimeEntityType.AddAnnotation("Relational:SqlQuery", null);
runtimeEntityType.AddAnnotation("Relational:TableName", "trust_overrides");
runtimeEntityType.AddAnnotation("Relational:ViewName", null);
runtimeEntityType.AddAnnotation("Relational:ViewSchema", null);
Customize(runtimeEntityType);
}
static partial void Customize(RuntimeEntityType runtimeEntityType);
}
}

View File

@@ -0,0 +1,190 @@
using Microsoft.EntityFrameworkCore;
using StellaOps.IssuerDirectory.Persistence.EfCore.Models;
namespace StellaOps.IssuerDirectory.Persistence.EfCore.Context;
/// <summary>
/// EF Core DbContext for IssuerDirectory module.
/// Maps to the 'issuer' PostgreSQL schema.
/// </summary>
public partial class IssuerDirectoryDbContext : DbContext
{
private readonly string _schemaName;
public IssuerDirectoryDbContext(DbContextOptions<IssuerDirectoryDbContext> options, string? schemaName = null)
: base(options)
{
_schemaName = string.IsNullOrWhiteSpace(schemaName)
? "issuer"
: schemaName.Trim();
}
public virtual DbSet<Issuer> Issuers { get; set; }
public virtual DbSet<IssuerKey> IssuerKeys { get; set; }
public virtual DbSet<TrustOverride> TrustOverrides { get; set; }
public virtual DbSet<AuditEntry> AuditEntries { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
var schemaName = _schemaName;
modelBuilder.Entity<Issuer>(entity =>
{
entity.HasKey(e => e.Id).HasName("issuers_pkey");
entity.ToTable("issuers", schemaName);
entity.HasIndex(e => e.TenantId, "idx_issuers_tenant");
entity.HasIndex(e => e.Status, "idx_issuers_status");
entity.HasIndex(e => e.Name, "idx_issuers_slug");
entity.HasIndex(e => new { e.TenantId, e.Name }).IsUnique();
entity.Property(e => e.Id)
.HasDefaultValueSql("gen_random_uuid()")
.HasColumnName("id");
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
entity.Property(e => e.Name).HasColumnName("name");
entity.Property(e => e.DisplayName).HasColumnName("display_name");
entity.Property(e => e.Description).HasColumnName("description");
entity.Property(e => e.Endpoints)
.HasDefaultValueSql("'{}'::jsonb")
.HasColumnType("jsonb")
.HasColumnName("endpoints");
entity.Property(e => e.Contact)
.HasDefaultValueSql("'{}'::jsonb")
.HasColumnType("jsonb")
.HasColumnName("contact");
entity.Property(e => e.Metadata)
.HasDefaultValueSql("'{}'::jsonb")
.HasColumnType("jsonb")
.HasColumnName("metadata");
entity.Property(e => e.Tags)
.HasDefaultValueSql("'{}'")
.HasColumnName("tags");
entity.Property(e => e.Status)
.HasDefaultValueSql("'active'")
.HasColumnName("status");
entity.Property(e => e.IsSystemSeed)
.HasDefaultValue(false)
.HasColumnName("is_system_seed");
entity.Property(e => e.CreatedAt)
.HasDefaultValueSql("now()")
.HasColumnName("created_at");
entity.Property(e => e.CreatedBy).HasColumnName("created_by");
entity.Property(e => e.UpdatedAt)
.HasDefaultValueSql("now()")
.HasColumnName("updated_at");
entity.Property(e => e.UpdatedBy).HasColumnName("updated_by");
});
modelBuilder.Entity<IssuerKey>(entity =>
{
entity.HasKey(e => e.Id).HasName("issuer_keys_pkey");
entity.ToTable("issuer_keys", schemaName);
entity.HasIndex(e => e.IssuerId, "idx_keys_issuer");
entity.HasIndex(e => e.Status, "idx_keys_status");
entity.HasIndex(e => e.TenantId, "idx_keys_tenant");
entity.HasIndex(e => new { e.IssuerId, e.KeyId }).IsUnique();
entity.HasIndex(e => e.Fingerprint).IsUnique();
entity.Property(e => e.Id)
.HasDefaultValueSql("gen_random_uuid()")
.HasColumnName("id");
entity.Property(e => e.IssuerId).HasColumnName("issuer_id");
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
entity.Property(e => e.KeyId).HasColumnName("key_id");
entity.Property(e => e.KeyType).HasColumnName("key_type");
entity.Property(e => e.PublicKey).HasColumnName("public_key");
entity.Property(e => e.Fingerprint).HasColumnName("fingerprint");
entity.Property(e => e.NotBefore).HasColumnName("not_before");
entity.Property(e => e.NotAfter).HasColumnName("not_after");
entity.Property(e => e.Status)
.HasDefaultValueSql("'active'")
.HasColumnName("status");
entity.Property(e => e.ReplacesKeyId).HasColumnName("replaces_key_id");
entity.Property(e => e.CreatedAt)
.HasDefaultValueSql("now()")
.HasColumnName("created_at");
entity.Property(e => e.CreatedBy).HasColumnName("created_by");
entity.Property(e => e.UpdatedAt)
.HasDefaultValueSql("now()")
.HasColumnName("updated_at");
entity.Property(e => e.UpdatedBy).HasColumnName("updated_by");
entity.Property(e => e.RetiredAt).HasColumnName("retired_at");
entity.Property(e => e.RevokedAt).HasColumnName("revoked_at");
entity.Property(e => e.RevokeReason).HasColumnName("revoke_reason");
entity.Property(e => e.Metadata)
.HasDefaultValueSql("'{}'::jsonb")
.HasColumnType("jsonb")
.HasColumnName("metadata");
});
modelBuilder.Entity<TrustOverride>(entity =>
{
entity.HasKey(e => e.Id).HasName("trust_overrides_pkey");
entity.ToTable("trust_overrides", schemaName);
entity.HasIndex(e => e.TenantId, "idx_trust_tenant");
entity.HasIndex(e => new { e.IssuerId, e.TenantId }).IsUnique();
entity.Property(e => e.Id)
.HasDefaultValueSql("gen_random_uuid()")
.HasColumnName("id");
entity.Property(e => e.IssuerId).HasColumnName("issuer_id");
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
entity.Property(e => e.Weight)
.HasColumnType("numeric(5,2)")
.HasColumnName("weight");
entity.Property(e => e.Rationale).HasColumnName("rationale");
entity.Property(e => e.ExpiresAt).HasColumnName("expires_at");
entity.Property(e => e.CreatedAt)
.HasDefaultValueSql("now()")
.HasColumnName("created_at");
entity.Property(e => e.CreatedBy).HasColumnName("created_by");
entity.Property(e => e.UpdatedAt)
.HasDefaultValueSql("now()")
.HasColumnName("updated_at");
entity.Property(e => e.UpdatedBy).HasColumnName("updated_by");
});
modelBuilder.Entity<AuditEntry>(entity =>
{
entity.HasKey(e => e.Id).HasName("audit_pkey");
entity.ToTable("audit", schemaName);
entity.HasIndex(e => new { e.TenantId, e.OccurredAt }, "idx_audit_tenant_time")
.IsDescending(false, true);
entity.HasIndex(e => e.IssuerId, "idx_audit_issuer");
entity.Property(e => e.Id)
.UseIdentityByDefaultColumn()
.HasColumnName("id");
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
entity.Property(e => e.Actor).HasColumnName("actor");
entity.Property(e => e.Action).HasColumnName("action");
entity.Property(e => e.IssuerId).HasColumnName("issuer_id");
entity.Property(e => e.KeyId).HasColumnName("key_id");
entity.Property(e => e.TrustOverrideId).HasColumnName("trust_override_id");
entity.Property(e => e.Reason).HasColumnName("reason");
entity.Property(e => e.Details)
.HasDefaultValueSql("'{}'::jsonb")
.HasColumnType("jsonb")
.HasColumnName("details");
entity.Property(e => e.CorrelationId).HasColumnName("correlation_id");
entity.Property(e => e.OccurredAt)
.HasDefaultValueSql("now()")
.HasColumnName("occurred_at");
});
OnModelCreatingPartial(modelBuilder);
}
partial void OnModelCreatingPartial(ModelBuilder modelBuilder);
}

View File

@@ -0,0 +1,30 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace StellaOps.IssuerDirectory.Persistence.EfCore.Context;
/// <summary>
/// Design-time factory for dotnet ef CLI tooling.
/// </summary>
public sealed class IssuerDirectoryDesignTimeDbContextFactory : IDesignTimeDbContextFactory<IssuerDirectoryDbContext>
{
private const string DefaultConnectionString =
"Host=localhost;Port=55433;Database=postgres;Username=postgres;Password=postgres;Search Path=issuer,public";
private const string ConnectionStringEnvironmentVariable = "STELLAOPS_ISSUERDIRECTORY_EF_CONNECTION";
public IssuerDirectoryDbContext CreateDbContext(string[] args)
{
var connectionString = ResolveConnectionString();
var options = new DbContextOptionsBuilder<IssuerDirectoryDbContext>()
.UseNpgsql(connectionString)
.Options;
return new IssuerDirectoryDbContext(options);
}
private static string ResolveConnectionString()
{
var fromEnvironment = Environment.GetEnvironmentVariable(ConnectionStringEnvironmentVariable);
return string.IsNullOrWhiteSpace(fromEnvironment) ? DefaultConnectionString : fromEnvironment;
}
}

View File

@@ -0,0 +1,31 @@
using System;
namespace StellaOps.IssuerDirectory.Persistence.EfCore.Models;
/// <summary>
/// EF Core entity for the issuer.audit table.
/// </summary>
public partial class AuditEntry
{
public long Id { get; set; }
public Guid TenantId { get; set; }
public string? Actor { get; set; }
public string Action { get; set; } = null!;
public Guid? IssuerId { get; set; }
public string? KeyId { get; set; }
public Guid? TrustOverrideId { get; set; }
public string? Reason { get; set; }
public string Details { get; set; } = null!;
public string? CorrelationId { get; set; }
public DateTime OccurredAt { get; set; }
}

View File

@@ -0,0 +1,39 @@
using System;
namespace StellaOps.IssuerDirectory.Persistence.EfCore.Models;
/// <summary>
/// EF Core entity for the issuer.issuers table.
/// </summary>
public partial class Issuer
{
public Guid Id { get; set; }
public Guid TenantId { get; set; }
public string Name { get; set; } = null!;
public string DisplayName { get; set; } = null!;
public string? Description { get; set; }
public string Endpoints { get; set; } = null!;
public string Contact { get; set; } = null!;
public string Metadata { get; set; } = null!;
public string[] Tags { get; set; } = Array.Empty<string>();
public string Status { get; set; } = null!;
public bool IsSystemSeed { get; set; }
public DateTime CreatedAt { get; set; }
public string? CreatedBy { get; set; }
public DateTime UpdatedAt { get; set; }
public string? UpdatedBy { get; set; }
}

View File

@@ -0,0 +1,47 @@
using System;
namespace StellaOps.IssuerDirectory.Persistence.EfCore.Models;
/// <summary>
/// EF Core entity for the issuer.issuer_keys table.
/// </summary>
public partial class IssuerKey
{
public Guid Id { get; set; }
public Guid IssuerId { get; set; }
public Guid TenantId { get; set; }
public string KeyId { get; set; } = null!;
public string KeyType { get; set; } = null!;
public string PublicKey { get; set; } = null!;
public string Fingerprint { get; set; } = null!;
public DateTime? NotBefore { get; set; }
public DateTime? NotAfter { get; set; }
public string Status { get; set; } = null!;
public string? ReplacesKeyId { get; set; }
public DateTime CreatedAt { get; set; }
public string? CreatedBy { get; set; }
public DateTime UpdatedAt { get; set; }
public string? UpdatedBy { get; set; }
public DateTime? RetiredAt { get; set; }
public DateTime? RevokedAt { get; set; }
public string? RevokeReason { get; set; }
public string Metadata { get; set; } = null!;
}

View File

@@ -0,0 +1,29 @@
using System;
namespace StellaOps.IssuerDirectory.Persistence.EfCore.Models;
/// <summary>
/// EF Core entity for the issuer.trust_overrides table.
/// </summary>
public partial class TrustOverride
{
public Guid Id { get; set; }
public Guid IssuerId { get; set; }
public Guid TenantId { get; set; }
public decimal Weight { get; set; }
public string? Rationale { get; set; }
public DateTime? ExpiresAt { get; set; }
public DateTime CreatedAt { get; set; }
public string? CreatedBy { get; set; }
public DateTime UpdatedAt { get; set; }
public string? UpdatedBy { get; set; }
}

View File

@@ -0,0 +1,62 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Infrastructure.Postgres.Options;
using StellaOps.IssuerDirectory.Core.Abstractions;
using StellaOps.IssuerDirectory.Persistence.Postgres;
using StellaOps.IssuerDirectory.Persistence.Postgres.Repositories;
namespace StellaOps.IssuerDirectory.Persistence.Extensions;
/// <summary>
/// Extension methods for registering IssuerDirectory persistence services.
/// </summary>
public static class IssuerDirectoryPersistenceExtensions
{
/// <summary>
/// Registers IssuerDirectory PostgreSQL persistence from configuration section.
/// </summary>
/// <param name="services">Service collection.</param>
/// <param name="configuration">Application configuration.</param>
/// <param name="sectionName">Configuration section name. Defaults to "Postgres:IssuerDirectory".</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddIssuerDirectoryPersistence(
this IServiceCollection services,
IConfiguration configuration,
string sectionName = "Postgres:IssuerDirectory")
{
services.Configure<PostgresOptions>(configuration.GetSection(sectionName));
services.AddSingleton<IssuerDirectoryDataSource>();
RegisterRepositories(services);
return services;
}
/// <summary>
/// Registers IssuerDirectory PostgreSQL persistence with an options delegate.
/// </summary>
/// <param name="services">Service collection.</param>
/// <param name="configureOptions">Options configuration delegate.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddIssuerDirectoryPersistence(
this IServiceCollection services,
Action<PostgresOptions> configureOptions)
{
ArgumentNullException.ThrowIfNull(configureOptions);
services.Configure(configureOptions);
services.AddSingleton<IssuerDirectoryDataSource>();
RegisterRepositories(services);
return services;
}
private static void RegisterRepositories(IServiceCollection services)
{
services.AddScoped<IIssuerRepository, PostgresIssuerRepository>();
services.AddScoped<IIssuerKeyRepository, PostgresIssuerKeyRepository>();
services.AddScoped<IIssuerTrustRepository, PostgresIssuerTrustRepository>();
services.AddScoped<IIssuerAuditSink, PostgresIssuerAuditSink>();
}
}

View File

@@ -0,0 +1,129 @@
-- Migration: 001_initial_schema
-- Category: startup
-- Description: Initial schema for IssuerDirectory PostgreSQL storage
-- Source: docs/db/schemas/issuer.sql
CREATE SCHEMA IF NOT EXISTS issuer;
-- Issuers (tenant or global)
CREATE TABLE IF NOT EXISTS issuer.issuers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL, -- use @global GUID for seed publishers
name TEXT NOT NULL, -- logical issuer name (slug)
display_name TEXT NOT NULL,
description TEXT,
endpoints JSONB DEFAULT '{}'::jsonb, -- CSAF feeds, OIDC issuer URLs, contact links
contact JSONB DEFAULT '{}'::jsonb, -- Contact information
metadata JSONB DEFAULT '{}'::jsonb, -- Domain metadata (CVE org ID, CSAF publisher ID, etc.)
tags TEXT[] DEFAULT '{}',
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','revoked','deprecated')),
is_system_seed BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_by TEXT,
UNIQUE (tenant_id, name)
);
-- Keys
CREATE TABLE IF NOT EXISTS issuer.issuer_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
issuer_id UUID NOT NULL REFERENCES issuer.issuers(id) ON DELETE CASCADE,
tenant_id UUID NOT NULL,
key_id TEXT NOT NULL, -- stable key identifier
key_type TEXT NOT NULL CHECK (key_type IN ('ed25519','x509','dsse','kms','hsm','fido2')),
public_key TEXT NOT NULL, -- PEM / base64
fingerprint TEXT NOT NULL, -- canonical fingerprint for dedupe
not_before TIMESTAMPTZ,
not_after TIMESTAMPTZ,
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','retired','revoked')),
replaces_key_id TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_by TEXT,
retired_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ,
revoke_reason TEXT,
metadata JSONB DEFAULT '{}'::jsonb,
UNIQUE (issuer_id, key_id),
UNIQUE (fingerprint)
);
-- Trust overrides (tenant-scoped weights)
CREATE TABLE IF NOT EXISTS issuer.trust_overrides (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
issuer_id UUID NOT NULL REFERENCES issuer.issuers(id) ON DELETE CASCADE,
tenant_id UUID NOT NULL, -- consumer tenant applying the override
weight NUMERIC(5,2) NOT NULL CHECK (weight >= -10 AND weight <= 10),
rationale TEXT,
expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_by TEXT,
UNIQUE (issuer_id, tenant_id)
);
-- Audit log (issuer-domain specific)
CREATE TABLE IF NOT EXISTS issuer.audit (
id BIGSERIAL PRIMARY KEY,
tenant_id UUID NOT NULL,
actor TEXT,
action TEXT NOT NULL, -- create_issuer, update_issuer, delete_issuer, add_key, rotate_key, revoke_key, set_trust, delete_trust, seed_csaf
issuer_id UUID,
key_id TEXT,
trust_override_id UUID,
reason TEXT,
details JSONB DEFAULT '{}'::jsonb,
correlation_id TEXT,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Schema migrations tracking
CREATE TABLE IF NOT EXISTS issuer.schema_migrations (
migration_name TEXT PRIMARY KEY,
category TEXT NOT NULL DEFAULT 'startup',
checksum TEXT NOT NULL,
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
applied_by TEXT,
duration_ms INT,
CONSTRAINT valid_category CHECK (category IN ('startup', 'release', 'seed', 'data'))
);
-- Indexes
CREATE INDEX IF NOT EXISTS idx_issuers_tenant ON issuer.issuers(tenant_id);
CREATE INDEX IF NOT EXISTS idx_issuers_status ON issuer.issuers(status);
CREATE INDEX IF NOT EXISTS idx_issuers_slug ON issuer.issuers(name);
CREATE INDEX IF NOT EXISTS idx_keys_issuer ON issuer.issuer_keys(issuer_id);
CREATE INDEX IF NOT EXISTS idx_keys_status ON issuer.issuer_keys(status);
CREATE INDEX IF NOT EXISTS idx_keys_tenant ON issuer.issuer_keys(tenant_id);
CREATE INDEX IF NOT EXISTS idx_trust_tenant ON issuer.trust_overrides(tenant_id);
CREATE INDEX IF NOT EXISTS idx_audit_tenant_time ON issuer.audit(tenant_id, occurred_at DESC);
CREATE INDEX IF NOT EXISTS idx_audit_issuer ON issuer.audit(issuer_id);
CREATE INDEX IF NOT EXISTS idx_schema_migrations_applied_at ON issuer.schema_migrations(applied_at DESC);
-- Updated-at trigger for issuers/trust overrides
CREATE OR REPLACE FUNCTION issuer.update_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_issuers_updated_at ON issuer.issuers;
CREATE TRIGGER trg_issuers_updated_at
BEFORE UPDATE ON issuer.issuers
FOR EACH ROW EXECUTE FUNCTION issuer.update_updated_at();
DROP TRIGGER IF EXISTS trg_keys_updated_at ON issuer.issuer_keys;
CREATE TRIGGER trg_keys_updated_at
BEFORE UPDATE ON issuer.issuer_keys
FOR EACH ROW EXECUTE FUNCTION issuer.update_updated_at();
DROP TRIGGER IF EXISTS trg_trust_updated_at ON issuer.trust_overrides;
CREATE TRIGGER trg_trust_updated_at
BEFORE UPDATE ON issuer.trust_overrides
FOR EACH ROW EXECUTE FUNCTION issuer.update_updated_at();

View File

@@ -0,0 +1,45 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Connections;
using StellaOps.Infrastructure.Postgres.Options;
namespace StellaOps.IssuerDirectory.Persistence.Postgres;
/// <summary>
/// PostgreSQL data source for the IssuerDirectory module.
/// Manages connection pooling, tenant context, and session configuration.
/// </summary>
public sealed class IssuerDirectoryDataSource : DataSourceBase
{
/// <summary>
/// Default schema name for IssuerDirectory tables.
/// </summary>
public const string DefaultSchemaName = "issuer";
/// <summary>
/// Creates a new IssuerDirectory data source.
/// </summary>
public IssuerDirectoryDataSource(IOptions<PostgresOptions> options, ILogger<IssuerDirectoryDataSource> logger)
: base(CreateOptions(options.Value), logger)
{
}
/// <inheritdoc />
protected override string ModuleName => "IssuerDirectory";
/// <inheritdoc />
protected override void ConfigureDataSourceBuilder(NpgsqlDataSourceBuilder builder)
{
base.ConfigureDataSourceBuilder(builder);
}
private static PostgresOptions CreateOptions(PostgresOptions baseOptions)
{
if (string.IsNullOrWhiteSpace(baseOptions.SchemaName))
{
baseOptions.SchemaName = DefaultSchemaName;
}
return baseOptions;
}
}

View File

@@ -0,0 +1,32 @@
using System;
using Microsoft.EntityFrameworkCore;
using Npgsql;
using StellaOps.IssuerDirectory.Persistence.EfCore.CompiledModels;
using StellaOps.IssuerDirectory.Persistence.EfCore.Context;
namespace StellaOps.IssuerDirectory.Persistence.Postgres;
/// <summary>
/// Runtime factory for creating IssuerDirectoryDbContext instances.
/// Uses the compiled model for the default schema path.
/// </summary>
internal static class IssuerDirectoryDbContextFactory
{
public static IssuerDirectoryDbContext Create(NpgsqlConnection connection, int commandTimeoutSeconds, string schemaName)
{
var normalizedSchema = string.IsNullOrWhiteSpace(schemaName)
? IssuerDirectoryDataSource.DefaultSchemaName
: schemaName.Trim();
var optionsBuilder = new DbContextOptionsBuilder<IssuerDirectoryDbContext>()
.UseNpgsql(connection, npgsql => npgsql.CommandTimeout(commandTimeoutSeconds));
if (string.Equals(normalizedSchema, IssuerDirectoryDataSource.DefaultSchemaName, StringComparison.Ordinal))
{
// Use the static compiled model when schema mapping matches the default model.
optionsBuilder.UseModel(IssuerDirectoryDbContextModel.Instance);
}
return new IssuerDirectoryDbContext(optionsBuilder.Options, normalizedSchema);
}
}

View File

@@ -0,0 +1,74 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.IssuerDirectory.Core.Abstractions;
using StellaOps.IssuerDirectory.Core.Domain;
using StellaOps.IssuerDirectory.Persistence.EfCore.Models;
using System.Text.Json;
namespace StellaOps.IssuerDirectory.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL implementation of the issuer audit sink backed by EF Core.
/// </summary>
public sealed class PostgresIssuerAuditSink : IIssuerAuditSink
{
private readonly IssuerDirectoryDataSource _dataSource;
private readonly ILogger<PostgresIssuerAuditSink> _logger;
private static readonly JsonSerializerOptions _jsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
};
public PostgresIssuerAuditSink(IssuerDirectoryDataSource dataSource, ILogger<PostgresIssuerAuditSink> logger)
{
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task WriteAsync(IssuerAuditEntry entry, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(entry);
await using var connection = await _dataSource.OpenConnectionAsync(entry.TenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var dbContext = IssuerDirectoryDbContextFactory.Create(
connection, _dataSource.CommandTimeoutSeconds, GetSchemaName());
var entity = new AuditEntry
{
TenantId = Guid.Parse(entry.TenantId),
Actor = entry.Actor,
Action = entry.Action,
IssuerId = Guid.Parse(entry.IssuerId),
Reason = entry.Reason,
Details = SerializeMetadata(entry.Metadata),
OccurredAt = entry.TimestampUtc.UtcDateTime
};
dbContext.AuditEntries.Add(entity);
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
_logger.LogDebug("Wrote audit entry: {Action} for issuer {IssuerId} by {Actor}.", entry.Action, entry.IssuerId, entry.Actor);
}
private string GetSchemaName()
{
if (!string.IsNullOrWhiteSpace(_dataSource.SchemaName))
{
return _dataSource.SchemaName!;
}
return IssuerDirectoryDataSource.DefaultSchemaName;
}
private static string SerializeMetadata(IReadOnlyDictionary<string, string> metadata)
{
if (metadata.Count == 0)
{
return "{}";
}
return JsonSerializer.Serialize(metadata, _jsonOptions);
}
}

View File

@@ -0,0 +1,57 @@
using Microsoft.EntityFrameworkCore;
using StellaOps.IssuerDirectory.Core.Domain;
namespace StellaOps.IssuerDirectory.Persistence.Postgres.Repositories;
public sealed partial class PostgresIssuerKeyRepository
{
public async Task<IssuerKeyRecord?> GetAsync(
string tenantId,
string issuerId,
string keyId,
CancellationToken cancellationToken)
{
await using var connection = await _dataSource
.OpenConnectionAsync(tenantId, "reader", cancellationToken)
.ConfigureAwait(false);
await using var dbContext = IssuerDirectoryDbContextFactory.Create(
connection, _dataSource.CommandTimeoutSeconds, GetSchemaName());
var tenantGuid = Guid.Parse(tenantId);
var issuerGuid = Guid.Parse(issuerId);
var entity = await dbContext.IssuerKeys
.AsNoTracking()
.FirstOrDefaultAsync(
e => e.TenantId == tenantGuid && e.IssuerId == issuerGuid && e.KeyId == keyId,
cancellationToken)
.ConfigureAwait(false);
return entity is null ? null : MapToRecord(entity);
}
public async Task<IssuerKeyRecord?> GetByFingerprintAsync(
string tenantId,
string issuerId,
string fingerprint,
CancellationToken cancellationToken)
{
await using var connection = await _dataSource
.OpenConnectionAsync(tenantId, "reader", cancellationToken)
.ConfigureAwait(false);
await using var dbContext = IssuerDirectoryDbContextFactory.Create(
connection, _dataSource.CommandTimeoutSeconds, GetSchemaName());
var tenantGuid = Guid.Parse(tenantId);
var issuerGuid = Guid.Parse(issuerId);
var entity = await dbContext.IssuerKeys
.AsNoTracking()
.FirstOrDefaultAsync(
e => e.TenantId == tenantGuid && e.IssuerId == issuerGuid && e.Fingerprint == fingerprint,
cancellationToken)
.ConfigureAwait(false);
return entity is null ? null : MapToRecord(entity);
}
}

View File

@@ -0,0 +1,109 @@
using Microsoft.EntityFrameworkCore;
using Npgsql;
using StellaOps.IssuerDirectory.Core.Domain;
namespace StellaOps.IssuerDirectory.Persistence.Postgres.Repositories;
public sealed partial class PostgresIssuerKeyRepository
{
public async Task<IReadOnlyCollection<IssuerKeyRecord>> ListAsync(
string tenantId,
string issuerId,
CancellationToken cancellationToken)
{
await using var connection = await _dataSource
.OpenConnectionAsync(tenantId, "reader", cancellationToken)
.ConfigureAwait(false);
await using var dbContext = IssuerDirectoryDbContextFactory.Create(
connection, _dataSource.CommandTimeoutSeconds, GetSchemaName());
var tenantGuid = Guid.Parse(tenantId);
var issuerGuid = Guid.Parse(issuerId);
var entities = await dbContext.IssuerKeys
.AsNoTracking()
.Where(e => e.TenantId == tenantGuid && e.IssuerId == issuerGuid)
.OrderBy(e => e.CreatedAt)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return entities.Select(MapToRecord).ToList();
}
public async Task<IReadOnlyCollection<IssuerKeyRecord>> ListGlobalAsync(
string issuerId,
CancellationToken cancellationToken)
{
// Preserve raw SQL for global tenant queries (same reasoning as PostgresIssuerRepository.ListGlobalAsync).
await using var connection = await _dataSource
.OpenSystemConnectionAsync(cancellationToken)
.ConfigureAwait(false);
var schemaName = GetSchemaName();
var results = new List<IssuerKeyRecord>();
const string sql = """
SELECT id, issuer_id, tenant_id, key_id, key_type, public_key, fingerprint, not_before, not_after, status,
replaces_key_id, created_at, created_by, updated_at, updated_by, retired_at, revoked_at,
revoke_reason, metadata
FROM issuer.issuer_keys
WHERE tenant_id = @globalTenantId::uuid AND issuer_id = @issuerId::uuid
ORDER BY created_at ASC
""";
await using var command = new NpgsqlCommand(sql.Replace("issuer.", schemaName + "."), connection);
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
command.Parameters.AddWithValue("globalTenantId", IssuerTenants.Global);
command.Parameters.AddWithValue("issuerId", issuerId);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
results.Add(MapToRecordFromReader(reader));
}
return results;
}
/// <summary>
/// Maps from NpgsqlDataReader for legacy global queries that use raw SQL.
/// </summary>
private static IssuerKeyRecord MapToRecordFromReader(NpgsqlDataReader reader)
{
var issuerId = reader.GetGuid(1).ToString();
var tenantId = reader.GetGuid(2).ToString();
var keyId = reader.GetString(3);
var keyType = ParseKeyType(reader.GetString(4));
var publicKey = reader.GetString(5);
var fingerprint = reader.GetString(6);
var notBefore = reader.IsDBNull(7) ? (DateTimeOffset?)null : new DateTimeOffset(reader.GetDateTime(7), TimeSpan.Zero);
var notAfter = reader.IsDBNull(8) ? (DateTimeOffset?)null : new DateTimeOffset(reader.GetDateTime(8), TimeSpan.Zero);
var status = ParseKeyStatus(reader.GetString(9));
var replacesKeyId = reader.IsDBNull(10) ? null : reader.GetString(10);
var createdAt = reader.GetDateTime(11);
var createdBy = reader.GetString(12);
var updatedAt = reader.GetDateTime(13);
var updatedBy = reader.GetString(14);
var retiredAt = reader.IsDBNull(15) ? (DateTimeOffset?)null : new DateTimeOffset(reader.GetDateTime(15), TimeSpan.Zero);
var revokedAt = reader.IsDBNull(16) ? (DateTimeOffset?)null : new DateTimeOffset(reader.GetDateTime(16), TimeSpan.Zero);
return new IssuerKeyRecord
{
Id = keyId,
IssuerId = issuerId,
TenantId = tenantId,
Type = keyType,
Status = status,
Material = new IssuerKeyMaterial("pem", publicKey),
Fingerprint = fingerprint,
CreatedAtUtc = new DateTimeOffset(createdAt, TimeSpan.Zero),
CreatedBy = createdBy,
UpdatedAtUtc = new DateTimeOffset(updatedAt, TimeSpan.Zero),
UpdatedBy = updatedBy,
ExpiresAtUtc = notAfter,
RetiredAtUtc = retiredAt,
RevokedAtUtc = revokedAt,
ReplacesKeyId = replacesKeyId
};
}
}

View File

@@ -0,0 +1,53 @@
using StellaOps.IssuerDirectory.Core.Domain;
using EfIssuerKey = StellaOps.IssuerDirectory.Persistence.EfCore.Models.IssuerKey;
namespace StellaOps.IssuerDirectory.Persistence.Postgres.Repositories;
public sealed partial class PostgresIssuerKeyRepository
{
private static IssuerKeyRecord MapToRecord(EfIssuerKey entity)
{
return new IssuerKeyRecord
{
Id = entity.KeyId,
IssuerId = entity.IssuerId.ToString(),
TenantId = entity.TenantId.ToString(),
Type = ParseKeyType(entity.KeyType),
Status = ParseKeyStatus(entity.Status),
Material = new IssuerKeyMaterial("pem", entity.PublicKey),
Fingerprint = entity.Fingerprint,
CreatedAtUtc = new DateTimeOffset(entity.CreatedAt, TimeSpan.Zero),
CreatedBy = entity.CreatedBy ?? string.Empty,
UpdatedAtUtc = new DateTimeOffset(entity.UpdatedAt, TimeSpan.Zero),
UpdatedBy = entity.UpdatedBy ?? string.Empty,
ExpiresAtUtc = entity.NotAfter.HasValue ? new DateTimeOffset(entity.NotAfter.Value, TimeSpan.Zero) : null,
RetiredAtUtc = entity.RetiredAt.HasValue ? new DateTimeOffset(entity.RetiredAt.Value, TimeSpan.Zero) : null,
RevokedAtUtc = entity.RevokedAt.HasValue ? new DateTimeOffset(entity.RevokedAt.Value, TimeSpan.Zero) : null,
ReplacesKeyId = entity.ReplacesKeyId
};
}
private static string MapKeyType(IssuerKeyType type) => type switch
{
IssuerKeyType.Ed25519PublicKey => "ed25519",
IssuerKeyType.X509Certificate => "x509",
IssuerKeyType.DssePublicKey => "dsse",
_ => throw new ArgumentOutOfRangeException(nameof(type), type, "Unsupported key type")
};
private static IssuerKeyType ParseKeyType(string value) => value.ToLowerInvariant() switch
{
"ed25519" => IssuerKeyType.Ed25519PublicKey,
"x509" => IssuerKeyType.X509Certificate,
"dsse" => IssuerKeyType.DssePublicKey,
_ => throw new ArgumentException($"Unknown key type: {value}", nameof(value))
};
private static IssuerKeyStatus ParseKeyStatus(string value) => value.ToLowerInvariant() switch
{
"active" => IssuerKeyStatus.Active,
"retired" => IssuerKeyStatus.Retired,
"revoked" => IssuerKeyStatus.Revoked,
_ => throw new ArgumentException($"Unknown key status: {value}", nameof(value))
};
}

View File

@@ -0,0 +1,123 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.IssuerDirectory.Core.Domain;
using StellaOps.IssuerDirectory.Persistence.EfCore.Models;
namespace StellaOps.IssuerDirectory.Persistence.Postgres.Repositories;
public sealed partial class PostgresIssuerKeyRepository
{
public async Task UpsertAsync(IssuerKeyRecord record, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(record);
await using var connection = await _dataSource
.OpenConnectionAsync(record.TenantId, "writer", cancellationToken)
.ConfigureAwait(false);
await using var dbContext = IssuerDirectoryDbContextFactory.Create(
connection, _dataSource.CommandTimeoutSeconds, GetSchemaName());
var issuerGuid = Guid.Parse(record.IssuerId);
var tenantGuid = Guid.Parse(record.TenantId);
var existing = await dbContext.IssuerKeys
.FirstOrDefaultAsync(
e => e.IssuerId == issuerGuid && e.KeyId == record.Id,
cancellationToken)
.ConfigureAwait(false);
if (existing is null)
{
var entity = new IssuerKey
{
Id = Guid.Parse(record.Id),
IssuerId = issuerGuid,
TenantId = tenantGuid,
KeyId = record.Id,
KeyType = MapKeyType(record.Type),
PublicKey = record.Material.Value,
Fingerprint = record.Fingerprint,
NotBefore = null,
NotAfter = record.ExpiresAtUtc.HasValue ? record.ExpiresAtUtc.Value.UtcDateTime : null,
Status = record.Status.ToString().ToLowerInvariant(),
ReplacesKeyId = record.ReplacesKeyId,
CreatedAt = record.CreatedAtUtc.UtcDateTime,
CreatedBy = record.CreatedBy,
UpdatedAt = record.UpdatedAtUtc.UtcDateTime,
UpdatedBy = record.UpdatedBy,
RetiredAt = record.RetiredAtUtc.HasValue ? record.RetiredAtUtc.Value.UtcDateTime : null,
RevokedAt = record.RevokedAtUtc.HasValue ? record.RevokedAtUtc.Value.UtcDateTime : null,
RevokeReason = null,
Metadata = "{}"
};
dbContext.IssuerKeys.Add(entity);
}
else
{
existing.KeyType = MapKeyType(record.Type);
existing.PublicKey = record.Material.Value;
existing.Fingerprint = record.Fingerprint;
existing.NotAfter = record.ExpiresAtUtc.HasValue ? record.ExpiresAtUtc.Value.UtcDateTime : null;
existing.Status = record.Status.ToString().ToLowerInvariant();
existing.ReplacesKeyId = record.ReplacesKeyId;
existing.UpdatedAt = record.UpdatedAtUtc.UtcDateTime;
existing.UpdatedBy = record.UpdatedBy;
existing.RetiredAt = record.RetiredAtUtc.HasValue ? record.RetiredAtUtc.Value.UtcDateTime : null;
existing.RevokedAt = record.RevokedAtUtc.HasValue ? record.RevokedAtUtc.Value.UtcDateTime : null;
existing.Metadata = "{}";
}
try
{
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
catch (DbUpdateException ex) when (IsUniqueViolation(ex))
{
dbContext.ChangeTracker.Clear();
var conflict = await dbContext.IssuerKeys
.FirstOrDefaultAsync(
e => e.IssuerId == issuerGuid && e.KeyId == record.Id,
cancellationToken)
.ConfigureAwait(false);
if (conflict is null)
{
throw;
}
conflict.KeyType = MapKeyType(record.Type);
conflict.PublicKey = record.Material.Value;
conflict.Fingerprint = record.Fingerprint;
conflict.NotAfter = record.ExpiresAtUtc.HasValue ? record.ExpiresAtUtc.Value.UtcDateTime : null;
conflict.Status = record.Status.ToString().ToLowerInvariant();
conflict.ReplacesKeyId = record.ReplacesKeyId;
conflict.UpdatedAt = record.UpdatedAtUtc.UtcDateTime;
conflict.UpdatedBy = record.UpdatedBy;
conflict.RetiredAt = record.RetiredAtUtc.HasValue ? record.RetiredAtUtc.Value.UtcDateTime : null;
conflict.RevokedAt = record.RevokedAtUtc.HasValue ? record.RevokedAtUtc.Value.UtcDateTime : null;
conflict.Metadata = "{}";
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
_logger.LogDebug("Upserted issuer key {KeyId} for issuer {IssuerId}.", record.Id, record.IssuerId);
}
private static bool IsUniqueViolation(DbUpdateException exception)
{
Exception? current = exception;
while (current is not null)
{
if (current is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation })
{
return true;
}
current = current.InnerException;
}
return false;
}
}

View File

@@ -0,0 +1,32 @@
using Microsoft.Extensions.Logging;
using StellaOps.IssuerDirectory.Core.Abstractions;
using StellaOps.IssuerDirectory.Persistence.Postgres;
namespace StellaOps.IssuerDirectory.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL implementation of the issuer key repository backed by EF Core.
/// </summary>
public sealed partial class PostgresIssuerKeyRepository : IIssuerKeyRepository
{
private readonly IssuerDirectoryDataSource _dataSource;
private readonly ILogger<PostgresIssuerKeyRepository> _logger;
public PostgresIssuerKeyRepository(
IssuerDirectoryDataSource dataSource,
ILogger<PostgresIssuerKeyRepository> logger)
{
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
private string GetSchemaName()
{
if (!string.IsNullOrWhiteSpace(_dataSource.SchemaName))
{
return _dataSource.SchemaName!;
}
return IssuerDirectoryDataSource.DefaultSchemaName;
}
}

View File

@@ -0,0 +1,40 @@
using System.Text.Json;
using StellaOps.IssuerDirectory.Core.Domain;
namespace StellaOps.IssuerDirectory.Persistence.Postgres.Repositories;
public sealed partial class PostgresIssuerRepository
{
private static string SerializeContact(IssuerContact contact)
{
var doc = new
{
email = contact.Email,
phone = contact.Phone,
website = contact.Website?.ToString(),
timezone = contact.Timezone
};
return JsonSerializer.Serialize(doc, _jsonOptions);
}
private static IssuerContact DeserializeContact(string json)
{
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
var email = root.TryGetProperty("email", out var e) && e.ValueKind != JsonValueKind.Null ? e.GetString() : null;
var phone = root.TryGetProperty("phone", out var p) && p.ValueKind != JsonValueKind.Null ? p.GetString() : null;
var websiteStr = root.TryGetProperty("website", out var w) && w.ValueKind != JsonValueKind.Null
? w.GetString()
: null;
var timezone = root.TryGetProperty("timezone", out var t) && t.ValueKind != JsonValueKind.Null
? t.GetString()
: null;
return new IssuerContact(
email,
phone,
string.IsNullOrWhiteSpace(websiteStr) ? null : new Uri(websiteStr),
timezone);
}
}

View File

@@ -0,0 +1,47 @@
using System.Text.Json;
using StellaOps.IssuerDirectory.Core.Domain;
namespace StellaOps.IssuerDirectory.Persistence.Postgres.Repositories;
public sealed partial class PostgresIssuerRepository
{
private static string SerializeEndpoints(IReadOnlyCollection<IssuerEndpoint> endpoints)
{
var docs = endpoints.Select(endpoint => new
{
kind = endpoint.Kind,
url = endpoint.Url.ToString(),
format = endpoint.Format,
requiresAuthentication = endpoint.RequiresAuthentication
}).ToList();
return JsonSerializer.Serialize(docs, _jsonOptions);
}
private static IReadOnlyCollection<IssuerEndpoint> DeserializeEndpoints(string json)
{
var results = new List<IssuerEndpoint>();
using var doc = JsonDocument.Parse(json);
if (doc.RootElement.ValueKind != JsonValueKind.Array)
{
return results;
}
foreach (var elem in doc.RootElement.EnumerateArray())
{
var kind = elem.TryGetProperty("kind", out var k) ? k.GetString() : null;
var urlStr = elem.TryGetProperty("url", out var u) ? u.GetString() : null;
var format = elem.TryGetProperty("format", out var f) && f.ValueKind != JsonValueKind.Null
? f.GetString()
: null;
var requiresAuth = elem.TryGetProperty("requiresAuthentication", out var ra) && ra.GetBoolean();
if (!string.IsNullOrWhiteSpace(kind) && !string.IsNullOrWhiteSpace(urlStr))
{
results.Add(new IssuerEndpoint(kind, new Uri(urlStr), format, requiresAuth));
}
}
return results;
}
}

View File

@@ -0,0 +1,12 @@
using System.Text.Json;
namespace StellaOps.IssuerDirectory.Persistence.Postgres.Repositories;
public sealed partial class PostgresIssuerRepository
{
private static readonly JsonSerializerOptions _jsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
};
}

View File

@@ -0,0 +1,32 @@
using StellaOps.IssuerDirectory.Core.Domain;
using StellaOps.IssuerDirectory.Persistence.EfCore.Models;
namespace StellaOps.IssuerDirectory.Persistence.Postgres.Repositories;
public sealed partial class PostgresIssuerRepository
{
private static IssuerRecord MapToRecord(Issuer entity)
{
var contact = DeserializeContact(entity.Contact);
var metadata = DeserializeMetadata(entity.Metadata);
var endpoints = DeserializeEndpoints(entity.Endpoints);
return new IssuerRecord
{
Id = entity.Id.ToString(),
TenantId = entity.TenantId.ToString(),
Slug = entity.Name,
DisplayName = entity.DisplayName,
Description = entity.Description,
Contact = contact,
Metadata = metadata,
Endpoints = endpoints,
Tags = entity.Tags,
IsSystemSeed = entity.IsSystemSeed,
CreatedAtUtc = new DateTimeOffset(entity.CreatedAt, TimeSpan.Zero),
CreatedBy = entity.CreatedBy ?? string.Empty,
UpdatedAtUtc = new DateTimeOffset(entity.UpdatedAt, TimeSpan.Zero),
UpdatedBy = entity.UpdatedBy ?? string.Empty
};
}
}

View File

@@ -0,0 +1,73 @@
using System.Text.Json;
using StellaOps.IssuerDirectory.Core.Domain;
namespace StellaOps.IssuerDirectory.Persistence.Postgres.Repositories;
public sealed partial class PostgresIssuerRepository
{
private static string SerializeMetadata(IssuerMetadata metadata)
{
var doc = new
{
cveOrgId = metadata.CveOrgId,
csafPublisherId = metadata.CsafPublisherId,
securityAdvisoriesUrl = metadata.SecurityAdvisoriesUrl?.ToString(),
catalogUrl = metadata.CatalogUrl?.ToString(),
languages = metadata.SupportedLanguages.ToList(),
attributes = new Dictionary<string, string>(metadata.Attributes)
};
return JsonSerializer.Serialize(doc, _jsonOptions);
}
private static IssuerMetadata DeserializeMetadata(string json)
{
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
var cveOrgId = root.TryGetProperty("cveOrgId", out var c) && c.ValueKind != JsonValueKind.Null
? c.GetString()
: null;
var csafPublisherId = root.TryGetProperty("csafPublisherId", out var cp) && cp.ValueKind != JsonValueKind.Null
? cp.GetString()
: null;
var securityAdvisoriesUrlStr = root.TryGetProperty("securityAdvisoriesUrl", out var sa) &&
sa.ValueKind != JsonValueKind.Null
? sa.GetString()
: null;
var catalogUrlStr = root.TryGetProperty("catalogUrl", out var cu) && cu.ValueKind != JsonValueKind.Null
? cu.GetString()
: null;
var languages = new List<string>();
if (root.TryGetProperty("languages", out var langs) && langs.ValueKind == JsonValueKind.Array)
{
foreach (var lang in langs.EnumerateArray())
{
if (lang.ValueKind == JsonValueKind.String)
{
languages.Add(lang.GetString()!);
}
}
}
var attributes = new Dictionary<string, string>();
if (root.TryGetProperty("attributes", out var attrs) && attrs.ValueKind == JsonValueKind.Object)
{
foreach (var prop in attrs.EnumerateObject())
{
if (prop.Value.ValueKind == JsonValueKind.String)
{
attributes[prop.Name] = prop.Value.GetString()!;
}
}
}
return new IssuerMetadata(
cveOrgId,
csafPublisherId,
string.IsNullOrWhiteSpace(securityAdvisoriesUrlStr) ? null : new Uri(securityAdvisoriesUrlStr),
string.IsNullOrWhiteSpace(catalogUrlStr) ? null : new Uri(catalogUrlStr),
languages,
attributes);
}
}

View File

@@ -0,0 +1,129 @@
using Microsoft.EntityFrameworkCore;
using Npgsql;
using StellaOps.IssuerDirectory.Core.Domain;
namespace StellaOps.IssuerDirectory.Persistence.Postgres.Repositories;
public sealed partial class PostgresIssuerRepository
{
public async Task<IssuerRecord?> GetAsync(
string tenantId,
string issuerId,
CancellationToken cancellationToken)
{
await using var connection = await _dataSource
.OpenConnectionAsync(tenantId, "reader", cancellationToken)
.ConfigureAwait(false);
await using var dbContext = IssuerDirectoryDbContextFactory.Create(
connection, _dataSource.CommandTimeoutSeconds, GetSchemaName());
var tenantGuid = Guid.Parse(tenantId);
var issuerGuid = Guid.Parse(issuerId);
var entity = await dbContext.Issuers
.AsNoTracking()
.FirstOrDefaultAsync(e => e.TenantId == tenantGuid && e.Id == issuerGuid, cancellationToken)
.ConfigureAwait(false);
return entity is null ? null : MapToRecord(entity);
}
public async Task<IReadOnlyCollection<IssuerRecord>> ListAsync(
string tenantId,
CancellationToken cancellationToken)
{
await using var connection = await _dataSource
.OpenConnectionAsync(tenantId, "reader", cancellationToken)
.ConfigureAwait(false);
await using var dbContext = IssuerDirectoryDbContextFactory.Create(
connection, _dataSource.CommandTimeoutSeconds, GetSchemaName());
var tenantGuid = Guid.Parse(tenantId);
var entities = await dbContext.Issuers
.AsNoTracking()
.Where(e => e.TenantId == tenantGuid)
.OrderBy(e => e.Name)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return entities.Select(MapToRecord).ToList();
}
public async Task<IReadOnlyCollection<IssuerRecord>> ListGlobalAsync(CancellationToken cancellationToken)
{
// IssuerTenants.Global may be a well-known UUID string or a sentinel value.
// Preserve the exact original SQL behavior by using raw SQL with the same ::uuid cast
// that the Npgsql-based implementation used, since the global tenant identifier format
// may not be a standard GUID parseable by Guid.Parse().
await using var connection = await _dataSource
.OpenSystemConnectionAsync(cancellationToken)
.ConfigureAwait(false);
var schemaName = GetSchemaName();
var results = new List<IssuerRecord>();
const string sql = """
SELECT id, tenant_id, name, display_name, description, endpoints, contact, metadata, tags, status,
is_system_seed, created_at, created_by, updated_at, updated_by
FROM issuer.issuers
WHERE tenant_id = @globalTenantId::uuid
ORDER BY name ASC
""";
await using var command = new NpgsqlCommand(sql.Replace("issuer.", schemaName + "."), connection);
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
command.Parameters.AddWithValue("globalTenantId", IssuerTenants.Global);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
results.Add(MapToRecordFromReader(reader));
}
return results;
}
/// <summary>
/// Maps from NpgsqlDataReader for legacy global queries that use raw SQL.
/// </summary>
private static IssuerRecord MapToRecordFromReader(NpgsqlDataReader reader)
{
var id = reader.GetGuid(0).ToString();
var tenantId = reader.GetGuid(1).ToString();
var name = reader.GetString(2);
var displayName = reader.GetString(3);
var description = reader.IsDBNull(4) ? null : reader.GetString(4);
var endpointsJson = reader.GetString(5);
var contactJson = reader.GetString(6);
var metadataJson = reader.GetString(7);
var tags = reader.GetFieldValue<string[]>(8);
var isSystemSeed = reader.GetBoolean(10);
var createdAt = reader.GetDateTime(11);
var createdBy = reader.GetString(12);
var updatedAt = reader.GetDateTime(13);
var updatedBy = reader.GetString(14);
var contact = DeserializeContact(contactJson);
var metadata = DeserializeMetadata(metadataJson);
var endpoints = DeserializeEndpoints(endpointsJson);
return new IssuerRecord
{
Id = id,
TenantId = tenantId,
Slug = name,
DisplayName = displayName,
Description = description,
Contact = contact,
Metadata = metadata,
Endpoints = endpoints,
Tags = tags,
IsSystemSeed = isSystemSeed,
CreatedAtUtc = new DateTimeOffset(createdAt, TimeSpan.Zero),
CreatedBy = createdBy,
UpdatedAtUtc = new DateTimeOffset(updatedAt, TimeSpan.Zero),
UpdatedBy = updatedBy
};
}
}

View File

@@ -0,0 +1,139 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.IssuerDirectory.Core.Domain;
using StellaOps.IssuerDirectory.Persistence.EfCore.Models;
namespace StellaOps.IssuerDirectory.Persistence.Postgres.Repositories;
public sealed partial class PostgresIssuerRepository
{
public async Task UpsertAsync(IssuerRecord record, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(record);
await using var connection = await _dataSource
.OpenConnectionAsync(record.TenantId, "writer", cancellationToken)
.ConfigureAwait(false);
await using var dbContext = IssuerDirectoryDbContextFactory.Create(
connection, _dataSource.CommandTimeoutSeconds, GetSchemaName());
var tenantGuid = Guid.Parse(record.TenantId);
var issuerGuid = Guid.Parse(record.Id);
var existing = await dbContext.Issuers
.FirstOrDefaultAsync(e => e.TenantId == tenantGuid && e.Name == record.Slug, cancellationToken)
.ConfigureAwait(false);
if (existing is null)
{
var entity = new Issuer
{
Id = issuerGuid,
TenantId = tenantGuid,
Name = record.Slug,
DisplayName = record.DisplayName,
Description = record.Description,
Endpoints = SerializeEndpoints(record.Endpoints),
Contact = SerializeContact(record.Contact),
Metadata = SerializeMetadata(record.Metadata),
Tags = record.Tags.ToArray(),
Status = "active",
IsSystemSeed = record.IsSystemSeed,
CreatedAt = record.CreatedAtUtc.UtcDateTime,
CreatedBy = record.CreatedBy,
UpdatedAt = record.UpdatedAtUtc.UtcDateTime,
UpdatedBy = record.UpdatedBy
};
dbContext.Issuers.Add(entity);
}
else
{
existing.DisplayName = record.DisplayName;
existing.Description = record.Description;
existing.Endpoints = SerializeEndpoints(record.Endpoints);
existing.Contact = SerializeContact(record.Contact);
existing.Metadata = SerializeMetadata(record.Metadata);
existing.Tags = record.Tags.ToArray();
existing.Status = "active";
existing.UpdatedAt = record.UpdatedAtUtc.UtcDateTime;
existing.UpdatedBy = record.UpdatedBy;
}
try
{
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
catch (DbUpdateException ex) when (IsUniqueViolation(ex))
{
// Idempotency: conflict on (tenant_id, name) -- update instead
dbContext.ChangeTracker.Clear();
var conflict = await dbContext.Issuers
.FirstOrDefaultAsync(e => e.TenantId == tenantGuid && e.Name == record.Slug, cancellationToken)
.ConfigureAwait(false);
if (conflict is null)
{
throw;
}
conflict.DisplayName = record.DisplayName;
conflict.Description = record.Description;
conflict.Endpoints = SerializeEndpoints(record.Endpoints);
conflict.Contact = SerializeContact(record.Contact);
conflict.Metadata = SerializeMetadata(record.Metadata);
conflict.Tags = record.Tags.ToArray();
conflict.Status = "active";
conflict.UpdatedAt = record.UpdatedAtUtc.UtcDateTime;
conflict.UpdatedBy = record.UpdatedBy;
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
_logger.LogDebug("Upserted issuer {IssuerId} for tenant {TenantId}.", record.Id, record.TenantId);
}
public async Task DeleteAsync(string tenantId, string issuerId, CancellationToken cancellationToken)
{
await using var connection = await _dataSource
.OpenConnectionAsync(tenantId, "writer", cancellationToken)
.ConfigureAwait(false);
await using var dbContext = IssuerDirectoryDbContextFactory.Create(
connection, _dataSource.CommandTimeoutSeconds, GetSchemaName());
var tenantGuid = Guid.Parse(tenantId);
var issuerGuid = Guid.Parse(issuerId);
var entity = await dbContext.Issuers
.FirstOrDefaultAsync(e => e.TenantId == tenantGuid && e.Id == issuerGuid, cancellationToken)
.ConfigureAwait(false);
if (entity is not null)
{
dbContext.Issuers.Remove(entity);
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
_logger.LogDebug(
"Deleted issuer {IssuerId} for tenant {TenantId}.",
issuerId,
tenantId);
}
private static bool IsUniqueViolation(DbUpdateException exception)
{
Exception? current = exception;
while (current is not null)
{
if (current is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation })
{
return true;
}
current = current.InnerException;
}
return false;
}
}

View File

@@ -0,0 +1,32 @@
using Microsoft.Extensions.Logging;
using StellaOps.IssuerDirectory.Core.Abstractions;
using StellaOps.IssuerDirectory.Persistence.Postgres;
namespace StellaOps.IssuerDirectory.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL implementation of the issuer repository backed by EF Core.
/// </summary>
public sealed partial class PostgresIssuerRepository : IIssuerRepository
{
private readonly IssuerDirectoryDataSource _dataSource;
private readonly ILogger<PostgresIssuerRepository> _logger;
public PostgresIssuerRepository(
IssuerDirectoryDataSource dataSource,
ILogger<PostgresIssuerRepository> logger)
{
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
private string GetSchemaName()
{
if (!string.IsNullOrWhiteSpace(_dataSource.SchemaName))
{
return _dataSource.SchemaName!;
}
return IssuerDirectoryDataSource.DefaultSchemaName;
}
}

View File

@@ -0,0 +1,22 @@
using StellaOps.IssuerDirectory.Core.Domain;
using StellaOps.IssuerDirectory.Persistence.EfCore.Models;
namespace StellaOps.IssuerDirectory.Persistence.Postgres.Repositories;
public sealed partial class PostgresIssuerTrustRepository
{
private static IssuerTrustOverrideRecord MapToRecord(TrustOverride entity)
{
return new IssuerTrustOverrideRecord
{
IssuerId = entity.IssuerId.ToString(),
TenantId = entity.TenantId.ToString(),
Weight = entity.Weight,
Reason = entity.Rationale,
CreatedAtUtc = new DateTimeOffset(entity.CreatedAt, TimeSpan.Zero),
CreatedBy = entity.CreatedBy ?? string.Empty,
UpdatedAtUtc = new DateTimeOffset(entity.UpdatedAt, TimeSpan.Zero),
UpdatedBy = entity.UpdatedBy ?? string.Empty
};
}
}

View File

@@ -0,0 +1,31 @@
using Microsoft.EntityFrameworkCore;
using StellaOps.IssuerDirectory.Core.Domain;
namespace StellaOps.IssuerDirectory.Persistence.Postgres.Repositories;
public sealed partial class PostgresIssuerTrustRepository
{
public async Task<IssuerTrustOverrideRecord?> GetAsync(
string tenantId,
string issuerId,
CancellationToken cancellationToken)
{
await using var connection = await _dataSource
.OpenConnectionAsync(tenantId, "reader", cancellationToken)
.ConfigureAwait(false);
await using var dbContext = IssuerDirectoryDbContextFactory.Create(
connection, _dataSource.CommandTimeoutSeconds, GetSchemaName());
var tenantGuid = Guid.Parse(tenantId);
var issuerGuid = Guid.Parse(issuerId);
var entity = await dbContext.TrustOverrides
.AsNoTracking()
.FirstOrDefaultAsync(
e => e.TenantId == tenantGuid && e.IssuerId == issuerGuid,
cancellationToken)
.ConfigureAwait(false);
return entity is null ? null : MapToRecord(entity);
}
}

View File

@@ -0,0 +1,131 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.IssuerDirectory.Core.Domain;
using StellaOps.IssuerDirectory.Persistence.EfCore.Models;
namespace StellaOps.IssuerDirectory.Persistence.Postgres.Repositories;
public sealed partial class PostgresIssuerTrustRepository
{
public async Task UpsertAsync(IssuerTrustOverrideRecord record, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(record);
await using var connection = await _dataSource
.OpenConnectionAsync(record.TenantId, "writer", cancellationToken)
.ConfigureAwait(false);
await using var dbContext = IssuerDirectoryDbContextFactory.Create(
connection, _dataSource.CommandTimeoutSeconds, GetSchemaName());
var issuerGuid = Guid.Parse(record.IssuerId);
var tenantGuid = Guid.Parse(record.TenantId);
var existing = await dbContext.TrustOverrides
.FirstOrDefaultAsync(
e => e.IssuerId == issuerGuid && e.TenantId == tenantGuid,
cancellationToken)
.ConfigureAwait(false);
if (existing is null)
{
var entity = new TrustOverride
{
IssuerId = issuerGuid,
TenantId = tenantGuid,
Weight = record.Weight,
Rationale = record.Reason,
CreatedAt = record.CreatedAtUtc.UtcDateTime,
CreatedBy = record.CreatedBy,
UpdatedAt = record.UpdatedAtUtc.UtcDateTime,
UpdatedBy = record.UpdatedBy
};
dbContext.TrustOverrides.Add(entity);
}
else
{
existing.Weight = record.Weight;
existing.Rationale = record.Reason;
existing.UpdatedAt = record.UpdatedAtUtc.UtcDateTime;
existing.UpdatedBy = record.UpdatedBy;
}
try
{
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
catch (DbUpdateException ex) when (IsUniqueViolation(ex))
{
dbContext.ChangeTracker.Clear();
var conflict = await dbContext.TrustOverrides
.FirstOrDefaultAsync(
e => e.IssuerId == issuerGuid && e.TenantId == tenantGuid,
cancellationToken)
.ConfigureAwait(false);
if (conflict is null)
{
throw;
}
conflict.Weight = record.Weight;
conflict.Rationale = record.Reason;
conflict.UpdatedAt = record.UpdatedAtUtc.UtcDateTime;
conflict.UpdatedBy = record.UpdatedBy;
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
_logger.LogDebug(
"Upserted trust override for issuer {IssuerId} in tenant {TenantId}.",
record.IssuerId,
record.TenantId);
}
public async Task DeleteAsync(string tenantId, string issuerId, CancellationToken cancellationToken)
{
await using var connection = await _dataSource
.OpenConnectionAsync(tenantId, "writer", cancellationToken)
.ConfigureAwait(false);
await using var dbContext = IssuerDirectoryDbContextFactory.Create(
connection, _dataSource.CommandTimeoutSeconds, GetSchemaName());
var tenantGuid = Guid.Parse(tenantId);
var issuerGuid = Guid.Parse(issuerId);
var entity = await dbContext.TrustOverrides
.FirstOrDefaultAsync(
e => e.TenantId == tenantGuid && e.IssuerId == issuerGuid,
cancellationToken)
.ConfigureAwait(false);
if (entity is not null)
{
dbContext.TrustOverrides.Remove(entity);
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
_logger.LogDebug(
"Deleted trust override for issuer {IssuerId} in tenant {TenantId}. Rows affected: {Rows}.",
issuerId,
tenantId,
entity is not null ? 1 : 0);
}
private static bool IsUniqueViolation(DbUpdateException exception)
{
Exception? current = exception;
while (current is not null)
{
if (current is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation })
{
return true;
}
current = current.InnerException;
}
return false;
}
}

View File

@@ -0,0 +1,32 @@
using Microsoft.Extensions.Logging;
using StellaOps.IssuerDirectory.Core.Abstractions;
using StellaOps.IssuerDirectory.Persistence.Postgres;
namespace StellaOps.IssuerDirectory.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL implementation of the issuer trust repository backed by EF Core.
/// </summary>
public sealed partial class PostgresIssuerTrustRepository : IIssuerTrustRepository
{
private readonly IssuerDirectoryDataSource _dataSource;
private readonly ILogger<PostgresIssuerTrustRepository> _logger;
public PostgresIssuerTrustRepository(
IssuerDirectoryDataSource dataSource,
ILogger<PostgresIssuerTrustRepository> logger)
{
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
private string GetSchemaName()
{
if (!string.IsNullOrWhiteSpace(_dataSource.SchemaName))
{
return _dataSource.SchemaName!;
}
return IssuerDirectoryDataSource.DefaultSchemaName;
}
}

View File

@@ -0,0 +1,40 @@
<?xml version="1.0" ?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<RootNamespace>StellaOps.IssuerDirectory.Persistence</RootNamespace>
<AssemblyName>StellaOps.IssuerDirectory.Persistence</AssemblyName>
<Description>Consolidated persistence layer for StellaOps IssuerDirectory module</Description>
</PropertyGroup>
<ItemGroup>
<EmbeddedResource Include="Migrations\**\*.sql" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
</ItemGroup>
<ItemGroup>
<!-- Prevent automatic compiled-model binding so non-default schemas can build runtime models. -->
<Compile Remove="EfCore\CompiledModels\IssuerDirectoryDbContextAssemblyAttributes.cs" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" PrivateAssets="all" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="Npgsql" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\StellaOps.IssuerDirectory\StellaOps.IssuerDirectory.Core\StellaOps.IssuerDirectory.Core.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Infrastructure.EfCore\StellaOps.Infrastructure.EfCore.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<RootNamespace>StellaOps.IssuerDirectory.Persistence</RootNamespace>
<AssemblyName>StellaOps.IssuerDirectory.Persistence</AssemblyName>
<Description>Consolidated persistence layer for StellaOps IssuerDirectory module</Description>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.0" PrivateAssets="all" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.1" />
<PackageReference Include="Npgsql" Version="10.0.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\StellaOps.IssuerDirectory\StellaOps.IssuerDirectory.Core\StellaOps.IssuerDirectory.Core.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Infrastructure.EfCore\StellaOps.Infrastructure.EfCore.csproj" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Migrations\*.sql" LogicalName="%(Filename)%(Extension)" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,12 @@
# StellaOps.IssuerDirectory.Persistence 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-0376-M | DONE | Revalidated 2026-01-07; maintainability audit for IssuerDirectory.Persistence. |
| AUDIT-0376-T | DONE | Revalidated 2026-01-07; test coverage audit for IssuerDirectory.Persistence. |
| AUDIT-0376-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 repositories into partials, removed service locator registration, expanded persistence tests (SPRINT_20260130_002). |