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;
}
}