up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-11-29 11:08:08 +02:00
parent 7e7be4d2fd
commit 3488b22c0c
102 changed files with 18487 additions and 969 deletions

View File

@@ -1,11 +1,17 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Authority.Storage.Postgres.Models;
using StellaOps.Infrastructure.Postgres;
using StellaOps.Infrastructure.Postgres.Repositories;
namespace StellaOps.Authority.Storage.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for API key operations.
/// </summary>
public sealed class ApiKeyRepository : RepositoryBase<AuthorityDataSource>, IApiKeyRepository
{
public ApiKeyRepository(AuthorityDataSource dataSource) : base(dataSource) { }
public ApiKeyRepository(AuthorityDataSource dataSource, ILogger<ApiKeyRepository> logger)
: base(dataSource, logger) { }
public async Task<ApiKeyEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
@@ -14,9 +20,9 @@ public sealed class ApiKeyRepository : RepositoryBase<AuthorityDataSource>, IApi
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);
return await QuerySingleOrDefaultAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
MapApiKey, cancellationToken).ConfigureAwait(false);
}
public async Task<ApiKeyEntity?> GetByPrefixAsync(string keyPrefix, CancellationToken cancellationToken = default)
@@ -27,9 +33,8 @@ public sealed class ApiKeyRepository : RepositoryBase<AuthorityDataSource>, IApi
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 command = CreateCommand(sql, connection);
AddParameter(command, "key_prefix", keyPrefix);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
return await reader.ReadAsync(cancellationToken).ConfigureAwait(false) ? MapApiKey(reader) : null;
}
@@ -42,7 +47,9 @@ public sealed class ApiKeyRepository : RepositoryBase<AuthorityDataSource>, IApi
WHERE tenant_id = @tenant_id
ORDER BY created_at DESC
""";
return await QueryAsync(tenantId, sql, MapApiKey, cancellationToken: cancellationToken).ConfigureAwait(false);
return await QueryAsync(tenantId, sql,
cmd => AddParameter(cmd, "tenant_id", tenantId),
MapApiKey, cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<ApiKeyEntity>> GetByUserIdAsync(string tenantId, Guid userId, CancellationToken cancellationToken = default)
@@ -53,9 +60,9 @@ public sealed class ApiKeyRepository : RepositoryBase<AuthorityDataSource>, IApi
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);
return await QueryAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "user_id", userId); },
MapApiKey, cancellationToken).ConfigureAwait(false);
}
public async Task<Guid> CreateAsync(string tenantId, ApiKeyEntity apiKey, CancellationToken cancellationToken = default)
@@ -66,25 +73,28 @@ public sealed class ApiKeyRepository : RepositoryBase<AuthorityDataSource>, IApi
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);
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "id", id);
AddParameter(command, "tenant_id", tenantId);
AddParameter(command, "user_id", apiKey.UserId);
AddParameter(command, "name", apiKey.Name);
AddParameter(command, "key_hash", apiKey.KeyHash);
AddParameter(command, "key_prefix", apiKey.KeyPrefix);
AddTextArrayParameter(command, "scopes", apiKey.Scopes);
AddParameter(command, "status", apiKey.Status);
AddParameter(command, "expires_at", apiKey.ExpiresAt);
AddJsonbParameter(command, "metadata", apiKey.Metadata);
await command.ExecuteNonQueryAsync(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);
await ExecuteAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
cancellationToken).ConfigureAwait(false);
}
public async Task RevokeAsync(string tenantId, Guid id, string revokedBy, CancellationToken cancellationToken = default)
@@ -95,32 +105,35 @@ public sealed class ApiKeyRepository : RepositoryBase<AuthorityDataSource>, IApi
""";
await ExecuteAsync(tenantId, sql, cmd =>
{
cmd.Parameters.AddWithValue("id", id);
cmd.Parameters.AddWithValue("revoked_by", revokedBy);
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
AddParameter(cmd, "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);
await ExecuteAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
cancellationToken).ConfigureAwait(false);
}
private static ApiKeyEntity MapApiKey(System.Data.Common.DbDataReader reader) => new()
private static ApiKeyEntity MapApiKey(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(0),
TenantId = reader.GetString(1),
UserId = reader.IsDBNull(2) ? null : reader.GetGuid(2),
UserId = GetNullableGuid(reader, 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),
LastUsedAt = GetNullableDateTimeOffset(reader, 8),
ExpiresAt = GetNullableDateTimeOffset(reader, 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)
RevokedAt = GetNullableDateTimeOffset(reader, 12),
RevokedBy = GetNullableString(reader, 13)
};
}

View File

@@ -1,11 +1,17 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Authority.Storage.Postgres.Models;
using StellaOps.Infrastructure.Postgres;
using StellaOps.Infrastructure.Postgres.Repositories;
namespace StellaOps.Authority.Storage.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for audit log operations.
/// </summary>
public sealed class AuditRepository : RepositoryBase<AuthorityDataSource>, IAuditRepository
{
public AuditRepository(AuthorityDataSource dataSource) : base(dataSource) { }
public AuditRepository(AuthorityDataSource dataSource, ILogger<AuditRepository> logger)
: base(dataSource, logger) { }
public async Task<long> CreateAsync(string tenantId, AuditEntity audit, CancellationToken cancellationToken = default)
{
@@ -14,19 +20,18 @@ public sealed class AuditRepository : RepositoryBase<AuthorityDataSource>, IAudi
VALUES (@tenant_id, @user_id, @action, @resource_type, @resource_id, @old_value::jsonb, @new_value::jsonb, @ip_address, @user_agent, @correlation_id)
RETURNING id
""";
await using var connection = await DataSource.OpenConnectionAsync(tenantId, DataSourceRole.Writer, cancellationToken).ConfigureAwait(false);
await using var command = connection.CreateCommand();
command.CommandText = sql;
command.Parameters.AddWithValue("tenant_id", tenantId);
AddNullableParameter(command, "user_id", audit.UserId);
command.Parameters.AddWithValue("action", audit.Action);
command.Parameters.AddWithValue("resource_type", audit.ResourceType);
AddNullableParameter(command, "resource_id", audit.ResourceId);
AddNullableJsonbParameter(command, "old_value", audit.OldValue);
AddNullableJsonbParameter(command, "new_value", audit.NewValue);
AddNullableParameter(command, "ip_address", audit.IpAddress);
AddNullableParameter(command, "user_agent", audit.UserAgent);
AddNullableParameter(command, "correlation_id", audit.CorrelationId);
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "tenant_id", tenantId);
AddParameter(command, "user_id", audit.UserId);
AddParameter(command, "action", audit.Action);
AddParameter(command, "resource_type", audit.ResourceType);
AddParameter(command, "resource_id", audit.ResourceId);
AddJsonbParameter(command, "old_value", audit.OldValue);
AddJsonbParameter(command, "new_value", audit.NewValue);
AddParameter(command, "ip_address", audit.IpAddress);
AddParameter(command, "user_agent", audit.UserAgent);
AddParameter(command, "correlation_id", audit.CorrelationId);
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
return (long)result!;
}
@@ -40,11 +45,12 @@ public sealed class AuditRepository : RepositoryBase<AuthorityDataSource>, IAudi
ORDER BY created_at DESC
LIMIT @limit OFFSET @offset
""";
return await QueryAsync(tenantId, sql, MapAudit, cmd =>
return await QueryAsync(tenantId, sql, cmd =>
{
cmd.Parameters.AddWithValue("limit", limit);
cmd.Parameters.AddWithValue("offset", offset);
}, cancellationToken).ConfigureAwait(false);
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "limit", limit);
AddParameter(cmd, "offset", offset);
}, MapAudit, cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<AuditEntity>> GetByUserIdAsync(string tenantId, Guid userId, int limit = 100, CancellationToken cancellationToken = default)
@@ -56,29 +62,31 @@ public sealed class AuditRepository : RepositoryBase<AuthorityDataSource>, IAudi
ORDER BY created_at DESC
LIMIT @limit
""";
return await QueryAsync(tenantId, sql, MapAudit, cmd =>
return await QueryAsync(tenantId, sql, cmd =>
{
cmd.Parameters.AddWithValue("user_id", userId);
cmd.Parameters.AddWithValue("limit", limit);
}, cancellationToken).ConfigureAwait(false);
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "user_id", userId);
AddParameter(cmd, "limit", limit);
}, MapAudit, cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<AuditEntity>> GetByResourceAsync(string tenantId, string resourceType, string? resourceId, int limit = 100, CancellationToken cancellationToken = default)
{
var sql = $"""
var sql = """
SELECT id, tenant_id, user_id, action, resource_type, resource_id, old_value, new_value, ip_address, user_agent, correlation_id, created_at
FROM authority.audit
WHERE tenant_id = @tenant_id AND resource_type = @resource_type
{(resourceId != null ? "AND resource_id = @resource_id" : "")}
ORDER BY created_at DESC
LIMIT @limit
""";
return await QueryAsync(tenantId, sql, MapAudit, cmd =>
if (resourceId != null) sql += " AND resource_id = @resource_id";
sql += " ORDER BY created_at DESC LIMIT @limit";
return await QueryAsync(tenantId, sql, cmd =>
{
cmd.Parameters.AddWithValue("resource_type", resourceType);
if (resourceId != null) cmd.Parameters.AddWithValue("resource_id", resourceId);
cmd.Parameters.AddWithValue("limit", limit);
}, cancellationToken).ConfigureAwait(false);
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "resource_type", resourceType);
if (resourceId != null) AddParameter(cmd, "resource_id", resourceId);
AddParameter(cmd, "limit", limit);
}, MapAudit, cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<AuditEntity>> GetByCorrelationIdAsync(string tenantId, string correlationId, CancellationToken cancellationToken = default)
@@ -89,9 +97,11 @@ public sealed class AuditRepository : RepositoryBase<AuthorityDataSource>, IAudi
WHERE tenant_id = @tenant_id AND correlation_id = @correlation_id
ORDER BY created_at
""";
return await QueryAsync(tenantId, sql, MapAudit,
cmd => { cmd.Parameters.AddWithValue("correlation_id", correlationId); },
cancellationToken).ConfigureAwait(false);
return await QueryAsync(tenantId, sql, cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "correlation_id", correlationId);
}, MapAudit, cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<AuditEntity>> GetByActionAsync(string tenantId, string action, int limit = 100, CancellationToken cancellationToken = default)
@@ -103,34 +113,27 @@ public sealed class AuditRepository : RepositoryBase<AuthorityDataSource>, IAudi
ORDER BY created_at DESC
LIMIT @limit
""";
return await QueryAsync(tenantId, sql, MapAudit, cmd =>
return await QueryAsync(tenantId, sql, cmd =>
{
cmd.Parameters.AddWithValue("action", action);
cmd.Parameters.AddWithValue("limit", limit);
}, cancellationToken).ConfigureAwait(false);
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "action", action);
AddParameter(cmd, "limit", limit);
}, MapAudit, cancellationToken).ConfigureAwait(false);
}
private void AddNullableJsonbParameter(Npgsql.NpgsqlCommand cmd, string name, string? value)
{
if (value == null)
cmd.Parameters.AddWithValue(name, DBNull.Value);
else
AddJsonbParameter(cmd, name, value);
}
private static AuditEntity MapAudit(System.Data.Common.DbDataReader reader) => new()
private static AuditEntity MapAudit(NpgsqlDataReader reader) => new()
{
Id = reader.GetInt64(0),
TenantId = reader.GetString(1),
UserId = reader.IsDBNull(2) ? null : reader.GetGuid(2),
UserId = GetNullableGuid(reader, 2),
Action = reader.GetString(3),
ResourceType = reader.GetString(4),
ResourceId = reader.IsDBNull(5) ? null : reader.GetString(5),
OldValue = reader.IsDBNull(6) ? null : reader.GetString(6),
NewValue = reader.IsDBNull(7) ? null : reader.GetString(7),
IpAddress = reader.IsDBNull(8) ? null : reader.GetString(8),
UserAgent = reader.IsDBNull(9) ? null : reader.GetString(9),
CorrelationId = reader.IsDBNull(10) ? null : reader.GetString(10),
ResourceId = GetNullableString(reader, 5),
OldValue = GetNullableString(reader, 6),
NewValue = GetNullableString(reader, 7),
IpAddress = GetNullableString(reader, 8),
UserAgent = GetNullableString(reader, 9),
CorrelationId = GetNullableString(reader, 10),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(11)
};
}

