@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,158 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* LC-1 topology-1 org-erasure ENDPOINT — the DRY adoption surface.
|
|
3
|
-
*
|
|
4
|
-
* A first-party biome that holds org data by-row opts in with ONE line:
|
|
5
|
-
*
|
|
6
|
-
* ```ts
|
|
7
|
-
* imports: [OrgErasureModule.forRoot({ prismaServiceToken: PrismaService })]
|
|
8
|
-
* ```
|
|
9
|
-
*
|
|
10
|
-
* That exposes the STANDARD internal route `POST /internal/org-erasure/:orgId`,
|
|
11
|
-
* which erases every row belonging to the org across the service's org-scoped
|
|
12
|
-
* models via {@link eraseOrgData} on the UNSCOPED client (`asSystem()`). The
|
|
13
|
-
* org-erasure orchestrator saga fans out to this route on every participant.
|
|
14
|
-
*
|
|
15
|
-
* Service-only, and ENFORCED by `ServiceActorGuard` — not by the classification
|
|
16
|
-
* decorator. `@XemaInternalRoute({ audience: Service })` is metadata the
|
|
17
|
-
* fail-closed route scanner reads; it installs NO runtime guard (see
|
|
18
|
-
* `.claude/rules/public-and-internal-route-guarding.md`). The global stack only
|
|
19
|
-
* proves the JWT signature, and `RolesGuard` enforces nothing unless a route
|
|
20
|
-
* declares `@RequireOrgRole`/`@RequireTokenClass`. Without the guard, ANY
|
|
21
|
-
* authenticated user token reaching this service — trivially so for any
|
|
22
|
-
* participant whose chart enables ingress — could irreversibly erase an
|
|
23
|
-
* arbitrary org's data, since `:orgId` is taken verbatim from the path. This
|
|
24
|
-
* module is adopted by 17 participants, so the guard belongs HERE, once.
|
|
25
|
-
*
|
|
26
|
-
* The org filter is applied inside {@link eraseOrgData}; the runner passes the
|
|
27
|
-
* unscoped client so scoping is neither doubled nor left to ambient context.
|
|
28
|
-
*
|
|
29
|
-
* **The module refuses to boot a participant that can erase nothing.** Wiring
|
|
30
|
-
* the runner asserts (via {@link assertOrgErasureMatchesModels}) that the
|
|
31
|
-
* configured `orgField` matches at least one model of THIS service's schema. A
|
|
32
|
-
* participant registered against a column its schema does not spell matches
|
|
33
|
-
* zero models, deletes nothing, and returns `{totalDeleted: 0}` — which the
|
|
34
|
-
* orchestrator records as a successful erasure. That must fail at deploy, not
|
|
35
|
-
* silently succeed during a Data Subject Request.
|
|
36
|
-
*/
|
|
37
|
-
import {
|
|
38
|
-
Controller,
|
|
39
|
-
Inject,
|
|
40
|
-
Logger,
|
|
41
|
-
Param,
|
|
42
|
-
Post,
|
|
43
|
-
UseGuards,
|
|
44
|
-
type DynamicModule,
|
|
45
|
-
type Type,
|
|
46
|
-
} from '@nestjs/common';
|
|
47
|
-
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
|
48
|
-
import { ServiceActorGuard } from '@xemahq/platform-common';
|
|
49
|
-
import { InternalRouteAudience, XemaInternalRoute } from '@xemahq/xema-decorators';
|
|
50
|
-
|
|
51
|
-
import {
|
|
52
|
-
assertOrgErasureMatchesModels,
|
|
53
|
-
eraseOrgData,
|
|
54
|
-
type EraseOrgDataOptions,
|
|
55
|
-
type OrgErasureReport,
|
|
56
|
-
} from '../tenant-isolation/org-erasure';
|
|
57
|
-
import { DEFAULT_ORG_FIELD } from '../tenant-isolation/tenant-isolation.extension';
|
|
58
|
-
|
|
59
|
-
import type { TenantScopedApi } from './prisma-service-factory';
|
|
60
|
-
|
|
61
|
-
/** DI token for the erasure runner the controller delegates to. */
|
|
62
|
-
export const ORG_ERASURE_RUNNER = Symbol.for(
|
|
63
|
-
'@xemahq/biome-database-nest/org-erasure-runner',
|
|
64
|
-
);
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* The capability a topology-1 participant declares in its service descriptor's
|
|
68
|
-
* `exposesCapabilities` so the org-erasure orchestrator can DISCOVER it via the
|
|
69
|
-
* service registry (`registry.list()` filtered on this string) and fan out to
|
|
70
|
-
* its `POST /internal/org-erasure/:orgId`. Single source of truth shared by the
|
|
71
|
-
* declaring service and the orchestrator — discovery is explicit-by-declaration,
|
|
72
|
-
* never a probe-and-guess heuristic.
|
|
73
|
-
*/
|
|
74
|
-
export const ORG_DATA_ERASURE_CAPABILITY = 'xema.org-data-erasure';
|
|
75
|
-
|
|
76
|
-
/** The internal route path this module exposes (orchestrator fans out to it). */
|
|
77
|
-
export const ORG_ERASURE_ROUTE_PATH = 'internal/org-erasure';
|
|
78
|
-
|
|
79
|
-
export interface OrgErasureRunner {
|
|
80
|
-
erase(orgId: string): Promise<OrgErasureReport>;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
@ApiTags('org-erasure')
|
|
84
|
-
@UseGuards(ServiceActorGuard)
|
|
85
|
-
@Controller('internal/org-erasure')
|
|
86
|
-
export class OrgErasureController {
|
|
87
|
-
constructor(
|
|
88
|
-
@Inject(ORG_ERASURE_RUNNER) private readonly runner: OrgErasureRunner,
|
|
89
|
-
) {}
|
|
90
|
-
|
|
91
|
-
@XemaInternalRoute({ audience: InternalRouteAudience.Service })
|
|
92
|
-
@Post(':orgId')
|
|
93
|
-
@ApiOperation({
|
|
94
|
-
summary:
|
|
95
|
-
"Erase every row belonging to an org across this service's org-scoped models",
|
|
96
|
-
})
|
|
97
|
-
erase(@Param('orgId') orgId: string): Promise<OrgErasureReport> {
|
|
98
|
-
return this.runner.erase(orgId);
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
export interface OrgErasureModuleOptions extends EraseOrgDataOptions {
|
|
103
|
-
/**
|
|
104
|
-
* The service's PrismaService DI token — the class it provides as its Prisma
|
|
105
|
-
* service (a {@link BiomePrismaServiceInstance}). The runner resolves it and
|
|
106
|
-
* calls `.asSystem()` to get the unscoped client.
|
|
107
|
-
*/
|
|
108
|
-
readonly prismaServiceToken: Type<unknown> | string | symbol;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* The subset of the biome Prisma service the runner needs: the unscoped
|
|
113
|
-
* `asSystem()` client accessor.
|
|
114
|
-
*/
|
|
115
|
-
type AsSystemProvider = TenantScopedApi<unknown>;
|
|
116
|
-
|
|
117
|
-
export class OrgErasureModule {
|
|
118
|
-
static forRoot(options: OrgErasureModuleOptions): DynamicModule {
|
|
119
|
-
const { prismaServiceToken, ...eraseOptions } = options;
|
|
120
|
-
return {
|
|
121
|
-
module: OrgErasureModule,
|
|
122
|
-
controllers: [OrgErasureController],
|
|
123
|
-
providers: [
|
|
124
|
-
{
|
|
125
|
-
provide: ORG_ERASURE_RUNNER,
|
|
126
|
-
useFactory: (prisma: AsSystemProvider): OrgErasureRunner => {
|
|
127
|
-
const client = prisma.asSystem();
|
|
128
|
-
// BOOT GATE. A participant whose orgField matches no model can
|
|
129
|
-
// never delete a row, and `eraseOrgData` would have answered every
|
|
130
|
-
// erasure `{totalDeleted: 0}` — which the orchestrator saga records
|
|
131
|
-
// as a successful erasure. Discovering that during a live Data
|
|
132
|
-
// Subject Request is discovering it too late, so the service
|
|
133
|
-
// refuses to START rather than advertise `xema.org-data-erasure`
|
|
134
|
-
// it cannot honour. Throwing here aborts Nest's bootstrap.
|
|
135
|
-
//
|
|
136
|
-
// This is the CONFIGURATION proof; `eraseOrgData` re-asserts per
|
|
137
|
-
// request, which is the proof of the RUN being recorded as
|
|
138
|
-
// evidence. The assertion is on MODELS matched — an org that
|
|
139
|
-
// genuinely holds no rows here still erases successfully.
|
|
140
|
-
const models = [
|
|
141
|
-
...assertOrgErasureMatchesModels(client, eraseOptions),
|
|
142
|
-
].sort();
|
|
143
|
-
new Logger(OrgErasureModule.name).log(
|
|
144
|
-
`org-erasure participant armed on "${
|
|
145
|
-
eraseOptions.orgField ?? DEFAULT_ORG_FIELD
|
|
146
|
-
}" over ${models.length} model(s): ${models.join(', ')}`,
|
|
147
|
-
);
|
|
148
|
-
return {
|
|
149
|
-
erase: (orgId: string): Promise<OrgErasureReport> =>
|
|
150
|
-
eraseOrgData(prisma.asSystem(), orgId, eraseOptions),
|
|
151
|
-
};
|
|
152
|
-
},
|
|
153
|
-
inject: [prismaServiceToken],
|
|
154
|
-
},
|
|
155
|
-
],
|
|
156
|
-
};
|
|
157
|
-
}
|
|
158
|
-
}
|
|
@@ -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
|
-
}
|