@substrat-run/adapter-cloudflare 0.89.0 → 0.90.1

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/host.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { fromWireFailure, accessLogEntry, adminLogEntry, opsFailureEntry, attachmentRecord, createTenantInput, identityLink, identityPool, createOrgInput, promotionAcknowledgement, bindHostnameInput, channelHistoryEntry, hostnameBinding, publishVersionInput, AUTO_ADMISSION_NOTE, registerVerticalInput, vertical as verticalSchema, verticalServingState, verticalChannel, verticalVersion, connection, connectionGrant, connectionGrantRecord, connectionSecret, systemGrant, entitlementGrant, entitlementGrantInput, instant, meterReading, subjectRef, createConnectionInput, projectedConnectionGrant, projectedConnectionKey, moduleManifest, org as orgSchema, orgMembership, resolvedIdentity, roleDefinition, scope as scopeSchema, tenant as tenantSchema, tenantRole, subjectShredReceipt, platformRequest, connectorDispatchKind, outboundOfManifestJson, substratError, } from '@substrat-run/contracts';
2
2
  import { normalizeHostname, toRouteTarget } from './route-resolver.js';
3
- import { attachmentBlobKey, entitlementDenial, foldMeterReading, parseValidationRecords, resolveScopeRecord, ulid, backoffAt, resolveRetryPolicy, isSecretBoxConfigured, unconfiguredSecretBox, createSubjectKeys, generateSealingKeyPair, openSealed, } from '@substrat-run/kernel';
3
+ import { attachmentBlobKey, entitlementDenial, foldMeterReading, parseValidationRecords, resolveScopeRecord, ulid, backoffAt, resolveRetryPolicy, isSecretBoxConfigured, unconfiguredSecretBox, createSubjectKeys, generateSealingKeyPair, openSealed, ImpersonationRefused, assertSessionUsable, impersonationRowValues, mapImpersonationRow, newImpersonationSession, } from '@substrat-run/kernel';
4
4
  import { tenantStoreDatabaseName } from './d1.js';
5
5
  import { blobStoreBucketName, r2TenantBlobStore } from './r2.js';
6
6
  /** DO row → contract shape. Never reads the secrets table — that is the split. */
