using Azaion.Common; using Azaion.Common.Database; using Azaion.Common.Entities; using LinqToDB; namespace Azaion.Services; /// /// AZ-535 — logout/revocation surface. Distinct from : /// refresh-token service rotates and reuse-detects; this service expresses the /// human / admin / system intent to kill a session and exposes the verifier-poll /// snapshot that powers cross-service denylists. /// public interface ISessionService { /// /// Revoke a single session by id. Returns the revocation status BEFORE this /// call: true if it was already revoked (idempotent no-op), /// false if this call is the one that revoked it. /// Task RevokeBySid(Guid sessionId, Guid? byUserId, string reason, CancellationToken ct = default); /// /// Revoke every active session for a user. Returns the count of rows newly /// revoked by this call. /// Task RevokeAllForUser(Guid userId, Guid? byUserId, string reason, CancellationToken ct = default); /// /// AZ-533 — auto-revoke every open mission session belonging to . /// Fired on successful /login or /token/refresh from the aircraft's own user. /// Task RevokeMissionsForAircraft(Guid aircraftId, CancellationToken ct = default); /// /// AZ-535 AC-4 — verifier-poll snapshot. Returns sessions revoked since /// whose exp is still in the future, so the /// list stays bounded. /// Task> GetRevokedSince(DateTime since, CancellationToken ct = default); } public sealed record RevokedSession(Guid Sid, DateTime Exp, DateTime RevokedAt, string? Reason); public class SessionService(IDbFactory dbFactory) : ISessionService { public async Task RevokeBySid(Guid sessionId, Guid? byUserId, string reason, CancellationToken ct = default) { return await dbFactory.RunAdmin(async db => { var existing = await db.Sessions.FirstOrDefaultAsync(s => s.Id == sessionId, token: ct); if (existing == null) throw new BusinessException(ExceptionEnum.SessionNotFound); if (existing.RevokedAt.HasValue) return true; // idempotent — already revoked, no DB write needed var now = DateTime.UtcNow; await db.Sessions .Where(s => s.Id == sessionId && s.RevokedAt == null) .Set(s => s.RevokedAt, now) .Set(s => s.RevokedReason, reason) .Set(s => s.RevokedByUserId, byUserId) .UpdateAsync(token: ct); return false; }); } public async Task RevokeAllForUser(Guid userId, Guid? byUserId, string reason, CancellationToken ct = default) => await dbFactory.RunAdmin(async db => { var now = DateTime.UtcNow; return await db.Sessions .Where(s => s.UserId == userId && s.RevokedAt == null) .Set(s => s.RevokedAt, now) .Set(s => s.RevokedReason, reason) .Set(s => s.RevokedByUserId, byUserId) .UpdateAsync(token: ct); }); public async Task RevokeMissionsForAircraft(Guid aircraftId, CancellationToken ct = default) => await dbFactory.RunAdmin(async db => { var now = DateTime.UtcNow; return await db.Sessions .Where(s => s.AircraftId == aircraftId && s.Class == SessionClasses.Mission && s.RevokedAt == null) .Set(s => s.RevokedAt, now) .Set(s => s.RevokedReason, SessionRevokedReasons.PostFlightReconnect) .UpdateAsync(token: ct); }); public async Task> GetRevokedSince(DateTime since, CancellationToken ct = default) { var now = DateTime.UtcNow; return await dbFactory.Run(async db => (await db.Sessions .Where(s => s.RevokedAt != null && s.RevokedAt > since && s.ExpiresAt > now) // AZ-535 AC-4: prune expired .Select(s => new RevokedSession(s.Id, s.ExpiresAt, s.RevokedAt!.Value, s.RevokedReason)) .ToListAsync(token: ct))); } }