Files
admin/Azaion.AdminApi/BusinessExceptionHandler.cs
T
Oleksandr Bezdieniezhnykh 8e7c602f51
ci/woodpecker/push/01-test Pipeline failed
ci/woodpecker/push/02-build-push unknown status
[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>
2026-05-14 05:51:23 +03:00

60 lines
2.3 KiB
C#

using System.Globalization;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
namespace Azaion.Common;
public class BusinessExceptionHandler(ILogger<BusinessExceptionHandler> logger) : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken)
{
if (exception is BusinessException ex)
{
logger.LogWarning(exception, ex.Message);
httpContext.Response.StatusCode = MapStatusCode(ex.ExceptionEnum);
httpContext.Response.ContentType = "application/json";
if (ex.RetryAfterSeconds is { } retry && retry > 0)
httpContext.Response.Headers.RetryAfter = retry.ToString(CultureInfo.InvariantCulture);
var err = JsonConvert.SerializeObject(new
{
ErrorCode = ex.ExceptionEnum,
ex.Message
});
await httpContext.Response.WriteAsync(err, cancellationToken).ConfigureAwait(false);
return true;
}
if (exception is BadHttpRequestException badReq)
{
logger.LogWarning(exception, badReq.Message);
httpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
httpContext.Response.ContentType = "application/json";
var err = JsonConvert.SerializeObject(new
{
ErrorCode = 0,
badReq.Message
});
await httpContext.Response.WriteAsync(err, cancellationToken).ConfigureAwait(false);
return true;
}
return false;
}
private static int MapStatusCode(ExceptionEnum kind) => kind switch
{
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
};
}