w4c-workflows-api/Controllers/WebhooksController.cs

253 lines
10 KiB
C#
Raw Normal View History

2026-09-13 08:35:17 +00:00
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using w4c_workflows.Data;
using w4c_workflows.Models;
2026-09-11 22:02:46 +00:00
using w4c_workflows.Services.Quota;
using w4c_workflows.Services.Runs;
using w4c_workflows.Services.Triggers;
namespace w4c_workflows.Controllers;
/// <summary>
/// Webhook receiver for workflows with <c>trigger.type = webhook</c>. The route
2026-09-13 08:35:17 +00:00
/// is PUBLIC (external callers have no operator key); access is governed by a
/// shared secret header (<c>X-Webhook-Secret</c>) and is fail-closed:
/// - If <c>Workflows:WebhookSecrets</c> maps tenants to secrets, the request is
/// attributed to exactly one tenant and only that tenant's workflows match.
/// - Otherwise the global <c>Workflows:WebhookSecret</c> 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 <see cref="Workflow.WebhookPath"/> column (falling
/// back to the trigger JSON for rows compiled before the column existed), and the
/// request body is capped by <c>Workflows:WebhookMaxBodyBytes</c>.
/// </summary>
[ApiController]
[Route("/h")]
public class WebhooksController : ControllerBase
{
2026-09-13 08:35:17 +00:00
private const long DefaultMaxBodyBytes = 1_048_576;
private readonly WorkflowsDbContext _db;
private readonly IRunLauncher _launcher;
private readonly ILogger<WebhooksController> _logger;
private readonly string? _sharedSecret;
2026-09-13 08:35:17 +00:00
private readonly Dictionary<string, string> _tenantSecrets;
private readonly long _maxBodyBytes;
public WebhooksController(
WorkflowsDbContext db,
IRunLauncher launcher,
IConfiguration config,
ILogger<WebhooksController> logger)
{
_db = db;
_launcher = launcher;
_logger = logger;
_sharedSecret = string.IsNullOrWhiteSpace(config["Workflows:WebhookSecret"])
? null
: config["Workflows:WebhookSecret"];
2026-09-13 08:35:17 +00:00
_tenantSecrets = new Dictionary<string, string>(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;
}
/// <summary>Receives a webhook at <c>/h/{path}</c> and fires every matching workflow.</summary>
[HttpPost("{**path}")]
public async Task<IActionResult> Receive(string? path, CancellationToken ct)
{
var webhookPath = "/h/" + (path ?? string.Empty);
2026-09-13 08:35:17 +00:00
if (!TryAuthorize(out var tenantId))
return Unauthorized(new { error = "Invalid webhook secret." });
2026-09-13 08:35:17 +00:00
var matches = await FindMatchesAsync(webhookPath, tenantId, ct);
if (matches.Count == 0)
return NotFound(new { error = $"No webhook workflow registered for path '{webhookPath}'." });
2026-09-13 08:35:17 +00:00
// 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<Guid>(matches.Count);
2026-09-11 22:02:46 +00:00
WorkflowQuotaExceededException? lastQuotaError = null;
foreach (var workflow in matches)
{
2026-09-11 22:02:46 +00:00
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);
}
}
2026-09-11 22:02:46 +00:00
// 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}",
2026-09-11 22:02:46 +00:00
webhookPath, runIds.Count, string.Join(",", matches.Select(m => m.TenantId).Distinct()));
return Accepted(new { runs = runIds });
}
2026-09-13 08:35:17 +00:00
/// <summary>
/// Resolves the caller's tenant. Returns false (fail-closed) when no secret is
/// configured or the provided secret matches nothing. A null
/// <paramref name="tenantId"/> with a true result means the global secret was
/// used and the tenant is unknown.
/// </summary>
private bool TryAuthorize(out string? tenantId)
{
2026-09-13 08:35:17 +00:00
tenantId = null;
if (_tenantSecrets.Count == 0 && _sharedSecret == null)
{
2026-09-13 08:35:17 +00:00
_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();
2026-09-13 08:35:17 +00:00
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);
}
2026-09-13 08:35:17 +00:00
private async Task<List<Workflow>> FindMatchesAsync(string webhookPath, string? tenantId, CancellationToken ct)
{
2026-09-13 08:35:17 +00:00
// 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<Workflow>();
foreach (var workflow in candidates)
{
2026-09-13 08:35:17 +00:00
if (IsWebhookMatch(workflow, webhookPath))
matches.Add(workflow);
}
return matches;
}
2026-09-13 08:35:17 +00:00
/// <summary>
/// True when the workflow's webhook trigger points at <paramref name="webhookPath"/>.
/// Prefers the denormalized column; parses trigger JSON only for legacy rows.
/// </summary>
public static bool IsWebhookMatch(Workflow workflow, string webhookPath)
{
2026-09-13 08:35:17 +00:00
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)
2026-09-13 08:35:17 +00:00
return (null, false);
try
{
using var _ = JsonDocument.Parse(body);
2026-09-13 08:35:17 +00:00
return (body, false); // already valid JSON — pass through verbatim
}
catch (JsonException)
{
2026-09-13 08:35:17 +00:00
return (JsonSerializer.Serialize(body), false); // wrap non-JSON bodies as a JSON string
}
}
private static bool CryptographicOperationsEquals(string a, string b)
{
2026-09-13 08:35:17 +00:00
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;
}
}