@flowdular/sdk 0.3.0 → 0.3.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.
Files changed (70) hide show
  1. package/.ai/platform-capabilities.md +2 -2
  2. package/.ai/policies/capabilities.yaml +30 -3
  3. package/.ai/references/catalog/module.json +1 -1
  4. package/.ai/references/catalog/package.json +2 -2
  5. package/.ai/references/catalog/spec/module.yaml +1 -1
  6. package/.ai/references/catalog.provenance.json +6 -6
  7. package/.ai/skills/cli-extension/SKILL.md +1 -1
  8. package/.ai/skills/deploy-operate/SKILL.md +7 -2
  9. package/modules/approvals/migrations/0005_approvals_grant_audit.down.sql +4 -0
  10. package/modules/approvals/migrations/0005_approvals_grant_audit.up.sql +20 -0
  11. package/modules/approvals/module.json +1 -1
  12. package/modules/approvals/package.json +1 -1
  13. package/modules/approvals/spec/module.yaml +11 -2
  14. package/modules/approvals/src/domain/capability.ts +12 -0
  15. package/modules/approvals/src/domain/grant.ts +69 -0
  16. package/modules/approvals/src/domain/types.ts +14 -0
  17. package/modules/approvals/src/index.ts +9 -0
  18. package/modules/approvals/src/platform.ts +8 -0
  19. package/modules/approvals/src/server/runtime.ts +4 -0
  20. package/modules/approvals/src/services/approvals-service.ts +80 -0
  21. package/modules/approvals/src/services/database-repository.ts +65 -6
  22. package/modules/approvals/src/services/migration.ts +34 -0
  23. package/modules/approvals/src/services/repository.ts +8 -0
  24. package/modules/connectors/migrations/0003_connectors_rotation_inventory.down.sql +2 -0
  25. package/modules/connectors/migrations/0003_connectors_rotation_inventory.up.sql +19 -0
  26. package/modules/connectors/module.json +7 -3
  27. package/modules/connectors/package.json +2 -1
  28. package/modules/connectors/spec/module.yaml +2 -1
  29. package/modules/connectors/src/cli/commands.json +17 -0
  30. package/modules/connectors/src/cli/index.ts +126 -0
  31. package/modules/connectors/src/services/credential-rotation.ts +221 -0
  32. package/modules/connectors/src/services/credential-vault.ts +6 -0
  33. package/modules/connectors/src/services/migration.ts +36 -0
  34. package/modules/documents/migrations/0003_documents_rotation_inventory.down.sql +2 -0
  35. package/modules/documents/migrations/0003_documents_rotation_inventory.up.sql +18 -0
  36. package/modules/documents/module.json +7 -3
  37. package/modules/documents/package.json +2 -1
  38. package/modules/documents/spec/module.yaml +2 -1
  39. package/modules/documents/src/cli/commands.json +17 -0
  40. package/modules/documents/src/cli/index.ts +145 -0
  41. package/modules/documents/src/services/database-repository.ts +15 -4
  42. package/modules/documents/src/services/documents-service.ts +13 -9
  43. package/modules/documents/src/services/migration.ts +35 -0
  44. package/modules/documents/src/services/repository.ts +12 -2
  45. package/modules/documents/src/services/storage-rotation.ts +157 -0
  46. package/modules/exports/migrations/0003_exports_rotation_inventory.down.sql +1 -0
  47. package/modules/exports/migrations/0003_exports_rotation_inventory.up.sql +9 -0
  48. package/modules/exports/module.json +7 -3
  49. package/modules/exports/package.json +2 -1
  50. package/modules/exports/spec/module.yaml +2 -1
  51. package/modules/exports/src/cli/commands.json +17 -0
  52. package/modules/exports/src/cli/index.ts +145 -0
  53. package/modules/exports/src/server/index.ts +0 -1
  54. package/modules/exports/src/services/data-classes.ts +16 -13
  55. package/modules/exports/src/services/database-repository.ts +30 -32
  56. package/modules/exports/src/services/migration.ts +27 -0
  57. package/modules/exports/src/services/repository.ts +9 -10
  58. package/modules/exports/src/services/storage-rotation.ts +138 -0
  59. package/package.json +1 -1
  60. package/packages/contracts/src/index.ts +1 -1
  61. package/packages/database/src/backup.ts +1 -0
  62. package/packages/database/src/migrations.ts +7 -0
  63. package/packages/harness/src/runtime.ts +169 -10
  64. package/packages/harness/src/tool-adapters.ts +6 -13
  65. package/packages/kernel/src/approval-grant.ts +310 -0
  66. package/packages/kernel/src/index.ts +20 -0
  67. package/packages/storage/src/envelope.ts +70 -21
  68. package/packages/storage/src/index.ts +7 -1
  69. package/packages/storage/src/port.ts +12 -1
  70. package/packages/storage/src/reseal.ts +128 -0
