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 an /// optional shared secret (Workflows:WebhookSecret) verified against the /// X-Webhook-Secret header. When no secret is configured the endpoint is /// open — acceptable for v1 dev, hardened in step 13. /// [ApiController] [Route("/h")] public class WebhooksController : ControllerBase { private readonly WorkflowsDbContext _db; private readonly IRunLauncher _launcher; private readonly ILogger _logger; private readonly string? _sharedSecret; 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"]; } /// 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 (!Authorized()) return Unauthorized(new { error = "Invalid webhook secret." }); var matches = await FindMatchesAsync(webhookPath, ct); if (matches.Count == 0) return NotFound(new { error = $"No webhook workflow registered for path '{webhookPath}'." }); var input = await ReadInputAsync(ct); 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 }); } private bool Authorized() { // When no secret is configured, reject all webhook requests. An empty // secret previously meant "open" — acceptable for dev but a security // hole in production. Callers must set Workflows:WebhookSecret. Log a // warning so this fail-closed state is not silent. if (_sharedSecret == null) { _logger.LogWarning("Workflows:WebhookSecret is not configured; the webhook endpoint is disabled and every request to /h/* will be rejected with 401."); return false; } var provided = Request.Headers["X-Webhook-Secret"].FirstOrDefault(); return provided != null && CryptographicOperationsEquals(provided, _sharedSecret); } private async Task> FindMatchesAsync(string webhookPath, CancellationToken ct) { var candidates = await _db.Workflows .Where(w => w.Status == WorkflowStatus.Compiled && w.TriggerJson != null) .ToListAsync(ct); var matches = new List(); foreach (var workflow in candidates) { var spec = TriggerSpec.Parse(workflow.TriggerJson, out _); if (spec?.Type == TriggerType.Webhook && string.Equals(spec.WebhookPath, webhookPath, StringComparison.Ordinal)) matches.Add(workflow); } return matches; } private async Task ReadInputAsync(CancellationToken ct) { using var reader = new StreamReader(Request.Body); var body = (await reader.ReadToEndAsync(ct)).Trim(); if (body.Length == 0) return null; try { using var _ = JsonDocument.Parse(body); return body; // already valid JSON — pass through verbatim } catch (JsonException) { return JsonSerializer.Serialize(body); // wrap non-JSON bodies as a JSON string } } private static bool CryptographicOperationsEquals(string a, string b) { var aBytes = System.Text.Encoding.UTF8.GetBytes(a); var bBytes = System.Text.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; } }