@substrat-run/adapter-cloudflare 0.161.0 → 0.163.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 +119 -15
- package/dist/control-plane-do.d.ts.map +1 -1
- package/dist/control-plane-do.js +292 -41
- package/dist/control-plane-do.js.map +1 -1
- package/dist/host.d.ts +103 -16
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +329 -163
- package/dist/host.js.map +1 -1
- package/dist/scope-do.d.ts +63 -0
- package/dist/scope-do.d.ts.map +1 -1
- package/dist/scope-do.js +307 -124
- package/dist/scope-do.js.map +1 -1
- package/package.json +9 -9
package/dist/scope-do.js
CHANGED
|
@@ -2,7 +2,7 @@ import { REWIND_REFUSED } from './rewind-refusal.js';
|
|
|
2
2
|
import { SYSTEM_DOOR_MOVED } from './system-door.js';
|
|
3
3
|
import { DurableObject } from 'cloudflare:workers';
|
|
4
4
|
import { ATTACHMENT_ADDED, ATTACHMENT_REMOVED, attachmentRecord, domainEvent, domainEventInput, eventId, instant, objectRef, entityObjectRef, assertModuleEmittableType, assertKernelAuthoredType, toWireFailure, grantRefFromProof, principalId, principalBecomeCapabilityInput, scopeId as scopeIdOf, tenantId as tenantIdOf, platformRequestInput, platformRequestId, MAX_PENDING_PLATFORM_REQUESTS, MAX_PENDING_SWEEP_RUNS, SWEEP_RUNS_KIND, SCOPE_DUMP_PAGE_MAX_BYTES, SCOPE_TABLE_PAGE_MAX, SCOPE_QUERY_ROW_MAX, listLimitOf, pageOf, countedPageOf, requestFingerprint, substratError, errorCodeOf, assertReplayableDump, REDRAIN_BATCH, } from '@substrat-run/contracts';
|
|
5
|
-
import { ulid, DO_SQL_LIMITS, unknownRoleError, createUlid, assertAllowed, ConnectionSealingKeyUnavailableError, noSealingKeyMessage, sealTo, assertReadOnlyQuery, entitlementDenial, platformRequestHistoryQuery, platformRequestOf, PLATFORM_REQUEST_COLUMNS, PLATFORM_REQUEST_REDACTION_SQL, platformRequestRedactionParams, platformRequestRedactionQuery, intentPayloadCarriesSubject, redactSubjectJobRuns, eraseSubjectFromModules, moduleErasurePlan, redactSubjectScopeText, assertRowLimit, assertRowOffset, JOB_RUN_PATCH_SQL, JOB_RUN_CLAIM_SQL, JOB_RUN_RENEW_SQL, JOB_RUN_BEGIN_SQL, JOB_RUN_MISS_SQL, JOB_RUN_MISS_SETTLE_SQL, admissionMissOutcome, JOB_LEASE_EXPIRED_NOTE, JOB_STEP_RECORD_SQL, DELIVERY_ERROR_REDACTION_SQL, REDACTED_DELIVERY_NOTE, seatScopeTuple, delegatedRevokeSql, writeExplicitTupleIn, grantEntityShapeIn, topUpEntityGrantShapes, applyScopeRoleChange, changeScopeRole, revokeScopeRoles, scopeRoleHolders, assertModuleDeclared, noteDeclaredKeys, noteReservedKeys, SCOPE_SEATED_ROLES_SQL, reservedGrantRefusal, reservedRoleRefusal, reservedShapeRefusal, effectiveRoleGrantQuery, switchRecordedOff, switchSystemSchedules, peerGrantsStatus, systemGrantsStatus, systemScheduleState, systemSwitchedOff, denialListQuery, refusalListQuery, mapRefusalRow, denialSummaryQuery, denialTotalsQuery, DENIAL_WINDOW_QUERY, mapDenialRow, mapDenialSummaryBuckets, PermissionDenied, assertImpersonationWrites, assertModuleEnqueueableKind, impersonationStampOf, createAtomic, NotSearchable, isSearchIndexTable, searchIndexPlans, NotListable, listIndexPlans, moduleMigrations, MIGRATION_DIGEST_FENCE_DDL, MIGRATION_DIGEST_MARK_LEGACY, assertJournalDumpCoherent, assertMigrationSql, migrationDivergence, migrationFailedError, migrationSetDivergence, migrationSteps, planMigrations, listQuery, cursorOf, searchLimit, searchMatchExpression, searchQuery, ATTACHMENT_TEXT_DDL, ATTACHMENT_BYTES_SQL, ATTACHMENT_CONDEMNED_DDL, ATTACHMENT_SWEEP_LICENCE_DDL, ATTACHMENT_SWEEP_LICENCE_TABLE, clearOrphanSweepLicence, condemnLicensedOrphanAttachments, consumeOrphanSweepLicence, licensedOrphanIds, recordOrphanSweepLicence, refuseUnlicensedOrphanSweep, refuseCondemnedAttachment, refuseCondemnedInDump, rowlessAttachmentIds, attachmentRecordOfRow, enqueueAttachmentText, reconcileAttachmentText, queueAttachmentTextBackfill, startAttachmentTextBackfill, recordAttachmentText, searchAttachments, IDEMPOTENCY_DDL, REFUSALS_TABLE_DDL, REFUSALS_INDEX, refusalInsert, refusalOf, markGuardRefusal, REFUSALS_REBUILD, refusalsAdmitGuards, assertIdempotencyKey, assertPermissionKey, idempotencyLookupQuery, idempotencyPruneStatement, idempotencyRecordStatement, idempotencyOptedOutMessage, replayFor, entityVersionQuery, entityVersionOf, assertIfMatch, OUTBOX_ENTITY_INDEX, SCHEDULE_STATE_DDL, SCHEDULE_STATE_REBUILD, scheduleStateHasKind, VERTICAL_EVENTS_DDL, EXPORT_HOPS_SQL, emptyImportResult, IMPORT_CURSORS_SQL, IMPORT_CURSOR_OF_SQL, OUTBOX_MARK_SQL, exportedSinceQuery, emittedSinceQuery, emittedReportOf, EMITTED_REPORT_CAP, IMPORT_CURSOR_ADVANCE_SQL, IMPORT_RECORD_SQL, moveImportCursor, CrossVerticalRegistry, exportReadPlan, exportReadQuery, planExportBatch, withheldNote, JOB_RUN_DDL, JOB_RUN_DUE_AT, jobRunListLimit, SYSTEM_DOOR_WAIT, ancestorsWithin, } from '@substrat-run/kernel';
|
|
5
|
+
import { ulid, DO_SQL_LIMITS, unknownRoleError, createUlid, assertAllowed, ConnectionSealingKeyUnavailableError, noSealingKeyMessage, sealTo, assertReadOnlyQuery, entitlementDenial, platformRequestHistoryQuery, platformRequestOf, PLATFORM_REQUEST_COLUMNS, PLATFORM_REQUEST_REDACTION_SQL, platformRequestRedactionParams, platformRequestRedactionQuery, intentPayloadCarriesSubject, redactSubjectJobRuns, eraseSubjectFromModules, moduleErasurePlan, redactSubjectScopeText, assertRowLimit, assertRowOffset, JOB_RUN_PATCH_SQL, JOB_RUN_CLAIM_SQL, JOB_RUN_RENEW_SQL, JOB_RUN_BEGIN_SQL, JOB_RUN_MISS_SQL, JOB_RUN_MISS_SETTLE_SQL, admissionMissOutcome, JOB_LEASE_EXPIRED_NOTE, JOB_STEP_RECORD_SQL, DELIVERY_ERROR_REDACTION_SQL, REDACTED_DELIVERY_NOTE, seatScopeTuple, delegatedRevokeSql, writeExplicitTupleIn, grantEntityShapeIn, topUpEntityGrantShapes, applyScopeRoleChange, changeScopeRole, revokeScopeRoles, scopeRoleHolders, assertModuleDeclared, noteDeclaredKeys, noteReservedKeys, SCOPE_SEATED_ROLES_SQL, inUnitRoleRefusal, loadedRoleSetRefusedRole, reservedRoleSetRefusedRole, reservedGrantRefusal, reservedRoleRefusal, reservedShapeRefusal, effectiveRoleGrantQuery, switchRecordedOff, switchSystemSchedules, peerGrantsStatus, systemGrantsStatus, systemScheduleState, systemSwitchedOff, denialListQuery, refusalListQuery, mapRefusalRow, denialSummaryQuery, denialTotalsQuery, DENIAL_WINDOW_QUERY, mapDenialRow, mapDenialSummaryBuckets, PermissionDenied, assertImpersonationWrites, assertModuleEnqueueableKind, impersonationStampOf, createAtomic, NotSearchable, isSearchIndexTable, searchIndexPlans, NotListable, listIndexPlans, moduleMigrations, MIGRATION_DIGEST_FENCE_DDL, MIGRATION_DIGEST_MARK_LEGACY, assertJournalDumpCoherent, assertMigrationSql, migrationDivergence, migrationFailedError, migrationSetDivergence, migrationSteps, planMigrations, listQuery, cursorOf, searchLimit, searchMatchExpression, searchQuery, ATTACHMENT_TEXT_DDL, ATTACHMENT_BYTES_SQL, ATTACHMENT_CONDEMNED_DDL, ATTACHMENT_SWEEP_LICENCE_DDL, ATTACHMENT_SWEEP_LICENCE_TABLE, WIRE_NONCE_DDL, WIRE_NONCE_FULL, claimWireNonceIn, isWireNonceTable, isWireNonceWrite, clearOrphanSweepLicence, condemnLicensedOrphanAttachments, consumeOrphanSweepLicence, licensedOrphanIds, recordOrphanSweepLicence, refuseUnlicensedOrphanSweep, refuseCondemnedAttachment, refuseCondemnedInDump, rowlessAttachmentIds, attachmentRecordOfRow, enqueueAttachmentText, reconcileAttachmentText, queueAttachmentTextBackfill, startAttachmentTextBackfill, recordAttachmentText, searchAttachments, IDEMPOTENCY_DDL, REFUSALS_TABLE_DDL, REFUSALS_INDEX, refusalInsert, refusalOf, markGuardRefusal, REFUSALS_REBUILD, refusalsAdmitGuards, assertIdempotencyKey, assertPermissionKey, idempotencyLookupQuery, idempotencyPruneStatement, idempotencyRecordStatement, idempotencyOptedOutMessage, replayFor, entityVersionQuery, entityVersionOf, assertIfMatch, OUTBOX_ENTITY_INDEX, SCHEDULE_STATE_DDL, SCHEDULE_STATE_REBUILD, scheduleStateHasKind, VERTICAL_EVENTS_DDL, EXPORT_HOPS_SQL, emptyImportResult, IMPORT_CURSORS_SQL, IMPORT_CURSOR_OF_SQL, OUTBOX_MARK_SQL, exportedSinceQuery, emittedSinceQuery, emittedReportOf, EMITTED_REPORT_CAP, IMPORT_CURSOR_ADVANCE_SQL, IMPORT_RECORD_SQL, moveImportCursor, CrossVerticalRegistry, exportReadPlan, exportReadQuery, planExportBatch, withheldNote, JOB_RUN_DDL, JOB_RUN_DUE_AT, jobRunListLimit, SYSTEM_DOOR_WAIT, ancestorsWithin, } from '@substrat-run/kernel';
|
|
6
6
|
import { isUpgradeRequest, readSubscription, LIVE_DECIDE_ATTEMPTS, LIVE_FANOUT_LIMIT, LIVE_PRINCIPAL_HEADER, LIVE_SCOPE_HEADER, LIVE_SUBSCRIBE_PATH, LIVE_TENANT_HEADER, LIVE_WITHIN_HEADER, LIVE_EXPIRES_HEADER, LIVE_CLOSE, LIVE_SOCKETS_PER_PRINCIPAL, decodeLiveWithin, liveInstant, liveRefusal, liveRefusalStatus, } from './live-reads.js';
|
|
7
7
|
import { replyOf } from './do-reply.js';
|
|
8
8
|
import { OperationQueue } from './serialization.js';
|
|
@@ -44,6 +44,14 @@ const tenantRefusals = new WeakSet();
|
|
|
44
44
|
/** #1722: a load refused because its copy move's lease ran out (`CopyRestoreFence`), told apart
|
|
45
45
|
* from a store that moved, since the host must not read it as an applied retry. */
|
|
46
46
|
const fenceRefusals = new WeakSet();
|
|
47
|
+
/** #2531: a role set refused inside the unit writing it (`refuseRoleSetInUnit`), told apart by identity. */
|
|
48
|
+
const roleRefusals = new WeakSet();
|
|
49
|
+
/** `ProjectedRoleRow` as the reserved-key rule reads it. */
|
|
50
|
+
const roleShapeOfRow = (r) => ({
|
|
51
|
+
key: r.role_key,
|
|
52
|
+
permissions: JSON.parse(r.permissions),
|
|
53
|
+
...(r.holder === 'system' ? { holder: 'system' } : r.holder == null ? {} : { holder: 'person' }),
|
|
54
|
+
});
|
|
47
55
|
/**
|
|
48
56
|
* The scope spine, as this adapter builds it — one of two hand-written copies (#969).
|
|
49
57
|
*
|
|
@@ -382,6 +390,10 @@ const KERNEL_DDL = `
|
|
|
382
390
|
-- #119: the kernel's authorization for one archive/trash move, read by the derived update
|
|
383
391
|
-- trigger. Shared with the other adapter from @substrat-run/kernel; the comments are there.
|
|
384
392
|
${ENTITY_STATE_MOVES_DDL}
|
|
393
|
+
-- RFC wire-signing §4: the nonces of signed calls this scope accepted, and their count.
|
|
394
|
+
-- Store-local: never dumped, written on the bookkeeping path. Shared with the pure adapter
|
|
395
|
+
-- from @substrat-run/kernel, where the column comments are.
|
|
396
|
+
${WIRE_NONCE_DDL}
|
|
385
397
|
`;
|
|
386
398
|
/**
|
|
387
399
|
* Workers RPC carries a plain `Error`'s MESSAGE faithfully and nothing else. A custom
|
|
@@ -417,6 +429,8 @@ function kernelEmit(ctx, event) {
|
|
|
417
429
|
throw new Error('kernelEmit: not an operation context this host built');
|
|
418
430
|
write(event);
|
|
419
431
|
}
|
|
432
|
+
/** #2555: the `_substrat_meta` keys a control-plane-full load keeps from the destination. */
|
|
433
|
+
const PERMISSION_FLAG_KEYS = ['permission_source', 'entitlements_enforced'];
|
|
420
434
|
/** #2450: a commit or a page naming a stage this store no longer holds. */
|
|
421
435
|
const STAGE_GONE = 'the staged load is not here any more (another carry replaced it, or it was already loaded); nothing was loaded';
|
|
422
436
|
/** A competing page zero must leave the stage a live copy owns intact. */
|
|
@@ -543,11 +557,11 @@ class WriteRevision {
|
|
|
543
557
|
if (write)
|
|
544
558
|
this.written++;
|
|
545
559
|
if (this.keeping && write) {
|
|
546
|
-
// Bookkeeping takes the copy-marker insert
|
|
547
|
-
// else: any other write here would be one the revision never saw, which is the hole this
|
|
560
|
+
// Bookkeeping takes the copy-marker insert, the lifecycle delivery (#1713) and the wire
|
|
561
|
+
// nonce claim's own statements (RFC wire-signing §4) and nothing else: any other write here would be one the revision never saw, which is the hole this
|
|
548
562
|
// class exists to close.
|
|
549
|
-
if (!isCopyMarkInsert(query) && !isLifecycleWrite(query)) {
|
|
550
|
-
throw substratError('internal', `the bookkeeping path takes only the copy-marker insert
|
|
563
|
+
if (!isCopyMarkInsert(query) && !isLifecycleWrite(query) && !isWireNonceWrite(query)) {
|
|
564
|
+
throw substratError('internal', `the bookkeeping path takes only the copy-marker insert, the lifecycle delivery and the wire nonce claim (#1722, #1713), not: ${query.slice(0, 80)}`);
|
|
551
565
|
}
|
|
552
566
|
return raw.exec(query, ...bindings);
|
|
553
567
|
}
|
|
@@ -574,8 +588,9 @@ class WriteRevision {
|
|
|
574
588
|
* r10). That only ever restricts what the store may run and changes no data, so it advances no
|
|
575
589
|
* write revision: a backfill that marks a carry's source between its export and its wipe must
|
|
576
590
|
* not read as a write the carry did not copy (that would keep the copy for nothing). Enforced,
|
|
577
|
-
* not trusted: any write in here but the marker insert (`isCopyMarkInsert`)
|
|
578
|
-
* delivery (`isLifecycleWrite`, #1713)
|
|
591
|
+
* not trusted: any write in here but the marker insert (`isCopyMarkInsert`), the lifecycle
|
|
592
|
+
* delivery (`isLifecycleWrite`, #1713) or a wire nonce claim (`isWireNonceWrite`, RFC
|
|
593
|
+
* wire-signing §4: a nonce describes a call this store accepted, not its data) throws. Clearing a
|
|
579
594
|
* marker is NOT bookkeeping (r11): it loosens the store, so it is a write a carry fences on.
|
|
580
595
|
*/
|
|
581
596
|
bookkeeping(run) {
|
|
@@ -3860,6 +3875,10 @@ options = {}) {
|
|
|
3860
3875
|
* set defined on this scope, or a role definition fanned out to it, is judged against.
|
|
3861
3876
|
*/
|
|
3862
3877
|
async roleStateHere(tenantId) {
|
|
3878
|
+
return this.roleStateSync(tenantId);
|
|
3879
|
+
}
|
|
3880
|
+
/** `roleStateHere`, read synchronously, so a unit that writes roles or seats can judge on what it holds (#2531). */
|
|
3881
|
+
roleStateSync(tenantId) {
|
|
3863
3882
|
// Spine tables only (built in the constructor), so no module migration runs ahead of a provision's.
|
|
3864
3883
|
const now = new Date().toISOString();
|
|
3865
3884
|
const rows = this.hasTable('_substrat_roles')
|
|
@@ -3875,6 +3894,25 @@ options = {}) {
|
|
|
3875
3894
|
const seated = new Set(this.sql.exec(SCOPE_SEATED_ROLES_SQL, now).toArray().map((r) => r.role_key));
|
|
3876
3895
|
return { roles, seated: [...seated], dropped };
|
|
3877
3896
|
}
|
|
3897
|
+
/**
|
|
3898
|
+
* #2531: the in-unit judgement. Throws when `roles`, the set the caller's unit is writing for
|
|
3899
|
+
* `tenantId`, is refused by the reserved-key rule (`reservedRoleSetRefusal`, one call) against the
|
|
3900
|
+
* roles, tombstones and seats this scope holds at that moment, with the caller's reservations
|
|
3901
|
+
* joined with this class's. Synchronous, so the unit it runs in cannot interleave with a write
|
|
3902
|
+
* between this read and its own writes; a refusal rolls the whole unit back.
|
|
3903
|
+
*
|
|
3904
|
+
* The answer is a fixed sentence naming the role key only, whoever reads it. The rule's own
|
|
3905
|
+
* wording is the pre-check's, which a caller still asks first for the common case.
|
|
3906
|
+
*/
|
|
3907
|
+
refuseRoleSetInUnit(tenantId, roles, { judge, loaded }) {
|
|
3908
|
+
const state = this.roleStateSync(tenantId);
|
|
3909
|
+
const refused = (loaded ? loadedRoleSetRefusedRole : reservedRoleSetRefusedRole)(roles, { reserved: new Set([...judge.reserved, ...this.reservedKeys]), declared: new Set([...judge.declared, ...this.declaredKeys]) }, { roles: state.roles, seated: new Set(state.seated), ...(state.dropped ? { dropped: state.dropped } : {}) });
|
|
3910
|
+
if (refused === null)
|
|
3911
|
+
return;
|
|
3912
|
+
const error = substratError('forbidden', inUnitRoleRefusal(refused.key));
|
|
3913
|
+
roleRefusals.add(error);
|
|
3914
|
+
throw error;
|
|
3915
|
+
}
|
|
3878
3916
|
/** #2447: what this class's modules reserve and declare — a coordinator judging a role definition joins its own. */
|
|
3879
3917
|
async reservationHere() {
|
|
3880
3918
|
return { reserved: [...this.reservedKeys], declared: [...this.declaredKeys] };
|
|
@@ -4898,6 +4936,24 @@ options = {}) {
|
|
|
4898
4936
|
});
|
|
4899
4937
|
return marked;
|
|
4900
4938
|
}
|
|
4939
|
+
/**
|
|
4940
|
+
* RFC wire-signing §4: spend a signed call's nonce in this scope's own store, the claim the
|
|
4941
|
+
* platform's entry makes through the vertical's host (`claimWireNonceLocal`) after every other
|
|
4942
|
+
* check of the envelope has passed. `true` once per nonce; `false` for one already spent, or a
|
|
4943
|
+
* claim after `until`. A store at its cap throws, after its purge committed, which the entry
|
|
4944
|
+
* answers as the store's failure. In storage, so it survives an eviction. Bookkeeping, like the
|
|
4945
|
+
* copy marker: a nonce describes a call this store accepted, not its data, so it advances no
|
|
4946
|
+
* write revision a carry fences on, and no dump carries it.
|
|
4947
|
+
*/
|
|
4948
|
+
claimWireNonce(nonce, until) {
|
|
4949
|
+
let answer;
|
|
4950
|
+
this.revision.transactionSync(() => {
|
|
4951
|
+
answer = this.revision.bookkeeping(() => claimWireNonceIn(this.switchSql(), nonce, until, Math.floor(Date.now() / 1000)));
|
|
4952
|
+
});
|
|
4953
|
+
if (answer === 'full')
|
|
4954
|
+
throw new Error(WIRE_NONCE_FULL);
|
|
4955
|
+
return answer;
|
|
4956
|
+
}
|
|
4901
4957
|
/**
|
|
4902
4958
|
* Clear a mistaken copy classification (#2005, `clearCopyMarker`); a load's events mark stays,
|
|
4903
4959
|
* so copied work still never runs here (#2009). A
|
|
@@ -5297,7 +5353,8 @@ options = {}) {
|
|
|
5297
5353
|
* The tables a dump carries, by name. The derived search index (#827) is excluded: its shadow
|
|
5298
5354
|
* tables cannot be replayed — this host answers "object name reserved for internal use" — and
|
|
5299
5355
|
* it is recomputable, so `importDump` rebuilds it from the loaded rows. A dump carries the rows,
|
|
5300
|
-
* never the index over them. A staged load's own tables (#2450) are this store's, not its data
|
|
5356
|
+
* never the index over them. A staged load's own tables (#2450) are this store's, not its data,
|
|
5357
|
+
* and so is the wire nonce store (RFC wire-signing §4): a load builds it empty.
|
|
5301
5358
|
*/
|
|
5302
5359
|
dumpTableDefs() {
|
|
5303
5360
|
return this.sql
|
|
@@ -5305,7 +5362,7 @@ options = {}) {
|
|
|
5305
5362
|
WHERE type = 'table' AND name NOT GLOB 'sqlite_*' AND name NOT GLOB '_cf_*'
|
|
5306
5363
|
AND name NOT GLOB '${STAGE_GLOB}' AND sql IS NOT NULL
|
|
5307
5364
|
ORDER BY name`)
|
|
5308
|
-
.toArray().filter(({ name }) => !isSearchIndexTable(name));
|
|
5365
|
+
.toArray().filter(({ name }) => !isSearchIndexTable(name) && !isWireNonceTable(name));
|
|
5309
5366
|
}
|
|
5310
5367
|
/**
|
|
5311
5368
|
* One page of `exportDumpStamped` (#2450): the tables in the same order, each table's rows in
|
|
@@ -5578,7 +5635,7 @@ options = {}) {
|
|
|
5578
5635
|
* in memory at once. `stageId` names the staged load the rows come from: checked inside the
|
|
5579
5636
|
* load's transaction, and its tables dropped in it once the rows are in.
|
|
5580
5637
|
*/
|
|
5581
|
-
async loadTables(tables, destScopeId, { switchOff, sourceScopeId, exact, loadStamp, expect, resolveKept, markCopy, provisionedFor, fence, } = {}, stageId) {
|
|
5638
|
+
async loadTables(tables, destScopeId, { switchOff, sourceScopeId, exact, loadStamp, expect, resolveKept, markCopy, provisionedFor, fence, roleRepair, onRolesProjected, projection, keepPermissionFlags, } = {}, stageId) {
|
|
5582
5639
|
// The WHOLE drop-then-replay runs under deferred foreign keys, in one transaction.
|
|
5583
5640
|
//
|
|
5584
5641
|
// Two distinct FK hazards, and both need the deferral — the second is why it cannot
|
|
@@ -5601,8 +5658,10 @@ options = {}) {
|
|
|
5601
5658
|
// rather than failing a restore over data about to be recomputed.
|
|
5602
5659
|
// Matched without case, as SQLite resolves a table name (#1883 review).
|
|
5603
5660
|
// #1686: a copy into another scope id loads no capability rows; a same-scope restore keeps them.
|
|
5661
|
+
// The wire nonce store is this store's own (RFC wire-signing §4): never replayed, so a load
|
|
5662
|
+
// starts it empty.
|
|
5604
5663
|
const replayable = tables
|
|
5605
|
-
.filter((t) => !isSearchIndexTable(t.name.toLowerCase()))
|
|
5664
|
+
.filter((t) => !isSearchIndexTable(t.name.toLowerCase()) && !isWireNonceTable(t.name))
|
|
5606
5665
|
.map((t) => (loadDropsRowsOf(t.name, destScopeId, sourceScopeId) ? { ...t, rows: () => [] } : t));
|
|
5607
5666
|
// The dump is untrusted input (#1143). `SqlStorage.exec` runs every statement
|
|
5608
5667
|
// in the string it is given, so a `ddl` with anything appended to its CREATE
|
|
@@ -5615,6 +5674,13 @@ options = {}) {
|
|
|
5615
5674
|
if (exact && !sourceScopeId) {
|
|
5616
5675
|
throw substratError('validation_failed', 'restore refused: `exact` needs the scope the dump came from');
|
|
5617
5676
|
}
|
|
5677
|
+
// #2531: a reaped store takes no role repair, judged or not, as `projectInUnit` drops a
|
|
5678
|
+
// projection there: the load comes back with no roles, no receipt and no enforcement flip,
|
|
5679
|
+
// as it did before the repair rode the load. Read before the unit, as `projectInUnit` reads it.
|
|
5680
|
+
// #2555: the directory's projection is dropped there too, for the same reason.
|
|
5681
|
+
const reaped = roleRepair || projection ? await this.isReaped() : false;
|
|
5682
|
+
const repair = roleRepair && !reaped ? roleRepair : undefined;
|
|
5683
|
+
const directory = projection && !reaped ? projection : undefined;
|
|
5618
5684
|
const switched = await this.revision.transaction(async () => {
|
|
5619
5685
|
// #1722: first, before any read: a store a reap has since emptied must refuse the late load
|
|
5620
5686
|
// too, and the check is against this store's clock, inside the load's own transaction.
|
|
@@ -5644,6 +5710,8 @@ options = {}) {
|
|
|
5644
5710
|
return { name: t.name, columns: ['id'], rows: at < 0 ? [] : Array.from(t.rows(), (row) => [row[at]]) };
|
|
5645
5711
|
}));
|
|
5646
5712
|
const receiptBefore = this.provisionedFor();
|
|
5713
|
+
// #2555: this store's own permission flags, read before the drops take them.
|
|
5714
|
+
const flagsBefore = keepPermissionFlags ? PERMISSION_FLAG_KEYS.map((key) => [key, this.metaValue(key)]) : [];
|
|
5647
5715
|
if (provisionedFor !== undefined && receiptBefore !== null && receiptBefore !== provisionedFor) {
|
|
5648
5716
|
const refused = substratError('conflict', tenantReceiptRefusal(receiptBefore, provisionedFor));
|
|
5649
5717
|
tenantRefusals.add(refused);
|
|
@@ -5705,7 +5773,8 @@ options = {}) {
|
|
|
5705
5773
|
// `_substrat_tenant_tuples` in its DIRECTORY database, so its per-scope dump omits
|
|
5706
5774
|
// them, and without them the very next permission check would raise a bare `no such
|
|
5707
5775
|
// table: _substrat_roles`. Roles land empty here and are re-projected by the
|
|
5708
|
-
// restore's repair leg (host.projectRolesLocal
|
|
5776
|
+
// restore's repair leg (`roleRepair` below, or host.projectRolesLocal after the load,
|
|
5777
|
+
// #2531) — the spine's job is only to exist so
|
|
5709
5778
|
// the checker can read it. The column pass follows, for the one outbox index KERNEL_DDL
|
|
5710
5779
|
// leaves to it.
|
|
5711
5780
|
for (const stmt of executableSqlStatements(KERNEL_DDL))
|
|
@@ -5740,6 +5809,15 @@ options = {}) {
|
|
|
5740
5809
|
if (receipt !== null) {
|
|
5741
5810
|
this.sql.exec(`INSERT INTO _substrat_meta (key, value) VALUES (?, ?)`, PROVISIONED_FOR_KEY, receipt);
|
|
5742
5811
|
}
|
|
5812
|
+
// #2555: which reader the checker uses, and whether entitlements are enforced, are this
|
|
5813
|
+
// store's, as its receipt is: the dump's values describe the scope it was captured from,
|
|
5814
|
+
// when it was. A flag this store did not hold stays unset. The projection below may then
|
|
5815
|
+
// move them as a fan-out would.
|
|
5816
|
+
for (const [key, value] of flagsBefore) {
|
|
5817
|
+
this.sql.exec(`DELETE FROM _substrat_meta WHERE key = ?`, key);
|
|
5818
|
+
if (value !== null)
|
|
5819
|
+
this.sql.exec(`INSERT INTO _substrat_meta (key, value) VALUES (?, ?)`, key, value);
|
|
5820
|
+
}
|
|
5743
5821
|
// #1722: whatever stamp an export read here no longer describes this store, so every load
|
|
5744
5822
|
// replaces it: with the carry's own, or with none. And the write revision moves on from
|
|
5745
5823
|
// where it stood before the drops, so it never goes back.
|
|
@@ -5786,9 +5864,38 @@ options = {}) {
|
|
|
5786
5864
|
// #1742: the recorded-off modules go back off INSIDE the replay's transaction, with
|
|
5787
5865
|
// the spine and the re-point. A switch that throws rolls the whole restore back, so
|
|
5788
5866
|
// the dump's grants never commit live without the switch that should cover them.
|
|
5789
|
-
|
|
5867
|
+
const switchedOff = destScopeId && switchOff
|
|
5790
5868
|
? switchRecordedOff(this.switchSql(), { scopeId: destScopeId, ...switchOff })
|
|
5791
5869
|
: [];
|
|
5870
|
+
// #2531: the role repair, last, in this same transaction: judged on the roles, tombstones and
|
|
5871
|
+
// seats the dump has just brought (`refuseRoleSetInUnit`), then projected exactly as
|
|
5872
|
+
// `projectRolesLocal` projects it. A refusal rolls the whole load back, so the scope keeps
|
|
5873
|
+
// its rows and a staged load keeps its stage; an admitted load lands with its repair.
|
|
5874
|
+
// #2555: a control-plane-full load writes the directory's current state the same way, in the
|
|
5875
|
+
// same place: the tenant's roles, tuples, entitlements, identity links and the vertical's
|
|
5876
|
+
// connection keys, each a full replace as a fan-out writes it, so nothing the directory has
|
|
5877
|
+
// since revoked comes back with the dump. Scope-level tuples (the seats) are not touched.
|
|
5878
|
+
// The roles are judged only where the loaded state has a part in them (`loaded`).
|
|
5879
|
+
if (repair || directory) {
|
|
5880
|
+
try {
|
|
5881
|
+
if (repair)
|
|
5882
|
+
this.projectionBody({ judge: repair.judge }, repair.tenantId, repair.roles, []);
|
|
5883
|
+
else if (directory) {
|
|
5884
|
+
const { judge, tenantId, roles, tuples, entitlements, identities, connectionKeys } = directory;
|
|
5885
|
+
this.projectionBody({ judge, loaded: true }, tenantId, roles, tuples, entitlements, undefined, identities, connectionKeys);
|
|
5886
|
+
}
|
|
5887
|
+
}
|
|
5888
|
+
catch (e) {
|
|
5889
|
+
// The projection's own tenant refusal (its receipt check) is a tenant refusal of the
|
|
5890
|
+
// load, not the kept-copy `conflict` `checkedLoad` would otherwise read it as.
|
|
5891
|
+
if (e instanceof Error && !roleRefusals.has(e) && errorCodeOf(e) === 'conflict')
|
|
5892
|
+
tenantRefusals.add(e);
|
|
5893
|
+
throw e;
|
|
5894
|
+
}
|
|
5895
|
+
}
|
|
5896
|
+
if (repair)
|
|
5897
|
+
onRolesProjected?.();
|
|
5898
|
+
return switchedOff;
|
|
5792
5899
|
}
|
|
5793
5900
|
finally {
|
|
5794
5901
|
this.revisionSuspended = false;
|
|
@@ -5849,6 +5956,57 @@ options = {}) {
|
|
|
5849
5956
|
async importDumpChecked(tables, destScopeId, opts) {
|
|
5850
5957
|
return this.checkedLoad(() => this.importDump(tables, destScopeId, opts));
|
|
5851
5958
|
}
|
|
5959
|
+
/**
|
|
5960
|
+
* #2531: `importDumpChecked` with the role repair judged and projected in the load's own
|
|
5961
|
+
* transaction (`roleRepair`), answered `rolesProjected: true` when it landed there. A reaped
|
|
5962
|
+
* store takes no repair (`loadTables`), so its load answers without it. A method of its
|
|
5963
|
+
* own, so a host meeting a ScopeDO from before it is refused by the runtime, nothing loaded,
|
|
5964
|
+
* rather than loading with the repair silently dropped.
|
|
5965
|
+
*/
|
|
5966
|
+
async importDumpJudged(tables, destScopeId, opts, roleRepair) {
|
|
5967
|
+
return this.judgedLoad((onRolesProjected) => this.checkedLoad(() => this.importDump(tables, destScopeId, { ...opts, roleRepair, onRolesProjected })));
|
|
5968
|
+
}
|
|
5969
|
+
/**
|
|
5970
|
+
* #2555: a control-plane-full restore or import. `importDump`, with this store's permission flags
|
|
5971
|
+
* kept through it and, when `projection` is given (a host that projects permissions into its
|
|
5972
|
+
* scopes), the directory's current state judged and projected last in the load's transaction. A
|
|
5973
|
+
* refusal rolls the whole load back. The role judgement's own is answered as `refusedRoles`, so
|
|
5974
|
+
* the host can tell it from any other; every other is answered as data so its code survives the
|
|
5975
|
+
* hop. A method of its own, so a host meeting a ScopeDO from before it is refused by the
|
|
5976
|
+
* runtime, nothing loaded, rather than loading the dump's permission state as is.
|
|
5977
|
+
*/
|
|
5978
|
+
importDumpProjected(tables, destScopeId, opts, projection) {
|
|
5979
|
+
return replyOf(async () => {
|
|
5980
|
+
try {
|
|
5981
|
+
return {
|
|
5982
|
+
switchedOff: await this.importDump(tables, destScopeId, {
|
|
5983
|
+
switchOff: opts.switchOff,
|
|
5984
|
+
sourceScopeId: opts.sourceScopeId,
|
|
5985
|
+
exact: opts.exact,
|
|
5986
|
+
keepPermissionFlags: true,
|
|
5987
|
+
...(projection ? { projection } : {}),
|
|
5988
|
+
}),
|
|
5989
|
+
};
|
|
5990
|
+
}
|
|
5991
|
+
catch (e) {
|
|
5992
|
+
if (e instanceof Error && roleRefusals.has(e))
|
|
5993
|
+
return { refusedRoles: e.message };
|
|
5994
|
+
throw e;
|
|
5995
|
+
}
|
|
5996
|
+
});
|
|
5997
|
+
}
|
|
5998
|
+
/** #2531: `commitStagedDumpChecked` with the role repair in the load's transaction (`importDumpJudged`). */
|
|
5999
|
+
async commitStagedDumpJudged(stageId, destScopeId, opts, roleRepair) {
|
|
6000
|
+
return this.judgedLoad((onRolesProjected) => this.commitStagedDumpChecked(stageId, destScopeId, { ...opts, roleRepair, onRolesProjected }));
|
|
6001
|
+
}
|
|
6002
|
+
/** A judged load, answered `rolesProjected: true` only when its repair was written in it. */
|
|
6003
|
+
async judgedLoad(run) {
|
|
6004
|
+
let projected = false;
|
|
6005
|
+
const out = await run(() => {
|
|
6006
|
+
projected = true;
|
|
6007
|
+
});
|
|
6008
|
+
return out.refused === false && projected ? { ...out, rolesProjected: true } : out;
|
|
6009
|
+
}
|
|
5852
6010
|
/**
|
|
5853
6011
|
* Stage one page of a dump for a later `commitStagedDump` (#2450). The rows go into this
|
|
5854
6012
|
* store's own staging tables, untyped, so every cell is kept as it arrived; nothing of the
|
|
@@ -6011,6 +6169,8 @@ options = {}) {
|
|
|
6011
6169
|
catch (e) {
|
|
6012
6170
|
if (e instanceof Error && fenceRefusals.has(e))
|
|
6013
6171
|
return { refused: 'lapsed' };
|
|
6172
|
+
if (e instanceof Error && roleRefusals.has(e))
|
|
6173
|
+
return { refused: 'roles', message: e.message };
|
|
6014
6174
|
const code = errorCodeOf(e);
|
|
6015
6175
|
if (code === 'precondition_failed')
|
|
6016
6176
|
return { refused: 'changed' };
|
|
@@ -7028,6 +7188,17 @@ options = {}) {
|
|
|
7028
7188
|
applyProjectionReply(...args) {
|
|
7029
7189
|
return replyOf(() => this.applyProjection(...args));
|
|
7030
7190
|
}
|
|
7191
|
+
/**
|
|
7192
|
+
* #2531: `applyProjectionReply` with the role set judged INSIDE the projection's unit, against
|
|
7193
|
+
* the roles, tombstones and seats this scope holds as it writes (`refuseRoleSetInUnit`). A
|
|
7194
|
+
* refusal writes nothing: no role row, tombstone, receipt, seat, switch or enforcement flip.
|
|
7195
|
+
* A method of its own, not an argument, so a host meeting a ScopeDO from before it is refused
|
|
7196
|
+
* by the runtime (no such method) before anything is written, rather than projecting unjudged.
|
|
7197
|
+
* The directory fan-out does not call it (K-55, #2531).
|
|
7198
|
+
*/
|
|
7199
|
+
applyJudgedProjectionReply(judge, ...args) {
|
|
7200
|
+
return replyOf(() => this.projectInUnit(judge, ...args));
|
|
7201
|
+
}
|
|
7031
7202
|
/**
|
|
7032
7203
|
* Replace this scope's projected view of a tenant's roles + tenant-level tuples
|
|
7033
7204
|
* with a fresh snapshot, and make it authoritative (source = 'local'). A full
|
|
@@ -7072,6 +7243,10 @@ options = {}) {
|
|
|
7072
7243
|
switchOff,
|
|
7073
7244
|
/** Declared service subjects excluded only from human lockout repair (#1896). */
|
|
7074
7245
|
serviceSubjects) {
|
|
7246
|
+
return this.projectInUnit(undefined, tenantId, roles, tuples, entitlements, scopeTuples, identities, connectionKeys, switchOff, serviceSubjects);
|
|
7247
|
+
}
|
|
7248
|
+
/** `applyProjection`'s unit, its role set judged in it when `judge` is given (#2531). */
|
|
7249
|
+
async projectInUnit(judge, ...args) {
|
|
7075
7250
|
if (await this.isReaped())
|
|
7076
7251
|
return [];
|
|
7077
7252
|
// A projection that arrives after this scope was reaped is dropped, not
|
|
@@ -7082,123 +7257,131 @@ options = {}) {
|
|
|
7082
7257
|
// The whole body is synchronous storage work, so it is ONE transaction (#1738): the queue
|
|
7083
7258
|
// serializes but does not roll back, and a role insert that throws part-way must not leave
|
|
7084
7259
|
// the receipt (or a half-replaced role set) behind for `servesTenant` to trust.
|
|
7085
|
-
return this.queue.enqueue(() => this.revision.transactionSync(() => {
|
|
7086
|
-
|
|
7087
|
-
|
|
7088
|
-
|
|
7089
|
-
|
|
7090
|
-
|
|
7091
|
-
|
|
7092
|
-
|
|
7093
|
-
|
|
7094
|
-
|
|
7095
|
-
|
|
7096
|
-
|
|
7097
|
-
|
|
7098
|
-
|
|
7099
|
-
|
|
7100
|
-
|
|
7101
|
-
|
|
7102
|
-
|
|
7103
|
-
|
|
7104
|
-
|
|
7105
|
-
|
|
7106
|
-
|
|
7107
|
-
|
|
7108
|
-
this.
|
|
7109
|
-
|
|
7110
|
-
|
|
7111
|
-
|
|
7112
|
-
|
|
7113
|
-
|
|
7114
|
-
|
|
7115
|
-
|
|
7116
|
-
|
|
7117
|
-
|
|
7118
|
-
|
|
7119
|
-
|
|
7120
|
-
|
|
7121
|
-
|
|
7122
|
-
|
|
7123
|
-
|
|
7124
|
-
|
|
7125
|
-
|
|
7126
|
-
|
|
7127
|
-
|
|
7128
|
-
|
|
7129
|
-
|
|
7130
|
-
|
|
7131
|
-
this.sql.exec(`INSERT OR REPLACE INTO
|
|
7260
|
+
return this.queue.enqueue(() => this.revision.transactionSync(() => this.projectionBody(judge && { judge }, ...args)));
|
|
7261
|
+
}
|
|
7262
|
+
/**
|
|
7263
|
+
* The projection's writes, synchronous, for a unit the caller holds: `projectInUnit`'s, or a
|
|
7264
|
+
* load's (`loadTables`, #2531), so a restore's role repair lands in the load's transaction.
|
|
7265
|
+
*/
|
|
7266
|
+
projectionBody(judgement, ...[tenantId, roles, tuples, entitlements, scopeTuples, identities, connectionKeys, switchOff, serviceSubjects]) {
|
|
7267
|
+
// #1738: the receipt `servesTenant` reads. First writer wins: absent or equal is written,
|
|
7268
|
+
// a receipt for ANOTHER tenant refuses the whole projection (K-3), before a single row
|
|
7269
|
+
// moves, so a misdirected projection can never re-point a scope or leave its roles behind.
|
|
7270
|
+
// A restore does not trip this: `importDump` keeps the destination's own receipt and
|
|
7271
|
+
// never the dump's (#2016), so the repair projection that follows finds its own tenant.
|
|
7272
|
+
// #2016: a scope with no receipt yet takes its first one only where its role rows agree
|
|
7273
|
+
// (`tenantVerdict`, the same reading the lifecycle back-fill uses), so a projection for
|
|
7274
|
+
// another tenant cannot pin a legacy scope to it.
|
|
7275
|
+
const { verdict, held } = this.tenantVerdict(tenantId);
|
|
7276
|
+
if (verdict === 'foreign') {
|
|
7277
|
+
throw substratError('conflict', held !== null
|
|
7278
|
+
? `applyProjection refused: this scope was provisioned for tenant ${held}, and a projection for tenant ${tenantId} would re-point it`
|
|
7279
|
+
: `applyProjection ${foreignTenant(held, tenantId)}`);
|
|
7280
|
+
}
|
|
7281
|
+
// #2531: judged before the first write of the unit, on what the scope holds now.
|
|
7282
|
+
if (judgement)
|
|
7283
|
+
this.refuseRoleSetInUnit(tenantId, roles.map(roleShapeOfRow), judgement);
|
|
7284
|
+
// Written before any guard below can return early, so every projection leaves it.
|
|
7285
|
+
this.sql.exec(`INSERT OR REPLACE INTO _substrat_meta (key, value) VALUES (?, ?)`, PROVISIONED_FOR_KEY, tenantId);
|
|
7286
|
+
// #2531: a role the new set leaves out is TOMBSTONED, not deleted: it keeps the keys and the
|
|
7287
|
+
// holder it had, and grants nothing (every reader skips a revoked row). Kept for good, as
|
|
7288
|
+
// evidence (K-21): the reserved-key rule reads a system-held tombstone as the stored
|
|
7289
|
+
// definition it was, so a release that drops a role and a later one that adds it back
|
|
7290
|
+
// are judged as one that kept it. A row already tombstoned keeps its first `revoked_at`;
|
|
7291
|
+
// a role the set names is written live again below, which clears it.
|
|
7292
|
+
this.sql.exec(`UPDATE _substrat_roles SET revoked_at = ? WHERE tenant_id = ? AND revoked_at IS NULL`, new Date().toISOString(), tenantId);
|
|
7293
|
+
for (const r of roles) {
|
|
7294
|
+
this.sql.exec(`INSERT OR REPLACE INTO _substrat_roles (tenant_id, role_key, permissions, source, holder, revoked_at)
|
|
7295
|
+
VALUES (?, ?, ?, ?, ?, NULL)`, tenantId, r.role_key, r.permissions, r.source, r.holder === 'system' ? 'system' : r.holder == null ? null : 'person');
|
|
7296
|
+
}
|
|
7297
|
+
this.sql.exec(`DELETE FROM _substrat_tenant_tuples WHERE tenant_id = ?`, tenantId);
|
|
7298
|
+
for (const t of tuples) {
|
|
7299
|
+
this.sql.exec(`INSERT OR REPLACE INTO _substrat_tenant_tuples
|
|
7300
|
+
(tenant_id, subject, relation, object, expires_at, revoked_at)
|
|
7301
|
+
VALUES (?, ?, ?, ?, ?, ?)`, tenantId, t.subject, t.relation, t.object, t.expires_at, t.revoked_at);
|
|
7302
|
+
}
|
|
7303
|
+
if (entitlements !== undefined) {
|
|
7304
|
+
this.sql.exec(`DELETE FROM _substrat_entitlements WHERE tenant_id = ?`, tenantId);
|
|
7305
|
+
for (const e of entitlements) {
|
|
7306
|
+
this.sql.exec(`INSERT OR REPLACE INTO _substrat_entitlements
|
|
7307
|
+
(tenant_id, entitlement_key, expires_at, quota, plan)
|
|
7308
|
+
VALUES (?, ?, ?, ?, ?)`, tenantId, e.entitlement_key, e.expires_at, e.quota, e.plan);
|
|
7132
7309
|
}
|
|
7133
|
-
// #
|
|
7134
|
-
//
|
|
7135
|
-
//
|
|
7136
|
-
|
|
7137
|
-
|
|
7138
|
-
|
|
7139
|
-
|
|
7140
|
-
|
|
7141
|
-
|
|
7142
|
-
|
|
7310
|
+
// #304: once a scope has been projected WITH entitlements even once, its gate
|
|
7311
|
+
// switches from trust-upstream to strict fail-closed (a missing/expired key
|
|
7312
|
+
// denies). Left unset, a scope provisioned before #304 keeps trusting upstream
|
|
7313
|
+
// until a projection (fanOut / reconcile / re-provision) back-fills it — so the
|
|
7314
|
+
// enforcement flip is per-scope and never strands an un-back-filled scope.
|
|
7315
|
+
this.sql.exec(`INSERT OR REPLACE INTO _substrat_meta (key, value) VALUES ('entitlements_enforced', '1')`);
|
|
7316
|
+
}
|
|
7317
|
+
// #406: identity links ride the same snapshot. Full replace, so an unlink is
|
|
7318
|
+
// durable against every later projection — unlike the compiled-in map this
|
|
7319
|
+
// replaces, where a version rollback silently resurrected a removed login.
|
|
7320
|
+
if (identities !== undefined) {
|
|
7321
|
+
this.sql.exec(`DELETE FROM _substrat_identity_links WHERE tenant_id = ?`, tenantId);
|
|
7322
|
+
for (const i of identities) {
|
|
7323
|
+
this.sql.exec(`INSERT OR REPLACE INTO _substrat_identity_links
|
|
7324
|
+
(tenant_id, provider, external_id, principal_id, scope_id)
|
|
7325
|
+
VALUES (?, ?, ?, ?, ?)`, tenantId, i.provider, i.external_id, i.principal_id, i.scope_id);
|
|
7143
7326
|
}
|
|
7144
|
-
|
|
7145
|
-
|
|
7146
|
-
|
|
7147
|
-
|
|
7148
|
-
|
|
7149
|
-
|
|
7150
|
-
|
|
7151
|
-
|
|
7152
|
-
|
|
7153
|
-
|
|
7327
|
+
}
|
|
7328
|
+
// #687: connection sealing keys ride the same snapshot. Full replace, so a
|
|
7329
|
+
// revoked connection's key stops being projected and `sealToConnection` starts
|
|
7330
|
+
// refusing — the same fail-closed direction an unlinked identity takes.
|
|
7331
|
+
if (connectionKeys !== undefined) {
|
|
7332
|
+
this.sql.exec(`DELETE FROM _substrat_connection_keys WHERE tenant_id = ?`, tenantId);
|
|
7333
|
+
for (const k of connectionKeys) {
|
|
7334
|
+
this.sql.exec(`INSERT OR REPLACE INTO _substrat_connection_keys
|
|
7335
|
+
(tenant_id, connection_id, provider, key_id, public_key)
|
|
7336
|
+
VALUES (?, ?, ?, ?, ?)`, tenantId, k.connection_id, k.provider, k.key_id, k.public_key);
|
|
7154
7337
|
}
|
|
7155
|
-
|
|
7156
|
-
|
|
7157
|
-
|
|
7158
|
-
|
|
7159
|
-
|
|
7160
|
-
|
|
7161
|
-
|
|
7162
|
-
|
|
7163
|
-
|
|
7164
|
-
|
|
7338
|
+
}
|
|
7339
|
+
// #332: scope-level grants (the owner's role tuple at provision) are written in the
|
|
7340
|
+
// SAME enqueued unit as the projection and the enforcement flip below. Additive upsert
|
|
7341
|
+
// — NOT a full replace — so existing scope tuples are preserved. This is what keeps a
|
|
7342
|
+
// scope from ever being left "roles projected, permission_source=local, zero tuples" by
|
|
7343
|
+
// a write that lands the projection but drops before a follow-up owner grant.
|
|
7344
|
+
//
|
|
7345
|
+
// #1659: SEATED, so a reconcile creates what is missing and leaves a revoke alone. It
|
|
7346
|
+
// used to be `INSERT OR REPLACE … revoked_at = NULL`, which undid an operator's revoke
|
|
7347
|
+
// of the owner seat or of a `system:` schedule grant on the next reconcile. And a
|
|
7348
|
+
// module switched off (#1666) gets no `system:` grant seated, new or old.
|
|
7349
|
+
for (const st of scopeTuples ?? []) {
|
|
7350
|
+
const seat = seatScopeTuple(st.subject, st.relation, st.object, st.expires_at);
|
|
7351
|
+
this.sql.exec(seat.sql, ...seat.params);
|
|
7352
|
+
}
|
|
7353
|
+
// #1659's one exception: the owner-of-record's seat comes back over a revoke when
|
|
7354
|
+
// No HUMAN holder remains — declared service subjects cannot prevent lockout repair.
|
|
7355
|
+
// That is the #332 lockout this path exists to repair. The flip guard below still
|
|
7356
|
+
// counts service authority; only the repair excludes the declared service subjects.
|
|
7357
|
+
// With any other effective holder, the revoke stands: a hand-over that seats a
|
|
7358
|
+
// successor before unseating the owner is not undone by the next promote. A holder of
|
|
7359
|
+
// a role the vertical no longer defines is NOT one — it passes no check, so it must
|
|
7360
|
+
// not stand in for the holder this repair exists to restore.
|
|
7361
|
+
if (roles.length > 0 && !this.hasEffectiveRoleGrant(tenantId, serviceSubjects)) {
|
|
7165
7362
|
for (const st of scopeTuples ?? []) {
|
|
7166
|
-
|
|
7167
|
-
|
|
7168
|
-
|
|
7169
|
-
|
|
7170
|
-
// No HUMAN holder remains — declared service subjects cannot prevent lockout repair.
|
|
7171
|
-
// That is the #332 lockout this path exists to repair. The flip guard below still
|
|
7172
|
-
// counts service authority; only the repair excludes the declared service subjects.
|
|
7173
|
-
// With any other effective holder, the revoke stands: a hand-over that seats a
|
|
7174
|
-
// successor before unseating the owner is not undone by the next promote. A holder of
|
|
7175
|
-
// a role the vertical no longer defines is NOT one — it passes no check, so it must
|
|
7176
|
-
// not stand in for the holder this repair exists to restore.
|
|
7177
|
-
if (roles.length > 0 && !this.hasEffectiveRoleGrant(tenantId, serviceSubjects)) {
|
|
7178
|
-
for (const st of scopeTuples ?? []) {
|
|
7179
|
-
if (!st.lockout_reseat)
|
|
7180
|
-
continue;
|
|
7181
|
-
this.sql.exec(`INSERT OR REPLACE INTO _substrat_tuples (subject, relation, object, expires_at, revoked_at)
|
|
7182
|
-
VALUES (?, ?, ?, ?, NULL)`, st.subject, st.relation, st.object, st.expires_at);
|
|
7183
|
-
}
|
|
7363
|
+
if (!st.lockout_reseat)
|
|
7364
|
+
continue;
|
|
7365
|
+
this.sql.exec(`INSERT OR REPLACE INTO _substrat_tuples (subject, relation, object, expires_at, revoked_at)
|
|
7366
|
+
VALUES (?, ?, ?, ?, NULL)`, st.subject, st.relation, st.object, st.expires_at);
|
|
7184
7367
|
}
|
|
7185
|
-
|
|
7186
|
-
|
|
7187
|
-
|
|
7188
|
-
|
|
7189
|
-
|
|
7190
|
-
|
|
7191
|
-
|
|
7192
|
-
|
|
7193
|
-
|
|
7194
|
-
|
|
7195
|
-
|
|
7196
|
-
|
|
7197
|
-
|
|
7198
|
-
|
|
7199
|
-
this.sql.exec(`INSERT OR REPLACE INTO _substrat_meta (key, value) VALUES ('permission_source', 'local')`);
|
|
7368
|
+
}
|
|
7369
|
+
// #1742: the directory's recorded-off modules go back off HERE, after the seat and in
|
|
7370
|
+
// its unit. A wiped scope's seat has just re-created their `system:` grants (#1659), and
|
|
7371
|
+
// a re-assert arriving later from the control plane left a window in which this
|
|
7372
|
+
// deployment's own sweeper could fire them. Nothing runs between these two lines.
|
|
7373
|
+
const switched = switchOff ? switchRecordedOff(this.switchSql(), switchOff) : [];
|
|
7374
|
+
// #332: only switch on strict local enforcement when SOMEONE actually holds a role.
|
|
7375
|
+
// A projection that leaves role definitions but no effective principal→role grant would
|
|
7376
|
+
// make every check fail closed — a scope serving nothing but denials, unfixable from
|
|
7377
|
+
// inside. Leave `permission_source` as-is instead; a reconcile that restores the owner
|
|
7378
|
+
// grant re-runs this and flips safely. (A CP-less vertical uses the local reader
|
|
7379
|
+
// regardless of this flag, so the owner grant is written above in the same unit — this
|
|
7380
|
+
// guard is the belt to that suspenders, and it protects the CP-backed flip outright.)
|
|
7381
|
+
if (roles.length > 0 && !this.hasEffectiveRoleGrant(tenantId))
|
|
7200
7382
|
return switched;
|
|
7201
|
-
|
|
7383
|
+
this.sql.exec(`INSERT OR REPLACE INTO _substrat_meta (key, value) VALUES ('permission_source', 'local')`);
|
|
7384
|
+
return switched;
|
|
7202
7385
|
}
|
|
7203
7386
|
/**
|
|
7204
7387
|
* Resolve an external identity from this scope's projected links (#406) — the
|