Skip to content

Commit 23418d6

Browse files
Fail closed on server nodes without HTTP control roles
Fail closed on server nodes without HTTP control roles
1 parent a83e666 commit 23418d6

5 files changed

Lines changed: 303 additions & 26 deletions

File tree

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -698,6 +698,13 @@ behavior for each role failure domain, the scaling axis for each role, and the
698698
incremental migration steps from today's standalone shape to the split
699699
control/execution topology.
700700

701+
Authenticated API routes now also fail closed against that advertised process
702+
class. Nodes that do not host the server's current HTTP control surface return
703+
`503` with `reason: "topology_role_unavailable"` on role-gated routes instead
704+
of pretending to be interchangeable HTTP peers. `GET /api/cluster/info`,
705+
`/api/health`, and `/api/ready` stay available for discovery and liveness even
706+
on scheduler-only, execution-only, or matching-only nodes.
707+
701708
The same `GET /api/cluster/info` response now includes a versioned
702709
`coordination_health` manifest for rollout-safety coordination risk. It
703710
summarizes the current server-wide workflow v2 health status, warning and error
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
<?php
2+
3+
namespace App\Http\Middleware;
4+
5+
use App\Support\ControlPlaneProtocol;
6+
use App\Support\ServerTopology;
7+
use App\Support\WorkerProtocol;
8+
use Closure;
9+
use Illuminate\Http\JsonResponse;
10+
use Illuminate\Http\Request;
11+
use Symfony\Component\HttpFoundation\Response;
12+
13+
class RequireTopologyRoles
14+
{
15+
public function handle(Request $request, Closure $next, string ...$roles): Response
16+
{
17+
$requiredRoles = $this->requiredRoles($roles);
18+
19+
if ($requiredRoles === []) {
20+
return self::error($request, 500, 'server_error', 'Route topology requirement is not configured.');
21+
}
22+
23+
$currentNode = ServerTopology::currentNode();
24+
$currentRoles = $currentNode['roles'];
25+
$missingRoles = array_values(array_diff($requiredRoles, $currentRoles));
26+
27+
if ($missingRoles === []) {
28+
return $next($request);
29+
}
30+
31+
return self::error(
32+
$request,
33+
503,
34+
'topology_role_unavailable',
35+
'This node does not host the topology roles required for this endpoint.',
36+
[
37+
'current_shape' => $currentNode['shape'],
38+
'current_process_class' => $currentNode['process_class'],
39+
'current_roles' => $currentRoles,
40+
'required_roles' => $requiredRoles,
41+
'missing_roles' => $missingRoles,
42+
],
43+
);
44+
}
45+
46+
/**
47+
* @param array<int, string> $roles
48+
* @return array<int, string>
49+
*/
50+
private function requiredRoles(array $roles): array
51+
{
52+
$required = [];
53+
54+
foreach ($roles as $role) {
55+
foreach (explode(',', $role) as $part) {
56+
$part = trim($part);
57+
58+
if ($part !== '') {
59+
$required[] = $part;
60+
}
61+
}
62+
}
63+
64+
return array_values(array_unique($required));
65+
}
66+
67+
/**
68+
* @param array<string, mixed> $extra
69+
*/
70+
private static function error(Request $request, int $status, string $reason, string $message, array $extra = []): JsonResponse
71+
{
72+
if (WorkerProtocol::isWorkerPlaneRequest($request)) {
73+
return WorkerProtocol::json(array_filter([
74+
'reason' => $reason,
75+
'message' => $message,
76+
] + $extra, static fn (mixed $value): bool => $value !== null), $status);
77+
}
78+
79+
return ControlPlaneProtocol::jsonForRequest($request, array_filter([
80+
'reason' => $reason,
81+
'message' => $message,
82+
] + $extra, static fn (mixed $value): bool => $value !== null), $status);
83+
}
84+
}

app/Support/ServerTopology.php

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -39,21 +39,19 @@ final class ServerTopology
3939
public static function info(): array
4040
{
4141
$shapeAssignments = self::shapeAssignments();
42-
$currentShape = self::currentShape();
43-
$currentProcessClass = self::currentProcessClass($currentShape, $shapeAssignments);
44-
$currentRoles = self::rolesForProcessClass($currentShape, $currentProcessClass, $shapeAssignments);
42+
$currentNode = self::currentNode($shapeAssignments);
4543

4644
return [
4745
'schema' => self::SCHEMA,
4846
'version' => self::VERSION,
4947
'supported_shapes' => self::SUPPORTED_SHAPES,
5048
'role_vocabulary' => self::ROLE_VOCABULARY,
51-
'current_shape' => $currentShape,
52-
'current_process_class' => $currentProcessClass,
53-
'current_roles' => $currentRoles,
49+
'current_shape' => $currentNode['shape'],
50+
'current_process_class' => $currentNode['process_class'],
51+
'current_roles' => $currentNode['roles'],
5452
'execution_mode' => self::executionMode(),
5553
'matching_role' => self::matchingRole(),
56-
'role_catalog' => self::roleCatalog($currentRoles),
54+
'role_catalog' => self::roleCatalog($currentNode['roles']),
5755
'shape_assignments' => $shapeAssignments,
5856
'authority_boundaries' => self::authorityBoundaries(),
5957
'authority_surfaces' => self::authoritySurfaces(),
@@ -64,6 +62,23 @@ public static function info(): array
6462
];
6563
}
6664

