up
This commit is contained in:
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
@@ -11,7 +11,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="Migrations\**\*.sql" CopyToOutputDirectory="PreserveNewest" />
|
||||
<EmbeddedResource Include="Migrations\**\*.sql" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user