View File

@@ -1,11 +1,17 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Authority.Storage.Postgres.Models;
using StellaOps.Infrastructure.Postgres;
using StellaOps.Infrastructure.Postgres.Repositories;
namespace StellaOps.Authority.Storage.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for permission operations.
/// </summary>
public sealed class PermissionRepository : RepositoryBase<AuthorityDataSource>, IPermissionRepository
{
public PermissionRepository(AuthorityDataSource dataSource) : base(dataSource) { }
public PermissionRepository(AuthorityDataSource dataSource, ILogger<PermissionRepository> logger)
: base(dataSource, logger) { }
public async Task<PermissionEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
@@ -14,9 +20,9 @@ public sealed class PermissionRepository : RepositoryBase<AuthorityDataSource>,
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);
return await QuerySingleOrDefaultAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
MapPermission, cancellationToken).ConfigureAwait(false);
}
public async Task<PermissionEntity?> GetByNameAsync(string tenantId, string name, CancellationToken cancellationToken = default)
@@ -26,9 +32,9 @@ public sealed class PermissionRepository : RepositoryBase<AuthorityDataSource>,
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);
return await QuerySingleOrDefaultAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "name", name); },
MapPermission, cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<PermissionEntity>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
@@ -39,7 +45,9 @@ public sealed class PermissionRepository : RepositoryBase<AuthorityDataSource>,
WHERE tenant_id = @tenant_id
ORDER BY resource, action
""";
return await QueryAsync(tenantId, sql, MapPermission, cancellationToken: cancellationToken).ConfigureAwait(false);
return await QueryAsync(tenantId, sql,
cmd => AddParameter(cmd, "tenant_id", tenantId),
MapPermission, cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<PermissionEntity>> GetByResourceAsync(string tenantId, string resource, CancellationToken cancellationToken = default)
@@ -50,9 +58,9 @@ public sealed class PermissionRepository : RepositoryBase<AuthorityDataSource>,
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);
return await QueryAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "resource", resource); },
MapPermission, cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<PermissionEntity>> GetRolePermissionsAsync(string tenantId, Guid roleId, CancellationToken cancellationToken = default)
@@ -64,9 +72,9 @@ public sealed class PermissionRepository : RepositoryBase<AuthorityDataSource>,
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);
return await QueryAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "role_id", roleId); },
MapPermission, cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<PermissionEntity>> GetUserPermissionsAsync(string tenantId, Guid userId, CancellationToken cancellationToken = default)
@@ -80,9 +88,9 @@ public sealed class PermissionRepository : RepositoryBase<AuthorityDataSource>,
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);
return await QueryAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "user_id", userId); },
MapPermission, cancellationToken).ConfigureAwait(false);
}
public async Task<Guid> CreateAsync(string tenantId, PermissionEntity permission, CancellationToken cancellationToken = default)
@@ -93,21 +101,24 @@ public sealed class PermissionRepository : RepositoryBase<AuthorityDataSource>,
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);
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "id", id);
AddParameter(command, "tenant_id", tenantId);
AddParameter(command, "name", permission.Name);
AddParameter(command, "resource", permission.Resource);
AddParameter(command, "action", permission.Action);
AddParameter(command, "description", permission.Description);
await command.ExecuteNonQueryAsync(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);
await ExecuteAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
cancellationToken).ConfigureAwait(false);
}
public async Task AssignToRoleAsync(string tenantId, Guid roleId, Guid permissionId, CancellationToken cancellationToken = default)
@@ -119,8 +130,8 @@ public sealed class PermissionRepository : RepositoryBase<AuthorityDataSource>,
""";
await ExecuteAsync(tenantId, sql, cmd =>
{
cmd.Parameters.AddWithValue("role_id", roleId);
cmd.Parameters.AddWithValue("permission_id", permissionId);
AddParameter(cmd, "role_id", roleId);
AddParameter(cmd, "permission_id", permissionId);
}, cancellationToken).ConfigureAwait(false);
}
@@ -129,19 +140,19 @@ public sealed class PermissionRepository : RepositoryBase<AuthorityDataSource>,
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);
AddParameter(cmd, "role_id", roleId);
AddParameter(cmd, "permission_id", permissionId);
}, cancellationToken).ConfigureAwait(false);
}
private static PermissionEntity MapPermission(System.Data.Common.DbDataReader reader) => new()
private static PermissionEntity MapPermission(NpgsqlDataReader 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),
Description = GetNullableString(reader, 5),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(6)
};
}

View File

