@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,203 +0,0 @@
1
- /**
2
- * The Prisma client-extension face of tenant isolation: detects org-scoped
3
- * models from the client's runtime data model at construction time and applies
4
- * the pure {@link scopeArgsToOrg} transformers per operation, throwing or
5
- * warning per {@link TenantIsolationMode}. Framework-free (the logger is a
6
- * structural interface — the Nest factory injects a Nest `Logger`).
7
- */
8
- import { scopeArgsToOrg } from './org-scope';
9
- import {
10
- TenantIsolationError,
11
- TenantIsolationViolationKind,
12
- formatTenantIsolationViolation,
13
- type TenantIsolationViolation,
14
- } from './tenant-isolation-error';
15
- import { TenantIsolationMode } from './tenant-isolation-mode';
16
-
17
- /** The org column enforced when a model declares one (see design C3). */
18
- export const DEFAULT_ORG_FIELD = 'orgId';
19
-
20
- /** Structural logger so this module stays Nest-free (Nest `Logger` satisfies it). */
21
- export interface TenantIsolationLogger {
22
- warn(message: string): void;
23
- debug(message: string): void;
24
- }
25
-
26
- export interface RuntimeDataModelField {
27
- readonly name: string;
28
- }
29
-
30
- export interface RuntimeDataModelModel {
31
- readonly fields: readonly RuntimeDataModelField[];
32
- }
33
-
34
- interface RuntimeDataModelCarrier {
35
- readonly _runtimeDataModel?: {
36
- readonly models?: Readonly<Record<string, RuntimeDataModelModel>>;
37
- };
38
- }
39
-
40
- /**
41
- * The client's runtime data model, or a hard error. Exported so the unscoped-
42
- * model declaration check reads the SAME source of truth as org-scoped-model
43
- * detection — two readers of one schema view, never two views.
44
- */
45
- export function readRuntimeModels(
46
- client: unknown,
47
- ): Readonly<Record<string, RuntimeDataModelModel>> {
48
- const models = (client as RuntimeDataModelCarrier)._runtimeDataModel?.models;
49
- if (models === undefined) {
50
- throw new Error(
51
- '[tenant-isolation] the Prisma client exposes no _runtimeDataModel.models — ' +
52
- 'cannot detect org-scoped models. Tenant isolation requires a generated ' +
53
- '@prisma/client >= 5 (this SDK peers on ^7).',
54
- );
55
- }
56
- return models;
57
- }
58
-
59
- /**
60
- * Detect which models are org-scoped: every model in the client's runtime data
61
- * model that declares a field named `orgField`, minus `exemptModels`.
62
- *
63
- * Fail-fast: an exempt name that matches NO model throws — a typo'd exemption
64
- * must never silently exempt nothing (or worse, look like it exempted the
65
- * intended model).
66
- */
67
- export function collectOrgScopedModels(
68
- client: unknown,
69
- orgField: string,
70
- exemptModels: readonly string[],
71
- ): ReadonlySet<string> {
72
- const models = readRuntimeModels(client);
73
- for (const exempt of exemptModels) {
74
- if (!(exempt in models)) {
75
- throw new Error(
76
- `[tenant-isolation] exemptModels names unknown model "${exempt}" — ` +
77
- `known models: ${Object.keys(models).join(', ')}.`,
78
- );
79
- }
80
- }
81
- const scoped = new Set<string>();
82
- for (const [name, model] of Object.entries(models)) {
83
- if (exemptModels.includes(name)) {
84
- continue;
85
- }
86
- if (model.fields.some((field) => field.name === orgField)) {
87
- scoped.add(name);
88
- }
89
- }
90
- return scoped;
91
- }
92
-
93
- /** The shape Prisma passes to a `$allModels.$allOperations` query callback. */
94
- export interface TenantIsolationQueryContext {
95
- readonly model?: string;
96
- readonly operation: string;
97
- readonly args?: Record<string, unknown>;
98
- readonly query: (args: unknown) => Promise<unknown>;
99
- }
100
-
101
- export interface CreateTenantIsolationExtensionOptions {
102
- /** Output of {@link collectOrgScopedModels}. */
103
- readonly orgScopedModels: ReadonlySet<string>;
104
- readonly orgField: string;
105
- readonly mode: TenantIsolationMode;
106
- /**
107
- * Where the scope's org comes from per operation: the ambient accessor
108
- * (`getAmbientOrgId`) for the default client, or a constant for
109
- * `forOrg(orgId)` clients. `undefined` = no org context.
110
- */
111
- readonly orgIdSource: () => string | undefined;
112
- readonly logger: TenantIsolationLogger;
113
- }
114
-
115
- /** The `$extends` argument shape produced by {@link createTenantIsolationExtension}. */
116
- export interface TenantIsolationExtensionDefinition {
117
- readonly name: string;
118
- readonly query: {
119
- readonly $allModels: {
120
- $allOperations(ctx: TenantIsolationQueryContext): Promise<unknown>;
121
- };
122
- };
123
- }
124
-
125
- /**
126
- * Build the `$extends` argument that applies org scoping to every operation on
127
- * every org-scoped model.
128
- *
129
- * - `Enforce`: run the rewritten args; throw {@link TenantIsolationError} on
130
- * any violation (org mismatch, missing org context, unsupported operation).
131
- * - `Observe`: run the ORIGINAL args (zero behavior change) and warn each
132
- * violation Enforce would reject.
133
- * - `Off`: full passthrough.
134
- */
135
- export function createTenantIsolationExtension(
136
- options: CreateTenantIsolationExtensionOptions,
137
- ): TenantIsolationExtensionDefinition {
138
- const { orgScopedModels, orgField, mode, orgIdSource, logger } = options;
139
-
140
- const handleViolations = (
141
- violations: readonly TenantIsolationViolation[],
142
- ): void => {
143
- const [first] = violations;
144
- if (first === undefined) {
145
- return;
146
- }
147
- if (mode === TenantIsolationMode.Enforce) {
148
- throw new TenantIsolationError(first);
149
- }
150
- for (const violation of violations) {
151
- logger.warn(formatTenantIsolationViolation(violation));
152
- }
153
- };
154
-
155
- return {
156
- name: 'xema-tenant-isolation',
157
- query: {
158
- $allModels: {
159
- $allOperations(ctx: TenantIsolationQueryContext): Promise<unknown> {
160
- const { model, operation, args, query } = ctx;
161
- if (
162
- mode === TenantIsolationMode.Off ||
163
- model === undefined ||
164
- !orgScopedModels.has(model)
165
- ) {
166
- return query(args);
167
- }
168
-
169
- const orgId = orgIdSource();
170
- if (orgId === undefined || orgId === '') {
171
- handleViolations([
172
- {
173
- kind: TenantIsolationViolationKind.MissingOrgContext,
174
- model,
175
- operation,
176
- orgField,
177
- },
178
- ]);
179
- // Observe only — Enforce threw above.
180
- return query(args);
181
- }
182
-
183
- const outcome = scopeArgsToOrg({
184
- model,
185
- operation,
186
- args,
187
- orgId,
188
- orgField,
189
- });
190
- if (outcome.violations.length > 0) {
191
- handleViolations(outcome.violations);
192
- // Observe only — Enforce threw above. Run the ORIGINAL args;
193
- // never a half-rewritten query.
194
- return query(args);
195
- }
196
- return mode === TenantIsolationMode.Enforce
197
- ? query(outcome.args)
198
- : query(args);
199
- },
200
- },
201
- },
202
- };
203
- }
@@ -1,272 +0,0 @@
1
- /**
2
- * ─────────────────────────────────────────────────────────────────────────────
3
- * The blind spot this module closes.
4
- *
5
- * {@link collectOrgScopedModels} treats a model as org-scoped ONLY when it
6
- * DECLARES the org column. A model with no org column is therefore invisible to
7
- * the isolation extension: every query on it passes through UNSCOPED, silently,
8
- * even in {@link TenantIsolationMode.Enforce}. That silence is the whole
9
- * problem — `Enforce` reads as "this service is isolated", but it only ever
10
- * covered the models that happened to carry an `orgId`.
11
- *
12
- * That is not a hypothetical. `ProjectRepositoryBinding` (scm-connector-api)
13
- * has no `orgId` — it is owned transitively via `orgRepositoryId ->
14
- * OrgRepository.orgId` — so enforcement never applied to it, and eight
15
- * cross-org queries lived there unnoticed, including a destructive
16
- * CloudEvent-driven `deleteByProjectId` and, on the sibling connector edge, a
17
- * cross-tenant PLAINTEXT-credential read.
18
- *
19
- * The fix is not to guess. A model with no org column can be either:
20
- *
21
- * - GENUINELY GLOBAL — a platform catalog, a kernel-shipped registry, a
22
- * bookkeeping table. Correctly unscoped.
23
- * - TENANT-SCOPED-INVISIBLE — the rows DO belong to a tenant, reached through
24
- * a foreign key to something that carries the org. These MUST be fenced by
25
- * hand at every query (`owner: { orgId }`), and nothing in the runtime can
26
- * infer that for them.
27
- *
28
- * Only the schema's author can tell those apart, so the runtime demands the
29
- * answer instead of assuming one: every model with no org column must be
30
- * DECLARED, with a written justification, via
31
- * {@link TenantIsolationServiceOptions.unscopedModels}. An undeclared one is a
32
- * boot-time error in `Enforce` and a loud warning in `Observe` — never a silent
33
- * pass-through.
34
- * ─────────────────────────────────────────────────────────────────────────────
35
- */
36
- import { TenantIsolationMode } from './tenant-isolation-mode';
37
-
38
- import type { TenantIsolationLogger } from './tenant-isolation.extension';
39
-
40
- /**
41
- * Why a model carries no org column, and — when it is tenant-scoped in reality
42
- * — how its queries are fenced instead.
43
- *
44
- * A bare string is accepted as shorthand for `{ reason }`.
45
- */
46
- export interface UnscopedModelDeclaration {
47
- /**
48
- * Why this model has no org column. Must be a real sentence, not a
49
- * placeholder: this text is the record of a security decision and is what a
50
- * future reader (or auditor) sees. Blank/whitespace is rejected.
51
- */
52
- readonly reason: string;
53
- /**
54
- * REQUIRED when the model is tenant-scoped in reality: the fence its queries
55
- * carry instead of the auto-scope, e.g.
56
- * `"orgRepository: { orgId } relation filter on every query"`.
57
- * Omit ONLY for genuinely global models.
58
- */
59
- readonly fencedBy?: string;
60
- }
61
-
62
- /** Declaration map: Prisma model name → justification. */
63
- export type UnscopedModelDeclarations = Readonly<
64
- Record<string, string | UnscopedModelDeclaration>
65
- >;
66
-
67
- /** Normalized form of one declaration. */
68
- export interface NormalizedUnscopedModelDeclaration {
69
- readonly model: string;
70
- readonly reason: string;
71
- readonly fencedBy?: string;
72
- }
73
-
74
- /**
75
- * Thrown when the set of models carrying no org column does not match what the
76
- * service declared. Fail-fast: in `Enforce` this aborts boot.
77
- */
78
- export class UndeclaredUnscopedModelsError extends Error {
79
- /** Models with no org column that were never declared. */
80
- readonly undeclared: readonly string[];
81
- /** Declared names that no longer match a model with no org column. */
82
- readonly staleDeclarations: readonly string[];
83
-
84
- constructor(
85
- undeclared: readonly string[],
86
- staleDeclarations: readonly string[],
87
- orgField: string,
88
- ) {
89
- super(
90
- formatUnscopedModelReport(undeclared, staleDeclarations, orgField, true),
91
- );
92
- this.name = 'UndeclaredUnscopedModelsError';
93
- this.undeclared = undeclared;
94
- this.staleDeclarations = staleDeclarations;
95
- }
96
- }
97
-
98
- /**
99
- * Every model in the client's runtime data model that does NOT declare
100
- * `orgField` — the set the isolation extension can never auto-scope.
101
- */
102
- export function collectUnscopableModels(
103
- models: Readonly<
104
- Record<string, { readonly fields: readonly { readonly name: string }[] }>
105
- >,
106
- orgField: string,
107
- ): readonly string[] {
108
- return Object.entries(models)
109
- .filter(([, model]) => !model.fields.some((f) => f.name === orgField))
110
- .map(([name]) => name)
111
- .sort();
112
- }
113
-
114
- /**
115
- * Normalize + validate the declaration map on its own terms (before it is
116
- * compared against the schema). Fail-fast on a blank reason: an empty
117
- * justification is a rubber stamp, and a rubber stamp is exactly the silent
118
- * pass-through this mechanism exists to remove.
119
- */
120
- export function normalizeUnscopedModelDeclarations(
121
- declarations: UnscopedModelDeclarations,
122
- ): readonly NormalizedUnscopedModelDeclaration[] {
123
- return Object.entries(declarations).map(([model, value]) => {
124
- const declaration: UnscopedModelDeclaration =
125
- typeof value === 'string' ? { reason: value } : value;
126
- if (declaration.reason.trim().length === 0) {
127
- throw new Error(
128
- `[tenant-isolation] unscopedModels["${model}"] has a blank reason. ` +
129
- 'Every model with no org column must carry a written justification — ' +
130
- 'say whether it is genuinely global, or tenant-scoped-in-reality and ' +
131
- 'fenced by hand (then also set `fencedBy`).',
132
- );
133
- }
134
- if (
135
- declaration.fencedBy !== undefined &&
136
- declaration.fencedBy.trim().length === 0
137
- ) {
138
- throw new Error(
139
- `[tenant-isolation] unscopedModels["${model}"].fencedBy is blank. ` +
140
- 'Either describe the manual fence or omit the field entirely.',
141
- );
142
- }
143
- return {
144
- model,
145
- reason: declaration.reason,
146
- ...(declaration.fencedBy !== undefined
147
- ? { fencedBy: declaration.fencedBy }
148
- : {}),
149
- };
150
- });
151
- }
152
-
153
- /** Render the actionable boot message for a declaration mismatch. */
154
- export function formatUnscopedModelReport(
155
- undeclared: readonly string[],
156
- staleDeclarations: readonly string[],
157
- orgField: string,
158
- fatal: boolean,
159
- ): string {
160
- const lines: string[] = [
161
- `[tenant-isolation] ${fatal ? 'REFUSING TO START' : 'UNDECLARED MODELS'} — ` +
162
- `tenant isolation cannot scope every model in this schema.`,
163
- ];
164
- if (undeclared.length > 0) {
165
- lines.push(
166
- ` ${undeclared.length} model(s) declare no "${orgField}" column and were ` +
167
- `not declared in tenantIsolation.unscopedModels, so every query on them ` +
168
- `runs UNSCOPED and enforcement silently does not apply:`,
169
- ` ${undeclared.join(', ')}`,
170
- ' For EACH one, decide which it is and record the decision:',
171
- ' - GENUINELY GLOBAL (platform catalog, kernel registry, bookkeeping) →',
172
- ' unscopedModels: { Foo: "global catalog; rows belong to no tenant" }',
173
- ' - TENANT-SCOPED via a foreign key (the dangerous case) → fence EVERY',
174
- ' query by hand through the owning relation, then record how:',
175
- ' unscopedModels: { Foo: { reason: "owned via barId -> Bar.orgId",',
176
- ' fencedBy: "bar: { orgId } on every query" } }',
177
- ' A model that carries the org column but must NOT be scoped belongs in',
178
- ' `exemptModels` instead — that is a different decision and stays separate.',
179
- );
180
- }
181
- if (staleDeclarations.length > 0) {
182
- lines.push(
183
- ` ${staleDeclarations.length} declaration(s) no longer match a model ` +
184
- `without "${orgField}" — the schema moved on and the justification is ` +
185
- 'now misleading. Remove them (or move them to `exemptModels` if the ' +
186
- 'model gained an org column but must stay unscoped):',
187
- ` ${staleDeclarations.join(', ')}`,
188
- );
189
- }
190
- return lines.join('\n');
191
- }
192
-
193
- export interface AssertUnscopedModelsOptions {
194
- /** Runtime data model of the generated client. */
195
- readonly models: Readonly<
196
- Record<string, { readonly fields: readonly { readonly name: string }[] }>
197
- >;
198
- readonly orgField: string;
199
- readonly mode: TenantIsolationMode;
200
- /** Declarations for models with NO org column. */
201
- readonly unscopedModels: UnscopedModelDeclarations;
202
- /** Models that DO carry the org column but are deliberately not scoped. */
203
- readonly exemptModels: readonly string[];
204
- readonly logger: TenantIsolationLogger;
205
- }
206
-
207
- /**
208
- * Compare the schema against the declarations and act per mode.
209
- *
210
- * - `Enforce` → THROW {@link UndeclaredUnscopedModelsError} (boot aborts).
211
- * - `Observe` → one loud `warn` carrying the same report (rollout mode keeps
212
- * its promise of zero behaviour change).
213
- * - `Off` → nothing; the kill switch must never be able to fail a boot.
214
- *
215
- * This is a STARTUP check rather than a per-query one on purpose. Startup is
216
- * strictly stronger here: it sees 100% of the schema deterministically, while a
217
- * query-time check only fires on code paths that actually execute (and would
218
- * require routing every model delegate through the extension, which changes the
219
- * proxy surface and adds cost on models the extension has nothing to do). It
220
- * also fails at the earliest possible moment — a deploy, not a request.
221
- */
222
- export function assertUnscopedModelsDeclared(
223
- options: AssertUnscopedModelsOptions,
224
- ): void {
225
- const { models, orgField, mode, unscopedModels, exemptModels, logger } =
226
- options;
227
- if (mode === TenantIsolationMode.Off) {
228
- return;
229
- }
230
-
231
- const normalized = normalizeUnscopedModelDeclarations(unscopedModels);
232
- const unscopable = new Set(collectUnscopableModels(models, orgField));
233
-
234
- for (const { model } of normalized) {
235
- if (!(model in models)) {
236
- throw new Error(
237
- `[tenant-isolation] unscopedModels names unknown model "${model}" — ` +
238
- `known models: ${Object.keys(models).join(', ')}.`,
239
- );
240
- }
241
- }
242
-
243
- // A model listed in BOTH lists is a contradiction: `exemptModels` means "has
244
- // the org column, deliberately not scoped"; `unscopedModels` means "has no
245
- // org column at all". Never let one silently shadow the other.
246
- const declaredUnscoped = new Set(normalized.map((d) => d.model));
247
- const inBoth = exemptModels.filter((m) => declaredUnscoped.has(m));
248
- if (inBoth.length > 0) {
249
- throw new Error(
250
- `[tenant-isolation] ${inBoth.join(', ')} appear in BOTH exemptModels and ` +
251
- 'unscopedModels. They answer different questions — exemptModels means ' +
252
- `"declares ${orgField} but must not be auto-scoped"; unscopedModels ` +
253
- `means "declares no ${orgField} at all". Pick the one that is true.`,
254
- );
255
- }
256
-
257
- const undeclared = [...unscopable].filter((m) => !declaredUnscoped.has(m));
258
- const stale = [...declaredUnscoped].filter((m) => !unscopable.has(m)).sort();
259
-
260
- if (undeclared.length === 0 && stale.length === 0) {
261
- logger.debug(
262
- `[tenant-isolation] ${unscopable.size} model(s) carry no "${orgField}" ` +
263
- 'column; all are declared in unscopedModels.',
264
- );
265
- return;
266
- }
267
-
268
- if (mode === TenantIsolationMode.Enforce) {
269
- throw new UndeclaredUnscopedModelsError(undeclared, stale, orgField);
270
- }
271
- logger.warn(formatUnscopedModelReport(undeclared, stale, orgField, false));
272
- }