using Gateway.Data; using Gateway.Models; using Microsoft.AspNetCore.Http; using System.Text.Json; namespace Gateway.Security; /// /// Centralized authorization guard for resource ownership, role checks, /// and status transition enforcement. /// /// DEFENSE IN DEPTH: /// Layer 1: Middleware authenticates session → populates ClientContext /// Layer 2: This guard validates resource ownership before operations /// Layer 3: Stored procs SHOULD also have WHERE clientId = @clientId /// /// All public methods return (bool Allowed, string? Error) to keep /// controller code clean and consistent. /// public sealed class AuthorizationGuard { private readonly SqlService _sql; private readonly ClientContext _client; private readonly ILogger _log; private readonly IConfiguration _config; private readonly IHttpContextAccessor _http; public AuthorizationGuard( SqlService sql, ClientContext client, ILogger log, IConfiguration config, IHttpContextAccessor http) { _sql = sql; _client = client; _log = log; _config = config; _http = http; } // ════════════════════════════════════════════════ // SERVICE KEY CHECK // ════════════════════════════════════════════════ /// /// Validate an internal service-to-service call via X-Service-Key header. /// Used by provider containers and background services that cannot carry /// a CIAM session token. Configure via INTERNAL_SERVICE_KEY env var. /// public (bool Ok, string? Error) RequireServiceKey() { var expected = _config["INTERNAL_SERVICE_KEY"]; if (string.IsNullOrWhiteSpace(expected)) { _log.LogWarning("[AuthZ] INTERNAL_SERVICE_KEY not configured — service key check denied"); return (false, "Service key not configured"); } var provided = _http.HttpContext?.Request.Headers["X-Service-Key"].FirstOrDefault(); if (string.IsNullOrWhiteSpace(provided) || provided != expected) { _log.LogWarning("[AuthZ] Invalid or missing X-Service-Key"); return (false, "Valid service key required"); } return (true, null); } // ════════════════════════════════════════════════ // BASIC AUTH CHECKS // ════════════════════════════════════════════════ /// Require authenticated session with a valid ClientId. public (bool Ok, string? Error) RequireAuth() { if (!_client.IsAuthenticated) return (false, "Authentication required"); return (true, null); } /// Require specific role(s). Case-insensitive. public (bool Ok, string? Error) RequireRole(params string[] allowedRoles) { var (ok, err) = RequireAuth(); if (!ok) return (ok, err); if (string.IsNullOrWhiteSpace(_client.Role)) return (false, "No role assigned"); if (!allowedRoles.Any(r => string.Equals(_client.Role, r, StringComparison.OrdinalIgnoreCase))) { _log.LogWarning( "[AuthZ] Role denied | ClientId={ClientId} Role={Role} Required={Required}", _client.ClientId, _client.Role, string.Join(",", allowedRoles)); return (false, "Insufficient permissions"); } return (true, null); } /// Require admin role. public (bool Ok, string? Error) RequireAdmin() => RequireRole("admin"); // ════════════════════════════════════════════════ // INITIATIVE OWNERSHIP // ════════════════════════════════════════════════ /// /// Verify initiative belongs to the authenticated client. /// Returns the initiative JSON on success (avoids double-fetch). /// public async Task VerifyInitiativeOwnerAsync(long initiativeId, CancellationToken ct) { var (ok, err) = RequireAuth(); if (!ok) return OwnershipResult.Denied(err!); try { var resp = await _sql.ExecProcAsync( SqlNames.Procs.Initiative, "get", JsonSerializer.Serialize(new { initiativeId }), ct: ct); if (string.IsNullOrWhiteSpace(resp)) return OwnershipResult.Denied("Initiative not found"); using var doc = JsonDocument.Parse(resp); var root = doc.RootElement; if (root.TryGetProperty("ok", out var okProp) && !okProp.GetBoolean()) return OwnershipResult.Denied("Initiative not found"); // Extract clientId from response — check both clean and prefixed shapes var initiative = root.TryGetProperty("initiative", out var initEl) ? initEl : root; var ownerClientId = initiative.TryGetProperty("clientId", out var cidProp) ? cidProp.GetString() : initiative.TryGetProperty("iniClientId", out var iniCidProp) ? iniCidProp.GetString() : null; if (string.IsNullOrWhiteSpace(ownerClientId)) { _log.LogWarning( "[AuthZ] Initiative {Id} has no clientId — ownership check inconclusive, denying", initiativeId); return OwnershipResult.Denied("Initiative ownership could not be verified"); } if (!string.Equals(ownerClientId, _client.ClientId, StringComparison.OrdinalIgnoreCase)) { _log.LogWarning( "[AuthZ] IDOR attempt | InitiativeId={InitiativeId} Owner={Owner} Requester={Requester}", initiativeId, ownerClientId, _client.ClientId); return OwnershipResult.Denied("Initiative not found"); // Don't reveal existence } // Extract current status for transition validation — check both shapes var status = initiative.TryGetProperty("status", out var stProp) ? stProp.GetString() : initiative.TryGetProperty("iniStatus", out var iniStProp) ? iniStProp.GetString() : null; return OwnershipResult.Allowed(resp, status); } catch (Exception ex) { _log.LogError(ex, "[AuthZ] Ownership check failed for initiative {Id}", initiativeId); return OwnershipResult.Denied("Authorization check failed"); } } // ════════════════════════════════════════════════ // CHANNEL CAMPAIGN OWNERSHIP // ════════════════════════════════════════════════ /// /// Verify channel campaign belongs to the authenticated client. /// Follows channelCampaign → initiative → client ownership chain. /// public async Task VerifyChannelOwnerAsync(long channelCampaignId, CancellationToken ct) { var (ok, err) = RequireAuth(); if (!ok) return OwnershipResult.Denied(err!); try { var resp = await _sql.ExecProcAsync( SqlNames.Procs.ChannelCampaign, "get", JsonSerializer.Serialize(new { channelCampaignId }), ct: ct); if (string.IsNullOrWhiteSpace(resp)) return OwnershipResult.Denied("Channel campaign not found"); using var doc = JsonDocument.Parse(resp); var root = doc.RootElement; if (root.TryGetProperty("ok", out var okProp) && !okProp.GetBoolean()) return OwnershipResult.Denied("Channel campaign not found"); // Get initiativeId, then check initiative ownership var campaign = root.TryGetProperty("channelCampaign", out var ccEl) ? ccEl : root; var initiativeId = campaign.TryGetProperty("initiativeId", out var initIdProp) ? initIdProp.GetInt64() : campaign.TryGetProperty("chcInitiativeId", out var chcInitProp) ? chcInitProp.GetInt64() : 0; if (initiativeId <= 0) return OwnershipResult.Denied("Channel campaign not found"); // Delegate to initiative ownership check return await VerifyInitiativeOwnerAsync(initiativeId, ct); } catch (Exception ex) { _log.LogError(ex, "[AuthZ] Channel ownership check failed for {Id}", channelCampaignId); return OwnershipResult.Denied("Authorization check failed"); } } // ════════════════════════════════════════════════ // WIZARD OWNERSHIP // ════════════════════════════════════════════════ /// /// Verify wizard belongs to the authenticated client. /// public async Task VerifyWizardOwnerAsync(string wizardId, CancellationToken ct) { var (ok, err) = RequireAuth(); if (!ok) return OwnershipResult.Denied(err!); try { var resp = await _sql.ExecProcAsync( "dbo.spCampaignWizard", "get", JsonSerializer.Serialize(new { wizardId }), ct: ct); if (string.IsNullOrWhiteSpace(resp)) return OwnershipResult.Denied("Wizard not found"); using var doc = JsonDocument.Parse(resp); var root = doc.RootElement; if (root.TryGetProperty("ok", out var okProp) && !okProp.GetBoolean()) return OwnershipResult.Denied("Wizard not found"); var wizard = root.TryGetProperty("wizard", out var wzEl) ? wzEl : root; var ownerClientId = wizard.TryGetProperty("clientId", out var cidProp) ? cidProp.GetString() : wizard.TryGetProperty("wizClientId", out var wzCidProp) ? wzCidProp.GetString() : null; if (string.IsNullOrWhiteSpace(ownerClientId)) { _log.LogWarning("[AuthZ] Wizard {Id} has no clientId", wizardId); return OwnershipResult.Denied("Wizard ownership could not be verified"); } if (!string.Equals(ownerClientId, _client.ClientId, StringComparison.OrdinalIgnoreCase)) { _log.LogWarning( "[AuthZ] IDOR attempt | WizardId={WizardId} Owner={Owner} Requester={Requester}", wizardId, ownerClientId, _client.ClientId); return OwnershipResult.Denied("Wizard not found"); } var status = wizard.TryGetProperty("status", out var stProp) ? stProp.GetString() : wizard.TryGetProperty("wizStatus", out var wzStProp) ? wzStProp.GetString() : null; return OwnershipResult.Allowed(resp, status); } catch (Exception ex) { _log.LogError(ex, "[AuthZ] Wizard ownership check failed for {Id}", wizardId); return OwnershipResult.Denied("Authorization check failed"); } } // ════════════════════════════════════════════════ // STATUS TRANSITION VALIDATION // ════════════════════════════════════════════════ /// /// Validate that a status transition is allowed for client-initiated actions. /// Internal/system transitions (from launch service, provider callbacks) bypass this. /// public (bool Ok, string? Error) ValidateClientStatusTransition( string? currentStatus, string requestedStatus, string resourceType = "initiative") { if (string.IsNullOrWhiteSpace(requestedStatus)) return (false, "Status is required"); // Normalize var from = (currentStatus ?? "").ToLowerInvariant(); var to = requestedStatus.ToLowerInvariant(); // Client-allowed transitions (restrictive) var allowed = IsClientTransitionAllowed(from, to); if (!allowed) { _log.LogWarning( "[AuthZ] Invalid status transition | {ResourceType} {From} → {To} by ClientId={ClientId}", resourceType, from, to, _client.ClientId); return (false, $"Cannot change {resourceType} from '{from}' to '{to}'"); } return (true, null); } /// /// Whitelist of client-allowed transitions. /// Everything else requires admin or system action. /// private static bool IsClientTransitionAllowed(string from, string to) { return (from, to) switch { // Pausing: only active campaigns can be paused ("active", "paused") => true, // Resuming: only paused campaigns can be resumed ("paused", "active") => true, // Cancelling: clients can cancel from most pre-completion states ("draft", "cancelled") => true, ("staged", "cancelled") => true, ("pending", "cancelled") => true, ("active", "cancelled") => true, ("paused", "cancelled") => true, // Everything else is denied at the client level _ => false }; } // ════════════════════════════════════════════════ // BUDGET VALIDATION // ════════════════════════════════════════════════ /// /// Validate budget against channel minimums. /// public (bool Ok, string? Error) ValidateBudget( decimal totalBudget, string? budgetPeriod, MultiChannelConfig config) { if (totalBudget <= 0) return (false, "Budget must be greater than zero"); // Convert to monthly for comparison var monthlyBudget = (budgetPeriod?.ToLowerInvariant()) switch { "daily" => totalBudget * 30.4m, "weekly" => totalBudget * 4.33m, _ => totalBudget }; // Check against lowest channel minimum var minBudget = config.EnabledChannels .Select(c => c.MinMonthlyBudget) .DefaultIfEmpty(150m) .Min(); if (monthlyBudget < minBudget) return (false, $"Monthly budget must be at least ${minBudget:F0}"); // Cap at reasonable maximum (safety valve) if (monthlyBudget > 1_000_000m) return (false, "Budget exceeds maximum allowed. Contact support for high-spend campaigns."); return (true, null); } } // ════════════════════════════════════════════════ // Result type // ════════════════════════════════════════════════ public sealed class OwnershipResult { public bool IsAllowed { get; init; } public string? Error { get; init; } /// The raw JSON response from the ownership lookup (avoids re-fetching). public string? EntityJson { get; init; } /// Current status of the entity (for transition validation). public string? CurrentStatus { get; init; } public static OwnershipResult Allowed(string? entityJson = null, string? status = null) => new() { IsAllowed = true, EntityJson = entityJson, CurrentStatus = status }; public static OwnershipResult Denied(string error) => new() { IsAllowed = false, Error = error }; }