This commit is contained in:
StellaOps Bot
2025-11-29 02:19:58 +02:00
parent b34f13dc03
commit a4c4fda2a1
4 changed files with 292 additions and 0 deletions

View File

@@ -0,0 +1,136 @@
using StellaOps.Authority.Storage.Postgres.Models;
using StellaOps.Infrastructure.Postgres;
namespace StellaOps.Authority.Storage.Postgres.Repositories;
public sealed class AuditRepository : RepositoryBase<AuthorityDataSource>, IAuditRepository
{
public AuditRepository(AuthorityDataSource dataSource) : base(dataSource) { }
public async Task<long> CreateAsync(string tenantId, AuditEntity audit, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO authority.audit (tenant_id, user_id, action, resource_type, resource_id, old_value, new_value, ip_address, user_agent, correlation_id)
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);
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
return (long)result!;
}
public async Task<IReadOnlyList<AuditEntity>> ListAsync(string tenantId, int limit = 100, int offset = 0, CancellationToken cancellationToken = default)
{
const string 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
ORDER BY created_at DESC
LIMIT @limit OFFSET @offset
""";
return await QueryAsync(tenantId, sql, MapAudit, cmd =>
{
cmd.Parameters.AddWithValue("limit", limit);
cmd.Parameters.AddWithValue("offset", offset);
}, cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<AuditEntity>> GetByUserIdAsync(string tenantId, Guid userId, int limit = 100, CancellationToken cancellationToken = default)
{
const string 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 user_id = @user_id
ORDER BY created_at DESC
LIMIT @limit
""";
return await QueryAsync(tenantId, sql, MapAudit, cmd =>
{
cmd.Parameters.AddWithValue("user_id", userId);
cmd.Parameters.AddWithValue("limit", limit);
}, cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<AuditEntity>> GetByResourceAsync(string tenantId, string resourceType, string? resourceId, int limit = 100, CancellationToken cancellationToken = default)
{
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 =>
{
cmd.Parameters.AddWithValue("resource_type", resourceType);
if (resourceId != null) cmd.Parameters.AddWithValue("resource_id", resourceId);
cmd.Parameters.AddWithValue("limit", limit);
}, cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<AuditEntity>> GetByCorrelationIdAsync(string tenantId, string correlationId, CancellationToken cancellationToken = default)
{
const string 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 correlation_id = @correlation_id
ORDER BY created_at
""";
return await QueryAsync(tenantId, sql, MapAudit,
cmd => { cmd.Parameters.AddWithValue("correlation_id", correlationId); },
cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<AuditEntity>> GetByActionAsync(string tenantId, string action, int limit = 100, CancellationToken cancellationToken = default)
{
const string 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 action = @action
ORDER BY created_at DESC
LIMIT @limit
""";
return await QueryAsync(tenantId, sql, MapAudit, cmd =>
{
cmd.Parameters.AddWithValue("action", action);
cmd.Parameters.AddWithValue("limit", limit);
}, 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()
{
Id = reader.GetInt64(0),
TenantId = reader.GetString(1),
UserId = reader.IsDBNull(2) ? null : reader.GetGuid(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),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(11)
};
}

View File

@@ -0,0 +1,13 @@
using StellaOps.Authority.Storage.Postgres.Models;
namespace StellaOps.Authority.Storage.Postgres.Repositories;
public interface IAuditRepository
{
Task<long> CreateAsync(string tenantId, AuditEntity audit, CancellationToken cancellationToken = default);
Task<IReadOnlyList<AuditEntity>> ListAsync(string tenantId, int limit = 100, int offset = 0, CancellationToken cancellationToken = default);
Task<IReadOnlyList<AuditEntity>> GetByUserIdAsync(string tenantId, Guid userId, int limit = 100, CancellationToken cancellationToken = default);
Task<IReadOnlyList<AuditEntity>> GetByResourceAsync(string tenantId, string resourceType, string? resourceId, int limit = 100, CancellationToken cancellationToken = default);
Task<IReadOnlyList<AuditEntity>> GetByCorrelationIdAsync(string tenantId, string correlationId, CancellationToken cancellationToken = default);
Task<IReadOnlyList<AuditEntity>> GetByActionAsync(string tenantId, string action, int limit = 100, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,15 @@
using StellaOps.Authority.Storage.Postgres.Models;
namespace StellaOps.Authority.Storage.Postgres.Repositories;
public interface ISessionRepository
{
Task<SessionEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
Task<SessionEntity?> GetByTokenHashAsync(string sessionTokenHash, CancellationToken cancellationToken = default);
Task<IReadOnlyList<SessionEntity>> GetByUserIdAsync(string tenantId, Guid userId, bool activeOnly = true, CancellationToken cancellationToken = default);
Task<Guid> CreateAsync(string tenantId, SessionEntity session, CancellationToken cancellationToken = default);
Task UpdateLastActivityAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
Task EndAsync(string tenantId, Guid id, string reason, CancellationToken cancellationToken = default);
Task EndByUserIdAsync(string tenantId, Guid userId, string reason, CancellationToken cancellationToken = default);
Task DeleteExpiredAsync(CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,128 @@
using StellaOps.Authority.Storage.Postgres.Models;
using StellaOps.Infrastructure.Postgres;
namespace StellaOps.Authority.Storage.Postgres.Repositories;
public sealed class SessionRepository : RepositoryBase<AuthorityDataSource>, ISessionRepository
{
public SessionRepository(AuthorityDataSource dataSource) : base(dataSource) { }
public async Task<SessionEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string 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 id = @id
""";
return await QuerySingleOrDefaultAsync(tenantId, sql, MapSession,
cmd => { cmd.Parameters.AddWithValue("id", id); },
cancellationToken).ConfigureAwait(false);
}
public async Task<SessionEntity?> GetByTokenHashAsync(string sessionTokenHash, CancellationToken cancellationToken = default)
{
const string 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 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 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 = $"""
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);
}
public async Task<Guid> CreateAsync(string tenantId, SessionEntity session, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO authority.sessions (id, tenant_id, user_id, session_token_hash, ip_address, user_agent, expires_at, metadata)
VALUES (@id, @tenant_id, @user_id, @session_token_hash, @ip_address, @user_agent, @expires_at, @metadata::jsonb)
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);
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);
}
public async Task EndAsync(string tenantId, Guid id, string reason, CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE authority.sessions SET ended_at = NOW(), end_reason = @end_reason
WHERE tenant_id = @tenant_id AND id = @id AND ended_at IS NULL
""";
await ExecuteAsync(tenantId, sql, cmd =>
{
cmd.Parameters.AddWithValue("id", id);
cmd.Parameters.AddWithValue("end_reason", reason);
}, cancellationToken).ConfigureAwait(false);
}
public async Task EndByUserIdAsync(string tenantId, Guid userId, string reason, CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE authority.sessions SET ended_at = NOW(), end_reason = @end_reason
WHERE tenant_id = @tenant_id AND user_id = @user_id AND ended_at IS NULL
""";
await ExecuteAsync(tenantId, sql, cmd =>
{
cmd.Parameters.AddWithValue("user_id", userId);
cmd.Parameters.AddWithValue("end_reason", reason);
}, cancellationToken).ConfigureAwait(false);
}
public async Task DeleteExpiredAsync(CancellationToken cancellationToken = default)
{
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 command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
private static SessionEntity MapSession(System.Data.Common.DbDataReader 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),
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),
Metadata = reader.GetString(11)
};
}