@@ -1,11 +1,17 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Authority.Storage.Postgres.Models;
using StellaOps.Infrastructure.Postgres;
using StellaOps.Infrastructure.Postgres.Repositories;
namespace StellaOps.Authority.Storage.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for role operations.
/// </summary>
public sealed class RoleRepository : RepositoryBase<AuthorityDataSource>, IRoleRepository
{
public RoleRepository(AuthorityDataSource dataSource) : base(dataSource) { }
public RoleRepository(AuthorityDataSource dataSource, ILogger<RoleRepository> logger)
: base(dataSource, logger) { }
public async Task<RoleEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
@@ -14,9 +20,9 @@ public sealed class RoleRepository : RepositoryBase<AuthorityDataSource>, IRoleR
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);
return await QuerySingleOrDefaultAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
MapRole, cancellationToken).ConfigureAwait(false);
}
public async Task<RoleEntity?> GetByNameAsync(string tenantId, string name, CancellationToken cancellationToken = default)
@@ -26,9 +32,9 @@ public sealed class RoleRepository : RepositoryBase<AuthorityDataSource>, IRoleR
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);
return await QuerySingleOrDefaultAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "name", name); },
MapRole, cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<RoleEntity>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
@@ -39,7 +45,9 @@ public sealed class RoleRepository : RepositoryBase<AuthorityDataSource>, IRoleR
WHERE tenant_id = @tenant_id
ORDER BY name
""";
return await QueryAsync(tenantId, sql, MapRole, cancellationToken: cancellationToken).ConfigureAwait(false);
return await QueryAsync(tenantId, sql,
cmd => AddParameter(cmd, "tenant_id", tenantId),
MapRole, cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<RoleEntity>> GetUserRolesAsync(string tenantId, Guid userId, CancellationToken cancellationToken = default)
@@ -52,9 +60,9 @@ public sealed class RoleRepository : RepositoryBase<AuthorityDataSource>, IRoleR
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);
return await QueryAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "user_id", userId); },
MapRole, cancellationToken).ConfigureAwait(false);
}
public async Task<Guid> CreateAsync(string tenantId, RoleEntity role, CancellationToken cancellationToken = default)
@@ -65,15 +73,16 @@ public sealed class RoleRepository : RepositoryBase<AuthorityDataSource>, IRoleR
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);
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "id", id);
AddParameter(command, "tenant_id", tenantId);
AddParameter(command, "name", role.Name);
AddParameter(command, "display_name", role.DisplayName);
AddParameter(command, "description", role.Description);
AddParameter(command, "is_system", role.IsSystem);
AddJsonbParameter(command, "metadata", role.Metadata);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
return id;
}
@@ -87,11 +96,12 @@ public sealed class RoleRepository : RepositoryBase<AuthorityDataSource>, IRoleR
""";
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);
AddParameter(cmd, "id", role.Id);
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "name", role.Name);
AddParameter(cmd, "display_name", role.DisplayName);
AddParameter(cmd, "description", role.Description);
AddParameter(cmd, "is_system", role.IsSystem);
AddJsonbParameter(cmd, "metadata", role.Metadata);
}, cancellationToken).ConfigureAwait(false);
}
@@ -99,7 +109,9 @@ public sealed class RoleRepository : RepositoryBase<AuthorityDataSource>, IRoleR
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);
await ExecuteAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
cancellationToken).ConfigureAwait(false);
}
public async Task AssignToUserAsync(string tenantId, Guid userId, Guid roleId, string? grantedBy, DateTimeOffset? expiresAt, CancellationToken cancellationToken = default)
@@ -112,10 +124,10 @@ public sealed class RoleRepository : RepositoryBase<AuthorityDataSource>, IRoleR
""";
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);
AddParameter(cmd, "user_id", userId);
AddParameter(cmd, "role_id", roleId);
AddParameter(cmd, "granted_by", grantedBy);
AddParameter(cmd, "expires_at", expiresAt);
}, cancellationToken).ConfigureAwait(false);
}
@@ -124,18 +136,18 @@ public sealed class RoleRepository : RepositoryBase<AuthorityDataSource>, IRoleR
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);
AddParameter(cmd, "user_id", userId);
AddParameter(cmd, "role_id", roleId);
}, cancellationToken).ConfigureAwait(false);
}
private static RoleEntity MapRole(System.Data.Common.DbDataReader reader) => new()
private static RoleEntity MapRole(NpgsqlDataReader 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),
DisplayName = GetNullableString(reader, 3),
Description = GetNullableString(reader, 4),
IsSystem = reader.GetBoolean(5),
Metadata = reader.GetString(6),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(7),

View File

@@ -1,11 +1,17 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Authority.Storage.Postgres.Models;
using StellaOps.Infrastructure.Postgres;
using StellaOps.Infrastructure.Postgres.Repositories;
namespace StellaOps.Authority.Storage.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for session operations.
/// </summary>
public sealed class SessionRepository : RepositoryBase<AuthorityDataSource>, ISessionRepository
{
public SessionRepository(AuthorityDataSource dataSource) : base(dataSource) { }
public SessionRepository(AuthorityDataSource dataSource, ILogger<SessionRepository> logger)
: base(dataSource, logger) { }
public async Task<SessionEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
@@ -14,9 +20,9 @@ public sealed class SessionRepository : RepositoryBase<AuthorityDataSource>, ISe
FROM authority.sessions
WHERE tenant_id = @tenant_id AND id = @id
""";
return await QuerySingleOrDefaultAsync(tenantId, sql, MapSession,
cmd => { cmd.Parameters.AddWithValue("id", id); },
cancellationToken).ConfigureAwait(false);
return await QuerySingleOrDefaultAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
MapSession, cancellationToken).ConfigureAwait(false);
}
public async Task<SessionEntity?> GetByTokenHashAsync(string sessionTokenHash, CancellationToken cancellationToken = default)
@@ -27,25 +33,25 @@ public sealed class SessionRepository : RepositoryBase<AuthorityDataSource>, ISe
WHERE session_token_hash = @session_token_hash AND ended_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("session_token_hash", sessionTokenHash);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "session_token_hash", sessionTokenHash);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
return await reader.ReadAsync(cancellationToken).ConfigureAwait(false) ? MapSession(reader) : null;
}
public async Task<IReadOnlyList<SessionEntity>> GetByUserIdAsync(string tenantId, Guid userId, bool activeOnly = true, CancellationToken cancellationToken = default)
{
var sql = $"""
var sql = """
SELECT id, tenant_id, user_id, session_token_hash, ip_address, user_agent, started_at, last_activity_at, expires_at, ended_at, end_reason, metadata
FROM authority.sessions
WHERE tenant_id = @tenant_id AND user_id = @user_id
{(activeOnly ? "AND ended_at IS NULL AND expires_at > NOW()" : "")}
ORDER BY started_at DESC
""";
return await QueryAsync(tenantId, sql, MapSession,
cmd => { cmd.Parameters.AddWithValue("user_id", userId); },
cancellationToken).ConfigureAwait(false);
if (activeOnly) sql += " AND ended_at IS NULL AND expires_at > NOW()";
sql += " ORDER BY started_at DESC";
return await QueryAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "user_id", userId); },
MapSession, cancellationToken).ConfigureAwait(false);
}
public async Task<Guid> CreateAsync(string tenantId, SessionEntity session, CancellationToken cancellationToken = default)
@@ -56,23 +62,26 @@ public sealed class SessionRepository : RepositoryBase<AuthorityDataSource>, ISe
RETURNING id
""";
var id = session.Id == Guid.Empty ? Guid.NewGuid() : session.Id;
await ExecuteAsync(tenantId, sql, cmd =>
{
cmd.Parameters.AddWithValue("id", id);
cmd.Parameters.AddWithValue("user_id", session.UserId);
cmd.Parameters.AddWithValue("session_token_hash", session.SessionTokenHash);
AddNullableParameter(cmd, "ip_address", session.IpAddress);
AddNullableParameter(cmd, "user_agent", session.UserAgent);
cmd.Parameters.AddWithValue("expires_at", session.ExpiresAt);
AddJsonbParameter(cmd, "metadata", session.Metadata);
}, cancellationToken).ConfigureAwait(false);
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "id", id);
AddParameter(command, "tenant_id", tenantId);
AddParameter(command, "user_id", session.UserId);
AddParameter(command, "session_token_hash", session.SessionTokenHash);
AddParameter(command, "ip_address", session.IpAddress);
AddParameter(command, "user_agent", session.UserAgent);
AddParameter(command, "expires_at", session.ExpiresAt);
AddJsonbParameter(command, "metadata", session.Metadata);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
return id;
}
public async Task UpdateLastActivityAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "UPDATE authority.sessions SET last_activity_at = NOW() WHERE tenant_id = @tenant_id AND id = @id AND ended_at IS NULL";
await ExecuteAsync(tenantId, sql, cmd => { cmd.Parameters.AddWithValue("id", id); }, cancellationToken).ConfigureAwait(false);
await ExecuteAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
cancellationToken).ConfigureAwait(false);
}
public async Task EndAsync(string tenantId, Guid id, string reason, CancellationToken cancellationToken = default)
@@ -83,8 +92,9 @@ public sealed class SessionRepository : RepositoryBase<AuthorityDataSource>, ISe
""";
await ExecuteAsync(tenantId, sql, cmd =>
{
cmd.Parameters.AddWithValue("id", id);
cmd.Parameters.AddWithValue("end_reason", reason);
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
AddParameter(cmd, "end_reason", reason);
}, cancellationToken).ConfigureAwait(false);
}
@@ -96,8 +106,9 @@ public sealed class SessionRepository : RepositoryBase<AuthorityDataSource>, ISe
""";
await ExecuteAsync(tenantId, sql, cmd =>
{
cmd.Parameters.AddWithValue("user_id", userId);
cmd.Parameters.AddWithValue("end_reason", reason);
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "user_id", userId);
AddParameter(cmd, "end_reason", reason);
}, cancellationToken).ConfigureAwait(false);
}
@@ -105,24 +116,23 @@ public sealed class SessionRepository : RepositoryBase<AuthorityDataSource>, ISe
{
const string sql = "DELETE FROM authority.sessions 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 using var command = CreateCommand(sql, connection);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
private static SessionEntity MapSession(System.Data.Common.DbDataReader reader) => new()
private static SessionEntity MapSession(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(0),
TenantId = reader.GetString(1),
UserId = reader.GetGuid(2),
SessionTokenHash = reader.GetString(3),
IpAddress = reader.IsDBNull(4) ? null : reader.GetString(4),
UserAgent = reader.IsDBNull(5) ? null : reader.GetString(5),
IpAddress = GetNullableString(reader, 4),
UserAgent = GetNullableString(reader, 5),
StartedAt = reader.GetFieldValue<DateTimeOffset>(6),
LastActivityAt = reader.GetFieldValue<DateTimeOffset>(7),
ExpiresAt = reader.GetFieldValue<DateTimeOffset>(8),
EndedAt = reader.IsDBNull(9) ? null : reader.GetFieldValue<DateTimeOffset>(9),
EndReason = reader.IsDBNull(10) ? null : reader.GetString(10),
EndedAt = GetNullableDateTimeOffset(reader, 9),
EndReason = GetNullableString(reader, 10),
Metadata = reader.GetString(11)
};
}

