feat: Implement PostgreSQL repositories for various entities
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled

- Added BootstrapInviteRepository for managing bootstrap invites.
- Added ClientRepository for handling OAuth/OpenID clients.
- Introduced LoginAttemptRepository for logging login attempts.
- Created OidcTokenRepository for managing OpenIddict tokens and refresh tokens.
- Implemented RevocationExportStateRepository for persisting revocation export state.
- Added RevocationRepository for managing revocations.
- Introduced ServiceAccountRepository for handling service accounts.
This commit is contained in:
master
2025-12-11 17:48:25 +02:00
parent 1995883476
commit ab22181e8b
82 changed files with 5153 additions and 2261 deletions

View File

@@ -0,0 +1,154 @@
-- Authority Schema Migration 002: Mongo Store Equivalents
-- Adds PostgreSQL-backed tables that replace legacy MongoDB collections used by Authority.
-- Bootstrap invites
CREATE TABLE IF NOT EXISTS authority.bootstrap_invites (
id TEXT PRIMARY KEY,
token TEXT NOT NULL UNIQUE,
type TEXT NOT NULL,
provider TEXT,
target TEXT,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
issued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
issued_by TEXT,
reserved_until TIMESTAMPTZ,
reserved_by TEXT,
consumed BOOLEAN NOT NULL DEFAULT FALSE,
status TEXT NOT NULL DEFAULT 'pending',
metadata JSONB NOT NULL DEFAULT '{}'
);
-- Service accounts
CREATE TABLE IF NOT EXISTS authority.service_accounts (
id TEXT PRIMARY KEY,
account_id TEXT NOT NULL UNIQUE,
tenant TEXT NOT NULL,
display_name TEXT NOT NULL,
description TEXT,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
allowed_scopes TEXT[] NOT NULL DEFAULT '{}',
authorized_clients TEXT[] NOT NULL DEFAULT '{}',
attributes JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_service_accounts_tenant ON authority.service_accounts(tenant);
-- Clients
CREATE TABLE IF NOT EXISTS authority.clients (
id TEXT PRIMARY KEY,
client_id TEXT NOT NULL UNIQUE,
client_secret TEXT,
secret_hash TEXT,
display_name TEXT,
description TEXT,
plugin TEXT,
sender_constraint TEXT,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
redirect_uris TEXT[] NOT NULL DEFAULT '{}',
post_logout_redirect_uris TEXT[] NOT NULL DEFAULT '{}',
allowed_scopes TEXT[] NOT NULL DEFAULT '{}',
allowed_grant_types TEXT[] NOT NULL DEFAULT '{}',
require_client_secret BOOLEAN NOT NULL DEFAULT TRUE,
require_pkce BOOLEAN NOT NULL DEFAULT FALSE,
allow_plain_text_pkce BOOLEAN NOT NULL DEFAULT FALSE,
client_type TEXT,
properties JSONB NOT NULL DEFAULT '{}',
certificate_bindings JSONB NOT NULL DEFAULT '[]',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Revocations
CREATE TABLE IF NOT EXISTS authority.revocations (
id TEXT PRIMARY KEY,
category TEXT NOT NULL,
revocation_id TEXT NOT NULL,
subject_id TEXT,
client_id TEXT,
token_id TEXT,
reason TEXT NOT NULL,
reason_description TEXT,
revoked_at TIMESTAMPTZ NOT NULL,
effective_at TIMESTAMPTZ NOT NULL,
expires_at TIMESTAMPTZ,
metadata JSONB NOT NULL DEFAULT '{}'
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_revocations_category_revocation_id
ON authority.revocations(category, revocation_id);
-- Login attempts
CREATE TABLE IF NOT EXISTS authority.login_attempts (
id TEXT PRIMARY KEY,
subject_id TEXT,
client_id TEXT,
event_type TEXT NOT NULL,
outcome TEXT NOT NULL,
reason TEXT,
ip_address TEXT,
user_agent TEXT,
occurred_at TIMESTAMPTZ NOT NULL,
properties JSONB NOT NULL DEFAULT '[]'
);
CREATE INDEX IF NOT EXISTS idx_login_attempts_subject ON authority.login_attempts(subject_id, occurred_at DESC);
-- OIDC tokens
CREATE TABLE IF NOT EXISTS authority.oidc_tokens (
id TEXT PRIMARY KEY,
token_id TEXT NOT NULL UNIQUE,
subject_id TEXT,
client_id TEXT,
token_type TEXT NOT NULL,
reference_id TEXT,
created_at TIMESTAMPTZ NOT NULL,
expires_at TIMESTAMPTZ,
redeemed_at TIMESTAMPTZ,
payload TEXT,
properties JSONB NOT NULL DEFAULT '{}'
);
CREATE INDEX IF NOT EXISTS idx_oidc_tokens_subject ON authority.oidc_tokens(subject_id);
CREATE INDEX IF NOT EXISTS idx_oidc_tokens_client ON authority.oidc_tokens(client_id);
CREATE INDEX IF NOT EXISTS idx_oidc_tokens_reference ON authority.oidc_tokens(reference_id);
-- OIDC refresh tokens
CREATE TABLE IF NOT EXISTS authority.oidc_refresh_tokens (
id TEXT PRIMARY KEY,
token_id TEXT NOT NULL UNIQUE,
subject_id TEXT,
client_id TEXT,
handle TEXT,
created_at TIMESTAMPTZ NOT NULL,
expires_at TIMESTAMPTZ,
consumed_at TIMESTAMPTZ,
payload TEXT
);
CREATE INDEX IF NOT EXISTS idx_oidc_refresh_tokens_subject ON authority.oidc_refresh_tokens(subject_id);
CREATE INDEX IF NOT EXISTS idx_oidc_refresh_tokens_handle ON authority.oidc_refresh_tokens(handle);
-- Airgap audit
CREATE TABLE IF NOT EXISTS authority.airgap_audit (
id TEXT PRIMARY KEY,
event_type TEXT NOT NULL,
operator_id TEXT,
component_id TEXT,
outcome TEXT NOT NULL,
reason TEXT,
occurred_at TIMESTAMPTZ NOT NULL,
properties JSONB NOT NULL DEFAULT '[]'
);
CREATE INDEX IF NOT EXISTS idx_airgap_audit_occurred_at ON authority.airgap_audit(occurred_at DESC);
-- Revocation export state (singleton row with optimistic concurrency)
CREATE TABLE IF NOT EXISTS authority.revocation_export_state (
id INT PRIMARY KEY DEFAULT 1,
sequence BIGINT NOT NULL DEFAULT 0,
bundle_id TEXT,
issued_at TIMESTAMPTZ
);

View File

@@ -0,0 +1,25 @@
namespace StellaOps.Authority.Storage.Postgres.Models;
/// <summary>
/// Represents an air-gapped audit record.
/// </summary>
public sealed class AirgapAuditEntity
{
public required string Id { get; init; }
public required string EventType { get; init; }
public string? OperatorId { get; init; }
public string? ComponentId { get; init; }
public required string Outcome { get; init; }
public string? Reason { get; init; }
public DateTimeOffset OccurredAt { get; init; }
public IReadOnlyList<AirgapAuditPropertyEntity> Properties { get; init; } = Array.Empty<AirgapAuditPropertyEntity>();
}
/// <summary>
/// Represents a property stored alongside an airgap audit record.
/// </summary>
public sealed class AirgapAuditPropertyEntity
{
public required string Name { get; init; }
public required string Value { get; init; }
}

View File

@@ -0,0 +1,22 @@
namespace StellaOps.Authority.Storage.Postgres.Models;
/// <summary>
/// Represents a bootstrap invite seed.
/// </summary>
public sealed class BootstrapInviteEntity
{
public required string Id { get; init; }
public required string Token { get; init; }
public required string Type { get; init; }
public string? Provider { get; init; }
public string? Target { get; init; }
public DateTimeOffset ExpiresAt { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset IssuedAt { get; init; }
public string? IssuedBy { get; init; }
public DateTimeOffset? ReservedUntil { get; init; }
public string? ReservedBy { get; init; }
public bool Consumed { get; init; }
public string Status { get; init; } = "pending";
public IReadOnlyDictionary<string, string?> Metadata { get; init; } = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
}

View File

@@ -0,0 +1,46 @@
namespace StellaOps.Authority.Storage.Postgres.Models;
/// <summary>
/// Represents an OAuth/OpenID Connect client configuration.
/// </summary>
public sealed class ClientEntity
{
public required string Id { get; init; }
public required string ClientId { get; init; }
public string? ClientSecret { get; init; }
public string? SecretHash { get; init; }
public string? DisplayName { get; init; }
public string? Description { get; init; }
public string? Plugin { get; init; }
public string? SenderConstraint { get; init; }
public bool Enabled { get; init; }
public IReadOnlyList<string> RedirectUris { get; init; } = Array.Empty<string>();
public IReadOnlyList<string> PostLogoutRedirectUris { get; init; } = Array.Empty<string>();
public IReadOnlyList<string> AllowedScopes { get; init; } = Array.Empty<string>();
public IReadOnlyList<string> AllowedGrantTypes { get; init; } = Array.Empty<string>();
public bool RequireClientSecret { get; init; }
public bool RequirePkce { get; init; }
public bool AllowPlainTextPkce { get; init; }
public string? ClientType { get; init; }
public IReadOnlyDictionary<string, string> Properties { get; init; } = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
public IReadOnlyList<ClientCertificateBindingEntity> CertificateBindings { get; init; } = Array.Empty<ClientCertificateBindingEntity>();
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset UpdatedAt { get; init; }
}
/// <summary>
/// Represents a certificate binding for mutual TLS clients.
/// </summary>
public sealed class ClientCertificateBindingEntity
{
public string? Thumbprint { get; init; }
public string? SerialNumber { get; init; }
public string? Subject { get; init; }
public string? Issuer { get; init; }
public IReadOnlyList<string> SubjectAlternativeNames { get; init; } = Array.Empty<string>();
public DateTimeOffset? NotBefore { get; init; }
public DateTimeOffset? NotAfter { get; init; }
public string? Label { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset UpdatedAt { get; init; }
}

View File

@@ -0,0 +1,29 @@
namespace StellaOps.Authority.Storage.Postgres.Models;
/// <summary>
/// Represents a login attempt.
/// </summary>
public sealed class LoginAttemptEntity
{
public required string Id { get; init; }
public string? SubjectId { get; init; }
public string? ClientId { get; init; }
public required string EventType { get; init; }
public required string Outcome { get; init; }
public string? Reason { get; init; }
public string? IpAddress { get; init; }
public string? UserAgent { get; init; }
public DateTimeOffset OccurredAt { get; init; }
public IReadOnlyList<LoginAttemptPropertyEntity> Properties { get; init; } = Array.Empty<LoginAttemptPropertyEntity>();
}
/// <summary>
/// Represents a property attached to a login attempt.
/// </summary>
public sealed class LoginAttemptPropertyEntity
{
public required string Name { get; init; }
public required string Value { get; init; }
public bool Sensitive { get; init; }
public string Classification { get; init; } = "none";
}

View File

@@ -0,0 +1,35 @@
namespace StellaOps.Authority.Storage.Postgres.Models;
/// <summary>
/// Represents an OpenIddict token persisted in PostgreSQL.
/// </summary>
public sealed class OidcTokenEntity
{
public required string Id { get; init; }
public required string TokenId { get; init; }
public string? SubjectId { get; init; }
public string? ClientId { get; init; }
public required string TokenType { get; init; }
public string? ReferenceId { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset? ExpiresAt { get; init; }
public DateTimeOffset? RedeemedAt { get; init; }
public string? Payload { get; init; }
public IReadOnlyDictionary<string, string> Properties { get; init; } = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
}
/// <summary>
/// Represents a refresh token persisted in PostgreSQL.
/// </summary>
public sealed class OidcRefreshTokenEntity
{
public required string Id { get; init; }
public required string TokenId { get; init; }
public string? SubjectId { get; init; }
public string? ClientId { get; init; }
public string? Handle { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset? ExpiresAt { get; init; }
public DateTimeOffset? ConsumedAt { get; init; }
public string? Payload { get; init; }
}

View File

@@ -0,0 +1,20 @@
namespace StellaOps.Authority.Storage.Postgres.Models;
/// <summary>
/// Represents a revocation record.
/// </summary>
public sealed class RevocationEntity
{
public required string Id { get; init; }
public required string Category { get; init; }
public required string RevocationId { get; init; }
public string SubjectId { get; init; } = string.Empty;
public string? ClientId { get; init; }
public string? TokenId { get; init; }
public required string Reason { get; init; }
public string? ReasonDescription { get; init; }
public DateTimeOffset RevokedAt { get; init; }
public DateTimeOffset EffectiveAt { get; init; }
public DateTimeOffset? ExpiresAt { get; init; }
public IReadOnlyDictionary<string, string?> Metadata { get; init; } = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
}

View File

@@ -0,0 +1,12 @@
namespace StellaOps.Authority.Storage.Postgres.Models;
/// <summary>
/// Represents the last exported revocation bundle metadata.
/// </summary>
public sealed class RevocationExportStateEntity
{
public int Id { get; set; } = 1;
public long Sequence { get; set; }
public string? BundleId { get; set; }
public DateTimeOffset? IssuedAt { get; set; }
}

View File

@@ -0,0 +1,19 @@
namespace StellaOps.Authority.Storage.Postgres.Models;
/// <summary>
/// Represents a service account configuration.
/// </summary>
public sealed class ServiceAccountEntity
{
public required string Id { get; init; }
public required string AccountId { get; init; }
public required string Tenant { get; init; }
public required string DisplayName { get; init; }
public string? Description { get; init; }
public bool Enabled { get; init; }
public IReadOnlyList<string> AllowedScopes { get; init; } = Array.Empty<string>();
public IReadOnlyList<string> AuthorizedClients { get; init; } = Array.Empty<string>();
public IReadOnlyDictionary<string, List<string>> Attributes { get; init; } = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset UpdatedAt { get; init; }
}

View File

@@ -0,0 +1,90 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Authority.Storage.Postgres.Models;
using StellaOps.Infrastructure.Postgres.Repositories;
namespace StellaOps.Authority.Storage.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for airgap audit records.
/// </summary>
public sealed class AirgapAuditRepository : RepositoryBase<AuthorityDataSource>
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.General);
public AirgapAuditRepository(AuthorityDataSource dataSource, ILogger<AirgapAuditRepository> logger)
: base(dataSource, logger)
{
}
public async Task InsertAsync(AirgapAuditEntity entity, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO authority.airgap_audit
(id, event_type, operator_id, component_id, outcome, reason, occurred_at, properties)
VALUES (@id, @event_type, @operator_id, @component_id, @outcome, @reason, @occurred_at, @properties)
""";
await ExecuteAsync(
tenantId: string.Empty,
sql: sql,
configureCommand: cmd =>
{
AddParameter(cmd, "id", entity.Id);
AddParameter(cmd, "event_type", entity.EventType);
AddParameter(cmd, "operator_id", entity.OperatorId);
AddParameter(cmd, "component_id", entity.ComponentId);
AddParameter(cmd, "outcome", entity.Outcome);
AddParameter(cmd, "reason", entity.Reason);
AddParameter(cmd, "occurred_at", entity.OccurredAt);
AddJsonbParameter(cmd, "properties", JsonSerializer.Serialize(entity.Properties, SerializerOptions));
},
cancellationToken: cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<AirgapAuditEntity>> ListAsync(int limit, int offset, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, event_type, operator_id, component_id, outcome, reason, occurred_at, properties
FROM authority.airgap_audit
ORDER BY occurred_at DESC
LIMIT @limit OFFSET @offset
""";
return await QueryAsync(
tenantId: string.Empty,
sql: sql,
configureCommand: cmd =>
{
AddParameter(cmd, "limit", limit);
AddParameter(cmd, "offset", offset);
},
mapRow: MapAudit,
cancellationToken: cancellationToken).ConfigureAwait(false);
}
private static AirgapAuditEntity MapAudit(NpgsqlDataReader reader) => new()
{
Id = reader.GetString(0),
EventType = reader.GetString(1),
OperatorId = GetNullableString(reader, 2),
ComponentId = GetNullableString(reader, 3),
Outcome = reader.GetString(4),
Reason = GetNullableString(reader, 5),
OccurredAt = reader.GetFieldValue<DateTimeOffset>(6),
Properties = DeserializeProperties(reader, 7)
};
private static IReadOnlyList<AirgapAuditPropertyEntity> DeserializeProperties(NpgsqlDataReader reader, int ordinal)
{
if (reader.IsDBNull(ordinal))
{
return Array.Empty<AirgapAuditPropertyEntity>();
}
var json = reader.GetString(ordinal);
List<AirgapAuditPropertyEntity>? parsed = JsonSerializer.Deserialize<List<AirgapAuditPropertyEntity>>(json, SerializerOptions);
return parsed ?? new List<AirgapAuditPropertyEntity>();
}
}

View File

@@ -0,0 +1,194 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Authority.Storage.Postgres.Models;
using StellaOps.Infrastructure.Postgres.Repositories;
namespace StellaOps.Authority.Storage.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for bootstrap invites.
/// </summary>
public sealed class BootstrapInviteRepository : RepositoryBase<AuthorityDataSource>
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.General);
public BootstrapInviteRepository(AuthorityDataSource dataSource, ILogger<BootstrapInviteRepository> logger)
: base(dataSource, logger)
{
}
public async Task<BootstrapInviteEntity?> FindByTokenAsync(string token, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, token, type, provider, target, expires_at, created_at, issued_at, issued_by, reserved_until, reserved_by, consumed, status, metadata
FROM authority.bootstrap_invites
WHERE token = @token
""";
return await QuerySingleOrDefaultAsync(
tenantId: string.Empty,
sql: sql,
configureCommand: cmd => AddParameter(cmd, "token", token),
mapRow: MapInvite,
cancellationToken: cancellationToken).ConfigureAwait(false);
}
public async Task InsertAsync(BootstrapInviteEntity invite, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO authority.bootstrap_invites
(id, token, type, provider, target, expires_at, created_at, issued_at, issued_by, reserved_until, reserved_by, consumed, status, metadata)
VALUES (@id, @token, @type, @provider, @target, @expires_at, @created_at, @issued_at, @issued_by, @reserved_until, @reserved_by, @consumed, @status, @metadata)
""";
await ExecuteAsync(
tenantId: string.Empty,
sql: sql,
configureCommand: cmd =>
{
AddParameter(cmd, "id", invite.Id);
AddParameter(cmd, "token", invite.Token);
AddParameter(cmd, "type", invite.Type);
AddParameter(cmd, "provider", invite.Provider);
AddParameter(cmd, "target", invite.Target);
AddParameter(cmd, "expires_at", invite.ExpiresAt);
AddParameter(cmd, "created_at", invite.CreatedAt);
AddParameter(cmd, "issued_at", invite.IssuedAt);
AddParameter(cmd, "issued_by", invite.IssuedBy);
AddParameter(cmd, "reserved_until", invite.ReservedUntil);
AddParameter(cmd, "reserved_by", invite.ReservedBy);
AddParameter(cmd, "consumed", invite.Consumed);
AddParameter(cmd, "status", invite.Status);
AddJsonbParameter(cmd, "metadata", JsonSerializer.Serialize(invite.Metadata, SerializerOptions));
},
cancellationToken: cancellationToken).ConfigureAwait(false);
}
public async Task<bool> ConsumeAsync(string token, string? consumedBy, DateTimeOffset consumedAt, CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE authority.bootstrap_invites
SET consumed = TRUE,
reserved_by = @consumed_by,
reserved_until = @consumed_at,
status = 'consumed'
WHERE token = @token AND consumed = FALSE
""";
var rows = await ExecuteAsync(
tenantId: string.Empty,
sql: sql,
configureCommand: cmd =>
{
AddParameter(cmd, "token", token);
AddParameter(cmd, "consumed_by", consumedBy);
AddParameter(cmd, "consumed_at", consumedAt);
},
cancellationToken: cancellationToken).ConfigureAwait(false);
return rows > 0;
}
public async Task<bool> ReleaseAsync(string token, CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE authority.bootstrap_invites
SET status = 'pending',
reserved_by = NULL,
reserved_until = NULL
WHERE token = @token AND status = 'reserved'
""";
var rows = await ExecuteAsync(
tenantId: string.Empty,
sql: sql,
configureCommand: cmd => AddParameter(cmd, "token", token),
cancellationToken: cancellationToken).ConfigureAwait(false);
return rows > 0;
}
public async Task<bool> TryReserveAsync(string token, string expectedType, DateTimeOffset now, string? reservedBy, CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE authority.bootstrap_invites
SET status = 'reserved',
reserved_by = @reserved_by,
reserved_until = @reserved_until
WHERE token = @token
AND type = @expected_type
AND consumed = FALSE
AND expires_at > @now
AND (status = 'pending' OR status IS NULL)
""";
var rows = await ExecuteAsync(
tenantId: string.Empty,
sql: sql,
configureCommand: cmd =>
{
AddParameter(cmd, "reserved_by", reservedBy);
AddParameter(cmd, "reserved_until", now.AddMinutes(15));
AddParameter(cmd, "token", token);
AddParameter(cmd, "expected_type", expectedType);
AddParameter(cmd, "now", now);
},
cancellationToken: cancellationToken).ConfigureAwait(false);
return rows > 0;
}
public async Task<IReadOnlyList<BootstrapInviteEntity>> ExpireAsync(DateTimeOffset asOf, CancellationToken cancellationToken = default)
{
const string selectSql = """
SELECT id, token, type, provider, target, expires_at, created_at, issued_at, issued_by, reserved_until, reserved_by, consumed, status, metadata
FROM authority.bootstrap_invites
WHERE expires_at <= @as_of
""";
const string deleteSql = """
DELETE FROM authority.bootstrap_invites
WHERE expires_at <= @as_of
""";
var expired = await QueryAsync(
tenantId: string.Empty,
sql: selectSql,
configureCommand: cmd => AddParameter(cmd, "as_of", asOf),
mapRow: MapInvite,
cancellationToken: cancellationToken).ConfigureAwait(false);
await ExecuteAsync(
tenantId: string.Empty,
sql: deleteSql,
configureCommand: cmd => AddParameter(cmd, "as_of", asOf),
cancellationToken: cancellationToken).ConfigureAwait(false);
return expired;
}
private static BootstrapInviteEntity MapInvite(NpgsqlDataReader reader) => new()
{
Id = reader.GetString(0),
Token = reader.GetString(1),
Type = reader.GetString(2),
Provider = GetNullableString(reader, 3),
Target = GetNullableString(reader, 4),
ExpiresAt = reader.GetFieldValue<DateTimeOffset>(5),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(6),
IssuedAt = reader.GetFieldValue<DateTimeOffset>(7),
IssuedBy = GetNullableString(reader, 8),
ReservedUntil = reader.IsDBNull(9) ? null : reader.GetFieldValue<DateTimeOffset>(9),
ReservedBy = GetNullableString(reader, 10),
Consumed = reader.GetBoolean(11),
Status = GetNullableString(reader, 12) ?? "pending",
Metadata = DeserializeMetadata(reader, 13)
};
private static IReadOnlyDictionary<string, string?> DeserializeMetadata(NpgsqlDataReader reader, int ordinal)
{
if (reader.IsDBNull(ordinal))
{
return new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
}
var json = reader.GetString(ordinal);
return JsonSerializer.Deserialize<Dictionary<string, string?>>(json, SerializerOptions)
?? new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
}
}

View File

@@ -0,0 +1,162 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Authority.Storage.Postgres.Models;
using StellaOps.Infrastructure.Postgres.Repositories;
namespace StellaOps.Authority.Storage.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for OAuth/OpenID clients.
/// </summary>
public sealed class ClientRepository : RepositoryBase<AuthorityDataSource>
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.General);
public ClientRepository(AuthorityDataSource dataSource, ILogger<ClientRepository> logger)
: base(dataSource, logger)
{
}
public async Task<ClientEntity?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT 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
FROM authority.clients
WHERE client_id = @client_id
""";
return await QuerySingleOrDefaultAsync(
tenantId: string.Empty,
sql: sql,
configureCommand: cmd => AddParameter(cmd, "client_id", clientId),
mapRow: MapClient,
cancellationToken: cancellationToken).ConfigureAwait(false);
}
public async Task UpsertAsync(ClientEntity entity, CancellationToken cancellationToken = default)
{
const string sql = """
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
(@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)
ON CONFLICT (client_id) DO UPDATE
SET client_secret = EXCLUDED.client_secret,
secret_hash = EXCLUDED.secret_hash,
display_name = EXCLUDED.display_name,
description = EXCLUDED.description,
plugin = EXCLUDED.plugin,
sender_constraint = EXCLUDED.sender_constraint,
enabled = EXCLUDED.enabled,
redirect_uris = EXCLUDED.redirect_uris,
post_logout_redirect_uris = EXCLUDED.post_logout_redirect_uris,
allowed_scopes = EXCLUDED.allowed_scopes,
allowed_grant_types = EXCLUDED.allowed_grant_types,
require_client_secret = EXCLUDED.require_client_secret,
require_pkce = EXCLUDED.require_pkce,
allow_plain_text_pkce = EXCLUDED.allow_plain_text_pkce,
client_type = EXCLUDED.client_type,
properties = EXCLUDED.properties,
certificate_bindings = EXCLUDED.certificate_bindings,
updated_at = EXCLUDED.updated_at
""";
await ExecuteAsync(
tenantId: string.Empty,
sql: sql,
configureCommand: cmd =>
{
AddParameter(cmd, "id", entity.Id);
AddParameter(cmd, "client_id", entity.ClientId);
AddParameter(cmd, "client_secret", entity.ClientSecret);
AddParameter(cmd, "secret_hash", entity.SecretHash);
AddParameter(cmd, "display_name", entity.DisplayName);
AddParameter(cmd, "description", entity.Description);
AddParameter(cmd, "plugin", entity.Plugin);
AddParameter(cmd, "sender_constraint", entity.SenderConstraint);
AddParameter(cmd, "enabled", entity.Enabled);
AddParameter(cmd, "redirect_uris", entity.RedirectUris.ToArray());
AddParameter(cmd, "post_logout_redirect_uris", entity.PostLogoutRedirectUris.ToArray());
AddParameter(cmd, "allowed_scopes", entity.AllowedScopes.ToArray());
AddParameter(cmd, "allowed_grant_types", entity.AllowedGrantTypes.ToArray());
AddParameter(cmd, "require_client_secret", entity.RequireClientSecret);
AddParameter(cmd, "require_pkce", entity.RequirePkce);
AddParameter(cmd, "allow_plain_text_pkce", entity.AllowPlainTextPkce);
AddParameter(cmd, "client_type", entity.ClientType);
AddJsonbParameter(cmd, "properties", JsonSerializer.Serialize(entity.Properties, SerializerOptions));
AddJsonbParameter(cmd, "certificate_bindings", JsonSerializer.Serialize(entity.CertificateBindings, SerializerOptions));
AddParameter(cmd, "created_at", entity.CreatedAt);
AddParameter(cmd, "updated_at", entity.UpdatedAt);
},
cancellationToken: cancellationToken).ConfigureAwait(false);
}
public async Task<bool> DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken = default)
{
const string sql = "DELETE FROM authority.clients WHERE client_id = @client_id";
var rows = await ExecuteAsync(
tenantId: string.Empty,
sql: sql,
configureCommand: cmd => AddParameter(cmd, "client_id", clientId),
cancellationToken: cancellationToken).ConfigureAwait(false);
return rows > 0;
}
private static ClientEntity MapClient(NpgsqlDataReader reader) => new()
{
Id = reader.GetString(0),
ClientId = reader.GetString(1),
ClientSecret = GetNullableString(reader, 2),
SecretHash = GetNullableString(reader, 3),
DisplayName = GetNullableString(reader, 4),
Description = GetNullableString(reader, 5),
Plugin = GetNullableString(reader, 6),
SenderConstraint = GetNullableString(reader, 7),
Enabled = reader.GetBoolean(8),
RedirectUris = reader.GetFieldValue<string[]>(9),
PostLogoutRedirectUris = reader.GetFieldValue<string[]>(10),
AllowedScopes = reader.GetFieldValue<string[]>(11),
AllowedGrantTypes = reader.GetFieldValue<string[]>(12),
RequireClientSecret = reader.GetBoolean(13),
RequirePkce = reader.GetBoolean(14),
AllowPlainTextPkce = reader.GetBoolean(15),
ClientType = GetNullableString(reader, 16),
Properties = DeserializeDictionary(reader, 17),
CertificateBindings = Deserialize<List<ClientCertificateBindingEntity>>(reader, 18) ?? new List<ClientCertificateBindingEntity>(),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(19),
UpdatedAt = reader.GetFieldValue<DateTimeOffset>(20)
};
private static IReadOnlyDictionary<string, string> DeserializeDictionary(NpgsqlDataReader reader, int ordinal)
{
if (reader.IsDBNull(ordinal))
{
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
}
var json = reader.GetString(ordinal);
return JsonSerializer.Deserialize<Dictionary<string, string>>(json, SerializerOptions) ??
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
}
private static T? Deserialize<T>(NpgsqlDataReader reader, int ordinal)
{
if (reader.IsDBNull(ordinal))
{
return default;
}
var json = reader.GetString(ordinal);
return JsonSerializer.Deserialize<T>(json, SerializerOptions);
}
}

View File

@@ -0,0 +1,95 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Authority.Storage.Postgres.Models;
using StellaOps.Infrastructure.Postgres.Repositories;
namespace StellaOps.Authority.Storage.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for login attempts.
/// </summary>
public sealed class LoginAttemptRepository : RepositoryBase<AuthorityDataSource>
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.General);
public LoginAttemptRepository(AuthorityDataSource dataSource, ILogger<LoginAttemptRepository> logger)
: base(dataSource, logger)
{
}
public async Task InsertAsync(LoginAttemptEntity entity, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO authority.login_attempts
(id, subject_id, client_id, event_type, outcome, reason, ip_address, user_agent, occurred_at, properties)
VALUES (@id, @subject_id, @client_id, @event_type, @outcome, @reason, @ip_address, @user_agent, @occurred_at, @properties)
""";
await ExecuteAsync(
tenantId: string.Empty,
sql: sql,
configureCommand: cmd =>
{
AddParameter(cmd, "id", entity.Id);
AddParameter(cmd, "subject_id", entity.SubjectId);
AddParameter(cmd, "client_id", entity.ClientId);
AddParameter(cmd, "event_type", entity.EventType);
AddParameter(cmd, "outcome", entity.Outcome);
AddParameter(cmd, "reason", entity.Reason);
AddParameter(cmd, "ip_address", entity.IpAddress);
AddParameter(cmd, "user_agent", entity.UserAgent);
AddParameter(cmd, "occurred_at", entity.OccurredAt);
AddJsonbParameter(cmd, "properties", JsonSerializer.Serialize(entity.Properties, SerializerOptions));
},
cancellationToken: cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<LoginAttemptEntity>> ListRecentAsync(string subjectId, int limit, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, subject_id, client_id, event_type, outcome, reason, ip_address, user_agent, occurred_at, properties
FROM authority.login_attempts
WHERE subject_id = @subject_id
ORDER BY occurred_at DESC
LIMIT @limit
""";
return await QueryAsync(
tenantId: string.Empty,
sql: sql,
configureCommand: cmd =>
{
AddParameter(cmd, "subject_id", subjectId);
AddParameter(cmd, "limit", limit);
},
mapRow: MapLoginAttempt,
cancellationToken: cancellationToken).ConfigureAwait(false);
}
private static LoginAttemptEntity MapLoginAttempt(NpgsqlDataReader reader) => new()
{
Id = reader.GetString(0),
SubjectId = GetNullableString(reader, 1),
ClientId = GetNullableString(reader, 2),
EventType = reader.GetString(3),
Outcome = reader.GetString(4),
Reason = GetNullableString(reader, 5),
IpAddress = GetNullableString(reader, 6),
UserAgent = GetNullableString(reader, 7),
OccurredAt = reader.GetFieldValue<DateTimeOffset>(8),
Properties = DeserializeProperties(reader, 9)
};
private static IReadOnlyList<LoginAttemptPropertyEntity> DeserializeProperties(NpgsqlDataReader reader, int ordinal)
{
if (reader.IsDBNull(ordinal))
{
return Array.Empty<LoginAttemptPropertyEntity>();
}
var json = reader.GetString(ordinal);
List<LoginAttemptPropertyEntity>? parsed = JsonSerializer.Deserialize<List<LoginAttemptPropertyEntity>>(json, SerializerOptions);
return parsed ?? new List<LoginAttemptPropertyEntity>();
}
}

