Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions backend/src/CodeSpace.Api/Controllers/WorkflowRunsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,19 @@ public async Task<IActionResult> ReissueWait([FromRoute] Guid runId, [FromRoute]
: Ok(new { outcome = outcome.ToString(), reissued = outcome == ReissueWaitOutcome.Reissued });
}

/// <summary>
/// The URL of a pending Callback wait — the one the external system POSTs to. Its token resumes the run without a
/// session, so no read projects it: this needs <c>runs.control</c> (a Viewer gets 403) and logs who took it. Team-scoped;
/// foreign, not pending or not a Callback → 404; a row that keeps only the token's digest → 409. Returns
/// <c>{ waitId, path }</c>.
/// </summary>
[HttpPost("{runId:guid}/waits/{waitId:guid}/callback-url")]
public async Task<IActionResult> RevealCallbackUrl([FromRoute] Guid runId, [FromRoute] Guid waitId, CancellationToken cancellationToken)
{
var result = await _mediator.Send(new RevealWorkflowCallbackUrlCommand { RunId = runId, WaitId = waitId }, cancellationToken).ConfigureAwait(false);
return Ok(result);
}

/// <summary>Continue a run IN PLACE (same run id, never a fork): a STRANDED Suspended run (the reconciler's re-dispatch twin), a terminal FAILURE run (re-run the halting node[s]), or a terminal CANCELLED run the operator stopped mid-flight (re-run the interrupted frontier). Team-scoped; foreign → 404. Returns <c>{ continued }</c> — false when the state can't continue in place (Success/Running, or nothing incomplete to resume) so the caller falls back to replay / rerun.</summary>
[HttpPost("{runId:guid}/continue")]
public async Task<IActionResult> Continue([FromRoute] Guid runId, CancellationToken cancellationToken)
Expand Down
3 changes: 2 additions & 1 deletion backend/src/CodeSpace.Api/Filters/GlobalExceptionFilter.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using CodeSpace.Api.Logging;
using CodeSpace.Core.Failures;
using CodeSpace.Messages.Failures;
using Microsoft.AspNetCore.Mvc;
Expand Down Expand Up @@ -69,7 +70,7 @@ private void LogIfNobodyElseDid(ExceptionContext context, FailureClassification
context.Exception,
"{Method} {Path} failed outside the request pipeline: {FailureKind}/{FailureCode}",
context.HttpContext.Request.Method,
context.HttpContext.Request.Path.Value,
BearerRouteLogging.PathOf(context.HttpContext),
failure.Kind,
failure.Code);
}
Expand Down
185 changes: 185 additions & 0 deletions backend/src/CodeSpace.Api/Logging/BearerRouteLogging.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
using CodeSpace.Core.Settings.Application;
using Microsoft.AspNetCore.Routing.Patterns;
using Serilog.Context;
using Serilog.Core;
using Serilog.Events;

namespace CodeSpace.Api.Logging;

/// <summary>
/// Keeps bearer tokens that travel in a URL PATH out of the logs. Invitation, password-reset and workflow-callback links
/// carry their token as a path segment (the URL shapes are issued and cannot change), and the request path is on every
/// line a request logs: in Serilog's completion line, and as <c>RequestPath</c> from the ASP.NET hosting scope on every
/// event logged while the request runs. Whoever could read the console or Seq could otherwise join a team, take over an
/// account or resume a run.
///
/// <para>For a matched route that binds a bearer parameter, both carry the route's TEMPLATE instead
/// (<c>/api/invitations/{token}/accept</c>), which still says which endpoint ran. Keyed on the parameter's NAME
/// (<see cref="BearerParameters"/>) rather than a list of paths, so a new token route is covered by naming its parameter
/// <c>token</c>; an E2E test fails if a route binds a credential-looking parameter this does not recognise.</para>
///
/// <para>The same token also arrives where no bearer route answers: a GET or HEAD on a callback URL (a link unfurler, a
/// person pasting it into a browser, an uptime ping), a wrong method on an invitation route, the SPA's own
/// <c>/invite/…</c> or <c>/reset-password/…</c> link opened on the API origin, and every line logged before routing has
/// chosen an endpoint (UseHttpsRedirection warns once per process without an https port). Those requests are answered by
/// the fallback, a method rejection or nothing yet, so the path's SHAPE decides: a path whose leading segments have the
/// shape of a bearer route up to its token is logged with that segment as <c>{token}</c>
/// (<c>/api/workflows/callbacks/{token}</c>). The shapes are the bearer routes in the host's endpoint table plus
/// <see cref="SpaBearerRoutes"/>. A request a non-bearer route answered logs exactly what it did before.</para>
///
/// <para>What this cannot reach: lines the server writes outside the pipeline — ASP.NET's "Request starting …" before
/// this middleware runs, and its line for an exception that escapes every middleware (an action's exception never does;
/// the global filter handles it). The first is Information and the second Error under <c>Microsoft.AspNetCore</c>; the
/// shipped <c>Serilog:MinimumLevel:Override:Microsoft = Warning</c> keeps the first out, and a test pins that setting.</para>
/// </summary>
public static class BearerRouteLogging
{
/// <summary>Route parameter names whose value is a bearer credential.</summary>
public static readonly IReadOnlySet<string> BearerParameters = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "token" };

