@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,69 @@
|
|
|
1
|
+
import { APPROVAL_GRANT_INPUT_DIGEST_LENGTH } from '@flowdular/sdk/kernel';
|
|
2
|
+
import { APPROVAL_LIMITS } from './capability.ts';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* How long after the approving decision the grant an approved request yields
|
|
6
|
+
* still verifies. The token is derived from the row and the key on every read,
|
|
7
|
+
* so the window is measured from `resolvedAt`, not from the read.
|
|
8
|
+
*/
|
|
9
|
+
export const APPROVAL_GRANT_TTL_MS = 60 * 60 * 1_000;
|
|
10
|
+
|
|
11
|
+
const SUBJECT_PREFIX = 'capability:';
|
|
12
|
+
const CAPABILITY_ID = /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$/;
|
|
13
|
+
const INPUT_DIGEST = new RegExp(
|
|
14
|
+
`^[a-f0-9]{${APPROVAL_GRANT_INPUT_DIGEST_LENGTH}}$`,
|
|
15
|
+
);
|
|
16
|
+
/* Prefix, id, one separator and the digest have to fit the subjectRef bound. */
|
|
17
|
+
export const APPROVAL_GRANT_CAPABILITY_ID_LENGTH =
|
|
18
|
+
APPROVAL_LIMITS.subjectRef -
|
|
19
|
+
SUBJECT_PREFIX.length -
|
|
20
|
+
1 -
|
|
21
|
+
APPROVAL_GRANT_INPUT_DIGEST_LENGTH;
|
|
22
|
+
|
|
23
|
+
/** The capability and the exact input an approval is asked for. */
|
|
24
|
+
export interface CapabilitySubject {
|
|
25
|
+
readonly capabilityId: string;
|
|
26
|
+
/** `approvalInputDigest` of the invocation input, as the runner computes it. */
|
|
27
|
+
readonly inputDigest: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A `subjectRef` of the form `capability:<capabilityId>:<inputDigest>`. A
|
|
32
|
+
* request opened with it yields, once approved, a signed grant for exactly
|
|
33
|
+
* that capability and that input.
|
|
34
|
+
*/
|
|
35
|
+
export function encodeCapabilitySubjectRef(subject: CapabilitySubject): string {
|
|
36
|
+
if (
|
|
37
|
+
!CAPABILITY_ID.test(subject.capabilityId) ||
|
|
38
|
+
subject.capabilityId.length > APPROVAL_GRANT_CAPABILITY_ID_LENGTH
|
|
39
|
+
) {
|
|
40
|
+
throw new RangeError(
|
|
41
|
+
`capabilityId must be a lowercase dot-separated identifier of at most ${APPROVAL_GRANT_CAPABILITY_ID_LENGTH} characters.`,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
if (!INPUT_DIGEST.test(subject.inputDigest)) {
|
|
45
|
+
throw new RangeError(
|
|
46
|
+
`inputDigest must be ${APPROVAL_GRANT_INPUT_DIGEST_LENGTH} lowercase hex characters.`,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
return `${SUBJECT_PREFIX}${subject.capabilityId}:${subject.inputDigest}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Null for a subject reference that names a record rather than a capability. */
|
|
53
|
+
export function decodeCapabilitySubjectRef(
|
|
54
|
+
subjectRef: string,
|
|
55
|
+
): CapabilitySubject | null {
|
|
56
|
+
if (!subjectRef.startsWith(SUBJECT_PREFIX)) return null;
|
|
57
|
+
const separator = subjectRef.lastIndexOf(':');
|
|
58
|
+
const capabilityId = subjectRef.slice(SUBJECT_PREFIX.length, separator);
|
|
59
|
+
const inputDigest = subjectRef.slice(separator + 1);
|
|
60
|
+
if (
|
|
61
|
+
separator <= SUBJECT_PREFIX.length ||
|
|
62
|
+
!CAPABILITY_ID.test(capabilityId) ||
|
|
63
|
+
capabilityId.length > APPROVAL_GRANT_CAPABILITY_ID_LENGTH ||
|
|
64
|
+
!INPUT_DIGEST.test(inputDigest)
|
|
65
|
+
) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
return { capabilityId, inputDigest };
|
|
69
|
+
}
|
|
@@ -76,6 +76,20 @@ export interface ApprovalDecision {
|
|
|
76
76
|
readonly decidedAt: number;
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
/** What the ledger records about a request beyond its decisions. */
|
|
80
|
+
export const APPROVAL_AUDIT_ACTIONS = ['grant.issued'] as const;
|
|
81
|
+
|
|
82
|
+
export type ApprovalAuditAction = (typeof APPROVAL_AUDIT_ACTIONS)[number];
|
|
83
|
+
|
|
84
|
+
export interface ApprovalAuditEntry {
|
|
85
|
+
readonly id: string;
|
|
86
|
+
readonly tenantId: string;
|
|
87
|
+
readonly requestId: string;
|
|
88
|
+
readonly action: ApprovalAuditAction;
|
|
89
|
+
readonly metadata: Readonly<Record<string, string | number>>;
|
|
90
|
+
readonly occurredAt: number;
|
|
91
|
+
}
|
|
92
|
+
|
|
79
93
|
/** What an erasure writes over the comment of a decision it keeps. */
|
|
80
94
|
export const ERASED_DECISION_COMMENT = '[erased]';
|
|
81
95
|
|
|
@@ -30,6 +30,13 @@ export type {
|
|
|
30
30
|
ApprovalsRequests,
|
|
31
31
|
OpenApprovalInput,
|
|
32
32
|
} from './domain/capability.ts';
|
|
33
|
+
export {
|
|
34
|
+
APPROVAL_GRANT_CAPABILITY_ID_LENGTH,
|
|
35
|
+
APPROVAL_GRANT_TTL_MS,
|
|
36
|
+
decodeCapabilitySubjectRef,
|
|
37
|
+
encodeCapabilitySubjectRef,
|
|
38
|
+
} from './domain/grant.ts';
|
|
39
|
+
export type { CapabilitySubject } from './domain/grant.ts';
|
|
33
40
|
|
|
34
41
|
export {
|
|
35
42
|
APPROVAL_DECISIONS,
|
|
@@ -38,6 +45,8 @@ export {
|
|
|
38
45
|
isTerminalApprovalStatus,
|
|
39
46
|
} from './domain/types.ts';
|
|
40
47
|
export type {
|
|
48
|
+
ApprovalAuditAction,
|
|
49
|
+
ApprovalAuditEntry,
|
|
41
50
|
ApprovalDecision,
|
|
42
51
|
ApprovalDecisionKind,
|
|
43
52
|
ApprovalMember,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { approvalGrantKeyringFromEnvironment } from '@flowdular/sdk/kernel';
|
|
1
2
|
import type {
|
|
2
3
|
PlatformServerComposition,
|
|
3
4
|
PlatformServerContext,
|
|
@@ -96,6 +97,9 @@ export function createServerComposition(
|
|
|
96
97
|
context.capabilities.get<NotificationPublisher>(
|
|
97
98
|
NOTIFICATIONS_PUBLISH_CAPABILITY,
|
|
98
99
|
),
|
|
100
|
+
/* Without FD_APPROVAL_GRANT_KEY an approved capability request yields no
|
|
101
|
+
grant, and the runner and the harness keep refusing the capability. */
|
|
102
|
+
grants: approvalGrantKeyringFromEnvironment(context.environment),
|
|
99
103
|
});
|
|
100
104
|
/* The runtime opens its database leases lazily, so the capability is a
|
|
101
105
|
forwarder rather than a resolved object: registration must not force a
|
|
@@ -106,6 +110,10 @@ export function createServerComposition(
|
|
|
106
110
|
open: async (input) => (await runtime.service()).capability().open(input),
|
|
107
111
|
get: async (tenantId, id) =>
|
|
108
112
|
(await runtime.service()).capability().get(tenantId, id),
|
|
113
|
+
grant: async (tenantId, id, subjectModule) =>
|
|
114
|
+
(await runtime.service())
|
|
115
|
+
.capability()
|
|
116
|
+
.grant(tenantId, id, subjectModule),
|
|
109
117
|
list: async (tenantId, filter) =>
|
|
110
118
|
(await runtime.service()).capability().list(tenantId, filter),
|
|
111
119
|
cancel: async (tenantId, id, actorAccountId) =>
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
DATABASE_CAPABILITY_IDS,
|
|
8
8
|
DATABASE_DIALECT_IDS,
|
|
9
9
|
} from '@flowdular/sdk/database';
|
|
10
|
+
import type { ApprovalGrantKeyring } from '@flowdular/sdk/kernel';
|
|
10
11
|
import type { JobRunner } from '@flowdular/sdk/server';
|
|
11
12
|
import type { ApprovalMember } from '../domain/types.ts';
|
|
12
13
|
import {
|
|
@@ -42,6 +43,8 @@ export interface ApprovalsRuntimeOptions {
|
|
|
42
43
|
readonly notifications?: NotificationPublisherResolver;
|
|
43
44
|
readonly callbacks?: ApprovalCallbackRegistry;
|
|
44
45
|
readonly repository?: ApprovalsRepository;
|
|
46
|
+
/** Signs the grant an approved capability request yields; absent means none. */
|
|
47
|
+
readonly grants?: ApprovalGrantKeyring | undefined;
|
|
45
48
|
readonly now?: () => number;
|
|
46
49
|
}
|
|
47
50
|
|
|
@@ -122,6 +125,7 @@ export function createApprovalsRuntime(
|
|
|
122
125
|
...(options.notifications
|
|
123
126
|
? { notifications: options.notifications }
|
|
124
127
|
: {}),
|
|
128
|
+
...(options.grants ? { grants: options.grants } : {}),
|
|
125
129
|
...(options.now ? { now: options.now } : {}),
|
|
126
130
|
}));
|
|
127
131
|
|
|
@@ -1,12 +1,21 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import {
|
|
3
|
+
issueApprovalGrant,
|
|
4
|
+
type ApprovalGrantKeyring,
|
|
5
|
+
} from '@flowdular/sdk/kernel';
|
|
2
6
|
import {
|
|
3
7
|
APPROVAL_LIMITS,
|
|
4
8
|
type ApprovalRequestFilter,
|
|
5
9
|
type ApprovalsRequests,
|
|
6
10
|
type OpenApprovalInput,
|
|
7
11
|
} from '../domain/capability.ts';
|
|
12
|
+
import {
|
|
13
|
+
APPROVAL_GRANT_TTL_MS,
|
|
14
|
+
decodeCapabilitySubjectRef,
|
|
15
|
+
} from '../domain/grant.ts';
|
|
8
16
|
import {
|
|
9
17
|
APPROVAL_STATUSES,
|
|
18
|
+
type ApprovalAuditEntry,
|
|
10
19
|
type ApprovalDecideOutcome,
|
|
11
20
|
type ApprovalDecision,
|
|
12
21
|
type ApprovalListPage,
|
|
@@ -61,6 +70,8 @@ export interface ApprovalsServiceOptions {
|
|
|
61
70
|
readonly defaultExpiryDays: (tenantId: string) => number | Promise<number>;
|
|
62
71
|
readonly notifications?: NotificationPublisherResolver;
|
|
63
72
|
readonly callbacks?: ApprovalCallbackRegistry;
|
|
73
|
+
/** Signs the grant an approved capability request yields; absent means none is issued. */
|
|
74
|
+
readonly grants?: ApprovalGrantKeyring | undefined;
|
|
64
75
|
readonly now?: () => number;
|
|
65
76
|
}
|
|
66
77
|
|
|
@@ -71,6 +82,7 @@ export class ApprovalsService {
|
|
|
71
82
|
readonly #defaultExpiryDays: ApprovalsServiceOptions['defaultExpiryDays'];
|
|
72
83
|
readonly #notifications: NotificationPublisherResolver | undefined;
|
|
73
84
|
readonly #callbacks: ApprovalCallbackRegistry;
|
|
85
|
+
readonly #grants: ApprovalGrantKeyring | undefined;
|
|
74
86
|
readonly #now: () => number;
|
|
75
87
|
|
|
76
88
|
constructor(options: ApprovalsServiceOptions) {
|
|
@@ -80,6 +92,7 @@ export class ApprovalsService {
|
|
|
80
92
|
this.#defaultExpiryDays = options.defaultExpiryDays;
|
|
81
93
|
this.#notifications = options.notifications;
|
|
82
94
|
this.#callbacks = options.callbacks ?? createApprovalCallbackRegistry();
|
|
95
|
+
this.#grants = options.grants;
|
|
83
96
|
this.#now = options.now ?? Date.now;
|
|
84
97
|
}
|
|
85
98
|
|
|
@@ -186,6 +199,48 @@ export class ApprovalsService {
|
|
|
186
199
|
);
|
|
187
200
|
}
|
|
188
201
|
|
|
202
|
+
/**
|
|
203
|
+
* The grant an approved capability request yields, derived from the row and
|
|
204
|
+
* the current key on every read: the same claims sign to the same token
|
|
205
|
+
* under the same key, and a rotated key signs a fresh one that verifies the
|
|
206
|
+
* same. Nothing is issued for any other state, or once the window closed.
|
|
207
|
+
*/
|
|
208
|
+
grantFor(request: ApprovalRequest): string | undefined {
|
|
209
|
+
if (!this.#grants || request.status !== 'approved') return undefined;
|
|
210
|
+
if (request.resolvedAt === null) return undefined;
|
|
211
|
+
const subject = decodeCapabilitySubjectRef(request.subjectRef);
|
|
212
|
+
if (!subject) return undefined;
|
|
213
|
+
const expiresAt = request.resolvedAt + APPROVAL_GRANT_TTL_MS;
|
|
214
|
+
if (expiresAt <= this.#now()) return undefined;
|
|
215
|
+
return issueApprovalGrant(this.#grants, {
|
|
216
|
+
tenantId: request.tenantId,
|
|
217
|
+
capabilityId: subject.capabilityId,
|
|
218
|
+
inputDigest: subject.inputDigest,
|
|
219
|
+
requestId: request.id,
|
|
220
|
+
issuedAt: request.resolvedAt,
|
|
221
|
+
expiresAt,
|
|
222
|
+
nonce: request.id,
|
|
223
|
+
}).token;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/* The token reaches only the module that asked the question: a holder of
|
|
227
|
+
the capability naming another module's request is answered null. */
|
|
228
|
+
async grant(
|
|
229
|
+
tenantId: string,
|
|
230
|
+
id: string,
|
|
231
|
+
subjectModule: string,
|
|
232
|
+
): Promise<string | null> {
|
|
233
|
+
const module = bounded(
|
|
234
|
+
subjectModule,
|
|
235
|
+
'subjectModule',
|
|
236
|
+
1,
|
|
237
|
+
APPROVAL_LIMITS.subjectModule,
|
|
238
|
+
);
|
|
239
|
+
const request = await this.get(tenantId, id);
|
|
240
|
+
if (!request || request.subjectModule !== module) return null;
|
|
241
|
+
return this.grantFor(request) ?? null;
|
|
242
|
+
}
|
|
243
|
+
|
|
189
244
|
async detail(
|
|
190
245
|
tenantId: string,
|
|
191
246
|
id: string,
|
|
@@ -458,6 +513,8 @@ export class ApprovalsService {
|
|
|
458
513
|
return {
|
|
459
514
|
open: (input) => this.open(input),
|
|
460
515
|
get: (tenantId, id) => this.get(tenantId, id),
|
|
516
|
+
grant: (tenantId, id, subjectModule) =>
|
|
517
|
+
this.grant(tenantId, id, subjectModule),
|
|
461
518
|
list: (tenantId, filter) => this.list(tenantId, filter),
|
|
462
519
|
cancel: async (tenantId, id, actorAccountId) =>
|
|
463
520
|
(await this.cancel(tenantId, id, actorAccountId)).request,
|
|
@@ -476,6 +533,28 @@ export class ApprovalsService {
|
|
|
476
533
|
return request;
|
|
477
534
|
}
|
|
478
535
|
|
|
536
|
+
/* The moment a capability request is approved is the moment its grant can
|
|
537
|
+
be read, so that is what the ledger records, under the deciding
|
|
538
|
+
transaction, with what the grant will say and which key signs it. */
|
|
539
|
+
#grantIssued(resolved: ApprovalRequest): ApprovalAuditEntry | null {
|
|
540
|
+
if (!this.#grants || resolved.status !== 'approved') return null;
|
|
541
|
+
const subject = decodeCapabilitySubjectRef(resolved.subjectRef);
|
|
542
|
+
if (!subject || resolved.resolvedAt === null) return null;
|
|
543
|
+
return {
|
|
544
|
+
id: randomUUID(),
|
|
545
|
+
tenantId: resolved.tenantId,
|
|
546
|
+
requestId: resolved.id,
|
|
547
|
+
action: 'grant.issued',
|
|
548
|
+
metadata: {
|
|
549
|
+
capabilityId: subject.capabilityId,
|
|
550
|
+
inputDigest: subject.inputDigest,
|
|
551
|
+
keyId: this.#grants.keyId,
|
|
552
|
+
expiresAt: resolved.resolvedAt + APPROVAL_GRANT_TTL_MS,
|
|
553
|
+
},
|
|
554
|
+
occurredAt: resolved.resolvedAt,
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
|
|
479
558
|
/* Only a member who may read the request is told its state; every caller
|
|
480
559
|
settles readability before this runs. */
|
|
481
560
|
#assertPending(request: ApprovalRequest): void {
|
|
@@ -512,6 +591,7 @@ export class ApprovalsService {
|
|
|
512
591
|
},
|
|
513
592
|
resolve,
|
|
514
593
|
resolvedAt: decidedAt,
|
|
594
|
+
audit: (resolved) => this.#grantIssued(resolved),
|
|
515
595
|
});
|
|
516
596
|
if (result.outcome === 'not-found') {
|
|
517
597
|
throw new ApprovalsServiceError(
|
|
@@ -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 [
|
|
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,
|
package/modules/auth/module.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"schemaVersion": 1,
|
|
4
4
|
"id": "auth.core",
|
|
5
5
|
"package": "@flowdular/module-auth",
|
|
6
|
-
"version": "0.13.
|
|
6
|
+
"version": "0.13.11",
|
|
7
7
|
"profile": "full",
|
|
8
8
|
"capabilities": ["api", "database", "client", "translations", "cli"],
|
|
9
9
|
"platform": {
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"dependencies": [
|
|
13
13
|
{
|
|
14
14
|
"id": "system.core",
|
|
15
|
-
"range": "^0.
|
|
15
|
+
"range": "^0.8.0"
|
|
16
16
|
}
|
|
17
17
|
],
|
|
18
18
|
"tenancy": "required",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
schemaVersion: 2
|
|
2
2
|
id: auth.core
|
|
3
|
-
specVersion: 0.13.
|
|
3
|
+
specVersion: 0.13.11
|
|
4
4
|
status: approved
|
|
5
5
|
name: Authentication Core
|
|
6
6
|
description: Provides tenant-aware accounts, password authentication, sessions, scopes, tenant roles, API tokens for machine clients, the module settings store, the auth audit trail, security headers, authentication screens, and server authorization middleware.
|
|
@@ -13,7 +13,7 @@ capabilities:
|
|
|
13
13
|
- cli
|
|
14
14
|
dependencies:
|
|
15
15
|
- id: system.core
|
|
16
|
-
range: ^0.
|
|
16
|
+
range: ^0.8.0
|
|
17
17
|
tenancy: required
|
|
18
18
|
locales:
|
|
19
19
|
- en
|
|
@@ -70,7 +70,11 @@ export function endpointIdentityFromContext(
|
|
|
70
70
|
): EndpointIdentity | null {
|
|
71
71
|
const principal = principalFromContext(context);
|
|
72
72
|
return principal
|
|
73
|
-
? {
|
|
73
|
+
? {
|
|
74
|
+
subjectId: principal.accountId,
|
|
75
|
+
tenantId: principal.tenantId,
|
|
76
|
+
permissions: new Set(principal.scopes),
|
|
77
|
+
}
|
|
74
78
|
: null;
|
|
75
79
|
}
|
|
76
80
|
|
|
@@ -385,6 +385,8 @@ export const AUDIT_ACTIONS = Object.freeze({
|
|
|
385
385
|
roleDeleted: 'auth.role.deleted',
|
|
386
386
|
settingsUpdated: 'settings.updated',
|
|
387
387
|
settingsFlagChanged: 'settings.flag.changed',
|
|
388
|
+
moduleActivated: 'system.module.activated',
|
|
389
|
+
moduleDeactivated: 'system.module.deactivated',
|
|
388
390
|
});
|
|
389
391
|
|
|
390
392
|
export const AUDIT_ACTION_LIST = Object.freeze(Object.values(AUDIT_ACTIONS));
|
|
@@ -886,6 +888,13 @@ export class AuthService {
|
|
|
886
888
|
);
|
|
887
889
|
}
|
|
888
890
|
|
|
891
|
+
/** How many members hold the owner role, counted the same way for the same screen. */
|
|
892
|
+
countTenantOwners(tenantId: string): Promise<number> {
|
|
893
|
+
return this.#repository.countTenantOwners(
|
|
894
|
+
this.#identifier(tenantId, 'tenantId'),
|
|
895
|
+
);
|
|
896
|
+
}
|
|
897
|
+
|
|
889
898
|
/**
|
|
890
899
|
* The identity bindings this workspace's own providers assert about its
|
|
891
900
|
* members, one bounded page per call. A binding names the account, the
|
|
@@ -2912,6 +2921,23 @@ export class AuthService {
|
|
|
2912
2921
|
);
|
|
2913
2922
|
}
|
|
2914
2923
|
|
|
2924
|
+
/** The one audit row a committed per-workspace module activation change owes. */
|
|
2925
|
+
async recordModuleActivation(
|
|
2926
|
+
actor: AuthActor,
|
|
2927
|
+
change: { readonly moduleId: string; readonly active: boolean },
|
|
2928
|
+
): Promise<void> {
|
|
2929
|
+
await this.#audit(
|
|
2930
|
+
actor.tenantId,
|
|
2931
|
+
this.#actorOf(actor),
|
|
2932
|
+
change.active
|
|
2933
|
+
? AUDIT_ACTIONS.moduleActivated
|
|
2934
|
+
: AUDIT_ACTIONS.moduleDeactivated,
|
|
2935
|
+
'module',
|
|
2936
|
+
change.moduleId,
|
|
2937
|
+
{ active: change.active },
|
|
2938
|
+
);
|
|
2939
|
+
}
|
|
2940
|
+
|
|
2915
2941
|
async queryAudit(query: AuditQuery): Promise<AuditActorPage> {
|
|
2916
2942
|
const limit = Math.min(
|
|
2917
2943
|
Math.max(1, Math.trunc(query.limit)),
|
|
@@ -939,6 +939,15 @@ export class DatabaseAuthRepository implements AuthRepository {
|
|
|
939
939
|
return rows[0] ? integer(rows[0].total, 'total') : 0;
|
|
940
940
|
}
|
|
941
941
|
|
|
942
|
+
async countTenantOwners(tenantId: string): Promise<number> {
|
|
943
|
+
const rows = await this.#query<CountRow>(tenantId, {
|
|
944
|
+
text: `SELECT count(*) AS total FROM auth_memberships
|
|
945
|
+
WHERE tenant_id = $1 AND role = 'owner'`,
|
|
946
|
+
parameters: [tenantId],
|
|
947
|
+
});
|
|
948
|
+
return rows[0] ? integer(rows[0].total, 'total') : 0;
|
|
949
|
+
}
|
|
950
|
+
|
|
942
951
|
async findTenantMember(
|
|
943
952
|
tenantId: string,
|
|
944
953
|
accountId: string,
|
|
@@ -363,6 +363,8 @@ export interface AuthRepository {
|
|
|
363
363
|
): Promise<TenantMemberSortedPage>;
|
|
364
364
|
/** How many memberships the workspace holds, an index range over 0030. */
|
|
365
365
|
countTenantMembers(tenantId: string): Promise<number>;
|
|
366
|
+
/** How many of those memberships hold the owner role, whatever their status. */
|
|
367
|
+
countTenantOwners(tenantId: string): Promise<number>;
|
|
366
368
|
/** One member, for a caller that needs a single account rather than the roll. */
|
|
367
369
|
findTenantMember(
|
|
368
370
|
tenantId: string,
|