@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.
- package/dist/lib/bootstrap.d.ts +1 -1
- package/dist/lib/bootstrap.d.ts.map +1 -1
- package/dist/lib/bootstrap.js +11 -15
- package/dist/lib/bootstrap.js.map +1 -1
- package/dist/lib/control-plane-client.js +1 -1
- package/dist/lib/control-plane-client.js.map +1 -1
- package/dist/lib/nest/biome-database.module.d.ts.map +1 -1
- package/dist/lib/nest/biome-database.module.js +24 -28
- package/dist/lib/nest/biome-database.module.js.map +1 -1
- package/package.json +7 -8
- package/src/index.ts +0 -115
- package/src/lib/adapter.ts +0 -117
- package/src/lib/bootstrap.ts +0 -433
- package/src/lib/config.ts +0 -183
- package/src/lib/control-plane-client.ts +0 -121
- package/src/lib/control-plane-discovery.ts +0 -42
- package/src/lib/identifier.ts +0 -177
- package/src/lib/migration-mode.ts +0 -25
- package/src/lib/nest/biome-database.module.ts +0 -175
- package/src/lib/nest/org-erasure.module.ts +0 -158
- package/src/lib/nest/prisma-service-factory.ts +0 -377
- package/src/lib/role-names.ts +0 -77
- package/src/lib/schema-name.ts +0 -62
- package/src/lib/tenant-isolation/org-erasure.ts +0 -235
- package/src/lib/tenant-isolation/org-scope.ts +0 -305
- package/src/lib/tenant-isolation/tenant-isolation-error.ts +0 -79
- package/src/lib/tenant-isolation/tenant-isolation-mode.ts +0 -55
- package/src/lib/tenant-isolation/tenant-isolation.extension.ts +0 -203
- package/src/lib/tenant-isolation/unscoped-models.ts +0 -272
|
@@ -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
|
-
}
|