Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
434c076
Derive workflow service authority from authenticated requests
rmcdaniel Oct 25, 2026
0ba2cba
Pin durable-workflow/workflow 2.14.0 (b814c6c4)
rmcdaniel Oct 26, 2026
cf17dea
Pin durable-workflow/workflow 2.14.1 (af9dbadb)
rmcdaniel Oct 26, 2026
9bfc79a
Adopt Workflow 2.14.1 service-operation scheduling and authority (WIP)
rmcdaniel Oct 26, 2026
20746ea
Adopt published Workflow 2.14.2 with worker protocol context
rmcdaniel Oct 26, 2026
892d654
Merge remote-tracking branch 'origin/main' into fix/784-workflow2142
rmcdaniel Oct 26, 2026
e3595d8
Compare committed Nexus envelope fields across database engines
rmcdaniel Oct 26, 2026
b7f906d
Avoid missing-stream gap locks during concurrent opens
rmcdaniel Oct 26, 2026
ef9432a
Reproduce: a workflow start's input claim does not hold its object un…
rmcdaniel Oct 26, 2026
0ac8206
Keep runtime payload claims through the reference writer commit
rmcdaniel Oct 26, 2026
b0fba22
Integrate published Workflow 2.14.2 adoption
rmcdaniel Oct 26, 2026
62445bf
Integrate independent stream open serialization fix
rmcdaniel Oct 26, 2026
6379587
Integrate atomic runtime payload reference claims
rmcdaniel Oct 26, 2026
8ddf53d
Assert payload refusal against the stored instance primary key
rmcdaniel Oct 26, 2026
265fa72
Correct the provider reference refusal assertion key
rmcdaniel Oct 26, 2026
e90a244
Claim HTTP inputs at commit and preserve scheduled payloads
rmcdaniel Oct 26, 2026
9d7cd8f
Align compatibility tests with protocol-aware Core adoption
rmcdaniel Oct 26, 2026
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
2 changes: 1 addition & 1 deletion app/Http/Controllers/Api/MessageStreamController.php
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ public function append(Request $request, string $workflowId, string $streamName)

$storage = $this->externalPayloadStorage->driverFor($namespace);
try {
$envelope = AvroPayloadEnvelopeResolver::resolve($validated['input'] ?? null, 'input', $storage);
$envelope = AvroPayloadEnvelopeResolver::resolve($validated['input'] ?? null, 'input', $storage, retainExternal: true);
} catch (ValidationException $exception) {
$this->streams->recordMalformed(
$namespace,
Expand Down
1 change: 1 addition & 0 deletions app/Http/Controllers/Api/ScheduleController.php
Original file line number Diff line number Diff line change
Expand Up @@ -1021,6 +1021,7 @@ private function validateActionInput(?array $action, ?string $namespace): void
$action['input'],
'action.input',
$this->externalPayloadStorage->driverFor($namespace),
retainExternal: true,
);
}

Expand Down
61 changes: 52 additions & 9 deletions app/Http/Controllers/Api/ServiceCatalogController.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,14 @@
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use Workflow\V2\Contracts\ServiceControlPlane;
use Workflow\V2\Exceptions\ServiceOperationAuthorizationException;
use Workflow\V2\Models\WorkflowService;
use Workflow\V2\Models\WorkflowServiceCall;
use Workflow\V2\Models\WorkflowServiceEndpoint;
use Workflow\V2\Models\WorkflowServiceOperation;
use Workflow\V2\Support\ServiceCallDeadlinePolicy;
use Workflow\V2\Support\ServiceCallPrincipal;
use Workflow\V2\Support\UpdateWaitPolicy;

