@xemahq/biome-database-nest 0.12.1 → 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/package.json +4 -5
- package/src/index.ts +0 -115
- package/src/lib/adapter.ts +0 -117
- package/src/lib/bootstrap.ts +0 -428
- package/src/lib/config.ts +0 -183
- package/src/lib/control-plane-client.ts +0 -124
- 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 -174
- 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,377 +0,0 @@
|
|
|
1
|
-
import { Injectable, Logger } from '@nestjs/common';
|
|
2
|
-
import { getAmbientOrgId } from '@xemahq/platform-common';
|
|
3
|
-
|
|
4
|
-
import { createBiomePrismaAdapterFromEnv } from '../adapter';
|
|
5
|
-
import {
|
|
6
|
-
TenantIsolationMode,
|
|
7
|
-
resolveTenantIsolationMode,
|
|
8
|
-
} from '../tenant-isolation/tenant-isolation-mode';
|
|
9
|
-
import {
|
|
10
|
-
DEFAULT_ORG_FIELD,
|
|
11
|
-
collectOrgScopedModels,
|
|
12
|
-
createTenantIsolationExtension,
|
|
13
|
-
readRuntimeModels,
|
|
14
|
-
type TenantIsolationLogger,
|
|
15
|
-
} from '../tenant-isolation/tenant-isolation.extension';
|
|
16
|
-
import {
|
|
17
|
-
assertUnscopedModelsDeclared,
|
|
18
|
-
type UnscopedModelDeclarations,
|
|
19
|
-
} from '../tenant-isolation/unscoped-models';
|
|
20
|
-
|
|
21
|
-
import type { OnModuleDestroy, OnModuleInit, Type } from '@nestjs/common';
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Minimal structural shape of a generated Prisma `PrismaClient` as far as the
|
|
25
|
-
* lifecycle wrapper cares: it is a constructor whose single (optional) argument
|
|
26
|
-
* is the Prisma client options bag (we inject `{ adapter }`), and instances
|
|
27
|
-
* expose the `$connect`/`$disconnect` lifecycle methods.
|
|
28
|
-
*
|
|
29
|
-
* It is deliberately structural and `any`-arg so that EVERY service's own
|
|
30
|
-
* generated `PrismaClient` — each with a different model surface — satisfies it
|
|
31
|
-
* without the SDK having to know any concrete schema.
|
|
32
|
-
*/
|
|
33
|
-
export type BiomePrismaClientCtor<TClient extends BiomePrismaClient> = new (
|
|
34
|
-
// The generated PrismaClient constructor's options arg is schema-specific;
|
|
35
|
-
// we only ever pass `{ adapter }`, which every generated client accepts.
|
|
36
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
37
|
-
...args: any[]
|
|
38
|
-
) => TClient;
|
|
39
|
-
|
|
40
|
-
/** The lifecycle surface every generated `PrismaClient` exposes. */
|
|
41
|
-
export interface BiomePrismaClient {
|
|
42
|
-
$connect(): Promise<void>;
|
|
43
|
-
$disconnect(): Promise<void>;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/** Tenant-isolation knobs a service may set on its Prisma service. */
|
|
47
|
-
export interface TenantIsolationServiceOptions {
|
|
48
|
-
/**
|
|
49
|
-
* Mode for the DEFAULT (ambient-scoped) client. Resolution: this option >
|
|
50
|
-
* `XEMA_TENANT_ISOLATION_MODE` env var > `Observe`.
|
|
51
|
-
*/
|
|
52
|
-
readonly mode?: TenantIsolationMode;
|
|
53
|
-
/** Org column detected on models (default `orgId`). */
|
|
54
|
-
readonly orgField?: string;
|
|
55
|
-
/**
|
|
56
|
-
* Models that carry the org column but are deliberately NOT tenant-scoped.
|
|
57
|
-
* Names must match the Prisma schema model names; unknown names throw.
|
|
58
|
-
*/
|
|
59
|
-
readonly exemptModels?: readonly string[];
|
|
60
|
-
/**
|
|
61
|
-
* Models that declare NO org column at all — the ones the isolation extension
|
|
62
|
-
* is structurally unable to auto-scope — each with a written justification.
|
|
63
|
-
*
|
|
64
|
-
* This is REQUIRED to be exhaustive: in `Enforce` mode a model with no org
|
|
65
|
-
* column and no declaration ABORTS BOOT
|
|
66
|
-
* ({@link UndeclaredUnscopedModelsError}); in `Observe` it is a loud warning.
|
|
67
|
-
* Previously such a model was simply invisible to enforcement, so `Enforce`
|
|
68
|
-
* gave false assurance — see `../tenant-isolation/unscoped-models.ts` for the
|
|
69
|
-
* incident that motivated this.
|
|
70
|
-
*
|
|
71
|
-
* A model that is tenant-scoped in reality (owned through a foreign key) MUST
|
|
72
|
-
* also state `fencedBy` — the manual predicate its queries carry.
|
|
73
|
-
*
|
|
74
|
-
* @example
|
|
75
|
-
* ```ts
|
|
76
|
-
* unscopedModels: {
|
|
77
|
-
* MigrationBookkeeping: 'internal bookkeeping; rows belong to no tenant',
|
|
78
|
-
* ChangeRequest: {
|
|
79
|
-
* reason: 'owned via projectRepositoryBindingId -> … -> OrgRepository.orgId',
|
|
80
|
-
* fencedBy: 'projectRepositoryBinding: { orgRepository: { orgId } }',
|
|
81
|
-
* },
|
|
82
|
-
* }
|
|
83
|
-
* ```
|
|
84
|
-
*/
|
|
85
|
-
readonly unscopedModels?: UnscopedModelDeclarations;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/** Options bag for {@link createBiomePrismaService}. */
|
|
89
|
-
export interface CreateBiomePrismaServiceOptions {
|
|
90
|
-
/** Logger name (defaults to `'PrismaService'`). */
|
|
91
|
-
readonly loggerContext?: string;
|
|
92
|
-
readonly tenantIsolation?: TenantIsolationServiceOptions;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* The explicit-scoping surface every biome Prisma service now carries in
|
|
97
|
-
* addition to its full generated-client surface.
|
|
98
|
-
*/
|
|
99
|
-
export interface TenantScopedApi<TClient> {
|
|
100
|
-
/**
|
|
101
|
-
* A client hard-bound to `orgId` — for jobs, workers, Temporal activities,
|
|
102
|
-
* contribution sync, and any other path with no ambient request context.
|
|
103
|
-
* ALWAYS enforces (regardless of the global mode): explicit scoping is a
|
|
104
|
-
* correctness request and is never silently degraded.
|
|
105
|
-
*/
|
|
106
|
-
forOrg(orgId: string): TClient;
|
|
107
|
-
/**
|
|
108
|
-
* The raw, UNSCOPED client for legitimate cross-org system paths (boot
|
|
109
|
-
* backfills, platform-level admin surfaces). Logged at debug level once per
|
|
110
|
-
* call site so system access stays auditable without log floods.
|
|
111
|
-
*/
|
|
112
|
-
asSystem(): TClient;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
/** Instance type produced by {@link createBiomePrismaService}. */
|
|
116
|
-
export type BiomePrismaServiceInstance<TClient> = TClient &
|
|
117
|
-
TenantScopedApi<TClient> &
|
|
118
|
-
OnModuleInit &
|
|
119
|
-
OnModuleDestroy;
|
|
120
|
-
|
|
121
|
-
interface ExtendableClient {
|
|
122
|
-
$extends?: (extension: unknown) => unknown;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
function lowerFirst(name: string): string {
|
|
126
|
-
return name.length === 0
|
|
127
|
-
? name
|
|
128
|
-
: name.charAt(0).toLowerCase() + name.slice(1);
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
function normalizeOptions(
|
|
132
|
-
optionsOrLoggerContext: string | CreateBiomePrismaServiceOptions | undefined,
|
|
133
|
-
): CreateBiomePrismaServiceOptions {
|
|
134
|
-
if (optionsOrLoggerContext === undefined) {
|
|
135
|
-
return {};
|
|
136
|
-
}
|
|
137
|
-
if (typeof optionsOrLoggerContext === 'string') {
|
|
138
|
-
return { loggerContext: optionsOrLoggerContext };
|
|
139
|
-
}
|
|
140
|
-
return optionsOrLoggerContext;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/**
|
|
144
|
-
* Build the `@Injectable()` Prisma service class for a biome from ITS OWN
|
|
145
|
-
* generated `PrismaClient` constructor.
|
|
146
|
-
*
|
|
147
|
-
* Eliminates the byte-identical `prisma.service.ts` every biome service
|
|
148
|
-
* hand-writes: the ONLY per-service difference is which generated client is
|
|
149
|
-
* extended. A service supplies that client and gets the schema-qualified
|
|
150
|
-
* driver-adapter (via {@link createBiomePrismaAdapterFromEnv}, the package's
|
|
151
|
-
* single correct adapter construction — REUSED, not reimplemented) plus the
|
|
152
|
-
* Nest `OnModuleInit`/`OnModuleDestroy` `$connect`/`$disconnect` lifecycle for
|
|
153
|
-
* free.
|
|
154
|
-
*
|
|
155
|
-
* **Tenant isolation (C3).** Org-scoped models (those declaring the org
|
|
156
|
-
* column, default `orgId`) are detected from the client's runtime data model
|
|
157
|
-
* at construction time, and the DEFAULT injected client routes their delegates
|
|
158
|
-
* (plus `$transaction`) through a Prisma client extension that scopes every
|
|
159
|
-
* operation to the AMBIENT org (`AmbientOrgContextMiddleware` /
|
|
160
|
-
* `runWithAmbientOrg`) per {@link TenantIsolationMode}:
|
|
161
|
-
*
|
|
162
|
-
* - `Observe` (default): no query is rewritten; violations are warned.
|
|
163
|
-
* - `Enforce`: org filters are injected; violations throw
|
|
164
|
-
* `TenantIsolationError`. Outside any request context, org-model access
|
|
165
|
-
* throws — use `forOrg(orgId)` / `asSystem()` (see {@link TenantScopedApi}).
|
|
166
|
-
* - `Off`: passthrough kill switch (detection is deferred so `Off` can never
|
|
167
|
-
* fail a boot; `forOrg` still enforces on first use).
|
|
168
|
-
*
|
|
169
|
-
* Fail-fast: a failed `$connect()` at `onModuleInit` is logged and rethrown so
|
|
170
|
-
* Nest aborts startup. `$disconnect()` errors at shutdown are logged but not
|
|
171
|
-
* rethrown (the process is already going down; masking shutdown noise must not
|
|
172
|
-
* abort an otherwise-clean teardown).
|
|
173
|
-
*
|
|
174
|
-
* The returned class is a concrete subclass of the supplied client, so callers
|
|
175
|
-
* keep the FULL typed `PrismaClient` surface (every model delegate, every
|
|
176
|
-
* `$`-method) on the resulting service — plus `forOrg`/`asSystem`.
|
|
177
|
-
*
|
|
178
|
-
* @example Per-service usage — the entire `prisma.service.ts` becomes:
|
|
179
|
-
* ```ts
|
|
180
|
-
* import { createBiomePrismaService } from '@xemahq/biome-database-nest';
|
|
181
|
-
* import { PrismaClient } from '../../generated/prisma-client'; // its own
|
|
182
|
-
*
|
|
183
|
-
* export class PrismaService extends createBiomePrismaService(PrismaClient) {}
|
|
184
|
-
* ```
|
|
185
|
-
*
|
|
186
|
-
* The `provide: PrismaService` factory still gates on `BIOME_DATABASE_READY`
|
|
187
|
-
* exactly as before — this factory changes only how the class body is written.
|
|
188
|
-
*
|
|
189
|
-
* @param PrismaClientCtor the service's OWN generated `PrismaClient` constructor.
|
|
190
|
-
* @param optionsOrLoggerContext either the legacy logger-context string
|
|
191
|
-
* (defaults to `'PrismaService'`) or a {@link CreateBiomePrismaServiceOptions}
|
|
192
|
-
* bag carrying `loggerContext` + `tenantIsolation`.
|
|
193
|
-
*/
|
|
194
|
-
export function createBiomePrismaService<TClient extends BiomePrismaClient>(
|
|
195
|
-
PrismaClientCtor: BiomePrismaClientCtor<TClient>,
|
|
196
|
-
optionsOrLoggerContext?: string | CreateBiomePrismaServiceOptions,
|
|
197
|
-
): Type<BiomePrismaServiceInstance<TClient>> {
|
|
198
|
-
const options = normalizeOptions(optionsOrLoggerContext);
|
|
199
|
-
const loggerContext = options.loggerContext ?? 'PrismaService';
|
|
200
|
-
const tenantOptions = options.tenantIsolation ?? {};
|
|
201
|
-
const orgField = tenantOptions.orgField ?? DEFAULT_ORG_FIELD;
|
|
202
|
-
const exemptModels = tenantOptions.exemptModels ?? [];
|
|
203
|
-
const unscopedModels = tenantOptions.unscopedModels ?? {};
|
|
204
|
-
|
|
205
|
-
@Injectable()
|
|
206
|
-
class BiomePrismaService
|
|
207
|
-
extends (PrismaClientCtor as BiomePrismaClientCtor<BiomePrismaClient>)
|
|
208
|
-
implements OnModuleInit, OnModuleDestroy
|
|
209
|
-
{
|
|
210
|
-
private readonly biomePrismaLogger = new Logger(loggerContext);
|
|
211
|
-
|
|
212
|
-
constructor() {
|
|
213
|
-
super({ adapter: createBiomePrismaAdapterFromEnv() });
|
|
214
|
-
|
|
215
|
-
// Fail-fast at construction: an invalid XEMA_TENANT_ISOLATION_MODE (or
|
|
216
|
-
// option) must abort boot, never fall back to a weaker mode.
|
|
217
|
-
const mode = resolveTenantIsolationMode(tenantOptions.mode);
|
|
218
|
-
const raw = this;
|
|
219
|
-
const tenantLogger: TenantIsolationLogger = new Logger(
|
|
220
|
-
`${loggerContext}:tenant-isolation`,
|
|
221
|
-
);
|
|
222
|
-
|
|
223
|
-
const requireExtends = (): ((extension: unknown) => unknown) => {
|
|
224
|
-
const extendsFn = (raw as ExtendableClient).$extends;
|
|
225
|
-
if (typeof extendsFn !== 'function') {
|
|
226
|
-
throw new Error(
|
|
227
|
-
'[tenant-isolation] the Prisma client has no $extends — tenant ' +
|
|
228
|
-
'isolation requires a generated @prisma/client >= 5 (this SDK peers on ^7).',
|
|
229
|
-
);
|
|
230
|
-
}
|
|
231
|
-
return extendsFn.bind(raw);
|
|
232
|
-
};
|
|
233
|
-
|
|
234
|
-
// Org-scoped-model detection reads the client's runtime data model. In
|
|
235
|
-
// Off mode it is DEFERRED (the kill switch must never be able to fail a
|
|
236
|
-
// boot); forOrg() still resolves it lazily on first use.
|
|
237
|
-
let scopedModelsCache: ReadonlySet<string> | undefined;
|
|
238
|
-
const getScopedModels = (): ReadonlySet<string> => {
|
|
239
|
-
scopedModelsCache ??= collectOrgScopedModels(
|
|
240
|
-
raw,
|
|
241
|
-
orgField,
|
|
242
|
-
exemptModels,
|
|
243
|
-
);
|
|
244
|
-
return scopedModelsCache;
|
|
245
|
-
};
|
|
246
|
-
|
|
247
|
-
// Close the blind spot BEFORE any client is handed out: a model with no
|
|
248
|
-
// org column can never be auto-scoped, so enforcement silently does not
|
|
249
|
-
// apply to it. Every such model must be DECLARED with a justification.
|
|
250
|
-
// Enforce → throws here and Nest aborts startup; Observe → one loud warn;
|
|
251
|
-
// Off → skipped with the rest of the detection (kill switch).
|
|
252
|
-
if (mode !== TenantIsolationMode.Off) {
|
|
253
|
-
assertUnscopedModelsDeclared({
|
|
254
|
-
models: readRuntimeModels(raw),
|
|
255
|
-
orgField,
|
|
256
|
-
mode,
|
|
257
|
-
unscopedModels,
|
|
258
|
-
exemptModels,
|
|
259
|
-
logger: tenantLogger,
|
|
260
|
-
});
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
const forOrg = (orgId: string): TClient => {
|
|
264
|
-
if (typeof orgId !== 'string' || orgId === '') {
|
|
265
|
-
throw new Error(
|
|
266
|
-
'[tenant-isolation] forOrg requires a non-empty orgId. For a ' +
|
|
267
|
-
'legitimate cross-org system path use asSystem().',
|
|
268
|
-
);
|
|
269
|
-
}
|
|
270
|
-
// Explicit scoping ALWAYS enforces, regardless of the global mode.
|
|
271
|
-
const orgScopedModels = getScopedModels();
|
|
272
|
-
return requireExtends()(
|
|
273
|
-
createTenantIsolationExtension({
|
|
274
|
-
orgScopedModels,
|
|
275
|
-
orgField,
|
|
276
|
-
mode: TenantIsolationMode.Enforce,
|
|
277
|
-
orgIdSource: () => orgId,
|
|
278
|
-
logger: tenantLogger,
|
|
279
|
-
}),
|
|
280
|
-
) as TClient;
|
|
281
|
-
};
|
|
282
|
-
|
|
283
|
-
const seenSystemCallSites = new Set<string>();
|
|
284
|
-
const asSystem = (): TClient => {
|
|
285
|
-
// Debug-log each distinct call site ONCE (auditable, no log floods).
|
|
286
|
-
const callSite = new Error().stack?.split('\n')[2]?.trim();
|
|
287
|
-
if (callSite !== undefined && !seenSystemCallSites.has(callSite)) {
|
|
288
|
-
seenSystemCallSites.add(callSite);
|
|
289
|
-
tenantLogger.debug(
|
|
290
|
-
`[tenant-isolation] asSystem() unscoped access from ${callSite}`,
|
|
291
|
-
);
|
|
292
|
-
}
|
|
293
|
-
return raw as unknown as TClient;
|
|
294
|
-
};
|
|
295
|
-
|
|
296
|
-
if (mode === TenantIsolationMode.Off) {
|
|
297
|
-
// Kill switch: the default client is the raw client; only the
|
|
298
|
-
// explicit-scoping API is layered on.
|
|
299
|
-
return new Proxy(raw, {
|
|
300
|
-
get(target, prop, _receiver): unknown {
|
|
301
|
-
if (prop === 'forOrg') {
|
|
302
|
-
return forOrg;
|
|
303
|
-
}
|
|
304
|
-
if (prop === 'asSystem') {
|
|
305
|
-
return asSystem;
|
|
306
|
-
}
|
|
307
|
-
const value = Reflect.get(target, prop, target);
|
|
308
|
-
return typeof value === 'function' ? value.bind(target) : value;
|
|
309
|
-
},
|
|
310
|
-
});
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
const ambientClient = requireExtends()(
|
|
314
|
-
createTenantIsolationExtension({
|
|
315
|
-
orgScopedModels: getScopedModels(),
|
|
316
|
-
orgField,
|
|
317
|
-
mode,
|
|
318
|
-
orgIdSource: getAmbientOrgId,
|
|
319
|
-
logger: tenantLogger,
|
|
320
|
-
}),
|
|
321
|
-
) as Record<string, unknown>;
|
|
322
|
-
|
|
323
|
-
// Delegates of org-scoped models — and $transaction, so interactive
|
|
324
|
-
// transactions get extension-applied tx clients — route through the
|
|
325
|
-
// ambient-scoped extension; everything else hits the raw client.
|
|
326
|
-
const routedProps = new Set<string>(['$transaction']);
|
|
327
|
-
for (const modelName of getScopedModels()) {
|
|
328
|
-
routedProps.add(lowerFirst(modelName));
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
return new Proxy(raw, {
|
|
332
|
-
get(target, prop, _receiver): unknown {
|
|
333
|
-
if (prop === 'forOrg') {
|
|
334
|
-
return forOrg;
|
|
335
|
-
}
|
|
336
|
-
if (prop === 'asSystem') {
|
|
337
|
-
return asSystem;
|
|
338
|
-
}
|
|
339
|
-
if (typeof prop === 'string' && routedProps.has(prop)) {
|
|
340
|
-
const value = ambientClient[prop];
|
|
341
|
-
return typeof value === 'function'
|
|
342
|
-
? (value as (...args: unknown[]) => unknown).bind(ambientClient)
|
|
343
|
-
: value;
|
|
344
|
-
}
|
|
345
|
-
const value = Reflect.get(target, prop, target);
|
|
346
|
-
return typeof value === 'function' ? value.bind(target) : value;
|
|
347
|
-
},
|
|
348
|
-
});
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
async onModuleInit(): Promise<void> {
|
|
352
|
-
try {
|
|
353
|
-
await this.$connect();
|
|
354
|
-
this.biomePrismaLogger.log('Connected to database');
|
|
355
|
-
} catch (error) {
|
|
356
|
-
this.biomePrismaLogger.error('Failed to connect to database', error);
|
|
357
|
-
throw error;
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
async onModuleDestroy(): Promise<void> {
|
|
362
|
-
try {
|
|
363
|
-
await this.$disconnect();
|
|
364
|
-
this.biomePrismaLogger.log('Disconnected from database');
|
|
365
|
-
} catch (error) {
|
|
366
|
-
this.biomePrismaLogger.error(
|
|
367
|
-
'Error disconnecting from database',
|
|
368
|
-
error,
|
|
369
|
-
);
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
return BiomePrismaService as unknown as Type<
|
|
375
|
-
BiomePrismaServiceInstance<TClient>
|
|
376
|
-
>;
|
|
377
|
-
}
|
package/src/lib/role-names.ts
DELETED
|
@@ -1,77 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
fitIdentifier,
|
|
3
|
-
POSTGRES_MAX_IDENTIFIER_LENGTH,
|
|
4
|
-
sanitizeIdentifierChars,
|
|
5
|
-
} from './identifier';
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Per-schema Postgres access roles.
|
|
9
|
-
*
|
|
10
|
-
* The org database pool CREATES these roles and grants them on the schema; every
|
|
11
|
-
* consumer that mints a connection asks for one of them BY NAME. Provisioner and
|
|
12
|
-
* consumer therefore have to derive byte-identical names from the same schema
|
|
13
|
-
* name — this module is the single place that derivation exists.
|
|
14
|
-
*/
|
|
15
|
-
export enum SchemaRoleKind {
|
|
16
|
-
ReadWrite = 'rw',
|
|
17
|
-
ReadOnly = 'ro',
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/** `_` + a two-character {@link SchemaRoleKind} value, e.g. `_rw`. */
|
|
21
|
-
export const SCHEMA_ROLE_SUFFIX_LENGTH = 3;
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Budget for the role BASE. Deliberately smaller than
|
|
25
|
-
* {@link POSTGRES_MAX_IDENTIFIER_LENGTH}: the base gets a `_rw` / `_ro` suffix
|
|
26
|
-
* appended, and the FULL role name is what has to fit in Postgres's 63 bytes.
|
|
27
|
-
* Fitting the base to 63 would let `<63-char base>_rw` be truncated by the
|
|
28
|
-
* server to the same 63 bytes as `<same base>_ro`, silently collapsing the
|
|
29
|
-
* read-write and read-only roles into one.
|
|
30
|
-
*/
|
|
31
|
-
export const SCHEMA_ROLE_BASE_MAX_LENGTH =
|
|
32
|
-
POSTGRES_MAX_IDENTIFIER_LENGTH - SCHEMA_ROLE_SUFFIX_LENGTH;
|
|
33
|
-
|
|
34
|
-
export interface SchemaRoleNames {
|
|
35
|
-
readonly rwRoleName: string;
|
|
36
|
-
readonly roRoleName: string;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* The shared stem of a schema's role names.
|
|
41
|
-
*
|
|
42
|
-
* The schema name is character-sanitized but NOT collapsed or trimmed: it is
|
|
43
|
-
* already a valid identifier, and collapsing would map the distinct schemas
|
|
44
|
-
* `app__x` and `app_x` onto one role. Distinct schema names whose first
|
|
45
|
-
* {@link SCHEMA_ROLE_BASE_MAX_LENGTH} characters collide would derive the SAME
|
|
46
|
-
* role under naive truncation — causing cross-schema privilege bleed and drop
|
|
47
|
-
* collisions — so an over-budget name gets a hash of the FULL schema name
|
|
48
|
-
* spliced in instead.
|
|
49
|
-
*/
|
|
50
|
-
export function schemaRoleBase(schemaName: string): string {
|
|
51
|
-
return fitIdentifier({
|
|
52
|
-
candidate: sanitizeIdentifierChars(schemaName),
|
|
53
|
-
maxLength: SCHEMA_ROLE_BASE_MAX_LENGTH,
|
|
54
|
-
hashInput: schemaName,
|
|
55
|
-
});
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/** The name of one specific role for a schema. */
|
|
59
|
-
export function schemaRoleName(
|
|
60
|
-
schemaName: string,
|
|
61
|
-
kind: SchemaRoleKind,
|
|
62
|
-
): string {
|
|
63
|
-
return `${schemaRoleBase(schemaName)}_${kind}`;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* Read-write / read-only role names for a schema. The single source of truth for
|
|
68
|
-
* role-name derivation across provisioning, migration, and every connection
|
|
69
|
-
* consumer — keep all call sites routed through here so the names never drift.
|
|
70
|
-
*/
|
|
71
|
-
export function rolesForSchema(schemaName: string): SchemaRoleNames {
|
|
72
|
-
const base = schemaRoleBase(schemaName);
|
|
73
|
-
return {
|
|
74
|
-
rwRoleName: `${base}_${SchemaRoleKind.ReadWrite}`,
|
|
75
|
-
roRoleName: `${base}_${SchemaRoleKind.ReadOnly}`,
|
|
76
|
-
};
|
|
77
|
-
}
|
package/src/lib/schema-name.ts
DELETED
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
fitIdentifier,
|
|
3
|
-
POSTGRES_MAX_IDENTIFIER_LENGTH,
|
|
4
|
-
sanitizeIdentifierSegment,
|
|
5
|
-
} from './identifier';
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* The default database key. A biome's primary database carries no `__<key>`
|
|
9
|
-
* suffix on its schema name; only additional, non-primary databases do.
|
|
10
|
-
*/
|
|
11
|
-
export const DEFAULT_BIOME_DB_KEY = 'primary';
|
|
12
|
-
|
|
13
|
-
const SCHEMA_PREFIX = 'biome';
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* Derive the Postgres schema name a biome's tables live in inside the shared
|
|
17
|
-
* `xema_biomes` database.
|
|
18
|
-
*
|
|
19
|
-
* Convention:
|
|
20
|
-
* - primary DB (`dbKey` omitted or `'primary'`) → `biome_<biomeId>`
|
|
21
|
-
* - additional DB → `biome_<biomeId>__<dbKey>`
|
|
22
|
-
*
|
|
23
|
-
* Each part is sanitised independently (see {@link sanitizeIdentifierSegment});
|
|
24
|
-
* the `__` (double underscore) is the only place two underscores survive
|
|
25
|
-
* collapsing, so it unambiguously separates the biome id from the db key.
|
|
26
|
-
*
|
|
27
|
-
* Over-budget names are fitted by {@link fitIdentifier}, hashing the RAW
|
|
28
|
-
* (pre-sanitisation) parts joined by `:` so two different inputs can never
|
|
29
|
-
* collide.
|
|
30
|
-
*/
|
|
31
|
-
export function deriveBiomeSchemaName(biomeId: string, dbKey?: string): string {
|
|
32
|
-
const sanitizedBiomeId = sanitizeIdentifierSegment(biomeId);
|
|
33
|
-
if (sanitizedBiomeId.length === 0) {
|
|
34
|
-
throw new Error(
|
|
35
|
-
`deriveBiomeSchemaName: biomeId "${biomeId}" sanitises to an empty identifier`,
|
|
36
|
-
);
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
const isDefaultKey =
|
|
40
|
-
dbKey === undefined || dbKey === '' || dbKey === DEFAULT_BIOME_DB_KEY;
|
|
41
|
-
const sanitizedDbKey = isDefaultKey ? '' : sanitizeIdentifierSegment(dbKey);
|
|
42
|
-
|
|
43
|
-
if (!isDefaultKey && sanitizedDbKey.length === 0) {
|
|
44
|
-
throw new Error(
|
|
45
|
-
`deriveBiomeSchemaName: dbKey "${dbKey}" sanitises to an empty identifier`,
|
|
46
|
-
);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
const candidate = isDefaultKey
|
|
50
|
-
? `${SCHEMA_PREFIX}_${sanitizedBiomeId}`
|
|
51
|
-
: `${SCHEMA_PREFIX}_${sanitizedBiomeId}__${sanitizedDbKey}`;
|
|
52
|
-
|
|
53
|
-
// The hash covers the raw parts so the disambiguating suffix is stable across
|
|
54
|
-
// calls and distinguishes inputs that sanitise to the same prefix.
|
|
55
|
-
const hashParts = isDefaultKey ? [biomeId] : [biomeId, dbKey];
|
|
56
|
-
|
|
57
|
-
return fitIdentifier({
|
|
58
|
-
candidate,
|
|
59
|
-
maxLength: POSTGRES_MAX_IDENTIFIER_LENGTH,
|
|
60
|
-
hashInput: hashParts.join(':'),
|
|
61
|
-
});
|
|
62
|
-
}
|