w4c-workflows-api/Filters/RequireScopeAttribute.cs

74 lines
2.7 KiB
C#
Raw Normal View History

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&lt;string&gt;</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.
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;
}
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();
}
}