class ServiceCatalogController
{
Expand Down Expand Up @@ -637,7 +640,8 @@ public function executeOperation(
'payload_codec' => ['nullable', 'string', 'max:191'],
'mode_override' => ['nullable', 'string', 'in:'.implode(',', self::OPERATION_MODES)],
'wait_for' => ['nullable', 'string', 'in:accepted,completed'],
'wait_timeout_seconds' => ['nullable', 'integer', 'min:0', 'max:86400'],
// Workflow 2.14 bounds every service-operation wait at 60 seconds.
'wait_timeout_seconds' => ['nullable', 'integer', 'min:0', 'max:'.UpdateWaitPolicy::MAX_COMPLETION_TIMEOUT_SECONDS],
'idempotency_key' => ['nullable', 'string', 'max:191'],
'caller_namespace' => ['nullable', 'string', 'max:255'],
'caller_workflow_instance_id' => ['nullable', 'string', 'max:191'],
Expand Down Expand Up @@ -760,16 +764,20 @@ public function executeOperation(

if ((string) $idempotentCall->status === 'accepted'
&& ! $this->acceptedCallHasIssuedHandler($idempotentCall)) {
$options = $executionOptions;
$options = $this->admittedExecutionOptions($request, $executionOptions, $callerNamespace);
$options['service_call_id'] = $idempotentCall->id;
$options['boundary_policy_outcome'] = 'accepted';

$result = $this->serviceControlPlane()->execute(
$endpoint->endpoint_name,
$service->service_name,
$operation->operation_name,
$options,
);
try {
$result = $this->serviceControlPlane()->execute(
$endpoint->endpoint_name,
$service->service_name,
$operation->operation_name,
$options,
);
} catch (ServiceOperationAuthorizationException) {
return $this->serviceOperationForbidden();
}
$result['idempotent_replay'] = true;
} else {
$result = array_replace(
Expand Down Expand Up @@ -815,7 +823,7 @@ public function executeOperation(
);
}

$options = $executionOptions;
$options = $this->admittedExecutionOptions($request, $executionOptions, $callerNamespace);
$options['service_call_id'] = $admission->call->id;
$options['boundary_policy_outcome'] = $admission->decision->outcome->value;

Expand All @@ -826,6 +834,8 @@ public function executeOperation(
$operation->operation_name,
$options,
);
} catch (ServiceOperationAuthorizationException) {
return $this->serviceOperationForbidden();
} finally {
if ($admission->request->operationMode->value === 'sync') {
$this->serviceCallBoundary()->release($admission->request);
Expand Down Expand Up @@ -1172,6 +1182,39 @@ private function principal(Request $request): Principal
?? Principal::role('operator', 'none', subject: 'unknown');
}

/**
* Workflow 2.14 re-authorizes an admitted call against its stored caller
* scope and current catalog policy before any effect. Give it the caller
* namespace and principal the boundary admitted, so the package checks
* the same identity instead of its own configured defaults.
*
* @param array<string, mixed> $options
* @return array<string, mixed>
*/
private function admittedExecutionOptions(Request $request, array $options, string $callerNamespace): array
{
$principal = $this->principal($request);
$options['caller_namespace'] = $callerNamespace;
$options['principal'] = new ServiceCallPrincipal(
subject: $principal->subject(),
method: $principal->method(),
roles: $principal->roles(),
tenant: $principal->tenant(),
claims: $principal->claims(),
);

return $options;
}

/** A package authority refusal: forbidden, with no ownership or policy detail. */
private function serviceOperationForbidden(): JsonResponse
{
return ControlPlaneProtocol::json([
'reason' => 'service_operation_forbidden',
'message' => 'The service operation is not authorized for this caller.',
], 403);
}

/**
* @return array<string, mixed>
*/
Expand Down
42 changes: 30 additions & 12 deletions app/Http/Controllers/Api/WorkerController.php
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
use Workflow\V2\Contracts\HistoryProjectionRole;
use Workflow\V2\Contracts\PreparedLocalActivityGroupTaskBridge;
use Workflow\V2\Contracts\PreparedLocalActivityTaskBridge;
use Workflow\V2\Contracts\ProtocolAwareWorkflowTaskBridge;
use Workflow\V2\Contracts\WorkflowTaskBridge;
use Workflow\V2\Enums\ActivityAttemptStatus;
use Workflow\V2\Enums\HistoryEventType;
Expand Down Expand Up @@ -1860,6 +1861,12 @@ private function mutateWorkflowTaskCommands(Request $request, string $taskId, bo
$commands = $this->promoteWorkflowFailureExceptionPayload($commands);
/** @var WorkflowTaskBridge $bridge */
$bridge = app(WorkflowTaskBridge::class);
if (! $bridge instanceof ProtocolAwareWorkflowTaskBridge) {
return WorkerProtocol::json([
'error' => 'workflow_bridge_protocol_unavailable',
'message' => 'The workflow task bridge must support authenticated worker protocol completion.',
], 503);
}
$workflowTaskQueue = WorkflowTask::query()
->whereKey($taskId)
->value('queue');
Expand Down Expand Up @@ -2007,7 +2014,7 @@ function () use (
$outcome['completed'] = false;
} else {
$outcome = WorkflowObservability::completion($request, $claimedTask,
fn (): array => $bridge->complete($taskId, $commands));
fn (): array => $bridge->completeForProtocol($taskId, $commands, WorkerProtocol::requestVersion($request)));
}
if (($outcome[$checkpoint ? 'checkpointed' : 'completed'] ?? false) === true) {
app(WorkflowStreamCommandProcessor::class)->process($taskId, (string) $namespace, $streamCommands);
Expand Down Expand Up @@ -3212,15 +3219,11 @@ private function authorizeServiceOperationCommands(Request $request, string $nam
);
}

// A worker owns command intent, not the authenticated caller identity.
// Principal authority travels through the request-bound authorizer,
// never through worker command fields.
$commands[$index] = array_replace($command, [
'namespace' => $targetNamespace,
'caller_namespace' => $namespace,
'principal_subject' => $principal->subject(),
'principal_method' => $principal->method(),
'principal_roles' => $principal->roles(),
'principal_tenant' => $principal->tenant(),
'principal_claims' => $principal->claims(),
]);
}

Expand Down Expand Up @@ -3253,23 +3256,38 @@ private function authorizeServiceOperationReplays(Request $request, string $name
->where('idempotency_key', trim($key))
->oldest('created_at')->oldest('id');
}
$call = $query->first();
$call = $query->lockForUpdate()->first();
if ($call === null && $callId === null) {
continue;
}

$runId = WorkflowTask::query()->whereKey($taskId)->value('workflow_run_id');
$instanceId = WorkflowRun::query()->whereKey($runId)->value('workflow_instance_id');
$principal = Authenticate::principal($request);
abort_unless($call !== null && $principal !== null
&& $call->caller_namespace === $namespace
&& $call->caller_workflow_run_id === $runId
&& $call->caller_workflow_instance_id === $instanceId
&& $call->endpoint_name === strtolower(trim($command['endpoint_name']))
&& $call->service_name === strtolower(trim($command['service_name']))
&& $call->operation_name === strtolower(trim($command['operation_name'])), 403);

// Existing call IDs and idempotency keys must not skip current boundary policy.
$operation = $call->operation;
abort_unless($operation !== null && $call->endpoint !== null && $call->service !== null, 403);
// Hold the catalog rows through Core replay so a concurrent policy
// change cannot race between this decision and the early return.
$endpoint = $call->endpoint()->lockForUpdate()->first();
$service = $call->service()->lockForUpdate()->first();
$operation = $call->operation()->lockForUpdate()->first();
abort_unless($operation !== null && $endpoint !== null && $service !== null, 403);
abort_unless($endpoint->namespace === $call->target_namespace
&& $service->namespace === $call->target_namespace
&& $operation->namespace === $call->target_namespace
&& $endpoint->endpoint_name === $call->endpoint_name
&& $service->service_name === $call->service_name
&& $operation->operation_name === $call->operation_name
&& $service->workflow_service_endpoint_id === $endpoint->id
&& $operation->workflow_service_endpoint_id === $endpoint->id
&& $operation->workflow_service_id === $service->id, 403);
$rejection = app(ServiceCallBoundary::class)->replayRejectionFor(
principal: $principal,
callerNamespace: $namespace,
Expand All @@ -3280,8 +3298,8 @@ private function authorizeServiceOperationReplays(Request $request, string $name
callerWorkflowRunId: $runId,
idempotencyKey: $key,
operationModeOverride: $command['mode_override'] ?? null,
endpointBoundaryPolicy: $call->endpoint->boundary_policy ?? [],
serviceBoundaryPolicy: $call->service->boundary_policy ?? [],
endpointBoundaryPolicy: $endpoint->boundary_policy ?? [],
serviceBoundaryPolicy: $service->boundary_policy ?? [],
operationBoundaryPolicy: $operation->boundary_policy ?? [],
);
abort_if($rejection !== null, 403);
Expand Down
3 changes: 2 additions & 1 deletion app/Http/Middleware/RuntimeExternalPayloadTransport.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use App\Support\RuntimeExternalPayloadAudit;
use App\Support\RuntimeExternalPayloadException;
use App\Support\RuntimeExternalPayloadRegistry;
use App\Support\RuntimeExternalPayloadWriteClaims;
use App\Support\WorkerProtocol;
use App\Support\WorkflowQueryTaskBroker;
use Closure;
Expand Down Expand Up @@ -220,7 +221,7 @@ public function handle(Request $request, Closure $next): Response
));

try {
$response = $next($request);
$response = app(RuntimeExternalPayloadWriteClaims::class)->transport(fn (): Response => $next($request));

if ($request->attributes->getBoolean(self::ATTRIBUTE_CLAIMED) && $response->getStatusCode() < 400) {
$this->audit->record($request, 'external_payload.claimed');
Expand Down
20 changes: 20 additions & 0 deletions app/Providers/AppServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
use App\Support\ExternalWorkflowUpdateAdmission;
use App\Support\NamespaceExternalPayloadStorage;
use App\Support\RemoteScheduleStarter;
use App\Support\RuntimeExternalPayloadWriteClaims;
use App\Support\ServerPollingCache;
use App\Support\ServerWorkflowControlPlane;
use App\Support\ServiceCallBoundary;
Expand All @@ -35,11 +36,15 @@
use App\Support\WorkflowMemoRollingCompatibility;
use App\Support\WorkflowObservability;
use App\Support\WorkflowPackageApiFloor;
use App\Support\WorkflowServiceOperationAuthorizer;
use App\Support\WorkflowTaskLeaseConfiguration;
use Illuminate\Console\Events\CommandStarting;
use Illuminate\Contracts\Bus\Dispatcher as BusDispatcher;
use Illuminate\Contracts\Console\Kernel as ConsoleKernel;
use Illuminate\Database\Connection;
use Illuminate\Database\Events\TransactionCommitted;
use Illuminate\Database\Events\TransactionCommitting;
use Illuminate\Database\Events\TransactionRolledBack;
use Illuminate\Queue\Events\Looping;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\ServiceProvider;
Expand All @@ -66,6 +71,17 @@ class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->scoped(RuntimeExternalPayloadWriteClaims::class);
$this->app->make('events')->listen(TransactionCommitting::class, static function (TransactionCommitting $event): void {
app(RuntimeExternalPayloadWriteClaims::class)->beforeCommit($event->connection);
});
$this->app->make('events')->listen(TransactionCommitted::class, static function (TransactionCommitted $event): void {
app(RuntimeExternalPayloadWriteClaims::class)->afterCommit($event->connection);
});
$this->app->make('events')->listen(TransactionRolledBack::class, static function (TransactionRolledBack $event): void {
app(RuntimeExternalPayloadWriteClaims::class)->afterRollback($event->connection);
});

Connection::resolverFor('sqlite', static fn ($pdo, $database, $prefix, $config) => new GuardedSqliteConnection($pdo, $database, $prefix, $config));
// Install before package boot callbacks or commands can open a database.
foreach ([
Expand Down Expand Up @@ -181,6 +197,10 @@ public function register(): void
$this->app->singleton(ServiceBoundaryAuditRecorder::class);

$this->app->singleton(ServiceCallBoundary::class);
$this->app->bind(
\Workflow\V2\Contracts\WorkflowServiceOperationAuthorizer::class,
WorkflowServiceOperationAuthorizer::class,
);
}

private function normalizeRedisPorts(): void
Expand Down
2 changes: 2 additions & 0 deletions app/Support/ExternalPayloadRetentionCleanup.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
use Workflow\V2\Models\WorkflowRunSummary;
use Workflow\V2\Models\WorkflowRunTimerEntry;
use Workflow\V2\Models\WorkflowRunWait;
use Workflow\V2\Models\WorkflowSchedule;
use Workflow\V2\Models\WorkflowScheduleHistoryEvent;
use Workflow\V2\Models\WorkflowSearchAttribute;
use Workflow\V2\Models\WorkflowServiceCall;
Expand Down Expand Up @@ -686,6 +687,7 @@ private function retainedPayloadColumns(
}

return [
[WorkflowSchedule::query(), 'action'],
[WorkflowRunSummary::query()->whereIn('id', $this->retainedRunIdsQuery($deletedRunIds)), 'visibility_labels'],
[ActivityExecution::query()->whereIn('workflow_run_id', $this->retainedRunIdsQuery($deletedRunIds)), 'arguments'],
[ActivityExecution::query()->whereIn('workflow_run_id', $this->retainedRunIdsQuery($deletedRunIds)), 'result'],
Expand Down
Loading
Loading