using Microsoft.AspNetCore.Http; namespace StellaOps.Scheduler.WebService.Auth; internal sealed class HeaderScopeAuthorizer : IScopeAuthorizer { private static readonly string[] ScopeHeaders = ["X-StellaOps-Scopes", "X-Scopes"]; public void EnsureScope(HttpContext context, string requiredScope) { Microsoft.Extensions.Primitives.StringValues values = default; bool found = false; foreach (var header in ScopeHeaders) { if (context.Request.Headers.TryGetValue(header, out values)) { found = true; break; } } if (!found) { throw new UnauthorizedAccessException($"Missing required scope header (accepted: {string.Join(", ", ScopeHeaders)})."); } var scopeBuffer = string.Join(' ', values.ToArray()); if (string.IsNullOrWhiteSpace(scopeBuffer)) { throw new UnauthorizedAccessException("Scope header 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}'."); } }