@pattern-stack/codegen 0.7.8 → 0.8.1
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/CHANGELOG.md +95 -0
- package/dist/runtime/base-classes/activity-entity-repository.js +98 -17
- package/dist/runtime/base-classes/activity-entity-repository.js.map +1 -1
- package/dist/runtime/base-classes/base-repository.d.ts +47 -3
- package/dist/runtime/base-classes/base-repository.js +98 -17
- package/dist/runtime/base-classes/base-repository.js.map +1 -1
- package/dist/runtime/base-classes/index.d.ts +1 -0
- package/dist/runtime/base-classes/index.js +137 -28
- package/dist/runtime/base-classes/index.js.map +1 -1
- package/dist/runtime/base-classes/junction-sync-repository.js +102 -21
- package/dist/runtime/base-classes/junction-sync-repository.js.map +1 -1
- package/dist/runtime/base-classes/knowledge-entity-repository.js +98 -17
- package/dist/runtime/base-classes/knowledge-entity-repository.js.map +1 -1
- package/dist/runtime/base-classes/metadata-entity-repository.js +101 -20
- package/dist/runtime/base-classes/metadata-entity-repository.js.map +1 -1
- package/dist/runtime/base-classes/synced-entity-repository.js +103 -22
- package/dist/runtime/base-classes/synced-entity-repository.js.map +1 -1
- package/dist/runtime/base-classes/tenant-context.d.ts +79 -0
- package/dist/runtime/base-classes/tenant-context.js +46 -0
- package/dist/runtime/base-classes/tenant-context.js.map +1 -0
- package/dist/runtime/subsystems/auth/controllers/auth.controller.d.ts +1 -0
- package/dist/runtime/subsystems/auth/index.d.ts +2 -0
- package/dist/runtime/subsystems/auth/index.js +55 -0
- package/dist/runtime/subsystems/auth/index.js.map +1 -1
- package/dist/runtime/subsystems/auth/middleware/requester-context.d.ts +81 -0
- package/dist/runtime/subsystems/auth/middleware/requester-context.js +60 -0
- package/dist/runtime/subsystems/auth/middleware/requester-context.js.map +1 -0
- package/dist/runtime/subsystems/auth/protocols/user-context.d.ts +18 -0
- package/dist/runtime/subsystems/index.d.ts +1 -0
- package/dist/runtime/subsystems/index.js +4 -0
- package/dist/runtime/subsystems/index.js.map +1 -1
- package/dist/src/cli/index.js +15 -1
- package/dist/src/cli/index.js.map +1 -1
- package/package.json +1 -1
- package/runtime/base-classes/base-repository.ts +96 -20
- package/runtime/base-classes/index.ts +13 -0
- package/runtime/base-classes/tenant-context.ts +175 -0
- package/runtime/subsystems/auth/index.ts +8 -0
- package/runtime/subsystems/auth/middleware/requester-context.ts +141 -0
- package/runtime/subsystems/auth/protocols/user-context.ts +17 -0
package/package.json
CHANGED
|
@@ -9,10 +9,15 @@
|
|
|
9
9
|
*
|
|
10
10
|
* NOT @Injectable — concrete repositories are @Injectable and inject DRIZZLE.
|
|
11
11
|
*/
|
|
12
|
-
import { eq, inArray, isNull, sql } from 'drizzle-orm';
|
|
12
|
+
import { and, eq, inArray, isNull, sql } from 'drizzle-orm';
|
|
13
13
|
import type { PgTableWithColumns, PgColumn } from 'drizzle-orm/pg-core';
|
|
14
14
|
import type { SQL } from 'drizzle-orm';
|
|
15
15
|
import type { DrizzleClient, DrizzleTx } from '../types/drizzle';
|
|
16
|
+
import {
|
|
17
|
+
requireRequester,
|
|
18
|
+
tryGetRequester,
|
|
19
|
+
type RequesterScope,
|
|
20
|
+
} from './tenant-context';
|
|
16
21
|
|
|
17
22
|
// ============================================================================
|
|
18
23
|
// Interfaces
|
|
@@ -59,6 +64,21 @@ export abstract class BaseRepository<TEntity> {
|
|
|
59
64
|
userTracking: false,
|
|
60
65
|
};
|
|
61
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Ambient tenant-scope enforcement for `userTracking` repos (see
|
|
69
|
+
* `scopePredicate`). Only has effect when `behaviors.userTracking === true`.
|
|
70
|
+
*
|
|
71
|
+
* - `'lenient'` (default): when no ambient requester context is active,
|
|
72
|
+
* reads/writes are NOT scoped — preserves pre-scoping behavior, so adopting
|
|
73
|
+
* ambient scoping is additive. Scoping kicks in automatically once a
|
|
74
|
+
* boundary installs `withRequester(...)`.
|
|
75
|
+
* - `'strict'`: a missing ambient context throws (`requireRequester`),
|
|
76
|
+
* making a forgotten boundary fail loud instead of silently returning
|
|
77
|
+
* cross-tenant rows. Recommended for new multi-tenant consumers — override
|
|
78
|
+
* in a concrete repo or a family base class.
|
|
79
|
+
*/
|
|
80
|
+
protected readonly scopeEnforcement: 'lenient' | 'strict' = 'lenient';
|
|
81
|
+
|
|
62
82
|
protected readonly db: DrizzleClient;
|
|
63
83
|
|
|
64
84
|
constructor(db: DrizzleClient) {
|
|
@@ -85,9 +105,7 @@ export abstract class BaseRepository<TEntity> {
|
|
|
85
105
|
* Returns null if not found (or soft-deleted when softDelete=true).
|
|
86
106
|
*/
|
|
87
107
|
async findById(id: string): Promise<TEntity | null> {
|
|
88
|
-
const rows = await this.baseQuery()
|
|
89
|
-
.where(eq(this.table['id'], id))
|
|
90
|
-
.limit(1);
|
|
108
|
+
const rows = await this.baseQuery(eq(this.table['id'], id)).limit(1);
|
|
91
109
|
return (rows[0] as TEntity) ?? null;
|
|
92
110
|
}
|
|
93
111
|
|
|
@@ -97,7 +115,7 @@ export abstract class BaseRepository<TEntity> {
|
|
|
97
115
|
*/
|
|
98
116
|
async findByIds(ids: string[]): Promise<TEntity[]> {
|
|
99
117
|
if (ids.length === 0) return [];
|
|
100
|
-
const rows = await this.baseQuery(
|
|
118
|
+
const rows = await this.baseQuery(inArray(this.table['id'], ids));
|
|
101
119
|
return rows as TEntity[];
|
|
102
120
|
}
|
|
103
121
|
|
|
@@ -105,11 +123,8 @@ export abstract class BaseRepository<TEntity> {
|
|
|
105
123
|
* List entities with optional filtering, pagination, and ordering.
|
|
106
124
|
*/
|
|
107
125
|
async list(options?: ListOptions): Promise<TEntity[]> {
|
|
108
|
-
let query = this.baseQuery();
|
|
126
|
+
let query = this.baseQuery(options?.where);
|
|
109
127
|
|
|
110
|
-
if (options?.where) {
|
|
111
|
-
query = query.where(options.where) as typeof query;
|
|
112
|
-
}
|
|
113
128
|
if (options?.orderBy) {
|
|
114
129
|
query = query.orderBy(options.orderBy as SQL) as typeof query;
|
|
115
130
|
}
|
|
@@ -137,6 +152,10 @@ export abstract class BaseRepository<TEntity> {
|
|
|
137
152
|
if (this.behaviors.softDelete) {
|
|
138
153
|
conditions.push(isNull(this.table['deletedAt']));
|
|
139
154
|
}
|
|
155
|
+
const scope = this.scopePredicate();
|
|
156
|
+
if (scope) {
|
|
157
|
+
conditions.push(scope);
|
|
158
|
+
}
|
|
140
159
|
if (where) {
|
|
141
160
|
conditions.push(where);
|
|
142
161
|
}
|
|
@@ -144,8 +163,6 @@ export abstract class BaseRepository<TEntity> {
|
|
|
144
163
|
if (conditions.length === 1) {
|
|
145
164
|
query = query.where(conditions[0]) as typeof query;
|
|
146
165
|
} else if (conditions.length > 1) {
|
|
147
|
-
// Combine with AND by building the condition inline
|
|
148
|
-
const { and } = await import('drizzle-orm');
|
|
149
166
|
query = query.where(and(...conditions)) as typeof query;
|
|
150
167
|
}
|
|
151
168
|
|
|
@@ -186,7 +203,7 @@ export abstract class BaseRepository<TEntity> {
|
|
|
186
203
|
const rows = await this.runner(tx)
|
|
187
204
|
.update(this.table)
|
|
188
205
|
.set(data as any) // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
189
|
-
.where(eq(this.table['id'], id))
|
|
206
|
+
.where(this.scopeAnd(eq(this.table['id'], id)))
|
|
190
207
|
.returning();
|
|
191
208
|
return rows[0] as TEntity;
|
|
192
209
|
}
|
|
@@ -202,11 +219,11 @@ export abstract class BaseRepository<TEntity> {
|
|
|
202
219
|
await runner
|
|
203
220
|
.update(this.table)
|
|
204
221
|
.set({ deletedAt: new Date() } as any) // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
205
|
-
.where(eq(this.table['id'], id));
|
|
222
|
+
.where(this.scopeAnd(eq(this.table['id'], id)));
|
|
206
223
|
} else {
|
|
207
224
|
await runner
|
|
208
225
|
.delete(this.table)
|
|
209
|
-
.where(eq(this.table['id'], id));
|
|
226
|
+
.where(this.scopeAnd(eq(this.table['id'], id)));
|
|
210
227
|
}
|
|
211
228
|
}
|
|
212
229
|
|
|
@@ -224,15 +241,74 @@ export abstract class BaseRepository<TEntity> {
|
|
|
224
241
|
// ============================================================================
|
|
225
242
|
|
|
226
243
|
/**
|
|
227
|
-
* Base SELECT query that automatically
|
|
228
|
-
* when softDelete
|
|
244
|
+
* Base SELECT query that automatically applies the ambient guards —
|
|
245
|
+
* soft-delete exclusion (when `softDelete`) and tenant scope (when
|
|
246
|
+
* `userTracking` + an active requester context) — combined with an optional
|
|
247
|
+
* caller `extra` predicate into a SINGLE `WHERE`.
|
|
248
|
+
*
|
|
249
|
+
* Pass the leaf predicate as `extra` rather than chaining a second
|
|
250
|
+
* `.where(...)`: Drizzle's `.where()` OVERRIDES (does not AND) a prior
|
|
251
|
+
* `.where()` on a `$dynamic()` query, so a chained call would silently drop
|
|
252
|
+
* the soft-delete and scope guards. `baseQuery(extra)` is the safe form.
|
|
229
253
|
*/
|
|
230
|
-
protected baseQuery() {
|
|
254
|
+
protected baseQuery(extra?: SQL) {
|
|
231
255
|
const query = this.db.select().from(this.table).$dynamic();
|
|
232
|
-
|
|
233
|
-
|
|
256
|
+
const where = this.scopeAnd(extra, { softDelete: this.behaviors.softDelete });
|
|
257
|
+
return where ? query.where(where) : query;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Build the ambient tenant-scope predicate for this repo's table.
|
|
262
|
+
*
|
|
263
|
+
* Returns `undefined` (no scoping) when:
|
|
264
|
+
* - `behaviors.userTracking` is false (repo is not user-owned), or
|
|
265
|
+
* - no ambient requester context is active AND `scopeEnforcement` is
|
|
266
|
+
* `'lenient'` (the default — preserves pre-scoping behavior).
|
|
267
|
+
*
|
|
268
|
+
* When a requester context is active, scopes by `user_id` per the ambient
|
|
269
|
+
* scope: `'user'` → `user_id = ctx.userId`; `'org'` → `user_id IN
|
|
270
|
+
* ctx.orgUserIds` (empty list matches nothing — fail-closed); `'superuser'`
|
|
271
|
+
* → no filter. See `tenant-context.ts` for the boundary-install contract.
|
|
272
|
+
*/
|
|
273
|
+
protected scopePredicate(): SQL | undefined {
|
|
274
|
+
if (!this.behaviors.userTracking) return undefined;
|
|
275
|
+
const ctx =
|
|
276
|
+
this.scopeEnforcement === 'strict'
|
|
277
|
+
? requireRequester()
|
|
278
|
+
: tryGetRequester();
|
|
279
|
+
if (!ctx) return undefined;
|
|
280
|
+
const scope: RequesterScope = ctx.scope ?? 'user';
|
|
281
|
+
switch (scope) {
|
|
282
|
+
case 'superuser':
|
|
283
|
+
return undefined;
|
|
284
|
+
case 'org':
|
|
285
|
+
return ctx.orgUserIds && ctx.orgUserIds.length > 0
|
|
286
|
+
? inArray(this.table['userId'], ctx.orgUserIds as string[])
|
|
287
|
+
: sql`false`;
|
|
288
|
+
case 'user':
|
|
289
|
+
default:
|
|
290
|
+
return eq(this.table['userId'], ctx.userId);
|
|
234
291
|
}
|
|
235
|
-
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Combine the ambient scope predicate (and, optionally, the soft-delete
|
|
296
|
+
* guard) with a caller `extra` predicate into one `SQL`. Returns `undefined`
|
|
297
|
+
* when nothing applies. Used by read + by-id write paths so a single
|
|
298
|
+
* `.where(...)` carries every guard.
|
|
299
|
+
*/
|
|
300
|
+
protected scopeAnd(
|
|
301
|
+
extra?: SQL,
|
|
302
|
+
opts?: { softDelete?: boolean },
|
|
303
|
+
): SQL | undefined {
|
|
304
|
+
const conditions: SQL[] = [];
|
|
305
|
+
if (opts?.softDelete) conditions.push(isNull(this.table['deletedAt']));
|
|
306
|
+
const scope = this.scopePredicate();
|
|
307
|
+
if (scope) conditions.push(scope);
|
|
308
|
+
if (extra) conditions.push(extra);
|
|
309
|
+
if (conditions.length === 0) return undefined;
|
|
310
|
+
if (conditions.length === 1) return conditions[0];
|
|
311
|
+
return and(...conditions);
|
|
236
312
|
}
|
|
237
313
|
|
|
238
314
|
/**
|
|
@@ -4,6 +4,19 @@
|
|
|
4
4
|
export { BaseRepository } from './base-repository';
|
|
5
5
|
export type { BehaviorConfig, ListOptions } from './base-repository';
|
|
6
6
|
|
|
7
|
+
// Ambient tenant scope (AsyncLocalStorage) — read by BaseRepository.scopePredicate,
|
|
8
|
+
// set at request/worker boundaries via withRequester/withUserScope/etc.
|
|
9
|
+
export {
|
|
10
|
+
withRequester,
|
|
11
|
+
requireRequester,
|
|
12
|
+
tryGetRequester,
|
|
13
|
+
requireRequesterScope,
|
|
14
|
+
withUserScope,
|
|
15
|
+
withOrgScope,
|
|
16
|
+
withSuperuserScope,
|
|
17
|
+
} from './tenant-context';
|
|
18
|
+
export type { RequesterContext, RequesterScope } from './tenant-context';
|
|
19
|
+
|
|
7
20
|
export { BaseService } from './base-service';
|
|
8
21
|
export type { IBaseRepository } from './base-service';
|
|
9
22
|
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ambient requester context — AsyncLocalStorage-backed tenant scope.
|
|
3
|
+
*
|
|
4
|
+
* The alternative to threading `userId`/`organizationId` through every
|
|
5
|
+
* repository/service signature. Set ONCE at each boundary the generated app
|
|
6
|
+
* owns, read implicitly inside `BaseRepository` (see `scopePredicate`).
|
|
7
|
+
*
|
|
8
|
+
* ## Where to set it (boundaries)
|
|
9
|
+
*
|
|
10
|
+
* - HTTP / tRPC handlers — from the authenticated `ctx.user`
|
|
11
|
+
* - OAuth callback controllers — from the authenticated session
|
|
12
|
+
* - Queue/worker `process()` — from the job's owning user after the
|
|
13
|
+
* job's record is loaded
|
|
14
|
+
*
|
|
15
|
+
* Each boundary wraps the rest of the request in `withRequester({ userId,
|
|
16
|
+
* organizationId }, () => ...)`. The context propagates through every `await`
|
|
17
|
+
* to all downstream repo/service calls without being passed explicitly.
|
|
18
|
+
*
|
|
19
|
+
* ## Where to read it
|
|
20
|
+
*
|
|
21
|
+
* - `BaseRepository.scopePredicate()` reads it (via `tryGetRequester` in
|
|
22
|
+
* lenient mode, `requireRequester` in strict mode) and filters every read
|
|
23
|
+
* by the ambient scope when the repo declares `userTracking: true`.
|
|
24
|
+
*
|
|
25
|
+
* ## Why AsyncLocalStorage over an explicit parameter
|
|
26
|
+
*
|
|
27
|
+
* Threading `userId` (and later `organizationId`) through dozens of method
|
|
28
|
+
* signatures is pure parameter pollution. Ambient context also lets a repo
|
|
29
|
+
* make the "I forgot to scope" mistake impossible at runtime: in strict mode
|
|
30
|
+
* `requireRequester()` throws when no context is active, surfacing a missing
|
|
31
|
+
* boundary call loudly rather than silently leaking cross-tenant data.
|
|
32
|
+
*
|
|
33
|
+
* ## Not-found semantics
|
|
34
|
+
*
|
|
35
|
+
* When a row exists but belongs to a different requester, scoped reads return
|
|
36
|
+
* `null`/`[]` — identical to "truly doesn't exist". No existence oracle;
|
|
37
|
+
* callers throw NotFound uniformly. Standard security practice.
|
|
38
|
+
*
|
|
39
|
+
* ## Testing
|
|
40
|
+
*
|
|
41
|
+
* Tests that exercise scoped repos must wrap the call in `withRequester(...)`.
|
|
42
|
+
* In strict mode an unwrapped call hitting `requireRequester()` throws — by
|
|
43
|
+
* design. In lenient mode (the default) an unwrapped call is simply unscoped.
|
|
44
|
+
*/
|
|
45
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Data-visibility scope. The auth layer decides which scope a request is
|
|
49
|
+
* allowed to claim; the repo trusts whatever the ambient context says.
|
|
50
|
+
*
|
|
51
|
+
* - `'user'`: filter every read by `user_id = ctx.userId`. Default.
|
|
52
|
+
* - `'org'`: filter every read by membership in the requester's org, resolved
|
|
53
|
+
* via `user_id IN (ctx.orgUserIds)` rather than via a per-entity
|
|
54
|
+
* `organization_id` column. Works for every user-owned table and keeps repos
|
|
55
|
+
* single-table — the org member list is pre-resolved at the boundary.
|
|
56
|
+
* - `'superuser'`: no scope filter. Engineering / internal-tools only.
|
|
57
|
+
*
|
|
58
|
+
* AUTHORIZATION (who is allowed to claim each scope) lives in boundary
|
|
59
|
+
* middleware, not in the repo. The repo trusts the ambient context — same
|
|
60
|
+
* trust model as a threaded `userId`.
|
|
61
|
+
*/
|
|
62
|
+
export type RequesterScope = 'user' | 'org' | 'superuser';
|
|
63
|
+
|
|
64
|
+
export interface RequesterContext {
|
|
65
|
+
/**
|
|
66
|
+
* The user making the request. Always present — even in `'org'` and
|
|
67
|
+
* `'superuser'` scopes it is the audit-trail "who actually did this".
|
|
68
|
+
*/
|
|
69
|
+
readonly userId: string;
|
|
70
|
+
/**
|
|
71
|
+
* The organization the requester belongs to. Required when
|
|
72
|
+
* `scope === 'org'`; may be null for `'user'` (users with no org) and for
|
|
73
|
+
* `'superuser'` (cross-org reads).
|
|
74
|
+
*/
|
|
75
|
+
readonly organizationId: string | null;
|
|
76
|
+
/**
|
|
77
|
+
* Data-visibility scope. Defaults to `'user'` when omitted.
|
|
78
|
+
*/
|
|
79
|
+
readonly scope?: RequesterScope;
|
|
80
|
+
/**
|
|
81
|
+
* For `scope === 'org'`: the list of user IDs in the requester's org,
|
|
82
|
+
* pre-resolved by the boundary middleware that established the `'org'`
|
|
83
|
+
* scope (one `SELECT users.id WHERE organization_id = X` at the trust
|
|
84
|
+
* boundary). Repos use this as a literal `IN (...)` filter — they never
|
|
85
|
+
* JOIN to `users` themselves. Required when `scope === 'org'`.
|
|
86
|
+
*/
|
|
87
|
+
readonly orgUserIds?: readonly string[];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const als = new AsyncLocalStorage<RequesterContext>();
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Set the ambient requester context for the duration of `fn`. The context
|
|
94
|
+
* propagates through `await` boundaries to all downstream calls. Nesting is
|
|
95
|
+
* fine — an inner `withRequester` overrides the outer for its callback.
|
|
96
|
+
*/
|
|
97
|
+
export function withRequester<T>(
|
|
98
|
+
ctx: RequesterContext,
|
|
99
|
+
fn: () => Promise<T>,
|
|
100
|
+
): Promise<T> {
|
|
101
|
+
return als.run(ctx, fn);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Read the ambient requester context. Throws if no context is active — by
|
|
106
|
+
* design. Used by repos in strict scope-enforcement mode; an unwrapped call
|
|
107
|
+
* site is a missing boundary.
|
|
108
|
+
*/
|
|
109
|
+
export function requireRequester(): RequesterContext {
|
|
110
|
+
const ctx = als.getStore();
|
|
111
|
+
if (!ctx) {
|
|
112
|
+
throw new Error(
|
|
113
|
+
'No requester context active. Wrap the entry point in ' +
|
|
114
|
+
'withRequester({ userId, organizationId }, fn). See tenant-context.ts.',
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
return ctx;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Read the ambient requester context without throwing. Returns `undefined`
|
|
122
|
+
* when no context is active. Used by repos in lenient scope-enforcement mode
|
|
123
|
+
* (the default) and by code paths that legitimately run outside a request.
|
|
124
|
+
*/
|
|
125
|
+
export function tryGetRequester(): RequesterContext | undefined {
|
|
126
|
+
return als.getStore();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Resolve the effective scope for the ambient context, defaulting to `'user'`.
|
|
131
|
+
*/
|
|
132
|
+
export function requireRequesterScope(): RequesterScope {
|
|
133
|
+
return requireRequester().scope ?? 'user';
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Convenience helpers for setting scope explicitly. All three preserve
|
|
138
|
+
* `userId` in the context (audit trail) regardless of scope.
|
|
139
|
+
*
|
|
140
|
+
* - `withUserScope`: regular end-user requests. Most call sites.
|
|
141
|
+
* - `withOrgScope`: admin / org-shared resource access. The caller MUST verify
|
|
142
|
+
* the requester's role permits `'org'` before calling — the helper does not
|
|
143
|
+
* enforce authorization. `orgUserIds` is pre-resolved at the boundary.
|
|
144
|
+
* - `withSuperuserScope`: engineering scripts / internal tools. `organizationId`
|
|
145
|
+
* is null (cross-org is the point). Same authorization caveat applies.
|
|
146
|
+
*/
|
|
147
|
+
export function withUserScope<T>(
|
|
148
|
+
userId: string,
|
|
149
|
+
organizationId: string | null,
|
|
150
|
+
fn: () => Promise<T>,
|
|
151
|
+
): Promise<T> {
|
|
152
|
+
return withRequester({ userId, organizationId, scope: 'user' }, fn);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function withOrgScope<T>(
|
|
156
|
+
userId: string,
|
|
157
|
+
organizationId: string,
|
|
158
|
+
orgUserIds: readonly string[],
|
|
159
|
+
fn: () => Promise<T>,
|
|
160
|
+
): Promise<T> {
|
|
161
|
+
return withRequester(
|
|
162
|
+
{ userId, organizationId, scope: 'org', orgUserIds },
|
|
163
|
+
fn,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function withSuperuserScope<T>(
|
|
168
|
+
userId: string,
|
|
169
|
+
fn: () => Promise<T>,
|
|
170
|
+
): Promise<T> {
|
|
171
|
+
return withRequester(
|
|
172
|
+
{ userId, organizationId: null, scope: 'superuser' },
|
|
173
|
+
fn,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
@@ -112,5 +112,13 @@ export {
|
|
|
112
112
|
// Controller
|
|
113
113
|
export { AuthController } from './controllers/auth.controller';
|
|
114
114
|
|
|
115
|
+
// Middleware — RequesterContext boundary (bridges auth → ambient tenant scope)
|
|
116
|
+
export {
|
|
117
|
+
installRequesterContext,
|
|
118
|
+
makeRequesterContextMiddleware,
|
|
119
|
+
resolveRequesterContext,
|
|
120
|
+
type RequesterContextOptions,
|
|
121
|
+
} from './middleware/requester-context';
|
|
122
|
+
|
|
115
123
|
// Module
|
|
116
124
|
export { AuthModule, type AuthModuleOptions } from './auth.module';
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RequesterContext boundary install — bridges authentication to ambient
|
|
3
|
+
* tenant scoping.
|
|
4
|
+
*
|
|
5
|
+
* This is the missing link that makes `BaseRepository`'s ambient scoping
|
|
6
|
+
* (see `base-classes/tenant-context.ts`) actually engage on HTTP requests:
|
|
7
|
+
* it reads the requester off each request (via the consumer-bound
|
|
8
|
+
* `IUserContext`) and runs the rest of the request inside `withRequester(...)`,
|
|
9
|
+
* so every downstream repository read/write is automatically scoped — no
|
|
10
|
+
* threaded `userId`.
|
|
11
|
+
*
|
|
12
|
+
* ## Wiring (one line in your bootstrap)
|
|
13
|
+
*
|
|
14
|
+
* In `main.ts`, after `NestFactory.create`:
|
|
15
|
+
*
|
|
16
|
+
* ```ts
|
|
17
|
+
* import { installRequesterContext } from './shared/subsystems/auth/middleware/requester-context';
|
|
18
|
+
* const app = await NestFactory.create(AppModule);
|
|
19
|
+
* installRequesterContext(app); // no-op + warn if AUTH_USER_CONTEXT is unbound
|
|
20
|
+
* ```
|
|
21
|
+
*
|
|
22
|
+
* `installRequesterContext` resolves `AUTH_USER_CONTEXT` from the root DI
|
|
23
|
+
* container (so it sees the binding the consumer provides in AppModule) and
|
|
24
|
+
* registers a global Express middleware. Pairs with Swagger's `@ApiBearerAuth`
|
|
25
|
+
* "Authorize" button: paste a token there and every request it sends now flows
|
|
26
|
+
* through this boundary into a scoped repository call.
|
|
27
|
+
*
|
|
28
|
+
* ## Trust + failure model
|
|
29
|
+
*
|
|
30
|
+
* - The middleware TRUSTS whatever `IUserContext` returns — authentication and
|
|
31
|
+
* authorization (validating the token, deciding which scope a requester may
|
|
32
|
+
* claim) are the `IUserContext` implementation's job, exactly as for a
|
|
33
|
+
* hand-threaded `userId`.
|
|
34
|
+
* - When the requester cannot be resolved (no/invalid credentials — e.g. a
|
|
35
|
+
* public route, or the OAuth callback itself), the request proceeds WITHOUT
|
|
36
|
+
* an ambient context (`onUnresolved: 'unscoped'`, the default). A
|
|
37
|
+
* `userTracking` repo in lenient mode then runs unscoped; in strict mode it
|
|
38
|
+
* throws downstream — which is correct: unauthenticated callers must not
|
|
39
|
+
* reach scoped data. Set `onUnresolved: 'reject'` to fail the request at the
|
|
40
|
+
* boundary instead.
|
|
41
|
+
*/
|
|
42
|
+
import type { INestApplication } from '@nestjs/common';
|
|
43
|
+
import {
|
|
44
|
+
withRequester,
|
|
45
|
+
type RequesterContext,
|
|
46
|
+
} from '../../../base-classes/tenant-context';
|
|
47
|
+
import { AUTH_USER_CONTEXT } from '../auth.tokens';
|
|
48
|
+
import type { IUserContext } from '../protocols/user-context';
|
|
49
|
+
|
|
50
|
+
/** Minimal Express-style middleware signature (avoids an `express` dep). */
|
|
51
|
+
type NextFn = (err?: unknown) => void;
|
|
52
|
+
type RequestHandler = (req: unknown, res: unknown, next: NextFn) => void;
|
|
53
|
+
|
|
54
|
+
export interface RequesterContextOptions {
|
|
55
|
+
/**
|
|
56
|
+
* What to do when `IUserContext` cannot resolve a requester (throws, or
|
|
57
|
+
* returns no `userId`).
|
|
58
|
+
* - `'unscoped'` (default): proceed without a context — public routes work;
|
|
59
|
+
* scoped repos run unscoped (lenient) or throw downstream (strict).
|
|
60
|
+
* - `'reject'`: fail the request at the boundary (`next(error)`).
|
|
61
|
+
*/
|
|
62
|
+
onUnresolved?: 'unscoped' | 'reject';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Resolve the ambient context for a request: prefer the richer
|
|
67
|
+
* `resolveRequester` (org/superuser), else derive plain `'user'` scope from
|
|
68
|
+
* `getCurrentUserId`. Returns `undefined` when no requester can be determined.
|
|
69
|
+
*/
|
|
70
|
+
export async function resolveRequesterContext(
|
|
71
|
+
userContext: IUserContext,
|
|
72
|
+
req: unknown,
|
|
73
|
+
): Promise<RequesterContext | undefined> {
|
|
74
|
+
if (typeof userContext.resolveRequester === 'function') {
|
|
75
|
+
const ctx = await userContext.resolveRequester(req);
|
|
76
|
+
return ctx?.userId ? ctx : undefined;
|
|
77
|
+
}
|
|
78
|
+
const userId = await userContext.getCurrentUserId(req);
|
|
79
|
+
return userId ? { userId, organizationId: null } : undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Build the global middleware. Runs the remainder of the request inside
|
|
84
|
+
* `withRequester(...)` so the ambient context propagates through every `await`
|
|
85
|
+
* to downstream repositories.
|
|
86
|
+
*/
|
|
87
|
+
export function makeRequesterContextMiddleware(
|
|
88
|
+
userContext: IUserContext,
|
|
89
|
+
options: RequesterContextOptions = {},
|
|
90
|
+
): RequestHandler {
|
|
91
|
+
const onUnresolved = options.onUnresolved ?? 'unscoped';
|
|
92
|
+
return (req, _res, next) => {
|
|
93
|
+
resolveRequesterContext(userContext, req).then(
|
|
94
|
+
(ctx) => {
|
|
95
|
+
if (!ctx) {
|
|
96
|
+
next();
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
// als.run executes its callback synchronously; Express dispatches the
|
|
100
|
+
// rest of the pipeline inside next(), so all downstream handlers (and
|
|
101
|
+
// their awaits) inherit this context.
|
|
102
|
+
withRequester(ctx, async () => {
|
|
103
|
+
next();
|
|
104
|
+
});
|
|
105
|
+
},
|
|
106
|
+
(err) => {
|
|
107
|
+
if (onUnresolved === 'reject') {
|
|
108
|
+
next(err);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
next();
|
|
112
|
+
},
|
|
113
|
+
);
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Register the requester-context boundary on a Nest app. Resolves
|
|
119
|
+
* `AUTH_USER_CONTEXT` from the root container (so it sees the consumer's
|
|
120
|
+
* AppModule binding) and installs the global middleware. No-ops with a warning
|
|
121
|
+
* when `AUTH_USER_CONTEXT` is not bound, so calling it unconditionally in
|
|
122
|
+
* bootstrap is safe.
|
|
123
|
+
*/
|
|
124
|
+
export function installRequesterContext(
|
|
125
|
+
app: INestApplication,
|
|
126
|
+
options: RequesterContextOptions = {},
|
|
127
|
+
): void {
|
|
128
|
+
const userContext = app.get<IUserContext>(AUTH_USER_CONTEXT, {
|
|
129
|
+
strict: false,
|
|
130
|
+
});
|
|
131
|
+
if (!userContext) {
|
|
132
|
+
// eslint-disable-next-line no-console
|
|
133
|
+
console.warn(
|
|
134
|
+
'[auth] installRequesterContext: AUTH_USER_CONTEXT is not bound — ' +
|
|
135
|
+
'request scoping NOT installed. Provide an IUserContext under ' +
|
|
136
|
+
'AUTH_USER_CONTEXT in your AppModule to enable ambient tenant scoping.',
|
|
137
|
+
);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
app.use(makeRequesterContextMiddleware(userContext, options));
|
|
141
|
+
}
|
|
@@ -17,6 +17,23 @@
|
|
|
17
17
|
* dependency on `express` / `fastify` / NestJS request types. The concrete
|
|
18
18
|
* adapter narrows it (e.g. via a `Request` import).
|
|
19
19
|
*/
|
|
20
|
+
import type { RequesterContext } from '../../../base-classes/tenant-context';
|
|
21
|
+
|
|
20
22
|
export interface IUserContext {
|
|
21
23
|
getCurrentUserId(req: unknown): Promise<string>;
|
|
24
|
+
/**
|
|
25
|
+
* Optional richer resolution of the full ambient requester context — the
|
|
26
|
+
* org/superuser dimensions on top of `userId`. When implemented, the
|
|
27
|
+
* `RequesterContextMiddleware` (see `../middleware/requester-context`) uses
|
|
28
|
+
* it verbatim to scope reads/writes; when omitted, the middleware falls back
|
|
29
|
+
* to `{ userId: await getCurrentUserId(req), organizationId: null }` (plain
|
|
30
|
+
* `'user'` scope).
|
|
31
|
+
*
|
|
32
|
+
* Implement this when the app supports org-shared (`'org'`) or admin
|
|
33
|
+
* (`'superuser'`) data visibility — resolve `organizationId` + the
|
|
34
|
+
* `orgUserIds` member list here, at the trust boundary, so repositories stay
|
|
35
|
+
* single-table. AUTHORIZATION (which scope a requester may claim) is the
|
|
36
|
+
* implementation's responsibility; the repo trusts what this returns.
|
|
37
|
+
*/
|
|
38
|
+
resolveRequester?(req: unknown): Promise<RequesterContext>;
|
|
22
39
|
}
|