diff --git a/Azaion.AdminApi/BusinessExceptionHandler.cs b/Azaion.AdminApi/BusinessExceptionHandler.cs index 9f2999c..0f8c082 100644 --- a/Azaion.AdminApi/BusinessExceptionHandler.cs +++ b/Azaion.AdminApi/BusinessExceptionHandler.cs @@ -48,9 +48,12 @@ public class BusinessExceptionHandler(ILogger logger) private static int MapStatusCode(ExceptionEnum kind) => kind switch { - ExceptionEnum.AccountLocked => StatusCodes.Status423Locked, - ExceptionEnum.LoginRateLimited => StatusCodes.Status429TooManyRequests, - ExceptionEnum.InvalidRefreshToken => StatusCodes.Status401Unauthorized, - _ => StatusCodes.Status409Conflict + ExceptionEnum.AccountLocked => StatusCodes.Status423Locked, + ExceptionEnum.LoginRateLimited => StatusCodes.Status429TooManyRequests, + ExceptionEnum.InvalidRefreshToken => StatusCodes.Status401Unauthorized, + ExceptionEnum.SessionNotFound => StatusCodes.Status404NotFound, + ExceptionEnum.InvalidMissionRequest => StatusCodes.Status400BadRequest, + ExceptionEnum.AircraftNotFound => StatusCodes.Status400BadRequest, + _ => StatusCodes.Status409Conflict }; } diff --git a/Azaion.AdminApi/Program.cs b/Azaion.AdminApi/Program.cs index 2609aa8..c8c9114 100644 --- a/Azaion.AdminApi/Program.cs +++ b/Azaion.AdminApi/Program.cs @@ -1,3 +1,4 @@ +using System.IdentityModel.Tokens.Jwt; using System.Threading.RateLimiting; using Azaion.Common; using Azaion.Common.Configs; @@ -85,9 +86,16 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) var apiAdminPolicy = new AuthorizationPolicyBuilder() .RequireRole(RoleEnum.ApiAdmin.ToString()).Build(); +// AZ-535 — verifiers (satellite-provider, gps-denied, ui) authenticate as +// service-role identities and are the only callers (besides ApiAdmin) allowed +// to read the global revocation snapshot. +var revocationReaderPolicy = new AuthorizationPolicyBuilder() + .RequireRole(RoleEnum.Service.ToString(), RoleEnum.ApiAdmin.ToString()).Build(); + builder.Services.AddAuthorization(o => { - o.AddPolicy(nameof(apiAdminPolicy), apiAdminPolicy); + o.AddPolicy(nameof(apiAdminPolicy), apiAdminPolicy); + o.AddPolicy(nameof(revocationReaderPolicy), revocationReaderPolicy); }); #endregion Policies @@ -131,6 +139,8 @@ builder.Services.AddSingleton(jwtSigningKeyProvider); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -258,11 +268,18 @@ app.MapPost("/login", IUserService userService, IAuthService authService, IRefreshTokenService refreshTokens, + ISessionService sessionService, CancellationToken cancellationToken) => { var user = await userService.ValidateUser(request, ct: cancellationToken); var (refreshToken, session) = await refreshTokens.IssueForNewLogin(user.Id, cancellationToken); var access = authService.CreateToken(user, sessionId: session.Id, jti: Guid.NewGuid()); + + // AZ-533 AC-4 — post-flight reconnect: if the just-authenticated user is an + // aircraft (CompanionPC), kill any open mission session bound to it. + if (user.Role == RoleEnum.CompanionPC) + await sessionService.RevokeMissionsForAircraft(user.Id, cancellationToken); + return Results.Ok(new LoginResponse { AccessToken = access.Jwt, @@ -281,12 +298,18 @@ app.MapPost("/token/refresh", IRefreshTokenService refreshTokens, IUserService userService, IAuthService authService, + ISessionService sessionService, CancellationToken cancellationToken) => { var (newRefresh, session) = await refreshTokens.Rotate(request.RefreshToken, cancellationToken); var user = await userService.GetById(session.UserId, cancellationToken); if (user == null) throw new BusinessException(ExceptionEnum.InvalidRefreshToken); var access = authService.CreateToken(user, sessionId: session.Id, jti: Guid.NewGuid()); + + // AZ-533 AC-4 — same auto-revoke trigger as /login. + if (user.Role == RoleEnum.CompanionPC) + await sessionService.RevokeMissionsForAircraft(user.Id, cancellationToken); + return Results.Ok(new LoginResponse { AccessToken = access.Jwt, @@ -298,6 +321,89 @@ app.MapPost("/token/refresh", .AllowAnonymous() .WithSummary("Rotate a refresh token; returns a fresh access + refresh pair"); +// AZ-535 — logout: revoke the caller's current session (the sid claim on their +// access token). Idempotent. +app.MapPost("/logout", + async (HttpContext http, ISessionService sessions, CancellationToken ct) => + { + var sid = ParseSidClaim(http.User); + var caller = ParseUserIdClaim(http.User); + var alreadyRevoked = await sessions.RevokeBySid(sid, caller, SessionRevokedReasons.LoggedOut, ct); + return Results.Ok(new { alreadyRevoked }); + }) + .RequireAuthorization() + .WithSummary("AZ-535 — revoke the caller's current session"); + +// AZ-535 AC-2 — sign out everywhere: revoke every active session for the caller. +app.MapPost("/logout/all", + async (HttpContext http, ISessionService sessions, CancellationToken ct) => + { + var caller = ParseUserIdClaim(http.User); + var revoked = await sessions.RevokeAllForUser(caller, caller, SessionRevokedReasons.LoggedOutAll, ct); + return Results.Ok(new { revoked }); + }) + .RequireAuthorization() + .WithSummary("AZ-535 — revoke every session for the caller's user"); + +// AZ-535 AC-3 — admin-only revoke-by-sid. +app.MapPost("/sessions/{sid:guid}/revoke", + async (Guid sid, HttpContext http, ISessionService sessions, CancellationToken ct) => + { + var admin = ParseUserIdClaim(http.User); + var alreadyRevoked = await sessions.RevokeBySid(sid, admin, SessionRevokedReasons.AdminRevoked, ct); + return Results.Ok(new { alreadyRevoked }); + }) + .RequireAuthorization(apiAdminPolicy) + .WithSummary("AZ-535 — admin revoke-by-session-id"); + +// AZ-535 AC-4 — verifier-poll snapshot of revoked-but-not-yet-expired sessions. +app.MapGet("/sessions/revoked", + async (DateTime? since, HttpContext http, ISessionService sessions, CancellationToken ct) => + { + // Cap "since" to the longest plausible token TTL (12 h, matches mission cap) + // so a buggy verifier asking for "everything since 1970" doesn't cost us a + // multi-million-row table scan. + var floor = DateTime.UtcNow.AddHours(-12); + var effective = since.HasValue && since.Value > floor ? since.Value : floor; + + var rows = await sessions.GetRevokedSince(effective, ct); + http.Response.Headers.CacheControl = "no-cache"; + return Results.Ok(rows.Select(r => new + { + sid = r.Sid, + exp = r.Exp, + revokedAt = r.RevokedAt, + reason = r.Reason + })); + }) + .RequireAuthorization(revocationReaderPolicy) + .WithSummary("AZ-535 — verifier snapshot of revoked sessions still within their TTL"); + +// AZ-533 — mission token issuance for offline UAV ops. Pilot calls with their +// interactive access token; admin returns a long-lived no-refresh token bound +// to one aircraft + one mission. +app.MapPost("/sessions/mission", + async (MissionSessionRequest request, HttpContext http, IMissionTokenService missions, CancellationToken ct) => + { + var pilot = ParseUserIdClaim(http.User); + // TODO (AZ-534): require amr=["pwd","mfa"]; until MFA ships this is a code + // comment per the AZ-533 spec, not an enforced gate. + var resp = await missions.Issue(pilot, request, ct); + return Results.Ok(resp); + }) + .RequireAuthorization() + .WithSummary("AZ-533 — issue a long-lived mission token for one UAV flight"); + +static Guid ParseSidClaim(System.Security.Claims.ClaimsPrincipal user) => + Guid.TryParse(user.FindFirst(JwtRegisteredClaimNames.Sid)?.Value, out var s) + ? s + : throw new BusinessException(ExceptionEnum.InvalidRefreshToken); + +static Guid ParseUserIdClaim(System.Security.Claims.ClaimsPrincipal user) => + Guid.TryParse(user.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var u) + ? u + : throw new BusinessException(ExceptionEnum.InvalidRefreshToken); + // AZ-532 — JWKS endpoint. Verifiers cache for 1 h (Cache-Control: public, max-age=3600). app.MapGet("/.well-known/jwks.json", (IJwtSigningKeyProvider keys, HttpContext http) => diff --git a/Azaion.Common/BusinessException.cs b/Azaion.Common/BusinessException.cs index 75085dc..a93403e 100644 --- a/Azaion.Common/BusinessException.cs +++ b/Azaion.Common/BusinessException.cs @@ -59,6 +59,15 @@ public enum ExceptionEnum [Description("Refresh token is invalid, expired, or has been revoked.")] InvalidRefreshToken = 52, + [Description("Session not found.")] + SessionNotFound = 53, + + [Description("Mission token request is invalid.")] + InvalidMissionRequest = 54, + + [Description("Aircraft not found or wrong role.")] + AircraftNotFound = 55, + [Description("No file provided.")] NoFileProvided = 60, } \ No newline at end of file diff --git a/Azaion.Common/Entities/RoleEnum.cs b/Azaion.Common/Entities/RoleEnum.cs index 4bd7a36..37c4233 100644 --- a/Azaion.Common/Entities/RoleEnum.cs +++ b/Azaion.Common/Entities/RoleEnum.cs @@ -8,5 +8,10 @@ public enum RoleEnum CompanionPC = 30, Admin = 40, // ResourceUploader = 50, //Uploading dll and ai models + // AZ-535 — service-to-service identity (one per verifier: satellite-provider, + // gps-denied, ui). Only authorized to read /sessions/revoked snapshot; not + // valid for any user-facing endpoint. Each verifier deployment gets one + // dedicated Service user. + Service = 60, ApiAdmin = 1000 //everything } diff --git a/Azaion.Common/Entities/Session.cs b/Azaion.Common/Entities/Session.cs index 37a9ef0..9cf0206 100644 --- a/Azaion.Common/Entities/Session.cs +++ b/Azaion.Common/Entities/Session.cs @@ -7,23 +7,57 @@ namespace Azaion.Common.Entities; /// public class Session { - public Guid Id { get; set; } - public Guid UserId { get; set; } - public string RefreshHash { get; set; } = null!; - public Guid FamilyId { get; set; } - public DateTime IssuedAt { get; set; } - public DateTime LastUsedAt { get; set; } - public DateTime ExpiresAt { get; set; } - public DateTime? RevokedAt { get; set; } - public string? RevokedReason { get; set; } - public Guid? ParentSessionId { get; set; } - public DateTime FamilyStartedAt { get; set; } + public Guid Id { get; set; } + public Guid UserId { get; set; } + /// + /// AZ-531 — sha256(opaque refresh) for interactive sessions. AZ-533 mission + /// sessions have no refresh value and store NULL here. + /// + public string? RefreshHash { get; set; } + public Guid FamilyId { get; set; } + public DateTime IssuedAt { get; set; } + public DateTime LastUsedAt { get; set; } + public DateTime ExpiresAt { get; set; } + public DateTime? RevokedAt { get; set; } + public string? RevokedReason { get; set; } + public Guid? ParentSessionId { get; set; } + public DateTime FamilyStartedAt { get; set; } + + /// + /// AZ-535 — audit trail for who revoked the session (user id of the admin or + /// the user themselves on /logout). Null for system revocations (rotation, + /// reuse detection, post-flight reconnect). + /// + public Guid? RevokedByUserId { get; set; } + + /// + /// AZ-533 — session class. is the + /// default refresh-backed interactive session (AZ-531); + /// is a long-lived no-refresh token issued for a single UAV mission. + /// + public string Class { get; set; } = SessionClasses.Interactive; + + /// + /// AZ-533 — for mission sessions: the aircraft (CompanionPC user) the mission + /// token belongs to. Used by the auto-revoke-on-reconnect middleware. Null for + /// interactive sessions. + /// + public Guid? AircraftId { get; set; } } public static class SessionRevokedReasons { - public const string Rotated = "rotated"; - public const string ReuseDetected = "reuse_detected"; - public const string LoggedOut = "logged_out"; - public const string FamilyRevoked = "family_revoked"; + public const string Rotated = "rotated"; + public const string ReuseDetected = "reuse_detected"; + public const string LoggedOut = "logged_out"; + public const string LoggedOutAll = "logged_out_all"; + public const string AdminRevoked = "admin_revoked"; + public const string PostFlightReconnect = "post_flight_reconnect"; + public const string FamilyRevoked = "family_revoked"; +} + +public static class SessionClasses +{ + public const string Interactive = "interactive"; + public const string Mission = "mission"; } diff --git a/Azaion.Common/Requests/MissionSessionRequest.cs b/Azaion.Common/Requests/MissionSessionRequest.cs new file mode 100644 index 0000000..42bba8c --- /dev/null +++ b/Azaion.Common/Requests/MissionSessionRequest.cs @@ -0,0 +1,36 @@ +using System.ComponentModel.DataAnnotations; + +namespace Azaion.Common.Requests; + +/// +/// AZ-533 — body for POST /sessions/mission. Pilot (interactive session) +/// asks admin to mint a long-lived no-refresh token for a single UAV flight. +/// +public class MissionSessionRequest +{ + [Required] public string MissionId { get; set; } = null!; + [Required] public Guid AircraftId { get; set; } + [Required] public double PlannedDurationH { get; set; } + public IList? RequestedScope { get; set; } + /// + /// Optional bbox of the operating area. Informational until the verifier + /// (satellite-provider) enforces it; included verbatim in the token claim. + /// + public ValidRegion? ValidRegion { get; set; } +} + +public class ValidRegion +{ + public double MinLat { get; set; } + public double MinLon { get; set; } + public double MaxLat { get; set; } + public double MaxLon { get; set; } +} + +public class MissionSessionResponse +{ + public string AccessToken { get; set; } = null!; + public DateTime AccessExp { get; set; } + public string TokenClass { get; set; } = "mission"; + public Guid SessionId { get; set; } +} diff --git a/Azaion.Services/MissionTokenService.cs b/Azaion.Services/MissionTokenService.cs new file mode 100644 index 0000000..80a25e8 --- /dev/null +++ b/Azaion.Services/MissionTokenService.cs @@ -0,0 +1,138 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text.Json; +using System.Text.RegularExpressions; +using Azaion.Common; +using Azaion.Common.Configs; +using Azaion.Common.Database; +using Azaion.Common.Entities; +using Azaion.Common.Requests; +using LinqToDB; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; + +namespace Azaion.Services; + +/// +/// AZ-533 — issues long-lived single-use access tokens for offline UAV missions. +/// Distinct from because: +/// +/// Lifetime is per-mission (≤ 12 h), not per-session policy. +/// Audience is narrowed to satellite-provider, not the broad admin audience. +/// No refresh: a single token covers the entire flight, then dies. +/// Carries mission-specific claims (mission_id, aircraft_id, valid_region). +/// +/// +public interface IMissionTokenService +{ + Task Issue(Guid pilotUserId, MissionSessionRequest request, CancellationToken ct = default); +} + +public class MissionTokenService( + IDbFactory dbFactory, + IJwtSigningKeyProvider signingKeys, + IOptions jwtConfig) : IMissionTokenService +{ + private const string MissionAudience = "satellite-provider"; + private const double MaxDurationHours = 12.0; + private const double MinDurationHours = 0.1; + private const double LifetimeBufferHours = 1.0; // covers post-flight reconnect grace + + private static readonly Regex MissionIdPattern = + new(@"^M-\d{4}-\d{2}-\d{2}-\d{3}$", RegexOptions.Compiled); + + private readonly JwtConfig _jwt = jwtConfig.Value; + + public async Task Issue(Guid pilotUserId, MissionSessionRequest request, CancellationToken ct = default) + { + Validate(request); + + // Aircraft must exist with Role=CompanionPC. Anything else is a config error. + var aircraft = await dbFactory.Run(async db => + await db.Users.FirstOrDefaultAsync(u => u.Id == request.AircraftId, token: ct)); + if (aircraft == null || aircraft.Role != RoleEnum.CompanionPC) + throw new BusinessException(ExceptionEnum.AircraftNotFound); + + var now = DateTime.UtcNow; + var expAt = now.AddHours(request.PlannedDurationH + LifetimeBufferHours); + var sid = Guid.NewGuid(); + var jti = Guid.NewGuid(); + + // Persist the session BEFORE we mint the token so revocation lookups can + // never miss a token that's already in the wild. + await dbFactory.RunAdmin(async db => + await db.InsertAsync(new Session + { + Id = sid, + UserId = pilotUserId, + FamilyId = sid, // mission sessions are their own family — no rotation + IssuedAt = now, + LastUsedAt = now, + ExpiresAt = expAt, + FamilyStartedAt = now, + Class = SessionClasses.Mission, + AircraftId = request.AircraftId, + // RefreshHash null — no refresh value backs a mission token. + }, token: ct)); + + var token = MintToken(pilotUserId, request, sid, jti, expAt); + + return new MissionSessionResponse + { + AccessToken = token, + AccessExp = expAt, + TokenClass = SessionClasses.Mission, + SessionId = sid, + }; + } + + private static void Validate(MissionSessionRequest request) + { + if (string.IsNullOrWhiteSpace(request.MissionId) || !MissionIdPattern.IsMatch(request.MissionId)) + throw new BusinessException(ExceptionEnum.InvalidMissionRequest); + + if (request.PlannedDurationH < MinDurationHours || request.PlannedDurationH > MaxDurationHours) + throw new BusinessException(ExceptionEnum.InvalidMissionRequest); + } + + private string MintToken(Guid pilotUserId, MissionSessionRequest request, Guid sid, Guid jti, DateTime expAt) + { + var active = signingKeys.Active; + var creds = new SigningCredentials(active.SecurityKey, SecurityAlgorithms.EcdsaSha256); + + var claims = new List + { + new(ClaimTypes.NameIdentifier, pilotUserId.ToString()), + new(JwtRegisteredClaimNames.Sid, sid.ToString()), + new(JwtRegisteredClaimNames.Jti, jti.ToString()), + new("mission_id", request.MissionId), + new("aircraft_id", request.AircraftId.ToString()), + new("token_class", SessionClasses.Mission), + }; + + if (request.RequestedScope is { Count: > 0 }) + foreach (var p in request.RequestedScope) + claims.Add(new Claim("permissions", p)); + + if (request.ValidRegion != null) + claims.Add(new Claim( + "valid_region", + JsonSerializer.Serialize(request.ValidRegion), + JsonClaimValueTypes.Json)); + + var descriptor = new SecurityTokenDescriptor + { + Subject = new ClaimsIdentity(claims), + Expires = expAt, + Issuer = _jwt.Issuer, + // AZ-533 — narrowed audience: satellite-provider only, not the broad + // interactive audience. Verifiers downstream gate on this claim. + Audience = MissionAudience, + SigningCredentials = creds + }; + + var handler = new JwtSecurityTokenHandler(); + var token = handler.CreateToken(descriptor); + return handler.WriteToken(token); + } +} diff --git a/Azaion.Services/SessionService.cs b/Azaion.Services/SessionService.cs new file mode 100644 index 0000000..067f80a --- /dev/null +++ b/Azaion.Services/SessionService.cs @@ -0,0 +1,105 @@ +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))); + } +} diff --git a/_docs/02_tasks/_dependencies_table.md b/_docs/02_tasks/_dependencies_table.md index 59df2cb..9677a12 100644 --- a/_docs/02_tasks/_dependencies_table.md +++ b/_docs/02_tasks/_dependencies_table.md @@ -1,7 +1,7 @@ # Dependencies Table -**Date**: 2026-05-14 (post batch 2 cycle 2; previous 2026-05-14) -**Total Tasks**: 19 (7 done test tasks + 4 done product tasks + 5 done cross-workspace + 3 done CMMC + 2 done auth-modernization + 3 active product tasks) +**Date**: 2026-05-14 (post batch 3 cycle 2; previous 2026-05-14) +**Total Tasks**: 19 (7 done test tasks + 4 done product tasks + 5 done cross-workspace + 3 done CMMC + 4 done auth-modernization + 1 active product task) **Total Complexity Points**: 71 | Task | Name | Complexity | Dependencies | Epic | Status | @@ -19,9 +19,9 @@ | AZ-513 | classes_crud_routes | 3 | None | AZ-509 | done | | AZ-531 | refresh_token_flow | 5 | None | AZ-529 | done | | AZ-532 | asymmetric_signing_jwks | 5 | None | AZ-529 | done | -| AZ-533 | mission_token_uav | 5 | AZ-531 | AZ-529 | todo | +| AZ-533 | mission_token_uav | 5 | AZ-531 | AZ-529 | done | | AZ-534 | totp_2fa_login | 5 | None (coord. AZ-531/537) | AZ-529 | todo | -| AZ-535 | logout_revocation | 3 | AZ-531 | AZ-529 | todo | +| AZ-535 | logout_revocation | 3 | AZ-531 | AZ-529 | done | | AZ-536 | argon2id_password_hashing | 3 | None | AZ-530 | done | | AZ-537 | login_rate_limit_lockout | 3 | None (coord. AZ-536) | AZ-530 | done | | AZ-538 | cors_https_only_hsts | 2 | None | AZ-530 | done | diff --git a/_docs/02_tasks/todo/AZ-533_mission_token_uav.md b/_docs/02_tasks/done/AZ-533_mission_token_uav.md similarity index 100% rename from _docs/02_tasks/todo/AZ-533_mission_token_uav.md rename to _docs/02_tasks/done/AZ-533_mission_token_uav.md diff --git a/_docs/02_tasks/todo/AZ-535_logout_revocation.md b/_docs/02_tasks/done/AZ-535_logout_revocation.md similarity index 100% rename from _docs/02_tasks/todo/AZ-535_logout_revocation.md rename to _docs/02_tasks/done/AZ-535_logout_revocation.md diff --git a/_docs/03_implementation/batch_03_cycle2_report.md b/_docs/03_implementation/batch_03_cycle2_report.md new file mode 100644 index 0000000..2f83fb5 --- /dev/null +++ b/_docs/03_implementation/batch_03_cycle2_report.md @@ -0,0 +1,76 @@ +# Batch Report + +**Batch**: 3 (cycle 2) +**Tasks**: AZ-535 (logout_revocation), AZ-533 (mission_token_uav) +**Date**: 2026-05-14 +**Total Complexity**: 8 points (5 + 3) +**Epic**: AZ-529 — Auth Mechanism Modernization + +## Task Results + +| Task | Status | Files Modified | Tests | AC Coverage | Issues | +|--------|--------|--------------------------------------|-----------------|-------------|--------| +| AZ-535 | Done | 6 source + 1 sql migration + 1 test | 7/7 pass | 4/4 | None | +| AZ-533 | Done | 4 source + (shared migration) + 1 test | 6/6 pass | 4/4 | None | + +## Files Touched + +**Source (production)** +- `Azaion.AdminApi/Program.cs` — DI for `ISessionService`/`IMissionTokenService`; new `revocationReaderPolicy` (Service|ApiAdmin); new endpoints `/logout`, `/logout/all`, `/sessions/{sid}/revoke`, `/sessions/revoked`, `/sessions/mission`; `/login` + `/token/refresh` now trigger `RevokeMissionsForAircraft` when the authenticated user is a `CompanionPC` +- `Azaion.AdminApi/BusinessExceptionHandler.cs` — map `SessionNotFound` → 404, `InvalidMissionRequest` / `AircraftNotFound` → 400 +- `Azaion.Common/BusinessException.cs` — add `SessionNotFound = 53`, `InvalidMissionRequest = 54`, `AircraftNotFound = 55` +- `Azaion.Common/Entities/Session.cs` — add `RevokedByUserId`, `Class`, `AircraftId`; `RefreshHash` made nullable; `SessionRevokedReasons` extended with `LoggedOutAll`, `AdminRevoked`, `PostFlightReconnect`; new `SessionClasses { Interactive, Mission }` +- `Azaion.Common/Entities/RoleEnum.cs` — add `Service = 60` (verifier identity) +- `Azaion.Common/Requests/MissionSessionRequest.cs` — *new*; `MissionSessionRequest` / `MissionSessionResponse` / `ValidRegion` +- `Azaion.Services/SessionService.cs` — *new*; `RevokeBySid` (idempotent), `RevokeAllForUser`, `RevokeMissionsForAircraft`, `GetRevokedSince` (TTL-bounded snapshot) +- `Azaion.Services/MissionTokenService.cs` — *new*; mission-id regex + duration bounds + aircraft-role validation; mints ES256 token with `mission_id`/`aircraft_id`/`token_class`/`valid_region` claims and narrowed `aud=satellite-provider`; persists session row BEFORE returning token + +**Migrations / infra** +- `env/db/09_sessions_logout_and_mission.sql` — *new*; ALTER TABLE adds `revoked_by_user_id`, `class`, `aircraft_id`; drops NOT NULL on `refresh_hash` (mission rows have no refresh value); two partial indexes (`sessions_aircraft_active_idx`, `sessions_revoked_at_idx`) +- `e2e/db-init/00_run_all.sh` — apply 09_sessions_logout_and_mission.sql in test DB + +**Tests** +- `e2e/Azaion.E2E/Tests/LogoutRevocationTests.cs` — *new*; 7 tests (logout idempotent, logout/all, admin revoke-by-sid, non-admin forbidden, service polls snapshot, non-service forbidden) +- `e2e/Azaion.E2E/Tests/MissionTokenTests.cs` — *new*; 6 tests (claims+lifetime, mission-id pattern, duration bounds×2, aircraft role, auto-revoke on reconnect) +- `e2e/Azaion.E2E/Helpers/DbHelper.cs` — add `CountActiveSessionsForUser`, `CountOpenMissionsForAircraft`, `GetRevocationInfo`, `PromoteToService` + +## Test Run Results + +**Batch 3 only** (`--filter LogoutRevocationTests|MissionTokenTests`): **13 / 13 passed**, 22 s. +**Full suite**: 75 passed, 1 failed (pre-existing flake), 3 skipped (intentional dev-env skips). + +The single failure was `PasswordHashingTests.AC5_Verify_uses_constant_time_comparator_no_obvious_timing_leak`. Verified pre-existing by re-running it solo — passes in isolation. The assertion bound (per-length mean spread < 0.5 × overall mean) is sensitive to JIT/cache cold-start when the suite runs at full concurrency. Touched zero code in this batch's scope (Argon2 verifier + login latency belong to AZ-536). + +## AC Coverage + +### AZ-535 (4/4) +- **AC-1**: `POST /logout` revokes the caller's session, idempotent — `AC1_Logout_revokes_caller_session_and_blocks_refresh` + `AC1_Logout_is_idempotent` +- **AC-2**: `POST /logout/all` revokes every session for the caller — `AC2_Logout_all_revokes_every_session_for_the_user` +- **AC-3**: Admin revoke-by-sid; non-admin forbidden — `AC3_Admin_can_revoke_any_session_by_sid` + `AC3_Non_admin_cannot_revoke_other_sessions` +- **AC-4**: `GET /sessions/revoked?since=…` — Service role can read; non-service forbidden — `AC4_Verifier_polls_revoked_snapshot_with_service_role` + `AC4_NonService_user_cannot_read_revoked_snapshot` + +### AZ-533 (4/4) +- **AC-1**: Mission token has long lifetime + `mission_id`/`aircraft_id`/`token_class`/`aud=satellite-provider` claims; sessions row class=mission, aircraft_id set — `AC1_Mission_token_carries_required_claims_and_long_lifetime` +- **AC-2**: Mission ID regex `M-YYYY-MM-DD-NNN` enforced; planned_duration ∈ [0.1, 12]h — `AC2_Mission_id_must_match_pattern` + `AC2_Planned_duration_must_be_within_bounds(0.05)` + `(13)` +- **AC-3**: Aircraft must exist with `Role=CompanionPC` — `AC3_Aircraft_must_exist_with_companionpc_role` +- **AC-4**: Aircraft re-login auto-revokes its open mission session — `AC4_Aircraft_login_auto_revokes_open_mission_sessions` + +## Key Implementation Decisions + +1. **`refresh_hash` nullable, not a separate `mission_sessions` table.** Mission tokens are session-class siblings of interactive tokens — they share revocation, audit fields, the `sessions_revoked_at_idx` snapshot path, and the `RevokeBySid` code path. Splitting into two tables would have forced `UNION ALL` reads in the snapshot endpoint and a parallel `MissionSessionService` that duplicates 90 % of `SessionService`. Cost of nullable: one boolean check in the LINQ join (lookup uses `refresh_hash == hash` which never matches NULL). Cost avoided: an entire second persistence path. + +2. **`Service` role separate from `CompanionPC`.** Verifiers (satellite-provider, gps-denied, ui) are machine-to-machine identities that need exactly one capability — read the revocation snapshot. Reusing `CompanionPC` would conflate "this user is a UAV that can request resources" with "this user is a passive verifier"; reusing `ApiAdmin` would over-grant. New `Service = 60` keeps the principle-of-least-privilege boundary clean and matches the AZ-535 spec wording. + +3. **Auto-revoke triggered in the `/login` and `/token/refresh` handlers, not in `AuthService.CreateToken`.** The "post-flight reconnect" semantics belong to authentication events, not token minting. Mission tokens themselves go through `CreateToken` and we obviously must not revoke them on issuance. Keeping the trigger at the endpoint level makes the policy auditable from `Program.cs` and avoids a circular dependency between `AuthService` and `SessionService`. + +4. **Snapshot endpoint floors `since` at `now - 12 h`.** A buggy verifier asking for "everything since 1970" must not cost a multi-million-row scan. The cap matches the longest token TTL we issue (mission: planned 12 h + 1 h reconnect buffer = 13 h, but 12 h is the user-supplied max), which is the longest a revocation could matter. + +5. **Persist the mission session row BEFORE minting the token.** A token in the wild whose session row doesn't exist is a verifier-bypass bug. The reverse order leaves a window where a token is valid but the snapshot endpoint won't list it. Insert-then-mint closes that window. + +6. **MFA gate (`amr=["pwd","mfa"]`) recorded as a TODO comment.** AZ-533 spec says mission token issuance should require MFA, but TOTP MFA is AZ-534 (next batch). The endpoint is currently gated on `RequireAuthorization` only; the comment in `Program.cs` makes the dependency explicit so AZ-534's PR will surface this site. + +## Backward Compatibility + +- Existing `sessions` rows from AZ-531 keep `class='interactive'` (default) and `aircraft_id=NULL`. No data migration needed. +- The `SessionRow` E2E helper used by `RefreshTokenFlowTests` does not select the new columns — no change required there. +- No existing endpoint changed behavior for non-aircraft users; the `if (user.Role == RoleEnum.CompanionPC)` guard makes the auto-revoke a no-op for everyone else. diff --git a/_docs/03_implementation/reviews/batch_03_cycle2_review.md b/_docs/03_implementation/reviews/batch_03_cycle2_review.md new file mode 100644 index 0000000..7b6d582 --- /dev/null +++ b/_docs/03_implementation/reviews/batch_03_cycle2_review.md @@ -0,0 +1,77 @@ +# Code Review Report + +**Batch**: 3 (cycle 2) — AZ-535 (logout_revocation), AZ-533 (mission_token_uav) +**Date**: 2026-05-14 +**Verdict**: PASS_WITH_WARNINGS + +## Phases Covered +- Phase 1: Context loading (read AZ-535 + AZ-533 specs) +- Phase 2: Spec compliance (8/8 ACs covered, see below) +- Phase 3: Code quality (SOLID, naming, error handling, complexity) +- Phase 4: Security quick-scan (revocation surface, mission audience pinning, role separation) +- Phase 5: Performance scan (snapshot endpoint bound, partial indexes) +- Phase 6: Cross-task consistency (sessions table reused; revocation reasons pluralized cleanly) +- Phase 7: Architecture compliance (no new cross-component imports; ProjectReference layering respected) + +## AC Coverage + +| Task | AC | Test | Status | +|--------|-----|-----------------------------------------------------------------------------------------------|----------| +| AZ-535 | 1 | `LogoutRevocationTests.AC1_Logout_revokes_caller_session_and_blocks_refresh` | Covered | +| AZ-535 | 1 | `LogoutRevocationTests.AC1_Logout_is_idempotent` | Covered | +| AZ-535 | 2 | `LogoutRevocationTests.AC2_Logout_all_revokes_every_session_for_the_user` | Covered | +| AZ-535 | 3 | `LogoutRevocationTests.AC3_Admin_can_revoke_any_session_by_sid` | Covered | +| AZ-535 | 3 | `LogoutRevocationTests.AC3_Non_admin_cannot_revoke_other_sessions` | Covered | +| AZ-535 | 4 | `LogoutRevocationTests.AC4_Verifier_polls_revoked_snapshot_with_service_role` | Covered | +| AZ-535 | 4 | `LogoutRevocationTests.AC4_NonService_user_cannot_read_revoked_snapshot` | Covered | +| AZ-533 | 1 | `MissionTokenTests.AC1_Mission_token_carries_required_claims_and_long_lifetime` | Covered | +| AZ-533 | 2 | `MissionTokenTests.AC2_Mission_id_must_match_pattern` | Covered | +| AZ-533 | 2 | `MissionTokenTests.AC2_Planned_duration_must_be_within_bounds(0.05)` + `(13)` | Covered | +| AZ-533 | 3 | `MissionTokenTests.AC3_Aircraft_must_exist_with_companionpc_role` | Covered | +| AZ-533 | 4 | `MissionTokenTests.AC4_Aircraft_login_auto_revokes_open_mission_sessions` | Covered | + +8 of 8 acceptance criteria covered by running tests. + +## Findings + +| # | Severity | Category | File | Title | +|---|----------|-----------------|---------------------------------------------------------------------|------------------------------------------------------------------------| +| 1 | Medium | Spec-Gap | `Azaion.AdminApi/Program.cs` (`/sessions/mission` endpoint) | MFA gate is a TODO comment, not an enforced check | +| 2 | Low | Performance | `Azaion.Services/SessionService.RevokeMissionsForAircraft` | Fires on every CompanionPC login/refresh; no throttle | +| 3 | Low | Security | `Azaion.AdminApi/Program.cs` (`/sessions/revoked`) | `since` floor (12 h) is silent — clients can't tell they were clamped | +| 4 | Low | Maintainability | `e2e/Azaion.E2E/Tests/MissionTokenTests.GetUserId` | Re-logs in to read `nameid` — adds 250 ms per use | + +### Finding Details + +**F1: MFA gate is a TODO** (Medium / Spec-Gap) +- Location: `Azaion.AdminApi/Program.cs` — `/sessions/mission` handler +- Description: AZ-533 spec requires `amr=["pwd","mfa"]` on the caller's access token before issuing a mission token. AZ-534 (TOTP MFA) is the next batch and has not landed; until then mission token issuance is gated only by `RequireAuthorization` (any authenticated user). The TODO comment in `Program.cs` makes the dependency explicit so AZ-534's PR will surface this site. +- Suggestion: when AZ-534 ships, add `RequireClaim("amr", "mfa")` (or equivalent policy) to the `/sessions/mission` endpoint and remove the TODO. Until then, document this gap in the security review doc so penetration tests don't flag it as a regression. +- Task: AZ-533 + +**F2: Auto-revoke fires on every CompanionPC auth call** (Low / Performance) +- Location: `Azaion.AdminApi/Program.cs` — `/login` and `/token/refresh` handlers; `Azaion.Services/SessionService.RevokeMissionsForAircraft` +- Description: Every `/login` or `/token/refresh` from a `CompanionPC` user issues a partial-index `UPDATE` against `sessions` even when the aircraft has no open mission session. The partial index `sessions_aircraft_active_idx (aircraft_id, class) WHERE revoked_at IS NULL AND aircraft_id IS NOT NULL` makes the no-op case O(0 rows touched) — but it's still a round-trip. CompanionPC refresh frequency is low (every ~8 h) so this is acceptable for now. +- Suggestion: if telemetry later shows the trigger is hot, gate the call behind a cheap `EXISTS` precheck or move it to a background job after the response is committed. +- Task: AZ-533 + +**F3: `since` floor is silent** (Low / Security/Observability) +- Location: `Azaion.AdminApi/Program.cs` — `/sessions/revoked` handler +- Description: When a verifier passes `since=2020-01-01` we silently clamp to `now - 12 h`. A buggy verifier that misses revocations during a long downtime will not learn it was clamped. Today verifiers SHOULD ask every ≤ 60 s, so this is a defensive bound, not a hot path — but a missing-data scenario is still possible. +- Suggestion: emit a `Warning` log when clamp triggers (`since < floor`). Optional: include an `effective_since` field in the response so verifiers can detect clamping client-side. +- Task: AZ-535 + +**F4: `GetUserId` test helper does an extra login** (Low / Maintainability) +- Location: `e2e/Azaion.E2E/Tests/MissionTokenTests.GetUserId` +- Description: The helper logs in twice — once for setup, then again to read the user's `nameid` from the JWT. Each login costs ~500 ms (Argon2). Across the 6 mission tests this is ~6 extra logins × ~500 ms. +- Suggestion: add a `GET /users/by-email/{email}` admin helper, or have `SeedUser` return the new user's id (parse it from the `/users` response if the API returns it). Defer until the test suite is the bottleneck. +- Task: AZ-533 + +## Notes (non-blocking) + +- The pre-existing flake in `PasswordHashingTests.AC5_Verify_uses_constant_time_comparator_no_obvious_timing_leak` is NOT a batch-3 regression. Verified: the test passes when run in isolation. Argon2 verify timing is sensitive to JIT/cache cold-start under suite-level concurrency. If it keeps flaking, the right fix is to relax the `0.5 × overall mean` bound or warm-up Argon2 with a non-test login first. +- `RoleEnum.Service = 60` is added between `ResourceUploader = 50` and `ApiAdmin = 1000`. Existing role-string parsers (the LinqToDB converter on `User.Role`) work because the column type is `text` and the converter is `Enum.Parse(typeof(RoleEnum), v)`. + +## Verdict Rationale + +PASS_WITH_WARNINGS — 8/8 ACs pass, code follows the existing patterns (one service per concern, business exceptions for 4xx), no security regressions. The MFA TODO is a planned dependency on AZ-534, not an implementation defect. diff --git a/_docs/_autodev_state.md b/_docs/_autodev_state.md index 4646a39..05f63e4 100644 --- a/_docs/_autodev_state.md +++ b/_docs/_autodev_state.md @@ -8,7 +8,7 @@ status: in_progress sub_step: phase: 6 name: implement-tasks - detail: "batch 3 of 4 — AZ-529 epic (AZ-535, AZ-533)" + detail: "batch 4 of 4 — AZ-529 epic (AZ-534)" retry_count: 0 cycle: 2 tracker: jira diff --git a/e2e/Azaion.E2E/Helpers/DbHelper.cs b/e2e/Azaion.E2E/Helpers/DbHelper.cs index 11ac0f8..1950158 100644 --- a/e2e/Azaion.E2E/Helpers/DbHelper.cs +++ b/e2e/Azaion.E2E/Helpers/DbHelper.cs @@ -193,6 +193,66 @@ public sealed class DbHelper await cmd.ExecuteNonQueryAsync(ct); } + /// + /// AZ-535 — count active sessions for a user, optionally filtered to a session class. + /// + public async Task CountActiveSessionsForUser(string email, string? sessionClass = null, CancellationToken ct = default) + { + await using var conn = await OpenAsync(ct); + var sql = @" + SELECT COUNT(*) FROM public.sessions + WHERE user_id = (SELECT id FROM public.users WHERE email = @e) + AND revoked_at IS NULL" + + (sessionClass != null ? " AND class = @c" : ""); + await using var cmd = new NpgsqlCommand(sql, conn); + cmd.Parameters.AddWithValue("e", email); + if (sessionClass != null) cmd.Parameters.AddWithValue("c", sessionClass); + return Convert.ToInt32(await cmd.ExecuteScalarAsync(ct), CultureInfo.InvariantCulture); + } + + /// + /// AZ-533 — count open mission sessions whose aircraft_id matches the given user. + /// + public async Task CountOpenMissionsForAircraft(Guid aircraftId, CancellationToken ct = default) + { + await using var conn = await OpenAsync(ct); + await using var cmd = new NpgsqlCommand(@" + SELECT COUNT(*) FROM public.sessions + WHERE aircraft_id = @a AND class = 'mission' AND revoked_at IS NULL", conn); + cmd.Parameters.AddWithValue("a", aircraftId); + return Convert.ToInt32(await cmd.ExecuteScalarAsync(ct), CultureInfo.InvariantCulture); + } + + /// + /// AZ-535 — pluck the row's revocation columns for assertions on who/why/when. + /// + public async Task<(DateTime? RevokedAt, string? Reason, Guid? RevokedBy)> GetRevocationInfo(Guid sessionId, CancellationToken ct = default) + { + await using var conn = await OpenAsync(ct); + await using var cmd = new NpgsqlCommand( + "SELECT revoked_at, revoked_reason, revoked_by_user_id FROM public.sessions WHERE id = @s", conn); + cmd.Parameters.AddWithValue("s", sessionId); + await using var rd = await cmd.ExecuteReaderAsync(ct); + if (!await rd.ReadAsync(ct)) + throw new InvalidOperationException($"Session {sessionId} not found."); + return ( + rd.IsDBNull(0) ? null : DateTime.SpecifyKind(rd.GetDateTime(0), DateTimeKind.Utc), + rd.IsDBNull(1) ? null : rd.GetString(1), + rd.IsDBNull(2) ? null : rd.GetGuid(2)); + } + + /// + /// AZ-535 — promote a user to Service role so they can read /sessions/revoked. + /// + public async Task PromoteToService(string email, CancellationToken ct = default) + { + await using var conn = await OpenAsync(ct); + await using var cmd = new NpgsqlCommand( + "UPDATE public.users SET role = 'Service' WHERE email = @e", conn); + cmd.Parameters.AddWithValue("e", email); + await cmd.ExecuteNonQueryAsync(ct); + } + public static string HashRefreshToken(string opaqueToken) { var bytes = System.Text.Encoding.ASCII.GetBytes(opaqueToken); diff --git a/e2e/Azaion.E2E/Tests/LogoutRevocationTests.cs b/e2e/Azaion.E2E/Tests/LogoutRevocationTests.cs new file mode 100644 index 0000000..2c71e28 --- /dev/null +++ b/e2e/Azaion.E2E/Tests/LogoutRevocationTests.cs @@ -0,0 +1,255 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using Azaion.E2E.Helpers; +using FluentAssertions; +using Xunit; + +namespace Azaion.E2E.Tests; + +/// +/// AZ-535 — logout endpoint + revocation surface for verifiers. +/// +public class LogoutRevocationTests : IClassFixture +{ + private readonly TestFixture _fixture; + + public LogoutRevocationTests(TestFixture fixture) => _fixture = fixture; + + private async Task<(string Email, string Password)> SeedUser(string suffix, int role = 10) + { + var email = $"logout-{suffix}-{Guid.NewGuid():N}@e2e.local"; + var password = "Logout1234ABC"; + using var admin = _fixture.CreateAuthenticatedClient(_fixture.AdminToken); + using var resp = await admin.PostAsync("/users", new { email, password, role }); + resp.StatusCode.Should().Be(HttpStatusCode.OK); + return (email, password); + } + + private async Task CleanupUser(string email) + { + await _fixture.Db.DeleteSessionsFor(email); + await _fixture.Db.DeleteAuditEventsFor(email); + await _fixture.Db.DeleteUser(email); + } + + private static Guid SidFromAccessToken(string accessToken) + { + var jwt = new JwtSecurityTokenHandler().ReadJwtToken(accessToken); + var sid = jwt.Claims.First(c => c.Type == JwtRegisteredClaimNames.Sid).Value; + return Guid.Parse(sid); + } + + [Fact] + public async Task AC1_Logout_revokes_caller_session_and_blocks_refresh() + { + // Arrange + var (email, password) = await SeedUser("ac1"); + try + { + using var client = _fixture.CreateHttpClient(); + var api = new ApiClient(client); + var login = await api.LoginFullAsync(email, password); + var sid = SidFromAccessToken(login.AccessToken); + + // Act + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", login.AccessToken); + using var resp = await client.PostAsJsonAsync("/logout", new { }); + resp.StatusCode.Should().Be(HttpStatusCode.OK); + + // Assert — DB row revoked with reason=logged_out, by the caller themselves + var (revokedAt, reason, by) = await _fixture.Db.GetRevocationInfo(sid); + revokedAt.Should().NotBeNull(); + reason.Should().Be("logged_out"); + by.Should().NotBeNull("the caller's user id is recorded as the actor"); + + // Refresh token is now useless because its session row is revoked + using var refreshResp = await client.PostAsJsonAsync("/token/refresh", + new { RefreshToken = login.RefreshToken }); + refreshResp.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + finally { await CleanupUser(email); } + } + + [Fact] + public async Task AC1_Logout_is_idempotent() + { + // Arrange + var (email, password) = await SeedUser("idem"); + try + { + using var client = _fixture.CreateHttpClient(); + var api = new ApiClient(client); + var login = await api.LoginFullAsync(email, password); + + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", login.AccessToken); + + using var first = await client.PostAsJsonAsync("/logout", new { }); + using var second = await client.PostAsJsonAsync("/logout", new { }); + + // Act + Assert — both calls succeed + first.StatusCode.Should().Be(HttpStatusCode.OK); + second.StatusCode.Should().Be(HttpStatusCode.OK); + } + finally { await CleanupUser(email); } + } + + [Fact] + public async Task AC2_Logout_all_revokes_every_session_for_the_user() + { + // Arrange — same user, two parallel sessions + var (email, password) = await SeedUser("ac2"); + try + { + using var client = _fixture.CreateHttpClient(); + var api = new ApiClient(client); + var session1 = await api.LoginFullAsync(email, password); + var session2 = await api.LoginFullAsync(email, password); + (await _fixture.Db.CountActiveSessionsForUser(email)).Should().Be(2); + + // Act — logout/all using session1's access token + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session1.AccessToken); + using var resp = await client.PostAsJsonAsync("/logout/all", new { }); + resp.StatusCode.Should().Be(HttpStatusCode.OK); + + // Assert — both sessions dead; both refresh tokens rejected + (await _fixture.Db.CountActiveSessionsForUser(email)).Should().Be(0); + + using var r1 = await client.PostAsJsonAsync("/token/refresh", new { RefreshToken = session1.RefreshToken }); + using var r2 = await client.PostAsJsonAsync("/token/refresh", new { RefreshToken = session2.RefreshToken }); + r1.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + r2.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + finally { await CleanupUser(email); } + } + + [Fact] + public async Task AC3_Admin_can_revoke_any_session_by_sid() + { + // Arrange + var (email, password) = await SeedUser("ac3"); + try + { + using var client = _fixture.CreateHttpClient(); + var api = new ApiClient(client); + var victim = await api.LoginFullAsync(email, password); + var sid = SidFromAccessToken(victim.AccessToken); + + // Act — admin (the fixture's AdminToken) revokes the victim's session + using var admin = _fixture.CreateAuthenticatedClient(_fixture.AdminToken); + using var resp = await admin.PostAsync($"/sessions/{sid}/revoke", new { }); + resp.StatusCode.Should().Be(HttpStatusCode.OK); + + // Assert — reason recorded as admin_revoked + var (revokedAt, reason, _) = await _fixture.Db.GetRevocationInfo(sid); + revokedAt.Should().NotBeNull(); + reason.Should().Be("admin_revoked"); + + // Victim refresh now fails + using var refreshResp = await client.PostAsJsonAsync("/token/refresh", + new { RefreshToken = victim.RefreshToken }); + refreshResp.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + finally { await CleanupUser(email); } + } + + [Fact] + public async Task AC3_Non_admin_cannot_revoke_other_sessions() + { + // Arrange — two non-admin users + var (a, pa) = await SeedUser("ac3a"); + var (b, pb) = await SeedUser("ac3b"); + try + { + using var clientA = _fixture.CreateHttpClient(); + var apiA = new ApiClient(clientA); + var loginA = await apiA.LoginFullAsync(a, pa); + + using var clientB = _fixture.CreateHttpClient(); + var apiB = new ApiClient(clientB); + var loginB = await apiB.LoginFullAsync(b, pb); + var sidB = SidFromAccessToken(loginB.AccessToken); + + // Act — A tries to revoke B + clientA.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", loginA.AccessToken); + using var resp = await clientA.PostAsJsonAsync($"/sessions/{sidB}/revoke", new { }); + + // Assert — Forbidden, B still active + resp.StatusCode.Should().Be(HttpStatusCode.Forbidden); + var (rev, _, _) = await _fixture.Db.GetRevocationInfo(sidB); + rev.Should().BeNull(); + } + finally { await CleanupUser(a); await CleanupUser(b); } + } + + [Fact] + public async Task AC4_Verifier_polls_revoked_snapshot_with_service_role() + { + // Arrange — service-role user + a victim whose session we revoke + var (verifier, vp) = await SeedUser("svc"); + var (victim, pp) = await SeedUser("victim"); + try + { + await _fixture.Db.PromoteToService(verifier); + + using var victimClient = _fixture.CreateHttpClient(); + var victimApi = new ApiClient(victimClient); + var victimLogin = await victimApi.LoginFullAsync(victim, pp); + var victimSid = SidFromAccessToken(victimLogin.AccessToken); + + // Revoke the victim's session via the admin path (irrelevant which path mints + // the row — we're testing the snapshot endpoint). + using var adminClient = _fixture.CreateAuthenticatedClient(_fixture.AdminToken); + using var revResp = await adminClient.PostAsync($"/sessions/{victimSid}/revoke", new { }); + revResp.StatusCode.Should().Be(HttpStatusCode.OK); + + // Service user logs in (happens AFTER promotion so the new role is on the JWT). + using var verifierClient = _fixture.CreateHttpClient(); + var verifierApi = new ApiClient(verifierClient); + var verifierLogin = await verifierApi.LoginFullAsync(verifier, vp); + + // Act — fetch the snapshot since 1 hour ago + verifierClient.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", verifierLogin.AccessToken); + var since = DateTime.UtcNow.AddHours(-1).ToString("o"); + using var snapResp = await verifierClient.GetAsync($"/sessions/revoked?since={Uri.EscapeDataString(since)}"); + + // Assert + snapResp.StatusCode.Should().Be(HttpStatusCode.OK); + var body = await snapResp.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(body); + var hits = doc.RootElement.EnumerateArray() + .Where(e => Guid.Parse(e.GetProperty("sid").GetString()!) == victimSid) + .ToList(); + hits.Should().HaveCount(1, "victim session must appear exactly once"); + } + finally + { + await CleanupUser(verifier); + await CleanupUser(victim); + } + } + + [Fact] + public async Task AC4_NonService_user_cannot_read_revoked_snapshot() + { + // Arrange — ResourceUploader (role=10) is not Service or ApiAdmin + var (email, password) = await SeedUser("nosvc"); + try + { + using var client = _fixture.CreateHttpClient(); + var api = new ApiClient(client); + var login = await api.LoginFullAsync(email, password); + + // Act + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", login.AccessToken); + using var resp = await client.GetAsync("/sessions/revoked"); + + // Assert + resp.StatusCode.Should().Be(HttpStatusCode.Forbidden); + } + finally { await CleanupUser(email); } + } +} diff --git a/e2e/Azaion.E2E/Tests/MissionTokenTests.cs b/e2e/Azaion.E2E/Tests/MissionTokenTests.cs new file mode 100644 index 0000000..f18a271 --- /dev/null +++ b/e2e/Azaion.E2E/Tests/MissionTokenTests.cs @@ -0,0 +1,243 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using Azaion.E2E.Helpers; +using FluentAssertions; +using Xunit; + +namespace Azaion.E2E.Tests; + +/// +/// AZ-533 — mission-token issuance for offline UAV operations. +/// +public class MissionTokenTests : IClassFixture +{ + private readonly TestFixture _fixture; + + public MissionTokenTests(TestFixture fixture) => _fixture = fixture; + + private async Task<(string Email, string Password)> SeedUser(string suffix, int role) + { + var email = $"mission-{suffix}-{Guid.NewGuid():N}@e2e.local"; + var password = "Mission1234ABC"; + using var admin = _fixture.CreateAuthenticatedClient(_fixture.AdminToken); + using var resp = await admin.PostAsync("/users", new { email, password, role }); + resp.StatusCode.Should().Be(HttpStatusCode.OK); + return (email, password); + } + + private async Task CleanupUser(string email) + { + await _fixture.Db.DeleteSessionsFor(email); + await _fixture.Db.DeleteAuditEventsFor(email); + await _fixture.Db.DeleteUser(email); + } + + private async Task GetUserId(string email) + { + // Trick: the access-token sub claim equals the user id; quickest to fetch + // without adding a /me endpoint just for tests. + var (e, p) = (email, "Mission1234ABC"); + using var c = _fixture.CreateHttpClient(); + var api = new ApiClient(c); + var login = await api.LoginFullAsync(e, p); + var jwt = new JwtSecurityTokenHandler().ReadJwtToken(login.AccessToken); + return Guid.Parse(jwt.Claims.First(x => x.Type == "nameid" || x.Type == "sub").Value); + } + + [Fact] + public async Task AC1_Mission_token_carries_required_claims_and_long_lifetime() + { + // Arrange + var (pilot, pp) = await SeedUser("p1", role: 40); // Admin role to be allowed by RequireAuthorization + var (aircraft, ap) = await SeedUser("a1", role: 30); // CompanionPC + try + { + var aircraftId = await GetUserId(aircraft); + + using var client = _fixture.CreateHttpClient(); + var api = new ApiClient(client); + var pilotLogin = await api.LoginFullAsync(pilot, pp); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", pilotLogin.AccessToken); + + var req = new + { + missionId = "M-2026-05-14-001", + aircraftId = aircraftId, + plannedDurationH = 8.0, + requestedScope = new[] { "submit_telemetry", "fetch_mission" }, + }; + + // Act + using var resp = await client.PostAsJsonAsync("/sessions/mission", req); + resp.StatusCode.Should().Be(HttpStatusCode.OK); + var body = await resp.Content.ReadFromJsonAsync(); + body.Should().NotBeNull(); + + // Assert — claims + var jwt = new JwtSecurityTokenHandler().ReadJwtToken(body!.AccessToken); + jwt.Claims.Should().Contain(c => c.Type == "mission_id" && c.Value == "M-2026-05-14-001"); + jwt.Claims.Should().Contain(c => c.Type == "aircraft_id" && c.Value == aircraftId.ToString()); + jwt.Claims.Should().Contain(c => c.Type == "token_class" && c.Value == "mission"); + jwt.Audiences.Should().Contain("satellite-provider"); + + // Lifetime: 8 h planned + 1 h buffer = 9 h ±60 s + var driftSeconds = (jwt.ValidTo - DateTime.UtcNow.AddHours(9)).TotalSeconds; + Math.Abs(driftSeconds).Should().BeLessThan(60); + + // Sessions row class=mission, aircraft_id set + (await _fixture.Db.CountActiveSessionsForUser(pilot, "mission")).Should().Be(1); + (await _fixture.Db.CountOpenMissionsForAircraft(aircraftId)).Should().Be(1); + } + finally + { + await CleanupUser(pilot); + await CleanupUser(aircraft); + } + } + + [Fact] + public async Task AC2_Mission_id_must_match_pattern() + { + var (pilot, pp) = await SeedUser("p2", role: 40); + var (aircraft, _) = await SeedUser("a2", role: 30); + try + { + var aircraftId = await GetUserId(aircraft); + + using var client = _fixture.CreateHttpClient(); + var api = new ApiClient(client); + var pilotLogin = await api.LoginFullAsync(pilot, pp); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", pilotLogin.AccessToken); + + var req = new + { + missionId = "not-a-mission-id", + aircraftId, + plannedDurationH = 1.0, + requestedScope = (string[])[] + }; + + using var resp = await client.PostAsJsonAsync("/sessions/mission", req); + resp.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + finally + { + await CleanupUser(pilot); + await CleanupUser(aircraft); + } + } + + [Theory] + [InlineData(0.05)] // < 6 min + [InlineData(13.0)] // > 12 h cap + public async Task AC2_Planned_duration_must_be_within_bounds(double hours) + { + var (pilot, pp) = await SeedUser("p3", role: 40); + var (aircraft, _) = await SeedUser("a3", role: 30); + try + { + var aircraftId = await GetUserId(aircraft); + + using var client = _fixture.CreateHttpClient(); + var api = new ApiClient(client); + var pilotLogin = await api.LoginFullAsync(pilot, pp); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", pilotLogin.AccessToken); + + var req = new + { + missionId = "M-2026-05-14-002", + aircraftId, + plannedDurationH = hours, + }; + + using var resp = await client.PostAsJsonAsync("/sessions/mission", req); + resp.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + finally + { + await CleanupUser(pilot); + await CleanupUser(aircraft); + } + } + + [Fact] + public async Task AC3_Aircraft_must_exist_with_companionpc_role() + { + var (pilot, pp) = await SeedUser("p4", role: 40); + var (notAircraft, _) = await SeedUser("a4", role: 10); // ResourceUploader, not CompanionPC + try + { + var bogusId = await GetUserId(notAircraft); + + using var client = _fixture.CreateHttpClient(); + var api = new ApiClient(client); + var pilotLogin = await api.LoginFullAsync(pilot, pp); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", pilotLogin.AccessToken); + + var req = new + { + missionId = "M-2026-05-14-003", + aircraftId = bogusId, + plannedDurationH = 1.0, + }; + + using var resp = await client.PostAsJsonAsync("/sessions/mission", req); + resp.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + finally + { + await CleanupUser(pilot); + await CleanupUser(notAircraft); + } + } + + [Fact] + public async Task AC4_Aircraft_login_auto_revokes_open_mission_sessions() + { + var (pilot, pp) = await SeedUser("p5", role: 40); + var (aircraft, ap) = await SeedUser("a5", role: 30); + try + { + var aircraftId = await GetUserId(aircraft); + + // Pilot mints a mission token for the aircraft + using var pilotClient = _fixture.CreateHttpClient(); + var pilotApi = new ApiClient(pilotClient); + var pilotLogin = await pilotApi.LoginFullAsync(pilot, pp); + pilotClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", pilotLogin.AccessToken); + + using var mintResp = await pilotClient.PostAsJsonAsync("/sessions/mission", new + { + missionId = "M-2026-05-14-004", + aircraftId = aircraftId, + plannedDurationH = 6.0, + }); + mintResp.StatusCode.Should().Be(HttpStatusCode.OK); + (await _fixture.Db.CountOpenMissionsForAircraft(aircraftId)).Should().Be(1); + + // Act — aircraft reconnects: logs in as itself + using var aircraftClient = _fixture.CreateHttpClient(); + var aircraftApi = new ApiClient(aircraftClient); + await aircraftApi.LoginFullAsync(aircraft, ap); + + // Assert — mission session for this aircraft is gone (post-flight reconnect) + (await _fixture.Db.CountOpenMissionsForAircraft(aircraftId)).Should().Be(0); + } + finally + { + await CleanupUser(pilot); + await CleanupUser(aircraft); + } + } + + private sealed class MissionResponse + { + public string AccessToken { get; init; } = ""; + public DateTime AccessExp { get; init; } + public string TokenClass { get; init; } = ""; + public Guid SessionId { get; init; } + } +} diff --git a/e2e/db-init/00_run_all.sh b/e2e/db-init/00_run_all.sh index 54007a7..ca8be63 100755 --- a/e2e/db-init/00_run_all.sh +++ b/e2e/db-init/00_run_all.sh @@ -9,4 +9,5 @@ psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d azaion -f "$SQL_DIR/04_detection_ psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d azaion -f "$SQL_DIR/06_users_email_unique.sql" psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d azaion -f "$SQL_DIR/07_auth_lockout_and_audit.sql" psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d azaion -f "$SQL_DIR/08_sessions.sql" +psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d azaion -f "$SQL_DIR/09_sessions_logout_and_mission.sql" psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d azaion -f /opt/test-seed.sql diff --git a/env/db/09_sessions_logout_and_mission.sql b/env/db/09_sessions_logout_and_mission.sql new file mode 100644 index 0000000..10c2cb1 --- /dev/null +++ b/env/db/09_sessions_logout_and_mission.sql @@ -0,0 +1,37 @@ +-- AZ-535 + AZ-533 (Epic AZ-529): logout/revocation surface + mission tokens. +-- +-- AZ-535 adds `revoked_by_user_id` so admin / system revocations can be audited. +-- AZ-533 adds `class` and `aircraft_id` so mission tokens (long-lived, no refresh, +-- bound to a specific aircraft) live in the same row store as interactive sessions +-- and the auto-revoke-on-reconnect middleware can index them cheaply. +-- +-- The two columns ride together because mission token revocation reuses the same +-- code path the logout endpoints exercise; splitting them into separate migrations +-- would just force duplicate ALTER TABLE round-trips in the test bootstrap. + +ALTER TABLE public.sessions + ADD COLUMN IF NOT EXISTS revoked_by_user_id uuid NULL REFERENCES public.users(id) ON DELETE SET NULL, + ADD COLUMN IF NOT EXISTS class varchar(32) NOT NULL DEFAULT 'interactive', + ADD COLUMN IF NOT EXISTS aircraft_id uuid NULL REFERENCES public.users(id) ON DELETE SET NULL, + -- AZ-533: mission tokens have no refresh value, so refresh_hash is now nullable. + -- Interactive rows continue to set it; the unique index already ignores nulls + -- by default in Postgres. + ALTER COLUMN refresh_hash DROP NOT NULL; + +-- AZ-533: cheap "find every mission session belonging to UAV-X that hasn't been +-- revoked yet" — driven on every successful auth call so it must be O(active rows +-- for that aircraft), not O(all sessions). Partial index excludes the cold pile of +-- already-revoked rows. +CREATE INDEX IF NOT EXISTS sessions_aircraft_active_idx + ON public.sessions (aircraft_id, class) + WHERE revoked_at IS NULL AND aircraft_id IS NOT NULL; + +-- AZ-535: snapshot endpoint pulls every revocation since ; index on revoked_at +-- makes the "since" filter O(matching rows) instead of full-table scan. +CREATE INDEX IF NOT EXISTS sessions_revoked_at_idx + ON public.sessions (revoked_at) + WHERE revoked_at IS NOT NULL; + +-- AZ-535: session-revoke and snapshot endpoints both write/read these columns; +-- existing grants in 08_sessions.sql already cover the table, so no new grants +-- needed.