mirror of
https://github.com/azaion/admin.git
synced 2026-06-21 06:51:08 +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
|
||||
{
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
+107
-1
@@ -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<IJwtSigningKeyProvider>(jwtSigningKeyProvider);
|
||||
builder.Services.AddScoped<IUserService, UserService>();
|
||||
builder.Services.AddScoped<IAuthService, AuthService>();
|
||||
builder.Services.AddScoped<IRefreshTokenService, RefreshTokenService>();
|
||||
builder.Services.AddScoped<ISessionService, SessionService>();
|
||||
builder.Services.AddScoped<IMissionTokenService, MissionTokenService>();
|
||||
builder.Services.AddScoped<IResourcesService, ResourcesService>();
|
||||
builder.Services.AddScoped<IDetectionClassService, DetectionClassService>();
|
||||
builder.Services.AddScoped<IAuditLog, AuditLog>();
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -7,23 +7,57 @@ namespace Azaion.Common.Entities;
|
||||
/// </summary>
|
||||
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; }
|
||||
/// <summary>
|
||||
/// AZ-531 — sha256(opaque refresh) for interactive sessions. AZ-533 mission
|
||||
/// sessions have no refresh value and store NULL here.
|
||||
/// </summary>
|
||||
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; }
|
||||
|
||||
/// <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 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";
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
**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 |
|
||||
|
||||
@@ -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:
|
||||
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
|
||||
|
||||
@@ -193,6 +193,66 @@ public sealed class DbHelper
|
||||
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)
|
||||
{
|
||||
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/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
|
||||
|
||||
+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