65+
/**
66+
* @param array<string, array{process_classes: list<array{name: string, roles: list<string>}>}>|null $shapeAssignments
67+
* @return array{shape: string, process_class: string, roles: list<string>}
68+
*/
69+
public static function currentNode(?array $shapeAssignments = null): array
70+
{
71+
$shapeAssignments ??= self::shapeAssignments();
72+
$shape = self::currentShape();
73+
$processClass = self::currentProcessClass($shape, $shapeAssignments);
74+
75+
return [
76+
'shape' => $shape,
77+
'process_class' => $processClass,
78+
'roles' => self::rolesForProcessClass($shape, $processClass, $shapeAssignments),
79+
];
80+
}
81+
6782
private static function executionMode(): string
6883
{
6984
return config('server.mode') === 'embedded'

routes/api.php

Lines changed: 26 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
use App\Http\Middleware\ControlPlaneVersionResolver;
1919
use App\Http\Middleware\NamespaceResolver;
2020
use App\Http\Middleware\RequireRole;
21+
use App\Http\Middleware\RequireTopologyRoles;
2122
use App\Http\Middleware\WorkerProtocolVersionResolver;
2223
use Illuminate\Support\Facades\Route;
2324

@@ -46,6 +47,10 @@
4647
// omitted — it is the version-advertising endpoint and must remain callable
4748
// without the header.
4849
//
50+
// RequireTopologyRoles sits after protocol validation and before
51+
// NamespaceResolver on hosted routes so wrong-node requests fail closed with a
52+
// machine-readable topology reason without leaking namespace existence.
53+
//
4954
// WorkerProtocolVersionResolver follows the same ordering for worker-plane
5055
// routes, keeping protocol skew and namespace errors in the worker envelope.
5156
Route::middleware([Authenticate::class])->group(function () {
@@ -55,27 +60,29 @@
5560
$authenticated = RequireRole::class.':worker,operator,admin';
5661
$ns = NamespaceResolver::class;
5762
$cpv = ControlPlaneVersionResolver::class;
63+
$httpControl = RequireTopologyRoles::class.':api_ingress,control_plane';
64+
$httpWorker = RequireTopologyRoles::class.':api_ingress,control_plane';
5865
$wpv = WorkerProtocolVersionResolver::class;
5966

6067
// ── System ───────────────────────────────────────────────────────
6168
Route::get('/cluster/info', [HealthController::class, 'clusterInfo'])->middleware([$authenticated, $ns]);
6269

6370
// ── Namespaces ───────────────────────────────────────────────────
64-
Route::prefix('namespaces')->group(function () use ($admin, $operator, $ns, $cpv) {
65-
Route::get('/', [NamespaceController::class, 'index'])->middleware([$operator, $cpv, $ns]);
66-
Route::post('/', [NamespaceController::class, 'store'])->middleware([$admin, $cpv, $ns]);
67-
Route::get('/{namespace}', [NamespaceController::class, 'show'])->middleware([$operator, $cpv, $ns]);
68-
Route::put('/{namespace}', [NamespaceController::class, 'update'])->middleware([$admin, $cpv, $ns]);
69-
Route::put('/{namespace}/external-storage', [NamespaceController::class, 'updateExternalStorage'])->middleware([$admin, $cpv, $ns]);
71+
Route::prefix('namespaces')->group(function () use ($admin, $operator, $ns, $cpv, $httpControl) {
72+
Route::get('/', [NamespaceController::class, 'index'])->middleware([$operator, $cpv, $httpControl, $ns]);
73+
Route::post('/', [NamespaceController::class, 'store'])->middleware([$admin, $cpv, $httpControl, $ns]);
74+
Route::get('/{namespace}', [NamespaceController::class, 'show'])->middleware([$operator, $cpv, $httpControl, $ns]);
75+
Route::put('/{namespace}', [NamespaceController::class, 'update'])->middleware([$admin, $cpv, $httpControl, $ns]);
76+
Route::put('/{namespace}/external-storage', [NamespaceController::class, 'updateExternalStorage'])->middleware([$admin, $cpv, $httpControl, $ns]);
7077
});
7178

7279
// ── External Payload Storage ───────────────────────────────────
73-
Route::prefix('storage')->middleware([$admin, $cpv, $ns])->group(function () {
80+
Route::prefix('storage')->middleware([$admin, $cpv, $httpControl, $ns])->group(function () {
7481
Route::post('/test', [StorageController::class, 'test']);
7582
});
7683

7784
// ── Workflows ────────────────────────────────────────────────────
78-
Route::prefix('workflows')->middleware([$operator, $cpv, $ns])->group(function () {
85+
Route::prefix('workflows')->middleware([$operator, $cpv, $httpControl, $ns])->group(function () {
7986
Route::get('/', [WorkflowController::class, 'index']);
8087
Route::post('/', [WorkflowController::class, 'start']);
8188
Route::get('/{workflowId}', [WorkflowController::class, 'show']);
@@ -106,12 +113,12 @@
106113
});
107114

108115
// ── Bridge Adapters ──────────────────────────────────────────────
109-
Route::prefix('bridge-adapters')->middleware([$operator, $cpv, $ns])->group(function () {
116+
Route::prefix('bridge-adapters')->middleware([$operator, $cpv, $httpControl, $ns])->group(function () {
110117
Route::post('/webhook/{adapter}', [BridgeAdapterController::class, 'webhook']);
111118
});
112119

113120
// ── Worker Task Polling ──────────────────────────────────────────
114-
Route::prefix('worker')->middleware([$worker, $wpv, $ns])->group(function () {
121+
Route::prefix('worker')->middleware([$worker, $wpv, $httpWorker, $ns])->group(function () {
115122
// Registration
116123
Route::post('/register', [WorkerController::class, 'register']);
117124
Route::post('/heartbeat', [WorkerController::class, 'heartbeat']);
@@ -136,14 +143,14 @@
136143
});
137144

138145
// ── Workers (Management) ──────────────────────────────────────────
139-
Route::prefix('workers')->group(function () use ($admin, $operator, $ns, $cpv) {
140-
Route::get('/', [WorkerManagementController::class, 'index'])->middleware([$operator, $cpv, $ns]);
141-
Route::get('/{workerId}', [WorkerManagementController::class, 'show'])->middleware([$operator, $cpv, $ns]);
142-
Route::delete('/{workerId}', [WorkerManagementController::class, 'destroy'])->middleware([$admin, $cpv, $ns]);
146+
Route::prefix('workers')->group(function () use ($admin, $operator, $ns, $cpv, $httpControl) {
147+
Route::get('/', [WorkerManagementController::class, 'index'])->middleware([$operator, $cpv, $httpControl, $ns]);
148+
Route::get('/{workerId}', [WorkerManagementController::class, 'show'])->middleware([$operator, $cpv, $httpControl, $ns]);
149+
Route::delete('/{workerId}', [WorkerManagementController::class, 'destroy'])->middleware([$admin, $cpv, $httpControl, $ns]);
143150
});
144151

145152
// ── Task Queues ──────────────────────────────────────────────────
146-
Route::prefix('task-queues')->middleware([$operator, $cpv, $ns])->group(function () {
153+
Route::prefix('task-queues')->middleware([$operator, $cpv, $httpControl, $ns])->group(function () {
147154
Route::get('/', [TaskQueueController::class, 'index']);
148155
Route::get('/{taskQueue}/build-ids', [TaskQueueController::class, 'buildIds']);
149156
Route::post('/{taskQueue}/build-ids/drain', [TaskQueueController::class, 'drainBuildId']);
@@ -152,7 +159,7 @@
152159
});
153160

154161
// ── Schedules ────────────────────────────────────────────────────
155-
Route::prefix('schedules')->middleware([$operator, $cpv, $ns])->group(function () {
162+
Route::prefix('schedules')->middleware([$operator, $cpv, $httpControl, $ns])->group(function () {
156163
Route::get('/', [ScheduleController::class, 'index']);
157164
Route::post('/', [ScheduleController::class, 'store']);
158165
Route::get('/{scheduleId}', [ScheduleController::class, 'show']);
@@ -166,14 +173,14 @@
166173
});
167174

168175
// ── Search Attributes ────────────────────────────────────────────
169-
Route::prefix('search-attributes')->middleware([$operator, $cpv, $ns])->group(function () {
176+
Route::prefix('search-attributes')->middleware([$operator, $cpv, $httpControl, $ns])->group(function () {
170177
Route::get('/', [SearchAttributeController::class, 'index']);
171178
Route::post('/', [SearchAttributeController::class, 'store']);
172179
Route::delete('/{name}', [SearchAttributeController::class, 'destroy']);
173180
});
174181

175182
// ── Service Catalog ──────────────────────────────────────────────
176-
Route::prefix('service-endpoints')->middleware([$admin, $cpv, $ns])->group(function () {
183+
Route::prefix('service-endpoints')->middleware([$admin, $cpv, $httpControl, $ns])->group(function () {
177184
Route::get('/', [ServiceCatalogController::class, 'endpointIndex']);
178185
Route::post('/', [ServiceCatalogController::class, 'endpointStore']);
179186
Route::get('/{endpointName}', [ServiceCatalogController::class, 'endpointShow']);
@@ -194,7 +201,7 @@
194201
});
195202

196203
// ── System / Operations ─────────────────────────────────────────
197-
Route::prefix('system')->middleware([$admin, $cpv, $ns])->group(function () {
204+
Route::prefix('system')->middleware([$admin, $cpv, $httpControl, $ns])->group(function () {
198205
Route::get('/health', [SystemController::class, 'health']);
199206
Route::match(['get', 'post'], '/metrics', [SystemController::class, 'metrics']);
200207
Route::get('/operator-metrics', [SystemController::class, 'operatorMetrics']);

0 commit comments

Comments
 (0)