@mettlecast/domain-runtime 0.2.59 → 0.2.61
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/README.md +57 -40
- package/dist/primitives/action.d.ts +80 -8
- package/dist/primitives/action.js +71 -4
- package/dist/primitives/index.d.ts +0 -1
- package/dist/primitives/index.js +0 -1
- package/dist/runtime/action-executor.d.ts +164 -0
- package/dist/runtime/action-executor.js +408 -0
- package/dist/runtime/action-handler.d.ts +110 -6
- package/dist/runtime/action-handler.js +110 -10
- package/dist/runtime/actions.d.ts +65 -5
- package/dist/runtime/actions.js +118 -13
- package/dist/runtime/audit.d.ts +23 -3
- package/dist/runtime/audit.js +20 -3
- package/dist/runtime/db.d.ts +23 -4
- package/dist/runtime/db.js +27 -6
- package/dist/runtime/error-formatter.d.ts +63 -0
- package/dist/runtime/error-formatter.js +68 -1
- package/dist/runtime/exposed-action-api-handler.d.ts +87 -0
- package/dist/runtime/exposed-action-api-handler.js +287 -0
- package/dist/runtime/extractors.d.ts +12 -0
- package/dist/runtime/extractors.js +21 -13
- package/dist/runtime/files.d.ts +8 -0
- package/dist/runtime/files.js +76 -8
- package/dist/runtime/hydrate.js +17 -2
- package/dist/runtime/index.d.ts +10 -2
- package/dist/runtime/index.js +18 -1
- package/dist/runtime/observability-bindings.d.ts +110 -0
- package/dist/runtime/observability-bindings.js +94 -0
- package/dist/runtime/store.d.ts +8 -0
- package/dist/runtime/store.js +29 -0
- package/dist/runtime/tracer.d.ts +1 -1
- package/dist/runtime/tracer.js +1 -1
- package/dist/schema/index.d.ts +1 -1
- package/dist/schema/index.js +1 -1
- package/dist/schema/rls.d.ts +31 -0
- package/dist/schema/rls.js +44 -0
- package/dist/types/tenant.d.ts +12 -0
- package/package.json +1 -1
- package/dist/primitives/api.d.ts +0 -44
- package/dist/primitives/api.js +0 -70
- package/dist/runtime/api-handler.d.ts +0 -29
- package/dist/runtime/api-handler.js +0 -212
package/dist/runtime/files.js
CHANGED
|
@@ -1,16 +1,72 @@
|
|
|
1
1
|
import { S3Client, GetObjectCommand, PutObjectCommand, DeleteObjectCommand, ListObjectsV2Command } from '@aws-sdk/client-s3';
|
|
2
2
|
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
|
3
3
|
import { getTenantCredentials } from './tenant-credentials.js';
|
|
4
|
+
/**
|
|
5
|
+
* Reject missing, empty, or 'unknown' tenant IDs at construction time.
|
|
6
|
+
*
|
|
7
|
+
* Mirrors the same guard in `createStore`: the runtime must fail loudly when
|
|
8
|
+
* a wrapper is instantiated with an unsafe tenant rather than silently
|
|
9
|
+
* writing/reading under a shared prefix named "unknown".
|
|
10
|
+
*/
|
|
11
|
+
function assertRuntimeTenantId(tenantId) {
|
|
12
|
+
if (typeof tenantId !== 'string' || tenantId.length === 0 || tenantId === 'unknown') {
|
|
13
|
+
throw new Error(`createFiles: a valid runtime tenantId is required (got ${JSON.stringify(tenantId)})`);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Normalise an S3 key and reject any input that would let a caller escape the
|
|
18
|
+
* tenant prefix or reach outside the bucket's logical root.
|
|
19
|
+
*
|
|
20
|
+
* S3 treats keys as opaque flat strings, so `..` and `.` segments are NOT
|
|
21
|
+
* resolved by the service — but the runtime contract is that keys are relative
|
|
22
|
+
* to the tenant's namespace and that path-traversal-style inputs are a
|
|
23
|
+
* developer error (and a likely injection vector if any layer ever resolves
|
|
24
|
+
* them). Reject early.
|
|
25
|
+
*
|
|
26
|
+
* Rules:
|
|
27
|
+
* - Must be a non-empty string.
|
|
28
|
+
* - Must not start with `/` (absolute paths are meaningless inside a bucket).
|
|
29
|
+
* - Must not equal `.` or `..`.
|
|
30
|
+
* - Must not contain any segment equal to `.` or `..` after splitting on `/`.
|
|
31
|
+
*/
|
|
32
|
+
function normalizeAndValidateKey(rawKey, op) {
|
|
33
|
+
if (typeof rawKey !== 'string' || rawKey.length === 0) {
|
|
34
|
+
throw new Error(`${op}: key must be a non-empty string (got ${typeof rawKey === 'string' ? JSON.stringify(rawKey) : typeof rawKey})`);
|
|
35
|
+
}
|
|
36
|
+
if (rawKey.startsWith('/')) {
|
|
37
|
+
throw new Error(`${op}: key must not start with '/': ${JSON.stringify(rawKey)}`);
|
|
38
|
+
}
|
|
39
|
+
if (rawKey === '.' || rawKey === '..') {
|
|
40
|
+
throw new Error(`${op}: key must not be '.' or '..': ${JSON.stringify(rawKey)}`);
|
|
41
|
+
}
|
|
42
|
+
for (const seg of rawKey.split('/')) {
|
|
43
|
+
if (seg === '.' || seg === '..') {
|
|
44
|
+
throw new Error(`${op}: key contains path-traversal segment '${seg}': ${JSON.stringify(rawKey)}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return rawKey;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Tenant-scoped S3 wrapper. All keys are automatically prefixed with
|
|
51
|
+
* `{tenantId}/` — developers supply only the path after the tenant prefix.
|
|
52
|
+
*
|
|
53
|
+
* Path-traversal patterns (`../`, `./`, leading `/`, empty keys) are rejected
|
|
54
|
+
* before the tenant prefix is applied, so every object operation is scoped to
|
|
55
|
+
* the runtime tenant regardless of caller input.
|
|
56
|
+
*/
|
|
4
57
|
export function createFiles(opts) {
|
|
58
|
+
assertRuntimeTenantId(opts.tenantId);
|
|
5
59
|
const client = new S3Client({
|
|
6
60
|
credentials: () => getTenantCredentials(opts.tenantRoleArn, opts.tenantId),
|
|
7
61
|
});
|
|
8
62
|
const { bucketName, tenantId } = opts;
|
|
9
|
-
const
|
|
63
|
+
const tenantPrefix = `${tenantId}/`;
|
|
64
|
+
const prefix = (key) => `${tenantPrefix}${key}`;
|
|
10
65
|
return {
|
|
11
66
|
async get(key) {
|
|
67
|
+
const safeKey = normalizeAndValidateKey(key, 'files.get');
|
|
12
68
|
try {
|
|
13
|
-
const res = await client.send(new GetObjectCommand({ Bucket: bucketName, Key: prefix(
|
|
69
|
+
const res = await client.send(new GetObjectCommand({ Bucket: bucketName, Key: prefix(safeKey) }));
|
|
14
70
|
const chunks = [];
|
|
15
71
|
for await (const chunk of res.Body) {
|
|
16
72
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
@@ -24,28 +80,40 @@ export function createFiles(opts) {
|
|
|
24
80
|
}
|
|
25
81
|
},
|
|
26
82
|
async put(key, body, putOpts) {
|
|
83
|
+
const safeKey = normalizeAndValidateKey(key, 'files.put');
|
|
27
84
|
await client.send(new PutObjectCommand({
|
|
28
85
|
Bucket: bucketName,
|
|
29
|
-
Key: prefix(
|
|
86
|
+
Key: prefix(safeKey),
|
|
30
87
|
Body: body,
|
|
31
88
|
ContentType: putOpts?.contentType,
|
|
32
89
|
Metadata: putOpts?.metadata,
|
|
33
90
|
}));
|
|
34
91
|
},
|
|
35
92
|
async delete(key) {
|
|
36
|
-
|
|
93
|
+
const safeKey = normalizeAndValidateKey(key, 'files.delete');
|
|
94
|
+
await client.send(new DeleteObjectCommand({ Bucket: bucketName, Key: prefix(safeKey) }));
|
|
37
95
|
},
|
|
38
96
|
async list(listPrefix) {
|
|
39
|
-
|
|
97
|
+
// listPrefix is optional. When provided it must be a tenant-relative
|
|
98
|
+
// path under the same rules; when omitted, we list everything in the
|
|
99
|
+
// tenant's namespace.
|
|
100
|
+
let fullPrefix;
|
|
101
|
+
if (listPrefix === undefined || listPrefix === '') {
|
|
102
|
+
fullPrefix = tenantPrefix;
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
const safePrefix = normalizeAndValidateKey(listPrefix, 'files.list');
|
|
106
|
+
fullPrefix = prefix(safePrefix);
|
|
107
|
+
}
|
|
40
108
|
const res = await client.send(new ListObjectsV2Command({ Bucket: bucketName, Prefix: fullPrefix }));
|
|
41
|
-
const tenantPrefixLen = `${tenantId}/`.length;
|
|
42
109
|
return {
|
|
43
|
-
keys: (res.Contents ?? []).map(o => (o.Key ?? '').slice(
|
|
110
|
+
keys: (res.Contents ?? []).map(o => (o.Key ?? '').slice(tenantPrefix.length)),
|
|
44
111
|
cursor: res.NextContinuationToken,
|
|
45
112
|
};
|
|
46
113
|
},
|
|
47
114
|
async getSignedUrl(key, expiresIn = 3600) {
|
|
48
|
-
const
|
|
115
|
+
const safeKey = normalizeAndValidateKey(key, 'files.getSignedUrl');
|
|
116
|
+
const cmd = new GetObjectCommand({ Bucket: bucketName, Key: prefix(safeKey) });
|
|
49
117
|
return getSignedUrl(client, cmd, { expiresIn });
|
|
50
118
|
},
|
|
51
119
|
};
|
package/dist/runtime/hydrate.js
CHANGED
|
@@ -12,6 +12,7 @@ import { createFlowsProxy } from './flows.js';
|
|
|
12
12
|
import { createIntegrationsProxy, createEmptyIntegrationsProxy } from './integrations.js';
|
|
13
13
|
import { createFeatureFlags } from './feature-flags.js';
|
|
14
14
|
import { createAudit } from './audit.js';
|
|
15
|
+
import { extractObservabilityBindings } from './observability-bindings.js';
|
|
15
16
|
import { createStore, createNoopStore } from './store.js';
|
|
16
17
|
import { createFiles, createNoopFiles } from './files.js';
|
|
17
18
|
/**
|
|
@@ -40,9 +41,14 @@ export async function hydrateCtx(_event, options = {}) {
|
|
|
40
41
|
const lambdaName = options.lambdaName ?? process.env['AWS_LAMBDA_FUNCTION_NAME'] ?? 'unknown';
|
|
41
42
|
const actor = options.actor ?? { type: 'system' };
|
|
42
43
|
const tenant = options.tenant ?? { id: 'unknown', workspaceId: 'unknown' };
|
|
44
|
+
// Issue #4662 Task D — every log line carries the canonical
|
|
45
|
+
// observability bindings (tenantId, orgId, actorSub, actorRoles,
|
|
46
|
+
// traceId, …) as Powertools persistent attributes so CloudWatch
|
|
47
|
+
// Logs Insights filters can reliably match by tenant without
|
|
48
|
+
// checking for both the key and the literal string 'unknown'.
|
|
49
|
+
const observabilityBindings = extractObservabilityBindings({ tenant, actor, traceId: options.traceId }, { definingDomain: options.domainId });
|
|
43
50
|
const logger = options.logger ?? createLogger({
|
|
44
|
-
|
|
45
|
-
workspaceId: tenant.workspaceId,
|
|
51
|
+
...observabilityBindings,
|
|
46
52
|
lambda: lambdaName,
|
|
47
53
|
});
|
|
48
54
|
const tracer = options.tracer ?? createTracer(lambdaName);
|
|
@@ -81,6 +87,11 @@ export async function hydrateCtx(_event, options = {}) {
|
|
|
81
87
|
connectionString: options.databaseUrl,
|
|
82
88
|
tenantId: tenant.id,
|
|
83
89
|
orgId: tenant.orgId,
|
|
90
|
+
// Bind the authenticated actor's subject so DB-side triggers and audit
|
|
91
|
+
// helpers can read `current_setting('app.actor_id', true)`. Falls back
|
|
92
|
+
// to undefined for system/schedule actors that don't carry a sub claim;
|
|
93
|
+
// createDb skips the set_config call when actorId is absent.
|
|
94
|
+
actorId: actor.sub,
|
|
84
95
|
});
|
|
85
96
|
// TODO: Wire proper teardown to call await db.release() after handler completes.
|
|
86
97
|
// For now, callers must manually call await ctx.db.release() in a try/finally block.
|
|
@@ -145,6 +156,10 @@ export async function hydrateCtx(_event, options = {}) {
|
|
|
145
156
|
actorId: actor?.sub,
|
|
146
157
|
traceId: options.traceId ?? 'unknown',
|
|
147
158
|
publish,
|
|
159
|
+
// Issue #4662 Task D — emit the canonical observability bindings
|
|
160
|
+
// alongside the audit envelope so SOC2 / forensics consumers can
|
|
161
|
+
// filter by any of the canonical fields (tenant, actor, action).
|
|
162
|
+
bindings: extractObservabilityBindings({ tenant, actor, traceId: options.traceId }, { definingDomain: options.domainId }),
|
|
148
163
|
});
|
|
149
164
|
const store = options.store ?? ((options.domainTableName ?? process.env['DOMAIN_TABLE_NAME'])
|
|
150
165
|
? createStore({ tableName: options.domainTableName ?? process.env['DOMAIN_TABLE_NAME'], tenantId: tenant.id, tenantRoleArn: process.env['DOMAIN_TENANT_ROLE_ARN'] })
|
package/dist/runtime/index.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export { createLogger } from './logger.js';
|
|
2
2
|
export { createTracer, noopTracer, initOtel } from './tracer.js';
|
|
3
3
|
export { createCache } from './cache.js';
|
|
4
|
+
export { extractObservabilityBindings, requireObservabilityBindings, } from './observability-bindings.js';
|
|
5
|
+
export type { ObservabilityBindings, ExtractBindingsOptions, } from './observability-bindings.js';
|
|
4
6
|
/**
|
|
5
7
|
* @deprecated Use `createKyClient` instead. The `createFetch` name is kept
|
|
6
8
|
* as a backward-compatible alias for callers that imported it before the
|
|
@@ -15,6 +17,8 @@ export { ok, err, notFound } from '../types/result.js';
|
|
|
15
17
|
export type { AppError, Result } from '../types/result.js';
|
|
16
18
|
export { safeFetch, safeDb, wrapHandler, captureStack } from './typed-errors.js';
|
|
17
19
|
export type { SafeResult } from './typed-errors.js';
|
|
20
|
+
export { TibError, TIB_ERROR_CODES, domainError, readLegacyHttpStatus, } from './error-formatter.js';
|
|
21
|
+
export type { TibErrorCode, StandardError, HttpStatusCarrier } from './error-formatter.js';
|
|
18
22
|
export { createSecrets, createEmptySecrets } from './secrets.js';
|
|
19
23
|
export type { SecretsOptions } from './secrets.js';
|
|
20
24
|
export { createIdempotency, createNoopIdempotency } from './idempotency.js';
|
|
@@ -33,6 +37,8 @@ export { createIntegrationsProxy, createEmptyIntegrationsProxy } from './integra
|
|
|
33
37
|
export type { IntegrationsProxyOptions } from './integrations.js';
|
|
34
38
|
export { hydrateCtx } from './hydrate.js';
|
|
35
39
|
export type { HydrateOptions } from './hydrate.js';
|
|
40
|
+
export { executeAction, withActionExecution, releaseDbSafely } from './action-executor.js';
|
|
41
|
+
export type { ApiActionSource, InternalActionSource, ActionSource, ExecuteActionMeta, ExecuteActionResult, } from './action-executor.js';
|
|
36
42
|
export { negotiateVersion } from './version-negotiator.js';
|
|
37
43
|
export type { VersionNegotiation } from './version-negotiator.js';
|
|
38
44
|
export { checkSunset, applySunsetHeaders } from './sunset-guard.js';
|
|
@@ -41,8 +47,10 @@ export { eventVersionMatches } from './event-version-matcher.js';
|
|
|
41
47
|
export { extractActorFromApiGatewayEvent, extractTenantFromApiGatewayEvent } from './extractors.js';
|
|
42
48
|
export { parseQuery, encodeCursor, decodeCursor, DEFAULT_LIMIT, MAX_LIMIT } from './query-parser.js';
|
|
43
49
|
export type { ParsedQuery } from './query-parser.js';
|
|
44
|
-
export {
|
|
45
|
-
export type {
|
|
50
|
+
export { createExposedActionApiHandler } from './exposed-action-api-handler.js';
|
|
51
|
+
export type { CreateExposedActionHandlerOptions } from './exposed-action-api-handler.js';
|
|
52
|
+
export { createActionLambdaHandler } from './action-handler.js';
|
|
53
|
+
export type { CreateActionHandlerOptions, ActionInvocationEnvelope, ActionActorEnvelope, ActionRegistryEntry, ActionHandlerFn, } from './action-handler.js';
|
|
46
54
|
export { createSubscriberLambdaHandler } from './subscriber-handler.js';
|
|
47
55
|
export type { CreateSubscriberHandlerOptions } from './subscriber-handler.js';
|
|
48
56
|
export { createJobLambdaHandler } from './job-handler.js';
|
package/dist/runtime/index.js
CHANGED
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
export { createLogger } from './logger.js';
|
|
3
3
|
export { createTracer, noopTracer, initOtel } from './tracer.js';
|
|
4
4
|
export { createCache } from './cache.js';
|
|
5
|
+
// Issue #4662 Task D — observability bindings. Used by hydrateCtx,
|
|
6
|
+
// the action executor, and the cross-domain ActionsProxy so every log
|
|
7
|
+
// line, audit event, and Lambda envelope carries the same canonical
|
|
8
|
+
// tenant / actor / action identity fields.
|
|
9
|
+
export { extractObservabilityBindings, requireObservabilityBindings, } from './observability-bindings.js';
|
|
5
10
|
/**
|
|
6
11
|
* @deprecated Use `createKyClient` instead. The `createFetch` name is kept
|
|
7
12
|
* as a backward-compatible alias for callers that imported it before the
|
|
@@ -15,6 +20,8 @@ export { checkRateLimit, clearRateLimitBuckets } from './rate-limiter.js';
|
|
|
15
20
|
// single barrel. The full type definitions live in `../types/result.js`.
|
|
16
21
|
export { ok, err, notFound } from '../types/result.js';
|
|
17
22
|
export { safeFetch, safeDb, wrapHandler, captureStack } from './typed-errors.js';
|
|
23
|
+
// Domain error helper — preferred throwing primitive for action handlers.
|
|
24
|
+
export { TibError, TIB_ERROR_CODES, domainError, readLegacyHttpStatus, } from './error-formatter.js';
|
|
18
25
|
// AWS SDK implementations
|
|
19
26
|
export { createSecrets, createEmptySecrets } from './secrets.js';
|
|
20
27
|
export { createIdempotency, createNoopIdempotency } from './idempotency.js';
|
|
@@ -28,6 +35,15 @@ export { createFlowsProxy, createCapturingFlowsProxy } from './flows.js';
|
|
|
28
35
|
export { createIntegrationsProxy, createEmptyIntegrationsProxy } from './integrations.js';
|
|
29
36
|
// Master factory
|
|
30
37
|
export { hydrateCtx } from './hydrate.js';
|
|
38
|
+
// Shared action executor (Wave 2 of #4619). Consumed by both the API-exposed
|
|
39
|
+
// action adapter and the internal cross-domain action Lambda handler. The
|
|
40
|
+
// helper functions exported alongside (`withActionExecution`,
|
|
41
|
+
// `releaseDbSafely`) cover the standalone-script / test case where the
|
|
42
|
+
// executor owns the DB lifecycle. Lower-level pipeline helpers
|
|
43
|
+
// (`isCallerAllowed`, `ensureTenantMatches`, `actorHasRequiredRoles`) stay
|
|
44
|
+
// private to the file — they're exported there only so unit tests can pin
|
|
45
|
+
// behaviour against a single import surface.
|
|
46
|
+
export { executeAction, withActionExecution, releaseDbSafely } from './action-executor.js';
|
|
31
47
|
// Versioning middleware
|
|
32
48
|
export { negotiateVersion } from './version-negotiator.js';
|
|
33
49
|
export { checkSunset, applySunsetHeaders } from './sunset-guard.js';
|
|
@@ -37,7 +53,8 @@ export { extractActorFromApiGatewayEvent, extractTenantFromApiGatewayEvent } fro
|
|
|
37
53
|
// Query parsing
|
|
38
54
|
export { parseQuery, encodeCursor, decodeCursor, DEFAULT_LIMIT, MAX_LIMIT } from './query-parser.js';
|
|
39
55
|
// Lambda handler wrappers
|
|
40
|
-
export {
|
|
56
|
+
export { createExposedActionApiHandler } from './exposed-action-api-handler.js';
|
|
57
|
+
export { createActionLambdaHandler } from './action-handler.js';
|
|
41
58
|
export { createSubscriberLambdaHandler } from './subscriber-handler.js';
|
|
42
59
|
export { createJobLambdaHandler } from './job-handler.js';
|
|
43
60
|
export { createWebhookLambdaHandler } from './webhook-handler.js';
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type { DomainContext } from '../ctx/context.js';
|
|
2
|
+
/**
|
|
3
|
+
* Canonical observability bindings attached to every log line and audit
|
|
4
|
+
* event in the runtime (#4662 Task D).
|
|
5
|
+
*
|
|
6
|
+
* These fields mirror the JWT contract:
|
|
7
|
+
* - `tenantId`, `orgId` come from `ctx.tenant`
|
|
8
|
+
* - `actorSub`, `actorEmail`, `actorRoles` come from `ctx.actor`
|
|
9
|
+
* - `actionId`, `callerDomain`, `traceId` come from the executing
|
|
10
|
+
* action's runtime context (`meta.definingDomain`, `meta.traceId`,
|
|
11
|
+
* and the inbound `ActionInvocationEnvelope` for cross-domain calls)
|
|
12
|
+
*
|
|
13
|
+
* Every key is optional — the helper intentionally omits empty values so
|
|
14
|
+
* structured logs stay compact and CloudWatch Insights filters can
|
|
15
|
+
* reliably match on `tenantId` without checking for both the key and
|
|
16
|
+
* the literal string `'unknown'`.
|
|
17
|
+
*/
|
|
18
|
+
export interface ObservabilityBindings {
|
|
19
|
+
/** Tenant ID from the request context. Omitted when unknown. */
|
|
20
|
+
tenantId?: string;
|
|
21
|
+
/** Org ID from the request context. Omitted when absent. */
|
|
22
|
+
orgId?: string;
|
|
23
|
+
/** Workspace ID from the request context. Omitted when unknown. */
|
|
24
|
+
workspaceId?: string;
|
|
25
|
+
/** JWT `sub` claim of the acting principal. Omitted when absent. */
|
|
26
|
+
actorSub?: string;
|
|
27
|
+
/** Email address of the acting principal. Omitted when absent. */
|
|
28
|
+
actorEmail?: string;
|
|
29
|
+
/** Role claims (e.g. `['sys_admin']`). Omitted when empty. */
|
|
30
|
+
actorRoles?: string[];
|
|
31
|
+
/** Action ID being executed (e.g. `'charge-card'`). Omitted when absent. */
|
|
32
|
+
actionId?: string;
|
|
33
|
+
/** Domain that owns the action being executed. Omitted when absent. */
|
|
34
|
+
callerDomain?: string;
|
|
35
|
+
/** Domain that invoked this call (cross-domain only). Omitted when same-domain. */
|
|
36
|
+
targetDomain?: string;
|
|
37
|
+
/** Distributed trace ID for the current request. Omitted when absent. */
|
|
38
|
+
traceId?: string;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Options for {@link extractObservabilityBindings}.
|
|
42
|
+
*/
|
|
43
|
+
export interface ExtractBindingsOptions {
|
|
44
|
+
/** Domain ID of the executing action (`meta.definingDomain`). */
|
|
45
|
+
definingDomain?: string;
|
|
46
|
+
/** Trace ID for the current request (`meta.traceId`). */
|
|
47
|
+
traceId?: string;
|
|
48
|
+
/** Inbound action invocation envelope (cross-domain only). */
|
|
49
|
+
envelope?: {
|
|
50
|
+
callerDomain?: string;
|
|
51
|
+
targetDomain?: string;
|
|
52
|
+
actionId?: string;
|
|
53
|
+
actorSub?: string;
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* When false (default), `callerDomain` is omitted when the caller is
|
|
57
|
+
* the same domain as `definingDomain`. Set to true to always include
|
|
58
|
+
* it (useful for cross-domain debugging).
|
|
59
|
+
*/
|
|
60
|
+
alwaysIncludeCaller?: boolean;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Minimal input the binding extractor needs from the runtime context.
|
|
64
|
+
* Keeps callers from having to pass the full {@link DomainContext}
|
|
65
|
+
* (which carries DB, secrets, integrations proxies that have nothing
|
|
66
|
+
* to do with observability).
|
|
67
|
+
*/
|
|
68
|
+
interface ObservabilityContext {
|
|
69
|
+
tenant?: {
|
|
70
|
+
id?: string;
|
|
71
|
+
workspaceId?: string;
|
|
72
|
+
orgId?: string;
|
|
73
|
+
};
|
|
74
|
+
actor?: {
|
|
75
|
+
sub?: string;
|
|
76
|
+
email?: string;
|
|
77
|
+
roles?: string[];
|
|
78
|
+
};
|
|
79
|
+
/** Optional inline trace id when callers don't pass it via `options.traceId`. */
|
|
80
|
+
traceId?: string;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Project the runtime `DomainContext` (plus a few optional metadata
|
|
84
|
+
* fields) onto the canonical {@link ObservabilityBindings} shape.
|
|
85
|
+
*
|
|
86
|
+
* Used by:
|
|
87
|
+
*
|
|
88
|
+
* - `createLogger({...bindings})` so every log line carries the
|
|
89
|
+
* bindings as Powertools persistent attributes.
|
|
90
|
+
* - `createAudit(...)` so audit events on the EventBridge bus carry the
|
|
91
|
+
* same shape.
|
|
92
|
+
* - The cross-domain `ActionsProxy` envelope so receiving handlers
|
|
93
|
+
* observe the same identity the caller had.
|
|
94
|
+
*
|
|
95
|
+
* The function is pure and side-effect free — it does NOT mutate `ctx`,
|
|
96
|
+
* does NOT log, and does NOT publish. Callers compose the bindings into
|
|
97
|
+
* whatever observability sink they need.
|
|
98
|
+
*/
|
|
99
|
+
export declare function extractObservabilityBindings(ctx: ObservabilityContext, options?: ExtractBindingsOptions): ObservabilityBindings;
|
|
100
|
+
/**
|
|
101
|
+
* Convenience helper: extract bindings and assert at least one
|
|
102
|
+
* tenant identity is present. Used by entrypoints that should never
|
|
103
|
+
* run without a tenant (e.g. API-exposed actions with `tenancy:
|
|
104
|
+
* 'required'`) — it produces a structured error before any side
|
|
105
|
+
* effects occur.
|
|
106
|
+
*
|
|
107
|
+
* @throws TibError when neither tenant nor trace id is present.
|
|
108
|
+
*/
|
|
109
|
+
export declare function requireObservabilityBindings(ctx: Pick<DomainContext, 'tenant' | 'actor'>, options?: ExtractBindingsOptions): ObservabilityBindings;
|
|
110
|
+
export {};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { TibError } from './error-formatter.js';
|
|
2
|
+
/**
|
|
3
|
+
* Project the runtime `DomainContext` (plus a few optional metadata
|
|
4
|
+
* fields) onto the canonical {@link ObservabilityBindings} shape.
|
|
5
|
+
*
|
|
6
|
+
* Used by:
|
|
7
|
+
*
|
|
8
|
+
* - `createLogger({...bindings})` so every log line carries the
|
|
9
|
+
* bindings as Powertools persistent attributes.
|
|
10
|
+
* - `createAudit(...)` so audit events on the EventBridge bus carry the
|
|
11
|
+
* same shape.
|
|
12
|
+
* - The cross-domain `ActionsProxy` envelope so receiving handlers
|
|
13
|
+
* observe the same identity the caller had.
|
|
14
|
+
*
|
|
15
|
+
* The function is pure and side-effect free — it does NOT mutate `ctx`,
|
|
16
|
+
* does NOT log, and does NOT publish. Callers compose the bindings into
|
|
17
|
+
* whatever observability sink they need.
|
|
18
|
+
*/
|
|
19
|
+
export function extractObservabilityBindings(ctx, options = {}) {
|
|
20
|
+
const out = {};
|
|
21
|
+
const { tenant, actor } = ctx;
|
|
22
|
+
if (tenant) {
|
|
23
|
+
if (tenant.id && tenant.id !== 'unknown')
|
|
24
|
+
out.tenantId = tenant.id;
|
|
25
|
+
if (tenant.orgId && tenant.orgId !== 'unknown')
|
|
26
|
+
out.orgId = tenant.orgId;
|
|
27
|
+
if (tenant.workspaceId && tenant.workspaceId !== 'unknown') {
|
|
28
|
+
out.workspaceId = tenant.workspaceId;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (actor) {
|
|
32
|
+
if (actor.sub)
|
|
33
|
+
out.actorSub = actor.sub;
|
|
34
|
+
if (actor.email)
|
|
35
|
+
out.actorEmail = actor.email;
|
|
36
|
+
if (Array.isArray(actor.roles) && actor.roles.length > 0)
|
|
37
|
+
out.actorRoles = actor.roles;
|
|
38
|
+
}
|
|
39
|
+
// `ctx.traceId` is set by `createExposedActionApiHandler` /
|
|
40
|
+
// `createActionLambdaHandler` from the inbound envelope or header. We
|
|
41
|
+
// prefer the explicit `options.traceId` so callers can override (e.g.
|
|
42
|
+
// when replaying a cached invocation).
|
|
43
|
+
const traceId = options.traceId ?? ctx.traceId;
|
|
44
|
+
if (traceId && traceId !== 'unknown')
|
|
45
|
+
out.traceId = traceId;
|
|
46
|
+
if (options.envelope) {
|
|
47
|
+
const { callerDomain, targetDomain, actionId } = options.envelope;
|
|
48
|
+
if (actionId)
|
|
49
|
+
out.actionId = actionId;
|
|
50
|
+
if (callerDomain)
|
|
51
|
+
out.callerDomain = callerDomain;
|
|
52
|
+
if (targetDomain && targetDomain !== callerDomain) {
|
|
53
|
+
out.targetDomain = targetDomain;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
else if (options.definingDomain) {
|
|
57
|
+
// Same-domain invocations: surface the executing action's domain so
|
|
58
|
+
// downstream logs always carry a `callerDomain` for filtering —
|
|
59
|
+
// the strip below removes it again unless `alwaysIncludeCaller`
|
|
60
|
+
// is set.
|
|
61
|
+
out.callerDomain = options.definingDomain;
|
|
62
|
+
}
|
|
63
|
+
// Strip the `callerDomain` when it equals `definingDomain` (the
|
|
64
|
+
// common in-process case where the only metadata we have is the
|
|
65
|
+
// executing action's defining domain) so the field only adds noise
|
|
66
|
+
// for genuinely cross-domain calls. `alwaysIncludeCaller` overrides
|
|
67
|
+
// this for debugging. Skipped when an envelope was supplied —
|
|
68
|
+
// explicit cross-domain metadata always wins over the redundancy
|
|
69
|
+
// heuristic.
|
|
70
|
+
if (!options.alwaysIncludeCaller
|
|
71
|
+
&& !options.envelope
|
|
72
|
+
&& out.callerDomain
|
|
73
|
+
&& options.definingDomain
|
|
74
|
+
&& out.callerDomain === options.definingDomain) {
|
|
75
|
+
delete out.callerDomain;
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Convenience helper: extract bindings and assert at least one
|
|
81
|
+
* tenant identity is present. Used by entrypoints that should never
|
|
82
|
+
* run without a tenant (e.g. API-exposed actions with `tenancy:
|
|
83
|
+
* 'required'`) — it produces a structured error before any side
|
|
84
|
+
* effects occur.
|
|
85
|
+
*
|
|
86
|
+
* @throws TibError when neither tenant nor trace id is present.
|
|
87
|
+
*/
|
|
88
|
+
export function requireObservabilityBindings(ctx, options = {}) {
|
|
89
|
+
const bindings = extractObservabilityBindings(ctx, options);
|
|
90
|
+
if (!bindings.tenantId) {
|
|
91
|
+
throw new TibError('Observability bindings missing tenantId', 'OBSERVABILITY_BINDINGS_INCOMPLETE');
|
|
92
|
+
}
|
|
93
|
+
return bindings;
|
|
94
|
+
}
|
package/dist/runtime/store.d.ts
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
1
|
import type { DomainStoreContext } from '../ctx/store.js';
|
|
2
|
+
/**
|
|
3
|
+
* Tenant-scoped DynamoDB wrapper. PK is always the current tenant — developers
|
|
4
|
+
* supply only the SK. All operations are automatically scoped to ctx.tenant.id.
|
|
5
|
+
*
|
|
6
|
+
* The runtime tenant ID supplied at construction time is the SOLE source of
|
|
7
|
+
* the partition key. Caller-supplied `tenantId` fields in `put` items or
|
|
8
|
+
* `update` payloads are dropped/overridden before reaching DynamoDB.
|
|
9
|
+
*/
|
|
2
10
|
export declare function createStore(opts: {
|
|
3
11
|
tableName: string;
|
|
4
12
|
tenantId: string;
|
package/dist/runtime/store.js
CHANGED
|
@@ -1,7 +1,30 @@
|
|
|
1
1
|
import { DynamoDBClient, GetItemCommand, PutItemCommand, DeleteItemCommand, QueryCommand, UpdateItemCommand } from '@aws-sdk/client-dynamodb';
|
|
2
2
|
import { marshall, unmarshall } from '@aws-sdk/util-dynamodb';
|
|
3
3
|
import { getTenantCredentials } from './tenant-credentials.js';
|
|
4
|
+
/**
|
|
5
|
+
* Reject missing, empty, or 'unknown' tenant IDs at construction time.
|
|
6
|
+
*
|
|
7
|
+
* The runtime layer (hydrateCtx, api-handler, action-executor) already
|
|
8
|
+
* surfaces tenant failures upstream, but the wrapper itself must fail loudly
|
|
9
|
+
* if it is ever instantiated with an unsafe tenant — otherwise it would
|
|
10
|
+
* silently write to a single shared partition named "unknown". This is the
|
|
11
|
+
* last line of defence for DynamoDB tenant isolation (#4619 Wave 5 Task 5.2).
|
|
12
|
+
*/
|
|
13
|
+
function assertRuntimeTenantId(tenantId) {
|
|
14
|
+
if (typeof tenantId !== 'string' || tenantId.length === 0 || tenantId === 'unknown') {
|
|
15
|
+
throw new Error(`createStore: a valid runtime tenantId is required (got ${JSON.stringify(tenantId)})`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Tenant-scoped DynamoDB wrapper. PK is always the current tenant — developers
|
|
20
|
+
* supply only the SK. All operations are automatically scoped to ctx.tenant.id.
|
|
21
|
+
*
|
|
22
|
+
* The runtime tenant ID supplied at construction time is the SOLE source of
|
|
23
|
+
* the partition key. Caller-supplied `tenantId` fields in `put` items or
|
|
24
|
+
* `update` payloads are dropped/overridden before reaching DynamoDB.
|
|
25
|
+
*/
|
|
4
26
|
export function createStore(opts) {
|
|
27
|
+
assertRuntimeTenantId(opts.tenantId);
|
|
5
28
|
const client = new DynamoDBClient({
|
|
6
29
|
credentials: () => getTenantCredentials(opts.tenantRoleArn, opts.tenantId),
|
|
7
30
|
});
|
|
@@ -15,6 +38,9 @@ export function createStore(opts) {
|
|
|
15
38
|
return res.Item ? unmarshall(res.Item) : null;
|
|
16
39
|
},
|
|
17
40
|
async put(sk, item, putOpts) {
|
|
41
|
+
// Caller-supplied tenantId/sk in `item` is always overridden by the
|
|
42
|
+
// runtime values. This is structural, not just a behavioural note —
|
|
43
|
+
// the spread order makes it impossible for caller fields to win.
|
|
18
44
|
const record = { ...item, tenantId, sk };
|
|
19
45
|
if (putOpts?.ttl) {
|
|
20
46
|
record['expiresAt'] = Math.floor(Date.now() / 1000) + putOpts.ttl;
|
|
@@ -52,6 +78,9 @@ export function createStore(opts) {
|
|
|
52
78
|
};
|
|
53
79
|
},
|
|
54
80
|
async update(sk, updates) {
|
|
81
|
+
// Defensive: even if a caller tries to write `tenantId` or `sk` via the
|
|
82
|
+
// updates map, those keys are filtered before reaching DynamoDB. The
|
|
83
|
+
// partition key and sort key are owned by the runtime.
|
|
55
84
|
const entries = Object.entries(updates).filter(([k]) => k !== 'tenantId' && k !== 'sk');
|
|
56
85
|
if (entries.length === 0)
|
|
57
86
|
return;
|
package/dist/runtime/tracer.d.ts
CHANGED
|
@@ -20,7 +20,7 @@ export declare const noopTracer: Tracer;
|
|
|
20
20
|
*
|
|
21
21
|
* **Opt-in by design.** This function is NOT called automatically by the
|
|
22
22
|
* runtime; domain handler templates must call `initOtel()` at module
|
|
23
|
-
* scope if they want OTel-exported spans. The `
|
|
23
|
+
* scope if they want OTel-exported spans. The `mc-domain-module doctor` gate
|
|
24
24
|
* (`checkOtelInitInLambdas`) enforces this convention by scanning
|
|
25
25
|
* `domains/.../api/-.ts` for an `initOtel(...)` call.
|
|
26
26
|
*
|
package/dist/runtime/tracer.js
CHANGED
|
@@ -68,7 +68,7 @@ let initialised = false;
|
|
|
68
68
|
*
|
|
69
69
|
* **Opt-in by design.** This function is NOT called automatically by the
|
|
70
70
|
* runtime; domain handler templates must call `initOtel()` at module
|
|
71
|
-
* scope if they want OTel-exported spans. The `
|
|
71
|
+
* scope if they want OTel-exported spans. The `mc-domain-module doctor` gate
|
|
72
72
|
* (`checkOtelInitInLambdas`) enforces this convention by scanning
|
|
73
73
|
* `domains/.../api/-.ts` for an `initOtel(...)` call.
|
|
74
74
|
*
|
package/dist/schema/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export { z } from 'zod';
|
|
2
2
|
export * from './helpers.js';
|
|
3
|
-
export { tenantIsolationSql, dropTenantIsolationSql, tenantRoleSql } from './rls.js';
|
|
3
|
+
export { tenantIsolationSql, dropTenantIsolationSql, tenantRoleSql, tenantAdminBypassSql, } from './rls.js';
|
|
4
4
|
export type { TenantIsolationOptions } from './rls.js';
|
|
5
5
|
export { generateRlsMigration } from './migration.js';
|
|
6
6
|
export type { RlsMigrationOptions, RlsMigrationTable } from './migration.js';
|
package/dist/schema/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export { z } from 'zod';
|
|
2
2
|
export * from './helpers.js';
|
|
3
|
-
export { tenantIsolationSql, dropTenantIsolationSql, tenantRoleSql } from './rls.js';
|
|
3
|
+
export { tenantIsolationSql, dropTenantIsolationSql, tenantRoleSql, tenantAdminBypassSql, } from './rls.js';
|
|
4
4
|
export { generateRlsMigration } from './migration.js';
|
package/dist/schema/rls.d.ts
CHANGED
|
@@ -22,10 +22,41 @@ export interface TenantIsolationOptions {
|
|
|
22
22
|
* if a query is made outside a request context — those queries return zero rows
|
|
23
23
|
* instead of throwing.
|
|
24
24
|
*
|
|
25
|
+
* **Session variable contract.** This policy reads `app.current_tenant`, which
|
|
26
|
+
* is bound by `createDb` (Wave 5 Task 5.1) via
|
|
27
|
+
* `SELECT set_config('app.current_tenant', $1, false)` on every per-request
|
|
28
|
+
* client checkout. The bound value comes from `ctx.tenant.id` (see
|
|
29
|
+
* `hydrate.ts`). Any domain code that issues raw SQL against a RLS-protected
|
|
30
|
+
* table therefore relies on the runtime having already bound that variable;
|
|
31
|
+
* if a query is made before `createDb` finishes binding (or after the client
|
|
32
|
+
* is released back to the pool), the policy falls back to a zero-row result
|
|
33
|
+
* because the setting is unset and `current_setting(..., true)` returns NULL.
|
|
34
|
+
*
|
|
35
|
+
* For sys-admin / cross-tenant queries, callers must additionally run
|
|
36
|
+
* `SELECT set_config('app.bypass_rls', 'true', false)` on the same client.
|
|
37
|
+
* This contract is enforced by `tenantAdminBypassSql` below.
|
|
38
|
+
*
|
|
25
39
|
* @param opts - Table and tenant column configuration.
|
|
26
40
|
* @returns A multi-statement SQL string suitable for inclusion in a migration.
|
|
27
41
|
*/
|
|
28
42
|
export declare function tenantIsolationSql(opts: TenantIsolationOptions): string;
|
|
43
|
+
/**
|
|
44
|
+
* Generate the SQL to add a permissive RLS bypass policy that fires when
|
|
45
|
+
* `current_setting('app.bypass_rls', true) = 'true'`. Intended to be added
|
|
46
|
+
* alongside `tenantIsolationSql` for sys-admin / cross-tenant queries that
|
|
47
|
+
* must read rows belonging to any tenant (for example, the data-management
|
|
48
|
+
* domain's schema explorer). The runtime sets `app.bypass_rls` via
|
|
49
|
+
* `SELECT set_config('app.bypass_rls', 'true', false)` on the same per-request
|
|
50
|
+
* client that hydrates with the sys-admin role.
|
|
51
|
+
*
|
|
52
|
+
* The generated policy is idempotent: it only creates the policy if no policy
|
|
53
|
+
* with the same name already exists.
|
|
54
|
+
*
|
|
55
|
+
* @param opts - Table and tenant column configuration (same shape as
|
|
56
|
+
* `tenantIsolationSql` so the two helpers compose cleanly).
|
|
57
|
+
* @returns A multi-statement SQL string suitable for inclusion in a migration.
|
|
58
|
+
*/
|
|
59
|
+
export declare function tenantAdminBypassSql(opts: TenantIsolationOptions): string;
|
|
29
60
|
/**
|
|
30
61
|
* Generate the SQL to drop a tenant isolation policy and disable RLS.
|
|
31
62
|
* Useful for down-migrations.
|