@xemahq/biome-database-nest 0.9.0 → 0.10.0

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.
Files changed (56) hide show
  1. package/dist/index.d.ts +3 -5
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +1 -15
  4. package/dist/index.js.map +1 -1
  5. package/dist/lib/bootstrap.d.ts +3 -16
  6. package/dist/lib/bootstrap.d.ts.map +1 -1
  7. package/dist/lib/bootstrap.js +12 -71
  8. package/dist/lib/bootstrap.js.map +1 -1
  9. package/dist/lib/config.d.ts +0 -1
  10. package/dist/lib/config.d.ts.map +1 -1
  11. package/dist/lib/config.js +7 -10
  12. package/dist/lib/config.js.map +1 -1
  13. package/dist/lib/control-plane-client.d.ts +0 -1
  14. package/dist/lib/control-plane-client.d.ts.map +1 -1
  15. package/dist/lib/control-plane-client.js +3 -13
  16. package/dist/lib/control-plane-client.js.map +1 -1
  17. package/dist/lib/migration-mode.d.ts +1 -117
  18. package/dist/lib/migration-mode.d.ts.map +1 -1
  19. package/dist/lib/migration-mode.js +1 -48
  20. package/dist/lib/migration-mode.js.map +1 -1
  21. package/dist/lib/nest/biome-database.module.d.ts +3 -8
  22. package/dist/lib/nest/biome-database.module.d.ts.map +1 -1
  23. package/dist/lib/nest/biome-database.module.js +1 -2
  24. package/dist/lib/nest/biome-database.module.js.map +1 -1
  25. package/package.json +4 -3
  26. package/src/index.ts +112 -0
  27. package/src/lib/adapter.ts +114 -0
  28. package/src/lib/bootstrap.ts +433 -0
  29. package/src/lib/config.ts +183 -0
  30. package/src/lib/control-plane-client.ts +121 -0
  31. package/src/lib/control-plane-discovery.ts +34 -0
  32. package/src/lib/identifier.ts +177 -0
  33. package/src/lib/migration-mode.ts +25 -0
  34. package/src/lib/nest/biome-database.module.ts +175 -0
  35. package/src/lib/nest/org-erasure.module.ts +124 -0
  36. package/src/lib/nest/prisma-service-factory.ts +376 -0
  37. package/src/lib/role-names.ts +77 -0
  38. package/src/lib/schema-name.ts +62 -0
  39. package/src/lib/tenant-isolation/org-erasure.ts +158 -0
  40. package/src/lib/tenant-isolation/org-scope.ts +305 -0
  41. package/src/lib/tenant-isolation/tenant-isolation-error.ts +79 -0
  42. package/src/lib/tenant-isolation/tenant-isolation-mode.ts +55 -0
  43. package/src/lib/tenant-isolation/tenant-isolation.extension.ts +203 -0
  44. package/src/lib/tenant-isolation/unscoped-models.ts +269 -0
  45. package/dist/lib/credential-generation.d.ts +0 -4
  46. package/dist/lib/credential-generation.d.ts.map +0 -1
  47. package/dist/lib/credential-generation.js +0 -24
  48. package/dist/lib/credential-generation.js.map +0 -1
  49. package/dist/lib/external-runtime-verifier.d.ts +0 -3
  50. package/dist/lib/external-runtime-verifier.d.ts.map +0 -1
  51. package/dist/lib/external-runtime-verifier.js +0 -860
  52. package/dist/lib/external-runtime-verifier.js.map +0 -1
  53. package/dist/lib/response-envelope.d.ts +0 -2
  54. package/dist/lib/response-envelope.d.ts.map +0 -1
  55. package/dist/lib/response-envelope.js +0 -6
  56. package/dist/lib/response-envelope.js.map +0 -1
