@substrat-run/adapter-cloudflare 0.161.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 +55 -6
- package/dist/control-plane-do.d.ts.map +1 -1
- package/dist/control-plane-do.js +123 -11
- package/dist/control-plane-do.js.map +1 -1
- package/dist/host.d.ts +35 -16
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +134 -79
- package/dist/host.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 +217 -115
- package/dist/scope-do.js.map +1 -1
- package/package.json +9 -9
package/dist/host.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
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
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';
|
|
@@ -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');
|
|
@@ -1124,7 +1128,7 @@ export class CloudflareScopeHost {
|
|
|
1124
1128
|
* #2016: `tenantId`, the tenant the platform restores for: recorded as the scope's receipt in
|
|
1125
1129
|
* the load's own transaction, and a store provisioned for another tenant refuses the load. */
|
|
1126
1130
|
opts) {
|
|
1127
|
-
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));
|
|
1128
1132
|
return { ...out, tables: tables.length };
|
|
1129
1133
|
}
|
|
1130
1134
|
/**
|
|
@@ -1146,7 +1150,9 @@ export class CloudflareScopeHost {
|
|
|
1146
1150
|
}
|
|
1147
1151
|
/** `restoreScopeLocal` from the dump staged under `stageId` (#2450), with the same fences and answers. */
|
|
1148
1152
|
async restoreStagedScopeLocal(scopeId, stageId, opts) {
|
|
1149
|
-
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));
|
|
1150
1156
|
}
|
|
1151
1157
|
/** Drop the dump staged under `stageId` (#2450), if it is still there. */
|
|
1152
1158
|
async discardStagedScopeLocal(scopeId, stageId) {
|
|
@@ -1156,6 +1162,11 @@ export class CloudflareScopeHost {
|
|
|
1156
1162
|
async loadLocal(scopeId, opts, run) {
|
|
1157
1163
|
if (opts?.markCopy)
|
|
1158
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;
|
|
1159
1170
|
// #2314: a load replaces the scope's rows, so the orphan sweep is fenced first, as for a rewind.
|
|
1160
1171
|
await this.fenceOrphanSweep(scopeId, opts?.tenantId);
|
|
1161
1172
|
const carry = opts ? recordedOffFromWire(opts) : undefined;
|
|
@@ -1173,14 +1184,22 @@ export class CloudflareScopeHost {
|
|
|
1173
1184
|
...load,
|
|
1174
1185
|
...(opts?.expect ? { expect: opts.expect } : {}),
|
|
1175
1186
|
...(opts?.fence ? { fence: opts.fence } : {}),
|
|
1176
|
-
});
|
|
1187
|
+
}, repair);
|
|
1177
1188
|
if (out.refused === false) {
|
|
1178
|
-
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
|
+
};
|
|
1179
1195
|
}
|
|
1180
1196
|
if (out.refused === 'kept')
|
|
1181
1197
|
throw substratError('conflict', KEPT_COPY_REFUSAL);
|
|
1182
1198
|
if (out.refused === 'tenant')
|
|
1183
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}`);
|
|
1184
1203
|
if (out.refused === 'lapsed')
|
|
1185
1204
|
throw substratError('precondition_failed', COPY_RESTORE_FENCE_LAPSED);
|
|
1186
1205
|
// A retry of a load that already committed (its answer was lost on the way back) is
|
|
@@ -1311,9 +1330,33 @@ export class CloudflareScopeHost {
|
|
|
1311
1330
|
* safe; scope-level tuples (the restored grants) are never touched.
|
|
1312
1331
|
*/
|
|
1313
1332
|
async projectRolesLocal(tenantId, scopeId, roles) {
|
|
1314
|
-
// #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.
|
|
1315
1335
|
await this.refuseReservedRoleSet(roles, tenantId, scopeId);
|
|
1316
|
-
|
|
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
|
+
}
|
|
1317
1360
|
}
|
|
1318
1361
|
/**
|
|
1319
1362
|
* Wipe one scope DO's storage in THIS deployment — the reap half of an orchestrated
|
|
@@ -3762,7 +3805,9 @@ export class CloudflareScopeHost {
|
|
|
3762
3805
|
await this.writeScopeTuple(node.scopeId, subject, `granted:${permission}`, `scope:${node.scopeId}`, expiresAt ?? null);
|
|
3763
3806
|
}
|
|
3764
3807
|
else {
|
|
3765
|
-
|
|
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');
|
|
3766
3811
|
}
|
|
3767
3812
|
};
|
|
3768
3813
|
/**
|
|
@@ -4294,11 +4339,11 @@ export class CloudflareScopeHost {
|
|
|
4294
4339
|
// `defineRoleOrRefusal`, not the legacy `defineRole` an older coordinator reads. A ControlPlaneDO from
|
|
4295
4340
|
// before #2447, met in a deploy's restart window, has no such method, so the call fails loudly rather
|
|
4296
4341
|
// than being misread.
|
|
4297
|
-
|
|
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));
|
|
4298
4345
|
if ('refused' in defined)
|
|
4299
4346
|
throw substratError('forbidden', defined.refused);
|
|
4300
|
-
const before = defined.before;
|
|
4301
|
-
await this.recordAdmin(actor, 'defineRole', { tenantId }, before, parsed);
|
|
4302
4347
|
await this.fanOut(tenantId); // role definitions are projected into the tenant's scopes
|
|
4303
4348
|
},
|
|
4304
4349
|
listRoles: async (actor, filter) => {
|
|
@@ -4323,29 +4368,22 @@ export class CloudflareScopeHost {
|
|
|
4323
4368
|
assignRole: async (actor, assignment) => {
|
|
4324
4369
|
// #2430: the platform's door — a role mixing reserved and other keys is refused (K-55).
|
|
4325
4370
|
// At a scope node, read where the checker reads the role, against the scope class's own
|
|
4326
|
-
// reservations (it answers null when it has none). At the tenant node, against this host's
|
|
4327
|
-
|
|
4328
|
-
|
|
4329
|
-
|
|
4330
|
-
|
|
4331
|
-
|
|
4332
|
-
|
|
4333
|
-
|
|
4334
|
-
|
|
4335
|
-
|
|
4336
|
-
}
|
|
4337
|
-
const subject = `principal:${assignment.principalId}`;
|
|
4338
|
-
if (assignment.node.scopeId) {
|
|
4339
|
-
await this.writeScopeTuple(assignment.node.scopeId, subject, `role:${assignment.roleKey}`, `scope:${assignment.node.scopeId}`, null);
|
|
4340
|
-
}
|
|
4341
|
-
else {
|
|
4342
|
-
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;
|
|
4343
4381
|
}
|
|
4344
|
-
await this.
|
|
4345
|
-
|
|
4346
|
-
|
|
4347
|
-
|
|
4348
|
-
|
|
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);
|
|
4349
4387
|
},
|
|
4350
4388
|
unassignRole: async (actor, assignment) => {
|
|
4351
4389
|
// Tombstone (K-21) — the checker skips revoked rows. A no-op returns false so
|
|
@@ -4372,12 +4410,18 @@ export class CloudflareScopeHost {
|
|
|
4372
4410
|
// to Z text (#963), and liveness here is a lexicographic `expires_at > ?`.
|
|
4373
4411
|
const grant = capabilityGrant.parse(raw);
|
|
4374
4412
|
await this.refuseReservedGrant([grant.permission], grant.node.scopeId); // #2430
|
|
4375
|
-
|
|
4376
|
-
|
|
4377
|
-
|
|
4378
|
-
|
|
4379
|
-
|
|
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));
|
|
4380
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);
|
|
4381
4425
|
},
|
|
4382
4426
|
grantEntityShape: async (actor, grant) => {
|
|
4383
4427
|
await this.grantEntityShapeLocal(grant.node.scopeId, grant.principalId, grant.entity, grant.permissions);
|
|
@@ -4427,27 +4471,37 @@ export class CloudflareScopeHost {
|
|
|
4427
4471
|
// fails below is still gathered by the next provision/reconcile — the
|
|
4428
4472
|
// repair channel — instead of vanishing. The tuple stays the only thing
|
|
4429
4473
|
// the permission checker reads; this row is what the platform gathers
|
|
4430
|
-
// from so scopes provisioned AFTER the grant receive it too.
|
|
4431
|
-
|
|
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', {
|
|
4432
4478
|
connectionId: grant.connectionId,
|
|
4433
4479
|
tenantId: grant.node.tenantId,
|
|
4434
|
-
vertical: conn.vertical,
|
|
4435
4480
|
permission: grant.permission,
|
|
4436
4481
|
scopeId: grant.node.scopeId ?? null,
|
|
4437
4482
|
expiresAt: grant.expiresAt ?? null,
|
|
4438
4483
|
grantedBy: grant.grantedBy,
|
|
4439
4484
|
grantedAt: new Date().toISOString(),
|
|
4440
|
-
}
|
|
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
|
+
}
|
|
4441
4496
|
// #574: a scope-level grant is a SCOPE tuple, and the scope's DO lives in the
|
|
4442
4497
|
// deployment serving it — for the shared control plane that is the vertical's
|
|
4443
|
-
// dispatch script, so the tuple write rides the delegation seam.
|
|
4444
|
-
|
|
4445
|
-
if (this.connectorDelegation && grant.node.scopeId) {
|
|
4498
|
+
// dispatch script, so the tuple write rides the delegation seam.
|
|
4499
|
+
if (this.connectorDelegation) {
|
|
4446
4500
|
await this.connectorDelegation.grant({
|
|
4447
4501
|
connectionId: grant.connectionId,
|
|
4448
4502
|
tenantId: grant.node.tenantId,
|
|
4449
4503
|
scopeId: grant.node.scopeId,
|
|
4450
|
-
vertical
|
|
4504
|
+
vertical,
|
|
4451
4505
|
permission: grant.permission,
|
|
4452
4506
|
expiresAt: grant.expiresAt,
|
|
4453
4507
|
});
|
|
@@ -4455,17 +4509,13 @@ export class CloudflareScopeHost {
|
|
|
4455
4509
|
else {
|
|
4456
4510
|
await writeGrant(subjectRef({ kind: 'connection', id: grant.connectionId }), grant.permission, grant.node, undefined, grant.expiresAt);
|
|
4457
4511
|
}
|
|
4458
|
-
await this.recordAdmin(actor, 'grantToConnection', { tenantId: grant.node.tenantId, scopeId: grant.node.scopeId, vertical: conn.vertical }, null, {
|
|
4459
|
-
connectionId: grant.connectionId,
|
|
4460
|
-
provider: conn.provider,
|
|
4461
|
-
permission: grant.permission,
|
|
4462
|
-
node: grant.node,
|
|
4463
|
-
});
|
|
4464
4512
|
},
|
|
4465
4513
|
grantToSystem: async (actor, raw) => {
|
|
4466
4514
|
// The scheduler's grant (#383) — mirror of grantToConnection. Narrow: one
|
|
4467
4515
|
// module, one permission; tombstones on revoke; shows in the permission diff.
|
|
4468
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 };
|
|
4469
4519
|
if (grant.node.scopeId) {
|
|
4470
4520
|
// #1666: refused while the module is switched off on this scope — checked and
|
|
4471
4521
|
// written in one DO unit. Restore is the lever; a grant is not.
|
|
@@ -4473,18 +4523,17 @@ export class CloudflareScopeHost {
|
|
|
4473
4523
|
if (!written) {
|
|
4474
4524
|
throw substratError('conflict', systemSwitchedOffMessage(grant.moduleId, grant.node.scopeId));
|
|
4475
4525
|
}
|
|
4526
|
+
await this.recordAdmin(actor, 'grantToSystem', target, null, after);
|
|
4527
|
+
return;
|
|
4476
4528
|
}
|
|
4477
|
-
|
|
4478
|
-
|
|
4479
|
-
|
|
4480
|
-
|
|
4481
|
-
|
|
4482
|
-
|
|
4483
|
-
}
|
|
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));
|
|
4484
4535
|
}
|
|
4485
|
-
await this.
|
|
4486
|
-
if (!grant.node.scopeId)
|
|
4487
|
-
await this.fanOut(grant.node.tenantId);
|
|
4536
|
+
await this.fanOut(grant.node.tenantId);
|
|
4488
4537
|
},
|
|
4489
4538
|
// #1666: the schedule kill switch and its lever back — `system-switch.ts` is the
|
|
4490
4539
|
// whole rule, shared with the pure adapter; this is the directory check, the reach
|
|
@@ -4572,10 +4621,16 @@ export class CloudflareScopeHost {
|
|
|
4572
4621
|
// applied, resolves for nobody, and still shows up in the permission diff.
|
|
4573
4622
|
await requireOrg(node.tenantId, orgId);
|
|
4574
4623
|
await this.refuseReservedGrant([permission], node.scopeId); // #2430: an org's members are people
|
|
4575
|
-
|
|
4576
|
-
|
|
4577
|
-
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));
|
|
4578
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);
|
|
4579
4634
|
},
|
|
4580
4635
|
// -- vertical + version registry (#31) ---------------------------------
|
|
4581
4636
|
// -- the hostname map (K-26) -------------------------------------------
|
|
@@ -5265,10 +5320,8 @@ export class CloudflareScopeHost {
|
|
|
5265
5320
|
},
|
|
5266
5321
|
createOrg: async (actor, input) => {
|
|
5267
5322
|
const parsed = createOrgInput.parse(input);
|
|
5268
|
-
|
|
5269
|
-
|
|
5270
|
-
return; // idempotent, and a no-op is not audited
|
|
5271
|
-
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));
|
|
5272
5325
|
},
|
|
5273
5326
|
listOrgs: async (actor, tenantId) => {
|
|
5274
5327
|
const orgs = (await this.cp.listOrgs(tenantId)).map(mapOrg);
|
|
@@ -5281,10 +5334,9 @@ export class CloudflareScopeHost {
|
|
|
5281
5334
|
return r ? mapOrg(r) : undefined;
|
|
5282
5335
|
},
|
|
5283
5336
|
addMember: async (actor, tenantId, principal, orgId, opts) => {
|
|
5284
|
-
await requireOrg(tenantId, orgId);
|
|
5285
5337
|
const expiresAt = opts?.expiresAt ?? null;
|
|
5286
|
-
|
|
5287
|
-
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)));
|
|
5288
5340
|
await this.fanOut(tenantId); // membership is a tenant-level tuple
|
|
5289
5341
|
},
|
|
5290
5342
|
applyMembership: async (actor, change) => {
|
|
@@ -7690,9 +7742,11 @@ export class CloudflareScopeHost {
|
|
|
7690
7742
|
throw substratError('forbidden', `the owner's seat: ${ownerRefusal}`);
|
|
7691
7743
|
const connections = await this.seatableConnectionGrants(input.tenantId, input.scopeId, input.connectionGrants);
|
|
7692
7744
|
const services = await this.servicePrincipals?.(input.tenantId, input.scopeId);
|
|
7693
|
-
const stub = this.scopeStub(input.scopeId);
|
|
7694
7745
|
await this.migrateAndRecord(input.scopeId); // create the module tables (setMigrationState no-ops on a null CP)
|
|
7695
|
-
|
|
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
|
|
7696
7750
|
// Only PROJECT (and thereby switch on strict enforcement) when the platform actually
|
|
7697
7751
|
// supplies entitlements. Omitting them leaves the scope un-projected and trusting-
|
|
7698
7752
|
// upstream, so a vertical whose provision path predates #304 is never denied — it
|
|
@@ -7752,7 +7806,7 @@ export class CloudflareScopeHost {
|
|
|
7752
7806
|
: undefined,
|
|
7753
7807
|
// #1742: the recorded-off modules and (#2029) peers, switched off after the seat in the
|
|
7754
7808
|
// same unit, on the scope this call provisions — the one the seat's tuples name.
|
|
7755
|
-
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}`));
|
|
7756
7810
|
const shapes = input.entityGrants
|
|
7757
7811
|
? await this.topUpEntityGrantShapesLocal(input.tenantId, input.scopeId, input.entityGrants)
|
|
7758
7812
|
: null;
|
|
@@ -7831,7 +7885,8 @@ export class CloudflareScopeHost {
|
|
|
7831
7885
|
* which replaces whatever the scope stores now, so what the set is judged against is the DUMP's
|
|
7832
7886
|
* roles and seats for `dump.tenantId` (`dumpRoleState`), when the caller has the dump in hand. A
|
|
7833
7887
|
* staged restore has not (its pages are already in the scope), so it is judged as a set alone
|
|
7834
|
-
* 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.
|
|
7835
7890
|
*/
|
|
7836
7891
|
async roleSetRefusalLocal(scopeId, roles, dump) {
|
|
7837
7892
|
const here = await this.scopeStub(scopeId).reservationHere();
|
|
@@ -7875,9 +7930,9 @@ export class CloudflareScopeHost {
|
|
|
7875
7930
|
* and the roles the scope stores for the tenant now with their seats there. Answers the reserved
|
|
7876
7931
|
* keys it judged with, for the seats the same call makes.
|
|
7877
7932
|
*
|
|
7878
|
-
*
|
|
7879
|
-
*
|
|
7880
|
-
*
|
|
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.
|
|
7881
7936
|
*/
|
|
7882
7937
|
async refuseReservedRoleSet(roles, tenantId, scopeId) {
|
|
7883
7938
|
const stub = this.scopeStub(scopeId);
|