397 lines
16 KiB
C#
397 lines
16 KiB
C#
using Gateway.Data;
|
|
using Gateway.Models;
|
|
using Microsoft.AspNetCore.Http;
|
|
using System.Text.Json;
|
|
|
|
namespace Gateway.Security;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public sealed class AuthorizationGuard
|
|
{
|
|
private readonly SqlService _sql;
|
|
private readonly ClientContext _client;
|
|
private readonly ILogger<AuthorizationGuard> _log;
|
|
private readonly IConfiguration _config;
|
|
private readonly IHttpContextAccessor _http;
|
|
|
|
public AuthorizationGuard(
|
|
SqlService sql,
|
|
ClientContext client,
|
|
ILogger<AuthorizationGuard> log,
|
|
IConfiguration config,
|
|
IHttpContextAccessor http)
|
|
{
|
|
_sql = sql;
|
|
_client = client;
|
|
_log = log;
|
|
_config = config;
|
|
_http = http;
|
|
}
|
|
|
|
// ════════════════════════════════════════════════
|
|
// SERVICE KEY CHECK
|
|
// ════════════════════════════════════════════════
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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
|
|
// ════════════════════════════════════════════════
|
|
|
|
/// <summary>Require authenticated session with a valid ClientId.</summary>
|
|
public (bool Ok, string? Error) RequireAuth()
|
|
{
|
|
if (!_client.IsAuthenticated)
|
|
return (false, "Authentication required");
|
|
return (true, null);
|
|
}
|
|
|
|
/// <summary>Require specific role(s). Case-insensitive.</summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>Require admin role.</summary>
|
|
public (bool Ok, string? Error) RequireAdmin()
|
|
=> RequireRole("admin");
|
|
|
|
// ════════════════════════════════════════════════
|
|
// INITIATIVE OWNERSHIP
|
|
// ════════════════════════════════════════════════
|
|
|
|
/// <summary>
|
|
/// Verify initiative belongs to the authenticated client.
|
|
/// Returns the initiative JSON on success (avoids double-fetch).
|
|
/// </summary>
|
|
public async Task<OwnershipResult> 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
|
|
// ════════════════════════════════════════════════
|
|
|
|
/// <summary>
|
|
/// Verify channel campaign belongs to the authenticated client.
|
|
/// Follows channelCampaign → initiative → client ownership chain.
|
|
/// </summary>
|
|
public async Task<OwnershipResult> 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
|
|
// ════════════════════════════════════════════════
|
|
|
|
/// <summary>
|
|
/// Verify wizard belongs to the authenticated client.
|
|
/// </summary>
|
|
public async Task<OwnershipResult> 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
|
|
// ════════════════════════════════════════════════
|
|
|
|
/// <summary>
|
|
/// Validate that a status transition is allowed for client-initiated actions.
|
|
/// Internal/system transitions (from launch service, provider callbacks) bypass this.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Whitelist of client-allowed transitions.
|
|
/// Everything else requires admin or system action.
|
|
/// </summary>
|
|
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
|
|
// ════════════════════════════════════════════════
|
|
|
|
/// <summary>
|
|
/// Validate budget against channel minimums.
|
|
/// </summary>
|
|
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; }
|
|
|
|
/// <summary>The raw JSON response from the ownership lookup (avoids re-fetching).</summary>
|
|
public string? EntityJson { get; init; }
|
|
|
|
/// <summary>Current status of the entity (for transition validation).</summary>
|
|
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 };
|
|
}
|