up
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
Concelier Attestation Tests / attestation-tests (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-11-28 20:55:22 +02:00
parent d040c001ac
commit 2548abc56f
231 changed files with 47468 additions and 68 deletions

View File

@@ -0,0 +1,39 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Infrastructure.Postgres.Connections;
using StellaOps.Infrastructure.Postgres.Options;
namespace StellaOps.Authority.Storage.Postgres;
/// <summary>
/// PostgreSQL data source for the Authority module.
/// Manages connections with tenant context for authentication and authorization data.
/// </summary>
public sealed class AuthorityDataSource : DataSourceBase
{
/// <summary>
/// Default schema name for Authority tables.
/// </summary>
public const string DefaultSchemaName = "auth";
/// <summary>
/// Creates a new Authority data source.
/// </summary>
public AuthorityDataSource(IOptions<PostgresOptions> options, ILogger<AuthorityDataSource> logger)
: base(CreateOptions(options.Value), logger)
{
}
/// <inheritdoc />
protected override string ModuleName => "Authority";
private static PostgresOptions CreateOptions(PostgresOptions baseOptions)
{
// Use default schema if not specified
if (string.IsNullOrWhiteSpace(baseOptions.SchemaName))
{
baseOptions.SchemaName = DefaultSchemaName;
}
return baseOptions;
}
}

View File

@@ -0,0 +1,232 @@
-- Authority Schema Migration 001: Initial Schema
-- Creates the authority schema for IAM, tenants, users, and tokens
-- Create schema
CREATE SCHEMA IF NOT EXISTS authority;
-- Tenants table
CREATE TABLE IF NOT EXISTS authority.tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
display_name TEXT,
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'suspended', 'deleted')),
settings JSONB NOT NULL DEFAULT '{}',
metadata JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by TEXT,
updated_by TEXT
);
CREATE INDEX idx_tenants_status ON authority.tenants(status);
CREATE INDEX idx_tenants_created_at ON authority.tenants(created_at);
-- Users table
CREATE TABLE IF NOT EXISTS authority.users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL REFERENCES authority.tenants(tenant_id),
username TEXT NOT NULL,
email TEXT,
display_name TEXT,
password_hash TEXT,
password_salt TEXT,
password_algorithm TEXT DEFAULT 'argon2id',
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive', 'locked', 'deleted')),
email_verified BOOLEAN NOT NULL DEFAULT FALSE,
mfa_enabled BOOLEAN NOT NULL DEFAULT FALSE,
mfa_secret TEXT,
failed_login_attempts INT NOT NULL DEFAULT 0,
last_login_at TIMESTAMPTZ,
last_password_change_at TIMESTAMPTZ,
password_expires_at TIMESTAMPTZ,
metadata JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by TEXT,
updated_by TEXT,
UNIQUE(tenant_id, username),
UNIQUE(tenant_id, email)
);
CREATE INDEX idx_users_tenant_id ON authority.users(tenant_id);
CREATE INDEX idx_users_status ON authority.users(tenant_id, status);
CREATE INDEX idx_users_email ON authority.users(tenant_id, email);
-- Roles table
CREATE TABLE IF NOT EXISTS authority.roles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL REFERENCES authority.tenants(tenant_id),
name TEXT NOT NULL,
display_name TEXT,
description TEXT,
is_system BOOLEAN NOT NULL DEFAULT FALSE,
metadata JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(tenant_id, name)
);
CREATE INDEX idx_roles_tenant_id ON authority.roles(tenant_id);
-- Permissions table
CREATE TABLE IF NOT EXISTS authority.permissions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL REFERENCES authority.tenants(tenant_id),
name TEXT NOT NULL,
resource TEXT NOT NULL,
action TEXT NOT NULL,
description TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(tenant_id, name)
);
CREATE INDEX idx_permissions_tenant_id ON authority.permissions(tenant_id);
CREATE INDEX idx_permissions_resource ON authority.permissions(tenant_id, resource);
-- Role-Permission assignments
CREATE TABLE IF NOT EXISTS authority.role_permissions (
role_id UUID NOT NULL REFERENCES authority.roles(id) ON DELETE CASCADE,
permission_id UUID NOT NULL REFERENCES authority.permissions(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (role_id, permission_id)
);
-- User-Role assignments
CREATE TABLE IF NOT EXISTS authority.user_roles (
user_id UUID NOT NULL REFERENCES authority.users(id) ON DELETE CASCADE,
role_id UUID NOT NULL REFERENCES authority.roles(id) ON DELETE CASCADE,
granted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
granted_by TEXT,
expires_at TIMESTAMPTZ,
PRIMARY KEY (user_id, role_id)
);
-- API Keys table
CREATE TABLE IF NOT EXISTS authority.api_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL REFERENCES authority.tenants(tenant_id),
user_id UUID REFERENCES authority.users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
key_hash TEXT NOT NULL,
key_prefix TEXT NOT NULL,
scopes TEXT[] NOT NULL DEFAULT '{}',
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'revoked', 'expired')),
last_used_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ,
metadata JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
revoked_at TIMESTAMPTZ,
revoked_by TEXT
);
CREATE INDEX idx_api_keys_tenant_id ON authority.api_keys(tenant_id);
CREATE INDEX idx_api_keys_key_prefix ON authority.api_keys(key_prefix);
CREATE INDEX idx_api_keys_user_id ON authority.api_keys(user_id);
CREATE INDEX idx_api_keys_status ON authority.api_keys(tenant_id, status);
-- Tokens table (access tokens)
CREATE TABLE IF NOT EXISTS authority.tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL REFERENCES authority.tenants(tenant_id),
user_id UUID REFERENCES authority.users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
token_type TEXT NOT NULL DEFAULT 'access' CHECK (token_type IN ('access', 'refresh', 'api')),
scopes TEXT[] NOT NULL DEFAULT '{}',
client_id TEXT,
issued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ,
revoked_by TEXT,
metadata JSONB NOT NULL DEFAULT '{}'
);
CREATE INDEX idx_tokens_tenant_id ON authority.tokens(tenant_id);
CREATE INDEX idx_tokens_user_id ON authority.tokens(user_id);
CREATE INDEX idx_tokens_expires_at ON authority.tokens(expires_at);
CREATE INDEX idx_tokens_token_hash ON authority.tokens(token_hash);
-- Refresh Tokens table
CREATE TABLE IF NOT EXISTS authority.refresh_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL REFERENCES authority.tenants(tenant_id),
user_id UUID NOT NULL REFERENCES authority.users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
access_token_id UUID REFERENCES authority.tokens(id) ON DELETE SET NULL,
client_id TEXT,
issued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ,
revoked_by TEXT,
replaced_by UUID,
metadata JSONB NOT NULL DEFAULT '{}'
);
CREATE INDEX idx_refresh_tokens_tenant_id ON authority.refresh_tokens(tenant_id);
CREATE INDEX idx_refresh_tokens_user_id ON authority.refresh_tokens(user_id);
CREATE INDEX idx_refresh_tokens_expires_at ON authority.refresh_tokens(expires_at);
-- Sessions table
CREATE TABLE IF NOT EXISTS authority.sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL REFERENCES authority.tenants(tenant_id),
user_id UUID NOT NULL REFERENCES authority.users(id) ON DELETE CASCADE,
session_token_hash TEXT NOT NULL UNIQUE,
ip_address TEXT,
user_agent TEXT,
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_activity_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
ended_at TIMESTAMPTZ,
end_reason TEXT,
metadata JSONB NOT NULL DEFAULT '{}'
);
CREATE INDEX idx_sessions_tenant_id ON authority.sessions(tenant_id);
CREATE INDEX idx_sessions_user_id ON authority.sessions(user_id);
CREATE INDEX idx_sessions_expires_at ON authority.sessions(expires_at);
-- Audit log table
CREATE TABLE IF NOT EXISTS authority.audit (
id BIGSERIAL PRIMARY KEY,
tenant_id TEXT NOT NULL,
user_id UUID,
action TEXT NOT NULL,
resource_type TEXT NOT NULL,
resource_id TEXT,
old_value JSONB,
new_value JSONB,
ip_address TEXT,
user_agent TEXT,
correlation_id TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_audit_tenant_id ON authority.audit(tenant_id);
CREATE INDEX idx_audit_user_id ON authority.audit(user_id);
CREATE INDEX idx_audit_action ON authority.audit(action);
CREATE INDEX idx_audit_resource ON authority.audit(resource_type, resource_id);
CREATE INDEX idx_audit_created_at ON authority.audit(created_at);
CREATE INDEX idx_audit_correlation_id ON authority.audit(correlation_id);
-- Function to update updated_at timestamp
CREATE OR REPLACE FUNCTION authority.update_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Triggers for updated_at
CREATE TRIGGER trg_tenants_updated_at
BEFORE UPDATE ON authority.tenants
FOR EACH ROW EXECUTE FUNCTION authority.update_updated_at();
CREATE TRIGGER trg_users_updated_at
BEFORE UPDATE ON authority.users
FOR EACH ROW EXECUTE FUNCTION authority.update_updated_at();
CREATE TRIGGER trg_roles_updated_at
BEFORE UPDATE ON authority.roles
FOR EACH ROW EXECUTE FUNCTION authority.update_updated_at();

View File

@@ -0,0 +1,62 @@
namespace StellaOps.Authority.Storage.Postgres.Models;
/// <summary>
/// Represents a tenant entity in the auth schema.
/// </summary>
public sealed class TenantEntity
{
/// <summary>
/// Unique tenant identifier.
/// </summary>
public required Guid Id { get; init; }
/// <summary>
/// Tenant slug/key (unique).
/// </summary>
public required string Slug { get; init; }
/// <summary>
/// Display name.
/// </summary>
public required string Name { get; init; }
/// <summary>
/// Optional description.
/// </summary>
public string? Description { get; init; }
/// <summary>
/// Contact email for the tenant.
/// </summary>
public string? ContactEmail { get; init; }
/// <summary>
/// Tenant is enabled.
/// </summary>
public bool Enabled { get; init; } = true;
/// <summary>
/// Tenant settings as JSON.
/// </summary>
public string Settings { get; init; } = "{}";
/// <summary>
/// Tenant metadata as JSON.
/// </summary>
public string Metadata { get; init; } = "{}";
/// <summary>
/// When the tenant was created.
/// </summary>
public DateTimeOffset CreatedAt { get; init; }
/// <summary>
/// When the tenant was last updated.
/// </summary>
public DateTimeOffset UpdatedAt { get; init; }
/// <summary>
/// User who created the tenant.
/// </summary>
public string? CreatedBy { get; init; }
}

View File

@@ -0,0 +1,112 @@
namespace StellaOps.Authority.Storage.Postgres.Models;
/// <summary>
/// Represents a user entity in the auth schema.
/// </summary>
public sealed class UserEntity
{
/// <summary>
/// Unique user identifier.
/// </summary>
public required Guid Id { get; init; }
/// <summary>
/// Tenant this user belongs to.
/// </summary>
public required string TenantId { get; init; }
/// <summary>
/// Username (unique per tenant).
/// </summary>
public required string Username { get; init; }
/// <summary>
/// Email address (unique per tenant).
/// </summary>
public required string Email { get; init; }
/// <summary>
/// User's display name.
/// </summary>
public string? DisplayName { get; init; }
/// <summary>
/// Argon2id password hash.
/// </summary>
public string? PasswordHash { get; init; }
/// <summary>
/// Password salt.
/// </summary>
public string? PasswordSalt { get; init; }
/// <summary>
/// User is enabled.
/// </summary>
public bool Enabled { get; init; } = true;
/// <summary>
/// Email has been verified.
/// </summary>
public bool EmailVerified { get; init; }
/// <summary>
/// MFA is enabled for this user.
/// </summary>
public bool MfaEnabled { get; init; }
/// <summary>
/// MFA secret (encrypted).
/// </summary>
public string? MfaSecret { get; init; }
/// <summary>
/// MFA backup codes (encrypted JSON array).
/// </summary>
public string? MfaBackupCodes { get; init; }
/// <summary>
/// Number of failed login attempts.
/// </summary>
public int FailedLoginAttempts { get; init; }
/// <summary>
/// Account locked until this time.
/// </summary>
public DateTimeOffset? LockedUntil { get; init; }
/// <summary>
/// Last successful login time.
/// </summary>
public DateTimeOffset? LastLoginAt { get; init; }
/// <summary>
/// When the password was last changed.
/// </summary>
public DateTimeOffset? PasswordChangedAt { get; init; }
/// <summary>
/// User settings as JSON.
/// </summary>
public string Settings { get; init; } = "{}";
/// <summary>
/// User metadata as JSON.
/// </summary>
public string Metadata { get; init; } = "{}";
/// <summary>
/// When the user was created.
/// </summary>
public DateTimeOffset CreatedAt { get; init; }
/// <summary>
/// When the user was last updated.
/// </summary>
public DateTimeOffset UpdatedAt { get; init; }
/// <summary>
/// User who created this user.
/// </summary>
public string? CreatedBy { get; init; }
}

View File

@@ -0,0 +1,48 @@
using StellaOps.Authority.Storage.Postgres.Models;
namespace StellaOps.Authority.Storage.Postgres.Repositories;
/// <summary>
/// Repository interface for tenant operations.
/// </summary>
public interface ITenantRepository
{
/// <summary>
/// Creates a new tenant.
/// </summary>
Task<TenantEntity> CreateAsync(TenantEntity tenant, CancellationToken cancellationToken = default);
/// <summary>
/// Gets a tenant by ID.
/// </summary>
Task<TenantEntity?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
/// <summary>
/// Gets a tenant by slug.
/// </summary>
Task<TenantEntity?> GetBySlugAsync(string slug, CancellationToken cancellationToken = default);
/// <summary>
/// Gets all tenants with optional filtering.
/// </summary>
Task<IReadOnlyList<TenantEntity>> GetAllAsync(
bool? enabled = null,
int limit = 100,
int offset = 0,
CancellationToken cancellationToken = default);
/// <summary>
/// Updates a tenant.
/// </summary>
Task<bool> UpdateAsync(TenantEntity tenant, CancellationToken cancellationToken = default);
/// <summary>
/// Deletes a tenant.
/// </summary>
Task<bool> DeleteAsync(Guid id, CancellationToken cancellationToken = default);
/// <summary>
/// Checks if a tenant slug exists.
/// </summary>
Task<bool> SlugExistsAsync(string slug, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,76 @@
using StellaOps.Authority.Storage.Postgres.Models;
namespace StellaOps.Authority.Storage.Postgres.Repositories;
/// <summary>
/// Repository interface for user operations.
/// </summary>
public interface IUserRepository
{
/// <summary>
/// Creates a new user.
/// </summary>
Task<UserEntity> CreateAsync(UserEntity user, CancellationToken cancellationToken = default);
/// <summary>
/// Gets a user by ID.
/// </summary>
Task<UserEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
/// <summary>
/// Gets a user by username.
/// </summary>
Task<UserEntity?> GetByUsernameAsync(string tenantId, string username, CancellationToken cancellationToken = default);
/// <summary>
/// Gets a user by email.
/// </summary>
Task<UserEntity?> GetByEmailAsync(string tenantId, string email, CancellationToken cancellationToken = default);
/// <summary>
/// Gets all users for a tenant with optional filtering.
/// </summary>
Task<IReadOnlyList<UserEntity>> GetAllAsync(
string tenantId,
bool? enabled = null,
int limit = 100,
int offset = 0,
CancellationToken cancellationToken = default);
/// <summary>
/// Updates a user.
/// </summary>
Task<bool> UpdateAsync(UserEntity user, CancellationToken cancellationToken = default);
/// <summary>
/// Deletes a user.
/// </summary>
Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
/// <summary>
/// Updates the user's password hash.
/// </summary>
Task<bool> UpdatePasswordAsync(
string tenantId,
Guid userId,
string passwordHash,
string passwordSalt,
CancellationToken cancellationToken = default);
/// <summary>
/// Records a failed login attempt.
/// </summary>
Task<int> RecordFailedLoginAsync(
string tenantId,
Guid userId,
DateTimeOffset? lockUntil = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Records a successful login.
/// </summary>
Task RecordSuccessfulLoginAsync(
string tenantId,
Guid userId,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,194 @@
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 tenant operations.
/// </summary>
public sealed class TenantRepository : RepositoryBase<AuthorityDataSource>, ITenantRepository
{
private const string SystemTenantId = "_system";
/// <summary>
/// Creates a new tenant repository.
/// </summary>
public TenantRepository(AuthorityDataSource dataSource, ILogger<TenantRepository> logger)
: base(dataSource, logger)
{
}
/// <inheritdoc />
public async Task<TenantEntity> CreateAsync(TenantEntity tenant, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO auth.tenants (id, slug, name, description, contact_email, enabled, settings, metadata, created_by)
VALUES (@id, @slug, @name, @description, @contact_email, @enabled, @settings::jsonb, @metadata::jsonb, @created_by)
RETURNING id, slug, name, description, contact_email, enabled, settings::text, metadata::text, created_at, updated_at, created_by
""";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "id", tenant.Id);
AddParameter(command, "slug", tenant.Slug);
AddParameter(command, "name", tenant.Name);
AddParameter(command, "description", tenant.Description);
AddParameter(command, "contact_email", tenant.ContactEmail);
AddParameter(command, "enabled", tenant.Enabled);
AddJsonbParameter(command, "settings", tenant.Settings);
AddJsonbParameter(command, "metadata", tenant.Metadata);
AddParameter(command, "created_by", tenant.CreatedBy);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
return MapTenant(reader);
}
/// <inheritdoc />
public async Task<TenantEntity?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, slug, name, description, contact_email, enabled, settings::text, metadata::text, created_at, updated_at, created_by
FROM auth.tenants
WHERE id = @id
""";
return await QuerySingleOrDefaultAsync(
SystemTenantId,
sql,
cmd => AddParameter(cmd, "id", id),
MapTenant,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<TenantEntity?> GetBySlugAsync(string slug, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, slug, name, description, contact_email, enabled, settings::text, metadata::text, created_at, updated_at, created_by
FROM auth.tenants
WHERE slug = @slug
""";
return await QuerySingleOrDefaultAsync(
SystemTenantId,
sql,
cmd => AddParameter(cmd, "slug", slug),
MapTenant,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<IReadOnlyList<TenantEntity>> GetAllAsync(
bool? enabled = null,
int limit = 100,
int offset = 0,
CancellationToken cancellationToken = default)
{
var sql = """
SELECT id, slug, name, description, contact_email, enabled, settings::text, metadata::text, created_at, updated_at, created_by
FROM auth.tenants
""";
if (enabled.HasValue)
{
sql += " WHERE enabled = @enabled";
}
sql += " ORDER BY name, id LIMIT @limit OFFSET @offset";
return await QueryAsync(
SystemTenantId,
sql,
cmd =>
{
if (enabled.HasValue)
{
AddParameter(cmd, "enabled", enabled.Value);
}
AddParameter(cmd, "limit", limit);
AddParameter(cmd, "offset", offset);
},
MapTenant,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<bool> UpdateAsync(TenantEntity tenant, CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE auth.tenants
SET name = @name,
description = @description,
contact_email = @contact_email,
enabled = @enabled,
settings = @settings::jsonb,
metadata = @metadata::jsonb
WHERE id = @id
""";
var rows = await ExecuteAsync(
SystemTenantId,
sql,
cmd =>
{
AddParameter(cmd, "id", tenant.Id);
AddParameter(cmd, "name", tenant.Name);
AddParameter(cmd, "description", tenant.Description);
AddParameter(cmd, "contact_email", tenant.ContactEmail);
AddParameter(cmd, "enabled", tenant.Enabled);
AddJsonbParameter(cmd, "settings", tenant.Settings);
AddJsonbParameter(cmd, "metadata", tenant.Metadata);
},
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
/// <inheritdoc />
public async Task<bool> DeleteAsync(Guid id, CancellationToken cancellationToken = default)
{
const string sql = "DELETE FROM auth.tenants WHERE id = @id";
var rows = await ExecuteAsync(
SystemTenantId,
sql,
cmd => AddParameter(cmd, "id", id),
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
/// <inheritdoc />
public async Task<bool> SlugExistsAsync(string slug, CancellationToken cancellationToken = default)
{
const string sql = "SELECT EXISTS(SELECT 1 FROM auth.tenants WHERE slug = @slug)";
var result = await ExecuteScalarAsync<bool>(
SystemTenantId,
sql,
cmd => AddParameter(cmd, "slug", slug),
cancellationToken).ConfigureAwait(false);
return result;
}
private static TenantEntity MapTenant(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(0),
Slug = reader.GetString(1),
Name = reader.GetString(2),
Description = GetNullableString(reader, 3),
ContactEmail = GetNullableString(reader, 4),
Enabled = reader.GetBoolean(5),
Settings = reader.GetString(6),
Metadata = reader.GetString(7),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(8),
UpdatedAt = reader.GetFieldValue<DateTimeOffset>(9),
CreatedBy = GetNullableString(reader, 10)
};
}

View File

@@ -0,0 +1,353 @@
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 user operations.
/// </summary>
public sealed class UserRepository : RepositoryBase<AuthorityDataSource>, IUserRepository
{
/// <summary>
/// Creates a new user repository.
/// </summary>
public UserRepository(AuthorityDataSource dataSource, ILogger<UserRepository> logger)
: base(dataSource, logger)
{
}
/// <inheritdoc />
public async Task<UserEntity> CreateAsync(UserEntity user, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO auth.users (
id, tenant_id, username, email, display_name, password_hash, password_salt,
enabled, email_verified, mfa_enabled, mfa_secret, mfa_backup_codes,
settings, metadata, created_by
)
VALUES (
@id, @tenant_id, @username, @email, @display_name, @password_hash, @password_salt,
@enabled, @email_verified, @mfa_enabled, @mfa_secret, @mfa_backup_codes,
@settings::jsonb, @metadata::jsonb, @created_by
)
RETURNING id, tenant_id, username, email, display_name, password_hash, password_salt,
enabled, email_verified, mfa_enabled, mfa_secret, mfa_backup_codes,
failed_login_attempts, locked_until, last_login_at, password_changed_at,
settings::text, metadata::text, created_at, updated_at, created_by
""";
await using var connection = await DataSource.OpenConnectionAsync(user.TenantId, "writer", cancellationToken)
.ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddUserParameters(command, user);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
return MapUser(reader);
}
/// <inheritdoc />
public async Task<UserEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, username, email, display_name, password_hash, password_salt,
enabled, email_verified, mfa_enabled, mfa_secret, mfa_backup_codes,
failed_login_attempts, locked_until, last_login_at, password_changed_at,
settings::text, metadata::text, created_at, updated_at, created_by
FROM auth.users
WHERE tenant_id = @tenant_id AND id = @id
""";
return await QuerySingleOrDefaultAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
},
MapUser,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<UserEntity?> GetByUsernameAsync(string tenantId, string username, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, username, email, display_name, password_hash, password_salt,
enabled, email_verified, mfa_enabled, mfa_secret, mfa_backup_codes,
failed_login_attempts, locked_until, last_login_at, password_changed_at,
settings::text, metadata::text, created_at, updated_at, created_by
FROM auth.users
WHERE tenant_id = @tenant_id AND username = @username
""";
return await QuerySingleOrDefaultAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "username", username);
},
MapUser,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<UserEntity?> GetByEmailAsync(string tenantId, string email, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, username, email, display_name, password_hash, password_salt,
enabled, email_verified, mfa_enabled, mfa_secret, mfa_backup_codes,
failed_login_attempts, locked_until, last_login_at, password_changed_at,
settings::text, metadata::text, created_at, updated_at, created_by
FROM auth.users
WHERE tenant_id = @tenant_id AND email = @email
""";
return await QuerySingleOrDefaultAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "email", email);
},
MapUser,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<IReadOnlyList<UserEntity>> GetAllAsync(
string tenantId,
bool? enabled = null,
int limit = 100,
int offset = 0,
CancellationToken cancellationToken = default)
{
var sql = """
SELECT id, tenant_id, username, email, display_name, password_hash, password_salt,
enabled, email_verified, mfa_enabled, mfa_secret, mfa_backup_codes,
failed_login_attempts, locked_until, last_login_at, password_changed_at,
settings::text, metadata::text, created_at, updated_at, created_by
FROM auth.users
WHERE tenant_id = @tenant_id
""";
if (enabled.HasValue)
{
sql += " AND enabled = @enabled";
}
sql += " ORDER BY username, id LIMIT @limit OFFSET @offset";
return await QueryAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
if (enabled.HasValue)
{
AddParameter(cmd, "enabled", enabled.Value);
}
AddParameter(cmd, "limit", limit);
AddParameter(cmd, "offset", offset);
},
MapUser,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<bool> UpdateAsync(UserEntity user, CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE auth.users
SET username = @username,
email = @email,
display_name = @display_name,
enabled = @enabled,
email_verified = @email_verified,
mfa_enabled = @mfa_enabled,
mfa_secret = @mfa_secret,
mfa_backup_codes = @mfa_backup_codes,
settings = @settings::jsonb,
metadata = @metadata::jsonb
WHERE tenant_id = @tenant_id AND id = @id
""";
var rows = await ExecuteAsync(
user.TenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", user.TenantId);
AddParameter(cmd, "id", user.Id);
AddParameter(cmd, "username", user.Username);
AddParameter(cmd, "email", user.Email);
AddParameter(cmd, "display_name", user.DisplayName);
AddParameter(cmd, "enabled", user.Enabled);
AddParameter(cmd, "email_verified", user.EmailVerified);
AddParameter(cmd, "mfa_enabled", user.MfaEnabled);
AddParameter(cmd, "mfa_secret", user.MfaSecret);
AddParameter(cmd, "mfa_backup_codes", user.MfaBackupCodes);
AddJsonbParameter(cmd, "settings", user.Settings);
AddJsonbParameter(cmd, "metadata", user.Metadata);
},
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
/// <inheritdoc />
public async Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "DELETE FROM auth.users WHERE tenant_id = @tenant_id AND id = @id";
var rows = await ExecuteAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
},
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
/// <inheritdoc />
public async Task<bool> UpdatePasswordAsync(
string tenantId,
Guid userId,
string passwordHash,
string passwordSalt,
CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE auth.users
SET password_hash = @password_hash,
password_salt = @password_salt,
password_changed_at = NOW()
WHERE tenant_id = @tenant_id AND id = @id
""";
var rows = await ExecuteAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", userId);
AddParameter(cmd, "password_hash", passwordHash);
AddParameter(cmd, "password_salt", passwordSalt);
},
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
/// <inheritdoc />
public async Task<int> RecordFailedLoginAsync(
string tenantId,
Guid userId,
DateTimeOffset? lockUntil = null,
CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE auth.users
SET failed_login_attempts = failed_login_attempts + 1,
locked_until = @locked_until
WHERE tenant_id = @tenant_id AND id = @id
RETURNING failed_login_attempts
""";
var result = await ExecuteScalarAsync<int>(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", userId);
AddParameter(cmd, "locked_until", lockUntil);
},
cancellationToken).ConfigureAwait(false);
return result;
}
/// <inheritdoc />
public async Task RecordSuccessfulLoginAsync(
string tenantId,
Guid userId,
CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE auth.users
SET failed_login_attempts = 0,
locked_until = NULL,
last_login_at = NOW()
WHERE tenant_id = @tenant_id AND id = @id
""";
await ExecuteAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", userId);
},
cancellationToken).ConfigureAwait(false);
}
private static void AddUserParameters(NpgsqlCommand command, UserEntity user)
{
AddParameter(command, "id", user.Id);
AddParameter(command, "tenant_id", user.TenantId);
AddParameter(command, "username", user.Username);
AddParameter(command, "email", user.Email);
AddParameter(command, "display_name", user.DisplayName);
AddParameter(command, "password_hash", user.PasswordHash);
AddParameter(command, "password_salt", user.PasswordSalt);
AddParameter(command, "enabled", user.Enabled);
AddParameter(command, "email_verified", user.EmailVerified);
AddParameter(command, "mfa_enabled", user.MfaEnabled);
AddParameter(command, "mfa_secret", user.MfaSecret);
AddParameter(command, "mfa_backup_codes", user.MfaBackupCodes);
AddJsonbParameter(command, "settings", user.Settings);
AddJsonbParameter(command, "metadata", user.Metadata);
AddParameter(command, "created_by", user.CreatedBy);
}
private static UserEntity MapUser(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(0),
TenantId = reader.GetString(1),
Username = reader.GetString(2),
Email = reader.GetString(3),
DisplayName = GetNullableString(reader, 4),
PasswordHash = GetNullableString(reader, 5),
PasswordSalt = GetNullableString(reader, 6),
Enabled = reader.GetBoolean(7),
EmailVerified = reader.GetBoolean(8),
MfaEnabled = reader.GetBoolean(9),
MfaSecret = GetNullableString(reader, 10),
MfaBackupCodes = GetNullableString(reader, 11),
FailedLoginAttempts = reader.GetInt32(12),
LockedUntil = GetNullableDateTimeOffset(reader, 13),
LastLoginAt = GetNullableDateTimeOffset(reader, 14),
PasswordChangedAt = GetNullableDateTimeOffset(reader, 15),
Settings = reader.GetString(16),
Metadata = reader.GetString(17),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(18),
UpdatedAt = reader.GetFieldValue<DateTimeOffset>(19),
CreatedBy = GetNullableString(reader, 20)
};
}

View File

@@ -0,0 +1,55 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Authority.Storage.Postgres.Repositories;
using StellaOps.Infrastructure.Postgres;
using StellaOps.Infrastructure.Postgres.Options;
namespace StellaOps.Authority.Storage.Postgres;
/// <summary>
/// Extension methods for configuring Authority PostgreSQL storage services.
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Adds Authority PostgreSQL storage services.
/// </summary>
/// <param name="services">Service collection.</param>
/// <param name="configuration">Configuration root.</param>
/// <param name="sectionName">Configuration section name for PostgreSQL options.</param>
/// <returns>Service collection for chaining.</returns>
public static IServiceCollection AddAuthorityPostgresStorage(
this IServiceCollection services,
IConfiguration configuration,
string sectionName = "Postgres:Authority")
{
services.Configure<PostgresOptions>(sectionName, configuration.GetSection(sectionName));
services.AddSingleton<AuthorityDataSource>();
// Register repositories
services.AddScoped<ITenantRepository, TenantRepository>();
services.AddScoped<IUserRepository, UserRepository>();
return services;
}
/// <summary>
/// Adds Authority PostgreSQL storage services with explicit options.
/// </summary>
/// <param name="services">Service collection.</param>
/// <param name="configureOptions">Options configuration action.</param>
/// <returns>Service collection for chaining.</returns>
public static IServiceCollection AddAuthorityPostgresStorage(
this IServiceCollection services,
Action<PostgresOptions> configureOptions)
{
services.Configure(configureOptions);
services.AddSingleton<AuthorityDataSource>();
// Register repositories
services.AddScoped<ITenantRepository, TenantRepository>();
services.AddScoped<IUserRepository, UserRepository>();
return services;
}
}

View File

@@ -0,0 +1,21 @@
<?xml version="1.0" ?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<RootNamespace>StellaOps.Authority.Storage.Postgres</RootNamespace>
</PropertyGroup>
<ItemGroup>
<None Include="Migrations\**\*.sql" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj" />
</ItemGroup>
</Project>