@@ -0,0 +1,269 @@
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
+ import type { TenantIsolationLogger } from './tenant-isolation.extension';
38
+
39
+ /**
40
+ * Why a model carries no org column, and — when it is tenant-scoped in reality
41
+ * — how its queries are fenced instead.
42
+ *
43
+ * A bare string is accepted as shorthand for `{ reason }`.
44
+ */
45
+ export interface UnscopedModelDeclaration {
46
+ /**
47
+ * Why this model has no org column. Must be a real sentence, not a
48
+ * placeholder: this text is the record of a security decision and is what a
49
+ * future reader (or auditor) sees. Blank/whitespace is rejected.
50
+ */
51
+ readonly reason: string;
52
+ /**
53
+ * REQUIRED when the model is tenant-scoped in reality: the fence its queries
54
+ * carry instead of the auto-scope, e.g.
55
+ * `"orgRepository: { orgId } relation filter on every query"`.
56
+ * Omit ONLY for genuinely global models.
57
+ */
58
+ readonly fencedBy?: string;
59
+ }
60
+
61
+ /** Declaration map: Prisma model name → justification. */
62
+ export type UnscopedModelDeclarations = Readonly<
63
+ Record<string, string | UnscopedModelDeclaration>
64
+ >;
65
+
66
+ /** Normalized form of one declaration. */
67
+ export interface NormalizedUnscopedModelDeclaration {
68
+ readonly model: string;
69
+ readonly reason: string;
70
+ readonly fencedBy?: string;
71
+ }
72
+
73
+ /**
74
+ * Thrown when the set of models carrying no org column does not match what the
75
+ * service declared. Fail-fast: in `Enforce` this aborts boot.
76
+ */
77
+ export class UndeclaredUnscopedModelsError extends Error {
78
+ /** Models with no org column that were never declared. */
79
+ readonly undeclared: readonly string[];
80
+ /** Declared names that no longer match a model with no org column. */
81
+ readonly staleDeclarations: readonly string[];
82
+
83
+ constructor(
84
+ undeclared: readonly string[],
85
+ staleDeclarations: readonly string[],
86
+ orgField: string,
87
+ ) {
88
+ super(
89
+ formatUnscopedModelReport(undeclared, staleDeclarations, orgField, true),
90
+ );
91
+ this.name = 'UndeclaredUnscopedModelsError';
92
+ this.undeclared = undeclared;
93
+ this.staleDeclarations = staleDeclarations;
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Every model in the client's runtime data model that does NOT declare
99
+ * `orgField` — the set the isolation extension can never auto-scope.
100
+ */
101
+ export function collectUnscopableModels(
102
+ models: Readonly<Record<string, { readonly fields: readonly { readonly name: string }[] }>>,
103
+ orgField: string,
104
+ ): readonly string[] {
105
+ return Object.entries(models)
106
+ .filter(([, model]) => !model.fields.some((f) => f.name === orgField))
107
+ .map(([name]) => name)
108
+ .sort();
109
+ }
110
+
111
+ /**
112
+ * Normalize + validate the declaration map on its own terms (before it is
113
+ * compared against the schema). Fail-fast on a blank reason: an empty
114
+ * justification is a rubber stamp, and a rubber stamp is exactly the silent
115
+ * pass-through this mechanism exists to remove.
116
+ */
117
+ export function normalizeUnscopedModelDeclarations(
118
+ declarations: UnscopedModelDeclarations,
119
+ ): readonly NormalizedUnscopedModelDeclaration[] {
120
+ return Object.entries(declarations).map(([model, value]) => {
121
+ const declaration: UnscopedModelDeclaration =
122
+ typeof value === 'string' ? { reason: value } : value;
123
+ if (declaration.reason.trim().length === 0) {
124
+ throw new Error(
125
+ `[tenant-isolation] unscopedModels["${model}"] has a blank reason. ` +
126
+ 'Every model with no org column must carry a written justification — ' +
127
+ 'say whether it is genuinely global, or tenant-scoped-in-reality and ' +
128
+ 'fenced by hand (then also set `fencedBy`).',
129
+ );
130
+ }
131
+ if (
132
+ declaration.fencedBy !== undefined &&
133
+ declaration.fencedBy.trim().length === 0
134
+ ) {
135
+ throw new Error(
136
+ `[tenant-isolation] unscopedModels["${model}"].fencedBy is blank. ` +
137
+ 'Either describe the manual fence or omit the field entirely.',
138
+ );
139
+ }
140
+ return {
141
+ model,
142
+ reason: declaration.reason,
143
+ ...(declaration.fencedBy !== undefined
144
+ ? { fencedBy: declaration.fencedBy }
145
+ : {}),
146
+ };
147
+ });
148
+ }
149
+
150
+ /** Render the actionable boot message for a declaration mismatch. */
151
+ export function formatUnscopedModelReport(
152
+ undeclared: readonly string[],
153
+ staleDeclarations: readonly string[],
154
+ orgField: string,
155
+ fatal: boolean,
156
+ ): string {
157
+ const lines: string[] = [
158
+ `[tenant-isolation] ${fatal ? 'REFUSING TO START' : 'UNDECLARED MODELS'} — ` +
159
+ `tenant isolation cannot scope every model in this schema.`,
160
+ ];
161
+ if (undeclared.length > 0) {
162
+ lines.push(
163
+ ` ${undeclared.length} model(s) declare no "${orgField}" column and were ` +
164
+ `not declared in tenantIsolation.unscopedModels, so every query on them ` +
165
+ `runs UNSCOPED and enforcement silently does not apply:`,
166
+ ` ${undeclared.join(', ')}`,
167
+ ' For EACH one, decide which it is and record the decision:',
168
+ ' - GENUINELY GLOBAL (platform catalog, kernel registry, bookkeeping) →',
169
+ ' unscopedModels: { Foo: "global catalog; rows belong to no tenant" }',
170
+ ' - TENANT-SCOPED via a foreign key (the dangerous case) → fence EVERY',
171
+ ' query by hand through the owning relation, then record how:',
172
+ ' unscopedModels: { Foo: { reason: "owned via barId -> Bar.orgId",',
173
+ ' fencedBy: "bar: { orgId } on every query" } }',
174
+ ' A model that carries the org column but must NOT be scoped belongs in',
175
+ ' `exemptModels` instead — that is a different decision and stays separate.',
176
+ );
177
+ }
178
+ if (staleDeclarations.length > 0) {
179
+ lines.push(
180
+ ` ${staleDeclarations.length} declaration(s) no longer match a model ` +
181
+ `without "${orgField}" — the schema moved on and the justification is ` +
182
+ 'now misleading. Remove them (or move them to `exemptModels` if the ' +
183
+ 'model gained an org column but must stay unscoped):',
184
+ ` ${staleDeclarations.join(', ')}`,
185
+ );
186
+ }
187
+ return lines.join('\n');
188
+ }
189
+
190
+ export interface AssertUnscopedModelsOptions {
191
+ /** Runtime data model of the generated client. */
192
+ readonly models: Readonly<
193
+ Record<string, { readonly fields: readonly { readonly name: string }[] }>
194
+ >;
195
+ readonly orgField: string;
196
+ readonly mode: TenantIsolationMode;
197
+ /** Declarations for models with NO org column. */
198
+ readonly unscopedModels: UnscopedModelDeclarations;
199
+ /** Models that DO carry the org column but are deliberately not scoped. */
200
+ readonly exemptModels: readonly string[];
201
+ readonly logger: TenantIsolationLogger;
202
+ }
203
+
204
+ /**
205
+ * Compare the schema against the declarations and act per mode.
206
+ *
207
+ * - `Enforce` → THROW {@link UndeclaredUnscopedModelsError} (boot aborts).
208
+ * - `Observe` → one loud `warn` carrying the same report (rollout mode keeps
209
+ * its promise of zero behaviour change).
210
+ * - `Off` → nothing; the kill switch must never be able to fail a boot.
211
+ *
212
+ * This is a STARTUP check rather than a per-query one on purpose. Startup is
213
+ * strictly stronger here: it sees 100% of the schema deterministically, while a
214
+ * query-time check only fires on code paths that actually execute (and would
215
+ * require routing every model delegate through the extension, which changes the
216
+ * proxy surface and adds cost on models the extension has nothing to do). It
217
+ * also fails at the earliest possible moment — a deploy, not a request.
218
+ */
219
+ export function assertUnscopedModelsDeclared(
220
+ options: AssertUnscopedModelsOptions,
221
+ ): void {
222
+ const { models, orgField, mode, unscopedModels, exemptModels, logger } =
223
+ options;
224
+ if (mode === TenantIsolationMode.Off) {
225
+ return;
226
+ }
227
+
228
+ const normalized = normalizeUnscopedModelDeclarations(unscopedModels);
229
+ const unscopable = new Set(collectUnscopableModels(models, orgField));
230
+
231
+ for (const { model } of normalized) {
232
+ if (!(model in models)) {
233
+ throw new Error(
234
+ `[tenant-isolation] unscopedModels names unknown model "${model}" — ` +
235
+ `known models: ${Object.keys(models).join(', ')}.`,
236
+ );
237
+ }
238
+ }
239
+
240
+ // A model listed in BOTH lists is a contradiction: `exemptModels` means "has
241
+ // the org column, deliberately not scoped"; `unscopedModels` means "has no
242
+ // org column at all". Never let one silently shadow the other.
243
+ const declaredUnscoped = new Set(normalized.map((d) => d.model));
244
+ const inBoth = exemptModels.filter((m) => declaredUnscoped.has(m));
245
+ if (inBoth.length > 0) {
246
+ throw new Error(
247
+ `[tenant-isolation] ${inBoth.join(', ')} appear in BOTH exemptModels and ` +
248
+ 'unscopedModels. They answer different questions — exemptModels means ' +
249
+ `"declares ${orgField} but must not be auto-scoped"; unscopedModels ` +
250
+ `means "declares no ${orgField} at all". Pick the one that is true.`,
251
+ );
252
+ }
253
+
254
+ const undeclared = [...unscopable].filter((m) => !declaredUnscoped.has(m));
255
+ const stale = [...declaredUnscoped].filter((m) => !unscopable.has(m)).sort();
256
+
257
+ if (undeclared.length === 0 && stale.length === 0) {
258
+ logger.debug(
259
+ `[tenant-isolation] ${unscopable.size} model(s) carry no "${orgField}" ` +
260
+ 'column; all are declared in unscopedModels.',
261
+ );
262
+ return;
263
+ }
264
+
265
+ if (mode === TenantIsolationMode.Enforce) {
266
+ throw new UndeclaredUnscopedModelsError(undeclared, stale, orgField);
267
+ }
268
+ logger.warn(formatUnscopedModelReport(undeclared, stale, orgField, false));
269
+ }
@@ -1,4 +0,0 @@
1
- export declare const MAX_DATABASE_CREDENTIAL_GENERATION = 2147483647;
2
- export declare function assertDatabaseCredentialGeneration(value: number, field: string): void;
3
- export declare function parseDatabaseCredentialGeneration(value: string, field: string): number;
4
- //# sourceMappingURL=credential-generation.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"credential-generation.d.ts","sourceRoot":"","sources":["../../src/lib/credential-generation.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,kCAAkC,aAAgB,CAAC;AAIhE,wBAAgB,kCAAkC,CAChD,KAAK,EAAE,MAAM,EACb,KAAK,EAAE,MAAM,GACZ,IAAI,CAWN;AAED,wBAAgB,iCAAiC,CAC/C,KAAK,EAAE,MAAM,EACb,KAAK,EAAE,MAAM,GACZ,MAAM,CASR"}
@@ -1,24 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.MAX_DATABASE_CREDENTIAL_GENERATION = void 0;
4
- exports.assertDatabaseCredentialGeneration = assertDatabaseCredentialGeneration;
5
- exports.parseDatabaseCredentialGeneration = parseDatabaseCredentialGeneration;
6
- exports.MAX_DATABASE_CREDENTIAL_GENERATION = 2_147_483_647;
7
- const CANONICAL_POSITIVE_DECIMAL = /^[1-9][0-9]*$/;
8
- function assertDatabaseCredentialGeneration(value, field) {
9
- if (!Number.isSafeInteger(value) ||
10
- value <= 0 ||
11
- value > exports.MAX_DATABASE_CREDENTIAL_GENERATION) {
12
- throw new Error(`${field} must be a safe positive integer no greater than ` +
13
- `${exports.MAX_DATABASE_CREDENTIAL_GENERATION}.`);
14
- }
15
- }
16
- function parseDatabaseCredentialGeneration(value, field) {
17
- if (!CANONICAL_POSITIVE_DECIMAL.test(value)) {
18
- throw new Error(`${field} value "${value}" must be a canonical positive decimal integer.`);
19
- }
20
- const generation = Number(value);
21
- assertDatabaseCredentialGeneration(generation, field);
22
- return generation;
23
- }
24
- //# sourceMappingURL=credential-generation.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"credential-generation.js","sourceRoot":"","sources":["../../src/lib/credential-generation.ts"],"names":[],"mappings":";;;AAIA,gFAcC;AAED,8EAYC;AAhCY,QAAA,kCAAkC,GAAG,aAAa,CAAC;AAEhE,MAAM,0BAA0B,GAAG,eAAe,CAAC;AAEnD,SAAgB,kCAAkC,CAChD,KAAa,EACb,KAAa;IAEb,IACE,CAAC,MAAM,CAAC,aAAa,CAAC,KAAK,CAAC;QAC5B,KAAK,IAAI,CAAC;QACV,KAAK,GAAG,0CAAkC,EAC1C,CAAC;QACD,MAAM,IAAI,KAAK,CACb,GAAG,KAAK,mDAAmD;YACzD,GAAG,0CAAkC,GAAG,CAC3C,CAAC;IACJ,CAAC;AACH,CAAC;AAED,SAAgB,iCAAiC,CAC/C,KAAa,EACb,KAAa;IAEb,IAAI,CAAC,0BAA0B,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC;QAC5C,MAAM,IAAI,KAAK,CACb,GAAG,KAAK,WAAW,KAAK,iDAAiD,CAC1E,CAAC;IACJ,CAAC;IACD,MAAM,UAAU,GAAG,MAAM,CAAC,KAAK,CAAC,CAAC;IACjC,kCAAkC,CAAC,UAAU,EAAE,KAAK,CAAC,CAAC;IACtD,OAAO,UAAU,CAAC;AACpB,CAAC"}
@@ -1,3 +0,0 @@
1
- import type { MigrationCompatibilityContext, MigrationCompatibilityExpectation, RuntimeDatabaseAccessExpectation, RuntimeDatabaseTarget } from './migration-mode';
2
- export declare function verifyExternallyManagedRuntime(runtimeUrl: string, target: RuntimeDatabaseTarget, access: RuntimeDatabaseAccessExpectation, compatibility: MigrationCompatibilityExpectation): Promise<MigrationCompatibilityContext>;
3
- //# sourceMappingURL=external-runtime-verifier.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"external-runtime-verifier.d.ts","sourceRoot":"","sources":["../../src/lib/external-runtime-verifier.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAEV,6BAA6B,EAC7B,iCAAiC,EACjC,gCAAgC,EAEhC,qBAAqB,EAEtB,MAAM,kBAAkB,CAAC;AAugC1B,wBAAsB,8BAA8B,CAClD,UAAU,EAAE,MAAM,EAClB,MAAM,EAAE,qBAAqB,EAC7B,MAAM,EAAE,gCAAgC,EACxC,aAAa,EAAE,iCAAiC,GAC/C,OAAO,CAAC,6BAA6B,CAAC,CAwfxC"}