@@ -28,6 +28,7 @@ function rowToPlatformRequest(r) {
28
28
  kind: r.kind,
29
29
  payload: JSON.parse(r.payload),
30
30
  requestedBy: JSON.parse(r.requested_by),
31
+ impersonation: r.impersonation == null ? null : JSON.parse(r.impersonation),
31
32
  status: r.status,
32
33
  attempts: r.attempts,
33
34
  lastError: r.last_error,
@@ -1025,6 +1026,33 @@ export class CloudflareScopeHost {
1025
1026
  await this.migrateAndRecord(scopeId);
1026
1027
  return this.buildStub(conn.tenant_id, scopeId, undefined, connectionId);
1027
1028
  }
1029
+ /**
1030
+ * Read a session and hold it to its own rules — the lookup behind both the door
1031
+ * and every invoke through it. Kept in one place so the two can never disagree
1032
+ * about what "still usable" means.
1033
+ */
1034
+ async resolveImpersonation(session, tenantId, scopeId) {
1035
+ const row = await this.cp.readImpersonation(String(session));
1036
+ if (!row)
1037
+ throw new ImpersonationRefused(`unknown impersonation session: ${session}`);
1038
+ const record = mapImpersonationRow(row);
1039
+ assertSessionUsable(record, new Date().toISOString(), { tenantId, scopeId });
1040
+ return record;
1041
+ }
1042
+ /**
1043
+ * The impersonation door (K-42, #868) — mirror of `getConnectorScope` and
1044
+ * `getSystemScope`. The session is the authority and it is read from the
1045
+ * directory, never described by the caller: a stub can only be minted by naming
1046
+ * a session somebody opened and the admin log already recorded.
1047
+ */
1048
+ async getImpersonatedScope(session, tenantId, scopeId, options) {
1049
+ const record = await this.resolveImpersonation(session, tenantId, scopeId);
1050
+ // The same lifecycle gate the principal door applies: a suspended tenant
1051
+ // refuses a support session exactly as it refuses a user.
1052
+ await this.cp.validateScopeAccess(tenantId, scopeId);
1053
+ await this.migrateAndRecord(scopeId);
1054
+ return this.buildStub(tenantId, scopeId, record.principal, undefined, undefined, options, record.id);
1055
+ }
1028
1056
  /**
1029
1057
  * A scope stub whose authority is a MODULE on a timer (#383) — the scheduler's
1030
1058
  * door, mirror of `getConnectorScope`. The module must be registered on this host
@@ -1095,8 +1123,16 @@ export class CloudflareScopeHost {
1095
1123
  }
1096
1124
  return report;
1097
1125
  }
1098
- /** The stub body, shared by the principal, connection, and system doors. */
1099
- buildStub(tenantId, scopeId, principal, connectionId, systemModuleId, options) {
1126
+ /** The stub body, shared by the principal, connection, system and impersonation doors. */
1127
+ buildStub(tenantId, scopeId, principal, connectionId, systemModuleId, options,
1128
+ /**
1129
+ * K-42: the session this stub acts under. Re-read from the directory on
1130
+ * EVERY invoke rather than captured here — a stub is a capability and
1131
+ * nothing takes it away, so a session checked only at the door would be a
1132
+ * time box that never runs out for the one caller holding it, and
1133
+ * `endImpersonation` would stop nothing.
1134
+ */
1135
+ sessionId) {
1100
1136
  const stub = this.scopeStub(scopeId);
1101
1137
  const cp = this.cp;
1102
1138
  const operationEntitlement = this.operationEntitlement;
@@ -1125,7 +1161,9 @@ export class CloudflareScopeHost {
1125
1161
  expired: r.expires_at !== null && r.expires_at <= now,
1126
1162
  })))));
1127
1163
  }
1128
- const envelope = await stub.invoke(operation, input, asPrincipalId, tenantId, scopeId, connectionId, requiredKey, systemModuleId, true, invokeOptions);
1164
+ // K-42: resolved per invoke, so expiry and `endImpersonation` both bite.
1165
+ const session = sessionId === undefined ? undefined : await this.resolveImpersonation(sessionId, tenantId, scopeId);
1166
+ const envelope = await stub.invoke(operation, input, asPrincipalId, tenantId, scopeId, connectionId, requiredKey, systemModuleId, true, invokeOptions, session);
1129
1167
  // The operation failed and the DO handed the error back as DATA — so it still
1130
1168
  // has its code and extensions, which a throw across this boundary would have
1131
1169
  // stripped down to a message (#113 §3). Rethrown here, where the caller expects
@@ -1154,7 +1192,24 @@ export class CloudflareScopeHost {
1154
1192
  throw substratError('unavailable', `${operation} ran on a scope host that did not honour Idempotency-Key — the ` +
1155
1193
  'operation may have executed a second time. Retry once the scope has been migrated');
1156
1194
  }
