@hazeljs/auth 0.2.0-beta.57 → 0.2.0-beta.59
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/README.md +344 -329
- package/dist/auth.test.js +351 -0
- package/dist/decorators/current-user.decorator.d.ts +26 -0
- package/dist/decorators/current-user.decorator.d.ts.map +1 -0
- package/dist/decorators/current-user.decorator.js +39 -0
- package/dist/guards/jwt-auth.guard.d.ts +24 -0
- package/dist/guards/jwt-auth.guard.d.ts.map +1 -0
- package/dist/guards/jwt-auth.guard.js +61 -0
- package/dist/guards/role.guard.d.ts +36 -0
- package/dist/guards/role.guard.d.ts.map +1 -0
- package/dist/guards/role.guard.js +66 -0
- package/dist/guards/tenant.guard.d.ts +54 -0
- package/dist/guards/tenant.guard.d.ts.map +1 -0
- package/dist/guards/tenant.guard.js +96 -0
- package/dist/index.d.ts +11 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +15 -1
- package/dist/tenant/tenant-context.d.ts +81 -0
- package/dist/tenant/tenant-context.d.ts.map +1 -0
- package/dist/tenant/tenant-context.js +108 -0
- package/dist/utils/role-hierarchy.d.ts +42 -0
- package/dist/utils/role-hierarchy.d.ts.map +1 -0
- package/dist/utils/role-hierarchy.js +57 -0
- package/package.json +2 -2
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { CanActivate, Type } from '@hazeljs/core';
|
|
2
|
+
export interface TenantGuardOptions {
|
|
3
|
+
/**
|
|
4
|
+
* Where to read the expected tenant ID from the incoming request.
|
|
5
|
+
*
|
|
6
|
+
* - `'param'` — URL segment, e.g. `/orgs/:tenantId/products` (default)
|
|
7
|
+
* - `'header'` — HTTP header, e.g. `X-Tenant-ID`
|
|
8
|
+
* - `'query'` — Query string, e.g. `?tenantId=acme`
|
|
9
|
+
*
|
|
10
|
+
* @default 'param'
|
|
11
|
+
*/
|
|
12
|
+
source?: 'param' | 'header' | 'query';
|
|
13
|
+
/**
|
|
14
|
+
* Name of the param / header / query key that carries the tenant ID.
|
|
15
|
+
* @default 'tenantId'
|
|
16
|
+
*/
|
|
17
|
+
key?: string;
|
|
18
|
+
/**
|
|
19
|
+
* Field on the authenticated user object that holds the user's tenant ID.
|
|
20
|
+
* @default 'tenantId'
|
|
21
|
+
*/
|
|
22
|
+
userField?: string;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Factory that returns a guard enforcing tenant-level isolation.
|
|
26
|
+
*
|
|
27
|
+
* Compares the tenant ID carried by the request (from a URL param, header,
|
|
28
|
+
* or query param) against the tenant ID stored on the authenticated user
|
|
29
|
+
* (from the JWT payload). Returns 403 if they do not match.
|
|
30
|
+
*
|
|
31
|
+
* Must be used after JwtAuthGuard so that `req.user` is populated.
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* ```ts
|
|
35
|
+
* // URL param: GET /orgs/:tenantId/invoices
|
|
36
|
+
* @UseGuards(JwtAuthGuard, TenantGuard())
|
|
37
|
+
* @Controller('/orgs/:tenantId/invoices')
|
|
38
|
+
* export class InvoicesController {}
|
|
39
|
+
*
|
|
40
|
+
* // Header: X-Org-ID
|
|
41
|
+
* @UseGuards(JwtAuthGuard, TenantGuard({ source: 'header', key: 'x-org-id' }))
|
|
42
|
+
* @Controller('/invoices')
|
|
43
|
+
* export class InvoicesController {}
|
|
44
|
+
*
|
|
45
|
+
* // Superadmins bypass tenant check:
|
|
46
|
+
* @UseGuards(JwtAuthGuard, TenantGuard({ bypassRoles: ['superadmin'] }))
|
|
47
|
+
* @Controller('/orgs/:tenantId/invoices')
|
|
48
|
+
* export class InvoicesController {}
|
|
49
|
+
* ```
|
|
50
|
+
*/
|
|
51
|
+
export declare function TenantGuard(options?: TenantGuardOptions & {
|
|
52
|
+
bypassRoles?: string[];
|
|
53
|
+
}): Type<CanActivate>;
|
|
54
|
+
//# sourceMappingURL=tenant.guard.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tenant.guard.d.ts","sourceRoot":"","sources":["../../src/guards/tenant.guard.ts"],"names":[],"mappings":"AAAA,OAAO,EAAc,WAAW,EAAoB,IAAI,EAAkB,MAAM,eAAe,CAAC;AAIhG,MAAM,WAAW,kBAAkB;IACjC;;;;;;;;OAQG;IACH,MAAM,CAAC,EAAE,OAAO,GAAG,QAAQ,GAAG,OAAO,CAAC;IAEtC;;;OAGG;IACH,GAAG,CAAC,EAAE,MAAM,CAAC;IAEb;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,wBAAgB,WAAW,CACzB,OAAO,GAAE,kBAAkB,GAAG;IAAE,WAAW,CAAC,EAAE,MAAM,EAAE,CAAA;CAAO,GAC5D,IAAI,CAAC,WAAW,CAAC,CAsEnB"}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
3
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
4
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
5
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
6
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
7
|
+
};
|
|
8
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
+
exports.TenantGuard = TenantGuard;
|
|
10
|
+
const core_1 = require("@hazeljs/core");
|
|
11
|
+
const tenant_context_1 = require("../tenant/tenant-context");
|
|
12
|
+
/**
|
|
13
|
+
* Factory that returns a guard enforcing tenant-level isolation.
|
|
14
|
+
*
|
|
15
|
+
* Compares the tenant ID carried by the request (from a URL param, header,
|
|
16
|
+
* or query param) against the tenant ID stored on the authenticated user
|
|
17
|
+
* (from the JWT payload). Returns 403 if they do not match.
|
|
18
|
+
*
|
|
19
|
+
* Must be used after JwtAuthGuard so that `req.user` is populated.
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* ```ts
|
|
23
|
+
* // URL param: GET /orgs/:tenantId/invoices
|
|
24
|
+
* @UseGuards(JwtAuthGuard, TenantGuard())
|
|
25
|
+
* @Controller('/orgs/:tenantId/invoices')
|
|
26
|
+
* export class InvoicesController {}
|
|
27
|
+
*
|
|
28
|
+
* // Header: X-Org-ID
|
|
29
|
+
* @UseGuards(JwtAuthGuard, TenantGuard({ source: 'header', key: 'x-org-id' }))
|
|
30
|
+
* @Controller('/invoices')
|
|
31
|
+
* export class InvoicesController {}
|
|
32
|
+
*
|
|
33
|
+
* // Superadmins bypass tenant check:
|
|
34
|
+
* @UseGuards(JwtAuthGuard, TenantGuard({ bypassRoles: ['superadmin'] }))
|
|
35
|
+
* @Controller('/orgs/:tenantId/invoices')
|
|
36
|
+
* export class InvoicesController {}
|
|
37
|
+
* ```
|
|
38
|
+
*/
|
|
39
|
+
function TenantGuard(options = {}) {
|
|
40
|
+
const { source = 'param', key = 'tenantId', userField = 'tenantId', bypassRoles = [] } = options;
|
|
41
|
+
let TenantGuardMixin = class TenantGuardMixin {
|
|
42
|
+
canActivate(context) {
|
|
43
|
+
const req = context.switchToHttp().getRequest();
|
|
44
|
+
const ctx = context.switchToHttp().getContext();
|
|
45
|
+
const user = req.user;
|
|
46
|
+
if (!user) {
|
|
47
|
+
const err = Object.assign(new Error('Unauthorized'), { status: 401 });
|
|
48
|
+
throw err;
|
|
49
|
+
}
|
|
50
|
+
// Privileged roles can bypass the tenant check entirely.
|
|
51
|
+
// Still seed the context so their own queries are naturally scoped.
|
|
52
|
+
if (bypassRoles.includes(user.role)) {
|
|
53
|
+
const bypassTenantId = user[userField];
|
|
54
|
+
if (bypassTenantId)
|
|
55
|
+
tenant_context_1.TenantContext.enterWith(bypassTenantId);
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
const userTenantId = user[userField];
|
|
59
|
+
if (!userTenantId) {
|
|
60
|
+
const err = Object.assign(new Error(`User is not associated with any tenant (missing "${userField}")`), { status: 403 });
|
|
61
|
+
throw err;
|
|
62
|
+
}
|
|
63
|
+
// Extract the request-level tenant ID from the configured source.
|
|
64
|
+
let requestTenantId;
|
|
65
|
+
switch (source) {
|
|
66
|
+
case 'param':
|
|
67
|
+
requestTenantId = ctx.params?.[key];
|
|
68
|
+
break;
|
|
69
|
+
case 'header':
|
|
70
|
+
requestTenantId = ctx.headers?.[key.toLowerCase()];
|
|
71
|
+
break;
|
|
72
|
+
case 'query':
|
|
73
|
+
requestTenantId = ctx.query?.[key];
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
if (!requestTenantId) {
|
|
77
|
+
const err = Object.assign(new Error(`Tenant ID not found in request ${source} "${key}"`), {
|
|
78
|
+
status: 400,
|
|
79
|
+
});
|
|
80
|
+
throw err;
|
|
81
|
+
}
|
|
82
|
+
if (userTenantId !== requestTenantId) {
|
|
83
|
+
const err = Object.assign(new Error('Access denied: resource belongs to a different tenant'), { status: 403 });
|
|
84
|
+
throw err;
|
|
85
|
+
}
|
|
86
|
+
// Seed AsyncLocalStorage so repositories can call tenantCtx.requireId()
|
|
87
|
+
// without needing tenantId passed through every function parameter.
|
|
88
|
+
tenant_context_1.TenantContext.enterWith(userTenantId);
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
TenantGuardMixin = __decorate([
|
|
93
|
+
(0, core_1.Injectable)()
|
|
94
|
+
], TenantGuardMixin);
|
|
95
|
+
return TenantGuardMixin;
|
|
96
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,8 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @hazeljs/auth - Authentication module for HazelJS
|
|
3
3
|
*/
|
|
4
|
-
export { AuthGuard } from './auth.guard';
|
|
4
|
+
export { AuthGuard, Auth } from './auth.guard';
|
|
5
5
|
export { AuthService } from './auth.service';
|
|
6
|
+
export type { AuthUser } from './auth.service';
|
|
6
7
|
export { JwtModule } from './jwt/jwt.module';
|
|
7
8
|
export { JwtService } from './jwt/jwt.service';
|
|
9
|
+
export { JwtAuthGuard } from './guards/jwt-auth.guard';
|
|
10
|
+
export { RoleGuard } from './guards/role.guard';
|
|
11
|
+
export type { RoleGuardOptions } from './guards/role.guard';
|
|
12
|
+
export { TenantGuard } from './guards/tenant.guard';
|
|
13
|
+
export type { TenantGuardOptions } from './guards/tenant.guard';
|
|
14
|
+
export { TenantContext } from './tenant/tenant-context';
|
|
15
|
+
export { RoleHierarchy, DEFAULT_ROLE_HIERARCHY } from './utils/role-hierarchy';
|
|
16
|
+
export type { RoleHierarchyMap } from './utils/role-hierarchy';
|
|
17
|
+
export { CurrentUser } from './decorators/current-user.decorator';
|
|
8
18
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EAAE,SAAS,EAAE,IAAI,EAAE,MAAM,cAAc,CAAC;AAC/C,OAAO,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAC;AAC7C,YAAY,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAC/C,OAAO,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAC7C,OAAO,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AAC/C,OAAO,EAAE,YAAY,EAAE,MAAM,yBAAyB,CAAC;AACvD,OAAO,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAC;AAChD,YAAY,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AAC5D,OAAO,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAC;AACpD,YAAY,EAAE,kBAAkB,EAAE,MAAM,uBAAuB,CAAC;AAChE,OAAO,EAAE,aAAa,EAAE,MAAM,yBAAyB,CAAC;AACxD,OAAO,EAAE,aAAa,EAAE,sBAAsB,EAAE,MAAM,wBAAwB,CAAC;AAC/E,YAAY,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAC/D,OAAO,EAAE,WAAW,EAAE,MAAM,qCAAqC,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -3,12 +3,26 @@
|
|
|
3
3
|
* @hazeljs/auth - Authentication module for HazelJS
|
|
4
4
|
*/
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
exports.JwtService = exports.JwtModule = exports.AuthService = exports.AuthGuard = void 0;
|
|
6
|
+
exports.CurrentUser = exports.DEFAULT_ROLE_HIERARCHY = exports.RoleHierarchy = exports.TenantContext = exports.TenantGuard = exports.RoleGuard = exports.JwtAuthGuard = exports.JwtService = exports.JwtModule = exports.AuthService = exports.Auth = exports.AuthGuard = void 0;
|
|
7
7
|
var auth_guard_1 = require("./auth.guard");
|
|
8
8
|
Object.defineProperty(exports, "AuthGuard", { enumerable: true, get: function () { return auth_guard_1.AuthGuard; } });
|
|
9
|
+
Object.defineProperty(exports, "Auth", { enumerable: true, get: function () { return auth_guard_1.Auth; } });
|
|
9
10
|
var auth_service_1 = require("./auth.service");
|
|
10
11
|
Object.defineProperty(exports, "AuthService", { enumerable: true, get: function () { return auth_service_1.AuthService; } });
|
|
11
12
|
var jwt_module_1 = require("./jwt/jwt.module");
|
|
12
13
|
Object.defineProperty(exports, "JwtModule", { enumerable: true, get: function () { return jwt_module_1.JwtModule; } });
|
|
13
14
|
var jwt_service_1 = require("./jwt/jwt.service");
|
|
14
15
|
Object.defineProperty(exports, "JwtService", { enumerable: true, get: function () { return jwt_service_1.JwtService; } });
|
|
16
|
+
var jwt_auth_guard_1 = require("./guards/jwt-auth.guard");
|
|
17
|
+
Object.defineProperty(exports, "JwtAuthGuard", { enumerable: true, get: function () { return jwt_auth_guard_1.JwtAuthGuard; } });
|
|
18
|
+
var role_guard_1 = require("./guards/role.guard");
|
|
19
|
+
Object.defineProperty(exports, "RoleGuard", { enumerable: true, get: function () { return role_guard_1.RoleGuard; } });
|
|
20
|
+
var tenant_guard_1 = require("./guards/tenant.guard");
|
|
21
|
+
Object.defineProperty(exports, "TenantGuard", { enumerable: true, get: function () { return tenant_guard_1.TenantGuard; } });
|
|
22
|
+
var tenant_context_1 = require("./tenant/tenant-context");
|
|
23
|
+
Object.defineProperty(exports, "TenantContext", { enumerable: true, get: function () { return tenant_context_1.TenantContext; } });
|
|
24
|
+
var role_hierarchy_1 = require("./utils/role-hierarchy");
|
|
25
|
+
Object.defineProperty(exports, "RoleHierarchy", { enumerable: true, get: function () { return role_hierarchy_1.RoleHierarchy; } });
|
|
26
|
+
Object.defineProperty(exports, "DEFAULT_ROLE_HIERARCHY", { enumerable: true, get: function () { return role_hierarchy_1.DEFAULT_ROLE_HIERARCHY; } });
|
|
27
|
+
var current_user_decorator_1 = require("./decorators/current-user.decorator");
|
|
28
|
+
Object.defineProperty(exports, "CurrentUser", { enumerable: true, get: function () { return current_user_decorator_1.CurrentUser; } });
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provides request-scoped tenant context via AsyncLocalStorage.
|
|
3
|
+
*
|
|
4
|
+
* ─── Why two layers? ──────────────────────────────────────────────────────────
|
|
5
|
+
* TenantGuard enforces isolation at the HTTP layer — it rejects requests where
|
|
6
|
+
* the JWT tenant doesn't match the route tenant. That's necessary but not
|
|
7
|
+
* sufficient: a bug in service code could still query another tenant's rows if
|
|
8
|
+
* the guard is misconfigured or skipped.
|
|
9
|
+
*
|
|
10
|
+
* TenantContext closes that gap at the DATA layer. After the guard validates
|
|
11
|
+
* the request, it calls TenantContext.enterWith(tenantId) which seeds an
|
|
12
|
+
* AsyncLocalStorage store for the remainder of the request's async call chain.
|
|
13
|
+
* Every repository/service that injects TenantContext can then call
|
|
14
|
+
* requireId() without receiving tenantId as a function parameter.
|
|
15
|
+
*
|
|
16
|
+
* ─── Usage in a repository ───────────────────────────────────────────────────
|
|
17
|
+
* ```ts
|
|
18
|
+
* @Service()
|
|
19
|
+
* export class OrdersRepository {
|
|
20
|
+
* constructor(private readonly tenantCtx: TenantContext) {}
|
|
21
|
+
*
|
|
22
|
+
* findAll() {
|
|
23
|
+
* const tenantId = this.tenantCtx.requireId();
|
|
24
|
+
* return db.query('SELECT * FROM orders WHERE tenant_id = $1', [tenantId]);
|
|
25
|
+
* }
|
|
26
|
+
*
|
|
27
|
+
* findById(id: string) {
|
|
28
|
+
* const tenantId = this.tenantCtx.requireId();
|
|
29
|
+
* // Even a direct ID lookup is scoped — prevents horizontal privilege escalation
|
|
30
|
+
* return db.query(
|
|
31
|
+
* 'SELECT * FROM orders WHERE id = $1 AND tenant_id = $2',
|
|
32
|
+
* [id, tenantId]
|
|
33
|
+
* );
|
|
34
|
+
* }
|
|
35
|
+
* }
|
|
36
|
+
* ```
|
|
37
|
+
*
|
|
38
|
+
* ─── How it propagates ────────────────────────────────────────────────────────
|
|
39
|
+
* Node.js AsyncLocalStorage propagates through the async call graph.
|
|
40
|
+
* TenantGuard calls enterWith() during guard execution; because the route
|
|
41
|
+
* handler is called afterwards in the same async chain, it (and everything
|
|
42
|
+
* it awaits) automatically has access to the stored tenant ID.
|
|
43
|
+
*/
|
|
44
|
+
export declare class TenantContext {
|
|
45
|
+
/**
|
|
46
|
+
* Returns the current tenant ID, or `undefined` if called outside a
|
|
47
|
+
* request context (e.g. during startup or in a background job).
|
|
48
|
+
*/
|
|
49
|
+
getId(): string | undefined;
|
|
50
|
+
/**
|
|
51
|
+
* Returns the current tenant ID and throws if it is not set.
|
|
52
|
+
*
|
|
53
|
+
* Use this in any repository or service method that must never run without
|
|
54
|
+
* a tenant — it acts as a last-resort safety net even if the guard was
|
|
55
|
+
* accidentally omitted on a route.
|
|
56
|
+
*/
|
|
57
|
+
requireId(): string;
|
|
58
|
+
/**
|
|
59
|
+
* Seeds the current async context with the given tenant ID.
|
|
60
|
+
*
|
|
61
|
+
* Called internally by TenantGuard after it validates the request.
|
|
62
|
+
* Unlike `run()`, `enterWith` propagates through the rest of the current
|
|
63
|
+
* async execution chain without requiring a wrapping callback — which
|
|
64
|
+
* makes it the right tool for seeding from within a guard.
|
|
65
|
+
*/
|
|
66
|
+
static enterWith(tenantId: string): void;
|
|
67
|
+
/**
|
|
68
|
+
* Wraps a callback so that everything inside it (and all async operations
|
|
69
|
+
* it spawns) runs with the given tenant ID in context.
|
|
70
|
+
*
|
|
71
|
+
* Use this when you need explicit scoping, e.g. in background jobs or tests:
|
|
72
|
+
*
|
|
73
|
+
* ```ts
|
|
74
|
+
* await TenantContext.run('acme', async () => {
|
|
75
|
+
* await ordersService.processPendingOrders();
|
|
76
|
+
* });
|
|
77
|
+
* ```
|
|
78
|
+
*/
|
|
79
|
+
static run<T>(tenantId: string, fn: () => T): T;
|
|
80
|
+
}
|
|
81
|
+
//# sourceMappingURL=tenant-context.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tenant-context.d.ts","sourceRoot":"","sources":["../../src/tenant/tenant-context.ts"],"names":[],"mappings":"AASA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0CG;AACH,qBACa,aAAa;IACxB;;;OAGG;IACH,KAAK,IAAI,MAAM,GAAG,SAAS;IAI3B;;;;;;OAMG;IACH,SAAS,IAAI,MAAM;IAanB;;;;;;;OAOG;IACH,MAAM,CAAC,SAAS,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI;IAIxC;;;;;;;;;;;OAWG;IACH,MAAM,CAAC,GAAG,CAAC,CAAC,EAAE,QAAQ,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,CAAC,GAAG,CAAC;CAGhD"}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
3
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
4
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
5
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
6
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
7
|
+
};
|
|
8
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
+
exports.TenantContext = void 0;
|
|
10
|
+
const async_hooks_1 = require("async_hooks");
|
|
11
|
+
const core_1 = require("@hazeljs/core");
|
|
12
|
+
const storage = new async_hooks_1.AsyncLocalStorage();
|
|
13
|
+
/**
|
|
14
|
+
* Provides request-scoped tenant context via AsyncLocalStorage.
|
|
15
|
+
*
|
|
16
|
+
* ─── Why two layers? ──────────────────────────────────────────────────────────
|
|
17
|
+
* TenantGuard enforces isolation at the HTTP layer — it rejects requests where
|
|
18
|
+
* the JWT tenant doesn't match the route tenant. That's necessary but not
|
|
19
|
+
* sufficient: a bug in service code could still query another tenant's rows if
|
|
20
|
+
* the guard is misconfigured or skipped.
|
|
21
|
+
*
|
|
22
|
+
* TenantContext closes that gap at the DATA layer. After the guard validates
|
|
23
|
+
* the request, it calls TenantContext.enterWith(tenantId) which seeds an
|
|
24
|
+
* AsyncLocalStorage store for the remainder of the request's async call chain.
|
|
25
|
+
* Every repository/service that injects TenantContext can then call
|
|
26
|
+
* requireId() without receiving tenantId as a function parameter.
|
|
27
|
+
*
|
|
28
|
+
* ─── Usage in a repository ───────────────────────────────────────────────────
|
|
29
|
+
* ```ts
|
|
30
|
+
* @Service()
|
|
31
|
+
* export class OrdersRepository {
|
|
32
|
+
* constructor(private readonly tenantCtx: TenantContext) {}
|
|
33
|
+
*
|
|
34
|
+
* findAll() {
|
|
35
|
+
* const tenantId = this.tenantCtx.requireId();
|
|
36
|
+
* return db.query('SELECT * FROM orders WHERE tenant_id = $1', [tenantId]);
|
|
37
|
+
* }
|
|
38
|
+
*
|
|
39
|
+
* findById(id: string) {
|
|
40
|
+
* const tenantId = this.tenantCtx.requireId();
|
|
41
|
+
* // Even a direct ID lookup is scoped — prevents horizontal privilege escalation
|
|
42
|
+
* return db.query(
|
|
43
|
+
* 'SELECT * FROM orders WHERE id = $1 AND tenant_id = $2',
|
|
44
|
+
* [id, tenantId]
|
|
45
|
+
* );
|
|
46
|
+
* }
|
|
47
|
+
* }
|
|
48
|
+
* ```
|
|
49
|
+
*
|
|
50
|
+
* ─── How it propagates ────────────────────────────────────────────────────────
|
|
51
|
+
* Node.js AsyncLocalStorage propagates through the async call graph.
|
|
52
|
+
* TenantGuard calls enterWith() during guard execution; because the route
|
|
53
|
+
* handler is called afterwards in the same async chain, it (and everything
|
|
54
|
+
* it awaits) automatically has access to the stored tenant ID.
|
|
55
|
+
*/
|
|
56
|
+
let TenantContext = class TenantContext {
|
|
57
|
+
/**
|
|
58
|
+
* Returns the current tenant ID, or `undefined` if called outside a
|
|
59
|
+
* request context (e.g. during startup or in a background job).
|
|
60
|
+
*/
|
|
61
|
+
getId() {
|
|
62
|
+
return storage.getStore()?.tenantId;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Returns the current tenant ID and throws if it is not set.
|
|
66
|
+
*
|
|
67
|
+
* Use this in any repository or service method that must never run without
|
|
68
|
+
* a tenant — it acts as a last-resort safety net even if the guard was
|
|
69
|
+
* accidentally omitted on a route.
|
|
70
|
+
*/
|
|
71
|
+
requireId() {
|
|
72
|
+
const id = this.getId();
|
|
73
|
+
if (!id) {
|
|
74
|
+
throw Object.assign(new Error('TenantContext: no tenant ID found. ' + 'Ensure TenantGuard is applied to the route.'), { status: 500 });
|
|
75
|
+
}
|
|
76
|
+
return id;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Seeds the current async context with the given tenant ID.
|
|
80
|
+
*
|
|
81
|
+
* Called internally by TenantGuard after it validates the request.
|
|
82
|
+
* Unlike `run()`, `enterWith` propagates through the rest of the current
|
|
83
|
+
* async execution chain without requiring a wrapping callback — which
|
|
84
|
+
* makes it the right tool for seeding from within a guard.
|
|
85
|
+
*/
|
|
86
|
+
static enterWith(tenantId) {
|
|
87
|
+
storage.enterWith({ tenantId });
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Wraps a callback so that everything inside it (and all async operations
|
|
91
|
+
* it spawns) runs with the given tenant ID in context.
|
|
92
|
+
*
|
|
93
|
+
* Use this when you need explicit scoping, e.g. in background jobs or tests:
|
|
94
|
+
*
|
|
95
|
+
* ```ts
|
|
96
|
+
* await TenantContext.run('acme', async () => {
|
|
97
|
+
* await ordersService.processPendingOrders();
|
|
98
|
+
* });
|
|
99
|
+
* ```
|
|
100
|
+
*/
|
|
101
|
+
static run(tenantId, fn) {
|
|
102
|
+
return storage.run({ tenantId }, fn);
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
exports.TenantContext = TenantContext;
|
|
106
|
+
exports.TenantContext = TenantContext = __decorate([
|
|
107
|
+
(0, core_1.Injectable)()
|
|
108
|
+
], TenantContext);
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Describes which roles a given role "inherits".
|
|
3
|
+
*
|
|
4
|
+
* Example: `{ superadmin: ['admin'], admin: ['manager'], manager: ['user'] }`
|
|
5
|
+
* means superadmin implicitly satisfies any check for admin, manager, or user.
|
|
6
|
+
*/
|
|
7
|
+
export type RoleHierarchyMap = Record<string, string[]>;
|
|
8
|
+
/**
|
|
9
|
+
* A sensible default hierarchy for most applications.
|
|
10
|
+
* Override by passing your own map to RoleHierarchy or to RoleGuard.
|
|
11
|
+
*
|
|
12
|
+
* superadmin → admin → manager → user
|
|
13
|
+
*/
|
|
14
|
+
export declare const DEFAULT_ROLE_HIERARCHY: RoleHierarchyMap;
|
|
15
|
+
/**
|
|
16
|
+
* Resolves inherited roles so that a higher-level role implicitly satisfies
|
|
17
|
+
* requirements for any role it inherits.
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* ```ts
|
|
21
|
+
* const h = new RoleHierarchy({ admin: ['editor'], editor: ['viewer'] });
|
|
22
|
+
*
|
|
23
|
+
* h.satisfies('admin', 'viewer') // true — admin → editor → viewer
|
|
24
|
+
* h.satisfies('editor', 'admin') // false — editor does not inherit admin
|
|
25
|
+
* h.resolve('admin') // Set { 'admin', 'editor', 'viewer' }
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
export declare class RoleHierarchy {
|
|
29
|
+
private readonly map;
|
|
30
|
+
constructor(map?: RoleHierarchyMap);
|
|
31
|
+
/**
|
|
32
|
+
* Returns true if `userRole` satisfies the `requiredRole` check, either
|
|
33
|
+
* directly (same role) or via inheritance.
|
|
34
|
+
*/
|
|
35
|
+
satisfies(userRole: string, requiredRole: string): boolean;
|
|
36
|
+
/**
|
|
37
|
+
* Returns the complete set of roles that `role` covers (itself plus all
|
|
38
|
+
* transitively inherited roles).
|
|
39
|
+
*/
|
|
40
|
+
resolve(role: string): Set<string>;
|
|
41
|
+
}
|
|
42
|
+
//# sourceMappingURL=role-hierarchy.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"role-hierarchy.d.ts","sourceRoot":"","sources":["../../src/utils/role-hierarchy.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,MAAM,MAAM,gBAAgB,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,CAAC;AAExD;;;;;GAKG;AACH,eAAO,MAAM,sBAAsB,EAAE,gBAKpC,CAAC;AAEF;;;;;;;;;;;;GAYG;AACH,qBAAa,aAAa;IACZ,OAAO,CAAC,QAAQ,CAAC,GAAG;gBAAH,GAAG,GAAE,gBAAyC;IAE3E;;;OAGG;IACH,SAAS,CAAC,QAAQ,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,OAAO;IAI1D;;;OAGG;IACH,OAAO,CAAC,IAAI,EAAE,MAAM,GAAG,GAAG,CAAC,MAAM,CAAC;CAcnC"}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.RoleHierarchy = exports.DEFAULT_ROLE_HIERARCHY = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* A sensible default hierarchy for most applications.
|
|
6
|
+
* Override by passing your own map to RoleHierarchy or to RoleGuard.
|
|
7
|
+
*
|
|
8
|
+
* superadmin → admin → manager → user
|
|
9
|
+
*/
|
|
10
|
+
exports.DEFAULT_ROLE_HIERARCHY = {
|
|
11
|
+
superadmin: ['admin'],
|
|
12
|
+
admin: ['manager'],
|
|
13
|
+
manager: ['user'],
|
|
14
|
+
user: [],
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Resolves inherited roles so that a higher-level role implicitly satisfies
|
|
18
|
+
* requirements for any role it inherits.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* ```ts
|
|
22
|
+
* const h = new RoleHierarchy({ admin: ['editor'], editor: ['viewer'] });
|
|
23
|
+
*
|
|
24
|
+
* h.satisfies('admin', 'viewer') // true — admin → editor → viewer
|
|
25
|
+
* h.satisfies('editor', 'admin') // false — editor does not inherit admin
|
|
26
|
+
* h.resolve('admin') // Set { 'admin', 'editor', 'viewer' }
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
class RoleHierarchy {
|
|
30
|
+
constructor(map = exports.DEFAULT_ROLE_HIERARCHY) {
|
|
31
|
+
this.map = map;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Returns true if `userRole` satisfies the `requiredRole` check, either
|
|
35
|
+
* directly (same role) or via inheritance.
|
|
36
|
+
*/
|
|
37
|
+
satisfies(userRole, requiredRole) {
|
|
38
|
+
return this.resolve(userRole).has(requiredRole);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Returns the complete set of roles that `role` covers (itself plus all
|
|
42
|
+
* transitively inherited roles).
|
|
43
|
+
*/
|
|
44
|
+
resolve(role) {
|
|
45
|
+
const visited = new Set();
|
|
46
|
+
const queue = [role];
|
|
47
|
+
while (queue.length > 0) {
|
|
48
|
+
const current = queue.shift();
|
|
49
|
+
if (!visited.has(current)) {
|
|
50
|
+
visited.add(current);
|
|
51
|
+
(this.map[current] ?? []).forEach((child) => queue.push(child));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return visited;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
exports.RoleHierarchy = RoleHierarchy;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hazeljs/auth",
|
|
3
|
-
"version": "0.2.0-beta.
|
|
3
|
+
"version": "0.2.0-beta.59",
|
|
4
4
|
"description": "Authentication and JWT module for HazelJS framework",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -51,5 +51,5 @@
|
|
|
51
51
|
"peerDependencies": {
|
|
52
52
|
"@hazeljs/core": ">=0.2.0-beta.0"
|
|
53
53
|
},
|
|
54
|
-
"gitHead": "
|
|
54
|
+
"gitHead": "f6d8ee8162a40e2298ccce46d843269838bbe6ff"
|
|
55
55
|
}
|