mirror of
https://github.com/azaion/admin.git
synced 2026-06-21 11:41:09 +00:00
[AZ-535] [AZ-533] Logout/revocation surface + UAV mission tokens
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>
This commit is contained in:
@@ -48,9 +48,12 @@ public class BusinessExceptionHandler(ILogger<BusinessExceptionHandler> logger)
|
|||||||
|
|
||||||
private static int MapStatusCode(ExceptionEnum kind) => kind switch
|
private static int MapStatusCode(ExceptionEnum kind) => kind switch
|
||||||
{
|
{
|
||||||
ExceptionEnum.AccountLocked => StatusCodes.Status423Locked,
|
ExceptionEnum.AccountLocked => StatusCodes.Status423Locked,
|
||||||
ExceptionEnum.LoginRateLimited => StatusCodes.Status429TooManyRequests,
|
ExceptionEnum.LoginRateLimited => StatusCodes.Status429TooManyRequests,
|
||||||
ExceptionEnum.InvalidRefreshToken => StatusCodes.Status401Unauthorized,
|
ExceptionEnum.InvalidRefreshToken => StatusCodes.Status401Unauthorized,
|
||||||
_ => StatusCodes.Status409Conflict
|
ExceptionEnum.SessionNotFound => StatusCodes.Status404NotFound,
|
||||||
|
ExceptionEnum.InvalidMissionRequest => StatusCodes.Status400BadRequest,
|
||||||
|
ExceptionEnum.AircraftNotFound => StatusCodes.Status400BadRequest,
|
||||||
|
_ => StatusCodes.Status409Conflict
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+107
-1
@@ -1,3 +1,4 @@
|
|||||||
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
using System.Threading.RateLimiting;
|
using System.Threading.RateLimiting;
|
||||||
using Azaion.Common;
|
using Azaion.Common;
|
||||||
using Azaion.Common.Configs;
|
using Azaion.Common.Configs;
|
||||||
@@ -85,9 +86,16 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
|||||||
var apiAdminPolicy = new AuthorizationPolicyBuilder()
|
var apiAdminPolicy = new AuthorizationPolicyBuilder()
|
||||||
.RequireRole(RoleEnum.ApiAdmin.ToString()).Build();
|
.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 =>
|
builder.Services.AddAuthorization(o =>
|
||||||
{
|
{
|
||||||
o.AddPolicy(nameof(apiAdminPolicy), apiAdminPolicy);
|
o.AddPolicy(nameof(apiAdminPolicy), apiAdminPolicy);
|
||||||
|
o.AddPolicy(nameof(revocationReaderPolicy), revocationReaderPolicy);
|
||||||
});
|
});
|
||||||
|
|
||||||
#endregion Policies
|
#endregion Policies
|
||||||
@@ -131,6 +139,8 @@ builder.Services.AddSingleton<IJwtSigningKeyProvider>(jwtSigningKeyProvider);
|
|||||||
builder.Services.AddScoped<IUserService, UserService>();
|
builder.Services.AddScoped<IUserService, UserService>();
|
||||||
builder.Services.AddScoped<IAuthService, AuthService>();
|
builder.Services.AddScoped<IAuthService, AuthService>();
|
||||||
builder.Services.AddScoped<IRefreshTokenService, RefreshTokenService>();
|
builder.Services.AddScoped<IRefreshTokenService, RefreshTokenService>();
|
||||||
|
builder.Services.AddScoped<ISessionService, SessionService>();
|
||||||
|
builder.Services.AddScoped<IMissionTokenService, MissionTokenService>();
|
||||||
builder.Services.AddScoped<IResourcesService, ResourcesService>();
|
builder.Services.AddScoped<IResourcesService, ResourcesService>();
|
||||||
builder.Services.AddScoped<IDetectionClassService, DetectionClassService>();
|
builder.Services.AddScoped<IDetectionClassService, DetectionClassService>();
|
||||||
builder.Services.AddScoped<IAuditLog, AuditLog>();
|
builder.Services.AddScoped<IAuditLog, AuditLog>();
|
||||||
@@ -258,11 +268,18 @@ app.MapPost("/login",
|
|||||||
IUserService userService,
|
IUserService userService,
|
||||||
IAuthService authService,
|
IAuthService authService,
|
||||||
IRefreshTokenService refreshTokens,
|
IRefreshTokenService refreshTokens,
|
||||||
|
ISessionService sessionService,
|
||||||
CancellationToken cancellationToken) =>
|
CancellationToken cancellationToken) =>
|
||||||
{
|
{
|
||||||
var user = await userService.ValidateUser(request, ct: cancellationToken);
|
var user = await userService.ValidateUser(request, ct: cancellationToken);
|
||||||
var (refreshToken, session) = await refreshTokens.IssueForNewLogin(user.Id, cancellationToken);
|
var (refreshToken, session) = await refreshTokens.IssueForNewLogin(user.Id, cancellationToken);
|
||||||
var access = authService.CreateToken(user, sessionId: session.Id, jti: Guid.NewGuid());
|
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
|
return Results.Ok(new LoginResponse
|
||||||
{
|
{
|
||||||
AccessToken = access.Jwt,
|
AccessToken = access.Jwt,
|
||||||
@@ -281,12 +298,18 @@ app.MapPost("/token/refresh",
|
|||||||
IRefreshTokenService refreshTokens,
|
IRefreshTokenService refreshTokens,
|
||||||
IUserService userService,
|
IUserService userService,
|
||||||
IAuthService authService,
|
IAuthService authService,
|
||||||
|
ISessionService sessionService,
|
||||||
CancellationToken cancellationToken) =>
|
CancellationToken cancellationToken) =>
|
||||||
{
|
{
|
||||||
var (newRefresh, session) = await refreshTokens.Rotate(request.RefreshToken, cancellationToken);
|
var (newRefresh, session) = await refreshTokens.Rotate(request.RefreshToken, cancellationToken);
|
||||||
var user = await userService.GetById(session.UserId, cancellationToken);
|
var user = await userService.GetById(session.UserId, cancellationToken);
|
||||||
if (user == null) throw new BusinessException(ExceptionEnum.InvalidRefreshToken);
|
if (user == null) throw new BusinessException(ExceptionEnum.InvalidRefreshToken);
|
||||||
var access = authService.CreateToken(user, sessionId: session.Id, jti: Guid.NewGuid());
|
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
|
return Results.Ok(new LoginResponse
|
||||||
{
|
{
|
||||||
AccessToken = access.Jwt,
|
AccessToken = access.Jwt,
|
||||||
@@ -298,6 +321,89 @@ app.MapPost("/token/refresh",
|
|||||||
.AllowAnonymous()
|
.AllowAnonymous()
|
||||||
.WithSummary("Rotate a refresh token; returns a fresh access + refresh pair");
|
.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).
|
// AZ-532 — JWKS endpoint. Verifiers cache for 1 h (Cache-Control: public, max-age=3600).
|
||||||
app.MapGet("/.well-known/jwks.json",
|
app.MapGet("/.well-known/jwks.json",
|
||||||
(IJwtSigningKeyProvider keys, HttpContext http) =>
|
(IJwtSigningKeyProvider keys, HttpContext http) =>
|
||||||
|
|||||||
@@ -59,6 +59,15 @@ public enum ExceptionEnum
|
|||||||
[Description("Refresh token is invalid, expired, or has been revoked.")]
|
[Description("Refresh token is invalid, expired, or has been revoked.")]
|
||||||
InvalidRefreshToken = 52,
|
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.")]
|
[Description("No file provided.")]
|
||||||
NoFileProvided = 60,
|
NoFileProvided = 60,
|
||||||
}
|
}
|
||||||
@@ -8,5 +8,10 @@ public enum RoleEnum
|
|||||||
CompanionPC = 30,
|
CompanionPC = 30,
|
||||||
Admin = 40, //
|
Admin = 40, //
|
||||||
ResourceUploader = 50, //Uploading dll and ai models
|
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
|
ApiAdmin = 1000 //everything
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,23 +7,57 @@ namespace Azaion.Common.Entities;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class Session
|
public class Session
|
||||||
{
|
{
|
||||||
public Guid Id { get; set; }
|
public Guid Id { get; set; }
|
||||||
public Guid UserId { get; set; }
|
public Guid UserId { get; set; }
|
||||||
public string RefreshHash { get; set; } = null!;
|
/// <summary>
|
||||||
public Guid FamilyId { get; set; }
|
/// AZ-531 — sha256(opaque refresh) for interactive sessions. AZ-533 mission
|
||||||
public DateTime IssuedAt { get; set; }
|
/// sessions have no refresh value and store NULL here.
|
||||||
public DateTime LastUsedAt { get; set; }
|
/// </summary>
|
||||||
public DateTime ExpiresAt { get; set; }
|
public string? RefreshHash { get; set; }
|
||||||
public DateTime? RevokedAt { get; set; }
|
public Guid FamilyId { get; set; }
|
||||||
public string? RevokedReason { get; set; }
|
public DateTime IssuedAt { get; set; }
|
||||||
public Guid? ParentSessionId { get; set; }
|
public DateTime LastUsedAt { get; set; }
|
||||||
public DateTime FamilyStartedAt { 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; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
public Guid? RevokedByUserId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// AZ-533 — session class. <see cref="SessionClasses.Interactive"/> is the
|
||||||
|
/// default refresh-backed interactive session (AZ-531); <see cref="SessionClasses.Mission"/>
|
||||||
|
/// is a long-lived no-refresh token issued for a single UAV mission.
|
||||||
|
/// </summary>
|
||||||
|
public string Class { get; set; } = SessionClasses.Interactive;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public Guid? AircraftId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class SessionRevokedReasons
|
public static class SessionRevokedReasons
|
||||||
{
|
{
|
||||||
public const string Rotated = "rotated";
|
public const string Rotated = "rotated";
|
||||||
public const string ReuseDetected = "reuse_detected";
|
public const string ReuseDetected = "reuse_detected";
|
||||||
public const string LoggedOut = "logged_out";
|
public const string LoggedOut = "logged_out";
|
||||||
public const string FamilyRevoked = "family_revoked";
|
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";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace Azaion.Common.Requests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// AZ-533 — body for <c>POST /sessions/mission</c>. Pilot (interactive session)
|
||||||
|
/// asks admin to mint a long-lived no-refresh token for a single UAV flight.
|
||||||
|
/// </summary>
|
||||||
|
public class MissionSessionRequest
|
||||||
|
{
|
||||||
|
[Required] public string MissionId { get; set; } = null!;
|
||||||
|
[Required] public Guid AircraftId { get; set; }
|
||||||
|
[Required] public double PlannedDurationH { get; set; }
|
||||||
|
public IList<string>? RequestedScope { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Optional bbox of the operating area. Informational until the verifier
|
||||||
|
/// (satellite-provider) enforces it; included verbatim in the token claim.
|
||||||
|
/// </summary>
|
||||||
|
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; }
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// AZ-533 — issues long-lived single-use access tokens for offline UAV missions.
|
||||||
|
/// Distinct from <see cref="IAuthService"/> because:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>Lifetime is per-mission (≤ 12 h), not per-session policy.</item>
|
||||||
|
/// <item>Audience is narrowed to <c>satellite-provider</c>, not the broad admin audience.</item>
|
||||||
|
/// <item>No refresh: a single token covers the entire flight, then dies.</item>
|
||||||
|
/// <item>Carries mission-specific claims (mission_id, aircraft_id, valid_region).</item>
|
||||||
|
/// </list>
|
||||||
|
/// </summary>
|
||||||
|
public interface IMissionTokenService
|
||||||
|
{
|
||||||
|
Task<MissionSessionResponse> Issue(Guid pilotUserId, MissionSessionRequest request, CancellationToken ct = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class MissionTokenService(
|
||||||
|
IDbFactory dbFactory,
|
||||||
|
IJwtSigningKeyProvider signingKeys,
|
||||||
|
IOptions<JwtConfig> 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<MissionSessionResponse> 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<Claim>
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# Dependencies Table
|
# Dependencies Table
|
||||||
|
|
||||||
**Date**: 2026-05-14 (post batch 2 cycle 2; previous 2026-05-14)
|
**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 + 2 done auth-modernization + 3 active product tasks)
|
**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
|
**Total Complexity Points**: 71
|
||||||
|
|
||||||
| Task | Name | Complexity | Dependencies | Epic | Status |
|
| Task | Name | Complexity | Dependencies | Epic | Status |
|
||||||
@@ -19,9 +19,9 @@
|
|||||||
| AZ-513 | classes_crud_routes | 3 | None | AZ-509 | done |
|
| AZ-513 | classes_crud_routes | 3 | None | AZ-509 | done |
|
||||||
| AZ-531 | refresh_token_flow | 5 | None | AZ-529 | done |
|
| AZ-531 | refresh_token_flow | 5 | None | AZ-529 | done |
|
||||||
| AZ-532 | asymmetric_signing_jwks | 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-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-536 | argon2id_password_hashing | 3 | None | AZ-530 | done |
|
||||||
| AZ-537 | login_rate_limit_lockout | 3 | None (coord. AZ-536) | 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 |
|
| AZ-538 | cors_https_only_hsts | 2 | None | AZ-530 | done |
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -8,7 +8,7 @@ status: in_progress
|
|||||||
sub_step:
|
sub_step:
|
||||||
phase: 6
|
phase: 6
|
||||||
name: implement-tasks
|
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
|
retry_count: 0
|
||||||
cycle: 2
|
cycle: 2
|
||||||
tracker: jira
|
tracker: jira
|
||||||
|
|||||||
@@ -193,6 +193,66 @@ public sealed class DbHelper
|
|||||||
await cmd.ExecuteNonQueryAsync(ct);
|
await cmd.ExecuteNonQueryAsync(ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// AZ-535 — count active sessions for a user, optionally filtered to a session class.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<int> 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// AZ-533 — count open mission sessions whose <c>aircraft_id</c> matches the given user.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<int> 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// AZ-535 — pluck the row's revocation columns for assertions on who/why/when.
|
||||||
|
/// </summary>
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// AZ-535 — promote a user to <c>Service</c> role so they can read /sessions/revoked.
|
||||||
|
/// </summary>
|
||||||
|
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)
|
public static string HashRefreshToken(string opaqueToken)
|
||||||
{
|
{
|
||||||
var bytes = System.Text.Encoding.ASCII.GetBytes(opaqueToken);
|
var bytes = System.Text.Encoding.ASCII.GetBytes(opaqueToken);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// AZ-535 — logout endpoint + revocation surface for verifiers.
|
||||||
|
/// </summary>
|
||||||
|
public class LogoutRevocationTests : IClassFixture<TestFixture>
|
||||||
|
{
|
||||||
|
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); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// AZ-533 — mission-token issuance for offline UAV operations.
|
||||||
|
/// </summary>
|
||||||
|
public class MissionTokenTests : IClassFixture<TestFixture>
|
||||||
|
{
|
||||||
|
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<Guid> 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<MissionResponse>();
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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/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/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/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
|
psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d azaion -f /opt/test-seed.sql
|
||||||
|
|||||||
+37
@@ -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 <ts>; 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.
|
||||||
Reference in New Issue
Block a user