mirror of
https://github.com/azaion/admin.git
synced 2026-06-21 19:31:08 +00:00
8e7c602f51
AZ-535: POST /logout (caller's session), /logout/all (all sessions for user),
admin POST /sessions/{sid}/revoke, and verifier-only GET /sessions/revoked
snapshot. New Service role gates the snapshot. Idempotent revoke; reason +
revoked_by_user_id audited per row.
AZ-533: POST /sessions/mission mints a long-lived no-refresh ES256 token bound
to one aircraft + one mission. Audience narrowed to satellite-provider, hard
12 h cap, persisted as class='mission' so the existing logout/revoke surface
covers it. Successful CompanionPC /login or /token/refresh auto-revokes that
aircraft's open mission session (post-flight reconnect).
Schema: 09_sessions_logout_and_mission.sql adds revoked_by_user_id, class,
aircraft_id; drops NOT NULL on refresh_hash for mission rows; adds two partial
indexes for the auto-revoke and snapshot hot paths.
Tests: 13 new e2e tests, all green; full suite 75/76 (1 pre-existing flake in
PasswordHashingTests AC5 timing assertion, unrelated to this batch).
Co-authored-by: Cursor <cursoragent@cursor.com>
106 lines
4.4 KiB
C#
106 lines
4.4 KiB
C#
using Azaion.Common;
|
|
using Azaion.Common.Database;
|
|
using Azaion.Common.Entities;
|
|
using LinqToDB;
|
|
|
|
namespace Azaion.Services;
|
|
|
|
/// <summary>
|
|
/// AZ-535 — logout/revocation surface. Distinct from <see cref="IRefreshTokenService"/>:
|
|
/// 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.
|
|
/// </summary>
|
|
public interface ISessionService
|
|
{
|
|
/// <summary>
|
|
/// Revoke a single session by id. Returns the revocation status BEFORE this
|
|
/// call: <c>true</c> if it was already revoked (idempotent no-op),
|
|
/// <c>false</c> if this call is the one that revoked it.
|
|
/// </summary>
|
|
Task<bool> RevokeBySid(Guid sessionId, Guid? byUserId, string reason, CancellationToken ct = default);
|
|
|
|
/// <summary>
|
|
/// Revoke every active session for a user. Returns the count of rows newly
|
|
/// revoked by this call.
|
|
/// </summary>
|
|
Task<int> RevokeAllForUser(Guid userId, Guid? byUserId, string reason, CancellationToken ct = default);
|
|
|
|
/// <summary>
|
|
/// AZ-533 — auto-revoke every open mission session belonging to <paramref name="aircraftId"/>.
|
|
/// Fired on successful /login or /token/refresh from the aircraft's own user.
|
|
/// </summary>
|
|
Task<int> RevokeMissionsForAircraft(Guid aircraftId, CancellationToken ct = default);
|
|
|
|
/// <summary>
|
|
/// AZ-535 AC-4 — verifier-poll snapshot. Returns sessions revoked since
|
|
/// <paramref name="since"/> whose <c>exp</c> is still in the future, so the
|
|
/// list stays bounded.
|
|
/// </summary>
|
|
Task<IReadOnlyList<RevokedSession>> 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<bool> 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<int> 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<int> 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<IReadOnlyList<RevokedSession>> 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)));
|
|
}
|
|
}
|