134 lines
4.9 KiB
C#
134 lines
4.9 KiB
C#
|
|
using System.Text.Json;
|
||
|
|
using Microsoft.AspNetCore.Mvc;
|
||
|
|
using Microsoft.EntityFrameworkCore;
|
||
|
|
using w4c_workflows.Data;
|
||
|
|
using w4c_workflows.Models;
|
||
|
|
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
|
||
|
|
/// is PUBLIC (external callers have no operator key); access is governed by an
|
||
|
|
/// optional shared secret (<c>Workflows:WebhookSecret</c>) verified against the
|
||
|
|
/// <c>X-Webhook-Secret</c> header. When no secret is configured the endpoint is
|
||
|
|
/// open — acceptable for v1 dev, hardened in step 13.
|
||
|
|
/// </summary>
|
||
|
|
[ApiController]
|
||
|
|
[Route("/h")]
|
||
|
|
public class WebhooksController : ControllerBase
|
||
|
|
{
|
||
|
|
private readonly WorkflowsDbContext _db;
|
||
|
|
private readonly IRunLauncher _launcher;
|
||
|
|
private readonly ILogger<WebhooksController> _logger;
|
||
|
|
private readonly string? _sharedSecret;
|
||
|
|
|
||
|
|
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"];
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <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);
|
||
|
|
|
||
|
|
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<Guid>(matches.Count);
|
||
|
|
foreach (var workflow in matches)
|
||
|
|
{
|
||
|
|
runIds.Add(await _launcher.LaunchAsync(
|
||
|
|
new LaunchRequest(workflow.TenantId, workflow.Id, workflow.TriggerJson, input, $"webhook:{correlation}"), ct));
|
||
|
|
}
|
||
|
|
|
||
|
|
_logger.LogInformation("Webhook {Path} fired {Count} workflow(s) for tenant(s) {Tenants}",
|
||
|
|
webhookPath, matches.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<List<Workflow>> 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<Workflow>();
|
||
|
|
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<string?> 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;
|
||
|
|
}
|
||
|
|
}
|