@substrat-run/control-plane-api 0.116.0 → 0.118.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api.d.ts +31 -1
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +526 -104
- package/dist/api.js.map +1 -1
- package/dist/auth.d.ts +35 -3
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js +11 -0
- package/dist/auth.js.map +1 -1
- package/dist/cf-observability.d.ts.map +1 -1
- package/dist/cf-observability.js +26 -2
- package/dist/cf-observability.js.map +1 -1
- package/dist/index.d.ts +8 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -2
- package/dist/index.js.map +1 -1
- package/dist/observability.d.ts +6 -0
- package/dist/observability.d.ts.map +1 -1
- package/dist/platform-drain.d.ts +6 -3
- package/dist/platform-drain.d.ts.map +1 -1
- package/dist/platform-drain.js +22 -4
- package/dist/platform-drain.js.map +1 -1
- package/dist/push-token.d.ts.map +1 -1
- package/dist/push-token.js +9 -23
- package/dist/push-token.js.map +1 -1
- package/dist/scope-deployment.d.ts +34 -0
- package/dist/scope-deployment.d.ts.map +1 -0
- package/dist/scope-deployment.js +19 -0
- package/dist/scope-deployment.js.map +1 -0
- package/dist/storage-meter.d.ts +32 -0
- package/dist/storage-meter.d.ts.map +1 -0
- package/dist/storage-meter.js +69 -0
- package/dist/storage-meter.js.map +1 -0
- package/dist/tenant-token.d.ts +41 -0
- package/dist/tenant-token.d.ts.map +1 -0
- package/dist/tenant-token.js +108 -0
- package/dist/tenant-token.js.map +1 -0
- package/dist/token-codec.d.ts +30 -0
- package/dist/token-codec.d.ts.map +1 -0
- package/dist/token-codec.js +63 -0
- package/dist/token-codec.js.map +1 -0
- package/dist/vertical-client.d.ts +34 -3
- package/dist/vertical-client.d.ts.map +1 -1
- package/dist/vertical-client.js +66 -3
- package/dist/vertical-client.js.map +1 -1
- package/package.json +5 -5
package/dist/api.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { Hono } from 'hono';
|
|
2
|
-
import { parseHostname, withLabel, RESERVED_LABEL_SEPARATOR, adminAction, ASSET_PART_PREFIX, assetHash, channelName, connectionActivity, connectionActivitySource, connectionCredential, connectionFilter, connectionProbe, createTenantInput, entitlementGrantInput, hostname as hostnameSchema, hostnameRegion, hostnameStatus, identityLink, errorCode, issueStatus, issueStatusInput, listPageQuery, matchesOutboundHost, pageOf, platformRequestFilter, principalId as principalIdSchema, promotionAcknowledgement, provisionableJurisdiction, publishVersionInput, queryScopeInput, readScopeTableInput, entityHistoryInput, eventFacetInput, eventCauseInput, eventEffectsInput, invocationEventsInput, deadLettersInput, delegatedReadParams, createOrgInput, roleKey as roleKeySchema, DEFAULT_DENIAL_LIMIT, DENIAL_LIMIT_MAX, denialGroupBy, registerVerticalInput, serviceDimensions, sweepRunKind, sweepRunOutcome, scopeDump, dataSubjectId as dataSubjectIdSchema, scopeId as scopeIdSchema, scopeStatus, storageShape, surfaceName, tenantId as tenantIdSchema, tenantStatus, versionOrigin, z, PROBLEM_CONTENT_TYPE, toProblem, redrainEventsInput, REDRAIN_BATCH, } from '@substrat-run/contracts';
|
|
2
|
+
import { parseHostname, withLabel, RESERVED_LABEL_SEPARATOR, adminAction, ASSET_PART_PREFIX, assetHash, channelName, connectionActivity, connectionActivitySource, connectionCredential, connectionFilter, connectionProbe, createTenantInput, entitlementGrantInput, hostname as hostnameSchema, hostnameRegion, hostnameStatus, identityLink, errorCode, issueStatus, issueStatusInput, listPageQuery, matchesOutboundHost, pageOf, platformRequestFilter, principalId as principalIdSchema, promotionAcknowledgement, provisionableJurisdiction, publishVersionInput, queryScopeInput, readScopeTableInput, entityHistoryInput, eventFacetInput, eventCauseInput, eventEffectsInput, invocationEventsInput, deadLettersInput, delegatedReadParams, createOrgInput, roleKey as roleKeySchema, systemSwitch, DEFAULT_DENIAL_LIMIT, DENIAL_LIMIT_MAX, denialGroupBy, registerVerticalInput, serviceDimensions, sweepRunKind, sweepRunOutcome, scopeDump, dataSubjectId as dataSubjectIdSchema, scopeId as scopeIdSchema, scopeStatus, storageShape, surfaceName, tenantId as tenantIdSchema, tenantStatus, versionOrigin, z, PROBLEM_CONTENT_TYPE, toProblem, redrainEventsInput, REDRAIN_BATCH, } from '@substrat-run/contracts';
|
|
3
3
|
import { attributeFailure } from './failure-attribution.js';
|
|
4
4
|
import { migrationProgress, ulid } from '@substrat-run/kernel';
|
|
5
|
-
import { TENANT_HEADER } from './auth.js';
|
|
5
|
+
import { TENANT_HEADER, confinedTenant } from './auth.js';
|
|
6
|
+
import { mintTenantToken } from './tenant-token.js';
|
|
6
7
|
import { connectionGrantsForScope } from './vertical-client.js';
|
|
8
|
+
import { versionReachedAt } from './scope-deployment.js';
|
|
7
9
|
import { reconcileConnectionGrants } from './connection-grants.js';
|
|
8
10
|
import { ConnectionRelayError, relayConnectionUpsert } from './connection-relay.js';
|
|
9
11
|
import { ControlPlaneError } from './client.js';
|
|
@@ -31,6 +33,7 @@ import { mintPushToken, pushActorFor } from './push-token.js';
|
|
|
31
33
|
import { TENANT_SERIES_SCOPE_CAP } from './observability.js';
|
|
32
34
|
import { namespacesForScript } from './do-namespaces.js';
|
|
33
35
|
import { backupDirectoryIfDue } from './directory-backup.js';
|
|
36
|
+
import { STORAGE_PAGE_DEFAULT, STORAGE_PAGE_MAX, readStoragePage } from './storage-meter.js';
|
|
34
37
|
import { isCustomHostname, validateBindableHostname, } from './custom-hostnames.js';
|
|
35
38
|
/**
|
|
36
39
|
* Response headers carrying the CLI version advisory (`ControlPlaneApiOptions.cliAdvisory`).
|
|
@@ -39,6 +42,18 @@ import { isCustomHostname, validateBindableHostname, } from './custom-hostnames.
|
|
|
39
42
|
*/
|
|
40
43
|
export const CLI_MIN_VERSION_HEADER = 'x-substrat-cli-min-version';
|
|
41
44
|
export const CLI_LATEST_VERSION_HEADER = 'x-substrat-cli-latest-version';
|
|
45
|
+
/**
|
|
46
|
+
* "This principal is confined to a tenant, and this is not that tenant."
|
|
47
|
+
*
|
|
48
|
+
* The question every `/tenants/:tenantId/…` route asks before it answers. Spelled in
|
|
49
|
+
* terms of CONFINEMENT rather than of a principal kind, because a route that asks
|
|
50
|
+
* `kind === 'builder'` answers fleet-wide for every other confined credential — which
|
|
51
|
+
* is the shape #977 found the dashboard's in.
|
|
52
|
+
*/
|
|
53
|
+
function outsideTenant(p, tenantId) {
|
|
54
|
+
const pin = confinedTenant(p);
|
|
55
|
+
return pin !== null && pin !== tenantId;
|
|
56
|
+
}
|
|
42
57
|
// -- request schemas ---------------------------------------------------------
|
|
43
58
|
// Parse, don't trust: every input crosses Zod at the boundary. The ids stay
|
|
44
59
|
// CALLER-SUPPLIED rather than minted here, exactly as the contract has them —
|
|
@@ -322,6 +337,12 @@ const tenantRoleAssignmentBody = z
|
|
|
322
337
|
roleKey: roleKeySchema,
|
|
323
338
|
})
|
|
324
339
|
.strict();
|
|
340
|
+
/**
|
|
341
|
+
* The schedule kill switch's body (#1666) — the module and the reason. The node comes
|
|
342
|
+
* from the PATH, after the K-3 cross-check, for the reason `tenantRoleAssignmentBody`
|
|
343
|
+
* spells out; `.strict()` so a body that tried to name one is refused, not stripped.
|
|
344
|
+
*/
|
|
345
|
+
const systemSwitchBody = systemSwitch.pick({ moduleId: true, reason: true }).strict();
|
|
325
346
|
/** A new org under an addressed tenant (#1343) — `tenantId` comes from the path. */
|
|
326
347
|
const tenantOrgBody = createOrgInput.omit({ tenantId: true });
|
|
327
348
|
const sweepRunsQuery = z.object({
|
|
@@ -356,6 +377,105 @@ export const DEFAULT_MODEL_MARGIN_PERCENT = 20;
|
|
|
356
377
|
/** The upstream provider's trace handle, when a failure message carries one —
|
|
357
378
|
* Cloudflare's `internal error; reference = <id>` shape (#559). */
|
|
358
379
|
const UPSTREAM_REFERENCE = /\breference\s*=\s*([a-z0-9]+)/i;
|
|
380
|
+
const TENANT_ROUTES = [
|
|
381
|
+
// Everything under one tenant's own path. One entry rather than sixty, because
|
|
382
|
+
// the confinement is carried by the URL shape and not by a list anybody has to
|
|
383
|
+
// keep current: `/tenants/<pin>/…` cannot address another tenant's row whatever
|
|
384
|
+
// the sub-route does. `/tenants` itself (the fleet list) does not match.
|
|
385
|
+
{ method: 'GET', re: /\/tenants\/[^/]+(\/.*)?$/, pin: 'path' },
|
|
386
|
+
{ method: 'POST', re: /\/tenants\/[^/]+(\/.*)?$/, pin: 'path' },
|
|
387
|
+
{ method: 'PUT', re: /\/tenants\/[^/]+(\/.*)?$/, pin: 'path' },
|
|
388
|
+
{ method: 'PATCH', re: /\/tenants\/[^/]+(\/.*)?$/, pin: 'path' },
|
|
389
|
+
{ method: 'DELETE', re: /\/tenants\/[^/]+(\/.*)?$/, pin: 'path' },
|
|
390
|
+
// Create the tenant's own directory row (idempotent) — `id` is the pin.
|
|
391
|
+
{ method: 'POST', re: /\/tenants$/, pin: 'body' },
|
|
392
|
+
// Provision an app + materialize its instance.
|
|
393
|
+
{ method: 'POST', re: /\/scopes$/, pin: 'body' },
|
|
394
|
+
{ method: 'POST', re: /\/verticals\/[^/]+\/instances$/, pin: 'body' },
|
|
395
|
+
// Mint the tenant's CI push token. Note what is NOT here: `/tenant-tokens`. A
|
|
396
|
+
// tenant credential cannot mint another, for itself or for anyone — the mint
|
|
397
|
+
// stays staff-only, so this credential can never widen its own reach.
|
|
398
|
+
{ method: 'POST', re: /\/push-tokens$/, pin: 'body' },
|
|
399
|
+
// The forced-filter reads: the route refuses without a tenantId and answers only
|
|
400
|
+
// that tenant's rows.
|
|
401
|
+
//
|
|
402
|
+
// `GET /scopes` is the one the dashboard leans on hardest — the Data tab's scope
|
|
403
|
+
// switcher and the whole Move/Retire flow (#1593) read it — and it is the one a
|
|
404
|
+
// path-shaped reading of this list would miss, because it names its tenant in a
|
|
405
|
+
// query while every other scope route names it in the path.
|
|
406
|
+
{ method: 'GET', re: /\/scopes$/, pin: 'query' },
|
|
407
|
+
{ method: 'GET', re: /\/admin-log$/, pin: 'query' },
|
|
408
|
+
{ method: 'GET', re: /\/ops-failures$/, pin: 'query' },
|
|
409
|
+
{ method: 'GET', re: /\/sweep-runs$/, pin: 'query' },
|
|
410
|
+
{ method: 'GET', re: /\/service-refs$/, pin: 'query' },
|
|
411
|
+
{ method: 'GET', re: /\/observability\/tenant-metrics$/, pin: 'query' },
|
|
412
|
+
{ method: 'GET', re: /\/observability\/tenant-metrics-series$/, pin: 'query' },
|
|
413
|
+
{ method: 'GET', re: /\/observability\/tenant-logs$/, pin: 'query' },
|
|
414
|
+
{ method: 'GET', re: /\/hostnames$/, pin: 'query' },
|
|
415
|
+
{ method: 'POST', re: /\/hostnames$/, pin: 'body' },
|
|
416
|
+
// The registry + install catalog.
|
|
417
|
+
{ method: 'GET', re: /\/verticals$/, pin: 'catalog' },
|
|
418
|
+
// Ownership-narrowed: the handler resolves who owns the slug / the hostname.
|
|
419
|
+
// Not listed, because the dashboard does not call them and an unused route on an
|
|
420
|
+
// allowlist is reach nobody asked for: POST /verticals (register), POST
|
|
421
|
+
// /verticals/:slug/versions (publish), .../deploy, .../publish-request. A push is
|
|
422
|
+
// the CLI's act, over a push token, and stays one.
|
|
423
|
+
{ method: 'DELETE', re: /\/verticals\/[^/]+$/, pin: 'owner' },
|
|
424
|
+
{ method: 'GET', re: /\/verticals\/[^/]+\/versions$/, pin: 'owner' },
|
|
425
|
+
{ method: 'GET', re: /\/verticals\/[^/]+\/versions\/[^/]+\/registry$/, pin: 'owner' },
|
|
426
|
+
{ method: 'GET', re: /\/verticals\/[^/]+\/versions\/[^/]+\/model$/, pin: 'owner' },
|
|
427
|
+
{ method: 'GET', re: /\/verticals\/[^/]+\/versions\/[^/]+\/flow$/, pin: 'owner' },
|
|
428
|
+
{ method: 'GET', re: /\/verticals\/[^/]+\/versions\/[^/]+\/assets$/, pin: 'owner' },
|
|
429
|
+
{ method: 'GET', re: /\/verticals\/[^/]+\/versions\/[^/]+\/schedules$/, pin: 'owner' },
|
|
430
|
+
{ method: 'GET', re: /\/verticals\/[^/]+\/channels$/, pin: 'owner' },
|
|
431
|
+
{ method: 'GET', re: /\/verticals\/[^/]+\/channels\/[^/]+\/history$/, pin: 'owner' },
|
|
432
|
+
{ method: 'POST', re: /\/verticals\/[^/]+\/channels\/[^/]+\/promote$/, pin: 'owner' },
|
|
433
|
+
{ method: 'GET', re: /\/verticals\/[^/]+\/previews$/, pin: 'owner' },
|
|
434
|
+
{ method: 'POST', re: /\/verticals\/[^/]+\/previews$/, pin: 'owner' },
|
|
435
|
+
{ method: 'DELETE', re: /\/verticals\/[^/]+\/previews\/[^/]+$/, pin: 'owner' },
|
|
436
|
+
{ method: 'POST', re: /\/hostnames\/[^/]+\/verify$/, pin: 'owner' },
|
|
437
|
+
{ method: 'PATCH', re: /\/hostnames\/[^/]+\/status$/, pin: 'owner' },
|
|
438
|
+
{ method: 'DELETE', re: /\/hostnames\/[^/]+$/, pin: 'owner' },
|
|
439
|
+
// Script-grain observability, narrowed in the handler to the services this
|
|
440
|
+
// tenant's own verticals deploy (see `ownedServices`). Without that narrowing
|
|
441
|
+
// these are fleet reads, which is how the dashboard used them over the staff
|
|
442
|
+
// token — filtering the fleet's answer in the browser worker's memory.
|
|
443
|
+
{ method: 'GET', re: /\/observability\/metrics$/, pin: 'owner' },
|
|
444
|
+
{ method: 'GET', re: /\/observability\/metrics-series$/, pin: 'owner' },
|
|
445
|
+
{ method: 'GET', re: /\/observability\/logs$/, pin: 'owner' },
|
|
446
|
+
];
|
|
447
|
+
/**
|
|
448
|
+
* How a tenant-scoped credential's pin is checked on this route, or `undefined` when
|
|
449
|
+
* the route is off its allowlist entirely (default-deny).
|
|
450
|
+
*
|
|
451
|
+
* Exported so the DASHBOARD's own suite can assert the other direction: that every
|
|
452
|
+
* route its authority seam can issue is one this credential reaches. Nothing else can
|
|
453
|
+
* check that — the allowlist lives here and the call sites live there — and the gap
|
|
454
|
+
* between the two is a 403 in production and a green test on both sides. The first
|
|
455
|
+
* pass of this list was written by reading `authority.ts`, and reading it missed
|
|
456
|
+
* `GET /scopes`, which is the route the Data tab and Move/Retire lean on hardest.
|
|
457
|
+
*/
|
|
458
|
+
export function tenantCredentialPin(method, path) {
|
|
459
|
+
return TENANT_ROUTES.find((r) => r.method === method && r.re.test(path))?.pin;
|
|
460
|
+
}
|
|
461
|
+
/** The `/tenants/<id>/…` segment a path names, if it names one. */
|
|
462
|
+
const pathTenant = (path) => /\/tenants\/([^/]+)(?:\/|$)/.exec(path)?.[1];
|
|
463
|
+
/**
|
|
464
|
+
* Every tenant this request names where the middleware can see it — the path
|
|
465
|
+
* segment, the tenant-ish query params, and the `x-substrat-tenant` pin header.
|
|
466
|
+
* The BODY is not swept here: it is read only for a `'body'` route, because parsing
|
|
467
|
+
* it costs a read on every request and only those routes are decided by it.
|
|
468
|
+
*/
|
|
469
|
+
const namedTenants = (c) => {
|
|
470
|
+
const named = [
|
|
471
|
+
pathTenant(c.req.path),
|
|
472
|
+
c.req.query('tenantId'),
|
|
473
|
+
c.req.query('ownerTenant'),
|
|
474
|
+
c.req.query('visibleTo'),
|
|
475
|
+
c.req.header(TENANT_HEADER)?.trim() || undefined,
|
|
476
|
+
];
|
|
477
|
+
return named.filter((v) => typeof v === 'string' && v.length > 0);
|
|
478
|
+
};
|
|
359
479
|
/**
|
|
360
480
|
* The audited HTTP surface over `HostAdmin` (control-plane.md §4.5).
|
|
361
481
|
*
|
|
@@ -384,7 +504,7 @@ const UPSTREAM_REFERENCE = /\breference\s*=\s*([a-z0-9]+)/i;
|
|
|
384
504
|
* adapter's read path, not an admin surface.
|
|
385
505
|
*/
|
|
386
506
|
export function createControlPlaneApi(options) {
|
|
387
|
-
const { host, authenticate, authenticateBuilder } = options;
|
|
507
|
+
const { host, authenticate, authenticateBuilder, authenticateTenantService } = options;
|
|
388
508
|
const admin = host.admin;
|
|
389
509
|
const app = new Hono();
|
|
390
510
|
// The CLI version advisory (#971), stamped on EVERY response — including the 401 the
|
|
@@ -401,11 +521,28 @@ export function createControlPlaneApi(options) {
|
|
|
401
521
|
c.header(CLI_LATEST_VERSION_HEADER, advisory.latestVersion);
|
|
402
522
|
});
|
|
403
523
|
}
|
|
404
|
-
// Fail closed, before any route runs: no principal, no reach.
|
|
405
|
-
//
|
|
524
|
+
// Fail closed, before any route runs: no principal, no reach.
|
|
525
|
+
//
|
|
526
|
+
// The TENANT-SCOPED credential is read FIRST (#977), ahead of staff. Not for
|
|
527
|
+
// precedence between two things that could both match — an `stt1.` value is not a
|
|
528
|
+
// platform service token and never will be — but because order is the only thing
|
|
529
|
+
// that makes "a tenant token is never staff" hold no matter how `authenticate` is
|
|
530
|
+
// configured. A deployment that pasted a tenant token into `SERVICE_TOKEN` would,
|
|
531
|
+
// with the readers the other way round, silently hand it the whole fleet.
|
|
532
|
+
//
|
|
533
|
+
// Staff/service next (unchanged, a superset); a builder session only when staff
|
|
534
|
+
// declines.
|
|
406
535
|
app.use('*', async (c, next) => {
|
|
407
|
-
|
|
408
|
-
|
|
536
|
+
let principal = null;
|
|
537
|
+
if (authenticateTenantService) {
|
|
538
|
+
const tenant = await authenticateTenantService(c.req.raw);
|
|
539
|
+
if (tenant) {
|
|
540
|
+
principal = { kind: 'tenant', actor: tenant.actor, tenantId: tenant.tenantId };
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
const staff = principal ? null : await authenticate(c.req.raw);
|
|
544
|
+
if (!principal && staff)
|
|
545
|
+
principal = { kind: 'staff', actor: staff };
|
|
409
546
|
if (!principal && authenticateBuilder) {
|
|
410
547
|
const builder = await authenticateBuilder(c.req.raw);
|
|
411
548
|
if (builder) {
|
|
@@ -518,6 +655,45 @@ export function createControlPlaneApi(options) {
|
|
|
518
655
|
}
|
|
519
656
|
await next();
|
|
520
657
|
});
|
|
658
|
+
app.use('*', async (c, next) => {
|
|
659
|
+
const p = c.get('principal');
|
|
660
|
+
if (p.kind !== 'tenant')
|
|
661
|
+
return next();
|
|
662
|
+
const pin = tenantCredentialPin(c.req.method, c.req.path);
|
|
663
|
+
if (!pin)
|
|
664
|
+
return c.json({ error: 'forbidden' }, 403);
|
|
665
|
+
// The sweep: anything naming a tenant must name THIS one. `x-substrat-tenant` is
|
|
666
|
+
// included, which is what stops the pre-#977 shape — a fleet credential plus a
|
|
667
|
+
// caller-asserted tenant header — from surviving as a way to ask for another.
|
|
668
|
+
if (namedTenants(c).some((t) => t !== p.tenantId)) {
|
|
669
|
+
return c.json({ error: 'forbidden: outside this credential’s tenant' }, 403);
|
|
670
|
+
}
|
|
671
|
+
// The route's own pin must be PRESENT, not merely consistent. A missing filter is
|
|
672
|
+
// what makes these routes answer fleet-wide, so absence is the refusal.
|
|
673
|
+
if (pin === 'path' && pathTenant(c.req.path) !== p.tenantId) {
|
|
674
|
+
return c.json({ error: 'forbidden: outside this credential’s tenant' }, 403);
|
|
675
|
+
}
|
|
676
|
+
if (pin === 'query' && c.req.query('tenantId') !== p.tenantId) {
|
|
677
|
+
return c.json({ error: 'forbidden: this credential must name its own tenant' }, 403);
|
|
678
|
+
}
|
|
679
|
+
if (pin === 'catalog') {
|
|
680
|
+
const named = c.req.query('ownerTenant') ?? c.req.query('visibleTo');
|
|
681
|
+
if (named !== p.tenantId) {
|
|
682
|
+
return c.json({ error: 'forbidden: this credential must name its own tenant' }, 403);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
if (pin === 'body') {
|
|
686
|
+
// Hono caches a parsed body, so the handler's own `c.req.json()` still works.
|
|
687
|
+
// An unparseable body names no tenant, which is a refusal here rather than the
|
|
688
|
+
// handler's 400 — a request this middleware cannot read is one it cannot clear.
|
|
689
|
+
const body = (await c.req.json().catch(() => null));
|
|
690
|
+
const named = body && typeof body === 'object' ? (body['tenantId'] ?? body['id']) : undefined;
|
|
691
|
+
if (named !== p.tenantId) {
|
|
692
|
+
return c.json({ error: 'forbidden: this credential must name its own tenant' }, 403);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
await next();
|
|
696
|
+
});
|
|
521
697
|
// Fire-and-forget ops-failure write (#559): a recorder that throws must never
|
|
522
698
|
// mask the failure it is recording, so every path through this swallows its own
|
|
523
699
|
// errors. The upstream reference is extracted here — one place — so a caller
|
|
@@ -742,6 +918,50 @@ export function createControlPlaneApi(options) {
|
|
|
742
918
|
const tenantId = raw ? tenantIdSchema.parse(raw) : undefined;
|
|
743
919
|
return c.json(await admin.readMeters(c.get('actor'), tenantId ? { tenantId } : undefined));
|
|
744
920
|
});
|
|
921
|
+
// Storage, read on demand (#1524): one tenant's scope-database sizes and their sum.
|
|
922
|
+
// Staff-only like `/meters` (on neither allowlist, so a builder or a tenant token is
|
|
923
|
+
// refused). `tenantId` is REQUIRED. A fleet-wide form would wake every DO in the fleet,
|
|
924
|
+
// the cost the decision rejected. Paged over the tenant's scopes, because every scope
|
|
925
|
+
// read is a DO wake. `readStoragePage` holds the page and concurrency bounds.
|
|
926
|
+
//
|
|
927
|
+
// A scope that names a vertical is read through that vertical's deployment, which
|
|
928
|
+
// holds its DO. When this plane delegates to verticals at all and none resolves, the
|
|
929
|
+
// read FAILS for that scope rather than falling back to the co-located host. On the
|
|
930
|
+
// hosted plane that host's namespace is a module-less placeholder, and waking it would
|
|
931
|
+
// create an empty database and report its size as the scope's.
|
|
932
|
+
const delegatesToVerticals = Boolean(options.verticals || options.resolveVertical || options.resolveVerticalVersion || options.resolveVerticalRef);
|
|
933
|
+
app.get('/meters/storage', async (c) => {
|
|
934
|
+
const q = z
|
|
935
|
+
.object({
|
|
936
|
+
tenantId: tenantIdSchema,
|
|
937
|
+
cursor: scopeIdSchema.optional(),
|
|
938
|
+
limit: z.coerce.number().int().min(1).max(STORAGE_PAGE_MAX).default(STORAGE_PAGE_DEFAULT),
|
|
939
|
+
})
|
|
940
|
+
.parse({
|
|
941
|
+
tenantId: c.req.query('tenantId'),
|
|
942
|
+
cursor: c.req.query('cursor') || undefined,
|
|
943
|
+
limit: c.req.query('limit') || undefined,
|
|
944
|
+
});
|
|
945
|
+
const actor = c.get('actor');
|
|
946
|
+
const scopes = await admin.listScopes(actor, { tenantId: q.tenantId });
|
|
947
|
+
return c.json(await readStoragePage({
|
|
948
|
+
tenantId: q.tenantId,
|
|
949
|
+
readAt: new Date().toISOString(),
|
|
950
|
+
scopes: scopes.map((s) => ({ ...s, scopeId: s.id })),
|
|
951
|
+
cursor: q.cursor,
|
|
952
|
+
limit: q.limit,
|
|
953
|
+
read: async (scope) => {
|
|
954
|
+
if (!scope.vertical)
|
|
955
|
+
return admin.scopeDatabaseSize(actor, q.tenantId, scope.id);
|
|
956
|
+
const vertical = await verticalForScope(c, scope);
|
|
957
|
+
if (vertical)
|
|
958
|
+
return vertical.databaseSize(scope.id);
|
|
959
|
+
if (delegatesToVerticals)
|
|
960
|
+
throw new Error(`no deployment resolves for vertical ${scope.vertical}`);
|
|
961
|
+
return admin.scopeDatabaseSize(actor, q.tenantId, scope.id);
|
|
962
|
+
},
|
|
963
|
+
}));
|
|
964
|
+
});
|
|
745
965
|
// -- per-tenant stores (#301, #473) ----------------------------------------
|
|
746
966
|
// The two ledgers as INVENTORY — what `listTenantStores`/`listBlobStores` were always
|
|
747
967
|
// meant to answer for a staff surface: which database and which bucket hold this
|
|
@@ -945,7 +1165,7 @@ export function createControlPlaneApi(options) {
|
|
|
945
1165
|
const filter = listScopesQuery.parse({
|
|
946
1166
|
// A builder reads only its OWN tenant's directory rows — the filter is forced,
|
|
947
1167
|
// not trusted from the query (#424 CLI parity for `substrat installs`).
|
|
948
|
-
tenantId: p
|
|
1168
|
+
tenantId: confinedTenant(p) ?? c.req.query('tenantId'),
|
|
949
1169
|
status: c.req.queries('status'),
|
|
950
1170
|
vertical: c.req.query('vertical'),
|
|
951
1171
|
});
|
|
@@ -993,7 +1213,7 @@ export function createControlPlaneApi(options) {
|
|
|
993
1213
|
const p = c.get('principal');
|
|
994
1214
|
// A builder acts only within its own tenant; a foreign tenant is hidden as 404 (K-3),
|
|
995
1215
|
// never distinguished from "no such scope".
|
|
996
|
-
if (p
|
|
1216
|
+
if (outsideTenant(p, tenantId)) {
|
|
997
1217
|
return c.json({ error: `unknown scope for tenant: (${tenantId}, ${input.parentScopeId})` }, 404);
|
|
998
1218
|
}
|
|
999
1219
|
// The provisioning sequence lives in one place — `provisionSiblingScope` — shared with the
|
|
@@ -1022,7 +1242,7 @@ export function createControlPlaneApi(options) {
|
|
|
1022
1242
|
const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
|
|
1023
1243
|
// A builder reads only its own tenant; a foreign tenant is hidden as 404 (K-3).
|
|
1024
1244
|
const p = c.get('principal');
|
|
1025
|
-
if (p
|
|
1245
|
+
if (outsideTenant(p, tenantId)) {
|
|
1026
1246
|
return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
|
|
1027
1247
|
}
|
|
1028
1248
|
const record = await admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
|
|
@@ -1058,7 +1278,18 @@ export function createControlPlaneApi(options) {
|
|
|
1058
1278
|
seal: (items) => admin.sealSubjectPayloads(c.get('actor'), tenantId, scopeId, items),
|
|
1059
1279
|
open: (items) => admin.openSubjectPayloads(c.get('actor'), tenantId, scopeId, items),
|
|
1060
1280
|
});
|
|
1061
|
-
const verticalForScope = async (c, scope) =>
|
|
1281
|
+
const verticalForScope = async (c, scope) => (await deploymentForScope(c, scope))?.client;
|
|
1282
|
+
/**
|
|
1283
|
+
* `verticalForScope`, saying WHICH rung of the ladder chose the client (#1653).
|
|
1284
|
+
*
|
|
1285
|
+
* A reconcile's receipt must name the version whose hook actually ran, and that is a
|
|
1286
|
+
* fact about the client chosen here — not about the scope's serving pointer read on the
|
|
1287
|
+
* side. When the serving ref does not resolve, the ladder falls back to the bound
|
|
1288
|
+
* version's deployment (or a slug's), and a receipt derived separately would claim the
|
|
1289
|
+
* served version's hook ran when the bound one's did. `versionReachedAt(via, …)` turns
|
|
1290
|
+
* this answer into the version, so the choice and the receipt have one source.
|
|
1291
|
+
*/
|
|
1292
|
+
const deploymentForScope = async (c, scope) => {
|
|
1062
1293
|
if (!scope.vertical)
|
|
1063
1294
|
return undefined;
|
|
1064
1295
|
const actor = c.get('actor');
|
|
@@ -1067,15 +1298,16 @@ export function createControlPlaneApi(options) {
|
|
|
1067
1298
|
if (scope.servingRef && options.resolveVerticalRef) {
|
|
1068
1299
|
const serving = await options.resolveVerticalRef(scope.servingRef);
|
|
1069
1300
|
if (serving)
|
|
1070
|
-
return serving;
|
|
1301
|
+
return { client: serving, via: 'serving-script' };
|
|
1071
1302
|
}
|
|
1072
1303
|
const bySlug = async (slug) => {
|
|
1073
1304
|
if (scope.verticalVersionId && options.resolveVerticalVersion) {
|
|
1074
1305
|
const bound = await options.resolveVerticalVersion(slug, scope.verticalVersionId, actor);
|
|
1075
1306
|
if (bound)
|
|
1076
|
-
return bound;
|
|
1307
|
+
return { client: bound, via: 'bound-version' };
|
|
1077
1308
|
}
|
|
1078
|
-
|
|
1309
|
+
const bySlugClient = options.verticals?.[slug] ?? (await options.resolveVertical?.(slug, actor));
|
|
1310
|
+
return bySlugClient ? { client: bySlugClient, via: 'slug' } : undefined;
|
|
1079
1311
|
};
|
|
1080
1312
|
const direct = await bySlug(scope.vertical);
|
|
1081
1313
|
if (direct)
|
|
@@ -1311,7 +1543,7 @@ export function createControlPlaneApi(options) {
|
|
|
1311
1543
|
const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
|
|
1312
1544
|
// A builder reads only its own tenant; a foreign tenant is hidden as 404 (K-3).
|
|
1313
1545
|
const principal = c.get('principal');
|
|
1314
|
-
if (principal
|
|
1546
|
+
if (outsideTenant(principal, tenantId)) {
|
|
1315
1547
|
return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
|
|
1316
1548
|
}
|
|
1317
1549
|
const scope = await admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
|
|
@@ -1363,7 +1595,7 @@ export function createControlPlaneApi(options) {
|
|
|
1363
1595
|
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
1364
1596
|
const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
|
|
1365
1597
|
const principal = c.get('principal');
|
|
1366
|
-
if (principal
|
|
1598
|
+
if (outsideTenant(principal, tenantId)) {
|
|
1367
1599
|
return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
|
|
1368
1600
|
}
|
|
1369
1601
|
const filter = platformRequestFilter.parse({
|
|
@@ -1396,14 +1628,16 @@ export function createControlPlaneApi(options) {
|
|
|
1396
1628
|
if (!scope)
|
|
1397
1629
|
return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
|
|
1398
1630
|
const p = c.get('principal');
|
|
1399
|
-
|
|
1631
|
+
const scopePin = confinedTenant(p);
|
|
1632
|
+
if (scopePin) {
|
|
1400
1633
|
const v = scope.vertical ? await verticalOf(actor, scope.vertical) : undefined;
|
|
1401
|
-
if (!v || v.ownerTenant !==
|
|
1634
|
+
if (!v || v.ownerTenant !== scopePin)
|
|
1402
1635
|
return c.json({ error: 'forbidden' }, 403);
|
|
1403
1636
|
}
|
|
1404
|
-
const
|
|
1405
|
-
if (!
|
|
1637
|
+
const reached = await deploymentForScope(c, scope);
|
|
1638
|
+
if (!reached)
|
|
1406
1639
|
return c.json({ error: await diagnoseUnboundScope(c.get('actor'), scope) }, 501);
|
|
1640
|
+
const vertical = reached.client;
|
|
1407
1641
|
const entitlements = await admin.listEntitlements(actor, tenantId);
|
|
1408
1642
|
// #406: re-gathered and re-delivered like entitlements, so a reconcile also repairs a
|
|
1409
1643
|
// dropped identity-link delivery — and is the channel a link/unlink after provision rides.
|
|
@@ -1502,6 +1736,17 @@ export function createControlPlaneApi(options) {
|
|
|
1502
1736
|
patchBindings: options.patchScriptBindings,
|
|
1503
1737
|
}));
|
|
1504
1738
|
}
|
|
1739
|
+
// #1653: WHICH version the hook is about to run as — the version of the deployment
|
|
1740
|
+
// `reached` IS, never one read on the side: a serving ref that did not resolve fell back
|
|
1741
|
+
// to the bound version's deployment, and it is that version's hook that runs. For the
|
|
1742
|
+
// serving script, the pointer is read BEFORE the call, as the sweep reads it, so a
|
|
1743
|
+
// promote landing mid-call cannot have its version recorded by a hook that ran as the
|
|
1744
|
+
// one before. Best-effort: without it the receipt falls back to the bound version, the
|
|
1745
|
+
// pre-#1653 answer, which costs at most one more reconcile.
|
|
1746
|
+
const serving = reached.via === 'serving-script' && scope.vertical
|
|
1747
|
+
? await admin.verticalServing(actor, scope.vertical).catch(() => null)
|
|
1748
|
+
: null;
|
|
1749
|
+
const ranAs = versionReachedAt(reached.via, scope, serving);
|
|
1505
1750
|
try {
|
|
1506
1751
|
const result = await vertical.reconcileInstance({
|
|
1507
1752
|
tenantId,
|
|
@@ -1515,15 +1760,16 @@ export function createControlPlaneApi(options) {
|
|
|
1515
1760
|
// but unmigrated database fails as loudly as an absent one.
|
|
1516
1761
|
...(tenantStores.length ? { tenantStores } : {}),
|
|
1517
1762
|
});
|
|
1518
|
-
// The reconcile succeeded; a store that did not mint rides back as a diagnosis
|
|
1519
|
-
// (`substrat scope provision` prints it, and `/health` keeps reporting the gap
|
|
1520
1763
|
// #1172: the reconcile succeeded, so record WHICH version it ran against. Without
|
|
1521
1764
|
// this the sweep would come back and do it again on the next pass — the console
|
|
1522
1765
|
// button and the automatic phase have to write the same receipt, or pressing the
|
|
1523
|
-
// button means nothing to the thing that watches.
|
|
1524
|
-
|
|
1525
|
-
|
|
1766
|
+
// button means nothing to the thing that watches. Both name it with
|
|
1767
|
+
// `versionReachedAt`, from the deployment each actually reached.
|
|
1768
|
+
if (ranAs) {
|
|
1769
|
+
await admin.markScopeProvisioned(actor, tenantId, scopeId, ranAs);
|
|
1526
1770
|
}
|
|
1771
|
+
// The reconcile succeeded; a store that did not mint rides back as a diagnosis
|
|
1772
|
+
// (`substrat scope provision` prints it, and `/health` keeps reporting the gap
|
|
1527
1773
|
// until it closes). Absent when nothing failed, so the response is unchanged for
|
|
1528
1774
|
// every scope that has no declared store or already has them all.
|
|
1529
1775
|
return c.json(storeErrors.length ? { ...result, storeError: storeErrors.join('; ') } : result);
|
|
@@ -1749,7 +1995,7 @@ export function createControlPlaneApi(options) {
|
|
|
1749
1995
|
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
1750
1996
|
const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
|
|
1751
1997
|
const principal = c.get('principal');
|
|
1752
|
-
if (principal
|
|
1998
|
+
if (outsideTenant(principal, tenantId)) {
|
|
1753
1999
|
return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
|
|
1754
2000
|
}
|
|
1755
2001
|
const filter = denialLogQuery.parse(c.req.query());
|
|
@@ -1765,7 +2011,7 @@ export function createControlPlaneApi(options) {
|
|
|
1765
2011
|
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
1766
2012
|
const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
|
|
1767
2013
|
const principal = c.get('principal');
|
|
1768
|
-
if (principal
|
|
2014
|
+
if (outsideTenant(principal, tenantId)) {
|
|
1769
2015
|
return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
|
|
1770
2016
|
}
|
|
1771
2017
|
const filter = denialLogQuery.parse(c.req.query());
|
|
@@ -1788,7 +2034,7 @@ export function createControlPlaneApi(options) {
|
|
|
1788
2034
|
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
1789
2035
|
const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
|
|
1790
2036
|
const principal = c.get('principal');
|
|
1791
|
-
if (principal
|
|
2037
|
+
if (outsideTenant(principal, tenantId)) {
|
|
1792
2038
|
return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
|
|
1793
2039
|
}
|
|
1794
2040
|
const scope = await admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
|
|
@@ -1810,7 +2056,7 @@ export function createControlPlaneApi(options) {
|
|
|
1810
2056
|
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
1811
2057
|
const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
|
|
1812
2058
|
const principal = c.get('principal');
|
|
1813
|
-
if (principal
|
|
2059
|
+
if (outsideTenant(principal, tenantId)) {
|
|
1814
2060
|
return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
|
|
1815
2061
|
}
|
|
1816
2062
|
const actor = c.get('actor');
|
|
@@ -2666,9 +2912,10 @@ export function createControlPlaneApi(options) {
|
|
|
2666
2912
|
const p = c.get('principal');
|
|
2667
2913
|
const slug = await resolveVerticalId(c, c.req.param('slug'));
|
|
2668
2914
|
const actor = c.get('actor');
|
|
2669
|
-
|
|
2915
|
+
const adoptPin = confinedTenant(p);
|
|
2916
|
+
if (adoptPin) {
|
|
2670
2917
|
const v = await verticalOf(actor, slug);
|
|
2671
|
-
if (!v || v.ownerTenant !==
|
|
2918
|
+
if (!v || v.ownerTenant !== adoptPin)
|
|
2672
2919
|
return c.json({ error: 'forbidden' }, 403);
|
|
2673
2920
|
}
|
|
2674
2921
|
const v = await verticalOf(actor, slug);
|
|
@@ -2902,6 +3149,19 @@ export function createControlPlaneApi(options) {
|
|
|
2902
3149
|
// vertical is PRIVATE (owned + not listed), which is what scopes a builder's prod
|
|
2903
3150
|
// self-serve below.
|
|
2904
3151
|
const verticalOf = async (actor, slug) => (await admin.listVerticals(actor)).find((x) => x.slug === slug);
|
|
3152
|
+
/**
|
|
3153
|
+
* "This principal is confined, and this vertical is not its tenant's." The one
|
|
3154
|
+
* predicate every slug-addressed route asks.
|
|
3155
|
+
*
|
|
3156
|
+
* It is `confinedTenant`, not `kind === 'builder'`, because the question is about
|
|
3157
|
+
* confinement and never about which KIND of credential is confined — the dashboard's
|
|
3158
|
+
* tenant token (#977) is confined too, and a route asking the narrower question
|
|
3159
|
+
* would hand it the fleet. Staff are unconfined and always pass.
|
|
3160
|
+
*/
|
|
3161
|
+
const notOwned = async (p, slug) => {
|
|
3162
|
+
const pin = confinedTenant(p);
|
|
3163
|
+
return pin !== null && (await ownerOf(p.actor, slug)) !== pin;
|
|
3164
|
+
};
|
|
2905
3165
|
// The vertical id a request actually addresses. For a BUILDER it is `<tenantSlug>/<name>`
|
|
2906
3166
|
// (builder-plane.md §5): they send a bare `--slug`, the control plane forms the prefix
|
|
2907
3167
|
// from their authenticated tenant — so two builders can each own a `helpdesk` with no
|
|
@@ -2970,33 +3230,22 @@ export function createControlPlaneApi(options) {
|
|
|
2970
3230
|
// release-health and issues views) whether or not a telemetry backend is
|
|
2971
3231
|
// configured. The dashboard's ownedServiceRefs() fetches this instead of
|
|
2972
3232
|
// re-deriving it, so every consumer means the same thing by `version`.
|
|
2973
|
-
|
|
2974
|
-
|
|
2975
|
-
|
|
2976
|
-
|
|
2977
|
-
|
|
2978
|
-
|
|
2979
|
-
|
|
2980
|
-
|
|
2981
|
-
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
const
|
|
2985
|
-
const
|
|
2986
|
-
|
|
2987
|
-
if (query.vertical !== undefined) {
|
|
2988
|
-
const slug = await resolveVerticalId(c, query.vertical);
|
|
2989
|
-
considered = all.filter((v) => v.slug === slug);
|
|
2990
|
-
// Non-ownership reads as absence (K-3), exactly like the per-slug registry reads.
|
|
2991
|
-
if (considered.length === 0)
|
|
2992
|
-
throw new ControlPlaneError(404, 'not found');
|
|
2993
|
-
}
|
|
2994
|
-
// Cap the per-vertical fan-out and REPORT the cut — truncating silently is the
|
|
2995
|
-
// defect the egress route names (#859), and the same posture holds here.
|
|
2996
|
-
const MAX_VERTICALS = 50;
|
|
2997
|
-
const verticalsTruncated = considered.length > MAX_VERTICALS;
|
|
3233
|
+
// Cap the per-vertical fan-out and REPORT the cut — truncating silently is the
|
|
3234
|
+
// defect the egress route names (#859), and the same posture holds here.
|
|
3235
|
+
const MAX_SERVICE_REF_VERTICALS = 50;
|
|
3236
|
+
/**
|
|
3237
|
+
* Every Cloudflare service ref a tenant's own verticals deploy, mapped to the
|
|
3238
|
+
* signals dimensions it stamps. The ONE implementation of "what a service ref
|
|
3239
|
+
* means", and now also of "which service refs are this tenant's" — the two are the
|
|
3240
|
+
* same enumeration, and #977's observability narrowing must not be a second copy of
|
|
3241
|
+
* it that can drift when a third ref scheme appears.
|
|
3242
|
+
*/
|
|
3243
|
+
const tenantServiceRefs = async (actor, tenantId, slug) => {
|
|
3244
|
+
const all = (await admin.listVerticals(actor)).filter((v) => v.ownerTenant === tenantId);
|
|
3245
|
+
const considered = slug === undefined ? all : all.filter((v) => v.slug === slug);
|
|
3246
|
+
const verticalsTruncated = considered.length > MAX_SERVICE_REF_VERTICALS;
|
|
2998
3247
|
const entries = [];
|
|
2999
|
-
for (const v of considered.slice(0,
|
|
3248
|
+
for (const v of considered.slice(0, MAX_SERVICE_REF_VERTICALS)) {
|
|
3000
3249
|
const versions = await admin.listVersions(actor, v.slug);
|
|
3001
3250
|
const labelOf = new Map(versions.map((ver) => [ver.id, ver.version]));
|
|
3002
3251
|
// The stable serving script — where real traffic lands. Its version is the
|
|
@@ -3022,6 +3271,44 @@ export function createControlPlaneApi(options) {
|
|
|
3022
3271
|
}));
|
|
3023
3272
|
}
|
|
3024
3273
|
}
|
|
3274
|
+
return { entries, verticalsTruncated, matched: considered.length };
|
|
3275
|
+
};
|
|
3276
|
+
/**
|
|
3277
|
+
* The service names a CONFINED principal may see telemetry for — its tenant's own,
|
|
3278
|
+
* and nothing else (#977).
|
|
3279
|
+
*
|
|
3280
|
+
* `null` means unconfined (staff): no narrowing, the fleet view the console reads.
|
|
3281
|
+
* A truncated enumeration throws rather than narrowing to a partial set: a chart
|
|
3282
|
+
* missing a vertical reads as "that vertical had no traffic", and inventing silence
|
|
3283
|
+
* is the one answer an observability route must never give.
|
|
3284
|
+
*/
|
|
3285
|
+
const ownedServices = async (p, actor) => {
|
|
3286
|
+
const pin = confinedTenant(p);
|
|
3287
|
+
if (pin === null)
|
|
3288
|
+
return null;
|
|
3289
|
+
const refs = await tenantServiceRefs(actor, pin);
|
|
3290
|
+
if (refs.verticalsTruncated) {
|
|
3291
|
+
throw new ControlPlaneError(503, 'service map truncated: too many verticals to resolve in one answer');
|
|
3292
|
+
}
|
|
3293
|
+
return new Set(refs.entries.map((e) => e.service));
|
|
3294
|
+
};
|
|
3295
|
+
app.get('/service-refs', async (c) => {
|
|
3296
|
+
const p = c.get('principal');
|
|
3297
|
+
// The /ops-failures forced-filter pattern: a confined principal's tenant comes from
|
|
3298
|
+
// the principal, never the query. A staff/service caller must SAY whose view it
|
|
3299
|
+
// wants — answering fleet-wide on a forgotten param would leak, so refuse.
|
|
3300
|
+
const tenantId = confinedTenant(p) ?? c.req.query('tenantId');
|
|
3301
|
+
if (!tenantId)
|
|
3302
|
+
throw new ControlPlaneError(400, 'tenantId is required');
|
|
3303
|
+
const query = z
|
|
3304
|
+
.object({ tenantId: tenantIdSchema, vertical: z.string().optional() })
|
|
3305
|
+
.parse({ tenantId, vertical: c.req.query('vertical') });
|
|
3306
|
+
const actor = c.get('actor');
|
|
3307
|
+
const slug = query.vertical === undefined ? undefined : await resolveVerticalId(c, query.vertical);
|
|
3308
|
+
const { entries, verticalsTruncated, matched } = await tenantServiceRefs(actor, query.tenantId, slug);
|
|
3309
|
+
// Non-ownership reads as absence (K-3), exactly like the per-slug registry reads.
|
|
3310
|
+
if (slug !== undefined && matched === 0)
|
|
3311
|
+
throw new ControlPlaneError(404, 'not found');
|
|
3025
3312
|
return c.json({ entries, verticalsTruncated });
|
|
3026
3313
|
});
|
|
3027
3314
|
app.post('/verticals', async (c) => {
|
|
@@ -3049,7 +3336,7 @@ export function createControlPlaneApi(options) {
|
|
|
3049
3336
|
const slug = await resolveVerticalId(c, c.req.param('slug'));
|
|
3050
3337
|
// A builder reading a vertical it does not own gets 404 — indistinguishable from
|
|
3051
3338
|
// absent, the same fail-closed reflex K-3 uses for a cross-tenant scope.
|
|
3052
|
-
if (
|
|
3339
|
+
if (await notOwned(p, slug)) {
|
|
3053
3340
|
return c.json({ error: 'not found' }, 404);
|
|
3054
3341
|
}
|
|
3055
3342
|
const page = pageParams(c);
|
|
@@ -3066,7 +3353,7 @@ export function createControlPlaneApi(options) {
|
|
|
3066
3353
|
}
|
|
3067
3354
|
const p = c.get('principal');
|
|
3068
3355
|
const slug = await resolveVerticalId(c, input.verticalSlug);
|
|
3069
|
-
if (
|
|
3356
|
+
if (await notOwned(p, slug)) {
|
|
3070
3357
|
return c.json({ error: 'forbidden' }, 403);
|
|
3071
3358
|
}
|
|
3072
3359
|
input.verticalSlug = slug;
|
|
@@ -3083,7 +3370,7 @@ export function createControlPlaneApi(options) {
|
|
|
3083
3370
|
app.get('/verticals/:slug/versions/:id/registry', async (c) => {
|
|
3084
3371
|
const p = c.get('principal');
|
|
3085
3372
|
const slug = await resolveVerticalId(c, c.req.param('slug'));
|
|
3086
|
-
if (
|
|
3373
|
+
if (await notOwned(p, slug)) {
|
|
3087
3374
|
return c.json({ error: 'not found' }, 404);
|
|
3088
3375
|
}
|
|
3089
3376
|
const json = await admin.versionManifest(c.get('actor'), slug, c.req.param('id'));
|
|
@@ -3100,7 +3387,7 @@ export function createControlPlaneApi(options) {
|
|
|
3100
3387
|
app.get('/verticals/:slug/versions/:id/schedules', async (c) => {
|
|
3101
3388
|
const p = c.get('principal');
|
|
3102
3389
|
const slug = await resolveVerticalId(c, c.req.param('slug'));
|
|
3103
|
-
if (
|
|
3390
|
+
if (await notOwned(p, slug)) {
|
|
3104
3391
|
return c.json({ error: 'not found' }, 404);
|
|
3105
3392
|
}
|
|
3106
3393
|
const json = await admin.versionManifest(c.get('actor'), slug, c.req.param('id'));
|
|
@@ -3123,7 +3410,7 @@ export function createControlPlaneApi(options) {
|
|
|
3123
3410
|
app.get('/verticals/:slug/versions/:id/flow', async (c) => {
|
|
3124
3411
|
const p = c.get('principal');
|
|
3125
3412
|
const slug = await resolveVerticalId(c, c.req.param('slug'));
|
|
3126
|
-
if (
|
|
3413
|
+
if (await notOwned(p, slug)) {
|
|
3127
3414
|
return c.json({ error: 'not found' }, 404);
|
|
3128
3415
|
}
|
|
3129
3416
|
const json = await admin.versionManifest(c.get('actor'), slug, c.req.param('id'));
|
|
@@ -3145,7 +3432,7 @@ export function createControlPlaneApi(options) {
|
|
|
3145
3432
|
app.get('/verticals/:slug/versions/:id/model', async (c) => {
|
|
3146
3433
|
const p = c.get('principal');
|
|
3147
3434
|
const slug = await resolveVerticalId(c, c.req.param('slug'));
|
|
3148
|
-
if (
|
|
3435
|
+
if (await notOwned(p, slug)) {
|
|
3149
3436
|
return c.json({ error: 'not found' }, 404);
|
|
3150
3437
|
}
|
|
3151
3438
|
const json = await admin.versionManifest(c.get('actor'), slug, c.req.param('id'));
|
|
@@ -3164,7 +3451,7 @@ export function createControlPlaneApi(options) {
|
|
|
3164
3451
|
app.get('/verticals/:slug/versions/:id/assets', async (c) => {
|
|
3165
3452
|
const p = c.get('principal');
|
|
3166
3453
|
const slug = await resolveVerticalId(c, c.req.param('slug'));
|
|
3167
|
-
if (
|
|
3454
|
+
if (await notOwned(p, slug)) {
|
|
3168
3455
|
return c.json({ error: 'not found' }, 404);
|
|
3169
3456
|
}
|
|
3170
3457
|
const json = await admin.versionManifest(c.get('actor'), slug, c.req.param('id'));
|
|
@@ -3189,7 +3476,7 @@ export function createControlPlaneApi(options) {
|
|
|
3189
3476
|
app.post('/verticals/:slug/publish-request', async (c) => {
|
|
3190
3477
|
const p = c.get('principal');
|
|
3191
3478
|
const slug = await resolveVerticalId(c, c.req.param('slug'));
|
|
3192
|
-
if (
|
|
3479
|
+
if (await notOwned(p, slug)) {
|
|
3193
3480
|
return c.json({ error: 'not found' }, 404);
|
|
3194
3481
|
}
|
|
3195
3482
|
await admin.requestPublish(c.get('actor'), slug);
|
|
@@ -3233,18 +3520,27 @@ export function createControlPlaneApi(options) {
|
|
|
3233
3520
|
await admin.setVerticalEmailSender(c.get('actor'), slug, granted);
|
|
3234
3521
|
return c.json({ slug, emailSender: granted });
|
|
3235
3522
|
});
|
|
3236
|
-
// Delete a vertical + its versions and channels
|
|
3237
|
-
//
|
|
3238
|
-
//
|
|
3523
|
+
// Delete a vertical + its versions and channels. Refused below the seam while any
|
|
3524
|
+
// scope is still bound — surfaces as a 4xx via mapError, naming the count. Dispatch
|
|
3525
|
+
// scripts become orphans for cleanup (#248).
|
|
3526
|
+
//
|
|
3527
|
+
// A CONFINED principal deletes only what its tenant owns (#977). This route had no
|
|
3528
|
+
// ownership check at all, because it was staff-only and the dashboard — which calls
|
|
3529
|
+
// it — checked ownership in its own worker before asking. That check was the whole
|
|
3530
|
+
// guard on a destructive registry write, and it lived in the caller.
|
|
3239
3531
|
app.delete('/verticals/:slug', async (c) => {
|
|
3240
3532
|
const slug = c.req.param('slug');
|
|
3533
|
+
// 404, not 403: a slug this tenant does not own reads as absent, the same
|
|
3534
|
+
// existence-hiding reflex the registry's other per-slug reads use (K-3).
|
|
3535
|
+
if (await notOwned(c.get('principal'), slug))
|
|
3536
|
+
return c.json({ error: 'not found' }, 404);
|
|
3241
3537
|
await admin.deleteVertical(c.get('actor'), slug);
|
|
3242
3538
|
return c.json({ slug, deleted: true });
|
|
3243
3539
|
});
|
|
3244
3540
|
app.get('/verticals/:slug/channels', async (c) => {
|
|
3245
3541
|
const p = c.get('principal');
|
|
3246
3542
|
const slug = await resolveVerticalId(c, c.req.param('slug'));
|
|
3247
|
-
if (
|
|
3543
|
+
if (await notOwned(p, slug)) {
|
|
3248
3544
|
return c.json({ error: 'not found' }, 404);
|
|
3249
3545
|
}
|
|
3250
3546
|
const page = pageParams(c);
|
|
@@ -3262,7 +3558,7 @@ export function createControlPlaneApi(options) {
|
|
|
3262
3558
|
return c.json({ error: `channel '${rawChannel}' is retired — only 'prod' has history` }, 400);
|
|
3263
3559
|
}
|
|
3264
3560
|
const channel = channelName.parse(rawChannel);
|
|
3265
|
-
if (
|
|
3561
|
+
if (await notOwned(p, slug)) {
|
|
3266
3562
|
return c.json({ error: 'not found' }, 404);
|
|
3267
3563
|
}
|
|
3268
3564
|
const page = pageParams(c);
|
|
@@ -3432,17 +3728,25 @@ export function createControlPlaneApi(options) {
|
|
|
3432
3728
|
}, 400);
|
|
3433
3729
|
}
|
|
3434
3730
|
const channel = channelName.parse(rawChannel);
|
|
3435
|
-
|
|
3436
|
-
|
|
3437
|
-
//
|
|
3438
|
-
// tenant itself, and dev/staging already run the same bundle in the same
|
|
3439
|
-
// sandbox, so a staff prod gate there protected nothing; it returns the moment
|
|
3440
|
-
// the audience widens (publish flips `listed`, and prod becomes staff-only
|
|
3441
|
-
// again — the trust boundary marketplace-publish.md §2 draws).
|
|
3731
|
+
const promotePin = confinedTenant(p);
|
|
3732
|
+
if (promotePin) {
|
|
3733
|
+
// A confined principal promotes only verticals its tenant owns.
|
|
3442
3734
|
const v = await verticalOf(p.actor, slug);
|
|
3443
|
-
if (!v || v.ownerTenant !==
|
|
3735
|
+
if (!v || v.ownerTenant !== promotePin)
|
|
3444
3736
|
return c.json({ error: 'forbidden' }, 403);
|
|
3445
|
-
|
|
3737
|
+
// …and a BUILDER promotes prod only while the vertical is PRIVATE (not listed).
|
|
3738
|
+
// A private vertical's blast radius is the owning tenant itself, and dev/staging
|
|
3739
|
+
// already run the same bundle in the same sandbox, so a staff prod gate there
|
|
3740
|
+
// protected nothing; it returns the moment the audience widens (publish flips
|
|
3741
|
+
// `listed`, and prod becomes staff-only again — the trust boundary
|
|
3742
|
+
// marketplace-publish.md §2 draws).
|
|
3743
|
+
//
|
|
3744
|
+
// Deliberately NOT widened to #977's tenant credential: promoting a vertical the
|
|
3745
|
+
// tenant has published is something the dashboard could already do over the
|
|
3746
|
+
// service token, and this issue narrows WHOSE data a credential reaches, not what
|
|
3747
|
+
// a tenant may do inside its own. Taking the gate away from them here would be a
|
|
3748
|
+
// capability change hidden in a security fix.
|
|
3749
|
+
if (p.kind === 'builder' && channel === 'prod' && v.listed) {
|
|
3446
3750
|
return c.json({ error: 'promotion to prod is staff-only for a listed vertical' }, 403);
|
|
3447
3751
|
}
|
|
3448
3752
|
}
|
|
@@ -3491,7 +3795,8 @@ export function createControlPlaneApi(options) {
|
|
|
3491
3795
|
// installed their vertical. The rest is a count, which tells them the fleet was covered
|
|
3492
3796
|
// without naming who is in it.
|
|
3493
3797
|
const p2 = c.get('principal');
|
|
3494
|
-
const
|
|
3798
|
+
const reportPin = confinedTenant(p2);
|
|
3799
|
+
const visible = reportPin ? backfill.minted.filter((m) => m.tenantId === reportPin) : backfill.minted;
|
|
3495
3800
|
const otherTenants = new Set(backfill.minted.filter((m) => !visible.includes(m)).map((m) => m.tenantId)).size;
|
|
3496
3801
|
return c.json({
|
|
3497
3802
|
...promoted,
|
|
@@ -3837,10 +4142,18 @@ export function createControlPlaneApi(options) {
|
|
|
3837
4142
|
return c.json(namespacesForScript(await options.doNamespaces.list(), script));
|
|
3838
4143
|
});
|
|
3839
4144
|
// -- observability (design/observability.md §4.1) --------------------------
|
|
3840
|
-
// Proxied Cloudflare-native reads
|
|
3841
|
-
//
|
|
3842
|
-
// the
|
|
4145
|
+
// Proxied Cloudflare-native reads at SCRIPT grain: one deployed unit, which serves
|
|
4146
|
+
// every tenant that installed the vertical. Not in BUILDER_ROUTES — a builder reads
|
|
4147
|
+
// the tenant grain below. Tier-3 numbers (master-plan §5.3): sampled, approximate,
|
|
3843
4148
|
// never money.
|
|
4149
|
+
//
|
|
4150
|
+
// For a CONFINED principal (#977) the answer is narrowed to the services its own
|
|
4151
|
+
// tenant's verticals deploy, HERE. These three were the sharpest edge of this issue:
|
|
4152
|
+
// they are fleet reads by construction, `/observability/metrics` takes no filter at
|
|
4153
|
+
// all, and the dashboard read them over the staff token and dropped other tenants'
|
|
4154
|
+
// rows in its own worker. So every one of the fleet's numbers crossed the seam and
|
|
4155
|
+
// the narrowing was a `.filter()` in the caller — the exact shape the issue is about,
|
|
4156
|
+
// on the route where a mistake is least visible.
|
|
3844
4157
|
app.get('/observability/metrics', async (c) => {
|
|
3845
4158
|
if (!options.observability) {
|
|
3846
4159
|
return c.json({ error: 'observability is not configured on this control plane' }, 501);
|
|
@@ -3848,7 +4161,12 @@ export function createControlPlaneApi(options) {
|
|
|
3848
4161
|
const { hours } = z
|
|
3849
4162
|
.object({ hours: z.coerce.number().int().min(1).max(72).default(24) })
|
|
3850
4163
|
.parse({ hours: c.req.query('hours') });
|
|
3851
|
-
|
|
4164
|
+
const owned = await ownedServices(c.get('principal'), c.get('actor'));
|
|
4165
|
+
const rows = await options.observability.serviceMetrics({ hours });
|
|
4166
|
+
// The route takes no service filter, so the narrowing is on the way OUT. It is the
|
|
4167
|
+
// last step before the response either way: a row this tenant does not own must not
|
|
4168
|
+
// leave the plane, whatever the backend was asked.
|
|
4169
|
+
return c.json(owned ? rows.filter((r) => owned.has(r.service)) : rows);
|
|
3852
4170
|
});
|
|
3853
4171
|
// The bucketed twin (#1236): the series a chart plots, with the deploy markers
|
|
3854
4172
|
// drawn on top by the caller (push instants are registry facts, not telemetry).
|
|
@@ -3863,7 +4181,19 @@ export function createControlPlaneApi(options) {
|
|
|
3863
4181
|
// Repeats like /observability/logs' `service`: a caller narrows to the handful
|
|
3864
4182
|
// it will plot, which is what keeps scripts × buckets bounded.
|
|
3865
4183
|
const services = c.req.queries('service') ?? undefined;
|
|
3866
|
-
|
|
4184
|
+
const owned = await ownedServices(c.get('principal'), c.get('actor'));
|
|
4185
|
+
if (!owned)
|
|
4186
|
+
return c.json(await options.observability.serviceMetricsSeries({ hours, services }));
|
|
4187
|
+
// A confined principal plots its own services: what it asked for, intersected —
|
|
4188
|
+
// and its whole set when it asked for none, since an unfiltered ask here means
|
|
4189
|
+
// "everything I may see", never "everything there is". Asking for nothing but
|
|
4190
|
+
// foreign refs is an empty series, indistinguishable from services with no
|
|
4191
|
+
// traffic (existence hiding, K-3).
|
|
4192
|
+
const narrowed = (services ?? [...owned]).filter((s) => owned.has(s));
|
|
4193
|
+
if (narrowed.length === 0)
|
|
4194
|
+
return c.json([]);
|
|
4195
|
+
const rows = await options.observability.serviceMetricsSeries({ hours, services: narrowed });
|
|
4196
|
+
return c.json(rows.filter((r) => owned.has(r.service)));
|
|
3867
4197
|
});
|
|
3868
4198
|
app.get('/observability/logs', async (c) => {
|
|
3869
4199
|
if (!options.observability) {
|
|
@@ -3872,7 +4202,14 @@ export function createControlPlaneApi(options) {
|
|
|
3872
4202
|
// `service` repeats: one deployed unit per param, so a caller can ask for a
|
|
3873
4203
|
// vertical's whole set (the dashboard's "all versions") in one query and get one
|
|
3874
4204
|
// merged stream back. Capped because each extra service is another backend query.
|
|
3875
|
-
const
|
|
4205
|
+
const asked = (c.req.queries('service') ?? []).filter((s) => s.length > 0);
|
|
4206
|
+
const ownedLogServices = await ownedServices(c.get('principal'), c.get('actor'));
|
|
4207
|
+
// A confined principal reads only its own services' lines. An ask that names none
|
|
4208
|
+
// of them answers `[]` rather than the fleet's stream — and an ask naming NOTHING
|
|
4209
|
+
// is one such ask, because for this caller "no filter" cannot mean "everything".
|
|
4210
|
+
const services = ownedLogServices ? asked.filter((s) => ownedLogServices.has(s)) : asked;
|
|
4211
|
+
if (ownedLogServices && services.length === 0)
|
|
4212
|
+
return c.json([]);
|
|
3876
4213
|
const input = z
|
|
3877
4214
|
.object({
|
|
3878
4215
|
services: z.array(z.string().min(1).max(200)).max(20).optional(),
|
|
@@ -3909,7 +4246,7 @@ export function createControlPlaneApi(options) {
|
|
|
3909
4246
|
return c.json({ error: 'tenant-grain observability is not configured on this control plane' }, 501);
|
|
3910
4247
|
}
|
|
3911
4248
|
const p = c.get('principal');
|
|
3912
|
-
const tenantId = p
|
|
4249
|
+
const tenantId = confinedTenant(p) ?? c.req.query('tenantId');
|
|
3913
4250
|
if (!tenantId)
|
|
3914
4251
|
throw new ControlPlaneError(400, 'tenantId is required');
|
|
3915
4252
|
const input = z
|
|
@@ -3940,7 +4277,7 @@ export function createControlPlaneApi(options) {
|
|
|
3940
4277
|
return c.json({ error: 'bucketed tenant-grain observability is not configured on this control plane' }, 501);
|
|
3941
4278
|
}
|
|
3942
4279
|
const p = c.get('principal');
|
|
3943
|
-
const tenantId = p
|
|
4280
|
+
const tenantId = confinedTenant(p) ?? c.req.query('tenantId');
|
|
3944
4281
|
if (!tenantId)
|
|
3945
4282
|
throw new ControlPlaneError(400, 'tenantId is required');
|
|
3946
4283
|
const scopeIds = (c.req.queries('scopeId') ?? []).filter((s) => s.length > 0);
|
|
@@ -3960,7 +4297,7 @@ export function createControlPlaneApi(options) {
|
|
|
3960
4297
|
return c.json({ error: 'tenant-grain observability is not configured on this control plane' }, 501);
|
|
3961
4298
|
}
|
|
3962
4299
|
const p = c.get('principal');
|
|
3963
|
-
const tenantId = p
|
|
4300
|
+
const tenantId = confinedTenant(p) ?? c.req.query('tenantId');
|
|
3964
4301
|
if (!tenantId)
|
|
3965
4302
|
throw new ControlPlaneError(400, 'tenantId is required');
|
|
3966
4303
|
const input = z
|
|
@@ -3970,6 +4307,15 @@ export function createControlPlaneApi(options) {
|
|
|
3970
4307
|
vertical: z.string().min(1).max(200).optional(),
|
|
3971
4308
|
level: z.enum(['log', 'info', 'warn', 'error', 'debug']).optional(),
|
|
3972
4309
|
search: z.string().min(1).max(200).optional(),
|
|
4310
|
+
// One call's lines (#1525): the id `invocationLog` stamps on its line and the
|
|
4311
|
+
// events carry. The shape is `ulid()`'s — the only thing that mints it — so
|
|
4312
|
+
// anything else is refused as a 400 rather than sent to the backend as a filter
|
|
4313
|
+
// that can only match nothing, which reads as "no logs for that call".
|
|
4314
|
+
// Tighter than the platform's id schemas (`ids.ts` accepts any Crockford
|
|
4315
|
+
// character first): a 48-bit timestamp encodes to 10 characters whose first is
|
|
4316
|
+
// 0–7, so `Z000…` is not a ULID however it looks. The contracts package exports
|
|
4317
|
+
// no generic ULID schema to reuse — each id brands its own private copy.
|
|
4318
|
+
invocationId: z.string().regex(/^[0-7][0-9A-HJKMNP-TV-Z]{25}$/, 'invocationId must be a ULID').optional(),
|
|
3973
4319
|
hours: z.coerce.number().int().min(1).max(72).default(24),
|
|
3974
4320
|
// The chart's time cursor (#1447): a window that ENDS in the past, which `hours`
|
|
3975
4321
|
// cannot spell — it always ends at now.
|
|
@@ -4007,6 +4353,9 @@ export function createControlPlaneApi(options) {
|
|
|
4007
4353
|
vertical: c.req.query('vertical') || undefined,
|
|
4008
4354
|
level: c.req.query('level') || undefined,
|
|
4009
4355
|
search: c.req.query('search') || undefined,
|
|
4356
|
+
// Raw, not `|| undefined`: an EMPTY id is a caller bug, and folding it into
|
|
4357
|
+
// "absent" would answer it with the tenant's whole log.
|
|
4358
|
+
invocationId: c.req.query('invocationId'),
|
|
4010
4359
|
hours: c.req.query('hours'),
|
|
4011
4360
|
since: c.req.query('since') || undefined,
|
|
4012
4361
|
until: c.req.query('until') || undefined,
|
|
@@ -4126,6 +4475,25 @@ export function createControlPlaneApi(options) {
|
|
|
4126
4475
|
});
|
|
4127
4476
|
return c.json({ token, tenantSlug: tenant.slug }, 201);
|
|
4128
4477
|
});
|
|
4478
|
+
// -- tenant tokens (tenant-token.ts, #977) ---------------------------------
|
|
4479
|
+
// Mint the dashboard's tenant-scoped service credential. STAFF-ONLY, and this is
|
|
4480
|
+
// the load-bearing part of the whole design: it is on NEITHER allowlist, so a
|
|
4481
|
+
// tenant token can never mint another — not for a different tenant, not even for
|
|
4482
|
+
// its own. The power to mint one for any tenant stays with the platform service
|
|
4483
|
+
// credential, which is what a multi-tenant dashboard must hold and which is now the
|
|
4484
|
+
// ONLY fleet-wide thing it does. Everything else it does is spent per tenant.
|
|
4485
|
+
app.post('/tenant-tokens', async (c) => {
|
|
4486
|
+
if (!options.tenantTokenSecret) {
|
|
4487
|
+
return c.json({ error: 'tenant tokens are not configured on this control plane' }, 501);
|
|
4488
|
+
}
|
|
4489
|
+
const { tenantId } = z.object({ tenantId: tenantIdSchema }).parse(await c.req.json());
|
|
4490
|
+
// Unlike the push-token mint, this does NOT require the tenant to exist: a token
|
|
4491
|
+
// for a tenant with no directory row reaches nothing except creating that row
|
|
4492
|
+
// (`POST /tenants`, body-pinned to the same id), which is the sign-up bootstrap.
|
|
4493
|
+
// See the mint's docblock in tenant-token.ts.
|
|
4494
|
+
const token = await mintTenantToken(options.tenantTokenSecret, { tenantId });
|
|
4495
|
+
return c.json({ token }, 201);
|
|
4496
|
+
});
|
|
4129
4497
|
// -- the hostname map (§4.7, K-26) -----------------------------------------
|
|
4130
4498
|
// Staff actions, PLUS a tenant-narrowed builder view (multi-surface exposure —
|
|
4131
4499
|
// binding an EKA-style second surface is self-serve for the scope's own tenant).
|
|
@@ -4139,7 +4507,8 @@ export function createControlPlaneApi(options) {
|
|
|
4139
4507
|
// (which would confirm the name is taken by someone).
|
|
4140
4508
|
const tenantHostname = async (c, name) => {
|
|
4141
4509
|
const p = c.get('principal');
|
|
4142
|
-
const
|
|
4510
|
+
const pin = confinedTenant(p);
|
|
4511
|
+
const filter = pin ? { tenantId: pin } : {};
|
|
4143
4512
|
return (await admin.listHostnames(c.get('actor'), filter)).find((h) => h.hostname === name.toLowerCase());
|
|
4144
4513
|
};
|
|
4145
4514
|
app.get('/hostnames', async (c) => {
|
|
@@ -4148,10 +4517,14 @@ export function createControlPlaneApi(options) {
|
|
|
4148
4517
|
tenantId: c.req.query('tenantId'),
|
|
4149
4518
|
scopeId: c.req.query('scopeId'),
|
|
4150
4519
|
});
|
|
4151
|
-
// A
|
|
4152
|
-
// (scopeId) but never widen; a foreign tenantId in the query loses
|
|
4153
|
-
|
|
4154
|
-
|
|
4520
|
+
// A CONFINED principal's list is ALWAYS its own tenant's — the query may narrow
|
|
4521
|
+
// further (scopeId) but never widen; a foreign tenantId in the query loses
|
|
4522
|
+
// silently. `confinedTenant`, not a kind check: the dashboard's tenant token is
|
|
4523
|
+
// confined too (#977), and reading this as "builders only" is what left it
|
|
4524
|
+
// paging the whole fleet's bindings.
|
|
4525
|
+
const hostnamePin = confinedTenant(p);
|
|
4526
|
+
if (hostnamePin)
|
|
4527
|
+
filter.tenantId = hostnamePin;
|
|
4155
4528
|
const page = pageParams(c);
|
|
4156
4529
|
const entries = await admin.listHostnames(c.get('actor'), { ...filter, ...page });
|
|
4157
4530
|
return c.json(pageOf(entries, page.limit, (h) => h.hostname));
|
|
@@ -4223,12 +4596,19 @@ export function createControlPlaneApi(options) {
|
|
|
4223
4596
|
app.post('/hostnames', async (c) => {
|
|
4224
4597
|
const p = c.get('principal');
|
|
4225
4598
|
const input = bindHostnameBody.parse(await c.req.json());
|
|
4226
|
-
|
|
4227
|
-
|
|
4228
|
-
//
|
|
4229
|
-
|
|
4599
|
+
const bindPin = confinedTenant(p);
|
|
4600
|
+
if (bindPin) {
|
|
4601
|
+
// The body names the tenant the binding lands under; a confined principal may
|
|
4602
|
+
// only name its own (the adapter then verifies the scope belongs to it, K-3).
|
|
4603
|
+
if (input.tenantId !== bindPin)
|
|
4230
4604
|
return c.json({ error: 'forbidden' }, 403);
|
|
4605
|
+
}
|
|
4606
|
+
if (p.kind === 'builder') {
|
|
4231
4607
|
// The region column is an EU-residency claim (K-30) — never builder-suppliable.
|
|
4608
|
+
// Left as a BUILDER rule rather than a confinement one: the dashboard binds the
|
|
4609
|
+
// platform's own hostnames for a tenant and has always been able to say where
|
|
4610
|
+
// the binding sits, so widening this to #977's credential would be a behaviour
|
|
4611
|
+
// change wearing a security fix's clothes.
|
|
4232
4612
|
if (input.region !== null) {
|
|
4233
4613
|
return c.json({ error: 'region is derived from the scope, not chosen on a binding' }, 403);
|
|
4234
4614
|
}
|
|
@@ -4255,7 +4635,7 @@ export function createControlPlaneApi(options) {
|
|
|
4255
4635
|
// re-affirm. Idempotent and safe to hammer — it just reflects Cloudflare's current state.
|
|
4256
4636
|
app.post('/hostnames/:hostname/verify', async (c) => {
|
|
4257
4637
|
const name = c.req.param('hostname');
|
|
4258
|
-
if (c.get('principal')
|
|
4638
|
+
if (confinedTenant(c.get('principal')) !== null && !(await tenantHostname(c, name))) {
|
|
4259
4639
|
return c.json({ error: `unknown hostname: ${name.toLowerCase()}` }, 404);
|
|
4260
4640
|
}
|
|
4261
4641
|
const row = await hostnameRow(c, name);
|
|
@@ -4269,7 +4649,7 @@ export function createControlPlaneApi(options) {
|
|
|
4269
4649
|
// Not path-parsed through the schema: a hostname is the path segment here, and
|
|
4270
4650
|
// `setHostnameStatus` normalizes and 404s an unknown one below the seam.
|
|
4271
4651
|
const name = c.req.param('hostname');
|
|
4272
|
-
if (c.get('principal')
|
|
4652
|
+
if (confinedTenant(c.get('principal')) !== null && !(await tenantHostname(c, name))) {
|
|
4273
4653
|
return c.json({ error: `unknown hostname: ${name.toLowerCase()}` }, 404);
|
|
4274
4654
|
}
|
|
4275
4655
|
await admin.setHostnameStatus(c.get('actor'), name, status, note);
|
|
@@ -4283,7 +4663,7 @@ export function createControlPlaneApi(options) {
|
|
|
4283
4663
|
// idempotency yields to existence hiding at the tenant boundary.
|
|
4284
4664
|
app.delete('/hostnames/:hostname', async (c) => {
|
|
4285
4665
|
const name = c.req.param('hostname');
|
|
4286
|
-
if (c.get('principal')
|
|
4666
|
+
if (confinedTenant(c.get('principal')) !== null && !(await tenantHostname(c, name))) {
|
|
4287
4667
|
return c.json({ error: `unknown hostname: ${name.toLowerCase()}` }, 404);
|
|
4288
4668
|
}
|
|
4289
4669
|
// Release the Cloudflare custom hostname before dropping the row, or the CF object
|
|
@@ -4556,8 +4936,9 @@ export function createControlPlaneApi(options) {
|
|
|
4556
4936
|
const v = await verticalOf(c.get('actor'), slug);
|
|
4557
4937
|
if (!v)
|
|
4558
4938
|
return { error: `unknown vertical '${slug}'`, status: 404 };
|
|
4559
|
-
if (p
|
|
4939
|
+
if (confinedTenant(p) !== null && v.ownerTenant !== confinedTenant(p)) {
|
|
4560
4940
|
return { error: 'forbidden', status: 403 };
|
|
4941
|
+
}
|
|
4561
4942
|
if (v.ownerTenant === null)
|
|
4562
4943
|
return { error: `vertical '${slug}' has no owner tenant`, status: 409 };
|
|
4563
4944
|
return { tenantId: v.ownerTenant, slug };
|
|
@@ -4708,6 +5089,47 @@ export function createControlPlaneApi(options) {
|
|
|
4708
5089
|
});
|
|
4709
5090
|
return c.body(null, 204);
|
|
4710
5091
|
});
|
|
5092
|
+
// -- the schedule kill switch (#1666) --------------------------------------
|
|
5093
|
+
//
|
|
5094
|
+
// One module's scheduled work on ONE scope, off and back on — `revokeFromSystem` and
|
|
5095
|
+
// `restoreToSystem`. Module-wide on the scope by design (the body schema says why a
|
|
5096
|
+
// per-permission switch is either inexpressible or noisy), `reason` required, and both
|
|
5097
|
+
// halves audited on the admin log — intent first, then outcome, on every attempt,
|
|
5098
|
+
// paired by the `operationId` the route answers with. Idempotent: a repeat answers
|
|
5099
|
+
// `changed: false`, and is audited all the same.
|
|
5100
|
+
//
|
|
5101
|
+
// **Restore is the lever; a grant is not.** While the switch is off, a `grantToSystem`
|
|
5102
|
+
// for the module is refused (409) and a reconcile seats none of its system grants, not
|
|
5103
|
+
// even a newly declared one — only the POST below turns it back on, and it gives back
|
|
5104
|
+
// exactly what the DELETE took. A switch that the next deploy, or a stray grant,
|
|
5105
|
+
// silently undid would not be a kill switch.
|
|
5106
|
+
//
|
|
5107
|
+
// For a HOSTED scope the grants live in the vertical's deployment, and the host's
|
|
5108
|
+
// `systemSwitchDelegation` moves the switch there; a deployment built before the far
|
|
5109
|
+
// end existed answers 501 "redeploy", nothing is switched, and the log shows the intent
|
|
5110
|
+
// and its failure.
|
|
5111
|
+
//
|
|
5112
|
+
// **Staff and the platform service token ONLY.** Builders are refused by BUILDER_ROUTES
|
|
5113
|
+
// (default-deny). A tenant-scoped credential (#977) would pass this path's `/tenants/<pin>`
|
|
5114
|
+
// confinement, so the handler refuses it by `confinedTenant`: the switched party must
|
|
5115
|
+
// not be able to switch itself back on, and the dashboard has no flow that pulls it.
|
|
5116
|
+
const switchScheduleRoute = (to) => async (c) => {
|
|
5117
|
+
if (confinedTenant(c.get('principal')) !== null) {
|
|
5118
|
+
return c.json({ error: 'forbidden: the schedule switch is staff-only' }, 403);
|
|
5119
|
+
}
|
|
5120
|
+
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
5121
|
+
const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
|
|
5122
|
+
const body = systemSwitchBody.parse(await c.req.json());
|
|
5123
|
+
const actor = c.get('actor');
|
|
5124
|
+
// K-3 first, so a scope of another tenant reads as absent before anything is reached.
|
|
5125
|
+
if (!(await admin.getScopeRecord(actor, tenantId, scopeId))) {
|
|
5126
|
+
return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
|
|
5127
|
+
}
|
|
5128
|
+
const input = { moduleId: body.moduleId, node: { tenantId, scopeId }, reason: body.reason };
|
|
5129
|
+
return c.json(to === 'off' ? await admin.revokeFromSystem(actor, input) : await admin.restoreToSystem(actor, input));
|
|
5130
|
+
};
|
|
5131
|
+
app.delete('/tenants/:tenantId/scopes/:scopeId/system-grants', switchScheduleRoute('off'));
|
|
5132
|
+
app.post('/tenants/:tenantId/scopes/:scopeId/system-grants', switchScheduleRoute('on'));
|
|
4711
5133
|
// Orgs: the portal-customer grouping (§4.1). Creating one mints no permission —
|
|
4712
5134
|
// members reach what the org was GRANTED, and granting stays off this surface.
|
|
4713
5135
|
app.post('/tenants/:tenantId/orgs', async (c) => {
|
|
@@ -4746,7 +5168,7 @@ export function createControlPlaneApi(options) {
|
|
|
4746
5168
|
app.get('/ops-failures', async (c) => {
|
|
4747
5169
|
const p = c.get('principal');
|
|
4748
5170
|
const filter = opsFailuresQuery.parse({
|
|
4749
|
-
tenantId: p
|
|
5171
|
+
tenantId: confinedTenant(p) ?? c.req.query('tenantId'),
|
|
4750
5172
|
scopeId: c.req.query('scopeId'),
|
|
4751
5173
|
vertical: c.req.query('vertical'),
|
|
4752
5174
|
version: c.req.query('version'),
|
|
@@ -4771,7 +5193,7 @@ export function createControlPlaneApi(options) {
|
|
|
4771
5193
|
kind: c.req.query('kind'),
|
|
4772
5194
|
unit: c.req.query('unit'),
|
|
4773
5195
|
outcome: c.req.query('outcome'),
|
|
4774
|
-
tenantId: p
|
|
5196
|
+
tenantId: confinedTenant(p) ?? c.req.query('tenantId'),
|
|
4775
5197
|
scopeId: c.req.query('scopeId'),
|
|
4776
5198
|
vertical: c.req.query('vertical'),
|
|
4777
5199
|
connectionId: c.req.query('connectionId'),
|