/// <summary>
/// The SPA's token routes — the links <see cref="PublicBaseUrlSetting"/> mints, and the prefixes the SPA's nginx masks.
/// This server never serves them, so no endpoint names them, but a link built against the API origin brings them here.
/// </summary>
public static readonly IReadOnlyList<string> SpaBearerRoutes = [PublicBaseUrlSetting.InvitePath + "{token}", PublicBaseUrlSetting.PasswordResetPath + "{token}"];

private const string RequestPathProperty = "RequestPath";

/// <summary>The path to log for this request: a bearer route's template, else the path with any bearer segment masked, else the raw path.</summary>
public static string PathOf(HttpContext context) => TemplateOf(context) ?? context.Request.Path.Value ?? string.Empty;

/// <summary>
/// <c>RequestLoggingOptions.GetMessageTemplateProperties</c>: the same four properties Serilog's default completion line
/// carries, with the bearer-safe path in place of a token's. <paramref name="requestPath"/> is what Serilog would log.
/// </summary>
public static IEnumerable<LogEventProperty> RequestCompletionProperties(HttpContext context, string requestPath, double elapsedMs, int statusCode) =>
[
new("RequestMethod", new ScalarValue(context.Request.Method)),
new(RequestPathProperty, new ScalarValue(TemplateOf(context) ?? requestPath)),
new("StatusCode", new ScalarValue(statusCode)),
new("Elapsed", new ScalarValue(elapsedMs)),
];

/// <summary>
/// Middleware, FIRST in the pipeline: every event the request logs carries the bearer-safe path as <c>RequestPath</c> —
/// the completion line, everything downstream of routing, what routing itself logs the moment it matches, and what the
/// middleware ahead of routing logs. The hosting scope's raw value reaches an event through Serilog's logging provider,
/// which only adds a property that is absent; this enricher overwrites, so it wins in either order.
/// </summary>
public static IApplicationBuilder UseBearerRouteLogging(this IApplicationBuilder app)
{
var shapes = new BearerPathShapes(app.ApplicationServices.GetService<EndpointDataSource>());

return app.Use((context, next) => RewriteRequestPathAsync(context, next, shapes));
}

private static async Task RewriteRequestPathAsync(HttpContext context, RequestDelegate next, BearerPathShapes shapes)
{
context.Features.Set(shapes.Loaded());
var route = new BearerRouteEnricher(context);

try
{
using (LogContext.Push(route))
await next(context).ConfigureAwait(false);
}
finally
{
route.Settle();
}
}

/// <summary>
/// A bearer route's template when one answered; null when any other route did, its literals accounting for the path;
/// otherwise — no endpoint yet, the catch-all fallback, a method rejection — the path with a bearer segment masked.
/// </summary>
private static string? TemplateOf(HttpContext context) => context.GetEndpoint() switch
{
RouteEndpoint endpoint when IsBearerRoute(endpoint.RoutePattern) => context.Request.PathBase.Value + "/" + endpoint.RoutePattern.RawText?.TrimStart('/'),
RouteEndpoint endpoint when !endpoint.RoutePattern.Parameters.Any(parameter => parameter.IsCatchAll) => null,
_ => context.Features.Get<BearerPathShapes>()?.Mask(context.Request.Path.Value) is { } masked ? context.Request.PathBase.Value + masked : null,
};

private static bool IsBearerRoute(RoutePattern pattern) => pattern.Parameters.Any(parameter => BearerParameters.Contains(parameter.Name));

