up
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user