View File

@@ -1,11 +1,17 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Authority.Storage.Postgres.Models;
using StellaOps.Infrastructure.Postgres;
using StellaOps.Infrastructure.Postgres.Repositories;
namespace StellaOps.Authority.Storage.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for access token operations.
/// </summary>
public sealed class TokenRepository : RepositoryBase<AuthorityDataSource>, ITokenRepository
{
public TokenRepository(AuthorityDataSource dataSource) : base(dataSource) { }
public TokenRepository(AuthorityDataSource dataSource, ILogger<TokenRepository> logger)
: base(dataSource, logger) { }
public async Task<TokenEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
@@ -14,9 +20,9 @@ public sealed class TokenRepository : RepositoryBase<AuthorityDataSource>, IToke
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);
return await QuerySingleOrDefaultAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
MapToken, cancellationToken).ConfigureAwait(false);
}
public async Task<TokenEntity?> GetByHashAsync(string tokenHash, CancellationToken cancellationToken = default)
@@ -27,9 +33,8 @@ public sealed class TokenRepository : RepositoryBase<AuthorityDataSource>, IToke
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 command = CreateCommand(sql, connection);
AddParameter(command, "token_hash", tokenHash);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
return await reader.ReadAsync(cancellationToken).ConfigureAwait(false) ? MapToken(reader) : null;
}
@@ -42,9 +47,9 @@ public sealed class TokenRepository : RepositoryBase<AuthorityDataSource>, IToke
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);
return await QueryAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "user_id", userId); },
MapToken, cancellationToken).ConfigureAwait(false);
}
public async Task<Guid> CreateAsync(string tenantId, TokenEntity token, CancellationToken cancellationToken = default)
@@ -55,17 +60,18 @@ public sealed class TokenRepository : RepositoryBase<AuthorityDataSource>, IToke
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);
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "id", id);
AddParameter(command, "tenant_id", tenantId);
AddParameter(command, "user_id", token.UserId);
AddParameter(command, "token_hash", token.TokenHash);
AddParameter(command, "token_type", token.TokenType);
AddTextArrayParameter(command, "scopes", token.Scopes);
AddParameter(command, "client_id", token.ClientId);
AddParameter(command, "expires_at", token.ExpiresAt);
AddJsonbParameter(command, "metadata", token.Metadata);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
return id;
}
@@ -77,8 +83,9 @@ public sealed class TokenRepository : RepositoryBase<AuthorityDataSource>, IToke
""";
await ExecuteAsync(tenantId, sql, cmd =>
{
cmd.Parameters.AddWithValue("id", id);
cmd.Parameters.AddWithValue("revoked_by", revokedBy);
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
AddParameter(cmd, "revoked_by", revokedBy);
}, cancellationToken).ConfigureAwait(false);
}
@@ -90,8 +97,9 @@ public sealed class TokenRepository : RepositoryBase<AuthorityDataSource>, IToke
""";
await ExecuteAsync(tenantId, sql, cmd =>
{
cmd.Parameters.AddWithValue("user_id", userId);
cmd.Parameters.AddWithValue("revoked_by", revokedBy);
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "user_id", userId);
AddParameter(cmd, "revoked_by", revokedBy);
}, cancellationToken).ConfigureAwait(false);
}
@@ -99,31 +107,34 @@ public sealed class TokenRepository : RepositoryBase<AuthorityDataSource>, IToke
{
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 using var command = CreateCommand(sql, connection);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
private static TokenEntity MapToken(System.Data.Common.DbDataReader reader) => new()
private static TokenEntity MapToken(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(0),
TenantId = reader.GetString(1),
UserId = reader.IsDBNull(2) ? null : reader.GetGuid(2),
UserId = GetNullableGuid(reader, 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),
ClientId = GetNullableString(reader, 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),
RevokedAt = GetNullableDateTimeOffset(reader, 9),
RevokedBy = GetNullableString(reader, 10),
Metadata = reader.GetString(11)
};
}
/// <summary>
/// PostgreSQL repository for refresh token operations.
/// </summary>
public sealed class RefreshTokenRepository : RepositoryBase<AuthorityDataSource>, IRefreshTokenRepository
{
public RefreshTokenRepository(AuthorityDataSource dataSource) : base(dataSource) { }
public RefreshTokenRepository(AuthorityDataSource dataSource, ILogger<RefreshTokenRepository> logger)
: base(dataSource, logger) { }
public async Task<RefreshTokenEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
@@ -132,9 +143,9 @@ public sealed class RefreshTokenRepository : RepositoryBase<AuthorityDataSource>
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);
return await QuerySingleOrDefaultAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
MapRefreshToken, cancellationToken).ConfigureAwait(false);
}
public async Task<RefreshTokenEntity?> GetByHashAsync(string tokenHash, CancellationToken cancellationToken = default)
@@ -145,9 +156,8 @@ public sealed class RefreshTokenRepository : RepositoryBase<AuthorityDataSource>
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 command = CreateCommand(sql, connection);
AddParameter(command, "token_hash", tokenHash);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
return await reader.ReadAsync(cancellationToken).ConfigureAwait(false) ? MapRefreshToken(reader) : null;
}
@@ -160,9 +170,9 @@ public sealed class RefreshTokenRepository : RepositoryBase<AuthorityDataSource>
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);
return await QueryAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "user_id", userId); },
MapRefreshToken, cancellationToken).ConfigureAwait(false);
}
public async Task<Guid> CreateAsync(string tenantId, RefreshTokenEntity token, CancellationToken cancellationToken = default)
@@ -173,16 +183,17 @@ public sealed class RefreshTokenRepository : RepositoryBase<AuthorityDataSource>
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);
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "id", id);
AddParameter(command, "tenant_id", tenantId);
AddParameter(command, "user_id", token.UserId);
AddParameter(command, "token_hash", token.TokenHash);
AddParameter(command, "access_token_id", token.AccessTokenId);
AddParameter(command, "client_id", token.ClientId);
AddParameter(command, "expires_at", token.ExpiresAt);
AddJsonbParameter(command, "metadata", token.Metadata);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
return id;
}
@@ -194,9 +205,10 @@ public sealed class RefreshTokenRepository : RepositoryBase<AuthorityDataSource>
""";
await ExecuteAsync(tenantId, sql, cmd =>
{
cmd.Parameters.AddWithValue("id", id);
cmd.Parameters.AddWithValue("revoked_by", revokedBy);
AddNullableParameter(cmd, "replaced_by", replacedBy);
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
AddParameter(cmd, "revoked_by", revokedBy);
AddParameter(cmd, "replaced_by", replacedBy);
}, cancellationToken).ConfigureAwait(false);
}
@@ -208,8 +220,9 @@ public sealed class RefreshTokenRepository : RepositoryBase<AuthorityDataSource>
""";
await ExecuteAsync(tenantId, sql, cmd =>
{
cmd.Parameters.AddWithValue("user_id", userId);
cmd.Parameters.AddWithValue("revoked_by", revokedBy);
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "user_id", userId);
AddParameter(cmd, "revoked_by", revokedBy);
}, cancellationToken).ConfigureAwait(false);
}
@@ -217,24 +230,23 @@ public sealed class RefreshTokenRepository : RepositoryBase<AuthorityDataSource>
{
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 using var command = CreateCommand(sql, connection);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
private static RefreshTokenEntity MapRefreshToken(System.Data.Common.DbDataReader reader) => new()
private static RefreshTokenEntity MapRefreshToken(NpgsqlDataReader 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),
AccessTokenId = GetNullableGuid(reader, 4),
ClientId = GetNullableString(reader, 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),
RevokedAt = GetNullableDateTimeOffset(reader, 8),
RevokedBy = GetNullableString(reader, 9),
ReplacedBy = GetNullableGuid(reader, 10),
Metadata = reader.GetString(11)
};
}

