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 };
}