consolidation of some of the modules, localization fixes, product advisories work, qa work
This commit is contained in:
@@ -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;
|
||||
|
||||
-- ============================================================================
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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). |
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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))]
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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!;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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). |
|
||||
Reference in New Issue
Block a user