View File

@@ -29,6 +29,13 @@ public static class ServiceCollectionExtensions
// Register repositories
services.AddScoped<ITenantRepository, TenantRepository>();
services.AddScoped<IUserRepository, UserRepository>();
services.AddScoped<IRoleRepository, RoleRepository>();
services.AddScoped<IPermissionRepository, PermissionRepository>();
services.AddScoped<ITokenRepository, TokenRepository>();
services.AddScoped<IRefreshTokenRepository, RefreshTokenRepository>();
services.AddScoped<IApiKeyRepository, ApiKeyRepository>();
services.AddScoped<ISessionRepository, SessionRepository>();
services.AddScoped<IAuditRepository, AuditRepository>();
return services;
}
@@ -49,6 +56,13 @@ public static class ServiceCollectionExtensions
// Register repositories
services.AddScoped<ITenantRepository, TenantRepository>();
services.AddScoped<IUserRepository, UserRepository>();
services.AddScoped<IRoleRepository, RoleRepository>();
services.AddScoped<IPermissionRepository, PermissionRepository>();
services.AddScoped<ITokenRepository, TokenRepository>();
services.AddScoped<IRefreshTokenRepository, RefreshTokenRepository>();
services.AddScoped<IApiKeyRepository, ApiKeyRepository>();
services.AddScoped<ISessionRepository, SessionRepository>();
services.AddScoped<IAuditRepository, AuditRepository>();
return services;
}

View File

@@ -0,0 +1,167 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Authority.Storage.Postgres.Models;
using StellaOps.Authority.Storage.Postgres.Repositories;
using Xunit;
namespace StellaOps.Authority.Storage.Postgres.Tests;
[Collection(AuthorityPostgresCollection.Name)]
public sealed class ApiKeyRepositoryTests : IAsyncLifetime
{
private readonly AuthorityPostgresFixture _fixture;
private readonly ApiKeyRepository _repository;
private readonly string _tenantId = Guid.NewGuid().ToString();
public ApiKeyRepositoryTests(AuthorityPostgresFixture fixture)
{
_fixture = fixture;
var options = fixture.Fixture.CreateOptions();
options.SchemaName = fixture.SchemaName;
var dataSource = new AuthorityDataSource(Options.Create(options), NullLogger<AuthorityDataSource>.Instance);
_repository = new ApiKeyRepository(dataSource, NullLogger<ApiKeyRepository>.Instance);
}
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task CreateAndGetByPrefix_RoundTripsApiKey()
{
// Arrange
var keyPrefix = "sk_live_" + Guid.NewGuid().ToString("N")[..8];
var apiKey = new ApiKeyEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
UserId = Guid.NewGuid(),
Name = "CI/CD Key",
KeyHash = "sha256_key_" + Guid.NewGuid().ToString("N"),
KeyPrefix = keyPrefix,
Scopes = ["scan:read", "scan:write"],
Status = ApiKeyStatus.Active,
ExpiresAt = DateTimeOffset.UtcNow.AddYears(1)
};
// Act
await _repository.CreateAsync(_tenantId, apiKey);
var fetched = await _repository.GetByPrefixAsync(keyPrefix);
// Assert
fetched.Should().NotBeNull();
fetched!.Id.Should().Be(apiKey.Id);
fetched.Name.Should().Be("CI/CD Key");
fetched.Scopes.Should().BeEquivalentTo(["scan:read", "scan:write"]);
}
[Fact]
public async Task GetById_ReturnsApiKey()
{
// Arrange
var apiKey = CreateApiKey(Guid.NewGuid(), "Test Key");
await _repository.CreateAsync(_tenantId, apiKey);
// Act
var fetched = await _repository.GetByIdAsync(_tenantId, apiKey.Id);
// Assert
fetched.Should().NotBeNull();
fetched!.Name.Should().Be("Test Key");
}
[Fact]
public async Task GetByUserId_ReturnsUserApiKeys()
{
// Arrange
var userId = Guid.NewGuid();
var key1 = CreateApiKey(userId, "Key 1");
var key2 = CreateApiKey(userId, "Key 2");
await _repository.CreateAsync(_tenantId, key1);
await _repository.CreateAsync(_tenantId, key2);
// Act
var keys = await _repository.GetByUserIdAsync(_tenantId, userId);
// Assert
keys.Should().HaveCount(2);
}
[Fact]
public async Task List_ReturnsAllKeysForTenant()
{
// Arrange
var key1 = CreateApiKey(Guid.NewGuid(), "Key A");
var key2 = CreateApiKey(Guid.NewGuid(), "Key B");
await _repository.CreateAsync(_tenantId, key1);
await _repository.CreateAsync(_tenantId, key2);
// Act
var keys = await _repository.ListAsync(_tenantId);
// Assert
keys.Should().HaveCount(2);
}
[Fact]
public async Task Revoke_UpdatesStatusAndRevokedFields()
{
// Arrange
var apiKey = CreateApiKey(Guid.NewGuid(), "ToRevoke");
await _repository.CreateAsync(_tenantId, apiKey);
// Act
await _repository.RevokeAsync(_tenantId, apiKey.Id, "security@test.com");
var fetched = await _repository.GetByIdAsync(_tenantId, apiKey.Id);
// Assert
fetched!.Status.Should().Be(ApiKeyStatus.Revoked);
fetched.RevokedAt.Should().NotBeNull();
fetched.RevokedBy.Should().Be("security@test.com");
}
[Fact]
public async Task UpdateLastUsed_SetsLastUsedAt()
{
// Arrange
var apiKey = CreateApiKey(Guid.NewGuid(), "Usage Test");
await _repository.CreateAsync(_tenantId, apiKey);
// Act
await _repository.UpdateLastUsedAsync(_tenantId, apiKey.Id);
var fetched = await _repository.GetByIdAsync(_tenantId, apiKey.Id);
// Assert
fetched!.LastUsedAt.Should().NotBeNull();
fetched.LastUsedAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5));
}
[Fact]
public async Task Delete_RemovesApiKey()
{
// Arrange
var apiKey = CreateApiKey(Guid.NewGuid(), "ToDelete");
await _repository.CreateAsync(_tenantId, apiKey);
// Act
await _repository.DeleteAsync(_tenantId, apiKey.Id);
var fetched = await _repository.GetByIdAsync(_tenantId, apiKey.Id);
// Assert
fetched.Should().BeNull();
}
private ApiKeyEntity CreateApiKey(Guid userId, string name) => new()
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
UserId = userId,
Name = name,
KeyHash = $"sha256_{Guid.NewGuid():N}",
KeyPrefix = $"sk_test_{Guid.NewGuid():N}"[..16],
Scopes = ["read"],
Status = ApiKeyStatus.Active,
ExpiresAt = DateTimeOffset.UtcNow.AddYears(1)
};
}

View File