1157
- const drained = await this.drainExecutors(tenantId, scopeId);
1195
+ // K-42, and the same shape as the two skew refusals above with a sharper
1196
+ // reason: a DO too old to know about sessions ran this as the impersonated
1197
+ // principal with nothing stamped and no read-only bound. The write has
1198
+ // committed; what this refuses is the SUCCESS, because a support session
1199
+ // believed to be recorded and bounded is worse than no support session.
1200
+ if (session !== undefined && envelope.impersonation?.honoured !== true) {
1201
+ throw substratError('unavailable', `${operation} ran on a scope host that did not understand impersonation — the ` +
1202
+ 'operation may have run unrecorded and unbounded. Retry once the scope has ' +
1203
+ 'been migrated');
1204
+ }
1205
+ // K-42, the coordinator half of the pure adapter's post-commit guard: a
1206
+ // read-only session's transaction was rolled back, so it added nothing to
1207
+ // this scope's outbox — and draining anyway would run executors and
1208
+ // connectors as a side effect of a session that may not have side effects.
1209
+ // Whatever the outbox already held is the drain sweep's own backstop.
1210
+ const drained = session?.mode === 'read-only'
1211
+ ? { attempted: 0, delivered: 0, retrying: 0, deadLettered: 0, routedToPlatform: 0 }
1212
+ : await this.drainExecutors(tenantId, scopeId);
1158
1213
  // #458: the operation committed having enqueued platform intents — tell the
1159
1214
  // caller's harness so it can flag the response for the router kick (#381).
1160
1215
  // Routed connector deliveries (#574 phase 3) count too: the inline drain just
@@ -2230,6 +2285,43 @@ export class CloudflareScopeHost {
2230
2285
  await this.recordAccess(actor, 'shredSubject', { tenantId, scopeId }, { subjectId }, eventsRedacted);
2231
2286
  return receipt;
2232
2287
  },
2288
+ // -- impersonation (K-42, #868) ----------------------------------------
2289
+ beginImpersonation: async (actor, input) => {
2290
+ // Fail closed on the scope before minting anything: a session naming a
2291
+ // scope that is not there is a credential for nothing, and issuing one
2292
+ // anyway leaves the admin log recording an access that could not happen.
2293
+ await this.assertScope(input.tenantId, input.scopeId);
2294
+ const session = newImpersonationSession(ulid(), actor, input, new Date().toISOString());
2295
+ await this.cp.writeImpersonation(impersonationRowValues(session));
2296
+ // BEFORE the session is usable, so the log entry precedes every row it
2297
+ // will ever touch. The reason rides in `after`: it is what a review reads
2298
+ // first, and a log saying a session opened but not why is half a record.
2299
+ await this.recordAdmin(actor, 'beginImpersonation', { tenantId: session.tenantId, scopeId: session.scopeId }, null, session);
2300
+ return session;
2301
+ },
2302
+ endImpersonation: async (actor, id) => {
2303
+ const row = await this.cp.readImpersonation(String(id));
2304
+ if (!row)
2305
+ throw new ImpersonationRefused(`unknown impersonation session: ${id}`);
2306
+ const before = mapImpersonationRow(row);
2307
+ // Idempotent: "stop that session" must not fail because somebody else
2308
+ // already stopped it.
2309
+ if (before.endedAt !== null)
2310
+ return before;
2311
+ await this.cp.endImpersonation(String(id), new Date().toISOString());
2312
+ const after = mapImpersonationRow((await this.cp.readImpersonation(String(id))));
2313
+ await this.recordAdmin(actor, 'endImpersonation', { tenantId: after.tenantId, scopeId: after.scopeId }, before, after);
2314
+ return after;
2315
+ },
2316
+ listImpersonations: async (actor, filter) => {
2317
+ const rows = await this.cp.listImpersonations(filter ?? {}, new Date().toISOString());
2318
+ const sessions = rows.map(mapImpersonationRow);
2319
+ // A staff READ, logged like every other one (K-24) — `result_count`
2320
+ // included, which is what separates "checked one session" from
2321
+ // "enumerated every support session on the platform".
2322
+ await this.recordAccess(actor, 'listImpersonations', { tenantId: (filter?.tenantId ?? null) }, filter ?? null, sessions.length);
2323
+ return sessions;
2324
+ },
2233
2325
  grantEntitlement: async (actor, tenantId, entitlementKey, plan) => {
2234
2326
  const input = entitlementGrantInput.parse(plan ?? {});
2235
2327
  const result = await this.cp.grantEntitlement(tenantId, entitlementKey, input, actor);