@xemahq/biome-database-nest 0.12.0 → 0.12.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.
@@ -1,235 +0,0 @@
1
- /**
2
- * LC-1 topology-1 org erasure — the generic, DETERMINISTIC eraser for
3
- * shared-table by-row org data.
4
- *
5
- * A first-party biome holds an org's data as rows carrying the org column in a
6
- * schema-per-service database. To erase an org we must delete exactly those
7
- * rows, in every org-scoped model. The set of org-scoped models is NOT guessed:
8
- * it is the SAME set the tenant-isolation extension scopes queries by —
9
- * {@link collectOrgScopedModels}, which reads the generated Prisma runtime data
10
- * model and picks every model declaring the org field. Reusing that set is what
11
- * makes this deterministic rather than a heuristic: a model is erased here iff
12
- * it is scoped there.
13
- *
14
- * FK-safe WITHOUT parsing private relation metadata: each `deleteMany` is its
15
- * own statement (no wrapping transaction to poison), so a child→parent FK
16
- * violation (Prisma `P2003`) simply defers that model to the next pass. We loop
17
- * until every model is deleted; a pass that makes NO progress while models
18
- * remain means something outside the org-scoped set (a non-org-scoped child
19
- * row, or a cycle) blocks deletion — we FAIL LOUD with the blocked models
20
- * rather than leave a silent partial erasure. Idempotent + resumable: re-running
21
- * re-deletes already-gone rows as no-ops, so the orchestrator saga can retry.
22
- *
23
- * A participant that matches ZERO models is likewise a FAILURE, not a clean run
24
- * — see {@link assertOrgErasureMatchesModels} for why, and for the distinction
25
- * (models vs rows) that makes that assertion correct rather than a new bug.
26
- */
27
- import {
28
- DEFAULT_ORG_FIELD,
29
- collectOrgScopedModels,
30
- readRuntimeModels,
31
- } from './tenant-isolation.extension';
32
-
33
- /** Prisma's error code for a foreign-key constraint failure on delete. */
34
- const PRISMA_FK_VIOLATION_CODE = 'P2003';
35
-
36
- export interface EraseOrgDataOptions {
37
- /** Org column. Defaults to {@link DEFAULT_ORG_FIELD} (`orgId`). */
38
- readonly orgField?: string;
39
- /**
40
- * Models to exclude from erasure even though they carry the org field.
41
- * Passed through to {@link collectOrgScopedModels}, which fails fast on a
42
- * name that matches no model. Use for models an org must NOT lose on erasure
43
- * (there should be none for GDPR erasure — this exists for symmetry with the
44
- * isolation extension's exemptions).
45
- */
46
- readonly exemptModels?: readonly string[];
47
- }
48
-
49
- export interface OrgErasureReport {
50
- readonly orgId: string;
51
- /** Rows deleted per org-scoped model. */
52
- readonly deletedByModel: Readonly<Record<string, number>>;
53
- readonly totalDeleted: number;
54
- /** Passes taken to converge (FK ordering) — observability only. */
55
- readonly passes: number;
56
- }
57
-
58
- /** Thrown when org-scoped models remain undeletable after a no-progress pass. */
59
- export class OrgErasureBlockedError extends Error {
60
- constructor(
61
- readonly orgId: string,
62
- readonly blockedModels: readonly string[],
63
- ) {
64
- super(
65
- `[org-erasure] could not erase org "${orgId}": models [${blockedModels.join(
66
- ', ',
67
- )}] remain blocked by a foreign key after a no-progress pass. This means a ` +
68
- `non-org-scoped table references these org rows, or the org-scoped models ` +
69
- `form a delete cycle. Add an onDelete rule or a service-specific eraser — ` +
70
- `erasure MUST NOT leave a silent partial.`,
71
- );
72
- this.name = 'OrgErasureBlockedError';
73
- }
74
- }
75
-
76
- /**
77
- * Thrown when an erasure participant's configuration matches NO org-scoped
78
- * model at all — it is structurally incapable of deleting anything.
79
- *
80
- * This is a MISCONFIGURATION, never an empty org. `memory-api` shipped as a
81
- * registered GDPR erasure participant whose every model spells the org column
82
- * `organizationId` while the participant was registered with no `orgField`, so
83
- * {@link collectOrgScopedModels} returned an empty set, the delete loop never
84
- * executed, and every erasure answered `200 {totalDeleted: 0}` — which the
85
- * orchestrator saga records as a SUCCESSFUL erasure. A Data Subject Request
86
- * answered from that record is false in writing.
87
- *
88
- * There is no degraded mode here: a participant that cannot delete anything
89
- * must not be allowed to report success.
90
- */
91
- export class OrgErasureMatchesNothingError extends Error {
92
- constructor(
93
- readonly orgField: string,
94
- readonly knownModels: readonly string[],
95
- readonly exemptModels: readonly string[],
96
- ) {
97
- super(
98
- `[org-erasure] this erasure participant matched ZERO models on org ` +
99
- `field "${orgField}" — it cannot delete anything, so it MUST NOT ` +
100
- `report a successful erasure (the orchestrator would record proof of ` +
101
- `an erasure that never happened). Known models: [${knownModels.join(
102
- ', ',
103
- )}]` +
104
- (exemptModels.length > 0
105
- ? `; exemptModels: [${exemptModels.join(', ')}]` +
106
- ` — exempting every org-scoped model leaves nothing to erase`
107
- : '') +
108
- `. Fix the participant's orgField (or stop registering the service as ` +
109
- `an org-erasure participant) rather than relaxing this check.`,
110
- );
111
- this.name = 'OrgErasureMatchesNothingError';
112
- }
113
- }
114
-
115
- /**
116
- * Assert the participant can possibly delete something, and return the models
117
- * it covers.
118
- *
119
- * **Models, never rows.** Zero MODELS matched means the participant is
120
- * structurally incapable of erasing — a configuration defect. Zero ROWS deleted
121
- * across a non-empty model set is a legitimate clean run: an org that genuinely
122
- * holds no data in this service must succeed (and re-running an erasure must
123
- * stay idempotent). Conflating the two would fail every honest erasure for an
124
- * org with no data here, which is strictly worse than the bug this closes.
125
- *
126
- * Exported so a participant can gate at BOOT as well as per request:
127
- * {@link OrgErasureModule} calls it while wiring its runner, so a service
128
- * refuses to start rather than advertise `xema.org-data-erasure` it cannot
129
- * honour. Boot proves the CONFIGURATION; the per-request call inside
130
- * {@link eraseOrgData} proves the RUN that is about to be recorded as erasure
131
- * evidence. Both are cheap and neither subsumes the other.
132
- */
133
- export function assertOrgErasureMatchesModels(
134
- client: unknown,
135
- options: EraseOrgDataOptions = {},
136
- ): ReadonlySet<string> {
137
- const orgField = options.orgField ?? DEFAULT_ORG_FIELD;
138
- const exemptModels = options.exemptModels ?? [];
139
- const scoped = collectOrgScopedModels(client, orgField, exemptModels);
140
- if (scoped.size === 0) {
141
- throw new OrgErasureMatchesNothingError(
142
- orgField,
143
- Object.keys(readRuntimeModels(client)),
144
- exemptModels,
145
- );
146
- }
147
- return scoped;
148
- }
149
-
150
- interface DeleteManyDelegate {
151
- deleteMany(args: { where: Record<string, unknown> }): Promise<{ count: number }>;
152
- }
153
-
154
- /** Prisma exposes a model `Foo` as the client property `foo`. */
155
- function delegateKeyForModel(modelName: string): string {
156
- return modelName.charAt(0).toLowerCase() + modelName.slice(1);
157
- }
158
-
159
- function isForeignKeyViolation(error: unknown): boolean {
160
- return (
161
- typeof error === 'object' &&
162
- error !== null &&
163
- (error as { code?: unknown }).code === PRISMA_FK_VIOLATION_CODE
164
- );
165
- }
166
-
167
- /**
168
- * Erase every row belonging to `orgId` across all org-scoped models of the
169
- * given Prisma client. Returns a per-model report. Fails loud (throws) rather
170
- * than leaving a partial erasure.
171
- *
172
- * `client` is the RAW/base Prisma client (NOT an org-scoped `forOrg` client):
173
- * the org filter is applied explicitly here, so this must not be double-scoped.
174
- */
175
- export async function eraseOrgData(
176
- client: unknown,
177
- orgId: string,
178
- options: EraseOrgDataOptions = {},
179
- ): Promise<OrgErasureReport> {
180
- const orgField = options.orgField ?? DEFAULT_ORG_FIELD;
181
- if (orgId.trim() === '') {
182
- throw new Error('[org-erasure] orgId must be a non-empty string.');
183
- }
184
-
185
- // Matching no model is a misconfiguration, not an empty org — never let it
186
- // return `{ totalDeleted: 0 }`, which the orchestrator reads as success.
187
- const scoped = assertOrgErasureMatchesModels(client, options);
188
-
189
- const deletedByModel: Record<string, number> = {};
190
- let remaining = [...scoped];
191
- let passes = 0;
192
-
193
- while (remaining.length > 0) {
194
- passes += 1;
195
- const blocked: string[] = [];
196
- let progressed = false;
197
-
198
- for (const modelName of remaining) {
199
- const delegate = (client as Record<string, unknown>)[
200
- delegateKeyForModel(modelName)
201
- ] as DeleteManyDelegate | undefined;
202
- if (delegate?.deleteMany === undefined) {
203
- throw new Error(
204
- `[org-erasure] Prisma client has no deleteMany delegate for model ` +
205
- `"${modelName}" (looked up "${delegateKeyForModel(modelName)}").`,
206
- );
207
- }
208
-
209
- try {
210
- const { count } = await delegate.deleteMany({
211
- where: { [orgField]: orgId },
212
- });
213
- deletedByModel[modelName] = (deletedByModel[modelName] ?? 0) + count;
214
- progressed = true;
215
- } catch (error) {
216
- if (isForeignKeyViolation(error)) {
217
- blocked.push(modelName);
218
- continue;
219
- }
220
- throw error;
221
- }
222
- }
223
-
224
- if (!progressed && blocked.length > 0) {
225
- throw new OrgErasureBlockedError(orgId, blocked);
226
- }
227
- remaining = blocked;
228
- }
229
-
230
- const totalDeleted = Object.values(deletedByModel).reduce(
231
- (sum, n) => sum + n,
232
- 0,
233
- );
234
- return { orgId, deletedByModel, totalDeleted, passes };
235
- }
@@ -1,305 +0,0 @@
1
- /**
2
- * PURE Prisma-arg transformers for org-tenant scoping. No I/O, no logging, no
3
- * mode logic — given (model, operation, args, orgId, orgField) they return the
4
- * rewritten args plus every violation found, and the caller (the client
5
- * extension) decides throw-vs-warn per {@link TenantIsolationMode}. Never
6
- * mutates the input args.
7
- */
8
- import {
9
- TenantIsolationViolationKind,
10
- type TenantIsolationViolation,
11
- } from './tenant-isolation-error';
12
-
13
- /**
14
- * The closed set of Prisma model-delegate operations tenant isolation knows
15
- * how to scope (relational providers; the raw Mongo ops do not apply). An
16
- * operation outside this set reaching an org-scoped model is reported as an
17
- * `UnsupportedOperation` violation — never silently passed through.
18
- */
19
- export enum PrismaModelOperation {
20
- FindUnique = 'findUnique',
21
- FindUniqueOrThrow = 'findUniqueOrThrow',
22
- FindFirst = 'findFirst',
23
- FindFirstOrThrow = 'findFirstOrThrow',
24
- FindMany = 'findMany',
25
- Create = 'create',
26
- CreateMany = 'createMany',
27
- CreateManyAndReturn = 'createManyAndReturn',
28
- Update = 'update',
29
- UpdateMany = 'updateMany',
30
- UpdateManyAndReturn = 'updateManyAndReturn',
31
- Upsert = 'upsert',
32
- Delete = 'delete',
33
- DeleteMany = 'deleteMany',
34
- Aggregate = 'aggregate',
35
- Count = 'count',
36
- GroupBy = 'groupBy',
37
- }
38
-
39
- /** How an operation's `where` participates in scoping. */
40
- enum WhereShape {
41
- /** No `where` clause on this operation. */
42
- None = 'none',
43
- /** A plain filter `where` — the org clause is AND-ed in. */
44
- Filter = 'filter',
45
- /**
46
- * A `WhereUniqueInput`. Since Prisma 5 (extendedWhereUnique GA) a unique
47
- * where accepts additional non-unique filter fields and `AND`/`OR`/`NOT`,
48
- * so the org clause is AND-ed in exactly like a filter where — a cross-org
49
- * unique read resolves to `null` and a cross-org update/delete raises
50
- * Prisma's P2025 (record not found), with no post-verification race.
51
- */
52
- Unique = 'unique',
53
- }
54
-
55
- interface OperationScopeSpec {
56
- readonly where: WhereShape;
57
- /** Args key holding the create payload to stamp (`data` or `create`). */
58
- readonly createDataKey?: 'data' | 'create';
59
- /** Args key holding the update payload to mismatch-check (`data`/`update`). */
60
- readonly updateDataKey?: 'data' | 'update';
61
- }
62
-
63
- const OPERATION_SCOPE_SPECS: Readonly<
64
- Record<PrismaModelOperation, OperationScopeSpec>
65
- > = {
66
- [PrismaModelOperation.FindUnique]: { where: WhereShape.Unique },
67
- [PrismaModelOperation.FindUniqueOrThrow]: { where: WhereShape.Unique },
68
- [PrismaModelOperation.FindFirst]: { where: WhereShape.Filter },
69
- [PrismaModelOperation.FindFirstOrThrow]: { where: WhereShape.Filter },
70
- [PrismaModelOperation.FindMany]: { where: WhereShape.Filter },
71
- [PrismaModelOperation.Create]: {
72
- where: WhereShape.None,
73
- createDataKey: 'data',
74
- },
75
- [PrismaModelOperation.CreateMany]: {
76
- where: WhereShape.None,
77
- createDataKey: 'data',
78
- },
79
- [PrismaModelOperation.CreateManyAndReturn]: {
80
- where: WhereShape.None,
81
- createDataKey: 'data',
82
- },
83
- [PrismaModelOperation.Update]: {
84
- where: WhereShape.Unique,
85
- updateDataKey: 'data',
86
- },
87
- [PrismaModelOperation.UpdateMany]: {
88
- where: WhereShape.Filter,
89
- updateDataKey: 'data',
90
- },
91
- [PrismaModelOperation.UpdateManyAndReturn]: {
92
- where: WhereShape.Filter,
93
- updateDataKey: 'data',
94
- },
95
- [PrismaModelOperation.Upsert]: {
96
- where: WhereShape.Unique,
97
- createDataKey: 'create',
98
- updateDataKey: 'update',
99
- },
100
- [PrismaModelOperation.Delete]: { where: WhereShape.Unique },
101
- [PrismaModelOperation.DeleteMany]: { where: WhereShape.Filter },
102
- [PrismaModelOperation.Aggregate]: { where: WhereShape.Filter },
103
- [PrismaModelOperation.Count]: { where: WhereShape.Filter },
104
- [PrismaModelOperation.GroupBy]: { where: WhereShape.Filter },
105
- };
106
-
107
- /** Input to {@link scopeArgsToOrg}. `orgId` is REQUIRED — resolving whether an
108
- * org context exists (and what missing context means per mode) is the
109
- * extension's job, not this pure module's. */
110
- export interface OrgScopeRequest {
111
- readonly model: string;
112
- readonly operation: string;
113
- readonly args: Readonly<Record<string, unknown>> | undefined;
114
- readonly orgId: string;
115
- readonly orgField: string;
116
- }
117
-
118
- export interface OrgScopeOutcome {
119
- /**
120
- * The org-scoped args. Only meaningful when `violations` is empty — on any
121
- * violation the caller must throw (Enforce) or warn + run the ORIGINAL args
122
- * (Observe), never run a half-rewritten query.
123
- */
124
- readonly args: Record<string, unknown>;
125
- readonly violations: readonly TenantIsolationViolation[];
126
- }
127
-
128
- function isPlainObject(value: unknown): value is Record<string, unknown> {
129
- return typeof value === 'object' && value !== null && !Array.isArray(value);
130
- }
131
-
132
- /** Append `clause` to an existing Prisma `AND` (absent | object | array). */
133
- function appendAndClause(
134
- existing: unknown,
135
- clause: Record<string, unknown>,
136
- ): unknown[] {
137
- if (existing === undefined) {
138
- return [clause];
139
- }
140
- if (Array.isArray(existing)) {
141
- return [...existing, clause];
142
- }
143
- return [existing, clause];
144
- }
145
-
146
- /**
147
- * AND `{ [orgField]: orgId }` into a `where` (filter or unique — both accept
148
- * `AND` since Prisma 5). AND-ing (instead of overwriting) means a caller that
149
- * explicitly filtered on ANOTHER org deterministically matches nothing rather
150
- * than silently having its filter replaced.
151
- */
152
- function andOrgIntoWhere(
153
- where: unknown,
154
- orgField: string,
155
- orgId: string,
156
- ): unknown {
157
- const orgClause = { [orgField]: orgId };
158
- if (where === undefined || where === null) {
159
- return orgClause;
160
- }
161
- if (!isPlainObject(where)) {
162
- // Malformed where — Prisma's own validation rejects the query; there is
163
- // nothing meaningful to scope and no way the query can return rows.
164
- return where;
165
- }
166
- return { ...where, AND: appendAndClause(where['AND'], orgClause) };
167
- }
168
-
169
- /** Unwrap `{ set: value }` (the atomic update shape for scalars). */
170
- function extractScalarWriteValue(value: unknown): unknown {
171
- if (isPlainObject(value) && 'set' in value) {
172
- return value['set'];
173
- }
174
- return value;
175
- }
176
-
177
- function orgMismatchViolation(
178
- req: OrgScopeRequest,
179
- actual: unknown,
180
- path: string,
181
- ): TenantIsolationViolation {
182
- return {
183
- kind: TenantIsolationViolationKind.OrgMismatch,
184
- model: req.model,
185
- operation: req.operation,
186
- orgField: req.orgField,
187
- expectedOrgId: req.orgId,
188
- actualOrgId: String(actual),
189
- path,
190
- };
191
- }
192
-
193
- /**
194
- * Stamp one create row: fill `orgField` when absent; report a violation when
195
- * present with a DIFFERENT org than the scope.
196
- */
197
- function stampCreateRow(
198
- row: unknown,
199
- req: OrgScopeRequest,
200
- path: string,
201
- violations: TenantIsolationViolation[],
202
- ): unknown {
203
- if (!isPlainObject(row)) {
204
- // Malformed payload — Prisma validation rejects it downstream.
205
- return row;
206
- }
207
- const current = row[req.orgField];
208
- if (current === undefined) {
209
- return { ...row, [req.orgField]: req.orgId };
210
- }
211
- if (extractScalarWriteValue(current) !== req.orgId) {
212
- violations.push(
213
- orgMismatchViolation(req, extractScalarWriteValue(current), path),
214
- );
215
- }
216
- return row;
217
- }
218
-
219
- function stampCreatePayload(
220
- payload: unknown,
221
- req: OrgScopeRequest,
222
- path: string,
223
- violations: TenantIsolationViolation[],
224
- ): unknown {
225
- if (Array.isArray(payload)) {
226
- return payload.map((row) => stampCreateRow(row, req, path, violations));
227
- }
228
- return stampCreateRow(payload, req, path, violations);
229
- }
230
-
231
- /**
232
- * Update payloads are never rewritten — only checked: setting `orgField` to a
233
- * different org would re-home the row into another tenant.
234
- */
235
- function checkUpdatePayload(
236
- payload: unknown,
237
- req: OrgScopeRequest,
238
- path: string,
239
- violations: TenantIsolationViolation[],
240
- ): void {
241
- if (!isPlainObject(payload)) {
242
- return;
243
- }
244
- const current = payload[req.orgField];
245
- if (current === undefined) {
246
- return;
247
- }
248
- if (extractScalarWriteValue(current) !== req.orgId) {
249
- violations.push(
250
- orgMismatchViolation(req, extractScalarWriteValue(current), path),
251
- );
252
- }
253
- }
254
-
255
- /**
256
- * Rewrite one org-scoped model operation's args so it can only see/touch rows
257
- * of `orgId`, and report every violation found. Pure: the input args object is
258
- * never mutated.
259
- */
260
- export function scopeArgsToOrg(req: OrgScopeRequest): OrgScopeOutcome {
261
- const spec = (
262
- OPERATION_SCOPE_SPECS as Readonly<
263
- Record<string, OperationScopeSpec | undefined>
264
- >
265
- )[req.operation];
266
- if (spec === undefined) {
267
- return {
268
- args: { ...(req.args ?? {}) },
269
- violations: [
270
- {
271
- kind: TenantIsolationViolationKind.UnsupportedOperation,
272
- model: req.model,
273
- operation: req.operation,
274
- orgField: req.orgField,
275
- expectedOrgId: req.orgId,
276
- },
277
- ],
278
- };
279
- }
280
-
281
- const violations: TenantIsolationViolation[] = [];
282
- const args: Record<string, unknown> = { ...(req.args ?? {}) };
283
-
284
- if (spec.where !== WhereShape.None) {
285
- args['where'] = andOrgIntoWhere(args['where'], req.orgField, req.orgId);
286
- }
287
- if (spec.createDataKey !== undefined) {
288
- args[spec.createDataKey] = stampCreatePayload(
289
- args[spec.createDataKey],
290
- req,
291
- spec.createDataKey,
292
- violations,
293
- );
294
- }
295
- if (spec.updateDataKey !== undefined) {
296
- checkUpdatePayload(
297
- args[spec.updateDataKey],
298
- req,
299
- spec.updateDataKey,
300
- violations,
301
- );
302
- }
303
-
304
- return { args, violations };
305
- }
@@ -1,79 +0,0 @@
1
- /**
2
- * What went wrong from a tenant-isolation point of view. Closed set.
3
- *
4
- * - `OrgMismatch` — a write payload names an org that differs from the org the
5
- * client is scoped to (attempted cross-org create, or re-homing a row into
6
- * another org via update data).
7
- * - `MissingOrgContext` — an org-scoped model was accessed through the
8
- * ambient-scoped client while NO org context was present (boot, migrations,
9
- * seeding, contribution sync, Temporal activities, background workers).
10
- * - `UnsupportedOperation` — a Prisma operation outside the known closed set
11
- * reached an org-scoped model; isolation cannot prove it is scoped, so it is
12
- * rejected rather than silently passed through.
13
- */
14
- export enum TenantIsolationViolationKind {
15
- OrgMismatch = 'org_mismatch',
16
- MissingOrgContext = 'missing_org_context',
17
- UnsupportedOperation = 'unsupported_operation',
18
- }
19
-
20
- /** Structured description of one violation (also the Observe-mode log body). */
21
- export interface TenantIsolationViolation {
22
- readonly kind: TenantIsolationViolationKind;
23
- /** Prisma model name (as in the schema, e.g. `Artifact`). */
24
- readonly model: string;
25
- /** Prisma delegate operation (e.g. `findMany`, `create`). */
26
- readonly operation: string;
27
- /** The org column being enforced (default `orgId`). */
28
- readonly orgField: string;
29
- /** The org the client is scoped to (absent for MissingOrgContext). */
30
- readonly expectedOrgId?: string;
31
- /** The conflicting org named by the query args (OrgMismatch only). */
32
- readonly actualOrgId?: string;
33
- /** Which args key carried the conflict, e.g. `data`, `create`, `update`. */
34
- readonly path?: string;
35
- }
36
-
37
- /** Render a violation as one structured, actionable log/error line. */
38
- export function formatTenantIsolationViolation(
39
- violation: TenantIsolationViolation,
40
- ): string {
41
- const site = `${violation.model}.${violation.operation}`;
42
- switch (violation.kind) {
43
- case TenantIsolationViolationKind.OrgMismatch:
44
- return (
45
- `[tenant-isolation] org mismatch on ${site}: args` +
46
- `${violation.path !== undefined ? ` "${violation.path}"` : ''} set ` +
47
- `${violation.orgField}="${violation.actualOrgId}" but the client is ` +
48
- `scoped to "${violation.expectedOrgId}".`
49
- );
50
- case TenantIsolationViolationKind.MissingOrgContext:
51
- return (
52
- `[tenant-isolation] ${site} executed with no org context. Outside a ` +
53
- `request (boot, migrations, seeding, workers, Temporal activities) ` +
54
- `there is no ambient org — use client.forOrg(orgId) for explicit ` +
55
- `scoping, or client.asSystem() for a legitimate cross-org system path.`
56
- );
57
- case TenantIsolationViolationKind.UnsupportedOperation:
58
- return (
59
- `[tenant-isolation] unrecognized Prisma operation "${violation.operation}" ` +
60
- `on org-scoped model ${violation.model} — tenant isolation cannot prove ` +
61
- `it is org-scoped. Add it to PrismaModelOperation or exempt the model.`
62
- );
63
- }
64
- }
65
-
66
- /**
67
- * Typed error thrown in Enforce mode. Carries the structured
68
- * {@link TenantIsolationViolation} so callers/tests can branch on `kind`
69
- * without parsing the message.
70
- */
71
- export class TenantIsolationError extends Error {
72
- readonly violation: TenantIsolationViolation;
73
-
74
- constructor(violation: TenantIsolationViolation) {
75
- super(formatTenantIsolationViolation(violation));
76
- this.name = 'TenantIsolationError';
77
- this.violation = violation;
78
- }
79
- }
@@ -1,55 +0,0 @@
1
- /**
2
- * How tenant isolation behaves for the DEFAULT (ambient-context-scoped)
3
- * client. Closed set — do not add members without a plan revision.
4
- *
5
- * - `Enforce` — org filters are injected into every query on an org-scoped
6
- * model; violations (org mismatch in write payloads, org-model access with
7
- * no org context) throw a typed {@link TenantIsolationError}.
8
- * - `Observe` — NO query is rewritten; instead every violation that Enforce
9
- * would reject is logged as a structured warning. This is the rollout
10
- * default so ~60 services can surface gaps without behavior change.
11
- * - `Off` — full passthrough (kill switch). Explicit `forOrg(orgId)` clients
12
- * still enforce — explicit scoping is a correctness request, never silently
13
- * degraded.
14
- */
15
- export enum TenantIsolationMode {
16
- Enforce = 'enforce',
17
- Observe = 'observe',
18
- Off = 'off',
19
- }
20
-
21
- /** Env var read by {@link resolveTenantIsolationMode} when no option is set. */
22
- export const TENANT_ISOLATION_MODE_ENV_VAR = 'XEMA_TENANT_ISOLATION_MODE';
23
-
24
- const MODE_VALUES: readonly string[] = Object.values(TenantIsolationMode);
25
-
26
- function isTenantIsolationMode(value: string): value is TenantIsolationMode {
27
- return MODE_VALUES.includes(value);
28
- }
29
-
30
- /**
31
- * Resolve the effective mode: explicit option > `XEMA_TENANT_ISOLATION_MODE`
32
- * env var > default {@link TenantIsolationMode.Observe}.
33
- *
34
- * Fail-fast: an env var set to anything outside the closed set throws — a
35
- * typo'd mode must never silently fall back to a weaker one.
36
- */
37
- export function resolveTenantIsolationMode(
38
- explicit?: TenantIsolationMode,
39
- env: NodeJS.ProcessEnv = process.env,
40
- ): TenantIsolationMode {
41
- if (explicit !== undefined) {
42
- return explicit;
43
- }
44
- const raw = env[TENANT_ISOLATION_MODE_ENV_VAR];
45
- if (raw === undefined || raw === '') {
46
- return TenantIsolationMode.Observe;
47
- }
48
- if (!isTenantIsolationMode(raw)) {
49
- throw new Error(
50
- `[tenant-isolation] invalid ${TENANT_ISOLATION_MODE_ENV_VAR}="${raw}" — ` +
51
- `expected one of: ${MODE_VALUES.join(', ')}.`,
52
- );
53
- }
54
- return raw;
55
- }