@@ -0,0 +1,192 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Authority.Storage.Postgres.Models;
using StellaOps.Authority.Storage.Postgres.Repositories;
using Xunit;
namespace StellaOps.Authority.Storage.Postgres.Tests;
[Collection(AuthorityPostgresCollection.Name)]
public sealed class AuditRepositoryTests : IAsyncLifetime
{
private readonly AuthorityPostgresFixture _fixture;
private readonly AuditRepository _repository;
private readonly string _tenantId = Guid.NewGuid().ToString();
public AuditRepositoryTests(AuthorityPostgresFixture fixture)
{
_fixture = fixture;
var options = fixture.Fixture.CreateOptions();
options.SchemaName = fixture.SchemaName;
var dataSource = new AuthorityDataSource(Options.Create(options), NullLogger<AuthorityDataSource>.Instance);
_repository = new AuditRepository(dataSource, NullLogger<AuditRepository>.Instance);
}
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task Create_ReturnsGeneratedId()
{
// Arrange
var audit = new AuditEntity
{
TenantId = _tenantId,
UserId = Guid.NewGuid(),
Action = "user.login",
ResourceType = "user",
ResourceId = Guid.NewGuid().ToString(),
IpAddress = "192.168.1.1",
UserAgent = "Mozilla/5.0",
CorrelationId = Guid.NewGuid().ToString()
};
// Act
var id = await _repository.CreateAsync(_tenantId, audit);
// Assert
id.Should().BeGreaterThan(0);
}
[Fact]
public async Task List_ReturnsAuditEntriesOrderedByCreatedAtDesc()
{
// Arrange
var audit1 = CreateAudit("action1");
var audit2 = CreateAudit("action2");
await _repository.CreateAsync(_tenantId, audit1);
await Task.Delay(10); // Ensure different timestamps
await _repository.CreateAsync(_tenantId, audit2);
// Act
var audits = await _repository.ListAsync(_tenantId, limit: 10);
// Assert
audits.Should().HaveCount(2);
audits[0].Action.Should().Be("action2"); // Most recent first
}
[Fact]
public async Task GetByUserId_ReturnsUserAudits()
{
// Arrange
var userId = Guid.NewGuid();
var audit = new AuditEntity
{
TenantId = _tenantId,
UserId = userId,
Action = "user.action",
ResourceType = "test"
};
await _repository.CreateAsync(_tenantId, audit);
// Act
var audits = await _repository.GetByUserIdAsync(_tenantId, userId);
// Assert
audits.Should().HaveCount(1);
audits[0].UserId.Should().Be(userId);
}
[Fact]
public async Task GetByResource_ReturnsResourceAudits()
{
// Arrange
var resourceId = Guid.NewGuid().ToString();
var audit = new AuditEntity
{
TenantId = _tenantId,
Action = "resource.update",
ResourceType = "role",
ResourceId = resourceId
};
await _repository.CreateAsync(_tenantId, audit);
// Act
var audits = await _repository.GetByResourceAsync(_tenantId, "role", resourceId);
// Assert
audits.Should().HaveCount(1);
audits[0].ResourceId.Should().Be(resourceId);
}
[Fact]
public async Task GetByCorrelationId_ReturnsCorrelatedAudits()
{
// Arrange
var correlationId = Guid.NewGuid().ToString();
var audit1 = new AuditEntity
{
TenantId = _tenantId,
Action = "step1",
ResourceType = "test",
CorrelationId = correlationId
};
var audit2 = new AuditEntity
{
TenantId = _tenantId,
Action = "step2",
ResourceType = "test",
CorrelationId = correlationId
};
await _repository.CreateAsync(_tenantId, audit1);
await _repository.CreateAsync(_tenantId, audit2);
// Act
var audits = await _repository.GetByCorrelationIdAsync(_tenantId, correlationId);
// Assert
audits.Should().HaveCount(2);
audits.Should().AllSatisfy(a => a.CorrelationId.Should().Be(correlationId));
}
[Fact]
public async Task GetByAction_ReturnsMatchingAudits()
{
// Arrange
await _repository.CreateAsync(_tenantId, CreateAudit("user.login"));
await _repository.CreateAsync(_tenantId, CreateAudit("user.logout"));
await _repository.CreateAsync(_tenantId, CreateAudit("user.login"));
// Act
var audits = await _repository.GetByActionAsync(_tenantId, "user.login");
// Assert
audits.Should().HaveCount(2);
audits.Should().AllSatisfy(a => a.Action.Should().Be("user.login"));
}
[Fact]
public async Task Create_StoresJsonbValues()
{
// Arrange
var audit = new AuditEntity
{
TenantId = _tenantId,
Action = "config.update",
ResourceType = "config",
OldValue = "{\"setting\": \"old\"}",
NewValue = "{\"setting\": \"new\"}"
};
// Act
await _repository.CreateAsync(_tenantId, audit);
var audits = await _repository.GetByActionAsync(_tenantId, "config.update");
// Assert
audits.Should().HaveCount(1);
audits[0].OldValue.Should().Contain("old");
audits[0].NewValue.Should().Contain("new");
}
private AuditEntity CreateAudit(string action) => new()
{
TenantId = _tenantId,
UserId = Guid.NewGuid(),
Action = action,
ResourceType = "test",
ResourceId = Guid.NewGuid().ToString()
};
}

View File

@@ -0,0 +1,133 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Authority.Storage.Postgres.Models;
using StellaOps.Authority.Storage.Postgres.Repositories;
using Xunit;
namespace StellaOps.Authority.Storage.Postgres.Tests;
[Collection(AuthorityPostgresCollection.Name)]
public sealed class PermissionRepositoryTests : IAsyncLifetime
{
private readonly AuthorityPostgresFixture _fixture;
private readonly PermissionRepository _repository;
private readonly string _tenantId = Guid.NewGuid().ToString();
public PermissionRepositoryTests(AuthorityPostgresFixture fixture)
{
_fixture = fixture;
var options = fixture.Fixture.CreateOptions();
options.SchemaName = fixture.SchemaName;
var dataSource = new AuthorityDataSource(Options.Create(options), NullLogger<AuthorityDataSource>.Instance);
_repository = new PermissionRepository(dataSource, NullLogger<PermissionRepository>.Instance);
}
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task CreateAndGet_RoundTripsPermission()
{
// Arrange
var permission = new PermissionEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "users:read",
Resource = "users",
Action = "read",
Description = "Read user data"
};
// Act
await _repository.CreateAsync(_tenantId, permission);
var fetched = await _repository.GetByIdAsync(_tenantId, permission.Id);
// Assert
fetched.Should().NotBeNull();
fetched!.Name.Should().Be("users:read");
fetched.Resource.Should().Be("users");
fetched.Action.Should().Be("read");
}
[Fact]
public async Task GetByName_ReturnsCorrectPermission()
{
// Arrange
var permission = new PermissionEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "roles:write",
Resource = "roles",
Action = "write"
};
await _repository.CreateAsync(_tenantId, permission);
// Act
var fetched = await _repository.GetByNameAsync(_tenantId, "roles:write");
// Assert
fetched.Should().NotBeNull();
fetched!.Id.Should().Be(permission.Id);
}
[Fact]
public async Task List_ReturnsAllPermissionsForTenant()
{
// Arrange
var perm1 = new PermissionEntity { Id = Guid.NewGuid(), TenantId = _tenantId, Name = "p1", Resource = "r1", Action = "a1" };
var perm2 = new PermissionEntity { Id = Guid.NewGuid(), TenantId = _tenantId, Name = "p2", Resource = "r2", Action = "a2" };
await _repository.CreateAsync(_tenantId, perm1);
await _repository.CreateAsync(_tenantId, perm2);
// Act
var permissions = await _repository.ListAsync(_tenantId);
// Assert
permissions.Should().HaveCount(2);
}
[Fact]
public async Task GetByResource_ReturnsResourcePermissions()
{
// Arrange
var perm1 = new PermissionEntity { Id = Guid.NewGuid(), TenantId = _tenantId, Name = "scans:read", Resource = "scans", Action = "read" };
var perm2 = new PermissionEntity { Id = Guid.NewGuid(), TenantId = _tenantId, Name = "scans:write", Resource = "scans", Action = "write" };
var perm3 = new PermissionEntity { Id = Guid.NewGuid(), TenantId = _tenantId, Name = "users:read", Resource = "users", Action = "read" };
await _repository.CreateAsync(_tenantId, perm1);
await _repository.CreateAsync(_tenantId, perm2);
await _repository.CreateAsync(_tenantId, perm3);
// Act
var permissions = await _repository.GetByResourceAsync(_tenantId, "scans");
// Assert
permissions.Should().HaveCount(2);
permissions.Should().AllSatisfy(p => p.Resource.Should().Be("scans"));
}
[Fact]
public async Task Delete_RemovesPermission()
{
// Arrange
var permission = new PermissionEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "temp:delete",
Resource = "temp",
Action = "delete"
};
await _repository.CreateAsync(_tenantId, permission);
// Act
await _repository.DeleteAsync(_tenantId, permission.Id);
var fetched = await _repository.GetByIdAsync(_tenantId, permission.Id);
// Assert
fetched.Should().BeNull();
}
}

View File

