This commit is contained in:
StellaOps Bot
2025-11-29 02:19:50 +02:00
parent 2548abc56f
commit b34f13dc03
86 changed files with 9625 additions and 640 deletions

View File

@@ -0,0 +1,53 @@
namespace StellaOps.Authority.Storage.Postgres.Models;
/// <summary>
/// Represents a role entity in the authority schema.
/// </summary>
public sealed class RoleEntity
{
public required Guid Id { get; init; }
public required string TenantId { get; init; }
public required string Name { get; init; }
public string? DisplayName { get; init; }
public string? Description { get; init; }
public bool IsSystem { get; init; }
public string Metadata { get; init; } = "{}";
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset UpdatedAt { get; init; }
}
/// <summary>
/// Represents a permission entity in the authority schema.
/// </summary>
public sealed class PermissionEntity
{
public required Guid Id { get; init; }
public required string TenantId { get; init; }
public required string Name { get; init; }
public required string Resource { get; init; }
public required string Action { get; init; }
public string? Description { get; init; }
public DateTimeOffset CreatedAt { get; init; }
}
/// <summary>
/// Represents a role-permission assignment.
/// </summary>
public sealed class RolePermissionEntity
{
public required Guid RoleId { get; init; }
public required Guid PermissionId { get; init; }
public DateTimeOffset CreatedAt { get; init; }
}
/// <summary>
/// Represents a user-role assignment.
/// </summary>
public sealed class UserRoleEntity
{
public required Guid UserId { get; init; }
public required Guid RoleId { get; init; }
public DateTimeOffset GrantedAt { get; init; }
public string? GrantedBy { get; init; }
public DateTimeOffset? ExpiresAt { get; init; }
}

View File

@@ -0,0 +1,39 @@
namespace StellaOps.Authority.Storage.Postgres.Models;
/// <summary>
/// Represents a session entity in the authority schema.
/// </summary>
public sealed class SessionEntity
{
public required Guid Id { get; init; }
public required string TenantId { get; init; }
public required Guid UserId { get; init; }
public required string SessionTokenHash { get; init; }
public string? IpAddress { get; init; }
public string? UserAgent { get; init; }
public DateTimeOffset StartedAt { get; init; }
public DateTimeOffset LastActivityAt { get; init; }
public DateTimeOffset ExpiresAt { get; init; }
public DateTimeOffset? EndedAt { get; init; }
public string? EndReason { get; init; }
public string Metadata { get; init; } = "{}";
}
/// <summary>
/// Represents an audit log entry in the authority schema.
/// </summary>
public sealed class AuditEntity
{
public long Id { get; init; }
public required string TenantId { get; init; }
public Guid? UserId { get; init; }
public required string Action { get; init; }
public required string ResourceType { get; init; }
public string? ResourceId { get; init; }
public string? OldValue { get; init; }
public string? NewValue { get; init; }
public string? IpAddress { get; init; }
public string? UserAgent { get; init; }
public string? CorrelationId { get; init; }
public DateTimeOffset CreatedAt { get; init; }
}

View File

@@ -0,0 +1,74 @@
namespace StellaOps.Authority.Storage.Postgres.Models;
/// <summary>
/// Represents an access token entity in the authority schema.
/// </summary>
public sealed class TokenEntity
{
public required Guid Id { get; init; }
public required string TenantId { get; init; }
public Guid? UserId { get; init; }
public required string TokenHash { get; init; }
public required string TokenType { get; init; }
public string[] Scopes { get; init; } = [];
public string? ClientId { get; init; }
public DateTimeOffset IssuedAt { get; init; }
public DateTimeOffset ExpiresAt { get; init; }
public DateTimeOffset? RevokedAt { get; init; }
public string? RevokedBy { get; init; }
public string Metadata { get; init; } = "{}";
}
/// <summary>
/// Represents a refresh token entity in the authority schema.
/// </summary>
public sealed class RefreshTokenEntity
{
public required Guid Id { get; init; }
public required string TenantId { get; init; }
public required Guid UserId { get; init; }
public required string TokenHash { get; init; }
public Guid? AccessTokenId { get; init; }
public string? ClientId { get; init; }
public DateTimeOffset IssuedAt { get; init; }
public DateTimeOffset ExpiresAt { get; init; }
public DateTimeOffset? RevokedAt { get; init; }
public string? RevokedBy { get; init; }
public Guid? ReplacedBy { get; init; }
public string Metadata { get; init; } = "{}";
}
/// <summary>
/// Represents an API key entity in the authority schema.
/// </summary>
public sealed class ApiKeyEntity
{
public required Guid Id { get; init; }
public required string TenantId { get; init; }
public Guid? UserId { get; init; }
public required string Name { get; init; }
public required string KeyHash { get; init; }
public required string KeyPrefix { get; init; }
public string[] Scopes { get; init; } = [];
public required string Status { get; init; }
public DateTimeOffset? LastUsedAt { get; init; }
public DateTimeOffset? ExpiresAt { get; init; }
public string Metadata { get; init; } = "{}";
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset? RevokedAt { get; init; }
public string? RevokedBy { get; init; }
}
public static class ApiKeyStatus
{
public const string Active = "active";
public const string Revoked = "revoked";
public const string Expired = "expired";
}
public static class TokenType
{
public const string Access = "access";
public const string Refresh = "refresh";
public const string Api = "api";
}

