using System.Text; using System.Text.Json; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using w4c_workflows.Data; using w4c_workflows.Models; using w4c_workflows.Services.Quota; using w4c_workflows.Services.Runs; using w4c_workflows.Services.Triggers; namespace w4c_workflows.Controllers; /// /// Webhook receiver for workflows with trigger.type = webhook. The route /// is PUBLIC (external callers have no operator key); access is governed by a /// shared secret header (X-Webhook-Secret) and is fail-closed: /// - If Workflows:WebhookSecrets maps tenants to secrets, the request is /// attributed to exactly one tenant and only that tenant's workflows match. /// - Otherwise the global Workflows:WebhookSecret is required. Because a /// single secret cannot identify a tenant, a path registered by more than one /// tenant is rejected (409) rather than firing across tenants. /// - With neither configured every request is rejected (401). /// /// Matching uses the indexed column (falling /// back to the trigger JSON for rows compiled before the column existed), and the /// request body is capped by Workflows:WebhookMaxBodyBytes. /// [ApiController] [Route("/h")] public class WebhooksController : ControllerBase { private const long DefaultMaxBodyBytes = 1_048_576; private readonly WorkflowsDbContext _db; private readonly IRunLauncher _launcher; private readonly ILogger _logger; private readonly string? _sharedSecret; private readonly Dictionary _tenantSecrets; private readonly long _maxBodyBytes; public WebhooksController( WorkflowsDbContext db, IRunLauncher launcher, IConfiguration config, ILogger logger) { _db = db; _launcher = launcher; _logger = logger; _sharedSecret = string.IsNullOrWhiteSpace(config["Workflows:WebhookSecret"]) ? null : config["Workflows:WebhookSecret"]; _tenantSecrets = new Dictionary(StringComparer.Ordinal); foreach (var child in config.GetSection("Workflows:WebhookSecrets").GetChildren()) { if (!string.IsNullOrWhiteSpace(child.Key) && !string.IsNullOrWhiteSpace(child.Value)) _tenantSecrets[child.Key] = child.Value!; } _maxBodyBytes = long.TryParse(config["Workflows:WebhookMaxBodyBytes"], out var max) && max > 0 ? max : DefaultMaxBodyBytes; } /// Receives a webhook at /h/{path} and fires every matching workflow. [HttpPost("{**path}")] public async Task Receive(string? path, CancellationToken ct) { var webhookPath = "/h/" + (path ?? string.Empty); if (!TryAuthorize(out var tenantId)) return Unauthorized(new { error = "Invalid webhook secret." }); var matches = await FindMatchesAsync(webhookPath, tenantId, ct); if (matches.Count == 0) return NotFound(new { error = $"No webhook workflow registered for path '{webhookPath}'." }); // A shared global secret cannot identify a tenant. Refuse to fan a path out // across tenants; the operator must configure per-tenant secrets or use a // tenant-unique path. if (tenantId == null) { var tenants = matches.Select(m => m.TenantId).Distinct(StringComparer.Ordinal).ToList(); if (tenants.Count > 1) { _logger.LogWarning( "Webhook {Path} matches workflows in multiple tenants ({Tenants}); refusing cross-tenant fan-out", webhookPath, string.Join(",", tenants)); return Conflict(new { error = $"webhook path '{webhookPath}' is registered by multiple tenants; " + "configure Workflows:WebhookSecrets or make the path tenant-unique.", }); } } var (input, tooLarge) = await ReadInputAsync(ct); if (tooLarge) return StatusCode(StatusCodes.Status413PayloadTooLarge, new { error = $"webhook body exceeds the {_maxBodyBytes} byte limit." }); var correlation = Request.Headers["X-Request-Id"].FirstOrDefault() ?? Guid.NewGuid().ToString("N"); var runIds = new List(matches.Count); WorkflowQuotaExceededException? lastQuotaError = null; foreach (var workflow in matches) { try { runIds.Add(await _launcher.LaunchAsync( new LaunchRequest(workflow.TenantId, workflow.Id, workflow.TriggerJson, input, $"webhook:{correlation}"), ct)); } catch (WorkflowQuotaExceededException ex) { // One tenant may be over quota while another is not; skip only the // exhausted one so the webhook still fires for everybody else. lastQuotaError = ex; _logger.LogWarning( "Webhook {Path}: execution quota exceeded for workflow {WorkflowId} (tenant {TenantId})", webhookPath, workflow.Id, workflow.TenantId); } } // Nothing could be started and the only reason was the quota → surface 429 // so the caller can back off instead of believing the webhook was handled. if (runIds.Count == 0 && lastQuotaError != null) return StatusCode(StatusCodes.Status429TooManyRequests, new { error = lastQuotaError.Message, quota = lastQuotaError.Quota }); _logger.LogInformation("Webhook {Path} fired {Count} workflow(s) for tenant(s) {Tenants}", webhookPath, runIds.Count, string.Join(",", matches.Select(m => m.TenantId).Distinct())); return Accepted(new { runs = runIds }); } /// /// Resolves the caller's tenant. Returns false (fail-closed) when no secret is /// configured or the provided secret matches nothing. A null /// with a true result means the global secret was /// used and the tenant is unknown. /// private bool TryAuthorize(out string? tenantId) { tenantId = null; if (_tenantSecrets.Count == 0 && _sharedSecret == null) { _logger.LogWarning( "Neither Workflows:WebhookSecret nor Workflows:WebhookSecrets is configured; every /h/* request is rejected."); return false; } var provided = Request.Headers["X-Webhook-Secret"].FirstOrDefault(); if (provided == null) return false; if (_tenantSecrets.Count > 0) { foreach (var (tenant, secret) in _tenantSecrets) { if (CryptographicOperationsEquals(provided, secret)) { tenantId = tenant; return true; } } return false; } return _sharedSecret != null && CryptographicOperationsEquals(provided, _sharedSecret); } private async Task> FindMatchesAsync(string webhookPath, string? tenantId, CancellationToken ct) { // The indexed column powers the common case; rows compiled before the // column existed (WebhookPath == null) still fall back to trigger JSON. var query = _db.Workflows.Where(w => w.Status == WorkflowStatus.Compiled && (w.WebhookPath == webhookPath || w.WebhookPath == null)); if (tenantId != null) query = query.Where(w => w.TenantId == tenantId); var candidates = await query.ToListAsync(ct); var matches = new List(); foreach (var workflow in candidates) { if (IsWebhookMatch(workflow, webhookPath)) matches.Add(workflow); } return matches; } /// /// True when the workflow's webhook trigger points at . /// Prefers the denormalized column; parses trigger JSON only for legacy rows. /// public static bool IsWebhookMatch(Workflow workflow, string webhookPath) { if (!string.IsNullOrWhiteSpace(workflow.WebhookPath)) return string.Equals(workflow.WebhookPath, webhookPath, StringComparison.Ordinal); var spec = TriggerSpec.Parse(workflow.TriggerJson, out _); return spec?.Type == TriggerType.Webhook && string.Equals(spec.WebhookPath, webhookPath, StringComparison.Ordinal); } private async Task<(string? Input, bool TooLarge)> ReadInputAsync(CancellationToken ct) { // Reject a declared oversized body before reading anything. if (Request.ContentLength is long declared && declared > _maxBodyBytes) return (null, true); using var buffer = new MemoryStream(); var chunk = new byte[81_920]; int read; while ((read = await Request.Body.ReadAsync(chunk, ct)) > 0) { if (buffer.Length + read > _maxBodyBytes) return (null, true); buffer.Write(chunk, 0, read); } var body = Encoding.UTF8.GetString(buffer.ToArray()).Trim(); if (body.Length == 0) return (null, false); try { using var _ = JsonDocument.Parse(body); return (body, false); // already valid JSON — pass through verbatim } catch (JsonException) { return (JsonSerializer.Serialize(body), false); // wrap non-JSON bodies as a JSON string } } private static bool CryptographicOperationsEquals(string a, string b) { var aBytes = Encoding.UTF8.GetBytes(a); var bBytes = Encoding.UTF8.GetBytes(b); if (aBytes.Length != bBytes.Length) return false; var result = 0; for (var i = 0; i < aBytes.Length; i++) result |= aBytes[i] ^ bBytes[i]; return result == 0; } }