using Microsoft.AspNetCore.Http; namespace StellaOps.Scheduler.WebService.Auth; internal sealed class HeaderScopeAuthorizer : IScopeAuthorizer { private const string ScopeHeader = "X-StellaOps-Scopes"; public void EnsureScope(HttpContext context, string requiredScope) { if (!context.Request.Headers.TryGetValue(ScopeHeader, out var values)) { throw new UnauthorizedAccessException($"Missing required header '{ScopeHeader}'."); } var scopeBuffer = string.Join(' ', values.ToArray()); if (string.IsNullOrWhiteSpace(scopeBuffer)) { throw new UnauthorizedAccessException($"Header '{ScopeHeader}' cannot be empty."); } var scopes = scopeBuffer .Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .ToHashSet(StringComparer.OrdinalIgnoreCase); if (scopes.Contains(requiredScope)) { return; } // Hierarchical match: fine-grained scope "scheduler.runs.read" is satisfied // by OIDC coarse-grained scope "scheduler:read" or "scheduler:admin". // Format: "{service}.{resource}.{action}" -> check "{service}:{action}" and "{service}:admin" var dotParts = requiredScope.Split('.'); if (dotParts.Length >= 2) { var service = dotParts[0]; var action = dotParts[^1]; if (scopes.Contains($"{service}:{action}") || scopes.Contains($"{service}:admin")) { return; } // Also check "operate" scope for write/manage actions if (action is "write" or "manage" or "preview" && scopes.Contains($"{service}:operate")) { return; } } throw new InvalidOperationException($"Missing required scope '{requiredScope}'."); } }