@substrat-run/adapter-cloudflare 0.153.0 → 0.154.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/control-plane-do.d.ts +3 -1
- package/dist/control-plane-do.d.ts.map +1 -1
- package/dist/control-plane-do.js +7 -2
- package/dist/control-plane-do.js.map +1 -1
- package/dist/host.d.ts +75 -2
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +153 -8
- package/dist/host.js.map +1 -1
- package/dist/scope-do.d.ts +10 -2
- package/dist/scope-do.d.ts.map +1 -1
- package/dist/scope-do.js +428 -30
- package/dist/scope-do.js.map +1 -1
- package/package.json +9 -9
package/dist/host.js
CHANGED
|
@@ -3,7 +3,7 @@ import { isRewindRefusal, REWIND_REFUSED } from './rewind-refusal.js';
|
|
|
3
3
|
import { isSystemDoorMoved, SYSTEM_DOOR_MOVED, SYSTEM_DOOR_REGATES } from './system-door.js';
|
|
4
4
|
import { delegatedReadParams, operationSeriesCount, fromWireFailure, isSubstratError, exportReadInput, exportedBatch, importBatch, importResult, importState, importsOfManifestJson, exportsOfManifestJson, assertNoBindingShapeBreak, importCursorMove, importCursorMoveAt, importCursorMoved, accessLogEntry, adminLogEntry, opsFailureEntry, opsFailureFingerprint, issueEntry, findingEntry, findingRuleEntry, sweepRunEntry, FRESHNESS_HEARTBEAT_MINUTES, sweepRunsPayload, modelUsageEntry, attachmentRecord, createTenantInput, identityLink, identityPool, createOrgInput, promotionAcknowledgement, bindAcknowledgement, bindHostnameInput, channelHistoryEntry, hostnameBinding, publishVersionInput, AUTO_ADMISSION_NOTE, registerVerticalInput, vertical as verticalSchema, verticalServingState, verticalChannel, verticalVersion, connection, capabilityExchange, mintedCapability, boundedBecomeMint, boundedBecomeRevoke, boundedUnbind, entityRef, becomeLinkState, principalBecomeCapabilityInput, capabilityGrant, principalId, connectionGrant, connectionGrantRecord, connectionSecret, systemGrant, systemSwitch, systemSwitchOutcome, systemScheduleEntry, systemSwitchRecord, entitlementGrant, entitlementGrantInput, capabilityFilter, instant, meterReading, scopeStorageSample, subjectRef, createConnectionInput, connectLinkFilter, connectLinkKey, consumeConnectLinkInput, mintConnectLinkInput, projectedConnectionGrant, projectedConnectionKey, moduleManifest, org as orgSchema, orgMembership, resolvedIdentity, identityMembership, roleDefinition, scope as scopeSchema, tenant as tenantSchema, tenantRole, delegatedReadRecord, ownerTransferAudit, memberChangeAudit, peerBindingAudit, connectionBindingAudit, copyMarkAudit, orphanSweepAudit, subjectShredReceipt, connectorDispatchKind, callsOfManifestJson, outboundOfManifestJson, SCOPE_GATE_REASONS, substratError, redrainEventsInput, coverage, peerCoverage, peerGrantsEntry, peerSwitch, peerSwitchOutcome, verticalCaller, verticalResolution, peerInstanceResolution, peerBinding as peerBindingSchema, verticalSlug, entityObjectRef, ATTACHMENT_BLOB_BINDING, blobStoreBindingName, lifecycleDelivery, scopeLifecycle, } from '@substrat-run/contracts';
|
|
5
5
|
import { normalizeHostname, toRouteTarget } from './route-resolver.js';
|
|
6
|
-
import { attachmentBlobKey, normalizeUploadMediaType, redeemOrReplayUploadLink, attachmentSha256, entitlementDenial, requiredEntitlementFor, foldMeterReading, parseValidationRecords, resolveScopeRecord, ulid, KEPT_COPY_REFUSAL, capabilityTokenHash, checkBecomeInput, assertBecomeLinkStateIds, plausibleSessionToken, foldModelUsage, backoffAt, resolveRetryPolicy, executorOutcomeOf, isDeliveryRefusal, refusalJournalText, isSecretBoxConfigured, unconfiguredSecretBox, createSubjectKeys, generateSealingKeyPair, openSealed, ImpersonationRefused, assertSessionUsable, impersonationRowValues, mapImpersonationRow, newImpersonationSession, jobRunOf, assertLeaseMs, runDueJobRuns, SYSTEM_DOOR_WAIT, startJobRun, attachmentTextJob, assertAttachmentExtractors, resolveAttachmentTextBounds, assertJobRegistrable, ATTACHMENT_TEXT_BACKFILL_JOB, ATTACHMENT_TEXT_JOB, kernelJobFor, attachmentTextBackfillJob, searchLimit, searchMatchExpression, globalFetch, assertRedrainWindow, platformRequestOf, undrainedEventsOf, inUnitMovesToAudit, staleCarryRevertRow, staleCarryReverts, recordedOffFromWire, peerSubjectRef, SWITCH_KINDS, reassertActionOf, reassertEntry, reassertOffRow, reassertOnRow, switchActionOf, switchAuditSubject, recordAuditOutcome, auditedCapabilityRevoke, auditWarningOf, switchNotFoundMessage, recordWriteSuperseded, switchSubjectOf, switchSupersededMessage, withRecorded, systemSwitchedOffMessage, tenantSystemSwitchedOffMessage, CrossVerticalRegistry, importCursorSourceOf, exportBreaksOf, exportBreakRefusal, assertPromoteReviewCurrent, promoteRaceRefusal, rebindRaceRefusal, bindExportBreaksOf, bindExportBreakRefusal, scopeMoveOf, runningVersionOf, strandJudgmentsOf, boundShapesOfColumn, installReconciled, collectPeers, peerCallerAt, peerSeats, connectorCallRecord, noopConnectorCallRecorder, recordConnectorCall, settleConnectionUse, splitManifestMigrations, publishedMigrationDigest, migrationChangeRefusal, unknownRoleError, assertRowLimit, assertRowOffset, emptyImportResult, INERT_SCOPE_REASON, lifecycleReceipt, lifecycleRefusal, isPrimaryScope, isPrimaryScopeRow, asyncInvocationId, asyncLinePass, assertNoCallerPurge, purgeReportOf, purgeStillDue, assertBoundOperations, registerTrashTargets, memberAddedAudit, shapeTopUpBatch, } from '@substrat-run/kernel';
|
|
6
|
+
import { attachmentBlobKey, normalizeUploadMediaType, redeemOrReplayUploadLink, attachmentSha256, entitlementDenial, requiredEntitlementFor, foldMeterReading, parseValidationRecords, resolveScopeRecord, ulid, KEPT_COPY_REFUSAL, capabilityTokenHash, checkBecomeInput, assertBecomeLinkStateIds, plausibleSessionToken, foldModelUsage, backoffAt, resolveRetryPolicy, executorOutcomeOf, isDeliveryRefusal, refusalJournalText, isSecretBoxConfigured, unconfiguredSecretBox, createSubjectKeys, generateSealingKeyPair, openSealed, ImpersonationRefused, assertSessionUsable, impersonationRowValues, mapImpersonationRow, newImpersonationSession, jobRunOf, assertLeaseMs, runDueJobRuns, SYSTEM_DOOR_WAIT, startJobRun, attachmentTextJob, assertAttachmentExtractors, resolveAttachmentTextBounds, assertJobRegistrable, ATTACHMENT_TEXT_BACKFILL_JOB, ATTACHMENT_TEXT_JOB, kernelJobFor, attachmentTextBackfillJob, searchLimit, searchMatchExpression, globalFetch, assertRedrainWindow, platformRequestOf, undrainedEventsOf, inUnitMovesToAudit, staleCarryRevertRow, staleCarryReverts, recordedOffFromWire, peerSubjectRef, SWITCH_KINDS, reassertActionOf, reassertEntry, reassertOffRow, reassertOnRow, switchActionOf, switchAuditSubject, recordAuditOutcome, auditedCapabilityRevoke, auditWarningOf, switchNotFoundMessage, recordWriteSuperseded, switchSubjectOf, switchSupersededMessage, withRecorded, systemSwitchedOffMessage, tenantSystemSwitchedOffMessage, CrossVerticalRegistry, importCursorSourceOf, exportBreaksOf, exportBreakRefusal, assertPromoteReviewCurrent, promoteRaceRefusal, rebindRaceRefusal, bindExportBreaksOf, bindExportBreakRefusal, scopeMoveOf, runningVersionOf, strandJudgmentsOf, boundShapesOfColumn, installReconciled, collectPeers, peerCallerAt, peerSeats, connectorCallRecord, noopConnectorCallRecorder, recordConnectorCall, settleConnectionUse, splitManifestMigrations, publishedMigrationDigest, migrationChangeRefusal, unknownRoleError, assertRowLimit, assertRowOffset, emptyImportResult, INERT_SCOPE_REASON, lifecycleReceipt, lifecycleRefusal, isPrimaryScope, isPrimaryScopeRow, asyncInvocationId, asyncLinePass, assertNoCallerPurge, purgeReportOf, purgeStillDue, assertBoundOperations, registerTrashTargets, assertModuleDeclared, noteReservedKeys, reservedGrantRefusal, reservedRoleRefusal, reservedShapeRefusal, memberAddedAudit, shapeTopUpBatch, } from '@substrat-run/kernel';
|
|
7
7
|
import { fenceAttachmentSweep, missingAttachmentStore, sweepOrphanAttachments, unsettledCopyRefusal } from '@substrat-run/kernel';
|
|
8
8
|
import { COPY_RESTORE_FENCE_LAPSED, scopeScriptCopyOf } from '@substrat-run/kernel';
|
|
9
9
|
import { attributedView, isModuleErasureCounts, moduleErasurePlan, moduleRowsErased, callReservedStoreOperation, callScopeStore, observeStoreCall, replayStoreErasures, scopeStoreLifecycleOf, scopeStoreRunnersOf } from '@substrat-run/kernel';
|
|
@@ -58,6 +58,18 @@ export const tenantUnrecordedLine = (tenantId, scopeId) => ({
|
|
|
58
58
|
scopeId,
|
|
59
59
|
message: 'admitted to a scope whose tenant is not recorded; a reconcile or lifecycle delivery records it',
|
|
60
60
|
});
|
|
61
|
+
/**
|
|
62
|
+
* #2430: the line a host writes when a provision or reconcile skips delivered connection grants that
|
|
63
|
+
* name a key reserved to a system principal here. Ids and keys only.
|
|
64
|
+
*/
|
|
65
|
+
const connectionGrantsSkippedLine = (tenantId, scopeId, skipped) => ({
|
|
66
|
+
substrat: 'connection-grants-skipped',
|
|
67
|
+
level: 'warn',
|
|
68
|
+
tenantId,
|
|
69
|
+
scopeId,
|
|
70
|
+
skipped,
|
|
71
|
+
message: 'delivered connection grants naming a key reserved to a system principal were not seated',
|
|
72
|
+
});
|
|
61
73
|
/**
|
|
62
74
|
* Refuse to mark a scope a copy unless the request classifies it non-primary (#2005). The
|
|
63
75
|
* classification is the platform directory's; the vertical applies the same `isPrimaryScope` to
|
|
@@ -332,6 +344,10 @@ export class CloudflareScopeHost {
|
|
|
332
344
|
// Registration-mechanics bookkeeping (validation only — the DO executes).
|
|
333
345
|
// Code-time, derived from the bundled modules, NOT durable directory state.
|
|
334
346
|
moduleIds = new Set();
|
|
347
|
+
/** #2430: what a registered module declares `holder: 'system'` — `assignRole`'s refusal reads it. */
|
|
348
|
+
reservedKeys = new Set();
|
|
349
|
+
/** #2430: the declared permission surface `registerModule` holds each module to, when given. */
|
|
350
|
+
permissions;
|
|
335
351
|
/**
|
|
336
352
|
* #1705: this deployment's cross-vertical declarations. The DO holds the handlers and makes
|
|
337
353
|
* every decision. The coordinator keeps its own copy for two reasons: registration refuses
|
|
@@ -427,6 +443,10 @@ export class CloudflareScopeHost {
|
|
|
427
443
|
*/
|
|
428
444
|
constructor(options) {
|
|
429
445
|
this.servicePrincipals = options.servicePrincipals;
|
|
446
|
+
this.permissions = options.permissions;
|
|
447
|
+
// #2430: what the declared surface reserves holds here too, whatever is registered under its ids.
|
|
448
|
+
for (const m of options.permissions?.modules ?? [])
|
|
449
|
+
noteReservedKeys(this.reservedKeys, m.manifest);
|
|
430
450
|
this.secretBox = options.secretBox ?? unconfiguredSecretBox;
|
|
431
451
|
this.tenantStores = options.tenantStores;
|
|
432
452
|
this.blobStores = options.blobStores;
|
|
@@ -1055,6 +1075,36 @@ export class CloudflareScopeHost {
|
|
|
1055
1075
|
* #2016: `tenantId`, the tenant the platform restores for: recorded as the scope's receipt in
|
|
1056
1076
|
* the load's own transaction, and a store provisioned for another tenant refuses the load. */
|
|
1057
1077
|
opts) {
|
|
1078
|
+
const out = await this.loadLocal(scopeId, opts, (load) => this.scopeStub(scopeId).importDumpChecked(tables, scopeId, load));
|
|
1079
|
+
return { ...out, tables: tables.length };
|
|
1080
|
+
}
|
|
1081
|
+
/**
|
|
1082
|
+
* One page of this scope's dump from THIS deployment (#2450), behind the vertical's
|
|
1083
|
+
* `/internal/export-page`: `exportScopeStampedLocal` a page at a time, for a scope too large to
|
|
1084
|
+
* cross in one call. With `stamp`, the first page (`cursor` null) mints the load stamp.
|
|
1085
|
+
*/
|
|
1086
|
+
async exportScopePageLocal(scopeId, cursor,
|
|
1087
|
+
/** `maxBytes`: a smaller page than `SCOPE_DUMP_PAGE_MAX_BYTES`, for a test that wants many. */
|
|
1088
|
+
opts) {
|
|
1089
|
+
return unwrapReply(await this.scopeStub(scopeId).exportDumpPage(cursor, {
|
|
1090
|
+
stamp: opts?.stamp === true,
|
|
1091
|
+
...(opts?.maxBytes !== undefined ? { maxBytes: opts.maxBytes } : {}),
|
|
1092
|
+
}));
|
|
1093
|
+
}
|
|
1094
|
+
/** Stage one page of a dump into a scope in THIS deployment (#2450), for `restoreStagedScopeLocal`. */
|
|
1095
|
+
async stageScopePageLocal(scopeId, stageId, seq, tables) {
|
|
1096
|
+
return unwrapReply(await this.scopeStub(scopeId).stageDumpPage(stageId, seq, tables));
|
|
1097
|
+
}
|
|
1098
|
+
/** `restoreScopeLocal` from the dump staged under `stageId` (#2450), with the same fences and answers. */
|
|
1099
|
+
async restoreStagedScopeLocal(scopeId, stageId, opts) {
|
|
1100
|
+
return this.loadLocal(scopeId, opts, (load) => this.scopeStub(scopeId).commitStagedDumpChecked(stageId, scopeId, load));
|
|
1101
|
+
}
|
|
1102
|
+
/** Drop the dump staged under `stageId` (#2450), if it is still there. */
|
|
1103
|
+
async discardStagedScopeLocal(scopeId, stageId) {
|
|
1104
|
+
return this.scopeStub(scopeId).discardStagedDump(stageId);
|
|
1105
|
+
}
|
|
1106
|
+
/** The restore both `restoreScopeLocal` and `restoreStagedScopeLocal` run around their load. */
|
|
1107
|
+
async loadLocal(scopeId, opts, run) {
|
|
1058
1108
|
if (opts?.markCopy)
|
|
1059
1109
|
assertCopyLineage(opts.markCopy);
|
|
1060
1110
|
// #2314: a load replaces the scope's rows, so the orphan sweep is fenced first, as for a rewind.
|
|
@@ -1070,13 +1120,13 @@ export class CloudflareScopeHost {
|
|
|
1070
1120
|
};
|
|
1071
1121
|
// The refusals come back as values (a throw over the RPC carries only its message) and are
|
|
1072
1122
|
// thrown here, in the vertical's own isolate, so its route answers 409 or 412, not a fault.
|
|
1073
|
-
const out = await
|
|
1123
|
+
const out = await run({
|
|
1074
1124
|
...load,
|
|
1075
1125
|
...(opts?.expect ? { expect: opts.expect } : {}),
|
|
1076
1126
|
...(opts?.fence ? { fence: opts.fence } : {}),
|
|
1077
1127
|
});
|
|
1078
1128
|
if (out.refused === false) {
|
|
1079
|
-
return { tables: tables
|
|
1129
|
+
return { tables: out.tables ?? 0, ...(carry ? { switchedOff: out.switchedOff } : {}) };
|
|
1080
1130
|
}
|
|
1081
1131
|
if (out.refused === 'kept')
|
|
1082
1132
|
throw substratError('conflict', KEPT_COPY_REFUSAL);
|
|
@@ -1092,7 +1142,7 @@ export class CloudflareScopeHost {
|
|
|
1092
1142
|
if (!opts?.loadStamp || now.loadStamp !== opts.loadStamp) {
|
|
1093
1143
|
throw substratError('precondition_failed', 'scope store changed since the carry read it; nothing was loaded (#1722)');
|
|
1094
1144
|
}
|
|
1095
|
-
return { tables: tables
|
|
1145
|
+
return { tables: out.tables ?? 0 };
|
|
1096
1146
|
}
|
|
1097
1147
|
/** The kept-copy marker of this scope's store in THIS deployment (#1722), or null. */
|
|
1098
1148
|
async keptCopyLocal(scopeId) {
|
|
@@ -1498,6 +1548,7 @@ export class CloudflareScopeHost {
|
|
|
1498
1548
|
if (this.moduleIds.has(manifest.id)) {
|
|
1499
1549
|
throw new Error(`module already registered: ${manifest.id}`);
|
|
1500
1550
|
}
|
|
1551
|
+
assertModuleDeclared(this.permissions, manifest.id); // #2430
|
|
1501
1552
|
// #2068: the same refusal the ScopeDO applies at code time, here at registration, so a
|
|
1502
1553
|
// misdeclared erasure fails where it is registered on both hosts.
|
|
1503
1554
|
moduleErasurePlan(registration);
|
|
@@ -1531,6 +1582,7 @@ export class CloudflareScopeHost {
|
|
|
1531
1582
|
// #1705: after the checks above, so a refused module leaves nothing registered here.
|
|
1532
1583
|
this.crossVertical.register(manifest, registration.imports);
|
|
1533
1584
|
this.moduleIds.add(manifest.id);
|
|
1585
|
+
noteReservedKeys(this.reservedKeys, manifest);
|
|
1534
1586
|
if (manifest.schedules && manifest.schedules.length > 0) {
|
|
1535
1587
|
this.moduleSchedules.set(manifest.id, manifest.schedules);
|
|
1536
1588
|
}
|
|
@@ -2791,6 +2843,10 @@ export class CloudflareScopeHost {
|
|
|
2791
2843
|
throw unknownScopeForTenant(tenantId, scopeId);
|
|
2792
2844
|
}
|
|
2793
2845
|
await this.peerScopeGate(tenantId, scopeId, 'canAssign');
|
|
2846
|
+
// #2430: the answer a person's door would give — a role carrying a reserved key is refused.
|
|
2847
|
+
const refused = await this.scopeStub(scopeId).reservedRoleRefusalFor(tenantId, roleKey, 'person');
|
|
2848
|
+
if (refused)
|
|
2849
|
+
throw substratError('forbidden', refused);
|
|
2794
2850
|
const bound = await this.scopeStub(scopeId).canAssignFor(tenantId, scopeId, principalId.parse(principal), roleKey);
|
|
2795
2851
|
if (!bound)
|
|
2796
2852
|
throw unknownRoleError(roleKey);
|
|
@@ -2804,6 +2860,8 @@ export class CloudflareScopeHost {
|
|
|
2804
2860
|
const bound = await this.scopeStub(scopeId).assignScopeRoleBoundedFor(tenantId, scopeId, principalId.parse(caller), principalId.parse(assignee), roleKey);
|
|
2805
2861
|
if (!bound)
|
|
2806
2862
|
throw unknownRoleError(roleKey);
|
|
2863
|
+
if ('refused' in bound)
|
|
2864
|
+
throw substratError('forbidden', bound.refused);
|
|
2807
2865
|
return coverage.parse(bound);
|
|
2808
2866
|
}
|
|
2809
2867
|
async listScopeRoleHolders(tenantId, scopeId, principal) {
|
|
@@ -2818,6 +2876,8 @@ export class CloudflareScopeHost {
|
|
|
2818
2876
|
throw substratError('conflict', `${target} does not hold '${from}' at this scope`);
|
|
2819
2877
|
if (bound === 'unknown-to')
|
|
2820
2878
|
throw unknownRoleError(to);
|
|
2879
|
+
if ('refused' in bound)
|
|
2880
|
+
throw substratError('forbidden', bound.refused);
|
|
2821
2881
|
return coverage.parse(bound);
|
|
2822
2882
|
}
|
|
2823
2883
|
async revokeScopeRolesBounded(tenantId, scopeId, caller, principal) {
|
|
@@ -4127,6 +4187,19 @@ export class CloudflareScopeHost {
|
|
|
4127
4187
|
}));
|
|
4128
4188
|
},
|
|
4129
4189
|
assignRole: async (actor, assignment) => {
|
|
4190
|
+
// #2430: the platform's door — a role mixing reserved and other keys is refused (K-55).
|
|
4191
|
+
// At a scope node, read where the checker reads the role, against the scope class's own
|
|
4192
|
+
// reservations (it answers null when it has none). At the tenant node, against this host's.
|
|
4193
|
+
{
|
|
4194
|
+
const { tenantId, scopeId } = assignment.node;
|
|
4195
|
+
const refusal = scopeId
|
|
4196
|
+
? await this.scopeStub(scopeId).reservedRoleRefusalFor(tenantId, assignment.roleKey, 'platform')
|
|
4197
|
+
: this.reservedKeys.size > 0
|
|
4198
|
+
? await this.cp.getRole(tenantId, assignment.roleKey).then((role) => role && reservedRoleRefusal(role, this.reservedKeys, 'platform'))
|
|
4199
|
+
: null;
|
|
4200
|
+
if (refusal)
|
|
4201
|
+
throw substratError('forbidden', refusal);
|
|
4202
|
+
}
|
|
4130
4203
|
const subject = `principal:${assignment.principalId}`;
|
|
4131
4204
|
if (assignment.node.scopeId) {
|
|
4132
4205
|
await this.writeScopeTuple(assignment.node.scopeId, subject, `role:${assignment.roleKey}`, `scope:${assignment.node.scopeId}`, null);
|
|
@@ -4164,6 +4237,7 @@ export class CloudflareScopeHost {
|
|
|
4164
4237
|
// trust: the parse is where an `expiresAt` carrying a UTC offset is normalised
|
|
4165
4238
|
// to Z text (#963), and liveness here is a lexicographic `expires_at > ?`.
|
|
4166
4239
|
const grant = capabilityGrant.parse(raw);
|
|
4240
|
+
await this.refuseReservedGrant([grant.permission], grant.node.scopeId); // #2430
|
|
4167
4241
|
await writeGrant(`principal:${grant.principalId}`, grant.permission, grant.node, grant.entity, grant.expiresAt);
|
|
4168
4242
|
await this.recordAdmin(actor, 'grant', { tenantId: grant.node.tenantId, scopeId: grant.node.scopeId }, null, grant);
|
|
4169
4243
|
// Tenant-level grant → changes the projected set. Scope-level + entity
|
|
@@ -4209,6 +4283,12 @@ export class CloudflareScopeHost {
|
|
|
4209
4283
|
`${grant.node.scopeId} runs '${scope.vertical ?? 'none'}'`);
|
|
4210
4284
|
}
|
|
4211
4285
|
}
|
|
4286
|
+
// #2430: a connection acts for the external provider holding its token, so it is never
|
|
4287
|
+
// given a key reserved to a system principal — refused before the directory record, so a
|
|
4288
|
+
// refused grant is gathered by no later provision. Against this host's reservations, and
|
|
4289
|
+
// the scope class's when the scope is served here; a delegated scope's deployment judges
|
|
4290
|
+
// its own at delivery (`connectorGrantLocal`). A grant recorded before stays.
|
|
4291
|
+
await this.refuseReservedGrant([grant.permission], this.connectorDelegation ? null : (grant.node.scopeId ?? null));
|
|
4212
4292
|
// #592: the directory-side record FIRST, so a grant whose tuple delivery
|
|
4213
4293
|
// fails below is still gathered by the next provision/reconcile — the
|
|
4214
4294
|
// repair channel — instead of vanishing. The tuple stays the only thing
|
|
@@ -4357,6 +4437,7 @@ export class CloudflareScopeHost {
|
|
|
4357
4437
|
// The org must exist in the node's tenant. A grant to a phantom org looks
|
|
4358
4438
|
// applied, resolves for nobody, and still shows up in the permission diff.
|
|
4359
4439
|
await requireOrg(node.tenantId, orgId);
|
|
4440
|
+
await this.refuseReservedGrant([permission], node.scopeId); // #2430: an org's members are people
|
|
4360
4441
|
await writeGrant(`org:${orgId}`, permission, node, entity);
|
|
4361
4442
|
await this.recordAdmin(actor, 'grantToOrg', { tenantId: node.tenantId, scopeId: node.scopeId }, null, { orgId, permission, node, entity });
|
|
4362
4443
|
if (!node.scopeId)
|
|
@@ -5115,7 +5196,9 @@ export class CloudflareScopeHost {
|
|
|
5115
5196
|
: this.adminEntry(actor, 'unassignRole', { tenantId, scopeId: null }, assignment, null),
|
|
5116
5197
|
// #2047: the org's own row, when the change joins or leaves one. The unit fills in its
|
|
5117
5198
|
// `before`/`after` and writes it only for what it does — a join's carries the expiry.
|
|
5118
|
-
orgId ? this.adminEntry(actor, op === 'add' ? 'addMember' : 'removeMember', { tenantId }, null, null) : undefined
|
|
5199
|
+
orgId ? this.adminEntry(actor, op === 'add' ? 'addMember' : 'removeMember', { tenantId }, null, null) : undefined,
|
|
5200
|
+
// #2430: what this host's modules reserve, so the unit refuses an add of a role carrying it.
|
|
5201
|
+
[...this.reservedKeys]);
|
|
5119
5202
|
// The tenant-level tuple, or its tombstone, reaches the projections.
|
|
5120
5203
|
if (result.applied && (op === 'add' || result.changed))
|
|
5121
5204
|
await this.fanOut(tenantId);
|
|
@@ -7475,6 +7558,10 @@ export class CloudflareScopeHost {
|
|
|
7475
7558
|
*/
|
|
7476
7559
|
async provisionScopeLocal(input) {
|
|
7477
7560
|
const carry = recordedOffFromWire(input);
|
|
7561
|
+
// #2430: refused before anything is written, rather than at the top-up after the projection.
|
|
7562
|
+
if (input.entityGrants)
|
|
7563
|
+
await this.refuseReservedShapes(input.entityGrants, input.scopeId);
|
|
7564
|
+
const connections = await this.seatableConnectionGrants(input.tenantId, input.scopeId, input.connectionGrants);
|
|
7478
7565
|
const services = await this.servicePrincipals?.(input.tenantId, input.scopeId);
|
|
7479
7566
|
const stub = this.scopeStub(input.scopeId);
|
|
7480
7567
|
await this.migrateAndRecord(input.scopeId); // create the module tables (setMigrationState no-ops on a null CP)
|
|
@@ -7511,7 +7598,7 @@ export class CloudflareScopeHost {
|
|
|
7511
7598
|
expires_at: null,
|
|
7512
7599
|
lockout_reseat: true,
|
|
7513
7600
|
},
|
|
7514
|
-
...this.declaredSeatsLocal(input.scopeId,
|
|
7601
|
+
...this.declaredSeatsLocal(input.scopeId, connections.seat),
|
|
7515
7602
|
],
|
|
7516
7603
|
// #406: identity links, delivered by the platform with provisioning exactly as
|
|
7517
7604
|
// entitlements are (#310). Preserve-on-undefined, so an absent field never wipes
|
|
@@ -7546,8 +7633,31 @@ export class CloudflareScopeHost {
|
|
|
7546
7633
|
...(carry ? { switchedOff } : {}),
|
|
7547
7634
|
...(shapes ? { retirementsLeftOpen: shapes.retirementsLeftOpen ?? 0 } : {}),
|
|
7548
7635
|
...(shapes?.boundEver ? { boundShapeTypes: shapes.boundEver } : {}),
|
|
7636
|
+
...(connections.skipped.length ? { skippedConnectionGrants: connections.skipped } : {}),
|
|
7549
7637
|
};
|
|
7550
7638
|
}
|
|
7639
|
+
/**
|
|
7640
|
+
* #2430: the delivered connection grants a provision or reconcile may seat, and those it skips. A
|
|
7641
|
+
* connection acts for the external provider holding its token, so a grant naming a key reserved
|
|
7642
|
+
* to a system principal here (this host's reservations, or the scope class's) is never seated.
|
|
7643
|
+
* The directory may have recorded it before these checks existed, so it is skipped and reported
|
|
7644
|
+
* (in the answer and as a warn line), never a failed provision: one such grant must not stop a
|
|
7645
|
+
* promote or a reconcile seating everything else.
|
|
7646
|
+
*/
|
|
7647
|
+
async seatableConnectionGrants(tenantId, scopeId, grants) {
|
|
7648
|
+
if (!grants?.length)
|
|
7649
|
+
return { seat: grants, skipped: [] };
|
|
7650
|
+
const permissions = [...new Set(grants.map((g) => g.permission))];
|
|
7651
|
+
const reserved = new Set([
|
|
7652
|
+
...permissions.filter((p) => this.reservedKeys.has(p)),
|
|
7653
|
+
...(await this.scopeStub(scopeId).reservedAmong(permissions)),
|
|
7654
|
+
]);
|
|
7655
|
+
if (reserved.size === 0)
|
|
7656
|
+
return { seat: grants, skipped: [] };
|
|
7657
|
+
const skipped = grants.filter((g) => reserved.has(g.permission)).map((g) => ({ connectionId: g.connectionId, permission: g.permission }));
|
|
7658
|
+
console.warn(JSON.stringify(connectionGrantsSkippedLine(tenantId, scopeId, skipped)));
|
|
7659
|
+
return { seat: grants.filter((g) => !reserved.has(g.permission)), skipped };
|
|
7660
|
+
}
|
|
7551
7661
|
/**
|
|
7552
7662
|
* The tuples this deployment's code declares for one scope, seated by every provision and
|
|
7553
7663
|
* reconcile: each module's SCHEDULE grants (#383, #461 — without them the grant-is-the-switch
|
|
@@ -7591,9 +7701,13 @@ export class CloudflareScopeHost {
|
|
|
7591
7701
|
if (!(await stub.servesTenant(input.tenantId)))
|
|
7592
7702
|
throw unknownScopeForTenant(input.tenantId, input.scopeId);
|
|
7593
7703
|
const carry = recordedOffFromWire(input);
|
|
7704
|
+
const connections = await this.seatableConnectionGrants(input.tenantId, input.scopeId, input.connectionGrants); // #2430
|
|
7594
7705
|
await this.migrateAndRecord(input.scopeId);
|
|
7595
|
-
const switchedOff = await stub.seatTuples(this.declaredSeatsLocal(input.scopeId,
|
|
7596
|
-
return
|
|
7706
|
+
const switchedOff = await stub.seatTuples(this.declaredSeatsLocal(input.scopeId, connections.seat), carry ? { scopeId: input.scopeId, at: new Date().toISOString(), ...carry } : undefined);
|
|
7707
|
+
return {
|
|
7708
|
+
...(carry ? { switchedOff } : {}),
|
|
7709
|
+
...(connections.skipped.length ? { skippedConnectionGrants: connections.skipped } : {}),
|
|
7710
|
+
};
|
|
7597
7711
|
}
|
|
7598
7712
|
/**
|
|
7599
7713
|
* Resolve an external identity from a scope's PROJECTED links (#406) — the CP-less
|
|
@@ -7618,6 +7732,12 @@ export class CloudflareScopeHost {
|
|
|
7618
7732
|
* already projected (via `provisionScopeLocal`), or the local checker expands it to nothing.
|
|
7619
7733
|
*/
|
|
7620
7734
|
async assignScopeRole(scopeId, principal, roleKey) {
|
|
7735
|
+
// #2430: a platform door, as `HostAdmin.assignRole` is — a role mixing reserved and other keys
|
|
7736
|
+
// is refused, against the scope class's own reservations and the role as the checker reads it
|
|
7737
|
+
// for the tenant the scope's receipt names (`reservedRoleRefusalHere` says what happens without one).
|
|
7738
|
+
const refusal = await this.scopeStub(scopeId).reservedRoleRefusalHere(roleKey, 'platform');
|
|
7739
|
+
if (refusal)
|
|
7740
|
+
throw substratError('forbidden', refusal);
|
|
7621
7741
|
unwrapReply(await this.scopeStub(scopeId).writeTuple(`principal:${principal}`, `role:${roleKey}`, `scope:${scopeId}`, null));
|
|
7622
7742
|
}
|
|
7623
7743
|
/**
|
|
@@ -7677,6 +7797,7 @@ export class CloudflareScopeHost {
|
|
|
7677
7797
|
* (writeTuple is INSERT OR REPLACE), so it is safe to re-issue on every link.
|
|
7678
7798
|
*/
|
|
7679
7799
|
async grantEntityLocal(scopeId, principal, permission, entity) {
|
|
7800
|
+
await this.refuseReservedGrant([permission], scopeId); // #2430
|
|
7680
7801
|
unwrapReply(await this.scopeStub(scopeId).writeTuple(`principal:${principal}`, `granted:${permission}`, entityObjectRef(entity, 'grantEntityLocal'), // #1856
|
|
7681
7802
|
null));
|
|
7682
7803
|
}
|
|
@@ -7689,8 +7810,29 @@ export class CloudflareScopeHost {
|
|
|
7689
7810
|
* like `grantEntityLocal`: it brings back a key or a marker a revoke tombstoned.
|
|
7690
7811
|
*/
|
|
7691
7812
|
async grantEntityShapeLocal(scopeId, principal, entity, permissions) {
|
|
7813
|
+
await this.refuseReservedGrant(permissions, scopeId); // #2430
|
|
7692
7814
|
await this.scopeStub(scopeId).grantEntityShape(principal, entity, permissions);
|
|
7693
7815
|
}
|
|
7816
|
+
/**
|
|
7817
|
+
* #2430: refuse a raw grant of a reserved key (`reservedGrantRefusal`): against this host's
|
|
7818
|
+
* reservations, and at a scope node against the scope class's own, which may name more.
|
|
7819
|
+
*/
|
|
7820
|
+
async refuseReservedGrant(permissions, scopeId) {
|
|
7821
|
+
const refusal = reservedGrantRefusal(permissions, this.reservedKeys) ??
|
|
7822
|
+
(scopeId ? await this.scopeStub(scopeId).reservedGrantRefusalFor([...permissions]) : null);
|
|
7823
|
+
if (refusal)
|
|
7824
|
+
throw substratError('forbidden', refusal);
|
|
7825
|
+
}
|
|
7826
|
+
/**
|
|
7827
|
+
* #2430: refuse a reconcile of declared shapes when one holds a reserved key in `permissions`
|
|
7828
|
+
* (`reservedShapeRefusal`): against this host's reservations, then the scope class's own.
|
|
7829
|
+
*/
|
|
7830
|
+
async refuseReservedShapes(shapes, scopeId) {
|
|
7831
|
+
const refusal = reservedShapeRefusal(shapes, this.reservedKeys) ??
|
|
7832
|
+
(await this.scopeStub(scopeId).reservedShapeRefusalFor(shapes.map((s) => ({ entityType: s.entityType, permissions: [...s.permissions] }))));
|
|
7833
|
+
if (refusal)
|
|
7834
|
+
throw substratError('forbidden', refusal);
|
|
7835
|
+
}
|
|
7694
7836
|
/**
|
|
7695
7837
|
* Top every holder of each declared shape up to the shape as it is now (#2071), in bounded
|
|
7696
7838
|
* passes — at most `batch` rows of work per scope transaction (default 500, at most 5000;
|
|
@@ -7702,6 +7844,7 @@ export class CloudflareScopeHost {
|
|
|
7702
7844
|
*/
|
|
7703
7845
|
async topUpEntityGrantShapesLocal(tenantId, scopeId, shapes, batch) {
|
|
7704
7846
|
const limit = shapeTopUpBatch(batch);
|
|
7847
|
+
await this.refuseReservedShapes(shapes, scopeId); // #2430
|
|
7705
7848
|
let toppedUp = 0;
|
|
7706
7849
|
let retired = 0;
|
|
7707
7850
|
let retirementsLeftOpen = 0;
|
|
@@ -7775,6 +7918,8 @@ export class CloudflareScopeHost {
|
|
|
7775
7918
|
* while the tuple remains; `expiresAt` bounds the tuple itself.
|
|
7776
7919
|
*/
|
|
7777
7920
|
async connectorGrantLocal(connectionId, scopeId, permission, expiresAt) {
|
|
7921
|
+
// #2430: against the reservations of the scope served here, as `grantToConnection` asks.
|
|
7922
|
+
await this.refuseReservedGrant([permission], scopeId);
|
|
7778
7923
|
await this.writeScopeTuple(scopeId, subjectRef({ kind: 'connection', id: connectionId }), `granted:${permission}`, `scope:${scopeId}`, expiresAt ?? null);
|
|
7779
7924
|
}
|
|
7780
7925
|
/** #2045 (Codex r3): every switch mover here honours the fence — what `/internal/switch-fence` reports. */
|