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
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,22 @@

## Unreleased

- Require a trusted host authorizer for workflow service operations. Refuse
worker-supplied principal overrides, foreign caller namespaces and service
call IDs or idempotency keys owned by another run, instance or operation.
Preserve explicit embedded null namespaces instead of using ambient config.
- Service-operation hosts must bind `WorkflowServiceOperationAuthorizer` before
upgrading. The default refuses service operations; explicitly trusted embedded
hosts can opt into `RunServiceOperationAuthorizer` for same-namespace calls
with an unprivileged workflow principal. Authenticated servers supply their
own current principal and authorize requested target scope.
- Recheck current service-call replay policy without reserving fresh admission
capacity, including cached checkpoint receipts. Custom policies must provide
the pure `authorizeReplay` hook.
- Serialize keyed service-call creation with a unique canonical scope digest.
Reauthorize the actual locked row after a concurrent insert or uniqueness
conflict. Apply the nullable schema expansion and drain older writers before
relying on concurrent deduplication.
- Bound `record_local_activity` timing fields (`duration_ms`, heartbeat
`elapsed_ms`, `backoff_seconds`) and refuse a report whose walk back from
completion leaves the storable timestamp range (#930).
Expand Down
80 changes: 80 additions & 0 deletions docs/architecture/workflow-service-calls-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,86 @@ of the five reasons, and a Cancelled call MUST carry
`ServiceCallFailureReason::Cancellation` even though its terminal
status is Cancelled rather than Failed.

## Workflow operation authority

Workflow service operations require a host binding for
`Workflow\V2\Contracts\WorkflowServiceOperationAuthorizer`. The default binding
refuses them with `service_operation_authority_invalid`. This applies to embedded
execution and external worker completions, including retained-claim prefixes.
Upgrade the host binding before accepting service-operation work.

An authorizer receives the locked caller run and normalized command intent.
It must validate the requested target scope against trusted host/run context,
then return a `ServiceCallPrincipal` derived from that context. It must not
reconstruct authority from the command's principal fields or metadata. It is an
authorization check, so it must not reserve admission budgets or perform
external effects; a transaction retry or preflight may invoke it again.

An embedded host that explicitly trusts its own workflow code to invoke
services within each run's namespace can opt into the provided workload policy:

```php
$app->bind(
\Workflow\V2\Contracts\WorkflowServiceOperationAuthorizer::class,
\Workflow\V2\Support\RunServiceOperationAuthorizer::class,
);
```

That policy permits only the run's namespace. Its principal is
`workflow:<run-id>`, method `workflow`, with no roles, tenant or claims. It does
not satisfy a catalog policy that requires privileged roles. A server with
authenticated workers instead binds an authorizer that checks its current
authentication/namespace policy and returns the authenticated worker principal.
Authorized cross-namespace routing remains possible through that trusted host
binding; a worker's requested `namespace` alone grants no permission.

Policies used for existing workflow-call replay must provide
`authorizeReplay(ServiceBoundaryRequest): ServiceBoundaryDecision`; the optional
`ServiceBoundaryReplayPolicy` interface declares this contract. The default
policy checks current authorization and circuit state without reserving rate
or concurrency capacity. Existing compatible host methods work without adopting
the interface. A custom policy without this pure check fails closed with
`service_operation_replay_authority_unavailable`; implement the hook as part of
the host upgrade. Cached checkpoint receipts require the same current check,
including after catalog policy changes or replacement.

Caller namespace and workflow run/instance identity always come from the locked
run. A mismatched non-null `caller_namespace` or non-null `principal_*` override
is refused before applying sibling commands. Unknown fields do not grant
authority. Explicit null run namespaces stay null even when the process has a
configured default namespace.

An explicit `service_call_id` or an existing idempotency-key match must bind to
the same caller namespace, run and instance, the authorized target namespace,
and the exact endpoint/service/operation names. Existing rows are checked under
locks before replay or dispatch. A missing or foreign explicit call, or a
foreign key match, refuses the worker turn with `service_call_not_owned` and
leaves the lease and durable rows unchanged. New default idempotency keys are
checked by the same rule. Server adapters retain their current-policy replay
checks before handing the call to Core.

The run, service call and catalog models must use the completion's database
connection. A separate connection is refused before sibling writes with
`service_operation_storage_connection_mismatch`; separate transactions cannot
provide the atomic refusal contract.

Keyed calls use a unique SHA-256 digest of the exact target namespace (including
null), catalog names and opaque key. The full scope is still compared after
lookup; a digest or database collation match never grants authority. Creation
uses a savepoint, then locks and reauthorizes the canonical row if another
transaction wins. Explicit call IDs obey the same keyed-call invariant.

Apply the nullable `idempotency_scope_hash` expansion before starting upgraded
writers. It does not backfill existing calls: an existing candidate must pass
current caller, catalog and policy checks before it can claim the key. Conflicting
legacy rows are refused rather than reassigned. Stop or drain older service-call
writers before relying on concurrent deduplication, since they do not populate
the digest. Retaining the column and index supports code rollback without
discarding canonical claims. The migration uses a short metadata-lock timeout
and online index creation on PostgreSQL and MySQL; retry a refused migration
after removing the lock contention. No production migration has been qualified
by the source tests alone.

## Observability surface

The observability surface for a cross-namespace service call MUST be
Expand Down
6 changes: 6 additions & 0 deletions src/Providers/WorkflowServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
use Workflow\V2\Contracts\ServiceBoundaryPolicy;
use Workflow\V2\Contracts\ServiceControlPlane;
use Workflow\V2\Contracts\WorkflowControlPlane;
use Workflow\V2\Contracts\WorkflowServiceOperationAuthorizer;
use Workflow\V2\Contracts\WorkflowTaskBridge;
use Workflow\V2\Models\WorkflowHistoryEvent;
use Workflow\V2\Models\WorkflowLink;
Expand All @@ -63,6 +64,7 @@
use Workflow\V2\Support\DefaultServiceControlPlane;
use Workflow\V2\Support\DefaultWorkflowControlPlane;
use Workflow\V2\Support\DefaultWorkflowTaskBridge;
use Workflow\V2\Support\DenyWorkflowServiceOperationAuthorizer;
use Workflow\V2\Support\ExecutionTracing;
use Workflow\V2\Support\HistoryProjectionMaintenanceFallback;
use Workflow\V2\Support\InMemoryTaskFairnessState;
Expand Down Expand Up @@ -132,6 +134,10 @@ static function ($app): HistoryProjectionMaintenanceRole {
);

$this->app->singletonIf(ServiceControlPlane::class, DefaultServiceControlPlane::class);
$this->app->singletonIf(
WorkflowServiceOperationAuthorizer::class,
DenyWorkflowServiceOperationAuthorizer::class
);

$this->app->singletonIf(SchedulerRole::class, DefaultSchedulerRole::class);

Expand Down
14 changes: 14 additions & 0 deletions src/V2/Contracts/ServiceBoundaryReplayPolicy.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

declare(strict_types=1);

namespace Workflow\V2\Contracts;

use Workflow\V2\Support\ServiceBoundaryDecision;
use Workflow\V2\Support\ServiceBoundaryRequest;

/** Current authorization/circuit checks without a fresh admission reservation. */
interface ServiceBoundaryReplayPolicy extends ServiceBoundaryPolicy
{
public function authorizeReplay(ServiceBoundaryRequest $request): ServiceBoundaryDecision;
}
26 changes: 26 additions & 0 deletions src/V2/Contracts/WorkflowServiceOperationAuthorizer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

declare(strict_types=1);

namespace Workflow\V2\Contracts;

use Workflow\V2\Models\WorkflowRun;
use Workflow\V2\Support\ServiceCallPrincipal;

/**
* Server-owned authority for workflow service operations.
*
* The command supplies routing intent only. Implementations must authorize
* that intent against trusted host/run context and return its authenticated
* principal, never a principal reconstructed from command fields. Throw
* ServiceOperationAuthorizationException to refuse the entire worker turn.
* This check must not reserve admission budgets or perform external effects;
* preflight and transaction retries can invoke it more than once.
*/
interface WorkflowServiceOperationAuthorizer
{
/**
* @param array<string, mixed> $command
*/
public function authorize(WorkflowRun $run, array $command): ServiceCallPrincipal;
}
15 changes: 15 additions & 0 deletions src/V2/Exceptions/ServiceOperationAuthorizationException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

declare(strict_types=1);

namespace Workflow\V2\Exceptions;

use RuntimeException;

final class ServiceOperationAuthorizationException extends RuntimeException
{
public function __construct(string $reason = 'service_operation_authority_invalid')
{
parent::__construct($reason);
}
}
11 changes: 9 additions & 2 deletions src/V2/Support/DefaultServiceBoundaryPolicy.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

namespace Workflow\V2\Support;

use Workflow\V2\Contracts\ServiceBoundaryPolicy;
use Workflow\V2\Contracts\ServiceBoundaryReplayPolicy;
use Workflow\V2\Enums\ServiceCallOperationMode;

/**
Expand All @@ -15,7 +15,7 @@
* tests exercise the contract without requiring Redis or a dedicated policy
* service.
*/
final class DefaultServiceBoundaryPolicy implements ServiceBoundaryPolicy
final class DefaultServiceBoundaryPolicy implements ServiceBoundaryReplayPolicy
{
public const POLICY_NAME = 'workflow.default-service-boundary';

Expand Down Expand Up @@ -82,6 +82,13 @@ public function release(ServiceBoundaryRequest $request): void
}
}

public function authorizeReplay(ServiceBoundaryRequest $request): ServiceBoundaryDecision
{
return $this->checkAuthorization($request)
?? $this->checkCircuitBreak($request)
?? ServiceBoundaryDecision::allow(policyName: self::POLICY_NAME);
}

/**
* @param array<string, mixed> $rules
*/
Expand Down
Loading
Loading