From a4c4fda2a1fd8d0d69b553653f17eb34cb559eeb Mon Sep 17 00:00:00 2001 From: StellaOps Bot Date: Sat, 29 Nov 2025 02:19:58 +0200 Subject: [PATCH] up --- .../Repositories/AuditRepository.cs | 136 ++++++++++++++++++ .../Repositories/IAuditRepository.cs | 13 ++ .../Repositories/ISessionRepository.cs | 15 ++ .../Repositories/SessionRepository.cs | 128 +++++++++++++++++ 4 files changed, 292 insertions(+) create mode 100644 src/Authority/__Libraries/StellaOps.Authority.Storage.Postgres/Repositories/AuditRepository.cs create mode 100644 src/Authority/__Libraries/StellaOps.Authority.Storage.Postgres/Repositories/IAuditRepository.cs create mode 100644 src/Authority/__Libraries/StellaOps.Authority.Storage.Postgres/Repositories/ISessionRepository.cs create mode 100644 src/Authority/__Libraries/StellaOps.Authority.Storage.Postgres/Repositories/SessionRepository.cs diff --git a/src/Authority/__Libraries/StellaOps.Authority.Storage.Postgres/Repositories/AuditRepository.cs b/src/Authority/__Libraries/StellaOps.Authority.Storage.Postgres/Repositories/AuditRepository.cs new file mode 100644 index 000000000..559aad067 --- /dev/null +++ b/src/Authority/__Libraries/StellaOps.Authority.Storage.Postgres/Repositories/AuditRepository.cs @@ -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, IAuditRepository +{ + public AuditRepository(AuthorityDataSource dataSource) : base(dataSource) { } + + public async Task 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> 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> 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> 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> 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> 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(11) + }; +} diff --git a/src/Authority/__Libraries/StellaOps.Authority.Storage.Postgres/Repositories/IAuditRepository.cs b/src/Authority/__Libraries/StellaOps.Authority.Storage.Postgres/Repositories/IAuditRepository.cs new file mode 100644 index 000000000..848dee156 --- /dev/null +++ b/src/Authority/__Libraries/StellaOps.Authority.Storage.Postgres/Repositories/IAuditRepository.cs @@ -0,0 +1,13 @@ +using StellaOps.Authority.Storage.Postgres.Models; + +namespace StellaOps.Authority.Storage.Postgres.Repositories; + +public interface IAuditRepository +{ + Task CreateAsync(string tenantId, AuditEntity audit, CancellationToken cancellationToken = default); + Task> ListAsync(string tenantId, int limit = 100, int offset = 0, CancellationToken cancellationToken = default); + Task> GetByUserIdAsync(string tenantId, Guid userId, int limit = 100, CancellationToken cancellationToken = default); + Task> GetByResourceAsync(string tenantId, string resourceType, string? resourceId, int limit = 100, CancellationToken cancellationToken = default); + Task> GetByCorrelationIdAsync(string tenantId, string correlationId, CancellationToken cancellationToken = default); + Task> GetByActionAsync(string tenantId, string action, int limit = 100, CancellationToken cancellationToken = default); +} diff --git a/src/Authority/__Libraries/StellaOps.Authority.Storage.Postgres/Repositories/ISessionRepository.cs b/src/Authority/__Libraries/StellaOps.Authority.Storage.Postgres/Repositories/ISessionRepository.cs new file mode 100644 index 000000000..8909e2c01 --- /dev/null +++ b/src/Authority/__Libraries/StellaOps.Authority.Storage.Postgres/Repositories/ISessionRepository.cs @@ -0,0 +1,15 @@ +using StellaOps.Authority.Storage.Postgres.Models; + +namespace StellaOps.Authority.Storage.Postgres.Repositories; + +public interface ISessionRepository +{ + Task GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default); + Task GetByTokenHashAsync(string sessionTokenHash, CancellationToken cancellationToken = default); + Task> GetByUserIdAsync(string tenantId, Guid userId, bool activeOnly = true, CancellationToken cancellationToken = default); + Task 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); +} diff --git a/src/Authority/__Libraries/StellaOps.Authority.Storage.Postgres/Repositories/SessionRepository.cs b/src/Authority/__Libraries/StellaOps.Authority.Storage.Postgres/Repositories/SessionRepository.cs new file mode 100644 index 000000000..4247f0a79 --- /dev/null +++ b/src/Authority/__Libraries/StellaOps.Authority.Storage.Postgres/Repositories/SessionRepository.cs @@ -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, ISessionRepository +{ + public SessionRepository(AuthorityDataSource dataSource) : base(dataSource) { } + + public async Task 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 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> 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 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(6), + LastActivityAt = reader.GetFieldValue(7), + ExpiresAt = reader.GetFieldValue(8), + EndedAt = reader.IsDBNull(9) ? null : reader.GetFieldValue(9), + EndReason = reader.IsDBNull(10) ? null : reader.GetString(10), + Metadata = reader.GetString(11) + }; +}