Initial import into Gitea

This commit is contained in:
Grae Jones
2026-03-14 13:50:09 -07:00
parent 8e7e03702e
commit 34c1f09e01
154 changed files with 17666 additions and 1548 deletions

View File

@@ -0,0 +1,396 @@
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 };
}

View File

@@ -223,7 +223,7 @@ public sealed class ClientAuthMiddleware
_logger.LogWarning("[Auth] Session validation starting | Corr={Corr}", corrId);
var rqst = JsonSerializer.Serialize(new { sessionToken = token });
var resp = await sql.ExecProcAsync("dbo.spSession", "validate", rqst, ct: context.RequestAborted);
var resp = await sql.ExecProcAsync("dbo.spClientSession", "validate", rqst, ct: context.RequestAborted);
if (string.IsNullOrWhiteSpace(resp))
{
@@ -239,12 +239,13 @@ public sealed class ClientAuthMiddleware
var data = root.TryGetProperty("data", out var dataProp) ? dataProp : root;
clientContext.SessionId = data.TryGetProperty("sessionId", out var sid) ? sid.GetString() : null;
clientContext.ClientId = data.TryGetProperty("clientId", out var cid) ? cid.GetString() : null;
clientContext.ClientName = data.TryGetProperty("clientName", out var cn) ? cn.GetString() : null;
clientContext.UserId = data.TryGetProperty("userId", out var uid) ? uid.GetString() : null;
clientContext.Email = data.TryGetProperty("userEmail", out var ue) ? ue.GetString() : null;
clientContext.Role = data.TryGetProperty("role", out var role) ? role.GetString() : null;
clientContext.IsDevBypass = false;
clientContext.ClientId = data.TryGetProperty("clientId", out var cid) ? cid.GetString() : null;
clientContext.ClientName = data.TryGetProperty("clientName", out var cn) ? cn.GetString() : null;
clientContext.ClientCategory = data.TryGetProperty("clientCategory", out var ccat) ? ccat.GetString() : null;
clientContext.UserId = data.TryGetProperty("userId", out var uid) ? uid.GetString() : null;
clientContext.Email = data.TryGetProperty("userEmail", out var ue) ? ue.GetString() : null;
clientContext.Role = data.TryGetProperty("role", out var role) ? role.GetString() : null;
clientContext.IsDevBypass = false;
_logger.LogWarning("[Auth] Session validated OK | ClientId={ClientId} Email={Email} | Corr={Corr}",
clientContext.ClientId, clientContext.Email, corrId);

View File

@@ -1,60 +1,37 @@
namespace Gateway.Security;
/// <summary>
/// Holds authenticated client information for the current request.
/// Populated by ClientAuthMiddleware.
/// Holds authenticated identity information for the current request.
/// Populated by MultiProviderAuthMiddleware.
/// </summary>
public sealed class ClientContext
{
/// <summary>
/// Session ID from session-based auth.
/// </summary>
public string? SessionId { get; set; }
public string? SessionId { get; set; }
public string? ClientId { get; set; } // OID (JWT) or platform client ID (session)
public string? TenantId { get; set; }
public string? ClientName { get; set; }
public string? ClientCategory { get; set; }
public string? UserId { get; set; }
public string? Email { get; set; }
public string? Role { get; set; }
public bool IsDevBypass { get; set; }
public string? AuthProvider { get; set; }
/// <summary>
/// The authenticated client ID (from session, JWT sub claim, or dev header).
/// This identifies the client/organization in our platform.
/// Raw Entra Object ID (oid claim) — always set for Microsoft tokens.
/// Used for identity and activity logging. Distinct from ClientId which may fall
/// back to sub for tokens where oid isn't surfaced as a named claim.
/// </summary>
public string? ClientId { get; set; }
public string? EntraOid { get; set; }
/// <summary>
/// Optional tenant ID for the ad platform (e.g., Google Ads customer ID).
/// May be derived from ClientId mapping or passed in request.
/// True when the token was issued by the standard Entra (staff) tenant.
/// </summary>
public string? TenantId { get; set; }
public bool IsStaff { get; set; }
/// <summary>
/// Display name from token or session (if available).
/// </summary>
public string? ClientName { get; set; }
/// <summary>
/// User ID from session (if using session auth).
/// </summary>
public string? UserId { get; set; }
/// <summary>
/// Email from token or session (if available).
/// </summary>
public string? Email { get; set; }
/// <summary>
/// User role from session (admin, user, readonly).
/// </summary>
public string? Role { get; set; }
/// <summary>
/// Whether this request was authenticated via dev bypass (vs real auth).
/// </summary>
public bool IsDevBypass { get; set; }
/// <summary>
/// The authentication provider used (microsoft, google, etc.)
/// </summary>
public string? AuthProvider { get; set; }
/// <summary>
/// True if we have a valid ClientId.
/// </summary>
/// <summary>True if we have a valid ClientId.</summary>
public bool IsAuthenticated => !string.IsNullOrWhiteSpace(ClientId);
/// <summary>True if this is an admin session (IsStaff + Role set).</summary>
public bool IsAdmin => IsStaff && !string.IsNullOrWhiteSpace(Role);
}

View File

@@ -248,11 +248,26 @@ public sealed class MultiProviderAuthMiddleware
}
else
{
// Standard Entra ID
// Standard Entra ID — could be CIAM tenant or Staff tenant (Tech, Admin)
// Detect by comparing issuer against configured Staff tenant ID
var staffTenantId = _config["Auth:Microsoft:StaffTenantId"];
var staffClientId = _config["Auth:Microsoft:StaffClientId"];
var isStaff = !string.IsNullOrWhiteSpace(staffTenantId) &&
jwt.Issuer.Contains(staffTenantId, StringComparison.OrdinalIgnoreCase);
if (isStaff)
{
tenantId = staffTenantId!;
clientId = staffClientId ?? clientId;
_logger.LogWarning("[Auth] Staff Entra token detected | tenant={Tenant} | Corr={Corr}", tenantId, corrId);
clientContext.IsStaff = true;
}
authority = $"https://login.microsoftonline.com/{tenantId}/v2.0";
metadataAddress = $"{authority}/.well-known/openid-configuration";
validIssuers = new[]
{
validIssuers = new[]
{
$"https://login.microsoftonline.com/{tenantId}/v2.0",
$"https://sts.windows.net/{tenantId}/"
};
@@ -342,9 +357,17 @@ public sealed class MultiProviderAuthMiddleware
/// </summary>
private static void ExtractClaims(ClaimsPrincipal principal, ClientContext clientContext)
{
// Always extract oid explicitly — used for activity logging and identity.
// For standard Entra access tokens oid may be under the full claim URI.
var oid = principal.FindFirstValue("oid")
?? principal.FindFirstValue("http://schemas.microsoft.com/identity/claims/objectidentifier");
clientContext.EntraOid = oid;
// ClientId: prefer oid, fall back to sub
clientContext.ClientId =
principal.FindFirstValue("oid") ?? // Microsoft object ID
principal.FindFirstValue("sub") ?? // Standard subject
oid ??
principal.FindFirstValue("sub") ??
principal.FindFirstValue(ClaimTypes.NameIdentifier);
clientContext.Email =
@@ -389,7 +412,9 @@ public sealed class MultiProviderAuthMiddleware
try
{
var rqst = JsonSerializer.Serialize(new { sessionToken = token });
var resp = await sql.ExecProcAsync("dbo.spSession", "validate", rqst, ct: context.RequestAborted);
var sessionProc = "dbo.spClientSession"; // Gateway handles CIAM client sessions only
var resp = await sql.ExecProcAsync(sessionProc, "validate", rqst, ct: context.RequestAborted);
if (string.IsNullOrWhiteSpace(resp))
{
@@ -412,8 +437,22 @@ public sealed class MultiProviderAuthMiddleware
clientContext.Role = data.TryGetProperty("role", out var role) ? role.GetString() : null;
clientContext.IsDevBypass = false;
_logger.LogWarning("[Auth] Session validated OK | ClientId={ClientId} Email={Email} | Corr={Corr}",
clientContext.ClientId, clientContext.Email, corrId);
// TenantId: session data first, then X-Tenant-Id header fallback
// (In agency model, this is the client's Google Ads customer ID)
clientContext.TenantId =
data.TryGetProperty("tenantId", out var tenId) ? tenId.GetString() :
data.TryGetProperty("googleCustomerId", out var gcid) ? gcid.GetString() :
null;
// Fall back to X-Tenant-Id header if not in session data
if (string.IsNullOrWhiteSpace(clientContext.TenantId) &&
context.Request.Headers.TryGetValue("X-Tenant-Id", out var tenantHeader))
{
clientContext.TenantId = tenantHeader.FirstOrDefault();
}
_logger.LogWarning("[Auth] Session validated OK | ClientId={ClientId} Email={Email} IsAdmin={IsAdmin} | Corr={Corr}",
clientContext.ClientId, clientContext.Email, clientContext.IsAdmin, corrId);
return clientContext.IsAuthenticated;
}