2026-09-01 16:37:53 +00:00
|
|
|
using Microsoft.AspNetCore.Mvc;
|
|
|
|
|
using Microsoft.AspNetCore.Mvc.Filters;
|
|
|
|
|
|
|
|
|
|
namespace w4c_workflows.Filters;
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Authorization filter that checks the operator-key scopes resolved by
|
|
|
|
|
/// <see cref="Middleware.AuthMiddleware"/>. Scopes are stored in
|
|
|
|
|
/// <c>HttpContext.Items["Scopes"]</c> as an <c>IReadOnlyList<string></c>.
|
|
|
|
|
///
|
|
|
|
|
/// Usage: <c>[RequireScope("manage")]</c> on a controller or action method.
|
|
|
|
|
/// Multiple attributes are OR-combined (any listed scope satisfies the check).
|
|
|
|
|
/// When no scopes are present (JWT auth, render-token auth), the check passes
|
|
|
|
|
/// — those surfaces have their own auth gates.
|
|
|
|
|
/// </summary>
|
|
|
|
|
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, AllowMultiple = true)]
|
|
|
|
|
public class RequireScopeAttribute : Attribute, IAsyncActionFilter
|
|
|
|
|
{
|
|
|
|
|
private readonly string _scope;
|
|
|
|
|
|
|
|
|
|
public RequireScopeAttribute(string scope)
|
|
|
|
|
{
|
|
|
|
|
_scope = scope;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
|
|
|
|
|
{
|
|
|
|
|
// JWT auth and render-token auth don't carry scopes — they have their
|
|
|
|
|
// own authorization gates, so skip the scope check.
|
|
|
|
|
var authKind = context.HttpContext.Items["AuthKind"] as string;
|
|
|
|
|
if (authKind is "jwt" or "render-token")
|
|
|
|
|
{
|
|
|
|
|
await next();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var scopes = context.HttpContext.Items["Scopes"] as IReadOnlyList<string>;
|
2026-09-13 08:35:17 +00:00
|
|
|
// A key with no recorded scope set predates scope enforcement (older/seed
|
|
|
|
|
// keys). By default it is treated as unlimited so legacy keys keep working.
|
|
|
|
|
// With Auth:EnforceScopes=true it is denied instead: an operator must
|
|
|
|
|
// re-mint the key with explicit scopes. This is the migration switch for
|
|
|
|
|
// tightening P1-14 without silently locking everyone out.
|
2026-09-01 16:37:53 +00:00
|
|
|
if (scopes == null || scopes.Count == 0)
|
|
|
|
|
{
|
2026-09-13 08:35:17 +00:00
|
|
|
var enforce = context.HttpContext.Items["EnforceScopes"] as bool? ?? false;
|
|
|
|
|
if (enforce)
|
|
|
|
|
{
|
|
|
|
|
context.Result = new ObjectResult(new
|
|
|
|
|
{
|
|
|
|
|
error = "Operator key has no scopes; re-mint it with explicit scopes.",
|
|
|
|
|
})
|
|
|
|
|
{
|
|
|
|
|
StatusCode = StatusCodes.Status403Forbidden,
|
|
|
|
|
};
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-01 16:37:53 +00:00
|
|
|
await next();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!scopes.Contains(_scope, StringComparer.Ordinal))
|
|
|
|
|
{
|
|
|
|
|
context.Result = new ObjectResult(new { error = $"Operator key requires the '{_scope}' scope." })
|
|
|
|
|
{
|
|
|
|
|
StatusCode = StatusCodes.Status403Forbidden,
|
|
|
|
|
};
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await next();
|
|
|
|
|
}
|
|
|
|
|
}
|