View File

@@ -0,0 +1,126 @@
using StellaOps.Authority.Storage.Postgres.Models;
using StellaOps.Infrastructure.Postgres;
namespace StellaOps.Authority.Storage.Postgres.Repositories;
public sealed class ApiKeyRepository : RepositoryBase<AuthorityDataSource>, IApiKeyRepository
{
public ApiKeyRepository(AuthorityDataSource dataSource) : base(dataSource) { }
public async Task<ApiKeyEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, user_id, name, key_hash, key_prefix, scopes, status, last_used_at, expires_at, metadata, created_at, revoked_at, revoked_by
FROM authority.api_keys
WHERE tenant_id = @tenant_id AND id = @id
""";
return await QuerySingleOrDefaultAsync(tenantId, sql, MapApiKey,
cmd => { cmd.Parameters.AddWithValue("id", id); },
cancellationToken).ConfigureAwait(false);
}
public async Task<ApiKeyEntity?> GetByPrefixAsync(string keyPrefix, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, user_id, name, key_hash, key_prefix, scopes, status, last_used_at, expires_at, metadata, created_at, revoked_at, revoked_by
FROM authority.api_keys
WHERE key_prefix = @key_prefix AND status = 'active'
""";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = connection.CreateCommand();
command.CommandText = sql;
command.Parameters.AddWithValue("key_prefix", keyPrefix);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
return await reader.ReadAsync(cancellationToken).ConfigureAwait(false) ? MapApiKey(reader) : null;
}
public async Task<IReadOnlyList<ApiKeyEntity>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, user_id, name, key_hash, key_prefix, scopes, status, last_used_at, expires_at, metadata, created_at, revoked_at, revoked_by
FROM authority.api_keys
WHERE tenant_id = @tenant_id
ORDER BY created_at DESC
""";
return await QueryAsync(tenantId, sql, MapApiKey, cancellationToken: cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<ApiKeyEntity>> GetByUserIdAsync(string tenantId, Guid userId, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, user_id, name, key_hash, key_prefix, scopes, status, last_used_at, expires_at, metadata, created_at, revoked_at, revoked_by
FROM authority.api_keys
WHERE tenant_id = @tenant_id AND user_id = @user_id
ORDER BY created_at DESC
""";
return await QueryAsync(tenantId, sql, MapApiKey,
cmd => { cmd.Parameters.AddWithValue("user_id", userId); },
cancellationToken).ConfigureAwait(false);
}
public async Task<Guid> CreateAsync(string tenantId, ApiKeyEntity apiKey, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO authority.api_keys (id, tenant_id, user_id, name, key_hash, key_prefix, scopes, status, expires_at, metadata)
VALUES (@id, @tenant_id, @user_id, @name, @key_hash, @key_prefix, @scopes, @status, @expires_at, @metadata::jsonb)
RETURNING id
""";
var id = apiKey.Id == Guid.Empty ? Guid.NewGuid() : apiKey.Id;
await ExecuteAsync(tenantId, sql, cmd =>
{
cmd.Parameters.AddWithValue("id", id);
AddNullableParameter(cmd, "user_id", apiKey.UserId);
cmd.Parameters.AddWithValue("name", apiKey.Name);
cmd.Parameters.AddWithValue("key_hash", apiKey.KeyHash);
cmd.Parameters.AddWithValue("key_prefix", apiKey.KeyPrefix);
AddArrayParameter(cmd, "scopes", apiKey.Scopes);
cmd.Parameters.AddWithValue("status", apiKey.Status);
AddNullableParameter(cmd, "expires_at", apiKey.ExpiresAt);
AddJsonbParameter(cmd, "metadata", apiKey.Metadata);
}, cancellationToken).ConfigureAwait(false);
return id;
}
public async Task UpdateLastUsedAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "UPDATE authority.api_keys SET last_used_at = NOW() WHERE tenant_id = @tenant_id AND id = @id";
await ExecuteAsync(tenantId, sql, cmd => { cmd.Parameters.AddWithValue("id", id); }, cancellationToken).ConfigureAwait(false);
}
public async Task RevokeAsync(string tenantId, Guid id, string revokedBy, CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE authority.api_keys SET status = 'revoked', revoked_at = NOW(), revoked_by = @revoked_by
WHERE tenant_id = @tenant_id AND id = @id AND status = 'active'
""";
await ExecuteAsync(tenantId, sql, cmd =>
{
cmd.Parameters.AddWithValue("id", id);
cmd.Parameters.AddWithValue("revoked_by", revokedBy);
}, cancellationToken).ConfigureAwait(false);
}
public async Task DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "DELETE FROM authority.api_keys WHERE tenant_id = @tenant_id AND id = @id";
await ExecuteAsync(tenantId, sql, cmd => { cmd.Parameters.AddWithValue("id", id); }, cancellationToken).ConfigureAwait(false);
}
private static ApiKeyEntity MapApiKey(System.Data.Common.DbDataReader reader) => new()
{
Id = reader.GetGuid(0),
TenantId = reader.GetString(1),
UserId = reader.IsDBNull(2) ? null : reader.GetGuid(2),
Name = reader.GetString(3),
KeyHash = reader.GetString(4),
KeyPrefix = reader.GetString(5),
Scopes = reader.IsDBNull(6) ? [] : reader.GetFieldValue<string[]>(6),
Status = reader.GetString(7),
LastUsedAt = reader.IsDBNull(8) ? null : reader.GetFieldValue<DateTimeOffset>(8),
ExpiresAt = reader.IsDBNull(9) ? null : reader.GetFieldValue<DateTimeOffset>(9),
Metadata = reader.GetString(10),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(11),
RevokedAt = reader.IsDBNull(12) ? null : reader.GetFieldValue<DateTimeOffset>(12),
RevokedBy = reader.IsDBNull(13) ? null : reader.GetString(13)
};
}