@@ -0,0 +1,148 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Authority.Storage.Postgres.Models;
using StellaOps.Authority.Storage.Postgres.Repositories;
using Xunit;
namespace StellaOps.Authority.Storage.Postgres.Tests;
[Collection(AuthorityPostgresCollection.Name)]
public sealed class RefreshTokenRepositoryTests : IAsyncLifetime
{
private readonly AuthorityPostgresFixture _fixture;
private readonly RefreshTokenRepository _repository;
private readonly string _tenantId = Guid.NewGuid().ToString();
public RefreshTokenRepositoryTests(AuthorityPostgresFixture fixture)
{
_fixture = fixture;
var options = fixture.Fixture.CreateOptions();
options.SchemaName = fixture.SchemaName;
var dataSource = new AuthorityDataSource(Options.Create(options), NullLogger<AuthorityDataSource>.Instance);
_repository = new RefreshTokenRepository(dataSource, NullLogger<RefreshTokenRepository>.Instance);
}
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task CreateAndGetByHash_RoundTripsRefreshToken()
{
// Arrange
var token = new RefreshTokenEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
UserId = Guid.NewGuid(),
TokenHash = "refresh_hash_" + Guid.NewGuid().ToString("N"),
AccessTokenId = Guid.NewGuid(),
ClientId = "web-app",
IssuedAt = DateTimeOffset.UtcNow,
ExpiresAt = DateTimeOffset.UtcNow.AddDays(30)
};
// Act
await _repository.CreateAsync(_tenantId, token);
var fetched = await _repository.GetByHashAsync(token.TokenHash);
// Assert
fetched.Should().NotBeNull();
fetched!.Id.Should().Be(token.Id);
fetched.ClientId.Should().Be("web-app");
}
[Fact]
public async Task GetById_ReturnsToken()
{
// Arrange
var token = CreateRefreshToken(Guid.NewGuid());
await _repository.CreateAsync(_tenantId, token);
// Act
var fetched = await _repository.GetByIdAsync(_tenantId, token.Id);
// Assert
fetched.Should().NotBeNull();
fetched!.Id.Should().Be(token.Id);
}
[Fact]
public async Task GetByUserId_ReturnsUserTokens()
{
// Arrange
var userId = Guid.NewGuid();
var token1 = CreateRefreshToken(userId);
var token2 = CreateRefreshToken(userId);
await _repository.CreateAsync(_tenantId, token1);
await _repository.CreateAsync(_tenantId, token2);
// Act
var tokens = await _repository.GetByUserIdAsync(_tenantId, userId);
// Assert
tokens.Should().HaveCount(2);
}
[Fact]
public async Task Revoke_SetsRevokedFields()
{
// Arrange
var token = CreateRefreshToken(Guid.NewGuid());
await _repository.CreateAsync(_tenantId, token);
// Act
await _repository.RevokeAsync(_tenantId, token.Id, "admin@test.com", null);
var fetched = await _repository.GetByHashAsync(token.TokenHash);
// Assert
fetched!.RevokedAt.Should().NotBeNull();
fetched.RevokedBy.Should().Be("admin@test.com");
}
[Fact]
public async Task Revoke_WithReplacedBy_SetsReplacedByField()
{
// Arrange
var token = CreateRefreshToken(Guid.NewGuid());
await _repository.CreateAsync(_tenantId, token);
var newTokenId = Guid.NewGuid();
// Act
await _repository.RevokeAsync(_tenantId, token.Id, "rotation", newTokenId);
var fetched = await _repository.GetByHashAsync(token.TokenHash);
// Assert
fetched!.RevokedAt.Should().NotBeNull();
fetched.ReplacedBy.Should().Be(newTokenId);
}
[Fact]
public async Task RevokeByUserId_RevokesAllUserTokens()
{
// Arrange
var userId = Guid.NewGuid();
var token1 = CreateRefreshToken(userId);
var token2 = CreateRefreshToken(userId);
await _repository.CreateAsync(_tenantId, token1);
await _repository.CreateAsync(_tenantId, token2);
// Act
await _repository.RevokeByUserIdAsync(_tenantId, userId, "security_action");
var tokens = await _repository.GetByUserIdAsync(_tenantId, userId);
// Assert
tokens.Should().AllSatisfy(t => t.RevokedAt.Should().NotBeNull());
}
private RefreshTokenEntity CreateRefreshToken(Guid userId) => new()
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
UserId = userId,
TokenHash = $"refresh_{Guid.NewGuid():N}",
IssuedAt = DateTimeOffset.UtcNow,
ExpiresAt = DateTimeOffset.UtcNow.AddDays(30)
};
}

View File

@@ -0,0 +1,140 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Authority.Storage.Postgres.Models;
using StellaOps.Authority.Storage.Postgres.Repositories;
using Xunit;
namespace StellaOps.Authority.Storage.Postgres.Tests;
[Collection(AuthorityPostgresCollection.Name)]
public sealed class RoleRepositoryTests : IAsyncLifetime
{
private readonly AuthorityPostgresFixture _fixture;
private readonly RoleRepository _repository;
private readonly string _tenantId = Guid.NewGuid().ToString();
public RoleRepositoryTests(AuthorityPostgresFixture fixture)
{
_fixture = fixture;
var options = fixture.Fixture.CreateOptions();
options.SchemaName = fixture.SchemaName;
var dataSource = new AuthorityDataSource(Options.Create(options), NullLogger<AuthorityDataSource>.Instance);
_repository = new RoleRepository(dataSource, NullLogger<RoleRepository>.Instance);
}
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task CreateAndGet_RoundTripsRole()
{
// Arrange
var role = new RoleEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "admin",
DisplayName = "Administrator",
Description = "Full system access",
IsSystem = true,
Metadata = "{\"level\": 1}"
};
// Act
await _repository.CreateAsync(_tenantId, role);
var fetched = await _repository.GetByIdAsync(_tenantId, role.Id);
// Assert
fetched.Should().NotBeNull();
fetched!.Id.Should().Be(role.Id);
fetched.Name.Should().Be("admin");
fetched.DisplayName.Should().Be("Administrator");
fetched.IsSystem.Should().BeTrue();
}
[Fact]
public async Task GetByName_ReturnsCorrectRole()
{
// Arrange
var role = new RoleEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "viewer",
DisplayName = "Viewer",
Description = "Read-only access"
};
await _repository.CreateAsync(_tenantId, role);
// Act
var fetched = await _repository.GetByNameAsync(_tenantId, "viewer");
// Assert
fetched.Should().NotBeNull();
fetched!.Id.Should().Be(role.Id);
}
[Fact]
public async Task List_ReturnsAllRolesForTenant()
{
// Arrange
var role1 = new RoleEntity { Id = Guid.NewGuid(), TenantId = _tenantId, Name = "role1" };
var role2 = new RoleEntity { Id = Guid.NewGuid(), TenantId = _tenantId, Name = "role2" };
await _repository.CreateAsync(_tenantId, role1);
await _repository.CreateAsync(_tenantId, role2);
// Act
var roles = await _repository.ListAsync(_tenantId);
// Assert
roles.Should().HaveCount(2);
roles.Select(r => r.Name).Should().Contain(["role1", "role2"]);
}
[Fact]
public async Task Update_ModifiesRole()
{
// Arrange
var role = new RoleEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "editor",
DisplayName = "Editor"
};
await _repository.CreateAsync(_tenantId, role);
// Act
var updated = new RoleEntity
{
Id = role.Id,
TenantId = _tenantId,
Name = "editor",
DisplayName = "Content Editor",
Description = "Updated description"
};
await _repository.UpdateAsync(_tenantId, updated);
var fetched = await _repository.GetByIdAsync(_tenantId, role.Id);
// Assert
fetched!.DisplayName.Should().Be("Content Editor");
fetched.Description.Should().Be("Updated description");
}
[Fact]
public async Task Delete_RemovesRole()
{
// Arrange
var role = new RoleEntity { Id = Guid.NewGuid(), TenantId = _tenantId, Name = "temp" };
await _repository.CreateAsync(_tenantId, role);
// Act
await _repository.DeleteAsync(_tenantId, role.Id);
var fetched = await _repository.GetByIdAsync(_tenantId, role.Id);
// Assert
fetched.Should().BeNull();
}
}

View File