@@ -8,6 +8,8 @@ import type {
8
8
  import { integer, runDatabaseMigrations } from '@flowdular/sdk/database';
9
9
  import { keysetWhere } from '@flowdular/sdk/server';
10
10
  import type {
11
+ ApprovalAuditAction,
12
+ ApprovalAuditEntry,
11
13
  ApprovalDecision,
12
14
  ApprovalDecisionKind,
13
15
  ApprovalListPage,
@@ -55,6 +57,15 @@ interface RequestRow {
55
57
  created_at: number | bigint | string;
56
58
  }
57
59
 
60
+ interface AuditRow {
61
+ id: string;
62
+ tenant_id: string;
63
+ request_id: string;
64
+ action: ApprovalAuditAction;
65
+ metadata_json: string;
66
+ occurred_at: number | bigint | string;
67
+ }
68
+
58
69
  interface DecisionRow {
59
70
  id: string;
60
71
  tenant_id: string;
@@ -125,6 +136,13 @@ const SQL = {
125
136
  resolveRequest: `UPDATE approvals_requests
126
137
  SET status = $1, resolved_at = $2
127
138
  WHERE tenant_id = $3 AND id = $4 AND status = 'pending'`,
139
+ insertAudit: `INSERT INTO approvals_audit
140
+ (id, tenant_id, request_id, action, metadata_json, occurred_at)
141
+ VALUES ($1, $2, $3, $4, $5, $6)`,
142
+ listAudit: `SELECT id, tenant_id, request_id, action, metadata_json, occurred_at
143
+ FROM approvals_audit
144
+ WHERE tenant_id = $1 AND request_id = $2
145
+ ORDER BY occurred_at, id`,
128
146
  /* The one cross-tenant read. It returns routing columns only; the request
129
147
  is read again under the tenant the routing row named before it expires. */
130
148
  dueExpiries: `SELECT tenant_id, id, expires_at, status FROM approvals_requests
@@ -156,6 +174,8 @@ const SQL = {
156
174
  WHERE tenant_id = $1 AND request_id = ANY($2::text[])`,
157
175
  deleteDecisionsOf: `DELETE FROM approvals_decisions
158
176
  WHERE tenant_id = $1 AND request_id = ANY($2::text[])`,
177
+ deleteAuditOf: `DELETE FROM approvals_audit
178
+ WHERE tenant_id = $1 AND request_id = ANY($2::text[])`,
159
179
  deleteRequests: `DELETE FROM approvals_requests
160
180
  WHERE tenant_id = $1 AND id = ANY($2::text[])`,
161
181
  /* PostgreSQL takes no LIMIT on an UPDATE, so the batch is chosen by the
@@ -266,6 +286,17 @@ function requestFrom(row: RequestRow): ApprovalRequest {
266
286
  };
267
287
  }
268
288
 
289
+ function auditFrom(row: AuditRow): ApprovalAuditEntry {
290
+ return {
291
+ id: row.id,
292
+ tenantId: row.tenant_id,
293
+ requestId: row.request_id,
294
+ action: row.action,
295
+ metadata: JSON.parse(row.metadata_json) as ApprovalAuditEntry['metadata'],
296
+ occurredAt: integer(row.occurred_at, 'occurredAt'),
297
+ };
298
+ }
299
+
269
300
  function decisionFrom(row: DecisionRow): ApprovalDecision {
270
301
  return {
271
302
  id: row.id,
@@ -530,13 +561,25 @@ export class DatabaseApprovalsRepository implements ApprovalsRepository {
530
561
  input.tenantId,
531
562
  input.requestId,
532
563
  ]);
564
+ const resolved = {
565
+ ...request,
566
+ status: terminal,
567
+ resolvedAt: input.resolvedAt,
568
+ };
569
+ const audit = input.audit?.(resolved);
570
+ if (audit) {
571
+ await this.#exec(transaction, SQL.insertAudit, [
572
+ audit.id,
573
+ input.tenantId,
574
+ input.requestId,
575
+ audit.action,
576
+ JSON.stringify(audit.metadata),
577
+ audit.occurredAt,
578
+ ]);
579
+ }
533
580
  return {
534
581
  outcome: 'recorded',
535
- request: {
536
- ...request,
537
- status: terminal,
538
- resolvedAt: input.resolvedAt,
539
- },
582
+ request: resolved,
540
583
  decisions,
541
584
  resolved: true,
542
585
  } as const;
@@ -545,6 +588,18 @@ export class DatabaseApprovalsRepository implements ApprovalsRepository {
545
588
  );
546
589
  }
547
590
 
591
+ async listAudit(
592
+ tenantId: string,
593
+ requestId: string,
594
+ ): Promise<readonly ApprovalAuditEntry[]> {
595
+ return (
596
+ await this.#read<AuditRow>(tenantId, {
597
+ text: SQL.listAudit,
598
+ parameters: [tenantId, requestId],
599
+ })
600
+ ).map(auditFrom);
601
+ }
602
+
548
603
  async listDueExpiries(
549
604
  now: number,
550
605
  limit: number,
@@ -655,7 +710,11 @@ export class DatabaseApprovalsRepository implements ApprovalsRepository {
655
710
  ): Promise<number> {
656
711
  if (rows.length === 0) return 0;
657
712
  const ids = rows.map((row) => row.id);
658
- for (const statement of [SQL.deleteEligibleOf, SQL.deleteDecisionsOf]) {
713
+ for (const statement of [
714
+ SQL.deleteEligibleOf,
715
+ SQL.deleteDecisionsOf,
716
+ SQL.deleteAuditOf,
717
+ ]) {
659
718
  await this.#exec(transaction, statement, [tenantId, ids]);
660
719
  }
661
720
  await this.#exec(transaction, SQL.deleteRequests, [tenantId, ids]);
@@ -139,6 +139,29 @@ CREATE INDEX IF NOT EXISTS approvals_requests_created_idx
139
139
  ON approvals_requests (tenant_id, created_at, id);
140
140
  `;
141
141
 
142
+ /* Mirrors migrations/0005_approvals_grant_audit.up.sql byte for byte. */
143
+ export const APPROVALS_MIGRATION_005_GRANT_AUDIT = `-- The grant an approved request yields is derived from the row and the key on
144
+ -- every read, so nothing about the token itself is stored. What is stored is
145
+ -- the fact of its issuance: one row per resolved request whose subject names
146
+ -- a capability, with the capability, the input digest, the key that signs and
147
+ -- the moment the grant stops verifying, written in the deciding transaction.
148
+ CREATE TABLE IF NOT EXISTS approvals_audit (
149
+ id TEXT PRIMARY KEY,
150
+ tenant_id TEXT NOT NULL,
151
+ request_id TEXT NOT NULL,
152
+ action TEXT NOT NULL,
153
+ metadata_json TEXT NOT NULL,
154
+ occurred_at BIGINT NOT NULL
155
+ );
156
+ CREATE INDEX IF NOT EXISTS approvals_audit_request_idx
157
+ ON approvals_audit (tenant_id, request_id, occurred_at, id);
158
+ ALTER TABLE approvals_audit ENABLE ROW LEVEL SECURITY;
159
+ ALTER TABLE approvals_audit FORCE ROW LEVEL SECURITY;
160
+ CREATE POLICY approvals_audit_tenant_policy ON approvals_audit
161
+ USING (tenant_id = current_setting('coreloom.tenant_id', true))
162
+ WITH CHECK (tenant_id = current_setting('coreloom.tenant_id', true));
163
+ `;
164
+
142
165
  export const databaseMigrations: readonly DatabaseMigration[] = [
143
166
  {
144
167
  id: '0001_approvals_core',
@@ -219,4 +242,15 @@ export const databaseMigrations: readonly DatabaseMigration[] = [
219
242
  () => database.schema.hasIndex('approvals_requests_created_idx'),
220
243
  ]),
221
244
  },
245
+ {
246
+ id: '0005_approvals_grant_audit',
247
+ sql: { postgresql: APPROVALS_MIGRATION_005_GRANT_AUDIT },
248
+ inspectExisting: (database) =>
249
+ postgresTenantTableState(
250
+ database,
251
+ 'approvals_audit',
252
+ 'approvals_audit_tenant_policy',
253
+ [() => database.schema.hasIndex('approvals_audit_request_idx')],
254
+ ),
255
+ },
222
256
  ];
@@ -1,4 +1,5 @@
1
1
  import type {
2
+ ApprovalAuditEntry,
2
3
  ApprovalDecision,
3
4
  ApprovalListPage,
4
5
  ApprovalRequest,
@@ -37,6 +38,8 @@ export interface DecideApprovalInput {
37
38
  decisions: readonly ApprovalDecision[],
38
39
  ) => TerminalApprovalStatus | null;
39
40
  readonly resolvedAt: number;
41
+ /** Written in the same transaction when the request resolves; null writes nothing. */
42
+ readonly audit?: (request: ApprovalRequest) => ApprovalAuditEntry | null;
40
43
  }
41
44
 
42
45
  export type DecideApprovalResult =
@@ -101,6 +104,11 @@ export interface ApprovalsRepository {
101
104
  /** Pending requests this account is in the eligibility snapshot of. */
102
105
  countDecidable(tenantId: string, accountId: string): Promise<number>;
103
106
  decide(input: DecideApprovalInput): Promise<DecideApprovalResult>;
107
+ /** The issuance ledger of one request, oldest first. */
108
+ listAudit(
109
+ tenantId: string,
110
+ requestId: string,
111
+ ): Promise<readonly ApprovalAuditEntry[]>;
104
112
  /** Cross-tenant, routing columns only, on the read-only background lease. */
105
113
  listDueExpiries(
106
114
  now: number,
@@ -0,0 +1,2 @@
1
+ REVOKE SELECT (tenant_id, credential_key_id) ON connectors_instances FROM coreloom_background;
2
+ DROP POLICY IF EXISTS connectors_instances_background_policy ON connectors_instances;
@@ -0,0 +1,19 @@
1
+ -- The credential key rotation has to find the instances still sealed with a
2
+ -- retired key before it knows whose they are, so the cross-tenant role may read
3
+ -- the key id of every row that holds an envelope and nothing else: the nonce,
4
+ -- the tag, the ciphertext and the fingerprint stay unreadable on this
5
+ -- connection, and every row it re-seals is read again under the workspace that
6
+ -- row named. PostgreSQL checks column privileges in WHERE too, so the key id is
7
+ -- part of the grant.
8
+ DO $$
9
+ BEGIN
10
+ IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'coreloom_background') THEN
11
+ RAISE EXCEPTION 'The coreloom_background role must exist before this migration.';
12
+ END IF;
13
+ END
14
+ $$;
15
+ CREATE POLICY connectors_instances_background_policy ON connectors_instances
16
+ FOR SELECT TO coreloom_background
17
+ USING (credential_key_id IS NOT NULL);
18
+ REVOKE SELECT ON connectors_instances FROM coreloom_background;
19
+ GRANT SELECT (tenant_id, credential_key_id) ON connectors_instances TO coreloom_background;
@@ -3,10 +3,10 @@
3
3
  "schemaVersion": 1,
4
4
  "id": "connectors.core",
5
5
  "package": "@flowdular/module-connectors",
6
- "version": "0.1.5",
6
+ "version": "0.1.6",
7
7
  "platformApi": "^0.1.0",
8
8
  "profile": "full",
9
- "capabilities": ["api", "database", "client", "translations"],
9
+ "capabilities": ["api", "database", "client", "translations", "cli"],
10
10
  "platform": {
11
11
  "server": true,
12
12
  "client": true
@@ -24,5 +24,9 @@
24
24
  "tenancy": "required",
25
25
  "locales": ["en", "pl"],
26
26
  "stability": "experimental",
27
- "provides": ["connectors.definitions.v1", "connectors.calls.v1"]
27
+ "provides": ["connectors.definitions.v1", "connectors.calls.v1"],
28
+ "cli": {
29
+ "catalog": "src/cli/commands.json",
30
+ "entry": "src/cli/index.ts"
31
+ }
28
32
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowdular/module-connectors",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -28,6 +28,7 @@
28
28
  "test": "vitest run"
29
29
  },
30
30
  "dependencies": {
31
+ "@flowdular/cli-protocol": "workspace:*",
31
32
  "@flowdular/client": "workspace:*",
32
33
  "@flowdular/contracts": "workspace:*",
33
34
  "@flowdular/database": "workspace:*",
@@ -1,6 +1,6 @@
1
1
  schemaVersion: 2
2
2
  id: connectors.core
3
- specVersion: 0.1.5
3
+ specVersion: 0.1.6
4
4
  status: approved
5
5
  name: Connectors Core
6
6
  description: Gives a workspace a governed way to call external systems through connector definitions a module or the platform ships, instances an owner configures with sealed credentials and a host allowlist, a test call, a call log without payloads, and an explicit per-instance consent that lets workflows and agents use the connector as an external action, with a generic HTTP JSON connector as the reference.
@@ -10,6 +10,7 @@ capabilities:
10
10
  - database
11
11
  - client
12
12
  - translations
13
+ - cli
13
14
  dependencies:
14
15
  - id: system.core
15
16
  range: ^0.7.0
@@ -0,0 +1,17 @@
1
+ {
2
+ "protocolVersion": 1,
3
+ "moduleId": "connectors.core",
4
+ "commands": [
5
+ {
6
+ "path": ["connectors", "secrets-rotate"],
7
+ "capability": {
8
+ "id": "connectors.secrets.rotate",
9
+ "version": 1,
10
+ "summary": "Re-seal stored connector credentials with the current encryption key.",
11
+ "risk": "process",
12
+ "requiresApprovedSpec": false,
13
+ "supportsDryRun": true
14
+ }
15
+ }
16
+ ]
17
+ }
@@ -0,0 +1,126 @@
1
+ import {
2
+ defineCliExtension,
3
+ type CliExtensionContext,
4
+ } from '@flowdular/sdk/cli-protocol';
5
+ import {
6
+ DATABASE_CAPABILITY_IDS,
7
+ DATABASE_DIALECT_IDS,
8
+ type DatabaseAdapterLease,
9
+ } from '@flowdular/sdk/database';
10
+ import { migrateConnectorsDatabase } from '../services/database-repository.ts';
11
+ import { rotateConnectorCredentials } from '../services/credential-rotation.ts';
12
+ import { credentialVaultFromEnvironment } from '../services/credential-vault.ts';
13
+
14
+ const rotateCapability = {
15
+ id: 'connectors.secrets.rotate',
16
+ version: 1,
17
+ summary:
18
+ 'Re-seal stored connector credentials with the current encryption key.',
19
+ risk: 'process' as const,
20
+ requiresApprovedSpec: false,
21
+ supportsDryRun: true,
22
+ };
23
+
24
+ interface OpenDatabase {
25
+ readonly leases: readonly DatabaseAdapterLease[];
26
+ readonly runtime: DatabaseAdapterLease;
27
+ readonly background: DatabaseAdapterLease;
28
+ }
29
+
30
+ /* The operator command reads the same deployment database the platform does;
31
+ the runner owns the provider and a module owns no driver, so it arrives on
32
+ the context. It may be the first thing to touch a fresh database, so it
33
+ migrates before reading. */
34
+ async function open(context: CliExtensionContext): Promise<OpenDatabase> {
35
+ const databases = context.databases;
36
+ if (!databases) {
37
+ throw new Error(
38
+ 'connectors.core CLI commands read the deployment database, and this workspace has none configured.',
39
+ );
40
+ }
41
+ const requirements = {
42
+ dialectIds: [DATABASE_DIALECT_IDS.postgresql],
43
+ capabilities: [DATABASE_CAPABILITY_IDS.TRANSACTIONS],
44
+ };
45
+ const migration = await databases.acquire({
46
+ namespace: 'connectors.core',
47
+ purpose: 'migration',
48
+ requirements,
49
+ });
50
+ await migrateConnectorsDatabase(migration.database);
51
+ const runtime = await databases.acquire({
52
+ namespace: 'connectors.core',
53
+ purpose: 'runtime',
54
+ requirements,
55
+ });
56
+ /* The inventory counts rows across the whole deployment, which only the
57
+ cross-tenant read-only role may do. */
58
+ const background = await databases.acquire({
59
+ namespace: 'connectors.core',
60
+ purpose: 'background',
61
+ requirements,
62
+ });
63
+ return { leases: [migration, runtime, background], runtime, background };
64
+ }
65
+
66
+ /* The provider belongs to the runner; only the leases this command took are
67
+ released here. */
68
+ async function close(open: OpenDatabase): Promise<void> {
69
+ for (const lease of open.leases) await lease.release();
70
+ }
71
+
72
+ export const cliExtension = defineCliExtension({
73
+ protocolVersion: 1,
74
+ moduleId: 'connectors.core',
75
+ commands: [
76
+ {
77
+ path: ['connectors', 'secrets-rotate'],
78
+ capability: rotateCapability,
79
+ execute: async (context) => {
80
+ /* The report names key ids and row counts only; a credential never
81
+ reaches the command output. */
82
+ const vault = credentialVaultFromEnvironment(
83
+ process.env,
84
+ context.workspaceRoot,
85
+ );
86
+ const opened = await open(context);
87
+ try {
88
+ const report = await rotateConnectorCredentials({
89
+ runtime: opened.runtime.database,
90
+ background: opened.background.database,
91
+ vault,
92
+ apply: context.apply,
93
+ });
94
+ const warnings: string[] = [];
95
+ if (report.skipped > 0) {
96
+ warnings.push(
97
+ `${report.skipped} rows were rewritten by the application while this ran and keep their own envelope. Run the command again.`,
98
+ );
99
+ }
100
+ if (report.unknown > 0) {
101
+ warnings.push(
102
+ `${report.unknown} rows are sealed under a key this ring does not hold and were left as they are. Put that key back in FD_CONNECTORS_SECRET_KEY_PREVIOUS before retiring it.`,
103
+ );
104
+ }
105
+ if (report.refused > 0) {
106
+ warnings.push(
107
+ `${report.refused} rows failed authentication under the key they name and were left as they are. Restore them from a database backup or replace the credential.`,
108
+ );
109
+ }
110
+ return {
111
+ data: { moduleId: 'connectors.core', ...report },
112
+ evidence: [
113
+ 'modules/connectors/spec/module.yaml',
114
+ 'docs/operations.md',
115
+ ],
116
+ warnings,
117
+ };
118
+ } finally {
119
+ await close(opened);
120
+ }
121
+ },
122
+ },
123
+ ],
124
+ });
125
+
126
+ export default cliExtension;
@@ -0,0 +1,221 @@
1
+ import type { DatabaseHandle } from '@flowdular/sdk/database';
2
+ import { KeyringError } from '@flowdular/sdk/kernel';
3
+ import { credentialContext, type CredentialVault } from './credential-vault.ts';
4
+
5
+ /** Rows re-sealed inside one tenant-scoped transaction. */
6
+ export const CREDENTIAL_ROTATION_BATCH = 200;
7
+
8
+ export const CREDENTIAL_ROTATION_TABLE = 'connectors_instances';
9
+
10
+ export interface CredentialKeyCount {
11
+ readonly keyId: string;
12
+ readonly rows: number;
13
+ }
14
+
15
+ export interface CredentialRotationReport {
16
+ readonly table: string;
17
+ /** Key id every row should end on: the current key of the vault. */
18
+ readonly currentKeyId: string;
19
+ readonly counts: readonly CredentialKeyCount[];
20
+ /** Rows on a retired key when the run started. */
21
+ readonly stale: number;
22
+ readonly tenants: number;
23
+ readonly rotated: number;
24
+ /** Rows a concurrent write changed between the read and the update. */
25
+ readonly skipped: number;
26
+ /** Rows sealed under a key id the vault does not hold; left as they are. */
27
+ readonly unknown: number;
28
+ /** Rows that failed authentication under the key they name; left as they are. */
29
+ readonly refused: number;
30
+ }
31
+
32
+ export interface CredentialRotationOptions {
33
+ /** Tenant-scoped handle. Every read and write of a row runs on it. */
34
+ readonly runtime: DatabaseHandle;
35
+ /** Cross-tenant handle. It is granted the key ids and nothing else. */
36
+ readonly background: DatabaseHandle;
37
+ readonly vault: CredentialVault;
38
+ readonly apply?: boolean;
39
+ readonly batchSize?: number;
40
+ }
41
+
42
+ interface StaleRow {
43
+ id: string;
44
+ credential_key_id: string;
45
+ credential_iv: string;
46
+ credential_tag: string;
47
+ credential_ciphertext: string;
48
+ }
49
+
50
+ /* Rows with auth_kind none hold no envelope and no key id, so every statement
51
+ here excludes them by the key id column. */
52
+ const SQL = {
53
+ counts: `SELECT credential_key_id AS key_id, count(*) AS row_count
54
+ FROM connectors_instances
55
+ WHERE credential_key_id IS NOT NULL
56
+ GROUP BY credential_key_id
57
+ ORDER BY credential_key_id`,
58
+ /* The key ids the vault holds are bound as one comma-separated parameter
59
+ (an id is a hex digest), so a row under a key it does not hold is neither
60
+ stale nor walked: it is counted and left. */
61
+ staleTenants: `SELECT tenant_id, count(*) AS row_count
62
+ FROM connectors_instances
63
+ WHERE credential_key_id IS NOT NULL
64
+ AND credential_key_id <> $1
65
+ AND credential_key_id = ANY(string_to_array($2, ','))
66
+ GROUP BY tenant_id
67
+ ORDER BY tenant_id`,
68
+ /* Paged by primary key: a row the optimistic update skipped stays stale, so
69
+ a query that only asked for stale rows would return it forever. */
70
+ staleBatch: `SELECT id, credential_key_id, credential_iv, credential_tag,
71
+ credential_ciphertext
72
+ FROM connectors_instances
73
+ WHERE tenant_id = $1 AND credential_key_id IS NOT NULL
74
+ AND credential_key_id <> $2
75
+ AND credential_key_id = ANY(string_to_array($3, ',')) AND id > $4
76
+ ORDER BY id
77
+ LIMIT $5`,
78
+ /* The envelope the row still holds is the optimistic check: a credential
79
+ replaced by an owner in between keeps its own value. updated_at marks
80
+ changes an owner can see, and a re-seal is not one. */
81
+ reseal: `UPDATE connectors_instances
82
+ SET credential_key_id = $1, credential_iv = $2, credential_tag = $3,
83
+ credential_ciphertext = $4, credential_fingerprint = $5
84
+ WHERE tenant_id = $6 AND id = $7 AND credential_ciphertext = $8`,
85
+ };
86
+
87
+ function count(value: number | bigint | string): number {
88
+ const normalized = Number(value);
89
+ if (!Number.isSafeInteger(normalized)) {
90
+ throw new Error('The connectors database returned an invalid count.');
91
+ }
92
+ return normalized;
93
+ }
94
+
95
+ /**
96
+ * Re-seals every stored connector credential that is not on the current key.
97
+ * The inventory is read once across tenants, and each batch of rows is read,
98
+ * decrypted, re-sealed and written inside one transaction scoped to the tenant
99
+ * that owns them. The fingerprint is keyed by the current key, so it is
100
+ * recomputed while the plaintext is in hand: the value an owner compares
101
+ * changes with the key, the credential does not. It is idempotent: a second
102
+ * run finds nothing to do.
103
+ */
104
+ export async function rotateConnectorCredentials(
105
+ options: CredentialRotationOptions,
106
+ ): Promise<CredentialRotationReport> {
107
+ const currentKeyId = options.vault.keyId;
108
+ const batchSize = options.batchSize ?? CREDENTIAL_ROTATION_BATCH;
109
+ const counts = await options.background.transaction(
110
+ async (transaction) =>
111
+ (
112
+ await transaction.query<{
113
+ key_id: string;
114
+ row_count: number | bigint | string;
115
+ }>({ text: SQL.counts })
116
+ ).rows.map((row) => ({ keyId: row.key_id, rows: count(row.row_count) })),
117
+ { access: 'read' },
118
+ );
119
+ const known = counts
120
+ .map((entry) => entry.keyId)
121
+ .filter((keyId) => options.vault.knows(keyId))
122
+ .join(',');
123
+ const unknown = counts
124
+ .filter((entry) => !options.vault.knows(entry.keyId))
125
+ .reduce((total, entry) => total + entry.rows, 0);
126
+ const inventory = await options.background.transaction(
127
+ async (transaction) =>
128
+ (
129
+ await transaction.query<{
130
+ tenant_id: string;
131
+ row_count: number | bigint | string;
132
+ }>({ text: SQL.staleTenants, parameters: [currentKeyId, known] })
133
+ ).rows,
134
+ { access: 'read' },
135
+ );
136
+ const report = {
137
+ table: CREDENTIAL_ROTATION_TABLE,
138
+ currentKeyId,
139
+ counts,
140
+ stale: inventory.reduce((total, row) => total + count(row.row_count), 0),
141
+ tenants: inventory.length,
142
+ unknown,
143
+ };
144
+ if (options.apply !== true) {
145
+ return { ...report, rotated: 0, skipped: 0, refused: 0 };
146
+ }
147
+ let rotated = 0;
148
+ let skipped = 0;
149
+ let refused = 0;
150
+ for (const { tenant_id: tenantId } of inventory) {
151
+ let cursor = '';
152
+ for (;;) {
153
+ const batch = await options.runtime.transaction(
154
+ async (transaction) => {
155
+ const rows = (
156
+ await transaction.query<StaleRow>({
157
+ text: SQL.staleBatch,
158
+ parameters: [tenantId, currentKeyId, known, cursor, batchSize],
159
+ })
160
+ ).rows;
161
+ let written = 0;
162
+ let left = 0;
163
+ for (const row of rows) {
164
+ const context = credentialContext(tenantId, row.id);
165
+ let plaintext: string;
166
+ try {
167
+ plaintext = options.vault.open(
168
+ {
169
+ keyId: row.credential_key_id,
170
+ iv: row.credential_iv,
171
+ tag: row.credential_tag,
172
+ ciphertext: row.credential_ciphertext,
173
+ },
174
+ context,
175
+ );
176
+ } catch (error) {
177
+ /* A tag that fails under the key it names is evidence the pass
178
+ must not replace with a fresh envelope; the row stays. */
179
+ if (
180
+ error instanceof KeyringError &&
181
+ error.code === 'ENVELOPE_INVALID'
182
+ ) {
183
+ left += 1;
184
+ continue;
185
+ }
186
+ throw error;
187
+ }
188
+ const sealed = options.vault.seal(plaintext, context);
189
+ const result = await transaction.execute({
190
+ text: SQL.reseal,
191
+ parameters: [
192
+ sealed.keyId,
193
+ sealed.iv,
194
+ sealed.tag,
195
+ sealed.ciphertext,
196
+ options.vault.fingerprint(plaintext, context),
197
+ tenantId,
198
+ row.id,
199
+ row.credential_ciphertext,
200
+ ],
201
+ });
202
+ written += result.affectedRows;
203
+ }
204
+ return {
205
+ read: rows.length,
206
+ written,
207
+ left,
208
+ last: rows.at(-1)?.id,
209
+ };
210
+ },
211
+ { access: 'write', tenantId },
212
+ );
213
+ rotated += batch.written;
214
+ refused += batch.left;
215
+ skipped += batch.read - batch.left - batch.written;
216
+ if (batch.read < batchSize || batch.last === undefined) break;
217
+ cursor = batch.last;
218
+ }
219
+ }
220
+ return { ...report, rotated, skipped, refused };
221
+ }
@@ -48,6 +48,8 @@ export function credentialContext(
48
48
  export interface CredentialVault {
49
49
  /** Key id every new envelope is written with; stored rows may carry older ones. */
50
50
  readonly keyId: string;
51
+ /** Whether the current key or a retired one in the ring carries this id. */
52
+ knows(keyId: string): boolean;
51
53
  seal(secret: string, context: string): SealedCredential;
52
54
  open(envelope: SealedCredential, context: string): string;
53
55
  /**
@@ -95,6 +97,10 @@ export class AesGcmCredentialVault implements CredentialVault {
95
97
  return this.#keyring.keyId;
96
98
  }
97
99
 
100
+ knows(keyId: string): boolean {
101
+ return this.#keyring.knows(keyId);
102
+ }
103
+
98
104
  fingerprint(secret: string, context: string): string {
99
105
  return createHmac('sha256', this.#fingerprintKey)
100
106
  .update(context, 'utf8')