View File

@@ -0,0 +1,286 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Authority.Storage.Postgres.Models;
using StellaOps.Infrastructure.Postgres.Repositories;
namespace StellaOps.Authority.Storage.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for OpenIddict tokens and refresh tokens.
/// </summary>
public sealed class OidcTokenRepository : RepositoryBase<AuthorityDataSource>
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.General);
public OidcTokenRepository(AuthorityDataSource dataSource, ILogger<OidcTokenRepository> logger)
: base(dataSource, logger)
{
}
public async Task<OidcTokenEntity?> FindByTokenIdAsync(string tokenId, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, token_id, subject_id, client_id, token_type, reference_id, created_at, expires_at, redeemed_at, payload, properties
FROM authority.oidc_tokens
WHERE token_id = @token_id
""";
return await QuerySingleOrDefaultAsync(
tenantId: string.Empty,
sql: sql,
configureCommand: cmd => AddParameter(cmd, "token_id", tokenId),
mapRow: MapToken,
cancellationToken: cancellationToken).ConfigureAwait(false);
}
public async Task<OidcTokenEntity?> FindByReferenceIdAsync(string referenceId, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, token_id, subject_id, client_id, token_type, reference_id, created_at, expires_at, redeemed_at, payload, properties
FROM authority.oidc_tokens
WHERE reference_id = @reference_id
""";
return await QuerySingleOrDefaultAsync(
tenantId: string.Empty,
sql: sql,
configureCommand: cmd => AddParameter(cmd, "reference_id", referenceId),
mapRow: MapToken,
cancellationToken: cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<OidcTokenEntity>> ListBySubjectAsync(string subjectId, int limit, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, token_id, subject_id, client_id, token_type, reference_id, created_at, expires_at, redeemed_at, payload, properties
FROM authority.oidc_tokens
WHERE subject_id = @subject_id
ORDER BY created_at DESC
LIMIT @limit
""";
return await QueryAsync(
tenantId: string.Empty,
sql: sql,
configureCommand: cmd =>
{
AddParameter(cmd, "subject_id", subjectId);
AddParameter(cmd, "limit", limit);
},
mapRow: MapToken,
cancellationToken: cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<OidcTokenEntity>> ListAsync(int limit, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, token_id, subject_id, client_id, token_type, reference_id, created_at, expires_at, redeemed_at, payload, properties
FROM authority.oidc_tokens
ORDER BY created_at DESC
LIMIT @limit
""";
return await QueryAsync(
tenantId: string.Empty,
sql: sql,
configureCommand: cmd => AddParameter(cmd, "limit", limit),
mapRow: MapToken,
cancellationToken: cancellationToken).ConfigureAwait(false);
}
public async Task UpsertAsync(OidcTokenEntity entity, CancellationToken cancellationToken = default)
{
const string sql = """
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 (@id, @token_id, @subject_id, @client_id, @token_type, @reference_id, @created_at, @expires_at, @redeemed_at, @payload, @properties)
ON CONFLICT (token_id) DO UPDATE
SET subject_id = EXCLUDED.subject_id,
client_id = EXCLUDED.client_id,
token_type = EXCLUDED.token_type,
reference_id = EXCLUDED.reference_id,
created_at = EXCLUDED.created_at,
expires_at = EXCLUDED.expires_at,
redeemed_at = EXCLUDED.redeemed_at,
payload = EXCLUDED.payload,
properties = EXCLUDED.properties
""";
await ExecuteAsync(
tenantId: string.Empty,
sql: sql,
configureCommand: cmd =>
{
AddParameter(cmd, "id", entity.Id);
AddParameter(cmd, "token_id", entity.TokenId);
AddParameter(cmd, "subject_id", entity.SubjectId);
AddParameter(cmd, "client_id", entity.ClientId);
AddParameter(cmd, "token_type", entity.TokenType);
AddParameter(cmd, "reference_id", entity.ReferenceId);
AddParameter(cmd, "created_at", entity.CreatedAt);
AddParameter(cmd, "expires_at", entity.ExpiresAt);
AddParameter(cmd, "redeemed_at", entity.RedeemedAt);
AddParameter(cmd, "payload", entity.Payload);
AddJsonbParameter(cmd, "properties", JsonSerializer.Serialize(entity.Properties, SerializerOptions));
},
cancellationToken: cancellationToken).ConfigureAwait(false);
}
public async Task<bool> RevokeAsync(string tokenId, CancellationToken cancellationToken = default)
{
const string sql = "DELETE FROM authority.oidc_tokens WHERE token_id = @token_id";
var rows = await ExecuteAsync(
tenantId: string.Empty,
sql: sql,
configureCommand: cmd => AddParameter(cmd, "token_id", tokenId),
cancellationToken: cancellationToken).ConfigureAwait(false);
return rows > 0;
}
public async Task<int> RevokeBySubjectAsync(string subjectId, CancellationToken cancellationToken = default)
{
const string sql = "DELETE FROM authority.oidc_tokens WHERE subject_id = @subject_id";
return await ExecuteAsync(
tenantId: string.Empty,
sql: sql,
configureCommand: cmd => AddParameter(cmd, "subject_id", subjectId),
cancellationToken: cancellationToken).ConfigureAwait(false);
}
public async Task<int> RevokeByClientAsync(string clientId, CancellationToken cancellationToken = default)
{
const string sql = "DELETE FROM authority.oidc_tokens WHERE client_id = @client_id";
return await ExecuteAsync(
tenantId: string.Empty,
sql: sql,
configureCommand: cmd => AddParameter(cmd, "client_id", clientId),
cancellationToken: cancellationToken).ConfigureAwait(false);
}
public async Task<OidcRefreshTokenEntity?> FindRefreshTokenAsync(string tokenId, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, token_id, subject_id, client_id, handle, created_at, expires_at, consumed_at, payload
FROM authority.oidc_refresh_tokens
WHERE token_id = @token_id
""";
return await QuerySingleOrDefaultAsync(
tenantId: string.Empty,
sql: sql,
configureCommand: cmd => AddParameter(cmd, "token_id", tokenId),
mapRow: MapRefreshToken,
cancellationToken: cancellationToken).ConfigureAwait(false);
}
public async Task<OidcRefreshTokenEntity?> FindRefreshTokenByHandleAsync(string handle, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, token_id, subject_id, client_id, handle, created_at, expires_at, consumed_at, payload
FROM authority.oidc_refresh_tokens
WHERE handle = @handle
""";
return await QuerySingleOrDefaultAsync(
tenantId: string.Empty,
sql: sql,
configureCommand: cmd => AddParameter(cmd, "handle", handle),
mapRow: MapRefreshToken,
cancellationToken: cancellationToken).ConfigureAwait(false);
}
public async Task UpsertRefreshTokenAsync(OidcRefreshTokenEntity entity, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO authority.oidc_refresh_tokens
(id, token_id, subject_id, client_id, handle, created_at, expires_at, consumed_at, payload)
VALUES (@id, @token_id, @subject_id, @client_id, @handle, @created_at, @expires_at, @consumed_at, @payload)
ON CONFLICT (token_id) DO UPDATE
SET subject_id = EXCLUDED.subject_id,
client_id = EXCLUDED.client_id,
handle = EXCLUDED.handle,
created_at = EXCLUDED.created_at,
expires_at = EXCLUDED.expires_at,
consumed_at = EXCLUDED.consumed_at,
payload = EXCLUDED.payload
""";
await ExecuteAsync(
tenantId: string.Empty,
sql: sql,
configureCommand: cmd =>
{
AddParameter(cmd, "id", entity.Id);
AddParameter(cmd, "token_id", entity.TokenId);
AddParameter(cmd, "subject_id", entity.SubjectId);
AddParameter(cmd, "client_id", entity.ClientId);
AddParameter(cmd, "handle", entity.Handle);
AddParameter(cmd, "created_at", entity.CreatedAt);
AddParameter(cmd, "expires_at", entity.ExpiresAt);
AddParameter(cmd, "consumed_at", entity.ConsumedAt);
AddParameter(cmd, "payload", entity.Payload);
},
cancellationToken: cancellationToken).ConfigureAwait(false);
}
public async Task<bool> ConsumeRefreshTokenAsync(string tokenId, CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE authority.oidc_refresh_tokens
SET consumed_at = NOW()
WHERE token_id = @token_id AND consumed_at IS NULL
""";
var rows = await ExecuteAsync(
tenantId: string.Empty,
sql: sql,
configureCommand: cmd => AddParameter(cmd, "token_id", tokenId),
cancellationToken: cancellationToken).ConfigureAwait(false);
return rows > 0;
}
public async Task<int> RevokeRefreshTokensBySubjectAsync(string subjectId, CancellationToken cancellationToken = default)
{
const string sql = "DELETE FROM authority.oidc_refresh_tokens WHERE subject_id = @subject_id";
return await ExecuteAsync(
tenantId: string.Empty,
sql: sql,
configureCommand: cmd => AddParameter(cmd, "subject_id", subjectId),
cancellationToken: cancellationToken).ConfigureAwait(false);
}
private static OidcTokenEntity MapToken(NpgsqlDataReader reader) => new()
{
Id = reader.GetString(0),
TokenId = reader.GetString(1),
SubjectId = GetNullableString(reader, 2),
ClientId = GetNullableString(reader, 3),
TokenType = reader.GetString(4),
ReferenceId = GetNullableString(reader, 5),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(6),
ExpiresAt = reader.IsDBNull(7) ? null : reader.GetFieldValue<DateTimeOffset>(7),
RedeemedAt = reader.IsDBNull(8) ? null : reader.GetFieldValue<DateTimeOffset>(8),
Payload = GetNullableString(reader, 9),
Properties = DeserializeProperties(reader, 10)
};
private static OidcRefreshTokenEntity MapRefreshToken(NpgsqlDataReader reader) => new()
{
Id = reader.GetString(0),
TokenId = reader.GetString(1),
SubjectId = GetNullableString(reader, 2),
ClientId = GetNullableString(reader, 3),
Handle = GetNullableString(reader, 4),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(5),
ExpiresAt = reader.IsDBNull(6) ? null : reader.GetFieldValue<DateTimeOffset>(6),
ConsumedAt = reader.IsDBNull(7) ? null : reader.GetFieldValue<DateTimeOffset>(7),
Payload = GetNullableString(reader, 8)
};
private static IReadOnlyDictionary<string, string> DeserializeProperties(NpgsqlDataReader reader, int ordinal)
{
if (reader.IsDBNull(ordinal))
{
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
}
var json = reader.GetString(ordinal);
return JsonSerializer.Deserialize<Dictionary<string, string>>(json, SerializerOptions) ??
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
}
}

View File

@@ -0,0 +1,71 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Authority.Storage.Postgres.Models;
using StellaOps.Infrastructure.Postgres.Repositories;
namespace StellaOps.Authority.Storage.Postgres.Repositories;
/// <summary>
/// Repository that persists revocation export sequence state.
/// </summary>
public sealed class RevocationExportStateRepository : RepositoryBase<AuthorityDataSource>
{
public RevocationExportStateRepository(AuthorityDataSource dataSource, ILogger<RevocationExportStateRepository> logger)
: base(dataSource, logger)
{
}
public async Task<RevocationExportStateEntity?> GetAsync(CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, sequence, bundle_id, issued_at
FROM authority.revocation_export_state
WHERE id = 1
""";
return await QuerySingleOrDefaultAsync(
tenantId: string.Empty,
sql: sql,
configureCommand: static _ => { },
mapRow: MapState,
cancellationToken: cancellationToken).ConfigureAwait(false);
}
public async Task UpsertAsync(long expectedSequence, RevocationExportStateEntity entity, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO authority.revocation_export_state (id, sequence, bundle_id, issued_at)
VALUES (1, @sequence, @bundle_id, @issued_at)
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 = @expected_sequence
""";
var affected = await ExecuteAsync(
tenantId: string.Empty,
sql: sql,
configureCommand: cmd =>
{
AddParameter(cmd, "sequence", entity.Sequence);
AddParameter(cmd, "bundle_id", entity.BundleId);
AddParameter(cmd, "issued_at", entity.IssuedAt);
AddParameter(cmd, "expected_sequence", expectedSequence);
},
cancellationToken: cancellationToken).ConfigureAwait(false);
if (affected == 0)
{
throw new InvalidOperationException($"Revocation export state update rejected. Expected sequence {expectedSequence}.");
}
}
private static RevocationExportStateEntity MapState(NpgsqlDataReader reader) => new()
{
Id = reader.GetInt32(0),
Sequence = reader.GetInt64(1),
BundleId = reader.IsDBNull(2) ? null : reader.GetString(2),
IssuedAt = reader.IsDBNull(3) ? null : reader.GetFieldValue<DateTimeOffset>(3)
};
}

View File

@@ -0,0 +1,121 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Authority.Storage.Postgres.Models;
using StellaOps.Infrastructure.Postgres.Repositories;
namespace StellaOps.Authority.Storage.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for revocations.
/// </summary>
public sealed class RevocationRepository : RepositoryBase<AuthorityDataSource>
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.General);
public RevocationRepository(AuthorityDataSource dataSource, ILogger<RevocationRepository> logger)
: base(dataSource, logger)
{
}
public async Task UpsertAsync(RevocationEntity entity, CancellationToken cancellationToken = default)
{
const string sql = """
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 (@id, @category, @revocation_id, @subject_id, @client_id, @token_id, @reason, @reason_description, @revoked_at, @effective_at, @expires_at, @metadata)
ON CONFLICT (category, revocation_id) DO UPDATE
SET subject_id = EXCLUDED.subject_id,
client_id = EXCLUDED.client_id,
token_id = EXCLUDED.token_id,
reason = EXCLUDED.reason,
reason_description = EXCLUDED.reason_description,
revoked_at = EXCLUDED.revoked_at,
effective_at = EXCLUDED.effective_at,
expires_at = EXCLUDED.expires_at,
metadata = EXCLUDED.metadata
""";
await ExecuteAsync(
tenantId: string.Empty,
sql: sql,
configureCommand: cmd =>
{
AddParameter(cmd, "id", entity.Id);
AddParameter(cmd, "category", entity.Category);
AddParameter(cmd, "revocation_id", entity.RevocationId);
AddParameter(cmd, "subject_id", entity.SubjectId);
AddParameter(cmd, "client_id", entity.ClientId);
AddParameter(cmd, "token_id", entity.TokenId);
AddParameter(cmd, "reason", entity.Reason);
AddParameter(cmd, "reason_description", entity.ReasonDescription);
AddParameter(cmd, "revoked_at", entity.RevokedAt);
AddParameter(cmd, "effective_at", entity.EffectiveAt);
AddParameter(cmd, "expires_at", entity.ExpiresAt);
AddJsonbParameter(cmd, "metadata", JsonSerializer.Serialize(entity.Metadata, SerializerOptions));
},
cancellationToken: cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<RevocationEntity>> GetActiveAsync(DateTimeOffset asOf, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, category, revocation_id, subject_id, client_id, token_id, reason, reason_description, revoked_at, effective_at, expires_at, metadata
FROM authority.revocations
WHERE effective_at <= @as_of
AND (expires_at IS NULL OR expires_at > @as_of)
""";
return await QueryAsync(
tenantId: string.Empty,
sql: sql,
configureCommand: cmd => AddParameter(cmd, "as_of", asOf),
mapRow: MapRevocation,
cancellationToken: cancellationToken).ConfigureAwait(false);
}
public async Task RemoveAsync(string category, string revocationId, CancellationToken cancellationToken = default)
{
const string sql = """
DELETE FROM authority.revocations
WHERE category = @category AND revocation_id = @revocation_id
""";
await ExecuteAsync(
tenantId: string.Empty,
sql: sql,
configureCommand: cmd =>
{
AddParameter(cmd, "category", category);
AddParameter(cmd, "revocation_id", revocationId);
},
cancellationToken: cancellationToken).ConfigureAwait(false);
}
private static RevocationEntity MapRevocation(NpgsqlDataReader reader) => new()
{
Id = reader.GetString(0),
Category = reader.GetString(1),
RevocationId = reader.GetString(2),
SubjectId = reader.IsDBNull(3) ? string.Empty : reader.GetString(3),
ClientId = GetNullableString(reader, 4),
TokenId = GetNullableString(reader, 5),
Reason = reader.GetString(6),
ReasonDescription = GetNullableString(reader, 7),
RevokedAt = reader.GetFieldValue<DateTimeOffset>(8),
EffectiveAt = reader.GetFieldValue<DateTimeOffset>(9),
ExpiresAt = reader.IsDBNull(10) ? null : reader.GetFieldValue<DateTimeOffset>(10),
Metadata = DeserializeMetadata(reader, 11)
};
private static IReadOnlyDictionary<string, string?> DeserializeMetadata(NpgsqlDataReader reader, int ordinal)
{
if (reader.IsDBNull(ordinal))
{
return new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
}
var json = reader.GetString(ordinal);
Dictionary<string, string?>? parsed = JsonSerializer.Deserialize<Dictionary<string, string?>>(json, SerializerOptions);
return parsed ?? new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
}
}

View File

@@ -0,0 +1,141 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Authority.Storage.Postgres.Models;
using StellaOps.Infrastructure.Postgres.Repositories;
namespace StellaOps.Authority.Storage.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for service accounts.
/// </summary>
public sealed class ServiceAccountRepository : RepositoryBase<AuthorityDataSource>
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.General);
public ServiceAccountRepository(AuthorityDataSource dataSource, ILogger<ServiceAccountRepository> logger)
: base(dataSource, logger)
{
}
public async Task<ServiceAccountEntity?> FindByAccountIdAsync(string accountId, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, account_id, tenant, display_name, description, enabled,
allowed_scopes, authorized_clients, attributes, created_at, updated_at
FROM authority.service_accounts
WHERE account_id = @account_id
""";
return await QuerySingleOrDefaultAsync(
tenantId: string.Empty,
sql: sql,
configureCommand: cmd => AddParameter(cmd, "account_id", accountId),
mapRow: MapServiceAccount,
cancellationToken: cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<ServiceAccountEntity>> ListAsync(string? tenant, CancellationToken cancellationToken = default)
{
var sql = """
SELECT id, account_id, tenant, display_name, description, enabled,
allowed_scopes, authorized_clients, attributes, created_at, updated_at
FROM authority.service_accounts
""";
if (!string.IsNullOrWhiteSpace(tenant))
{
sql += " WHERE tenant = @tenant";
}
return await QueryAsync(
tenantId: string.Empty,
sql: sql,
configureCommand: cmd =>
{
if (!string.IsNullOrWhiteSpace(tenant))
{
AddParameter(cmd, "tenant", tenant);
}
},
mapRow: MapServiceAccount,
cancellationToken: cancellationToken).ConfigureAwait(false);
}
public async Task UpsertAsync(ServiceAccountEntity entity, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO authority.service_accounts
(id, account_id, tenant, display_name, description, enabled, allowed_scopes, authorized_clients, attributes, created_at, updated_at)
VALUES (@id, @account_id, @tenant, @display_name, @description, @enabled, @allowed_scopes, @authorized_clients, @attributes, @created_at, @updated_at)
ON CONFLICT (account_id) DO UPDATE
SET tenant = EXCLUDED.tenant,
display_name = EXCLUDED.display_name,
description = EXCLUDED.description,
enabled = EXCLUDED.enabled,
allowed_scopes = EXCLUDED.allowed_scopes,
authorized_clients = EXCLUDED.authorized_clients,
attributes = EXCLUDED.attributes,
updated_at = EXCLUDED.updated_at
""";
await ExecuteAsync(
tenantId: string.Empty,
sql: sql,
configureCommand: cmd =>
{
AddParameter(cmd, "id", entity.Id);
AddParameter(cmd, "account_id", entity.AccountId);
AddParameter(cmd, "tenant", entity.Tenant);
AddParameter(cmd, "display_name", entity.DisplayName);
AddParameter(cmd, "description", entity.Description);
AddParameter(cmd, "enabled", entity.Enabled);
AddParameter(cmd, "allowed_scopes", entity.AllowedScopes.ToArray());
AddParameter(cmd, "authorized_clients", entity.AuthorizedClients.ToArray());
AddJsonbParameter(cmd, "attributes", JsonSerializer.Serialize(entity.Attributes, SerializerOptions));
AddParameter(cmd, "created_at", entity.CreatedAt);
AddParameter(cmd, "updated_at", entity.UpdatedAt);
},
cancellationToken: cancellationToken).ConfigureAwait(false);
}
public async Task<bool> DeleteAsync(string accountId, CancellationToken cancellationToken = default)
{
const string sql = """
DELETE FROM authority.service_accounts WHERE account_id = @account_id
""";
var rows = await ExecuteAsync(
tenantId: string.Empty,
sql: sql,
configureCommand: cmd => AddParameter(cmd, "account_id", accountId),
cancellationToken: cancellationToken).ConfigureAwait(false);
return rows > 0;
}
private static ServiceAccountEntity MapServiceAccount(NpgsqlDataReader reader) => new()
{
Id = reader.GetString(0),
AccountId = reader.GetString(1),
Tenant = reader.GetString(2),
DisplayName = reader.GetString(3),
Description = GetNullableString(reader, 4),
Enabled = reader.GetBoolean(5),
AllowedScopes = reader.GetFieldValue<string[]>(6),
AuthorizedClients = reader.GetFieldValue<string[]>(7),
Attributes = ReadDictionary(reader, 8),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(9),
UpdatedAt = reader.GetFieldValue<DateTimeOffset>(10)
};
private static IReadOnlyDictionary<string, List<string>> ReadDictionary(NpgsqlDataReader reader, int ordinal)
{
if (reader.IsDBNull(ordinal))
{
return new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);
}
var json = reader.GetString(ordinal);
var dictionary = System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, List<string>>>(json) ??
new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);
return dictionary;
}
}

View File

@@ -66,5 +66,15 @@ public static class ServiceCollectionExtensions
services.AddScoped<IAuditRepository>(sp => sp.GetRequiredService<AuditRepository>());
services.AddScoped<ITokenRepository>(sp => sp.GetRequiredService<TokenRepository>());
services.AddScoped<IRefreshTokenRepository>(sp => sp.GetRequiredService<RefreshTokenRepository>());
// Mongo-store equivalents (PostgreSQL-backed)
services.AddScoped<BootstrapInviteRepository>();
services.AddScoped<ServiceAccountRepository>();
services.AddScoped<ClientRepository>();
services.AddScoped<RevocationRepository>();
services.AddScoped<LoginAttemptRepository>();
services.AddScoped<OidcTokenRepository>();
services.AddScoped<AirgapAuditRepository>();
services.AddScoped<RevocationExportStateRepository>();
}
}