@@ -0,0 +1,179 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Authority.Storage.Postgres.Models;
using StellaOps.Authority.Storage.Postgres.Repositories;
using Xunit;
namespace StellaOps.Authority.Storage.Postgres.Tests;
[Collection(AuthorityPostgresCollection.Name)]
public sealed class SessionRepositoryTests : IAsyncLifetime
{
private readonly AuthorityPostgresFixture _fixture;
private readonly SessionRepository _repository;
private readonly string _tenantId = Guid.NewGuid().ToString();
public SessionRepositoryTests(AuthorityPostgresFixture fixture)
{
_fixture = fixture;
var options = fixture.Fixture.CreateOptions();
options.SchemaName = fixture.SchemaName;
var dataSource = new AuthorityDataSource(Options.Create(options), NullLogger<AuthorityDataSource>.Instance);
_repository = new SessionRepository(dataSource, NullLogger<SessionRepository>.Instance);
}
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task CreateAndGet_RoundTripsSession()
{
// Arrange
var session = new SessionEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
UserId = Guid.NewGuid(),
SessionTokenHash = "session_hash_" + Guid.NewGuid().ToString("N"),
IpAddress = "192.168.1.1",
UserAgent = "Mozilla/5.0",
StartedAt = DateTimeOffset.UtcNow,
LastActivityAt = DateTimeOffset.UtcNow,
ExpiresAt = DateTimeOffset.UtcNow.AddDays(7)
};
// Act
await _repository.CreateAsync(_tenantId, session);
var fetched = await _repository.GetByIdAsync(_tenantId, session.Id);
// Assert
fetched.Should().NotBeNull();
fetched!.Id.Should().Be(session.Id);
fetched.IpAddress.Should().Be("192.168.1.1");
fetched.UserAgent.Should().Be("Mozilla/5.0");
}
[Fact]
public async Task GetByTokenHash_ReturnsSession()
{
// Arrange
var tokenHash = "lookup_hash_" + Guid.NewGuid().ToString("N");
var session = new SessionEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
UserId = Guid.NewGuid(),
SessionTokenHash = tokenHash,
StartedAt = DateTimeOffset.UtcNow,
LastActivityAt = DateTimeOffset.UtcNow,
ExpiresAt = DateTimeOffset.UtcNow.AddDays(7)
};
await _repository.CreateAsync(_tenantId, session);
// Act
var fetched = await _repository.GetByTokenHashAsync(tokenHash);
// Assert
fetched.Should().NotBeNull();
fetched!.Id.Should().Be(session.Id);
}
[Fact]
public async Task GetByUserId_WithActiveOnly_ReturnsOnlyActiveSessions()
{
// Arrange
var userId = Guid.NewGuid();
var activeSession = CreateSession(userId);
var endedSession = new SessionEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
UserId = userId,
SessionTokenHash = "ended_" + Guid.NewGuid().ToString("N"),
StartedAt = DateTimeOffset.UtcNow.AddHours(-2),
LastActivityAt = DateTimeOffset.UtcNow.AddHours(-1),
ExpiresAt = DateTimeOffset.UtcNow.AddDays(7),
EndedAt = DateTimeOffset.UtcNow,
EndReason = "logout"
};
await _repository.CreateAsync(_tenantId, activeSession);
await _repository.CreateAsync(_tenantId, endedSession);
// Act
var activeSessions = await _repository.GetByUserIdAsync(_tenantId, userId, activeOnly: true);
var allSessions = await _repository.GetByUserIdAsync(_tenantId, userId, activeOnly: false);
// Assert
activeSessions.Should().HaveCount(1);
allSessions.Should().HaveCount(2);
}
[Fact]
public async Task UpdateLastActivity_UpdatesTimestamp()
{
// Arrange
var session = CreateSession(Guid.NewGuid());
await _repository.CreateAsync(_tenantId, session);
// Act
await Task.Delay(100); // Ensure time difference
await _repository.UpdateLastActivityAsync(_tenantId, session.Id);
var fetched = await _repository.GetByIdAsync(_tenantId, session.Id);
// Assert
fetched!.LastActivityAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5));
}
[Fact]
public async Task End_SetsEndFieldsCorrectly()
{
// Arrange
var session = CreateSession(Guid.NewGuid());
await _repository.CreateAsync(_tenantId, session);
// Act
await _repository.EndAsync(_tenantId, session.Id, "session_timeout");
var fetched = await _repository.GetByIdAsync(_tenantId, session.Id);
// Assert
fetched!.EndedAt.Should().NotBeNull();
fetched.EndReason.Should().Be("session_timeout");
}
[Fact]
public async Task EndByUserId_EndsAllUserSessions()
{
// Arrange
var userId = Guid.NewGuid();
var session1 = CreateSession(userId);
var session2 = CreateSession(userId);
await _repository.CreateAsync(_tenantId, session1);
await _repository.CreateAsync(_tenantId, session2);
// Act
await _repository.EndByUserIdAsync(_tenantId, userId, "forced_logout");
var sessions = await _repository.GetByUserIdAsync(_tenantId, userId, activeOnly: false);
// Assert
sessions.Should().HaveCount(2);
sessions.Should().AllSatisfy(s =>
{
s.EndedAt.Should().NotBeNull();
s.EndReason.Should().Be("forced_logout");
});
}
private SessionEntity CreateSession(Guid userId) => new()
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
UserId = userId,
SessionTokenHash = $"session_{Guid.NewGuid():N}",
StartedAt = DateTimeOffset.UtcNow,
LastActivityAt = DateTimeOffset.UtcNow,
ExpiresAt = DateTimeOffset.UtcNow.AddDays(7)
};
}

View File

@@ -0,0 +1,135 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Authority.Storage.Postgres.Models;
using StellaOps.Authority.Storage.Postgres.Repositories;
using Xunit;
namespace StellaOps.Authority.Storage.Postgres.Tests;
[Collection(AuthorityPostgresCollection.Name)]
public sealed class TokenRepositoryTests : IAsyncLifetime
{
private readonly AuthorityPostgresFixture _fixture;
private readonly TokenRepository _repository;
private readonly string _tenantId = Guid.NewGuid().ToString();
public TokenRepositoryTests(AuthorityPostgresFixture fixture)
{
_fixture = fixture;
var options = fixture.Fixture.CreateOptions();
options.SchemaName = fixture.SchemaName;
var dataSource = new AuthorityDataSource(Options.Create(options), NullLogger<AuthorityDataSource>.Instance);
_repository = new TokenRepository(dataSource, NullLogger<TokenRepository>.Instance);
}
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task CreateAndGetByHash_RoundTripsToken()
{
// Arrange
var token = new TokenEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
UserId = Guid.NewGuid(),
TokenHash = "sha256_hash_" + Guid.NewGuid().ToString("N"),
TokenType = TokenType.Access,
Scopes = ["read", "write"],
ClientId = "web-app",
IssuedAt = DateTimeOffset.UtcNow,
ExpiresAt = DateTimeOffset.UtcNow.AddHours(1)
};
// Act
await _repository.CreateAsync(_tenantId, token);
var fetched = await _repository.GetByHashAsync(token.TokenHash);
// Assert
fetched.Should().NotBeNull();
fetched!.Id.Should().Be(token.Id);
fetched.TokenType.Should().Be(TokenType.Access);
fetched.Scopes.Should().BeEquivalentTo(["read", "write"]);
}
[Fact]
public async Task GetById_ReturnsToken()
{
// Arrange
var token = CreateToken(Guid.NewGuid());
await _repository.CreateAsync(_tenantId, token);
// Act
var fetched = await _repository.GetByIdAsync(_tenantId, token.Id);
// Assert
fetched.Should().NotBeNull();
fetched!.Id.Should().Be(token.Id);
}
[Fact]
public async Task GetByUserId_ReturnsUserTokens()
{
// Arrange
var userId = Guid.NewGuid();
var token1 = CreateToken(userId);
var token2 = CreateToken(userId);
await _repository.CreateAsync(_tenantId, token1);
await _repository.CreateAsync(_tenantId, token2);
// Act
var tokens = await _repository.GetByUserIdAsync(_tenantId, userId);
// Assert
tokens.Should().HaveCount(2);
}
[Fact]
public async Task Revoke_SetsRevokedFields()
{
// Arrange
var token = CreateToken(Guid.NewGuid());
await _repository.CreateAsync(_tenantId, token);
// Act
await _repository.RevokeAsync(_tenantId, token.Id, "admin@test.com");
var fetched = await _repository.GetByHashAsync(token.TokenHash);
// Assert
fetched!.RevokedAt.Should().NotBeNull();
fetched.RevokedBy.Should().Be("admin@test.com");
}
[Fact]
public async Task RevokeByUserId_RevokesAllUserTokens()
{
// Arrange
var userId = Guid.NewGuid();
var token1 = CreateToken(userId);
var token2 = CreateToken(userId);
await _repository.CreateAsync(_tenantId, token1);
await _repository.CreateAsync(_tenantId, token2);
// Act
await _repository.RevokeByUserIdAsync(_tenantId, userId, "security_action");
var tokens = await _repository.GetByUserIdAsync(_tenantId, userId);
// Assert
tokens.Should().AllSatisfy(t => t.RevokedAt.Should().NotBeNull());
}
private TokenEntity CreateToken(Guid userId) => new()
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
UserId = userId,
TokenHash = $"sha256_{Guid.NewGuid():N}",
TokenType = TokenType.Access,
Scopes = ["read"],
IssuedAt = DateTimeOffset.UtcNow,
ExpiresAt = DateTimeOffset.UtcNow.AddHours(1)
};
}