View File

@@ -0,0 +1,15 @@
using StellaOps.Authority.Storage.Postgres.Models;
namespace StellaOps.Authority.Storage.Postgres.Repositories;
public interface IApiKeyRepository
{
Task<ApiKeyEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
Task<ApiKeyEntity?> GetByPrefixAsync(string keyPrefix, CancellationToken cancellationToken = default);
Task<IReadOnlyList<ApiKeyEntity>> ListAsync(string tenantId, CancellationToken cancellationToken = default);
Task<IReadOnlyList<ApiKeyEntity>> GetByUserIdAsync(string tenantId, Guid userId, CancellationToken cancellationToken = default);
Task<Guid> CreateAsync(string tenantId, ApiKeyEntity apiKey, CancellationToken cancellationToken = default);
Task UpdateLastUsedAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
Task RevokeAsync(string tenantId, Guid id, string revokedBy, CancellationToken cancellationToken = default);
Task DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,17 @@
using StellaOps.Authority.Storage.Postgres.Models;
namespace StellaOps.Authority.Storage.Postgres.Repositories;
public interface IPermissionRepository
{
Task<PermissionEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
Task<PermissionEntity?> GetByNameAsync(string tenantId, string name, CancellationToken cancellationToken = default);
Task<IReadOnlyList<PermissionEntity>> ListAsync(string tenantId, CancellationToken cancellationToken = default);
Task<IReadOnlyList<PermissionEntity>> GetByResourceAsync(string tenantId, string resource, CancellationToken cancellationToken = default);
Task<IReadOnlyList<PermissionEntity>> GetRolePermissionsAsync(string tenantId, Guid roleId, CancellationToken cancellationToken = default);
Task<IReadOnlyList<PermissionEntity>> GetUserPermissionsAsync(string tenantId, Guid userId, CancellationToken cancellationToken = default);
Task<Guid> CreateAsync(string tenantId, PermissionEntity permission, CancellationToken cancellationToken = default);
Task DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
Task AssignToRoleAsync(string tenantId, Guid roleId, Guid permissionId, CancellationToken cancellationToken = default);
Task RemoveFromRoleAsync(string tenantId, Guid roleId, Guid permissionId, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,16 @@
using StellaOps.Authority.Storage.Postgres.Models;
namespace StellaOps.Authority.Storage.Postgres.Repositories;
public interface IRoleRepository
{
Task<RoleEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
Task<RoleEntity?> GetByNameAsync(string tenantId, string name, CancellationToken cancellationToken = default);
Task<IReadOnlyList<RoleEntity>> ListAsync(string tenantId, CancellationToken cancellationToken = default);
Task<IReadOnlyList<RoleEntity>> GetUserRolesAsync(string tenantId, Guid userId, CancellationToken cancellationToken = default);
Task<Guid> CreateAsync(string tenantId, RoleEntity role, CancellationToken cancellationToken = default);
Task UpdateAsync(string tenantId, RoleEntity role, CancellationToken cancellationToken = default);
Task DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
Task AssignToUserAsync(string tenantId, Guid userId, Guid roleId, string? grantedBy, DateTimeOffset? expiresAt, CancellationToken cancellationToken = default);
Task RemoveFromUserAsync(string tenantId, Guid userId, Guid roleId, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,25 @@
using StellaOps.Authority.Storage.Postgres.Models;
namespace StellaOps.Authority.Storage.Postgres.Repositories;
public interface ITokenRepository
{
Task<TokenEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
Task<TokenEntity?> GetByHashAsync(string tokenHash, CancellationToken cancellationToken = default);
Task<IReadOnlyList<TokenEntity>> GetByUserIdAsync(string tenantId, Guid userId, CancellationToken cancellationToken = default);
Task<Guid> CreateAsync(string tenantId, TokenEntity token, CancellationToken cancellationToken = default);
Task RevokeAsync(string tenantId, Guid id, string revokedBy, CancellationToken cancellationToken = default);
Task RevokeByUserIdAsync(string tenantId, Guid userId, string revokedBy, CancellationToken cancellationToken = default);
Task DeleteExpiredAsync(CancellationToken cancellationToken = default);
}
public interface IRefreshTokenRepository
{
Task<RefreshTokenEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
Task<RefreshTokenEntity?> GetByHashAsync(string tokenHash, CancellationToken cancellationToken = default);
Task<IReadOnlyList<RefreshTokenEntity>> GetByUserIdAsync(string tenantId, Guid userId, CancellationToken cancellationToken = default);
Task<Guid> CreateAsync(string tenantId, RefreshTokenEntity token, CancellationToken cancellationToken = default);
Task RevokeAsync(string tenantId, Guid id, string revokedBy, Guid? replacedBy, CancellationToken cancellationToken = default);
Task RevokeByUserIdAsync(string tenantId, Guid userId, string revokedBy, CancellationToken cancellationToken = default);
Task DeleteExpiredAsync(CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,147 @@
using StellaOps.Authority.Storage.Postgres.Models;
using StellaOps.Infrastructure.Postgres;
namespace StellaOps.Authority.Storage.Postgres.Repositories;
public sealed class PermissionRepository : RepositoryBase<AuthorityDataSource>, IPermissionRepository
{
public PermissionRepository(AuthorityDataSource dataSource) : base(dataSource) { }
public async Task<PermissionEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, name, resource, action, description, created_at
FROM authority.permissions
WHERE tenant_id = @tenant_id AND id = @id
""";
return await QuerySingleOrDefaultAsync(tenantId, sql, MapPermission,
cmd => { cmd.Parameters.AddWithValue("id", id); },
cancellationToken).ConfigureAwait(false);
}
public async Task<PermissionEntity?> GetByNameAsync(string tenantId, string name, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, name, resource, action, description, created_at
FROM authority.permissions
WHERE tenant_id = @tenant_id AND name = @name
""";
return await QuerySingleOrDefaultAsync(tenantId, sql, MapPermission,
cmd => { cmd.Parameters.AddWithValue("name", name); },
cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<PermissionEntity>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, name, resource, action, description, created_at
FROM authority.permissions
WHERE tenant_id = @tenant_id
ORDER BY resource, action
""";
return await QueryAsync(tenantId, sql, MapPermission, cancellationToken: cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<PermissionEntity>> GetByResourceAsync(string tenantId, string resource, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, name, resource, action, description, created_at
FROM authority.permissions
WHERE tenant_id = @tenant_id AND resource = @resource
ORDER BY action
""";
return await QueryAsync(tenantId, sql, MapPermission,
cmd => { cmd.Parameters.AddWithValue("resource", resource); },
cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<PermissionEntity>> GetRolePermissionsAsync(string tenantId, Guid roleId, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT p.id, p.tenant_id, p.name, p.resource, p.action, p.description, p.created_at
FROM authority.permissions p
INNER JOIN authority.role_permissions rp ON p.id = rp.permission_id
WHERE p.tenant_id = @tenant_id AND rp.role_id = @role_id
ORDER BY p.resource, p.action
""";
return await QueryAsync(tenantId, sql, MapPermission,
cmd => { cmd.Parameters.AddWithValue("role_id", roleId); },
cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<PermissionEntity>> GetUserPermissionsAsync(string tenantId, Guid userId, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT DISTINCT p.id, p.tenant_id, p.name, p.resource, p.action, p.description, p.created_at
FROM authority.permissions p
INNER JOIN authority.role_permissions rp ON p.id = rp.permission_id
INNER JOIN authority.user_roles ur ON rp.role_id = ur.role_id
WHERE p.tenant_id = @tenant_id AND ur.user_id = @user_id
AND (ur.expires_at IS NULL OR ur.expires_at > NOW())
ORDER BY p.resource, p.action
""";
return await QueryAsync(tenantId, sql, MapPermission,
cmd => { cmd.Parameters.AddWithValue("user_id", userId); },
cancellationToken).ConfigureAwait(false);
}
public async Task<Guid> CreateAsync(string tenantId, PermissionEntity permission, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO authority.permissions (id, tenant_id, name, resource, action, description)
VALUES (@id, @tenant_id, @name, @resource, @action, @description)
RETURNING id
""";
var id = permission.Id == Guid.Empty ? Guid.NewGuid() : permission.Id;
await ExecuteAsync(tenantId, sql, cmd =>
{
cmd.Parameters.AddWithValue("id", id);
cmd.Parameters.AddWithValue("name", permission.Name);
cmd.Parameters.AddWithValue("resource", permission.Resource);
cmd.Parameters.AddWithValue("action", permission.Action);
AddNullableParameter(cmd, "description", permission.Description);
}, cancellationToken).ConfigureAwait(false);
return id;
}
public async Task DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "DELETE FROM authority.permissions WHERE tenant_id = @tenant_id AND id = @id";
await ExecuteAsync(tenantId, sql, cmd => { cmd.Parameters.AddWithValue("id", id); }, cancellationToken).ConfigureAwait(false);
}
public async Task AssignToRoleAsync(string tenantId, Guid roleId, Guid permissionId, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO authority.role_permissions (role_id, permission_id)
VALUES (@role_id, @permission_id)
ON CONFLICT (role_id, permission_id) DO NOTHING
""";
await ExecuteAsync(tenantId, sql, cmd =>
{
cmd.Parameters.AddWithValue("role_id", roleId);
cmd.Parameters.AddWithValue("permission_id", permissionId);
}, cancellationToken).ConfigureAwait(false);
}
public async Task RemoveFromRoleAsync(string tenantId, Guid roleId, Guid permissionId, CancellationToken cancellationToken = default)
{
const string sql = "DELETE FROM authority.role_permissions WHERE role_id = @role_id AND permission_id = @permission_id";
await ExecuteAsync(tenantId, sql, cmd =>
{
cmd.Parameters.AddWithValue("role_id", roleId);
cmd.Parameters.AddWithValue("permission_id", permissionId);
}, cancellationToken).ConfigureAwait(false);
}
private static PermissionEntity MapPermission(System.Data.Common.DbDataReader reader) => new()
{
Id = reader.GetGuid(0),
TenantId = reader.GetString(1),
Name = reader.GetString(2),
Resource = reader.GetString(3),
Action = reader.GetString(4),
Description = reader.IsDBNull(5) ? null : reader.GetString(5),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(6)
};
}

View File

@@ -0,0 +1,144 @@
using StellaOps.Authority.Storage.Postgres.Models;
using StellaOps.Infrastructure.Postgres;
namespace StellaOps.Authority.Storage.Postgres.Repositories;
public sealed class RoleRepository : RepositoryBase<AuthorityDataSource>, IRoleRepository
{
public RoleRepository(AuthorityDataSource dataSource) : base(dataSource) { }
public async Task<RoleEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, name, display_name, description, is_system, metadata, created_at, updated_at
FROM authority.roles
WHERE tenant_id = @tenant_id AND id = @id
""";
return await QuerySingleOrDefaultAsync(tenantId, sql, MapRole,
cmd => { cmd.Parameters.AddWithValue("id", id); },
cancellationToken).ConfigureAwait(false);
}
public async Task<RoleEntity?> GetByNameAsync(string tenantId, string name, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, name, display_name, description, is_system, metadata, created_at, updated_at
FROM authority.roles
WHERE tenant_id = @tenant_id AND name = @name
""";
return await QuerySingleOrDefaultAsync(tenantId, sql, MapRole,
cmd => { cmd.Parameters.AddWithValue("name", name); },
cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<RoleEntity>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, name, display_name, description, is_system, metadata, created_at, updated_at
FROM authority.roles
WHERE tenant_id = @tenant_id
ORDER BY name
""";
return await QueryAsync(tenantId, sql, MapRole, cancellationToken: cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<RoleEntity>> GetUserRolesAsync(string tenantId, Guid userId, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT r.id, r.tenant_id, r.name, r.display_name, r.description, r.is_system, r.metadata, r.created_at, r.updated_at
FROM authority.roles r
INNER JOIN authority.user_roles ur ON r.id = ur.role_id
WHERE r.tenant_id = @tenant_id AND ur.user_id = @user_id
AND (ur.expires_at IS NULL OR ur.expires_at > NOW())
ORDER BY r.name
""";
return await QueryAsync(tenantId, sql, MapRole,
cmd => { cmd.Parameters.AddWithValue("user_id", userId); },
cancellationToken).ConfigureAwait(false);
}
public async Task<Guid> CreateAsync(string tenantId, RoleEntity role, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO authority.roles (id, tenant_id, name, display_name, description, is_system, metadata)
VALUES (@id, @tenant_id, @name, @display_name, @description, @is_system, @metadata::jsonb)
RETURNING id
""";
var id = role.Id == Guid.Empty ? Guid.NewGuid() : role.Id;
await ExecuteAsync(tenantId, sql, cmd =>
{
cmd.Parameters.AddWithValue("id", id);
cmd.Parameters.AddWithValue("name", role.Name);
AddNullableParameter(cmd, "display_name", role.DisplayName);
AddNullableParameter(cmd, "description", role.Description);
cmd.Parameters.AddWithValue("is_system", role.IsSystem);
AddJsonbParameter(cmd, "metadata", role.Metadata);
}, cancellationToken).ConfigureAwait(false);
return id;
}
public async Task UpdateAsync(string tenantId, RoleEntity role, CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE authority.roles
SET name = @name, display_name = @display_name, description = @description,
is_system = @is_system, metadata = @metadata::jsonb
WHERE tenant_id = @tenant_id AND id = @id
""";
await ExecuteAsync(tenantId, sql, cmd =>
{
cmd.Parameters.AddWithValue("id", role.Id);
cmd.Parameters.AddWithValue("name", role.Name);
AddNullableParameter(cmd, "display_name", role.DisplayName);
AddNullableParameter(cmd, "description", role.Description);
cmd.Parameters.AddWithValue("is_system", role.IsSystem);
AddJsonbParameter(cmd, "metadata", role.Metadata);
}, cancellationToken).ConfigureAwait(false);
}
public async Task DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "DELETE FROM authority.roles WHERE tenant_id = @tenant_id AND id = @id";
await ExecuteAsync(tenantId, sql, cmd => { cmd.Parameters.AddWithValue("id", id); }, cancellationToken).ConfigureAwait(false);
}
public async Task AssignToUserAsync(string tenantId, Guid userId, Guid roleId, string? grantedBy, DateTimeOffset? expiresAt, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO authority.user_roles (user_id, role_id, granted_by, expires_at)
VALUES (@user_id, @role_id, @granted_by, @expires_at)
ON CONFLICT (user_id, role_id) DO UPDATE SET
granted_at = NOW(), granted_by = EXCLUDED.granted_by, expires_at = EXCLUDED.expires_at
""";
await ExecuteAsync(tenantId, sql, cmd =>
{
cmd.Parameters.AddWithValue("user_id", userId);
cmd.Parameters.AddWithValue("role_id", roleId);
AddNullableParameter(cmd, "granted_by", grantedBy);
AddNullableParameter(cmd, "expires_at", expiresAt);
}, cancellationToken).ConfigureAwait(false);
}
public async Task RemoveFromUserAsync(string tenantId, Guid userId, Guid roleId, CancellationToken cancellationToken = default)
{
const string sql = "DELETE FROM authority.user_roles WHERE user_id = @user_id AND role_id = @role_id";
await ExecuteAsync(tenantId, sql, cmd =>
{
cmd.Parameters.AddWithValue("user_id", userId);
cmd.Parameters.AddWithValue("role_id", roleId);
}, cancellationToken).ConfigureAwait(false);
}
private static RoleEntity MapRole(System.Data.Common.DbDataReader reader) => new()
{
Id = reader.GetGuid(0),
TenantId = reader.GetString(1),
Name = reader.GetString(2),
DisplayName = reader.IsDBNull(3) ? null : reader.GetString(3),
Description = reader.IsDBNull(4) ? null : reader.GetString(4),
IsSystem = reader.GetBoolean(5),
Metadata = reader.GetString(6),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(7),
UpdatedAt = reader.GetFieldValue<DateTimeOffset>(8)
};
}

View File

@@ -0,0 +1,240 @@
using StellaOps.Authority.Storage.Postgres.Models;
using StellaOps.Infrastructure.Postgres;
namespace StellaOps.Authority.Storage.Postgres.Repositories;
public sealed class TokenRepository : RepositoryBase<AuthorityDataSource>, ITokenRepository
{
public TokenRepository(AuthorityDataSource dataSource) : base(dataSource) { }
public async Task<TokenEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, user_id, token_hash, token_type, scopes, client_id, issued_at, expires_at, revoked_at, revoked_by, metadata
FROM authority.tokens
WHERE tenant_id = @tenant_id AND id = @id
""";
return await QuerySingleOrDefaultAsync(tenantId, sql, MapToken,
cmd => { cmd.Parameters.AddWithValue("id", id); },
cancellationToken).ConfigureAwait(false);
}
public async Task<TokenEntity?> GetByHashAsync(string tokenHash, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, user_id, token_hash, token_type, scopes, client_id, issued_at, expires_at, revoked_at, revoked_by, metadata
FROM authority.tokens
WHERE token_hash = @token_hash AND revoked_at IS NULL AND expires_at > NOW()
""";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = connection.CreateCommand();
command.CommandText = sql;
command.Parameters.AddWithValue("token_hash", tokenHash);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
return await reader.ReadAsync(cancellationToken).ConfigureAwait(false) ? MapToken(reader) : null;
}
public async Task<IReadOnlyList<TokenEntity>> GetByUserIdAsync(string tenantId, Guid userId, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, user_id, token_hash, token_type, scopes, client_id, issued_at, expires_at, revoked_at, revoked_by, metadata
FROM authority.tokens
WHERE tenant_id = @tenant_id AND user_id = @user_id AND revoked_at IS NULL
ORDER BY issued_at DESC
""";
return await QueryAsync(tenantId, sql, MapToken,
cmd => { cmd.Parameters.AddWithValue("user_id", userId); },
cancellationToken).ConfigureAwait(false);
}
public async Task<Guid> CreateAsync(string tenantId, TokenEntity token, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO authority.tokens (id, tenant_id, user_id, token_hash, token_type, scopes, client_id, expires_at, metadata)
VALUES (@id, @tenant_id, @user_id, @token_hash, @token_type, @scopes, @client_id, @expires_at, @metadata::jsonb)
RETURNING id
""";
var id = token.Id == Guid.Empty ? Guid.NewGuid() : token.Id;
await ExecuteAsync(tenantId, sql, cmd =>
{
cmd.Parameters.AddWithValue("id", id);
AddNullableParameter(cmd, "user_id", token.UserId);
cmd.Parameters.AddWithValue("token_hash", token.TokenHash);
cmd.Parameters.AddWithValue("token_type", token.TokenType);
AddArrayParameter(cmd, "scopes", token.Scopes);
AddNullableParameter(cmd, "client_id", token.ClientId);
cmd.Parameters.AddWithValue("expires_at", token.ExpiresAt);
AddJsonbParameter(cmd, "metadata", token.Metadata);
}, cancellationToken).ConfigureAwait(false);
return id;
}
public async Task RevokeAsync(string tenantId, Guid id, string revokedBy, CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE authority.tokens SET revoked_at = NOW(), revoked_by = @revoked_by
WHERE tenant_id = @tenant_id AND id = @id AND revoked_at IS NULL
""";
await ExecuteAsync(tenantId, sql, cmd =>
{
cmd.Parameters.AddWithValue("id", id);
cmd.Parameters.AddWithValue("revoked_by", revokedBy);
}, cancellationToken).ConfigureAwait(false);
}
public async Task RevokeByUserIdAsync(string tenantId, Guid userId, string revokedBy, CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE authority.tokens SET revoked_at = NOW(), revoked_by = @revoked_by
WHERE tenant_id = @tenant_id AND user_id = @user_id AND revoked_at IS NULL
""";
await ExecuteAsync(tenantId, sql, cmd =>
{
cmd.Parameters.AddWithValue("user_id", userId);
cmd.Parameters.AddWithValue("revoked_by", revokedBy);
}, cancellationToken).ConfigureAwait(false);
}
public async Task DeleteExpiredAsync(CancellationToken cancellationToken = default)
{
const string sql = "DELETE FROM authority.tokens WHERE expires_at < NOW() - INTERVAL '7 days'";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = connection.CreateCommand();
command.CommandText = sql;
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
private static TokenEntity MapToken(System.Data.Common.DbDataReader reader) => new()
{
Id = reader.GetGuid(0),
TenantId = reader.GetString(1),
UserId = reader.IsDBNull(2) ? null : reader.GetGuid(2),
TokenHash = reader.GetString(3),
TokenType = reader.GetString(4),
Scopes = reader.IsDBNull(5) ? [] : reader.GetFieldValue<string[]>(5),
ClientId = reader.IsDBNull(6) ? null : reader.GetString(6),
IssuedAt = reader.GetFieldValue<DateTimeOffset>(7),
ExpiresAt = reader.GetFieldValue<DateTimeOffset>(8),
RevokedAt = reader.IsDBNull(9) ? null : reader.GetFieldValue<DateTimeOffset>(9),
RevokedBy = reader.IsDBNull(10) ? null : reader.GetString(10),
Metadata = reader.GetString(11)
};
}
public sealed class RefreshTokenRepository : RepositoryBase<AuthorityDataSource>, IRefreshTokenRepository
{
public RefreshTokenRepository(AuthorityDataSource dataSource) : base(dataSource) { }
public async Task<RefreshTokenEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, user_id, token_hash, access_token_id, client_id, issued_at, expires_at, revoked_at, revoked_by, replaced_by, metadata
FROM authority.refresh_tokens
WHERE tenant_id = @tenant_id AND id = @id
""";
return await QuerySingleOrDefaultAsync(tenantId, sql, MapRefreshToken,
cmd => { cmd.Parameters.AddWithValue("id", id); },
cancellationToken).ConfigureAwait(false);
}
public async Task<RefreshTokenEntity?> GetByHashAsync(string tokenHash, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, user_id, token_hash, access_token_id, client_id, issued_at, expires_at, revoked_at, revoked_by, replaced_by, metadata
FROM authority.refresh_tokens
WHERE token_hash = @token_hash AND revoked_at IS NULL AND expires_at > NOW()
""";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = connection.CreateCommand();
command.CommandText = sql;
command.Parameters.AddWithValue("token_hash", tokenHash);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
return await reader.ReadAsync(cancellationToken).ConfigureAwait(false) ? MapRefreshToken(reader) : null;
}
public async Task<IReadOnlyList<RefreshTokenEntity>> GetByUserIdAsync(string tenantId, Guid userId, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, user_id, token_hash, access_token_id, client_id, issued_at, expires_at, revoked_at, revoked_by, replaced_by, metadata
FROM authority.refresh_tokens
WHERE tenant_id = @tenant_id AND user_id = @user_id AND revoked_at IS NULL
ORDER BY issued_at DESC
""";
return await QueryAsync(tenantId, sql, MapRefreshToken,
cmd => { cmd.Parameters.AddWithValue("user_id", userId); },
cancellationToken).ConfigureAwait(false);
}
public async Task<Guid> CreateAsync(string tenantId, RefreshTokenEntity token, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO authority.refresh_tokens (id, tenant_id, user_id, token_hash, access_token_id, client_id, expires_at, metadata)
VALUES (@id, @tenant_id, @user_id, @token_hash, @access_token_id, @client_id, @expires_at, @metadata::jsonb)
RETURNING id
""";
var id = token.Id == Guid.Empty ? Guid.NewGuid() : token.Id;
await ExecuteAsync(tenantId, sql, cmd =>
{
cmd.Parameters.AddWithValue("id", id);
cmd.Parameters.AddWithValue("user_id", token.UserId);
cmd.Parameters.AddWithValue("token_hash", token.TokenHash);
AddNullableParameter(cmd, "access_token_id", token.AccessTokenId);
AddNullableParameter(cmd, "client_id", token.ClientId);
cmd.Parameters.AddWithValue("expires_at", token.ExpiresAt);
AddJsonbParameter(cmd, "metadata", token.Metadata);
}, cancellationToken).ConfigureAwait(false);
return id;
}
public async Task RevokeAsync(string tenantId, Guid id, string revokedBy, Guid? replacedBy, CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE authority.refresh_tokens SET revoked_at = NOW(), revoked_by = @revoked_by, replaced_by = @replaced_by
WHERE tenant_id = @tenant_id AND id = @id AND revoked_at IS NULL
""";
await ExecuteAsync(tenantId, sql, cmd =>
{
cmd.Parameters.AddWithValue("id", id);
cmd.Parameters.AddWithValue("revoked_by", revokedBy);
AddNullableParameter(cmd, "replaced_by", replacedBy);
}, cancellationToken).ConfigureAwait(false);
}
public async Task RevokeByUserIdAsync(string tenantId, Guid userId, string revokedBy, CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE authority.refresh_tokens SET revoked_at = NOW(), revoked_by = @revoked_by
WHERE tenant_id = @tenant_id AND user_id = @user_id AND revoked_at IS NULL
""";
await ExecuteAsync(tenantId, sql, cmd =>
{
cmd.Parameters.AddWithValue("user_id", userId);
cmd.Parameters.AddWithValue("revoked_by", revokedBy);
}, cancellationToken).ConfigureAwait(false);
}
public async Task DeleteExpiredAsync(CancellationToken cancellationToken = default)
{
const string sql = "DELETE FROM authority.refresh_tokens WHERE expires_at < NOW() - INTERVAL '30 days'";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = connection.CreateCommand();
command.CommandText = sql;
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
private static RefreshTokenEntity MapRefreshToken(System.Data.Common.DbDataReader reader) => new()
{
Id = reader.GetGuid(0),
TenantId = reader.GetString(1),
UserId = reader.GetGuid(2),
TokenHash = reader.GetString(3),
AccessTokenId = reader.IsDBNull(4) ? null : reader.GetGuid(4),
ClientId = reader.IsDBNull(5) ? null : reader.GetString(5),
IssuedAt = reader.GetFieldValue<DateTimeOffset>(6),
ExpiresAt = reader.GetFieldValue<DateTimeOffset>(7),
RevokedAt = reader.IsDBNull(8) ? null : reader.GetFieldValue<DateTimeOffset>(8),
RevokedBy = reader.IsDBNull(9) ? null : reader.GetString(9),
ReplacedBy = reader.IsDBNull(10) ? null : reader.GetGuid(10),
Metadata = reader.GetString(11)
};
}

View File

@@ -11,7 +11,7 @@
</PropertyGroup>
<ItemGroup>
<None Include="Migrations\**\*.sql" CopyToOutputDirectory="PreserveNewest" />
<EmbeddedResource Include="Migrations\**\*.sql" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
</ItemGroup>
<ItemGroup>

View File

@@ -0,0 +1,68 @@
using FluentAssertions;
using Npgsql;
using Xunit;
namespace StellaOps.Authority.Storage.Postgres.Tests;
/// <summary>
/// Tests that verify Authority module migrations run successfully.
/// </summary>
[Collection(AuthorityPostgresCollection.Name)]
public sealed class AuthorityMigrationTests
{
private readonly AuthorityPostgresFixture _fixture;
public AuthorityMigrationTests(AuthorityPostgresFixture fixture)
{
_fixture = fixture;
}
[Fact]
public async Task MigrationsApplied_SchemaHasTables()
{
// Arrange
await using var connection = new NpgsqlConnection(_fixture.ConnectionString);
await connection.OpenAsync();
// Act - Query for tables in schema
await using var cmd = new NpgsqlCommand(
"""
SELECT table_name FROM information_schema.tables
WHERE table_schema = @schema
AND table_type = 'BASE TABLE'
ORDER BY table_name;
""",
connection);
cmd.Parameters.AddWithValue("schema", _fixture.SchemaName);
var tables = new List<string>();
await using var reader = await cmd.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
tables.Add(reader.GetString(0));
}
// Assert - Should have core Authority tables
tables.Should().Contain("schema_migrations");
// Add more specific table assertions based on Authority migrations
}
[Fact]
public async Task MigrationsApplied_SchemaVersionRecorded()
{
// Arrange
await using var connection = new NpgsqlConnection(_fixture.ConnectionString);
await connection.OpenAsync();
// Act - Check schema_migrations table
await using var cmd = new NpgsqlCommand(
$"SELECT COUNT(*) FROM {_fixture.SchemaName}.schema_migrations;",
connection);
var count = await cmd.ExecuteScalarAsync();
// Assert - At least one migration should be recorded
count.Should().NotBeNull();
((long)count!).Should().BeGreaterThan(0);
}
}

View File

@@ -0,0 +1,28 @@
using System.Reflection;
using StellaOps.Authority.Storage.Postgres;
using StellaOps.Infrastructure.Postgres.Testing;
using Xunit;
namespace StellaOps.Authority.Storage.Postgres.Tests;
/// <summary>
/// PostgreSQL integration test fixture for the Authority module.
/// Runs migrations from embedded resources and provides test isolation.
/// </summary>
public sealed class AuthorityPostgresFixture : PostgresIntegrationFixture, ICollectionFixture<AuthorityPostgresFixture>
{
protected override Assembly? GetMigrationAssembly()
=> typeof(AuthorityDataSource).Assembly;
protected override string GetModuleName() => "Authority";
}
/// <summary>
/// Collection definition for Authority PostgreSQL integration tests.
/// Tests in this collection share a single PostgreSQL container instance.
/// </summary>
[CollectionDefinition(Name)]
public sealed class AuthorityPostgresCollection : ICollectionFixture<AuthorityPostgresFixture>
{
public const string Name = "AuthorityPostgres";
}

View File

@@ -0,0 +1,33 @@
<?xml version="1.0" ?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="Moq" Version="4.20.70" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="6.0.4">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.Authority.Storage.Postgres\StellaOps.Authority.Storage.Postgres.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Infrastructure.Postgres.Testing\StellaOps.Infrastructure.Postgres.Testing.csproj" />
</ItemGroup>
</Project>