/// <summary>
/// Every bearer route's shape, read once from the host's endpoint table on the first request (the table is complete only
/// once the host has started) — in the middleware, so a failure to read it fails that request rather than leaving a log
/// line unmasked.
/// </summary>
private sealed class BearerPathShapes
{
private readonly Lazy<IReadOnlyList<RoutePattern>> _routes;

public BearerPathShapes(EndpointDataSource? endpoints) => _routes = new(() => BearerRoutesOf(endpoints));

public BearerPathShapes Loaded()
{
_ = _routes.Value;

return this;
}

/// <summary>The path with every segment a bearer route's token would bind replaced by <c>{name}</c>; null when no bearer route has its shape.</summary>
public string? Mask(string? path)
{
var segments = (path ?? string.Empty).Split('/', StringSplitOptions.RemoveEmptyEntries);
var bearers = _routes.Value.Select(route => BearerSegmentOf(route, segments)).OfType<(int Index, string Name)>().ToList();

if (bearers.Count == 0) return null;

foreach (var (index, name) in bearers)
segments[index] = "{" + name + "}";

return "/" + string.Join('/', segments);
}

private static IReadOnlyList<RoutePattern> BearerRoutesOf(EndpointDataSource? endpoints) =>
[.. SpaBearerRoutes.Select(RoutePatternFactory.Parse), .. endpoints?.Endpoints.OfType<RouteEndpoint>().Select(endpoint => endpoint.RoutePattern).Where(IsBearerRoute) ?? []];

/// <summary>
/// Walks the route's segments against the path's, case-insensitively as routing matches: a literal must equal its
/// segment, any other part matches anything, and reaching the bearer parameter names the segment it would bind. HTTP
/// method and constraints are ignored — a token in the path is a token whichever route would have refused it.
/// </summary>
private static (int Index, string Name)? BearerSegmentOf(RoutePattern route, string[] segments)
{
for (var i = 0; i < route.PathSegments.Count && i < segments.Length; i++)
{
var parts = route.PathSegments[i].Parts;

if (parts is [RoutePatternParameterPart bearer] && BearerParameters.Contains(bearer.Name)) return (i, bearer.Name);

if (parts is [RoutePatternLiteralPart literal] && !string.Equals(literal.Content, segments[i], StringComparison.OrdinalIgnoreCase)) return null;
}

return null;
}
}

/// <summary>
/// The request's bearer-safe path, read live while the request runs (routing matches it part-way through) and kept once
/// it ends, for work the request started that still logs — the server recycles the <see cref="HttpContext"/>, so it is
/// never read after that.
/// </summary>
private sealed class BearerRouteEnricher : ILogEventEnricher
{
private volatile HttpContext? _request;
private volatile string? _settledTemplate;

public BearerRouteEnricher(HttpContext request) { _request = request; }

public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory)
{
var template = _request is { } request ? TemplateOf(request) : _settledTemplate;

if (template != null) logEvent.AddOrUpdateProperty(propertyFactory.CreateProperty(RequestPathProperty, template));
}

public void Settle()
{
_settledTemplate = _request is { } request ? TemplateOf(request) : null;
_request = null;
}
}
}
7 changes: 7 additions & 0 deletions backend/src/CodeSpace.Api/Startup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using CodeSpace.Core.Persistence.Db;
using CodeSpace.Core.Services.Workflows.Llm;
using CodeSpace.Api.Filters;
using CodeSpace.Api.Logging;
using CodeSpace.Core.Services.Credentials;
using CodeSpace.Core.Services.OutboundHttp;
using CodeSpace.Core.Services.Identity;
Expand Down Expand Up @@ -102,13 +103,19 @@ public void ConfigureServices(IServiceCollection services)

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
// First, so every line a request logs — routing's own and the https redirect's ahead of it included, and requests
// only the fallback answers — names a bearer route by its template or masks the token's path segment, rather than
// carrying the invitation, reset or callback token in its path (see BearerRouteLogging).
app.UseBearerRouteLogging();
app.UseSerilogRequestLogging(opts =>
{
opts.EnrichDiagnosticContext = (diag, ctx) =>
{
var teamId = ctx.Request.Headers[HeaderCurrentTeam.HeaderName].ToString();
if (!string.IsNullOrEmpty(teamId)) diag.Set("TeamId", teamId);
};
// An invitation, reset or callback token in the path is logged as its route template, never verbatim.
opts.GetMessageTemplateProperties = BearerRouteLogging.RequestCompletionProperties;
});
if (!env.IsDevelopment()) app.UseHttpsRedirection();
app.UseRouting();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using CodeSpace.Core.Services.Identity;
using CodeSpace.Core.Services.Workflows.Callbacks;
using CodeSpace.Messages.Commands.Workflows;
using CodeSpace.Messages.Dtos.Workflows;
using MediatR;

