feat: Implement PostgreSQL repositories for various entities
- 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:
@@ -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
|
||||
);
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user