2026-09-13 08:35:17 +00:00
|
|
|
using System.Text;
|
2026-09-01 16:37:53 +00:00
|
|
|
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;
|
2026-09-01 16:37:53 +00:00
|
|
|
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>.
|
2026-09-01 16:37:53 +00:00
|
|
|
/// </summary>
|
|
|
|
|
[ApiController]
|
|
|
|
|
[Route("/h")]
|
|
|
|
|
public class WebhooksController : ControllerBase
|
|
|
|
|
{
|
2026-09-13 08:35:17 +00:00
|
|
|
private const long DefaultMaxBodyBytes = 1_048_576;
|
|
|
|
|
|
2026-09-01 16:37:53 +00:00
|
|
|
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;
|
2026-09-01 16:37:53 +00:00
|
|
|
|
|
|
|
|
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;
|
2026-09-01 16:37:53 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <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))
|
2026-09-01 16:37:53 +00:00
|
|
|
return Unauthorized(new { error = "Invalid webhook secret." });
|
|
|
|
|
|
2026-09-13 08:35:17 +00:00
|
|
|
var matches = await FindMatchesAsync(webhookPath, tenantId, ct);
|
2026-09-01 16:37:53 +00:00
|
|
|
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." });
|
|
|
|
|
|
2026-09-01 16:37:53 +00:00
|
|
|
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;
|
2026-09-01 16:37:53 +00:00
|
|
|
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-01 16:37:53 +00:00
|
|
|
}
|
|
|
|
|
|
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 });
|
|
|
|
|
|
2026-09-01 16:37:53 +00:00
|
|
|
_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()));
|
2026-09-01 16:37:53 +00:00
|
|
|
|
|
|
|
|
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-01 16:37:53 +00:00
|
|
|
{
|
2026-09-13 08:35:17 +00:00
|
|
|
tenantId = null;
|
|
|
|
|
|
|
|
|
|
if (_tenantSecrets.Count == 0 && _sharedSecret == null)
|
2026-09-01 16:37:53 +00:00
|
|
|
{
|
2026-09-13 08:35:17 +00:00
|
|
|
_logger.LogWarning(
|
|
|
|
|
"Neither Workflows:WebhookSecret nor Workflows:WebhookSecrets is configured; every /h/* request is rejected.");
|
2026-09-01 16:37:53 +00:00
|
|
|
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-01 16:37:53 +00:00
|
|
|
}
|
|
|
|
|
|
2026-09-13 08:35:17 +00:00
|
|
|
private async Task<List<Workflow>> FindMatchesAsync(string webhookPath, string? tenantId, CancellationToken ct)
|
2026-09-01 16:37:53 +00:00
|
|
|
{
|
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);
|
2026-09-01 16:37:53 +00:00
|
|
|
|
|
|
|
|
var matches = new List<Workflow>();
|
|
|
|
|
foreach (var workflow in candidates)
|
|
|
|
|
{
|
2026-09-13 08:35:17 +00:00
|
|
|
if (IsWebhookMatch(workflow, webhookPath))
|
2026-09-01 16:37:53 +00:00
|
|
|
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-01 16:37:53 +00:00
|
|
|
{
|
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();
|
2026-09-01 16:37:53 +00:00
|
|
|
if (body.Length == 0)
|
2026-09-13 08:35:17 +00:00
|
|
|
return (null, false);
|
2026-09-01 16:37:53 +00:00
|
|
|
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
using var _ = JsonDocument.Parse(body);
|
2026-09-13 08:35:17 +00:00
|
|
|
return (body, false); // already valid JSON — pass through verbatim
|
2026-09-01 16:37:53 +00:00
|
|
|
}
|
|
|
|
|
catch (JsonException)
|
|
|
|
|
{
|
2026-09-13 08:35:17 +00:00
|
|
|
return (JsonSerializer.Serialize(body), false); // wrap non-JSON bodies as a JSON string
|
2026-09-01 16:37:53 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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);
|
2026-09-01 16:37:53 +00:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|