serverless-event-orchestrator 1.2.6 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/dispatcher.d.ts.map +1 -1
- package/dist/dispatcher.js +30 -7
- package/dist/dispatcher.js.map +1 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17 -1
- package/dist/index.js.map +1 -1
- package/dist/middleware/crm-guard.d.ts +25 -0
- package/dist/middleware/crm-guard.d.ts.map +1 -0
- package/dist/middleware/crm-guard.js +45 -0
- package/dist/middleware/crm-guard.js.map +1 -0
- package/dist/middleware/index.d.ts +4 -0
- package/dist/middleware/index.d.ts.map +1 -0
- package/dist/middleware/index.js +10 -0
- package/dist/middleware/index.js.map +1 -0
- package/dist/middleware/init-tenant-context.d.ts +21 -0
- package/dist/middleware/init-tenant-context.d.ts.map +1 -0
- package/dist/middleware/init-tenant-context.js +50 -0
- package/dist/middleware/init-tenant-context.js.map +1 -0
- package/dist/middleware/tenant-guard.d.ts +26 -0
- package/dist/middleware/tenant-guard.d.ts.map +1 -0
- package/dist/middleware/tenant-guard.js +48 -0
- package/dist/middleware/tenant-guard.js.map +1 -0
- package/dist/tenant/TenantContext.d.ts +88 -0
- package/dist/tenant/TenantContext.d.ts.map +1 -0
- package/dist/tenant/TenantContext.js +110 -0
- package/dist/tenant/TenantContext.js.map +1 -0
- package/dist/tenant/helpers.d.ts +26 -0
- package/dist/tenant/helpers.d.ts.map +1 -0
- package/dist/tenant/helpers.js +86 -0
- package/dist/tenant/helpers.js.map +1 -0
- package/dist/tenant/index.d.ts +5 -0
- package/dist/tenant/index.d.ts.map +1 -0
- package/dist/tenant/index.js +14 -0
- package/dist/tenant/index.js.map +1 -0
- package/dist/tenant/types.d.ts +84 -0
- package/dist/tenant/types.d.ts.map +1 -0
- package/dist/tenant/types.js +29 -0
- package/dist/tenant/types.js.map +1 -0
- package/dist/types/routes.d.ts +2 -0
- package/dist/types/routes.d.ts.map +1 -1
- package/dist/utils/path-matcher.d.ts.map +1 -1
- package/dist/utils/path-matcher.js +6 -2
- package/dist/utils/path-matcher.js.map +1 -1
- package/package.json +11 -1
- package/src/dispatcher.ts +34 -9
- package/src/index.ts +30 -0
- package/src/middleware/crm-guard.ts +51 -0
- package/src/middleware/index.ts +3 -0
- package/src/middleware/init-tenant-context.ts +59 -0
- package/src/middleware/tenant-guard.ts +54 -0
- package/src/tenant/TenantContext.ts +115 -0
- package/src/tenant/helpers.ts +95 -0
- package/src/tenant/index.ts +21 -0
- package/src/tenant/types.ts +101 -0
- package/src/types/routes.ts +2 -0
- package/src/utils/path-matcher.ts +10 -5
- package/tests/middleware/crm-guard.test.ts +69 -0
- package/tests/middleware/init-tenant-context.test.ts +147 -0
- package/tests/middleware/tenant-guard.test.ts +100 -0
- package/tests/tenant/TenantContext.test.ts +134 -0
- package/tests/tenant/helpers.test.ts +122 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { NormalizedEvent, MiddlewareFn } from '../types/routes.js';
|
|
2
|
+
import {
|
|
3
|
+
TenantContext,
|
|
4
|
+
tenantInfoFromClaims,
|
|
5
|
+
tenantInfoFromHeaders,
|
|
6
|
+
type TenantInfo,
|
|
7
|
+
} from '../tenant/index.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Middleware that initializes the TenantContext from JWT claims or headers.
|
|
11
|
+
*
|
|
12
|
+
* Execution order: globalMiddleware (runs on ALL routes, before segment middleware).
|
|
13
|
+
*
|
|
14
|
+
* Resolution strategy:
|
|
15
|
+
* 1. Private/Backoffice segments → extract from identity.claims (JWT)
|
|
16
|
+
* 2. Internal segment → extract from headers (X-Tenant-Id, Lambda-to-Lambda)
|
|
17
|
+
* 3. Public segment → try claims first (authenticated public), then headers, then skip
|
|
18
|
+
*
|
|
19
|
+
* If tenant info is found:
|
|
20
|
+
* - Sets TenantContext via AsyncLocalStorage (for repositories)
|
|
21
|
+
* - Adds tenantInfo to event.context (for handlers and logger)
|
|
22
|
+
*
|
|
23
|
+
* If tenant info is NOT found:
|
|
24
|
+
* - Does NOT throw (public routes are allowed without tenant)
|
|
25
|
+
* - tenantGuard (segment middleware) will enforce tenant requirement where needed
|
|
26
|
+
*/
|
|
27
|
+
export const initTenantContext: MiddlewareFn = async (
|
|
28
|
+
event: NormalizedEvent
|
|
29
|
+
): Promise<NormalizedEvent> => {
|
|
30
|
+
let tenantInfo: TenantInfo | undefined;
|
|
31
|
+
|
|
32
|
+
// Strategy 1: From JWT claims (private, backoffice, or authenticated public)
|
|
33
|
+
if (event.context.identity?.claims) {
|
|
34
|
+
tenantInfo = tenantInfoFromClaims(event.context.identity.claims);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Strategy 2: From headers (internal Lambda-to-Lambda, or fallback)
|
|
38
|
+
if (!tenantInfo && event.payload.headers) {
|
|
39
|
+
tenantInfo = tenantInfoFromHeaders(event.payload.headers);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// If we found tenant info, set it everywhere
|
|
43
|
+
if (tenantInfo) {
|
|
44
|
+
// 1. Set AsyncLocalStorage (for TenantAwareDynamoRepository and ApiInvoker)
|
|
45
|
+
TenantContext.set(tenantInfo);
|
|
46
|
+
|
|
47
|
+
// 2. Enrich event.context (for handlers, logger, and downstream middleware)
|
|
48
|
+
return {
|
|
49
|
+
...event,
|
|
50
|
+
context: {
|
|
51
|
+
...event.context,
|
|
52
|
+
tenantInfo,
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// No tenant info found — this is normal for public routes
|
|
58
|
+
return event;
|
|
59
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { NormalizedEvent, MiddlewareFn } from '../types/routes.js';
|
|
2
|
+
import { forbiddenResponse } from '../http/response.js';
|
|
3
|
+
import { hasAnyGroup } from '../identity/extractor.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Roles that can operate cross-tenant (bypass tenant check).
|
|
7
|
+
* PLATFORM_ADMIN = MLHolding employees in the Backoffice pool.
|
|
8
|
+
*/
|
|
9
|
+
const CROSS_TENANT_ROLES = ['PLATFORM_ADMIN'];
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Middleware that enforces tenant context on protected routes.
|
|
13
|
+
* FAIL-CLOSED: If no tenant context exists and user is not PLATFORM_ADMIN, request is denied.
|
|
14
|
+
*
|
|
15
|
+
* Usage: Add as segment middleware for private and backoffice segments.
|
|
16
|
+
*
|
|
17
|
+
* ```typescript
|
|
18
|
+
* const routes: AdvancedSegmentedRouter = {
|
|
19
|
+
* public: { routes: { ... } }, // ← NO tenantGuard
|
|
20
|
+
* private: { middleware: [tenantGuard], routes: { ... } }, // ← YES
|
|
21
|
+
* backoffice: { middleware: [tenantGuard], routes: { ... } }, // ← YES
|
|
22
|
+
* internal: { routes: { ... } }, // ← NO (uses headers, initTenantContext handles it)
|
|
23
|
+
* };
|
|
24
|
+
* ```
|
|
25
|
+
*
|
|
26
|
+
* Throws forbiddenResponse (403) if:
|
|
27
|
+
* - No tenantInfo in context AND user is not PLATFORM_ADMIN
|
|
28
|
+
* - tenantInfo exists but tenantId is empty or whitespace-only
|
|
29
|
+
*
|
|
30
|
+
* Allows through if:
|
|
31
|
+
* - tenantInfo exists with valid non-empty tenantId
|
|
32
|
+
* - User is PLATFORM_ADMIN (cross-tenant access)
|
|
33
|
+
*/
|
|
34
|
+
export const tenantGuard: MiddlewareFn = async (
|
|
35
|
+
event: NormalizedEvent
|
|
36
|
+
): Promise<NormalizedEvent> => {
|
|
37
|
+
const tenantInfo = event.context.tenantInfo;
|
|
38
|
+
const identity = event.context.identity;
|
|
39
|
+
|
|
40
|
+
// PLATFORM_ADMIN can operate without tenant context (cross-tenant)
|
|
41
|
+
if (hasAnyGroup(identity, CROSS_TENANT_ROLES)) {
|
|
42
|
+
return event;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Fail-closed: validate tenantInfo exists AND tenantId is not empty
|
|
46
|
+
if (!tenantInfo?.tenantId || tenantInfo.tenantId.trim() === '') {
|
|
47
|
+
throw forbiddenResponse(
|
|
48
|
+
'Tenant context required. Ensure you are authenticated with a valid tenant.',
|
|
49
|
+
'TENANT_CONTEXT_MISSING' as any
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return event;
|
|
54
|
+
};
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
+
import type { TenantInfo } from './types.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* TenantContext provides thread-safe (async-safe) access to the current tenant context.
|
|
6
|
+
*
|
|
7
|
+
* Uses Node.js AsyncLocalStorage to maintain TenantInfo throughout the
|
|
8
|
+
* execution of a request without needing to pass it as a parameter.
|
|
9
|
+
*
|
|
10
|
+
* Initialization:
|
|
11
|
+
* - In HTTP requests: the `initTenantContext` middleware extracts the tenant
|
|
12
|
+
* from JWT claims or headers and calls `TenantContext.set()`.
|
|
13
|
+
* - In EventBridge/SQS: the handler extracts tenantId from event.detail and calls `TenantContext.run()`.
|
|
14
|
+
*
|
|
15
|
+
* Consumption:
|
|
16
|
+
* - TenantAwareDynamoRepository: `TenantContext.current().tenantId`
|
|
17
|
+
* - ApiInvoker: `TenantContext.currentOptional()` to propagate headers
|
|
18
|
+
* - Use cases: `TenantContext.current()` when they need the tenant explicitly
|
|
19
|
+
*
|
|
20
|
+
* IMPORTANT:
|
|
21
|
+
* - `current()` is FAIL-CLOSED: throws error if no context (prevents data leaks).
|
|
22
|
+
* - `currentOptional()` returns undefined without error (for code that works with/without tenant).
|
|
23
|
+
* - `run()` is for scenarios where an explicit scope is needed (EventBridge handlers).
|
|
24
|
+
* - `set()` is for the orchestrator middleware that operates in the same async scope.
|
|
25
|
+
*/
|
|
26
|
+
export class TenantContext {
|
|
27
|
+
private static storage = new AsyncLocalStorage<TenantInfo>();
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Executes a callback within a tenant context.
|
|
31
|
+
* Useful for EventBridge/SQS handlers where there's no orchestrator middleware.
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* ```typescript
|
|
35
|
+
* await TenantContext.run(tenantInfo, async () => {
|
|
36
|
+
* const props = await propertiesRepo.findByStatus('PUBLISHED');
|
|
37
|
+
* // propertiesRepo automatically filters by tenantInfo.tenantId
|
|
38
|
+
* });
|
|
39
|
+
* ```
|
|
40
|
+
*/
|
|
41
|
+
static run<T>(tenant: TenantInfo, callback: () => T): T {
|
|
42
|
+
return this.storage.run(tenant, callback);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Sets the tenant context in the current AsyncLocalStorage store.
|
|
47
|
+
* ONLY should be called by the initTenantContext middleware.
|
|
48
|
+
*
|
|
49
|
+
* NOTE: Requires an active store (created by AsyncLocalStorage.run()
|
|
50
|
+
* or by the Lambda runtime). If called outside an async context,
|
|
51
|
+
* the value is lost. For those cases, use `run()`.
|
|
52
|
+
*
|
|
53
|
+
* Internally uses enterWith() which replaces the current store.
|
|
54
|
+
*/
|
|
55
|
+
static set(tenant: TenantInfo): void {
|
|
56
|
+
this.storage.enterWith(tenant);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Gets the current TenantInfo. FAIL-CLOSED: throws error if not initialized.
|
|
61
|
+
* Use in code that REQUIRES a tenant (repositories, guards).
|
|
62
|
+
*
|
|
63
|
+
* @throws Error if TenantContext is not initialized
|
|
64
|
+
*
|
|
65
|
+
* @example
|
|
66
|
+
* ```typescript
|
|
67
|
+
* const { tenantId } = TenantContext.current();
|
|
68
|
+
* // Safe: if we get here, tenantId exists
|
|
69
|
+
* ```
|
|
70
|
+
*/
|
|
71
|
+
static current(): TenantInfo {
|
|
72
|
+
const tenant = this.storage.getStore();
|
|
73
|
+
if (!tenant) {
|
|
74
|
+
throw new Error(
|
|
75
|
+
'TenantContext not initialized. Ensure initTenantContext middleware is configured ' +
|
|
76
|
+
'in globalMiddleware, or use TenantContext.run() for non-HTTP triggers.'
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
return tenant;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Gets the current TenantInfo or undefined if not initialized.
|
|
84
|
+
* Use in code that works with or without tenant (ApiInvoker, loggers, public routes).
|
|
85
|
+
*
|
|
86
|
+
* @example
|
|
87
|
+
* ```typescript
|
|
88
|
+
* const tenant = TenantContext.currentOptional();
|
|
89
|
+
* if (tenant) {
|
|
90
|
+
* headers['x-tenant-id'] = tenant.tenantId;
|
|
91
|
+
* }
|
|
92
|
+
* ```
|
|
93
|
+
*/
|
|
94
|
+
static currentOptional(): TenantInfo | undefined {
|
|
95
|
+
return this.storage.getStore();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Checks if there's an active tenant context.
|
|
100
|
+
* Useful for conditionals without getting the full object.
|
|
101
|
+
*/
|
|
102
|
+
static isActive(): boolean {
|
|
103
|
+
return this.storage.getStore() !== undefined;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Clears the current context. Only for testing.
|
|
108
|
+
* DO NOT use in production — the context is automatically cleaned when exiting the scope.
|
|
109
|
+
* @internal
|
|
110
|
+
*/
|
|
111
|
+
static _reset(): void {
|
|
112
|
+
this.storage.disable();
|
|
113
|
+
(this as any).storage = new AsyncLocalStorage<TenantInfo>();
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { TenantInfo } from './types.js';
|
|
2
|
+
import { isTenantType, isPlan, TENANT_HEADERS } from './types.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Builds a TenantInfo from TenantClaims in the JWT.
|
|
6
|
+
* Used by initTenantContext middleware in serverless-event-orchestrator.
|
|
7
|
+
*
|
|
8
|
+
* @param claims - Partial claims object from JWT (event.context.identity.claims)
|
|
9
|
+
* @returns TenantInfo if all required fields are present, undefined otherwise
|
|
10
|
+
*/
|
|
11
|
+
export function tenantInfoFromClaims(claims: Record<string, any>): TenantInfo | undefined {
|
|
12
|
+
const tenantId = claims['custom:tenantId'];
|
|
13
|
+
const tenantType = claims['custom:tenantType'];
|
|
14
|
+
const userId = claims['custom:userId'] || claims['sub'];
|
|
15
|
+
const countryCode = claims['custom:countryCode'];
|
|
16
|
+
|
|
17
|
+
if (!tenantId || !tenantType || !userId || !countryCode) {
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (!isTenantType(tenantType)) {
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
tenantId,
|
|
27
|
+
tenantType,
|
|
28
|
+
userId,
|
|
29
|
+
personProfileId: claims['custom:personProfileId'],
|
|
30
|
+
orgProfileId: claims['custom:orgProfileId'],
|
|
31
|
+
countryCode,
|
|
32
|
+
plan: isPlan(claims['custom:plan']) ? claims['custom:plan'] : undefined,
|
|
33
|
+
hasCRM: claims['custom:hasCRM'] === 'true',
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Builds a TenantInfo from headers (Lambda-to-Lambda).
|
|
39
|
+
* Used by initTenantContext middleware for internal routes.
|
|
40
|
+
*
|
|
41
|
+
* @param headers - Headers object (may have original or lowercase keys)
|
|
42
|
+
* @returns TenantInfo if all required fields are present, undefined otherwise
|
|
43
|
+
*/
|
|
44
|
+
export function tenantInfoFromHeaders(
|
|
45
|
+
headers: Record<string, string | undefined>
|
|
46
|
+
): TenantInfo | undefined {
|
|
47
|
+
const get = (key: string) => headers[key] || headers[key.toLowerCase()];
|
|
48
|
+
|
|
49
|
+
const tenantId = get(TENANT_HEADERS.TENANT_ID);
|
|
50
|
+
const tenantType = get(TENANT_HEADERS.TENANT_TYPE);
|
|
51
|
+
const userId = get(TENANT_HEADERS.USER_ID);
|
|
52
|
+
const countryCode = get(TENANT_HEADERS.COUNTRY_CODE);
|
|
53
|
+
|
|
54
|
+
if (!tenantId || !tenantType || !userId || !countryCode) {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (!isTenantType(tenantType)) {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
tenantId,
|
|
64
|
+
tenantType: tenantType as 'ORG' | 'PERSON',
|
|
65
|
+
userId,
|
|
66
|
+
personProfileId: get(TENANT_HEADERS.PERSON_PROFILE_ID),
|
|
67
|
+
orgProfileId: get(TENANT_HEADERS.ORG_PROFILE_ID),
|
|
68
|
+
countryCode,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Converts TenantInfo to headers for Lambda-to-Lambda propagation.
|
|
74
|
+
* Used by ApiInvoker to propagate the context.
|
|
75
|
+
*
|
|
76
|
+
* @param tenant - TenantInfo to serialize
|
|
77
|
+
* @returns Headers object with X-Tenant-* headers
|
|
78
|
+
*/
|
|
79
|
+
export function tenantInfoToHeaders(tenant: TenantInfo): Record<string, string> {
|
|
80
|
+
const headers: Record<string, string> = {
|
|
81
|
+
[TENANT_HEADERS.TENANT_ID]: tenant.tenantId,
|
|
82
|
+
[TENANT_HEADERS.TENANT_TYPE]: tenant.tenantType,
|
|
83
|
+
[TENANT_HEADERS.USER_ID]: tenant.userId,
|
|
84
|
+
[TENANT_HEADERS.COUNTRY_CODE]: tenant.countryCode,
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
if (tenant.personProfileId) {
|
|
88
|
+
headers[TENANT_HEADERS.PERSON_PROFILE_ID] = tenant.personProfileId;
|
|
89
|
+
}
|
|
90
|
+
if (tenant.orgProfileId) {
|
|
91
|
+
headers[TENANT_HEADERS.ORG_PROFILE_ID] = tenant.orgProfileId;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return headers;
|
|
95
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export { TenantContext } from './TenantContext.js';
|
|
2
|
+
|
|
3
|
+
export type {
|
|
4
|
+
TenantInfo,
|
|
5
|
+
TenantType,
|
|
6
|
+
Plan,
|
|
7
|
+
TenantFeatures,
|
|
8
|
+
TenantClaims,
|
|
9
|
+
} from './types.js';
|
|
10
|
+
|
|
11
|
+
export {
|
|
12
|
+
isTenantType,
|
|
13
|
+
isPlan,
|
|
14
|
+
TENANT_HEADERS,
|
|
15
|
+
} from './types.js';
|
|
16
|
+
|
|
17
|
+
export {
|
|
18
|
+
tenantInfoFromClaims,
|
|
19
|
+
tenantInfoFromHeaders,
|
|
20
|
+
tenantInfoToHeaders,
|
|
21
|
+
} from './helpers.js';
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tenant types for the multi-tenant system.
|
|
3
|
+
* - ORG: Organization/Agency (tenantId = orgProfileId)
|
|
4
|
+
* - PERSON: Independent agent (tenantId = personProfileId)
|
|
5
|
+
*/
|
|
6
|
+
export type TenantType = 'ORG' | 'PERSON';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* SaaS subscription plans.
|
|
10
|
+
* Determine the features available for a tenant.
|
|
11
|
+
* Stored in the Tenants table and injected into JWT via Pre Token Generation.
|
|
12
|
+
*/
|
|
13
|
+
export type Plan = 'FREE' | 'BASIC' | 'PRO' | 'ENTERPRISE';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Features enabled per plan.
|
|
17
|
+
* Stored in the Tenants table and queried from JWT claim or table directly.
|
|
18
|
+
*/
|
|
19
|
+
export interface TenantFeatures {
|
|
20
|
+
maxProperties: number;
|
|
21
|
+
hasWhiteLabelWebsite: boolean;
|
|
22
|
+
hasCRMAccess: boolean;
|
|
23
|
+
hasAdvancedAnalytics: boolean;
|
|
24
|
+
maxAgents: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Tenant information that travels in the context of each request.
|
|
29
|
+
* Propagated via AsyncLocalStorage and available throughout the async chain.
|
|
30
|
+
*
|
|
31
|
+
* Filled from JWT claims (private/backoffice routes) or from headers
|
|
32
|
+
* X-Tenant-Id (internal routes, Lambda-to-Lambda).
|
|
33
|
+
*/
|
|
34
|
+
export interface TenantInfo {
|
|
35
|
+
/** Tenant ID: orgProfileId (ORG) or personProfileId (PERSON) */
|
|
36
|
+
tenantId: string;
|
|
37
|
+
|
|
38
|
+
/** Tenant type */
|
|
39
|
+
tenantType: TenantType;
|
|
40
|
+
|
|
41
|
+
/** Authenticated user ID (Cognito sub or custom:userId) */
|
|
42
|
+
userId: string;
|
|
43
|
+
|
|
44
|
+
/** PersonProfileId of the user (always present, it's their personal profile) */
|
|
45
|
+
personProfileId?: string;
|
|
46
|
+
|
|
47
|
+
/** OrgProfileId if belongs to an organization (only for TenantType = ORG) */
|
|
48
|
+
orgProfileId?: string;
|
|
49
|
+
|
|
50
|
+
/** User's country code (ISO 3166-1 alpha-2, e.g., 'CO', 'MX', 'US') */
|
|
51
|
+
countryCode: string;
|
|
52
|
+
|
|
53
|
+
/** Tenant's plan (optional, filled if present in JWT) */
|
|
54
|
+
plan?: Plan;
|
|
55
|
+
|
|
56
|
+
/** Whether the tenant has CRM access (shortcut for features.hasCRMAccess) */
|
|
57
|
+
hasCRM?: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Custom claims injected into the Cognito JWT via Pre Token Generation.
|
|
62
|
+
* These claims are available in event.context.identity.claims
|
|
63
|
+
* after the orchestrator extracts the identity.
|
|
64
|
+
*/
|
|
65
|
+
export interface TenantClaims {
|
|
66
|
+
'custom:tenantId': string;
|
|
67
|
+
'custom:tenantType': TenantType;
|
|
68
|
+
'custom:userId': string;
|
|
69
|
+
'custom:personProfileId': string;
|
|
70
|
+
'custom:orgProfileId'?: string;
|
|
71
|
+
'custom:countryCode': string;
|
|
72
|
+
'custom:plan'?: Plan;
|
|
73
|
+
'custom:hasCRM'?: string; // 'true' | 'false' (Cognito only accepts strings)
|
|
74
|
+
'custom:hasWhiteLabel'?: string; // 'true' | 'false'
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Headers used to propagate tenant in Lambda-to-Lambda calls.
|
|
79
|
+
*/
|
|
80
|
+
export const TENANT_HEADERS = {
|
|
81
|
+
TENANT_ID: 'x-tenant-id',
|
|
82
|
+
TENANT_TYPE: 'x-tenant-type',
|
|
83
|
+
USER_ID: 'x-user-id',
|
|
84
|
+
COUNTRY_CODE: 'x-country-code',
|
|
85
|
+
PERSON_PROFILE_ID: 'x-person-profile-id',
|
|
86
|
+
ORG_PROFILE_ID: 'x-org-profile-id',
|
|
87
|
+
} as const;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Type guard: checks if a value is a valid TenantType.
|
|
91
|
+
*/
|
|
92
|
+
export function isTenantType(value: unknown): value is TenantType {
|
|
93
|
+
return value === 'ORG' || value === 'PERSON';
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Type guard: checks if a value is a valid Plan.
|
|
98
|
+
*/
|
|
99
|
+
export function isPlan(value: unknown): value is Plan {
|
|
100
|
+
return value === 'FREE' || value === 'BASIC' || value === 'PRO' || value === 'ENTERPRISE';
|
|
101
|
+
}
|
package/src/types/routes.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { RouteSegment } from './event-type.enum.js';
|
|
2
|
+
import type { TenantInfo } from '../tenant/types.js';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* HTTP methods supported by the router
|
|
@@ -146,6 +147,7 @@ export interface NormalizedEvent {
|
|
|
146
147
|
segment: RouteSegment;
|
|
147
148
|
identity?: IdentityContext;
|
|
148
149
|
requestId?: string;
|
|
150
|
+
tenantInfo?: TenantInfo;
|
|
149
151
|
};
|
|
150
152
|
}
|
|
151
153
|
|
|
@@ -10,18 +10,22 @@
|
|
|
10
10
|
*/
|
|
11
11
|
export function patternToRegex(pattern: string): { regex: RegExp; paramNames: string[] } {
|
|
12
12
|
const paramNames: string[] = [];
|
|
13
|
-
|
|
13
|
+
|
|
14
|
+
// First, handle :paramName format (Express style) BEFORE escaping
|
|
15
|
+
// This converts :id to {id} for uniform processing
|
|
16
|
+
let normalizedPattern = pattern.replace(/:(\w+)/g, '{$1}');
|
|
17
|
+
|
|
14
18
|
// Escape special regex characters except for our parameter syntax
|
|
15
|
-
let regexPattern =
|
|
19
|
+
let regexPattern = normalizedPattern
|
|
16
20
|
.replace(/[.+?^${}()|[\]\\]/g, '\\$&')
|
|
17
21
|
.replace(/\\\{(\w+)\\\}/g, (_, paramName) => {
|
|
18
22
|
paramNames.push(paramName);
|
|
19
23
|
return '([^/]+)';
|
|
20
24
|
});
|
|
21
|
-
|
|
25
|
+
|
|
22
26
|
// Ensure exact match
|
|
23
27
|
regexPattern = `^${regexPattern}$`;
|
|
24
|
-
|
|
28
|
+
|
|
25
29
|
return {
|
|
26
30
|
regex: new RegExp(regexPattern),
|
|
27
31
|
paramNames,
|
|
@@ -56,7 +60,8 @@ export function matchPath(pattern: string, path: string): Record<string, string>
|
|
|
56
60
|
* @returns True if pattern has parameters
|
|
57
61
|
*/
|
|
58
62
|
export function hasPathParameters(pattern: string): boolean {
|
|
59
|
-
|
|
63
|
+
// Support both {id} and :id formats
|
|
64
|
+
return /\{[\w]+\}/.test(pattern) || /:[\w]+/.test(pattern);
|
|
60
65
|
}
|
|
61
66
|
|
|
62
67
|
/**
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { crmGuard } from '../../src/middleware/crm-guard';
|
|
2
|
+
import { RouteSegment } from '../../src/types/event-type.enum';
|
|
3
|
+
import type { NormalizedEvent } from '../../src/types/routes';
|
|
4
|
+
import type { TenantInfo } from '../../src/tenant';
|
|
5
|
+
|
|
6
|
+
function createEvent(options: {
|
|
7
|
+
hasCRM?: boolean;
|
|
8
|
+
groups?: string[];
|
|
9
|
+
}): NormalizedEvent {
|
|
10
|
+
const tenantInfo: TenantInfo = {
|
|
11
|
+
tenantId: 'org_abc',
|
|
12
|
+
tenantType: 'ORG',
|
|
13
|
+
userId: 'user-1',
|
|
14
|
+
countryCode: 'CO',
|
|
15
|
+
hasCRM: options.hasCRM,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
eventRaw: {},
|
|
20
|
+
eventType: 'apigateway',
|
|
21
|
+
payload: { headers: {} },
|
|
22
|
+
params: {},
|
|
23
|
+
context: {
|
|
24
|
+
segment: RouteSegment.Private,
|
|
25
|
+
identity: {
|
|
26
|
+
userId: 'user-1',
|
|
27
|
+
groups: options.groups ?? [],
|
|
28
|
+
claims: {},
|
|
29
|
+
},
|
|
30
|
+
requestId: 'req-test',
|
|
31
|
+
tenantInfo,
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe('crmGuard', () => {
|
|
37
|
+
it('allows access when hasCRM is true', async () => {
|
|
38
|
+
const event = createEvent({ hasCRM: true });
|
|
39
|
+
const result = await crmGuard(event);
|
|
40
|
+
expect(result).toBeDefined();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('throws 403 when hasCRM is false', async () => {
|
|
44
|
+
const event = createEvent({ hasCRM: false });
|
|
45
|
+
await expect(crmGuard(event)).rejects.toMatchObject({ statusCode: 403 });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('throws 403 when hasCRM is undefined', async () => {
|
|
49
|
+
const event = createEvent({});
|
|
50
|
+
await expect(crmGuard(event)).rejects.toMatchObject({ statusCode: 403 });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('PLATFORM_ADMIN bypasses CRM check', async () => {
|
|
54
|
+
const event = createEvent({ hasCRM: false, groups: ['PLATFORM_ADMIN'] });
|
|
55
|
+
const result = await crmGuard(event);
|
|
56
|
+
expect(result).toBeDefined();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('error includes CRM_ACCESS_DENIED code', async () => {
|
|
60
|
+
const event = createEvent({ hasCRM: false, groups: ['ORG_ADMIN'] });
|
|
61
|
+
try {
|
|
62
|
+
await crmGuard(event);
|
|
63
|
+
fail('Should have thrown');
|
|
64
|
+
} catch (err: any) {
|
|
65
|
+
const body = JSON.parse(err.body);
|
|
66
|
+
expect(body.code).toBe('CRM_ACCESS_DENIED');
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
});
|