@flowdular/sdk 0.3.0 → 0.3.2
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/.ai/platform-capabilities.md +4 -2
- package/.ai/policies/capabilities.yaml +30 -3
- package/.ai/references/catalog/migrations/0005_catalog_list_indexes.down.sql +3 -0
- package/.ai/references/catalog/migrations/0005_catalog_list_indexes.up.sql +11 -0
- package/.ai/references/catalog/module.json +11 -1
- package/.ai/references/catalog/package.json +2 -2
- package/.ai/references/catalog/spec/module.yaml +25 -4
- package/.ai/references/catalog/src/agent/tools.ts +19 -10
- package/.ai/references/catalog/src/api/endpoints.ts +150 -10
- package/.ai/references/catalog/src/api/list-cursor.ts +83 -0
- package/.ai/references/catalog/src/client/CatalogView.tsrx +505 -159
- package/.ai/references/catalog/src/client/api.ts +124 -36
- package/.ai/references/catalog/src/client/contribution.tsrx +5 -0
- package/.ai/references/catalog/src/client/state.ts +169 -3
- package/.ai/references/catalog/src/domain/lists.ts +7 -0
- package/.ai/references/catalog/src/domain/types.ts +20 -0
- package/.ai/references/catalog/src/platform.ts +20 -0
- package/.ai/references/catalog/src/services/catalog-service.ts +143 -8
- package/.ai/references/catalog/src/services/database-repository.ts +104 -17
- package/.ai/references/catalog/src/services/item-export.ts +81 -0
- package/.ai/references/catalog/src/services/migration.ts +27 -1
- package/.ai/references/catalog/src/services/repository.ts +31 -2
- package/.ai/references/catalog/tests/agent-tools.test.ts +6 -5
- package/.ai/references/catalog/tests/client-state.test.ts +124 -0
- package/.ai/references/catalog/tests/endpoints.test.ts +269 -0
- package/.ai/references/catalog/tests/export.test.ts +134 -0
- package/.ai/references/catalog/tests/idempotency.test.ts +15 -14
- package/.ai/references/catalog/tests/list.test.ts +217 -0
- package/.ai/references/catalog/tests/migrations.test.ts +58 -2
- package/.ai/references/catalog/tests/module.test.ts +2 -1
- package/.ai/references/catalog/tests/support/database.ts +14 -0
- package/.ai/references/catalog/translations/en.json +35 -4
- package/.ai/references/catalog/translations/pl.json +35 -4
- package/.ai/references/catalog.provenance.json +34 -26
- package/.ai/skills/cli-extension/SKILL.md +1 -1
- package/.ai/skills/deploy-operate/SKILL.md +7 -2
- package/README.md +1 -1
- package/assets/flowdular-banner.webp +0 -0
- package/modules/access/module.json +1 -1
- package/modules/access/spec/module.yaml +1 -1
- package/modules/agents/module.json +1 -1
- package/modules/agents/spec/module.yaml +1 -1
- package/modules/approvals/migrations/0005_approvals_grant_audit.down.sql +4 -0
- package/modules/approvals/migrations/0005_approvals_grant_audit.up.sql +20 -0
- package/modules/approvals/module.json +2 -2
- package/modules/approvals/package.json +1 -1
- package/modules/approvals/spec/module.yaml +12 -3
- package/modules/approvals/src/domain/capability.ts +12 -0
- package/modules/approvals/src/domain/grant.ts +69 -0
- package/modules/approvals/src/domain/types.ts +14 -0
- package/modules/approvals/src/index.ts +9 -0
- package/modules/approvals/src/platform.ts +8 -0
- package/modules/approvals/src/server/runtime.ts +4 -0
- package/modules/approvals/src/services/approvals-service.ts +80 -0
- package/modules/approvals/src/services/database-repository.ts +65 -6
- package/modules/approvals/src/services/migration.ts +34 -0
- package/modules/approvals/src/services/repository.ts +8 -0
- package/modules/audit/module.json +1 -1
- package/modules/audit/spec/module.yaml +1 -1
- package/modules/auth/module.json +2 -2
- package/modules/auth/package.json +1 -1
- package/modules/auth/spec/module.yaml +2 -2
- package/modules/auth/src/middleware/authentication.ts +5 -1
- package/modules/auth/src/services/auth-service.ts +26 -0
- package/modules/auth/src/services/database-repository.ts +9 -0
- package/modules/auth/src/services/repository.ts +2 -0
- package/modules/automations/module.json +1 -1
- package/modules/automations/spec/module.yaml +1 -1
- package/modules/connectors/migrations/0003_connectors_rotation_inventory.down.sql +2 -0
- package/modules/connectors/migrations/0003_connectors_rotation_inventory.up.sql +19 -0
- package/modules/connectors/module.json +8 -4
- package/modules/connectors/package.json +2 -1
- package/modules/connectors/spec/module.yaml +3 -2
- package/modules/connectors/src/cli/commands.json +17 -0
- package/modules/connectors/src/cli/index.ts +126 -0
- package/modules/connectors/src/services/credential-rotation.ts +221 -0
- package/modules/connectors/src/services/credential-vault.ts +6 -0
- package/modules/connectors/src/services/migration.ts +36 -0
- package/modules/directory/module.json +1 -1
- package/modules/directory/spec/module.yaml +1 -1
- package/modules/documents/migrations/0003_documents_rotation_inventory.down.sql +2 -0
- package/modules/documents/migrations/0003_documents_rotation_inventory.up.sql +18 -0
- package/modules/documents/module.json +8 -4
- package/modules/documents/package.json +2 -1
- package/modules/documents/spec/module.yaml +3 -2
- package/modules/documents/src/cli/commands.json +17 -0
- package/modules/documents/src/cli/index.ts +145 -0
- package/modules/documents/src/services/database-repository.ts +15 -4
- package/modules/documents/src/services/documents-service.ts +13 -9
- package/modules/documents/src/services/migration.ts +35 -0
- package/modules/documents/src/services/repository.ts +12 -2
- package/modules/documents/src/services/storage-rotation.ts +157 -0
- package/modules/exports/migrations/0003_exports_rotation_inventory.down.sql +1 -0
- package/modules/exports/migrations/0003_exports_rotation_inventory.up.sql +9 -0
- package/modules/exports/module.json +8 -4
- package/modules/exports/package.json +2 -1
- package/modules/exports/spec/module.yaml +3 -2
- package/modules/exports/src/cli/commands.json +17 -0
- package/modules/exports/src/cli/index.ts +145 -0
- package/modules/exports/src/server/index.ts +0 -1
- package/modules/exports/src/services/data-classes.ts +16 -13
- package/modules/exports/src/services/database-repository.ts +30 -32
- package/modules/exports/src/services/migration.ts +27 -0
- package/modules/exports/src/services/repository.ts +9 -10
- package/modules/exports/src/services/storage-rotation.ts +138 -0
- package/modules/import/module.json +1 -1
- package/modules/import/spec/module.yaml +1 -1
- package/modules/metering/module.json +1 -1
- package/modules/metering/spec/module.yaml +1 -1
- package/modules/notifications/module.json +1 -1
- package/modules/notifications/spec/module.yaml +1 -1
- package/modules/reports/module.json +1 -1
- package/modules/reports/spec/module.yaml +1 -1
- package/modules/sandbox/module.json +1 -1
- package/modules/sandbox/spec/module.yaml +1 -1
- package/modules/search/module.json +1 -1
- package/modules/search/spec/module.yaml +1 -1
- package/modules/system/migrations/0001_system_module_activations.down.sql +2 -0
- package/modules/system/migrations/0001_system_module_activations.up.sql +17 -0
- package/modules/system/module.json +3 -2
- package/modules/system/package.json +2 -2
- package/modules/system/spec/module.yaml +98 -1
- package/modules/system/src/client/ModulesView.tsrx +101 -8
- package/modules/system/src/client/api.ts +52 -11
- package/modules/system/src/client/module-columns.tsrx +32 -6
- package/modules/system/src/client/state.ts +26 -0
- package/modules/system/src/domain/modules.ts +93 -0
- package/modules/system/src/index.ts +13 -0
- package/modules/system/src/platform.ts +41 -1
- package/modules/system/src/server/capability.ts +6 -0
- package/modules/system/src/server/endpoints.ts +125 -5
- package/modules/system/src/server/index.ts +26 -0
- package/modules/system/src/server/module-catalog.ts +19 -0
- package/modules/system/src/server/runtime.ts +102 -0
- package/modules/system/src/services/database-repository.ts +91 -0
- package/modules/system/src/services/migration.ts +39 -0
- package/modules/system/src/services/module-activation-service.ts +245 -0
- package/modules/system/src/services/repository.ts +17 -0
- package/modules/system/translations/en.json +17 -1
- package/modules/system/translations/pl.json +17 -1
- package/modules/users/module.json +2 -2
- package/modules/users/package.json +1 -1
- package/modules/users/spec/module.yaml +6 -9
- package/modules/users/src/client/UsersView.tsrx +27 -13
- package/modules/users/src/client/member-columns.tsrx +13 -4
- package/modules/users/src/client/state.ts +16 -0
- package/modules/users/src/services/users-service.ts +3 -0
- package/modules/users/translations/en.json +2 -1
- package/modules/users/translations/pl.json +2 -1
- package/modules/workflows/module.json +2 -2
- package/modules/workflows/package.json +1 -1
- package/modules/workflows/spec/module.yaml +2 -2
- package/modules/workflows/src/services/database-repository.ts +93 -53
- package/package.json +1 -1
- package/packages/client/package.json +1 -0
- package/packages/client/src/ApplicationShell.tsrx +46 -5
- package/packages/client/src/index.ts +5 -0
- package/packages/client/src/shell/modules.ts +49 -0
- package/packages/client/src/shell/types.ts +3 -0
- package/packages/contracts/src/index.ts +11 -1
- package/packages/database/src/backup.ts +1 -0
- package/packages/database/src/migrations.ts +7 -0
- package/packages/harness/src/runtime.ts +169 -10
- package/packages/harness/src/tool-adapters.ts +6 -13
- package/packages/kernel/src/approval-grant.ts +310 -0
- package/packages/kernel/src/index.ts +20 -0
- package/packages/server/src/endpoint.ts +17 -1
- package/packages/server/src/index.ts +10 -0
- package/packages/server/src/module-activation.ts +69 -0
- package/packages/storage/src/envelope.ts +70 -21
- package/packages/storage/src/index.ts +7 -1
- package/packages/storage/src/port.ts +12 -1
- package/packages/storage/src/reseal.ts +128 -0
- package/assets/flowdular-banner.png +0 -0
|
@@ -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.
|
|
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
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"dependencies": [
|
|
15
15
|
{
|
|
16
16
|
"id": "system.core",
|
|
17
|
-
"range": "^0.
|
|
17
|
+
"range": "^0.8.0"
|
|
18
18
|
},
|
|
19
19
|
{
|
|
20
20
|
"id": "auth.core",
|
|
@@ -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.
|
|
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.
|
|
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,9 +10,10 @@ capabilities:
|
|
|
10
10
|
- database
|
|
11
11
|
- client
|
|
12
12
|
- translations
|
|
13
|
+
- cli
|
|
13
14
|
dependencies:
|
|
14
15
|
- id: system.core
|
|
15
|
-
range: ^0.
|
|
16
|
+
range: ^0.8.0
|
|
16
17
|
- id: auth.core
|
|
17
18
|
range: ^0.13.0
|
|
18
19
|
tenancy: required
|
|
@@ -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')
|
|
@@ -111,6 +111,28 @@ async function combined(
|
|
|
111
111
|
return 'partial';
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
/* Mirrors migrations/0003_connectors_rotation_inventory.up.sql byte for byte. */
|
|
115
|
+
export const CONNECTORS_MIGRATION_003 = `-- The credential key rotation has to find the instances still sealed with a
|
|
116
|
+
-- retired key before it knows whose they are, so the cross-tenant role may read
|
|
117
|
+
-- the key id of every row that holds an envelope and nothing else: the nonce,
|
|
118
|
+
-- the tag, the ciphertext and the fingerprint stay unreadable on this
|
|
119
|
+
-- connection, and every row it re-seals is read again under the workspace that
|
|
120
|
+
-- row named. PostgreSQL checks column privileges in WHERE too, so the key id is
|
|
121
|
+
-- part of the grant.
|
|
122
|
+
DO $$
|
|
123
|
+
BEGIN
|
|
124
|
+
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'coreloom_background') THEN
|
|
125
|
+
RAISE EXCEPTION 'The coreloom_background role must exist before this migration.';
|
|
126
|
+
END IF;
|
|
127
|
+
END
|
|
128
|
+
$$;
|
|
129
|
+
CREATE POLICY connectors_instances_background_policy ON connectors_instances
|
|
130
|
+
FOR SELECT TO coreloom_background
|
|
131
|
+
USING (credential_key_id IS NOT NULL);
|
|
132
|
+
REVOKE SELECT ON connectors_instances FROM coreloom_background;
|
|
133
|
+
GRANT SELECT (tenant_id, credential_key_id) ON connectors_instances TO coreloom_background;
|
|
134
|
+
`;
|
|
135
|
+
|
|
114
136
|
export const databaseMigrations: readonly DatabaseMigration[] = [
|
|
115
137
|
{
|
|
116
138
|
id: '0001_connectors_core',
|
|
@@ -163,4 +185,18 @@ export const databaseMigrations: readonly DatabaseMigration[] = [
|
|
|
163
185
|
],
|
|
164
186
|
),
|
|
165
187
|
},
|
|
188
|
+
{
|
|
189
|
+
id: '0003_connectors_rotation_inventory',
|
|
190
|
+
sql: { postgresql: CONNECTORS_MIGRATION_003 },
|
|
191
|
+
/* A policy and a column grant leave no schema object behind, so the
|
|
192
|
+
privilege itself is what proves this migration ran. */
|
|
193
|
+
inspectExisting: async (database) => {
|
|
194
|
+
const result = await database.query<{ granted: boolean }>({
|
|
195
|
+
text: `SELECT CASE WHEN to_regclass('connectors_instances') IS NOT NULL THEN
|
|
196
|
+
has_column_privilege('coreloom_background', 'connectors_instances', 'credential_key_id', 'SELECT')
|
|
197
|
+
ELSE false END AS granted`,
|
|
198
|
+
});
|
|
199
|
+
return result.rows[0]?.granted === true ? 'complete' : 'absent';
|
|
200
|
+
},
|
|
201
|
+
},
|
|
166
202
|
];
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
-- The storage key rotation has to find the workspaces that still hold objects
|
|
2
|
+
-- before it knows which objects those are, so the cross-tenant role may count
|
|
3
|
+
-- stored rows by workspace and nothing else: the storage key, the record and
|
|
4
|
+
-- the file name stay invisible to it, and every object it names is read again
|
|
5
|
+
-- under the workspace that row named. PostgreSQL checks column privileges in
|
|
6
|
+
-- WHERE too, so `status` is part of the grant.
|
|
7
|
+
DO $$
|
|
8
|
+
BEGIN
|
|
9
|
+
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'coreloom_background') THEN
|
|
10
|
+
RAISE EXCEPTION 'The coreloom_background role must exist before this migration.';
|
|
11
|
+
END IF;
|
|
12
|
+
END
|
|
13
|
+
$$;
|
|
14
|
+
CREATE POLICY documents_files_background_policy ON documents_files
|
|
15
|
+
FOR SELECT TO coreloom_background
|
|
16
|
+
USING (status = 'stored');
|
|
17
|
+
REVOKE SELECT ON documents_files FROM coreloom_background;
|
|
18
|
+
GRANT SELECT (tenant_id, status) ON documents_files TO coreloom_background;
|
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
"schemaVersion": 1,
|
|
4
4
|
"id": "documents.core",
|
|
5
5
|
"package": "@flowdular/module-documents",
|
|
6
|
-
"version": "0.1.
|
|
6
|
+
"version": "0.1.9",
|
|
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
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"dependencies": [
|
|
15
15
|
{
|
|
16
16
|
"id": "system.core",
|
|
17
|
-
"range": "^0.
|
|
17
|
+
"range": "^0.8.0"
|
|
18
18
|
},
|
|
19
19
|
{
|
|
20
20
|
"id": "auth.core",
|
|
@@ -24,5 +24,9 @@
|
|
|
24
24
|
"provides": ["documents.attachments.v1"],
|
|
25
25
|
"tenancy": "required",
|
|
26
26
|
"locales": ["en", "pl"],
|
|
27
|
-
"stability": "experimental"
|
|
27
|
+
"stability": "experimental",
|
|
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-documents",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
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: documents.core
|
|
3
|
-
specVersion: 0.1.
|
|
3
|
+
specVersion: 0.1.9
|
|
4
4
|
status: approved
|
|
5
5
|
name: Documents Core
|
|
6
6
|
description: Lets a workspace attach files to its records through the platform storage port, owning only the document metadata and the reference to the record, with tenant-scoped keys, encrypted bytes, size and type limits, and a scan state, so a module attaches by reference and never holds bytes.
|
|
@@ -10,9 +10,10 @@ capabilities:
|
|
|
10
10
|
- database
|
|
11
11
|
- client
|
|
12
12
|
- translations
|
|
13
|
+
- cli
|
|
13
14
|
dependencies:
|
|
14
15
|
- id: system.core
|
|
15
|
-
range: ^0.
|
|
16
|
+
range: ^0.8.0
|
|
16
17
|
- id: auth.core
|
|
17
18
|
range: ^0.13.0
|
|
18
19
|
tenancy: required
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"protocolVersion": 1,
|
|
3
|
+
"moduleId": "documents.core",
|
|
4
|
+
"commands": [
|
|
5
|
+
{
|
|
6
|
+
"path": ["documents", "secrets-rotate"],
|
|
7
|
+
"capability": {
|
|
8
|
+
"id": "documents.storage.rotate",
|
|
9
|
+
"version": 1,
|
|
10
|
+
"summary": "Re-seal stored document objects with the current storage encryption key.",
|
|
11
|
+
"risk": "process",
|
|
12
|
+
"requiresApprovedSpec": false,
|
|
13
|
+
"supportsDryRun": true
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
]
|
|
17
|
+
}
|