@substrat-run/adapter-cloudflare 0.160.0 → 0.162.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 +97 -14
- package/dist/control-plane-do.d.ts.map +1 -1
- package/dist/control-plane-do.js +279 -39
- package/dist/control-plane-do.js.map +1 -1
- package/dist/host.d.ts +111 -26
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +294 -196
- package/dist/host.js.map +1 -1
- package/dist/r2.d.ts.map +1 -1
- package/dist/r2.js +13 -10
- package/dist/r2.js.map +1 -1
- package/dist/scope-do.d.ts +27 -0
- package/dist/scope-do.d.ts.map +1 -1
- package/dist/scope-do.js +290 -113
- package/dist/scope-do.js.map +1 -1
- package/package.json +9 -9
package/dist/host.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { env as ambientEnv } from 'cloudflare:workers';
|
|
2
2
|
import { isRewindRefusal, REWIND_REFUSED } from './rewind-refusal.js';
|
|
3
3
|
import { isSystemDoorMoved, SYSTEM_DOOR_MOVED, SYSTEM_DOOR_REGATES } from './system-door.js';
|
|
4
|
-
import { delegatedReadParams, operationSeriesCount, fromWireFailure, isSubstratError, exportReadInput, exportedBatch, importBatch, importResult, importState, importsOfManifestJson, connectionLandingsOfManifestJson, exportsOfManifestJson, assertNoBindingShapeBreak, importCursorMove, importCursorMoveAt, importCursorMoved, accessLogEntry, adminLogEntry, opsFailureEntry, opsFailureFingerprint, issueEntry, findingEntry, findingRuleEntry, sweepRunEntry, FRESHNESS_HEARTBEAT_MINUTES, sweepRunsPayload, modelUsageEntry, createTenantInput, identityLink, identityPool, createOrgInput, promotionAcknowledgement, bindAcknowledgement, bindHostnameInput, channelHistoryEntry, hostnameBinding, publishVersionInput, AUTO_ADMISSION_NOTE, registerVerticalInput, vertical as verticalSchema, verticalServingState, deploymentWireAuth, 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, requestDay, requestRollupDay, meterDay, meterDayTake, verticalOwnerPeriod, 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';
|
|
4
|
+
import { delegatedReadParams, operationSeriesCount, fromWireFailure, isSubstratError, exportReadInput, exportedBatch, importBatch, importResult, importState, importsOfManifestJson, connectionLandingsOfManifestJson, exportsOfManifestJson, assertNoBindingShapeBreak, importCursorMove, importCursorMoveAt, importCursorMoved, accessLogEntry, adminLogEntry, opsFailureEntry, opsFailureFingerprint, issueEntry, findingEntry, findingRuleEntry, sweepRunEntry, FRESHNESS_HEARTBEAT_MINUTES, sweepRunsPayload, modelUsageEntry, createTenantInput, identityLink, identityPool, createOrgInput, promotionAcknowledgement, bindAcknowledgement, bindHostnameInput, channelHistoryEntry, hostnameBinding, publishVersionInput, AUTO_ADMISSION_NOTE, registerVerticalInput, vertical as verticalSchema, verticalServingState, deploymentWireAuth, 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, requestDay, requestRollupDay, meterDay, meterDayTake, verticalOwnerPeriod, 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, errorCodeOf, 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, isSizedByteStream, putAttachmentBytes, entitlementDenial, requiredEntitlementFor, foldMeterReading, parseValidationRecords, readableScopes, resolveScopeRecord, ulid, withheldOpsFailure, 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, fetchLikeOf, deadlineFetch, 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, scopeNotHeldRefusal, isPrimaryScope, isPrimaryScopeRow, asyncInvocationId, asyncLinePass, assertNoCallerPurge, purgeReportOf, purgeStillDue, assertBoundOperations, registerTrashTargets, assertModuleDeclared, noteDeclaredKeys, noteReservedKeys, reservedGrantRefusal, reservedRoleRefusal, reservedRoleSetRefusal, reservedShapeRefusal, SEAT_HOLDING_SCOPE_STATUSES, dumpRoleState, memberAddedAudit, shapeTopUpBatch, verticalOwnerNow, } from '@substrat-run/kernel';
|
|
6
|
+
import { attachmentBlobKey, normalizeUploadMediaType, redeemOrReplayUploadLink, attachmentSha256, bytesStream, isSizedByteStream, openAttachmentByteStream, putAttachmentBytes, entitlementDenial, requiredEntitlementFor, foldMeterReading, parseValidationRecords, readableScopes, resolveScopeRecord, ulid, withheldOpsFailure, 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, fetchLikeOf, deadlineFetch, 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, servingRaceRefusal, 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, scopeNotHeldRefusal, isPrimaryScope, isPrimaryScopeRow, asyncInvocationId, asyncLinePass, assertNoCallerPurge, purgeReportOf, purgeStillDue, assertBoundOperations, registerTrashTargets, assertModuleDeclared, noteDeclaredKeys, noteReservedKeys, reservedGrantRefusal, reservedRoleRefusal, reservedRoleSetRefusal, reservedShapeRefusal, SEAT_HOLDING_SCOPE_STATUSES, dumpRoleRefusal, dumpRoleState, memberAddedAudit, shapeTopUpBatch, verticalOwnerNow, } from '@substrat-run/kernel';
|
|
7
7
|
import { fenceAttachmentSweep, missingAttachmentStore, sweepOrphanAttachments, unsettledCopyRefusal } from '@substrat-run/kernel';
|
|
8
8
|
import { assertWireAuthFilter, wireAuthAccessTarget, 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';
|
|
@@ -75,6 +75,10 @@ const connectionGrantsSkippedLine = (tenantId, scopeId, skipped) => ({
|
|
|
75
75
|
* classification is the platform directory's; the vertical applies the same `isPrimaryScope` to
|
|
76
76
|
* it, so a primary can be marked only by a request that misstates its lineage, never by default.
|
|
77
77
|
*/
|
|
78
|
+
/** A vertical's role definitions as a CP-less projection writes them (`applyProjection`'s rows). */
|
|
79
|
+
function projectedRoleRows(roles) {
|
|
80
|
+
return roles.map((r) => ({ role_key: r.key, permissions: JSON.stringify(r.permissions), source: r.source, holder: r.holder === 'system' ? 'system' : 'person' }));
|
|
81
|
+
}
|
|
78
82
|
function assertCopyLineage(lineage) {
|
|
79
83
|
if (isPrimaryScope(lineage)) {
|
|
80
84
|
throw substratError('conflict', 'mark-copy refused: the directory classifies this scope as primary, not as a copy');
|
|
@@ -349,11 +353,11 @@ const DOOR_WORDS = {
|
|
|
349
353
|
};
|
|
350
354
|
/** #2029: the rewind hold's key for one subject — a module's id, bare as #1819 stored it, or `vertical:<slug>`. */
|
|
351
355
|
const holdKeyOf = (kind, key) => (kind === 'system' ? key : peerSubjectRef(key));
|
|
352
|
-
/** #2541: a vertical-side
|
|
353
|
-
function heldOrRefused(scopeId,
|
|
354
|
-
if (
|
|
356
|
+
/** #2541, #2544: a vertical-side read's answer, or the refusal when the scope's own object said it holds nothing (`null`). */
|
|
357
|
+
function heldOrRefused(scopeId, answer) {
|
|
358
|
+
if (answer === null)
|
|
355
359
|
throw substratError('conflict', scopeNotHeldRefusal(scopeId));
|
|
356
|
-
return
|
|
360
|
+
return answer;
|
|
357
361
|
}
|
|
358
362
|
export class CloudflareScopeHost {
|
|
359
363
|
admin;
|
|
@@ -578,7 +582,8 @@ export class CloudflareScopeHost {
|
|
|
578
582
|
grants: async () => (await this.connectionGrantsInScope(tenantId, scopeId))
|
|
579
583
|
.filter((g) => g.connectionId === open.id)
|
|
580
584
|
.map((g) => g.permission),
|
|
581
|
-
openAttachment: (attachmentId) => this.getConnectorAttachments(open.id, scopeId, { eventId })
|
|
585
|
+
openAttachment: async (attachmentId) => (await this.getConnectorAttachments(open.id, scopeId, { eventId })).open(attachmentId),
|
|
586
|
+
openAttachmentStream: async (attachmentId) => (await this.getConnectorAttachments(open.id, scopeId, { eventId })).openStream(attachmentId),
|
|
582
587
|
// #687: open a cell the scope sealed TO this connection. The keyId-indexed
|
|
583
588
|
// map goes in, never out — a connector receives the plaintext of one cell,
|
|
584
589
|
// not a private key it could mislay. Retired keys are in the map too, so a
|
|
@@ -1005,14 +1010,29 @@ export class CloudflareScopeHost {
|
|
|
1005
1010
|
* gate (the caller is the control plane, which did the K-3 check + audit on its side).
|
|
1006
1011
|
*/
|
|
1007
1012
|
async introspectScopeTables(scopeId) {
|
|
1008
|
-
return this.scopeStub(scopeId).
|
|
1013
|
+
return heldOrRefused(scopeId, await this.scopeStub(scopeId).introspectTablesIfHeld());
|
|
1009
1014
|
}
|
|
1015
|
+
/**
|
|
1016
|
+
* The Data view's table page (#2563): refused `conflict` with the shared sentence for a scope the
|
|
1017
|
+
* deployment does not hold, as the reads above are, never answered as an empty store. The carry
|
|
1018
|
+
* and its cleanup read a wiped copy's tombstone, which is not held by design: they use
|
|
1019
|
+
* `readTombstoneTable`.
|
|
1020
|
+
*/
|
|
1010
1021
|
async introspectScopeTable(scopeId, input) {
|
|
1022
|
+
const reply = heldOrRefused(scopeId, await this.scopeStub(scopeId).introspectTableIfHeld(input.table, input.limit, input.offset));
|
|
1023
|
+
return unwrapReply(reply);
|
|
1024
|
+
}
|
|
1025
|
+
/**
|
|
1026
|
+
* A table of a store this deployment may no longer HOLD, for the carry and its cleanup, which ask
|
|
1027
|
+
* a wiped copy whether it carries the `carried_away` tombstone (#2563). Not guarded: a wiped copy
|
|
1028
|
+
* answers nothing else. Not for a screen or a console; those use `introspectScopeTable`.
|
|
1029
|
+
*/
|
|
1030
|
+
async readTombstoneTable(scopeId, input) {
|
|
1011
1031
|
return unwrapReply(await this.scopeStub(scopeId).introspectTableReply(input.table, input.limit, input.offset));
|
|
1012
1032
|
}
|
|
1013
|
-
/** The SQL console's CP-less path (#219)
|
|
1033
|
+
/** The SQL console's CP-less path (#219): guarded as the table page is (#2563). */
|
|
1014
1034
|
async introspectScopeQuery(scopeId, input) {
|
|
1015
|
-
return unwrapReply(await this.scopeStub(scopeId).
|
|
1035
|
+
return unwrapReply(heldOrRefused(scopeId, await this.scopeStub(scopeId).introspectQueryIfHeld(input.sql)));
|
|
1016
1036
|
}
|
|
1017
1037
|
/**
|
|
1018
1038
|
* The K-35 denial log's CP-less path (#867) — same trust line as the reads above.
|
|
@@ -1108,7 +1128,7 @@ export class CloudflareScopeHost {
|
|
|
1108
1128
|
* #2016: `tenantId`, the tenant the platform restores for: recorded as the scope's receipt in
|
|
1109
1129
|
* the load's own transaction, and a store provisioned for another tenant refuses the load. */
|
|
1110
1130
|
opts) {
|
|
1111
|
-
const out = await this.loadLocal(scopeId, opts, (load) => this.scopeStub(scopeId).importDumpChecked(tables, scopeId, load));
|
|
1131
|
+
const out = await this.loadLocal(scopeId, opts, (load, repair) => repair ? this.scopeStub(scopeId).importDumpJudged(tables, scopeId, load, repair) : this.scopeStub(scopeId).importDumpChecked(tables, scopeId, load));
|
|
1112
1132
|
return { ...out, tables: tables.length };
|
|
1113
1133
|
}
|
|
1114
1134
|
/**
|
|
@@ -1130,7 +1150,9 @@ export class CloudflareScopeHost {
|
|
|
1130
1150
|
}
|
|
1131
1151
|
/** `restoreScopeLocal` from the dump staged under `stageId` (#2450), with the same fences and answers. */
|
|
1132
1152
|
async restoreStagedScopeLocal(scopeId, stageId, opts) {
|
|
1133
|
-
return this.loadLocal(scopeId, opts, (load) =>
|
|
1153
|
+
return this.loadLocal(scopeId, opts, (load, repair) => repair
|
|
1154
|
+
? this.scopeStub(scopeId).commitStagedDumpJudged(stageId, scopeId, load, repair)
|
|
1155
|
+
: this.scopeStub(scopeId).commitStagedDumpChecked(stageId, scopeId, load));
|
|
1134
1156
|
}
|
|
1135
1157
|
/** Drop the dump staged under `stageId` (#2450), if it is still there. */
|
|
1136
1158
|
async discardStagedScopeLocal(scopeId, stageId) {
|
|
@@ -1140,6 +1162,11 @@ export class CloudflareScopeHost {
|
|
|
1140
1162
|
async loadLocal(scopeId, opts, run) {
|
|
1141
1163
|
if (opts?.markCopy)
|
|
1142
1164
|
assertCopyLineage(opts.markCopy);
|
|
1165
|
+
if (opts?.roles && !opts.tenantId) {
|
|
1166
|
+
throw substratError('validation_failed', 'restore refused: a role repair needs the tenant it is projected for; nothing was loaded');
|
|
1167
|
+
}
|
|
1168
|
+
// #2531: the repair the load judges and projects in its own transaction.
|
|
1169
|
+
const repair = opts?.roles && opts.tenantId ? { tenantId: opts.tenantId, roles: projectedRoleRows(opts.roles), judge: this.roleJudge() } : undefined;
|
|
1143
1170
|
// #2314: a load replaces the scope's rows, so the orphan sweep is fenced first, as for a rewind.
|
|
1144
1171
|
await this.fenceOrphanSweep(scopeId, opts?.tenantId);
|
|
1145
1172
|
const carry = opts ? recordedOffFromWire(opts) : undefined;
|
|
@@ -1157,14 +1184,22 @@ export class CloudflareScopeHost {
|
|
|
1157
1184
|
...load,
|
|
1158
1185
|
...(opts?.expect ? { expect: opts.expect } : {}),
|
|
1159
1186
|
...(opts?.fence ? { fence: opts.fence } : {}),
|
|
1160
|
-
});
|
|
1187
|
+
}, repair);
|
|
1161
1188
|
if (out.refused === false) {
|
|
1162
|
-
return {
|
|
1189
|
+
return {
|
|
1190
|
+
tables: out.tables ?? 0,
|
|
1191
|
+
...(carry ? { switchedOff: out.switchedOff } : {}),
|
|
1192
|
+
// Only a load that ran the judged method can say the repair landed in it.
|
|
1193
|
+
...(repair && out.rolesProjected === true ? { rolesProjected: true } : {}),
|
|
1194
|
+
};
|
|
1163
1195
|
}
|
|
1164
1196
|
if (out.refused === 'kept')
|
|
1165
1197
|
throw substratError('conflict', KEPT_COPY_REFUSAL);
|
|
1166
1198
|
if (out.refused === 'tenant')
|
|
1167
1199
|
throw substratError('conflict', out.message);
|
|
1200
|
+
// #2531: the repair's role set, refused on the seats the load wrote; the whole load rolled back.
|
|
1201
|
+
if (out.refused === 'roles')
|
|
1202
|
+
throw substratError('conflict', `the vertical's roles cannot be defined here: ${out.message}`);
|
|
1168
1203
|
if (out.refused === 'lapsed')
|
|
1169
1204
|
throw substratError('precondition_failed', COPY_RESTORE_FENCE_LAPSED);
|
|
1170
1205
|
// A retry of a load that already committed (its answer was lost on the way back) is
|
|
@@ -1295,9 +1330,33 @@ export class CloudflareScopeHost {
|
|
|
1295
1330
|
* safe; scope-level tuples (the restored grants) are never touched.
|
|
1296
1331
|
*/
|
|
1297
1332
|
async projectRolesLocal(tenantId, scopeId, roles) {
|
|
1298
|
-
// #2447: the role set is defined here, so it is judged here, before anything is projected
|
|
1333
|
+
// #2447: the role set is defined here, so it is judged here, before anything is projected, and
|
|
1334
|
+
// (#2531) again inside the projection's unit, on the seats the scope holds as it writes.
|
|
1299
1335
|
await this.refuseReservedRoleSet(roles, tenantId, scopeId);
|
|
1300
|
-
|
|
1336
|
+
await this.projectJudged(tenantId, scopeId, projectedRoleRows(roles), []);
|
|
1337
|
+
}
|
|
1338
|
+
/**
|
|
1339
|
+
* #2531: what this host's own modules reserve and declare, for a unit that judges a role set in the
|
|
1340
|
+
* scope (`RoleJudge`); the scope class's are joined there.
|
|
1341
|
+
*/
|
|
1342
|
+
roleJudge() {
|
|
1343
|
+
return { reserved: [...this.reservedKeys], declared: [...this.declaredKeys] };
|
|
1344
|
+
}
|
|
1345
|
+
/**
|
|
1346
|
+
* #2531: `applyProjectionReply` with the role set judged in its unit (`applyJudgedProjectionReply`).
|
|
1347
|
+
* A refusal there answers with the staff-facing prefix `refuseReservedRoleSet` gives a pre-check's.
|
|
1348
|
+
* A ScopeDO from before the judged method refuses the call itself, with nothing written.
|
|
1349
|
+
*/
|
|
1350
|
+
async projectJudged(tenantId, scopeId, ...rest) {
|
|
1351
|
+
try {
|
|
1352
|
+
return unwrapReply(await this.scopeStub(scopeId).applyJudgedProjectionReply(this.roleJudge(), tenantId, ...rest));
|
|
1353
|
+
}
|
|
1354
|
+
catch (e) {
|
|
1355
|
+
if (errorCodeOf(e) === 'forbidden') {
|
|
1356
|
+
throw substratError('forbidden', `tenant ${tenantId}, scope ${scopeId}: the vertical's roles cannot be defined here: ${e.message}`);
|
|
1357
|
+
}
|
|
1358
|
+
throw e;
|
|
1359
|
+
}
|
|
1301
1360
|
}
|
|
1302
1361
|
/**
|
|
1303
1362
|
* Wipe one scope DO's storage in THIS deployment — the reap half of an orchestrated
|
|
@@ -1333,39 +1392,39 @@ export class CloudflareScopeHost {
|
|
|
1333
1392
|
}
|
|
1334
1393
|
/** Facet this host's own scope's outbox (#1239) — the vertical-host read. */
|
|
1335
1394
|
async facetEventsLocal(scopeId, input) {
|
|
1336
|
-
return this.scopeStub(scopeId).
|
|
1395
|
+
return heldOrRefused(scopeId, await this.scopeStub(scopeId).facetEventsIfHeld(input));
|
|
1337
1396
|
}
|
|
1338
1397
|
/** What one event set off (#1237) on this host's own scope — the vertical-host read. */
|
|
1339
1398
|
async eventEffectsLocal(scopeId, input) {
|
|
1340
|
-
return this.scopeStub(scopeId).
|
|
1399
|
+
return heldOrRefused(scopeId, await this.scopeStub(scopeId).eventEffectsIfHeld(input));
|
|
1341
1400
|
}
|
|
1342
1401
|
/** Everything one call emitted (#1237) on this host's own scope — the vertical-host read. */
|
|
1343
1402
|
async invocationEventsLocal(scopeId, input) {
|
|
1344
|
-
return this.scopeStub(scopeId).
|
|
1403
|
+
return heldOrRefused(scopeId, await this.scopeStub(scopeId).invocationEventsIfHeld(input));
|
|
1345
1404
|
}
|
|
1346
1405
|
/** Every delivery that gave up (#1525) on this host's own scope — the vertical-host read. */
|
|
1347
1406
|
async deadLettersLocal(scopeId, input) {
|
|
1348
|
-
return this.scopeStub(scopeId).
|
|
1407
|
+
return heldOrRefused(scopeId, await this.scopeStub(scopeId).deadLettersIfHeld(input));
|
|
1349
1408
|
}
|
|
1350
1409
|
/** One entity's lifecycle replayed (#1744) on this host's own scope — the vertical-host read. */
|
|
1351
1410
|
async lifecycleFlowLocal(scopeId, input) {
|
|
1352
|
-
return this.scopeStub(scopeId).
|
|
1411
|
+
return heldOrRefused(scopeId, await this.scopeStub(scopeId).lifecycleFlowIfHeld(input));
|
|
1353
1412
|
}
|
|
1354
1413
|
/** Business volumes per bucket (#1750) on this host's own scope — the vertical-host read. */
|
|
1355
1414
|
async operationSeriesLocal(scopeId, input) {
|
|
1356
|
-
return this.scopeStub(scopeId).
|
|
1415
|
+
return heldOrRefused(scopeId, await this.scopeStub(scopeId).operationSeriesIfHeld(input));
|
|
1357
1416
|
}
|
|
1358
1417
|
/** One event's causal chain (#1237) on this host's own scope — the vertical-host read. */
|
|
1359
1418
|
async eventCauseLocal(scopeId, input) {
|
|
1360
|
-
return this.scopeStub(scopeId).
|
|
1419
|
+
return heldOrRefused(scopeId, await this.scopeStub(scopeId).eventCauseIfHeld(input));
|
|
1361
1420
|
}
|
|
1362
1421
|
/** One record's event history (#1235) on this host's own scope — the vertical-host read. */
|
|
1363
1422
|
async entityHistoryLocal(scopeId, input) {
|
|
1364
|
-
return this.scopeStub(scopeId).
|
|
1423
|
+
return heldOrRefused(scopeId, await this.scopeStub(scopeId).entityHistoryIfHeld(input));
|
|
1365
1424
|
}
|
|
1366
1425
|
/** When this host's own scope applied each migration (#1236) — the vertical-host read. */
|
|
1367
1426
|
async appliedMigrationsLocal(scopeId) {
|
|
1368
|
-
return this.scopeStub(scopeId).
|
|
1427
|
+
return heldOrRefused(scopeId, await this.scopeStub(scopeId).appliedMigrationsIfHeld());
|
|
1369
1428
|
}
|
|
1370
1429
|
/**
|
|
1371
1430
|
* This host's own scope's database size in bytes (#1524), the vertical-host read. A scope this
|
|
@@ -1418,7 +1477,8 @@ export class CloudflareScopeHost {
|
|
|
1418
1477
|
* so a caller cannot ask this side for more than the other would.
|
|
1419
1478
|
*/
|
|
1420
1479
|
async undrainedEventsLocal(scopeId, limit) {
|
|
1421
|
-
|
|
1480
|
+
const bounded = Math.min(Math.max(limit, 1), 1000);
|
|
1481
|
+
return undrainedEventsOf(heldOrRefused(scopeId, await this.scopeStub(scopeId).undrainedEventsReadIfHeld(bounded)));
|
|
1422
1482
|
}
|
|
1423
1483
|
/**
|
|
1424
1484
|
* Stamp `drained_at` on this host's own scope (#1334) — the delegation's other half.
|
|
@@ -1444,7 +1504,7 @@ export class CloudflareScopeHost {
|
|
|
1444
1504
|
* platform's `redrainEvents` is the door, and a count egresses nothing to record.
|
|
1445
1505
|
*/
|
|
1446
1506
|
async redrainCountLocal(scopeId, drainedBefore) {
|
|
1447
|
-
return this.scopeStub(scopeId).
|
|
1507
|
+
return heldOrRefused(scopeId, await this.scopeStub(scopeId).redrainCountIfHeld(drainedBefore));
|
|
1448
1508
|
}
|
|
1449
1509
|
/**
|
|
1450
1510
|
* The PITR bookmarks one scope recorded before its migration passes (#286) — the
|
|
@@ -1452,7 +1512,7 @@ export class CloudflareScopeHost {
|
|
|
1452
1512
|
* `/internal/bookmarks`; the control plane is the gate and the auditor.
|
|
1453
1513
|
*/
|
|
1454
1514
|
async migrationBookmarksLocal(scopeId) {
|
|
1455
|
-
return this.scopeStub(scopeId).
|
|
1515
|
+
return heldOrRefused(scopeId, await this.scopeStub(scopeId).migrationBookmarksIfHeld());
|
|
1456
1516
|
}
|
|
1457
1517
|
/**
|
|
1458
1518
|
* Rewind one scope to a pre-migration bookmark (#286's backout) — schema AND data,
|
|
@@ -1940,19 +2000,28 @@ export class CloudflareScopeHost {
|
|
|
1940
2000
|
throw new Error(`connector attachment ${verb} is not delegated (#574) — upload and open are the ` +
|
|
1941
2001
|
`verbs that cross the /internal seam to the serving deployment`);
|
|
1942
2002
|
};
|
|
2003
|
+
const openArgs = (attachmentId) => ({
|
|
2004
|
+
connectionId,
|
|
2005
|
+
tenantId: tenant,
|
|
2006
|
+
scopeId,
|
|
2007
|
+
vertical,
|
|
2008
|
+
attachmentId,
|
|
2009
|
+
// #726 remedy B: the delivery, resolved at the far end against the
|
|
2010
|
+
// deployment's own outbox. Absent outside a dispatch, where the ordinary
|
|
2011
|
+
// permission check still applies.
|
|
2012
|
+
eventId: forEvent?.eventId,
|
|
2013
|
+
});
|
|
1943
2014
|
return {
|
|
1944
2015
|
upload: (upload) => delegation.uploadAttachment({ connectionId, tenantId: tenant, scopeId, vertical, upload }),
|
|
1945
|
-
open: (attachmentId) => delegation.openAttachment(
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
eventId: forEvent?.eventId,
|
|
1955
|
-
}),
|
|
2016
|
+
open: (attachmentId) => delegation.openAttachment(openArgs(attachmentId)),
|
|
2017
|
+
// #2514: streamed over the seam when the embedder can. One that cannot reads the bytes whole,
|
|
2018
|
+
// as `open` does, and hands them on as a stream, so the verb still works and holds what it did.
|
|
2019
|
+
openStream: async (attachmentId) => {
|
|
2020
|
+
if (delegation.openAttachmentStream)
|
|
2021
|
+
return delegation.openAttachmentStream(openArgs(attachmentId));
|
|
2022
|
+
const opened = await delegation.openAttachment(openArgs(attachmentId));
|
|
2023
|
+
return opened && { record: opened.record, body: bytesStream(opened.body), contentType: opened.contentType };
|
|
2024
|
+
},
|
|
1956
2025
|
list: notDelegated('list'),
|
|
1957
2026
|
remove: notDelegated('remove'),
|
|
1958
2027
|
// #1575: undelegated for `list`'s reason — a connector picks a document by id.
|
|
@@ -1999,6 +2068,10 @@ export class CloudflareScopeHost {
|
|
|
1999
2068
|
const record = unwrapReply(await stub.capabilityAttachmentOpen(attachmentId, hash, tenantId, scopeId));
|
|
2000
2069
|
return record ? this.openAttachmentBytes(store, scopeId, record) : null;
|
|
2001
2070
|
},
|
|
2071
|
+
openStream: async (attachmentId) => {
|
|
2072
|
+
const record = unwrapReply(await stub.capabilityAttachmentOpen(attachmentId, hash, tenantId, scopeId));
|
|
2073
|
+
return record ? openAttachmentByteStream(store, attachmentBlobKey(scopeId, record.id), record, workerdSha256) : null;
|
|
2074
|
+
},
|
|
2002
2075
|
remove: async (attachmentId) => unwrapReply(await stub.capabilityAttachmentRefuseWrite(hash, tenantId, scopeId, 'attachments.remove', {
|
|
2003
2076
|
attachmentId,
|
|
2004
2077
|
})),
|
|
@@ -2018,6 +2091,8 @@ export class CloudflareScopeHost {
|
|
|
2018
2091
|
const asPrincipalId = ('principal' in subject
|
|
2019
2092
|
? subject.principal
|
|
2020
2093
|
: subject.connectionId);
|
|
2094
|
+
// The read gate, asked in the DO before the blob store is touched: a refused read never reaches it.
|
|
2095
|
+
const authorizeRead = async (attachmentId) => unwrapReply(await stub.attachmentAuthorizeReply(attachmentId, 'read', asPrincipalId, tenantId, scopeId, connectionId, forEvent?.eventId));
|
|
2021
2096
|
return {
|
|
2022
2097
|
upload: async (input) => {
|
|
2023
2098
|
const id = ulid();
|
|
@@ -2044,9 +2119,14 @@ export class CloudflareScopeHost {
|
|
|
2044
2119
|
},
|
|
2045
2120
|
list: async (entity) => unwrapReply(await stub.attachmentListReply(entity, asPrincipalId, tenantId, scopeId, connectionId)),
|
|
2046
2121
|
open: async (attachmentId) => {
|
|
2047
|
-
const record =
|
|
2122
|
+
const record = await authorizeRead(attachmentId);
|
|
2048
2123
|
return record ? this.openAttachmentBytes(store, scopeId, record) : null;
|
|
2049
2124
|
},
|
|
2125
|
+
// #2514: the same gate, then the bytes as R2 reads them, checked against the record as they pass.
|
|
2126
|
+
openStream: async (attachmentId) => {
|
|
2127
|
+
const record = await authorizeRead(attachmentId);
|
|
2128
|
+
return record ? openAttachmentByteStream(store, attachmentBlobKey(scopeId, record.id), record, workerdSha256) : null;
|
|
2129
|
+
},
|
|
2050
2130
|
remove: async (attachmentId) => {
|
|
2051
2131
|
const removed = unwrapReply(await stub.attachmentRemoveReply(attachmentId, asPrincipalId, tenantId, scopeId, connectionId));
|
|
2052
2132
|
if (removed)
|
|
@@ -2080,7 +2160,17 @@ export class CloudflareScopeHost {
|
|
|
2080
2160
|
return { record, body: obj.body, contentType: obj.contentType ?? record.contentType };
|
|
2081
2161
|
}
|
|
2082
2162
|
async importScope(actor, input, dump) {
|
|
2163
|
+
await this.importScopeFrom(actor, input, dump, true);
|
|
2164
|
+
}
|
|
2165
|
+
/**
|
|
2166
|
+
* `importScope`, with its role judgement (#2555) when `judge`, made before the scope is created so a
|
|
2167
|
+
* refused import leaves nothing behind. A snapshot passes false: it is the platform's copy of a live
|
|
2168
|
+
* scope of this host, made at once, so its seats are the ones that scope holds now (K-55).
|
|
2169
|
+
*/
|
|
2170
|
+
async importScopeFrom(actor, input, dump, judge) {
|
|
2083
2171
|
this.refuseUnusableScopeStores(dump.stores, input.vertical ?? null);
|
|
2172
|
+
if (judge)
|
|
2173
|
+
await this.refuseDumpRoles(input.tenantId, null, dump);
|
|
2084
2174
|
// Create the destination scope (directory row + DO + lazy migrate); the DO then
|
|
2085
2175
|
// replaces its provisioned schema with the dump wholesale (drop-then-replay), so
|
|
2086
2176
|
// the end state is the dump, at the source's frontier. Provenance is stamped from
|
|
@@ -2105,6 +2195,8 @@ export class CloudflareScopeHost {
|
|
|
2105
2195
|
if (!existing)
|
|
2106
2196
|
throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
|
|
2107
2197
|
this.refuseUnusableScopeStores(dump.stores, existing.vertical);
|
|
2198
|
+
// #2555: judged before the fence, so a refused restore moves nothing.
|
|
2199
|
+
await this.refuseDumpRoles(tenantId, scopeId, dump);
|
|
2108
2200
|
// #2314: the load replaces the scope's rows, so the orphan sweep is fenced first.
|
|
2109
2201
|
await this.fenceOrphanSweep(scopeId, tenantId);
|
|
2110
2202
|
// #1742: a dump from before a switch was pulled brings the module's grants back live, so
|
|
@@ -2171,7 +2263,7 @@ export class CloudflareScopeHost {
|
|
|
2171
2263
|
throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
|
|
2172
2264
|
const dump = await this.admin.exportScope(actor, tenantId, scopeId);
|
|
2173
2265
|
const snapshotId = ulid();
|
|
2174
|
-
await this.
|
|
2266
|
+
await this.importScopeFrom(actor, {
|
|
2175
2267
|
tenantId,
|
|
2176
2268
|
scopeId: snapshotId,
|
|
2177
2269
|
kind: opts?.kind ?? 'archive',
|
|
@@ -2180,7 +2272,7 @@ export class CloudflareScopeHost {
|
|
|
2180
2272
|
jurisdiction: source.jurisdiction,
|
|
2181
2273
|
expiresAt: opts?.expiresAt,
|
|
2182
2274
|
// forkedFrom/forkedAt are stamped from the dump by importScope.
|
|
2183
|
-
}, dump);
|
|
2275
|
+
}, dump, false);
|
|
2184
2276
|
// Bind the snapshot to the source's current version so it is a runnable copy at the
|
|
2185
2277
|
// same frontier (a fresh bind, so it never re-triggers the snapshot path).
|
|
2186
2278
|
if (source.verticalVersionId) {
|
|
@@ -3713,7 +3805,9 @@ export class CloudflareScopeHost {
|
|
|
3713
3805
|
await this.writeScopeTuple(node.scopeId, subject, `granted:${permission}`, `scope:${node.scopeId}`, expiresAt ?? null);
|
|
3714
3806
|
}
|
|
3715
3807
|
else {
|
|
3716
|
-
|
|
3808
|
+
// #2526: a tenant-level grant is a directory tuple, written by the ControlPlaneDO in the
|
|
3809
|
+
// unit that writes its admin row — never through here.
|
|
3810
|
+
throw new Error('writeGrant: a tenant-level grant is written with its row in the ControlPlaneDO');
|
|
3717
3811
|
}
|
|
3718
3812
|
};
|
|
3719
3813
|
/**
|
|
@@ -4245,11 +4339,11 @@ export class CloudflareScopeHost {
|
|
|
4245
4339
|
// `defineRoleOrRefusal`, not the legacy `defineRole` an older coordinator reads. A ControlPlaneDO from
|
|
4246
4340
|
// before #2447, met in a deploy's restart window, has no such method, so the call fails loudly rather
|
|
4247
4341
|
// than being misread.
|
|
4248
|
-
|
|
4342
|
+
// #2526: through `reply`, with the row, which the DO writes in the definition's unit, its
|
|
4343
|
+
// `before` the role it read there. A DO from before #2526 does not list the method, and refuses.
|
|
4344
|
+
const defined = await this.directory('defineRoleOrRefusal', tenantId, parsed, context.reservation, context.seatedInScopes, this.adminEntry(actor, 'defineRole', { tenantId }, null, parsed));
|
|
4249
4345
|
if ('refused' in defined)
|
|
4250
4346
|
throw substratError('forbidden', defined.refused);
|
|
4251
|
-
const before = defined.before;
|
|
4252
|
-
await this.recordAdmin(actor, 'defineRole', { tenantId }, before, parsed);
|
|
4253
4347
|
await this.fanOut(tenantId); // role definitions are projected into the tenant's scopes
|
|
4254
4348
|
},
|
|
4255
4349
|
listRoles: async (actor, filter) => {
|
|
@@ -4274,29 +4368,22 @@ export class CloudflareScopeHost {
|
|
|
4274
4368
|
assignRole: async (actor, assignment) => {
|
|
4275
4369
|
// #2430: the platform's door — a role mixing reserved and other keys is refused (K-55).
|
|
4276
4370
|
// At a scope node, read where the checker reads the role, against the scope class's own
|
|
4277
|
-
// reservations (it answers null when it has none). At the tenant node, against this host's
|
|
4278
|
-
|
|
4279
|
-
|
|
4280
|
-
|
|
4281
|
-
|
|
4282
|
-
|
|
4283
|
-
|
|
4284
|
-
|
|
4285
|
-
|
|
4286
|
-
|
|
4287
|
-
}
|
|
4288
|
-
const subject = `principal:${assignment.principalId}`;
|
|
4289
|
-
if (assignment.node.scopeId) {
|
|
4290
|
-
await this.writeScopeTuple(assignment.node.scopeId, subject, `role:${assignment.roleKey}`, `scope:${assignment.node.scopeId}`, null);
|
|
4291
|
-
}
|
|
4292
|
-
else {
|
|
4293
|
-
await this.cp.writeTenantTuple(assignment.node.tenantId, subject, `role:${assignment.roleKey}`, `tenant:${assignment.node.tenantId}`, null);
|
|
4371
|
+
// reservations (it answers null when it has none). At the tenant node, against this host's,
|
|
4372
|
+
// in the ControlPlaneDO's unit that writes the tuple and its row (#2526).
|
|
4373
|
+
const { tenantId, scopeId } = assignment.node;
|
|
4374
|
+
if (!scopeId) {
|
|
4375
|
+
const assigned = await this.directory('assignTenantRole', tenantId, assignment.principalId, assignment.roleKey, [...this.reservedKeys], this.adminEntry(actor, 'assignRole', { tenantId, scopeId }, null, assignment));
|
|
4376
|
+
if (assigned.refused)
|
|
4377
|
+
throw substratError('forbidden', assigned.refused);
|
|
4378
|
+
// A tenant-level assignment changes the projected set and must fan out.
|
|
4379
|
+
await this.fanOut(tenantId);
|
|
4380
|
+
return;
|
|
4294
4381
|
}
|
|
4295
|
-
await this.
|
|
4296
|
-
|
|
4297
|
-
|
|
4298
|
-
|
|
4299
|
-
|
|
4382
|
+
const refusal = await this.scopeStub(scopeId).reservedRoleRefusalFor(tenantId, assignment.roleKey, 'platform');
|
|
4383
|
+
if (refusal)
|
|
4384
|
+
throw substratError('forbidden', refusal);
|
|
4385
|
+
await this.writeScopeTuple(scopeId, `principal:${assignment.principalId}`, `role:${assignment.roleKey}`, `scope:${scopeId}`, null);
|
|
4386
|
+
await this.recordAdmin(actor, 'assignRole', { tenantId, scopeId }, null, assignment);
|
|
4300
4387
|
},
|
|
4301
4388
|
unassignRole: async (actor, assignment) => {
|
|
4302
4389
|
// Tombstone (K-21) — the checker skips revoked rows. A no-op returns false so
|
|
@@ -4323,12 +4410,18 @@ export class CloudflareScopeHost {
|
|
|
4323
4410
|
// to Z text (#963), and liveness here is a lexicographic `expires_at > ?`.
|
|
4324
4411
|
const grant = capabilityGrant.parse(raw);
|
|
4325
4412
|
await this.refuseReservedGrant([grant.permission], grant.node.scopeId); // #2430
|
|
4326
|
-
|
|
4327
|
-
|
|
4328
|
-
|
|
4329
|
-
|
|
4330
|
-
|
|
4413
|
+
const subject = `principal:${grant.principalId}`;
|
|
4414
|
+
const target = { tenantId: grant.node.tenantId, scopeId: grant.node.scopeId };
|
|
4415
|
+
if (!grant.node.scopeId && !grant.entity) {
|
|
4416
|
+
// #2526: a directory tuple, written with its row in one ControlPlaneDO unit. Tenant-level
|
|
4417
|
+
// → it changes the projected set, so it fans out.
|
|
4418
|
+
await this.directory('writeTenantTuple', grant.node.tenantId, subject, `granted:${grant.permission}`, `tenant:${grant.node.tenantId}`, grant.expiresAt ?? null, this.adminEntry(actor, 'grant', target, null, grant));
|
|
4331
4419
|
await this.fanOut(grant.node.tenantId);
|
|
4420
|
+
return;
|
|
4421
|
+
}
|
|
4422
|
+
// Scope-level and entity grants write scope tuples (already local): no fan-out.
|
|
4423
|
+
await writeGrant(subject, grant.permission, grant.node, grant.entity, grant.expiresAt);
|
|
4424
|
+
await this.recordAdmin(actor, 'grant', target, null, grant);
|
|
4332
4425
|
},
|
|
4333
4426
|
grantEntityShape: async (actor, grant) => {
|
|
4334
4427
|
await this.grantEntityShapeLocal(grant.node.scopeId, grant.principalId, grant.entity, grant.permissions);
|
|
@@ -4378,27 +4471,37 @@ export class CloudflareScopeHost {
|
|
|
4378
4471
|
// fails below is still gathered by the next provision/reconcile — the
|
|
4379
4472
|
// repair channel — instead of vanishing. The tuple stays the only thing
|
|
4380
4473
|
// the permission checker reads; this row is what the platform gathers
|
|
4381
|
-
// from so scopes provisioned AFTER the grant receive it too.
|
|
4382
|
-
|
|
4474
|
+
// from so scopes provisioned AFTER the grant receive it too. #2526: the
|
|
4475
|
+
// record, the checks above made again, the admin row and — at the tenant
|
|
4476
|
+
// node — the tuple are one ControlPlaneDO unit.
|
|
4477
|
+
const { vertical } = await this.directory('grantConnectionInDirectory', {
|
|
4383
4478
|
connectionId: grant.connectionId,
|
|
4384
4479
|
tenantId: grant.node.tenantId,
|
|
4385
|
-
vertical: conn.vertical,
|
|
4386
4480
|
permission: grant.permission,
|
|
4387
4481
|
scopeId: grant.node.scopeId ?? null,
|
|
4388
4482
|
expiresAt: grant.expiresAt ?? null,
|
|
4389
4483
|
grantedBy: grant.grantedBy,
|
|
4390
4484
|
grantedAt: new Date().toISOString(),
|
|
4391
|
-
}
|
|
4485
|
+
}, this.adminEntry(actor, 'grantToConnection', { tenantId: grant.node.tenantId, scopeId: grant.node.scopeId, vertical: conn.vertical }, null, {
|
|
4486
|
+
connectionId: grant.connectionId,
|
|
4487
|
+
provider: conn.provider,
|
|
4488
|
+
permission: grant.permission,
|
|
4489
|
+
node: grant.node,
|
|
4490
|
+
}));
|
|
4491
|
+
if (!grant.node.scopeId) {
|
|
4492
|
+
// A tenant tuple changes the projected set, as its siblings' do: it fans out.
|
|
4493
|
+
await this.fanOut(grant.node.tenantId);
|
|
4494
|
+
return;
|
|
4495
|
+
}
|
|
4392
4496
|
// #574: a scope-level grant is a SCOPE tuple, and the scope's DO lives in the
|
|
4393
4497
|
// deployment serving it — for the shared control plane that is the vertical's
|
|
4394
|
-
// dispatch script, so the tuple write rides the delegation seam.
|
|
4395
|
-
|
|
4396
|
-
if (this.connectorDelegation && grant.node.scopeId) {
|
|
4498
|
+
// dispatch script, so the tuple write rides the delegation seam.
|
|
4499
|
+
if (this.connectorDelegation) {
|
|
4397
4500
|
await this.connectorDelegation.grant({
|
|
4398
4501
|
connectionId: grant.connectionId,
|
|
4399
4502
|
tenantId: grant.node.tenantId,
|
|
4400
4503
|
scopeId: grant.node.scopeId,
|
|
4401
|
-
vertical
|
|
4504
|
+
vertical,
|
|
4402
4505
|
permission: grant.permission,
|
|
4403
4506
|
expiresAt: grant.expiresAt,
|
|
4404
4507
|
});
|
|
@@ -4406,17 +4509,13 @@ export class CloudflareScopeHost {
|
|
|
4406
4509
|
else {
|
|
4407
4510
|
await writeGrant(subjectRef({ kind: 'connection', id: grant.connectionId }), grant.permission, grant.node, undefined, grant.expiresAt);
|
|
4408
4511
|
}
|
|
4409
|
-
await this.recordAdmin(actor, 'grantToConnection', { tenantId: grant.node.tenantId, scopeId: grant.node.scopeId, vertical: conn.vertical }, null, {
|
|
4410
|
-
connectionId: grant.connectionId,
|
|
4411
|
-
provider: conn.provider,
|
|
4412
|
-
permission: grant.permission,
|
|
4413
|
-
node: grant.node,
|
|
4414
|
-
});
|
|
4415
4512
|
},
|
|
4416
4513
|
grantToSystem: async (actor, raw) => {
|
|
4417
4514
|
// The scheduler's grant (#383) — mirror of grantToConnection. Narrow: one
|
|
4418
4515
|
// module, one permission; tombstones on revoke; shows in the permission diff.
|
|
4419
4516
|
const grant = systemGrant.parse(raw);
|
|
4517
|
+
const target = { tenantId: grant.node.tenantId, scopeId: grant.node.scopeId };
|
|
4518
|
+
const after = { moduleId: grant.moduleId, permission: grant.permission, node: grant.node };
|
|
4420
4519
|
if (grant.node.scopeId) {
|
|
4421
4520
|
// #1666: refused while the module is switched off on this scope — checked and
|
|
4422
4521
|
// written in one DO unit. Restore is the lever; a grant is not.
|
|
@@ -4424,18 +4523,17 @@ export class CloudflareScopeHost {
|
|
|
4424
4523
|
if (!written) {
|
|
4425
4524
|
throw substratError('conflict', systemSwitchedOffMessage(grant.moduleId, grant.node.scopeId));
|
|
4426
4525
|
}
|
|
4526
|
+
await this.recordAdmin(actor, 'grantToSystem', target, null, after);
|
|
4527
|
+
return;
|
|
4427
4528
|
}
|
|
4428
|
-
|
|
4429
|
-
|
|
4430
|
-
|
|
4431
|
-
|
|
4432
|
-
|
|
4433
|
-
|
|
4434
|
-
}
|
|
4529
|
+
// #1743: refused while the record holds the module off on any scope — checked and
|
|
4530
|
+
// written in one control-plane call, where the record and the tenant tuple both live,
|
|
4531
|
+
// and (#2526) the admin row with them.
|
|
4532
|
+
const off = await this.directory('writeTenantSystemGrant', grant.node.tenantId, grant.moduleId, `granted:${grant.permission}`, grant.expiresAt ?? null, this.adminEntry(actor, 'grantToSystem', target, null, after));
|
|
4533
|
+
if (off.length > 0) {
|
|
4534
|
+
throw substratError('conflict', tenantSystemSwitchedOffMessage(grant.moduleId, off));
|
|
4435
4535
|
}
|
|
4436
|
-
await this.
|
|
4437
|
-
if (!grant.node.scopeId)
|
|
4438
|
-
await this.fanOut(grant.node.tenantId);
|
|
4536
|
+
await this.fanOut(grant.node.tenantId);
|
|
4439
4537
|
},
|
|
4440
4538
|
// #1666: the schedule kill switch and its lever back — `system-switch.ts` is the
|
|
4441
4539
|
// whole rule, shared with the pure adapter; this is the directory check, the reach
|
|
@@ -4523,10 +4621,16 @@ export class CloudflareScopeHost {
|
|
|
4523
4621
|
// applied, resolves for nobody, and still shows up in the permission diff.
|
|
4524
4622
|
await requireOrg(node.tenantId, orgId);
|
|
4525
4623
|
await this.refuseReservedGrant([permission], node.scopeId); // #2430: an org's members are people
|
|
4526
|
-
|
|
4527
|
-
|
|
4528
|
-
if (!node.scopeId)
|
|
4624
|
+
const target = { tenantId: node.tenantId, scopeId: node.scopeId };
|
|
4625
|
+
const after = { orgId, permission, node, entity };
|
|
4626
|
+
if (!node.scopeId && !entity) {
|
|
4627
|
+
// #2526: the org checked again, the tuple and its row, in one ControlPlaneDO unit.
|
|
4628
|
+
await this.directory('writeOrgTenantTuple', node.tenantId, orgId, `org:${orgId}`, `granted:${permission}`, `tenant:${node.tenantId}`, null, this.adminEntry(actor, 'grantToOrg', target, null, after));
|
|
4529
4629
|
await this.fanOut(node.tenantId);
|
|
4630
|
+
return;
|
|
4631
|
+
}
|
|
4632
|
+
await writeGrant(`org:${orgId}`, permission, node, entity);
|
|
4633
|
+
await this.recordAdmin(actor, 'grantToOrg', target, null, after);
|
|
4530
4634
|
},
|
|
4531
4635
|
// -- vertical + version registry (#31) ---------------------------------
|
|
4532
4636
|
// -- the hostname map (K-26) -------------------------------------------
|
|
@@ -4705,7 +4809,9 @@ export class CloudflareScopeHost {
|
|
|
4705
4809
|
// Lands PENDING — a push is not a deploy — except for a PRIVATE vertical
|
|
4706
4810
|
// (tenant-owned, not listed), whose blast radius is its own tenant: there the
|
|
4707
4811
|
// sandbox contract is the gate and the version self-admits, noted so the
|
|
4708
|
-
// publish seam can tell a staff vouch from this shortcut.
|
|
4812
|
+
// publish seam can tell a staff vouch from this shortcut. The DO judges it again on the
|
|
4813
|
+
// vertical as the insert's unit reads it, and that is what the row and the version carry
|
|
4814
|
+
// (#2528); this read answers an older DO, which takes what it is sent.
|
|
4709
4815
|
const selfAdmits = owning.owner_tenant !== null && !owning.listed;
|
|
4710
4816
|
// The manifest is retained for the serving upload (#286), not audited — a whole
|
|
4711
4817
|
// manifest per publish would drown the admin log in bundle metadata.
|
|
@@ -4751,67 +4857,33 @@ export class CloudflareScopeHost {
|
|
|
4751
4857
|
await this.recordAccess(actor, 'getVersion', {}, { versionId, verticalSlug }, hit ? 1 : 0);
|
|
4752
4858
|
return hit ? mapVersion(hit) : undefined;
|
|
4753
4859
|
},
|
|
4860
|
+
// #2528: each vertical write below is one DO call that reads the row's `before`, and runs its
|
|
4861
|
+
// guard, in the unit that writes; it refuses an unknown vertical with not_found.
|
|
4754
4862
|
setVerticalListed: async (actor, slug, listed) => {
|
|
4755
|
-
|
|
4756
|
-
|
|
4757
|
-
|
|
4758
|
-
//
|
|
4759
|
-
|
|
4760
|
-
// prod version has never been read by anyone but its author.
|
|
4761
|
-
if (listed) {
|
|
4762
|
-
const prod = await this.cp.readChannel(slug, 'prod');
|
|
4763
|
-
const prodVersion = prod ? await this.cp.readVersion(prod.version_id) : undefined;
|
|
4764
|
-
if (prodVersion?.admission_note === AUTO_ADMISSION_NOTE) {
|
|
4765
|
-
throw substratError('conflict', `vertical '${slug}' prod version ${prodVersion.id} is auto-admitted (private self-serve) — ` +
|
|
4766
|
-
`a staff admit must vouch for it before listing`);
|
|
4767
|
-
}
|
|
4768
|
-
}
|
|
4769
|
-
await this.cp.updateVerticalListed(slug, listed ? 1 : 0, // also resolves any pending request
|
|
4770
|
-
this.adminEntry(actor, 'setVerticalListed', { tenantId: null }, { listed: !!existing.listed }, { listed }));
|
|
4863
|
+
// Listing is the moment other tenants start trusting this code, so the version they
|
|
4864
|
+
// would install must carry a real staff vouch — an auto-admitted prod version has never
|
|
4865
|
+
// been read by anyone but its author. The DO refuses one; it also resolves any pending
|
|
4866
|
+
// publish request.
|
|
4867
|
+
await this.directory('updateVerticalListed', slug, listed ? 1 : 0, this.adminEntry(actor, 'setVerticalListed', { tenantId: null }, null, { listed }));
|
|
4771
4868
|
},
|
|
4772
4869
|
requestPublish: async (actor, slug) => {
|
|
4773
|
-
|
|
4774
|
-
if (!existing)
|
|
4775
|
-
throw substratError('not_found', `unknown vertical '${slug}'`);
|
|
4776
|
-
await this.cp.updateVerticalPublishRequest(slug, new Date().toISOString(), this.adminEntry(actor, 'requestPublish', { tenantId: null }, null, { slug }));
|
|
4870
|
+
await this.directory('updateVerticalPublishRequest', slug, new Date().toISOString(), this.adminEntry(actor, 'requestPublish', { tenantId: null }, null, { slug }));
|
|
4777
4871
|
},
|
|
4778
4872
|
setVerticalInstallsBlocked: async (actor, slug, blocked) => {
|
|
4779
|
-
|
|
4780
|
-
if (!existing)
|
|
4781
|
-
throw substratError('not_found', `unknown vertical '${slug}'`);
|
|
4782
|
-
await this.cp.updateVerticalInstallsBlocked(slug, blocked ? 1 : 0, this.adminEntry(actor, 'setVerticalInstallsBlocked', { tenantId: null }, { installsBlocked: !!existing.installs_blocked }, { installsBlocked: blocked }));
|
|
4873
|
+
await this.directory('updateVerticalInstallsBlocked', slug, blocked ? 1 : 0, this.adminEntry(actor, 'setVerticalInstallsBlocked', { tenantId: null }, null, { installsBlocked: blocked }));
|
|
4783
4874
|
},
|
|
4784
4875
|
setVerticalTenantProvisioner: async (actor, slug, granted) => {
|
|
4785
|
-
|
|
4786
|
-
if (!existing)
|
|
4787
|
-
throw substratError('not_found', `unknown vertical '${slug}'`);
|
|
4788
|
-
await this.cp.updateVerticalTenantProvisioner(slug, granted ? 1 : 0, this.adminEntry(actor, 'setVerticalTenantProvisioner', { tenantId: null }, { tenantProvisioner: !!existing.tenant_provisioner }, { tenantProvisioner: granted }));
|
|
4876
|
+
await this.directory('updateVerticalTenantProvisioner', slug, granted ? 1 : 0, this.adminEntry(actor, 'setVerticalTenantProvisioner', { tenantId: null }, null, { tenantProvisioner: granted }));
|
|
4789
4877
|
},
|
|
4790
4878
|
setVerticalEmailSender: async (actor, slug, granted) => {
|
|
4791
|
-
|
|
4792
|
-
if (!existing)
|
|
4793
|
-
throw substratError('not_found', `unknown vertical '${slug}'`);
|
|
4794
|
-
await this.cp.updateVerticalEmailSender(slug, granted ? 1 : 0, this.adminEntry(actor, 'setVerticalEmailSender', { tenantId: null }, { emailSender: !!existing.email_sender }, { emailSender: granted }));
|
|
4879
|
+
await this.directory('updateVerticalEmailSender', slug, granted ? 1 : 0, this.adminEntry(actor, 'setVerticalEmailSender', { tenantId: null }, null, { emailSender: granted }));
|
|
4795
4880
|
},
|
|
4796
4881
|
deleteVertical: async (actor, slug) => {
|
|
4797
|
-
|
|
4798
|
-
|
|
4799
|
-
|
|
4800
|
-
//
|
|
4801
|
-
|
|
4802
|
-
// blocks — unarchive can bring it back — but the refusal names reap/restore, not
|
|
4803
|
-
// "delete", because the app itself is already gone. `reaped` is terminal history and
|
|
4804
|
-
// never blocks. Deployed dispatch scripts are NOT reaped here — they become orphans
|
|
4805
|
-
// for the cleanup script (#248).
|
|
4806
|
-
const bound = await this.cp.countScopesForVertical(slug);
|
|
4807
|
-
if (bound.live > 0) {
|
|
4808
|
-
throw substratError('conflict', `vertical '${slug}' still backs ${bound.live} scope(s) — delete or rebind them first`);
|
|
4809
|
-
}
|
|
4810
|
-
if (bound.archived > 0) {
|
|
4811
|
-
throw substratError('conflict', `vertical '${slug}' still backs ${bound.archived} archived scope(s) — reap or restore them first`);
|
|
4812
|
-
}
|
|
4813
|
-
// #2501: the period's `until` is read here, on the clock its `since` was.
|
|
4814
|
-
await this.cp.deleteVertical(slug, this.adminEntry(actor, 'deleteVertical', { tenantId: null }, { slug, source: existing.source, ownerTenant: existing.owner_tenant }, null), verticalOwnerNow());
|
|
4882
|
+
// Refused while any restorable scope is bound (the DO counts them in the delete's unit).
|
|
4883
|
+
// `reaped` is terminal history and never blocks. Deployed dispatch scripts are NOT reaped
|
|
4884
|
+
// here — they become orphans for the cleanup script (#248). #2501: the period's `until`
|
|
4885
|
+
// is read here, on the clock its `since` was.
|
|
4886
|
+
await this.directory('deleteVertical', slug, this.adminEntry(actor, 'deleteVertical', { tenantId: null }, null, null), verticalOwnerNow());
|
|
4815
4887
|
},
|
|
4816
4888
|
listVerticalOwners: async (actor, slug) => {
|
|
4817
4889
|
const periods = (await this.cp.listVerticalOwners(slug)).map((p) => verticalOwnerPeriod.parse(p));
|
|
@@ -4819,21 +4891,9 @@ export class CloudflareScopeHost {
|
|
|
4819
4891
|
return periods;
|
|
4820
4892
|
},
|
|
4821
4893
|
admitVersion: async (actor, versionId) => {
|
|
4822
|
-
|
|
4823
|
-
|
|
4824
|
-
|
|
4825
|
-
if (v.admission === 'admitted') {
|
|
4826
|
-
// Idempotent — except an AUTO-admitted version, which this upgrades to a
|
|
4827
|
-
// manual vouch by clearing the note (what the publish seam requires).
|
|
4828
|
-
if (v.admission_note !== AUTO_ADMISSION_NOTE)
|
|
4829
|
-
return;
|
|
4830
|
-
await this.cp.setAdmission(versionId, 'admitted', null, this.adminEntry(actor, 'admitVersion', { tenantId: null }, { admission: v.admission, note: v.admission_note }, { admission: 'admitted', note: null }));
|
|
4831
|
-
return;
|
|
4832
|
-
}
|
|
4833
|
-
if (v.admission === 'rejected') {
|
|
4834
|
-
throw substratError('conflict', `version ${versionId} was rejected — publish a new one`);
|
|
4835
|
-
}
|
|
4836
|
-
await this.cp.setAdmission(versionId, 'admitted', null, this.adminEntry(actor, 'admitVersion', { tenantId: null }, { admission: v.admission }, { admission: 'admitted' }));
|
|
4894
|
+
// Judged in the DO's unit (#2528): idempotent on an admitted version, except an AUTO-admitted
|
|
4895
|
+
// one, which it upgrades to a manual vouch; a rejected one is refused. The DO fills the row.
|
|
4896
|
+
await this.directory('admitVersion', versionId, this.adminEntry(actor, 'admitVersion', { tenantId: null }, null, null));
|
|
4837
4897
|
},
|
|
4838
4898
|
promotionImpact: async (actor, verticalSlug, channel, versionId) => {
|
|
4839
4899
|
const incoming = await this.cp.readVersion(versionId);
|
|
@@ -4849,15 +4909,8 @@ export class CloudflareScopeHost {
|
|
|
4849
4909
|
return breaks;
|
|
4850
4910
|
},
|
|
4851
4911
|
rejectVersion: async (actor, versionId, note) => {
|
|
4852
|
-
|
|
4853
|
-
|
|
4854
|
-
throw substratError('not_found', `unknown version ${versionId}`);
|
|
4855
|
-
if (v.admission === 'admitted') {
|
|
4856
|
-
throw substratError('conflict', `version ${versionId} is already admitted — it may be bound`);
|
|
4857
|
-
}
|
|
4858
|
-
if (v.admission === 'rejected')
|
|
4859
|
-
return;
|
|
4860
|
-
await this.cp.setAdmission(versionId, 'rejected', note, this.adminEntry(actor, 'rejectVersion', { tenantId: null }, { admission: v.admission }, { admission: 'rejected', note }));
|
|
4912
|
+
// Judged in the DO's unit (#2528): an admitted version is refused, a rejected one is a no-op.
|
|
4913
|
+
await this.directory('rejectVersion', versionId, note, this.adminEntry(actor, 'rejectVersion', { tenantId: null }, null, { admission: 'rejected', note }));
|
|
4861
4914
|
},
|
|
4862
4915
|
promoteVersion: async (actor, verticalSlug, channel, versionId, acknowledge) => {
|
|
4863
4916
|
const incoming = await this.cp.readVersion(versionId);
|
|
@@ -5134,14 +5187,16 @@ export class CloudflareScopeHost {
|
|
|
5134
5187
|
await this.assertNoStrandFrom([r.serving_version_id], incoming.id, incoming, r.serving_ref ? await this.cp.provisionedVersionsOf(verticalSlug, { ref: r.serving_ref, versionId: r.serving_version_id }, null) : []);
|
|
5135
5188
|
}
|
|
5136
5189
|
}
|
|
5137
|
-
|
|
5190
|
+
// #2528: a compare-and-set on the serving ref and version the gate above judged, across
|
|
5191
|
+
// awaits another serve can run in. The DO fills the row's `before` from the state it moves.
|
|
5192
|
+
const moved = await this.directory('setVerticalServing', verticalSlug, {
|
|
5138
5193
|
ref: parsed.ref,
|
|
5139
5194
|
versionId: parsed.versionId,
|
|
5140
5195
|
doClassesJson: JSON.stringify(parsed.doClasses),
|
|
5141
5196
|
migrationTag: parsed.migrationTag,
|
|
5142
|
-
}, this.adminEntry(actor, 'setVerticalServing', { tenantId: null }, r.serving_ref
|
|
5143
|
-
|
|
5144
|
-
|
|
5197
|
+
}, this.adminEntry(actor, 'setVerticalServing', { tenantId: null }, null, { vertical: verticalSlug, ref: parsed.ref, versionId: parsed.versionId }), { ref: r.serving_ref ?? null, versionId: r.serving_version_id ?? null });
|
|
5198
|
+
if (!moved)
|
|
5199
|
+
throw servingRaceRefusal(verticalSlug);
|
|
5145
5200
|
},
|
|
5146
5201
|
versionManifest: async (actor, verticalSlug, versionId) => {
|
|
5147
5202
|
const v = await this.cp.readVersion(versionId);
|
|
@@ -5161,7 +5216,10 @@ export class CloudflareScopeHost {
|
|
|
5161
5216
|
},
|
|
5162
5217
|
recordWireAuth: async (actor, input) => {
|
|
5163
5218
|
const row = deploymentWireAuth.parse(input);
|
|
5164
|
-
await this.cp.recordWireAuth(row, this.adminEntry(actor, 'recordWireAuth', { tenantId: null, vertical: row.verticalSlug }, null, { vertical: row.verticalSlug, deploymentRef: row.deploymentRef, versionId: row.versionId, record: row.record }));
|
|
5219
|
+
const written = await this.cp.recordWireAuth(row, this.adminEntry(actor, 'recordWireAuth', { tenantId: null, vertical: row.verticalSlug }, null, { vertical: row.verticalSlug, deploymentRef: row.deploymentRef, versionId: row.versionId, record: row.record }));
|
|
5220
|
+
// Thrown here, not in the directory's object, so the code survives the RPC.
|
|
5221
|
+
if (!written)
|
|
5222
|
+
throw substratError('not_found', `no capability record for '${row.deploymentRef}': vertical '${row.verticalSlug}' is not registered`);
|
|
5165
5223
|
},
|
|
5166
5224
|
listWireAuth: async (actor, input) => {
|
|
5167
5225
|
// Judged here, before the call into the directory's object, so a refusal keeps its code; the
|
|
@@ -5262,10 +5320,8 @@ export class CloudflareScopeHost {
|
|
|
5262
5320
|
},
|
|
5263
5321
|
createOrg: async (actor, input) => {
|
|
5264
5322
|
const parsed = createOrgInput.parse(input);
|
|
5265
|
-
|
|
5266
|
-
|
|
5267
|
-
return; // idempotent, and a no-op is not audited
|
|
5268
|
-
await this.recordAdmin(actor, 'createOrg', { tenantId: parsed.tenantId }, null, parsed);
|
|
5323
|
+
// #2526: the org and its row in one unit; an org that exists already writes neither.
|
|
5324
|
+
await this.directory('insertOrg', parsed.id, parsed.tenantId, parsed.slug, parsed.name, new Date().toISOString(), this.adminEntry(actor, 'createOrg', { tenantId: parsed.tenantId }, null, parsed));
|
|
5269
5325
|
},
|
|
5270
5326
|
listOrgs: async (actor, tenantId) => {
|
|
5271
5327
|
const orgs = (await this.cp.listOrgs(tenantId)).map(mapOrg);
|
|
@@ -5278,10 +5334,9 @@ export class CloudflareScopeHost {
|
|
|
5278
5334
|
return r ? mapOrg(r) : undefined;
|
|
5279
5335
|
},
|
|
5280
5336
|
addMember: async (actor, tenantId, principal, orgId, opts) => {
|
|
5281
|
-
await requireOrg(tenantId, orgId);
|
|
5282
5337
|
const expiresAt = opts?.expiresAt ?? null;
|
|
5283
|
-
|
|
5284
|
-
await this.
|
|
5338
|
+
// #2526: the org's check, the tuple and its row, in one ControlPlaneDO unit.
|
|
5339
|
+
await this.directory('writeOrgTenantTuple', tenantId, orgId, `principal:${principal}`, 'member', `org:${orgId}`, expiresAt, this.adminEntry(actor, 'addMember', { tenantId }, null, memberAddedAudit(principal, orgId, expiresAt)));
|
|
5285
5340
|
await this.fanOut(tenantId); // membership is a tenant-level tuple
|
|
5286
5341
|
},
|
|
5287
5342
|
applyMembership: async (actor, change) => {
|
|
@@ -5458,6 +5513,9 @@ export class CloudflareScopeHost {
|
|
|
5458
5513
|
recordStoreDriverRebuildRun: async (actor, entry) => {
|
|
5459
5514
|
await this.recordAdmin(actor, 'rebuildStoreDriver', { tenantId: null }, null, entry);
|
|
5460
5515
|
},
|
|
5516
|
+
recordWireWalk: async (actor, entry) => {
|
|
5517
|
+
await this.recordAdmin(actor, 'walkWireAuth', { tenantId: null }, null, entry);
|
|
5518
|
+
},
|
|
5461
5519
|
listScopeTables: async (actor, tenantId, scopeId) => {
|
|
5462
5520
|
// K-3 cross-check on the shared directory BEFORE reaching the scope DO: a pair
|
|
5463
5521
|
// that does not resolve is unreachable, never another tenant's database.
|
|
@@ -7684,9 +7742,11 @@ export class CloudflareScopeHost {
|
|
|
7684
7742
|
throw substratError('forbidden', `the owner's seat: ${ownerRefusal}`);
|
|
7685
7743
|
const connections = await this.seatableConnectionGrants(input.tenantId, input.scopeId, input.connectionGrants);
|
|
7686
7744
|
const services = await this.servicePrincipals?.(input.tenantId, input.scopeId);
|
|
7687
|
-
const stub = this.scopeStub(input.scopeId);
|
|
7688
7745
|
await this.migrateAndRecord(input.scopeId); // create the module tables (setMigrationState no-ops on a null CP)
|
|
7689
|
-
|
|
7746
|
+
// #2531: judged again in the projection's own unit, on the roles and seats the scope holds as it
|
|
7747
|
+
// writes, so a seat made since the pre-check above is seen. The module migrations above have
|
|
7748
|
+
// already run when that refuses; they are this host's own setup, and the projection writes nothing.
|
|
7749
|
+
const switchedOff = await this.projectJudged(input.tenantId, input.scopeId, projectedRoleRows(input.roles), [], // no tenant-level tuples — a CP-less vertical grants at scope level only
|
|
7690
7750
|
// Only PROJECT (and thereby switch on strict enforcement) when the platform actually
|
|
7691
7751
|
// supplies entitlements. Omitting them leaves the scope un-projected and trusting-
|
|
7692
7752
|
// upstream, so a vertical whose provision path predates #304 is never denied — it
|
|
@@ -7746,7 +7806,7 @@ export class CloudflareScopeHost {
|
|
|
7746
7806
|
: undefined,
|
|
7747
7807
|
// #1742: the recorded-off modules and (#2029) peers, switched off after the seat in the
|
|
7748
7808
|
// same unit, on the scope this call provisions — the one the seat's tuples name.
|
|
7749
|
-
carry ? { scopeId: input.scopeId, at: new Date().toISOString(), ...carry } : undefined, services?.map((id) => `principal:${id}`))
|
|
7809
|
+
carry ? { scopeId: input.scopeId, at: new Date().toISOString(), ...carry } : undefined, services?.map((id) => `principal:${id}`));
|
|
7750
7810
|
const shapes = input.entityGrants
|
|
7751
7811
|
? await this.topUpEntityGrantShapesLocal(input.tenantId, input.scopeId, input.entityGrants)
|
|
7752
7812
|
: null;
|
|
@@ -7825,12 +7885,36 @@ export class CloudflareScopeHost {
|
|
|
7825
7885
|
* which replaces whatever the scope stores now, so what the set is judged against is the DUMP's
|
|
7826
7886
|
* roles and seats for `dump.tenantId` (`dumpRoleState`), when the caller has the dump in hand. A
|
|
7827
7887
|
* staged restore has not (its pages are already in the scope), so it is judged as a set alone
|
|
7828
|
-
* here
|
|
7888
|
+
* here. The load given the vertical's `roles` judges again in its own transaction, against what
|
|
7889
|
+
* the dump brought (#2531), and `projectRolesLocal` does in its unit where the load did not.
|
|
7829
7890
|
*/
|
|
7830
7891
|
async roleSetRefusalLocal(scopeId, roles, dump) {
|
|
7831
7892
|
const here = await this.scopeStub(scopeId).reservationHere();
|
|
7832
7893
|
return reservedRoleSetRefusal(roles, { reserved: new Set([...this.reservedKeys, ...here.reserved]), declared: new Set([...this.declaredKeys, ...here.declared]) }, dump ? dumpRoleState(dump.tables, dump.tenantId, new Date().toISOString()) : undefined);
|
|
7833
7894
|
}
|
|
7895
|
+
/**
|
|
7896
|
+
* #2555: refuse loading `dump` into a scope of `tenantId` (`dumpRoleRefusal`): the tenant's roles as
|
|
7897
|
+
* the directory stores them now, against the dump's role rows and seats, with this host's
|
|
7898
|
+
* reservations joined with the scope class's. The class is read at `scopeId` when the scope exists
|
|
7899
|
+
* (a restore), else at a live scope of the tenant (an import, whose scope is not created yet: asking
|
|
7900
|
+
* its object would create storage for a scope a refusal leaves unmade).
|
|
7901
|
+
*
|
|
7902
|
+
* Two stated residuals. The directory is read here and the dump is loaded in the scope's own call,
|
|
7903
|
+
* so a role defined between the two is not judged against the dump's seats. And an import for a
|
|
7904
|
+
* tenant with no live scope reads no class, so it is judged with this host's reservations alone: the
|
|
7905
|
+
* seat rule holds without them, and a key only the class reserves is not counted.
|
|
7906
|
+
*/
|
|
7907
|
+
async refuseDumpRoles(tenantId, scopeId, dump) {
|
|
7908
|
+
const classScope = scopeId ?? (await this.cp.listScopes({ tenantId, status: ['active', 'suspended'], limit: 1 }))[0]?.scope_id;
|
|
7909
|
+
const [rows, here] = await Promise.all([
|
|
7910
|
+
this.cp.listRoles({ tenantId }),
|
|
7911
|
+
classScope ? this.scopeStub(classScope).reservationHere() : { reserved: [], declared: [] },
|
|
7912
|
+
]);
|
|
7913
|
+
const roles = rows.map((r) => ({ key: r.role_key, permissions: JSON.parse(r.permissions), ...(r.holder ? { holder: r.holder } : {}) }));
|
|
7914
|
+
const refusal = dumpRoleRefusal(roles, { reserved: new Set([...this.reservedKeys, ...here.reserved]), declared: new Set([...this.declaredKeys, ...here.declared]) }, dump, new Date().toISOString());
|
|
7915
|
+
if (refusal)
|
|
7916
|
+
throw substratError('forbidden', refusal);
|
|
7917
|
+
}
|
|
7834
7918
|
/**
|
|
7835
7919
|
* #2446: the roles `scopeId` stores for `tenantId` and the role keys anyone but a system principal
|
|
7836
7920
|
* holds a live seat in there: what `refuseReservedRoleSet` judges a reconcile's role set against
|
|
@@ -7846,15 +7930,18 @@ export class CloudflareScopeHost {
|
|
|
7846
7930
|
* and the roles the scope stores for the tenant now with their seats there. Answers the reserved
|
|
7847
7931
|
* keys it judged with, for the seats the same call makes.
|
|
7848
7932
|
*
|
|
7849
|
-
*
|
|
7850
|
-
*
|
|
7851
|
-
*
|
|
7933
|
+
* The scope state is read in one RPC (`roleStateHere`) and written in another, so this is the
|
|
7934
|
+
* first check, for its detailed wording; the write judges the set again inside its own unit
|
|
7935
|
+
* (`projectJudged`, #2531), where a seat made between the two is seen.
|
|
7852
7936
|
*/
|
|
7853
7937
|
async refuseReservedRoleSet(roles, tenantId, scopeId) {
|
|
7854
7938
|
const stub = this.scopeStub(scopeId);
|
|
7855
7939
|
const [here, state] = await Promise.all([stub.reservationHere(), stub.roleStateHere(tenantId)]);
|
|
7856
7940
|
const reserved = new Set([...this.reservedKeys, ...here.reserved]);
|
|
7857
|
-
const refusal = reservedRoleSetRefusal(roles, { reserved, declared: new Set([...this.declaredKeys, ...here.declared]) },
|
|
7941
|
+
const refusal = reservedRoleSetRefusal(roles, { reserved, declared: new Set([...this.declaredKeys, ...here.declared]) },
|
|
7942
|
+
// #2531: with the tombstones the scope keeps, so a system-held role a release dropped is judged
|
|
7943
|
+
// as the stored definition it was. A ScopeDO from before them answers none.
|
|
7944
|
+
{ roles: state.roles, seated: new Set(state.seated), ...(state.dropped ? { dropped: state.dropped } : {}) });
|
|
7858
7945
|
// Named by tenant and scope as well as role: a release push admitted can be refused here at every
|
|
7859
7946
|
// scope where the role is seated, and staff find each one by this line (K-55).
|
|
7860
7947
|
if (refusal)
|
|
@@ -8137,6 +8224,17 @@ export class CloudflareScopeHost {
|
|
|
8137
8224
|
* so a caller falls back rather than failing a dispatch over a missing file.
|
|
8138
8225
|
*/
|
|
8139
8226
|
async connectorAttachmentOpenLocal(connectionId, tenantId, scopeId, attachmentId, eventId) {
|
|
8227
|
+
return (await this.connectorReadSurface(connectionId, tenantId, scopeId, eventId)).open(attachmentId);
|
|
8228
|
+
}
|
|
8229
|
+
/**
|
|
8230
|
+
* `connectorAttachmentOpenLocal`, with the bytes as a stream read from R2 as the platform reads
|
|
8231
|
+
* the response (#2514). The same gates, in the same order, before the bucket is touched.
|
|
8232
|
+
*/
|
|
8233
|
+
async connectorAttachmentOpenStreamLocal(connectionId, tenantId, scopeId, attachmentId, eventId) {
|
|
8234
|
+
return (await this.connectorReadSurface(connectionId, tenantId, scopeId, eventId)).openStream(attachmentId);
|
|
8235
|
+
}
|
|
8236
|
+
/** The attachment surface the platform's reads as a connection go through, behind their gates. */
|
|
8237
|
+
async connectorReadSurface(connectionId, tenantId, scopeId, eventId) {
|
|
8140
8238
|
// Same gate as the upload leg: a held scope's bytes are not read out of its bucket.
|
|
8141
8239
|
await this.assertLive(tenantId, scopeId);
|
|
8142
8240
|
await this.migrateAndRecord(scopeId);
|
|
@@ -8144,7 +8242,7 @@ export class CloudflareScopeHost {
|
|
|
8144
8242
|
return this.buildAttachmentSurface({ connectionId }, tenantId, scopeId, store,
|
|
8145
8243
|
// #726 remedy B: the platform named a delivery; this deployment decides what that
|
|
8146
8244
|
// delivery reaches, from its own spine row. Absent ⇒ the ordinary grant check.
|
|
8147
|
-
eventId === undefined ? undefined : { eventId })
|
|
8245
|
+
eventId === undefined ? undefined : { eventId });
|
|
8148
8246
|
}
|
|
8149
8247
|
/**
|
|
8150
8248
|
* The delivery half of `grantToConnection` for a scope served HERE (#574): write
|