namespace CodeSpace.Core.Handlers.CommandHandlers.Workflows;

public sealed class RevealWorkflowCallbackUrlCommandHandler : IRequestHandler<RevealWorkflowCallbackUrlCommand, WorkflowCallbackUrl>
{
private readonly IWorkflowCallbackUrlService _service;
private readonly ICurrentTeam _currentTeam;

public RevealWorkflowCallbackUrlCommandHandler(IWorkflowCallbackUrlService service, ICurrentTeam currentTeam)
{
_service = service;
_currentTeam = currentTeam;
}

public async Task<WorkflowCallbackUrl> Handle(RevealWorkflowCallbackUrlCommand request, CancellationToken cancellationToken) =>
await _service.RevealAsync(request.RunId, request.WaitId, _currentTeam.Id!.Value, cancellationToken).ConfigureAwait(false);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
-- 0248_workflow_callback_token_digest.sql
--
-- A flow.wait_callback wait is resumed by an anonymous POST to /api/workflows/callbacks/{token}, so its token is a
-- bearer credential, and workflow_run_wait.token holds it in plaintext, readable by anyone with database or backup
-- access. This is the EXPAND step of moving it out of plaintext. It adds two columns and changes no existing value:
--
-- token_digest the token's lowercase hex SHA-256, which this version looks a Callback wait up by (the way
-- invitation and reset tokens are kept). Filled below for every Callback row holding a minted token.
-- token_ciphertext the token AES-GCM encrypted (base64) under the deployment's variable master key
-- (Variables:MasterKey, read from CODESPACE_VARIABLE_MASTER_KEY), opened only by the reveal that needs
-- runs.control. That key is held by the environment and never by this database -- unlike the Data
-- Protection key ring, which sits in data_protection_keys. SQL cannot reach the key, so only rows this
-- version mints get one.
--
-- token itself is left as it is, and this version keeps writing the minted token there, because a pod on the previous
-- build looks a callback up with `token = <presented>`. So:
-- * Any rollout order works -- API or worker first, pod by pod -- and a rollback to the previous build keeps every
-- callback URL resuming, including URLs this version issued.
-- * The digest is not a key anywhere: the previous build compares the presented value with token, which never holds a
-- digest, and this version compares the digest OF the presented value. (Both route only 32 characters since
-- a0eb90933; builds before it route any segment to that comparison.)
-- * A Callback row the previous build parks after this script ran has no digest and no ciphertext; this version still
-- resumes it by its token column and reveals it from there.
--
-- CONTRACT STEP (a later release, not this script): only once no pod older than this version can run. It must first fill
-- token_digest where it is still NULL (rows previous-version pods parked after this script) and seal token_ciphertext in
-- the application, which holds the key; then set token to '' for Callback rows and stop writing it there. Never write
-- the digest, the projection placeholder, or any other value a callback route can carry into token: every build before
-- the contract step compares whatever is presented with that column.
--
-- Both columns are NULLABLE with NO DEFAULT: catalog-only ADD COLUMNs. The UPDATE only fills a NULL digest for the minted
-- shape (32 lowercase hex), so running the script twice changes nothing. The partial index covers the one lookup that
-- reads the digest: a pending Callback wait by token_digest.

ALTER TABLE workflow_run_wait ADD COLUMN IF NOT EXISTS token_digest TEXT NULL;
ALTER TABLE workflow_run_wait ADD COLUMN IF NOT EXISTS token_ciphertext TEXT NULL;

COMMENT ON COLUMN workflow_run_wait.token_digest IS
'A Callback wait''s token as lowercase hex SHA-256, which a resume looks the wait up by. NULL on other wait kinds, and on Callback rows a pre-0248 build parks.';

COMMENT ON COLUMN workflow_run_wait.token_ciphertext IS
'A Callback wait''s token, AES-GCM encrypted (base64) under the deployment''s environment-held variable master key, opened only by the reveal that needs runs.control. NULL on other wait kinds, and on Callback rows minted before 0248 or parked by a pre-0248 build.';

UPDATE workflow_run_wait
SET token_digest = encode(sha256(convert_to(token, 'UTF8')), 'hex')
WHERE wait_kind = 'Callback'
AND token_digest IS NULL
AND token ~ '^[0-9a-f]{32}$';

CREATE INDEX IF NOT EXISTS idx_wrw_pending_callback_token_digest
ON workflow_run_wait (token_digest)
WHERE status = 'Pending' AND wait_kind = 'Callback';
Loading
Loading