@valentine-efagene/qshelter-common 2.0.60 → 2.0.63
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/src/auth/index.d.ts +3 -0
- package/dist/src/auth/index.js +3 -0
- package/dist/src/auth/permission-cache.d.ts +56 -0
- package/dist/src/auth/permission-cache.js +268 -0
- package/dist/src/auth/policy-evaluator.d.ts +71 -0
- package/dist/src/auth/policy-evaluator.js +107 -0
- package/dist/src/auth/types.d.ts +69 -0
- package/dist/src/auth/types.js +4 -0
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.js +1 -0
- package/dist/src/prisma/tenant.js +33 -28
- package/package.json +3 -1
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory permission cache for fast role → scopes resolution
|
|
3
|
+
*
|
|
4
|
+
* This module provides caching for role permissions to avoid DynamoDB calls
|
|
5
|
+
* on every authorization request. The cache is warmed on Lambda cold start
|
|
6
|
+
* and refreshed based on TTL.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```typescript
|
|
10
|
+
* import { resolveScopes, warmCache } from '@valentine-efagene/qshelter-common';
|
|
11
|
+
*
|
|
12
|
+
* // Warm cache on cold start (optional but recommended)
|
|
13
|
+
* await warmCache();
|
|
14
|
+
*
|
|
15
|
+
* // Resolve scopes for user's roles
|
|
16
|
+
* const scopes = await resolveScopes(['admin', 'customer']);
|
|
17
|
+
* // Returns: ['contract:*', 'payment:*', 'user:read', ...]
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
/**
|
|
21
|
+
* Resolve all scopes for a list of roles (deduped)
|
|
22
|
+
*
|
|
23
|
+
* @param roles - Array of role IDs/names
|
|
24
|
+
* @returns Deduplicated array of all scopes from all roles
|
|
25
|
+
*/
|
|
26
|
+
export declare function resolveScopes(roles: string[]): Promise<string[]>;
|
|
27
|
+
/**
|
|
28
|
+
* Warm the permission cache on cold start
|
|
29
|
+
* Loads all active roles from DynamoDB into memory
|
|
30
|
+
*
|
|
31
|
+
* @returns Promise that resolves when cache is warmed
|
|
32
|
+
*/
|
|
33
|
+
export declare function warmCache(): Promise<void>;
|
|
34
|
+
/**
|
|
35
|
+
* Check if cache has been warmed
|
|
36
|
+
*/
|
|
37
|
+
export declare function isCacheWarmed(): boolean;
|
|
38
|
+
/**
|
|
39
|
+
* Invalidate a specific role in the cache
|
|
40
|
+
* Call this when role permissions are updated
|
|
41
|
+
*
|
|
42
|
+
* @param roleId - Role ID to invalidate
|
|
43
|
+
*/
|
|
44
|
+
export declare function invalidateRole(roleId: string): void;
|
|
45
|
+
/**
|
|
46
|
+
* Clear the entire permission cache
|
|
47
|
+
* Call this for full refresh or testing
|
|
48
|
+
*/
|
|
49
|
+
export declare function clearCache(): void;
|
|
50
|
+
/**
|
|
51
|
+
* Get cache statistics for monitoring
|
|
52
|
+
*/
|
|
53
|
+
export declare function getCacheStats(): {
|
|
54
|
+
size: number;
|
|
55
|
+
warmed: boolean;
|
|
56
|
+
};
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory permission cache for fast role → scopes resolution
|
|
3
|
+
*
|
|
4
|
+
* This module provides caching for role permissions to avoid DynamoDB calls
|
|
5
|
+
* on every authorization request. The cache is warmed on Lambda cold start
|
|
6
|
+
* and refreshed based on TTL.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```typescript
|
|
10
|
+
* import { resolveScopes, warmCache } from '@valentine-efagene/qshelter-common';
|
|
11
|
+
*
|
|
12
|
+
* // Warm cache on cold start (optional but recommended)
|
|
13
|
+
* await warmCache();
|
|
14
|
+
*
|
|
15
|
+
* // Resolve scopes for user's roles
|
|
16
|
+
* const scopes = await resolveScopes(['admin', 'customer']);
|
|
17
|
+
* // Returns: ['contract:*', 'payment:*', 'user:read', ...]
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
import { DynamoDBClient, GetItemCommand, ScanCommand } from '@aws-sdk/client-dynamodb';
|
|
21
|
+
import { unmarshall } from '@aws-sdk/util-dynamodb';
|
|
22
|
+
/** Cache TTL in milliseconds (5 minutes) */
|
|
23
|
+
const CACHE_TTL_MS = 5 * 60 * 1000;
|
|
24
|
+
/** In-memory LRU cache for role → scopes */
|
|
25
|
+
const cache = new Map();
|
|
26
|
+
/** DynamoDB client (lazy initialized) */
|
|
27
|
+
let dynamoClient = null;
|
|
28
|
+
/** Flag to track if cache has been warmed */
|
|
29
|
+
let cacheWarmed = false;
|
|
30
|
+
/**
|
|
31
|
+
* Get or create DynamoDB client
|
|
32
|
+
*/
|
|
33
|
+
function getClient() {
|
|
34
|
+
if (!dynamoClient) {
|
|
35
|
+
dynamoClient = new DynamoDBClient({
|
|
36
|
+
region: process.env.AWS_REGION || 'us-east-1',
|
|
37
|
+
endpoint: process.env.LOCALSTACK_ENDPOINT || undefined,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
return dynamoClient;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Get the roles table name from environment
|
|
44
|
+
*/
|
|
45
|
+
function getTableName() {
|
|
46
|
+
return process.env.ROLES_TABLE || process.env.ROLE_POLICIES_TABLE_NAME || 'qshelter-roles';
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Get scopes for a single role (cache-first)
|
|
50
|
+
*
|
|
51
|
+
* @param roleId - Role ID or name
|
|
52
|
+
* @returns Array of scopes for the role
|
|
53
|
+
*/
|
|
54
|
+
async function getRoleScopes(roleId) {
|
|
55
|
+
const now = Date.now();
|
|
56
|
+
const cached = cache.get(roleId);
|
|
57
|
+
// Return cached value if not expired
|
|
58
|
+
if (cached && cached.expiresAt > now) {
|
|
59
|
+
return cached.scopes;
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
// Try to fetch from DynamoDB using the existing schema (PK/SK pattern)
|
|
63
|
+
const result = await getClient().send(new GetItemCommand({
|
|
64
|
+
TableName: getTableName(),
|
|
65
|
+
Key: {
|
|
66
|
+
PK: { S: `ROLE#${roleId}` },
|
|
67
|
+
SK: { S: 'POLICY' },
|
|
68
|
+
},
|
|
69
|
+
}));
|
|
70
|
+
if (!result.Item) {
|
|
71
|
+
// Role not found, cache empty scopes
|
|
72
|
+
cache.set(roleId, { scopes: [], expiresAt: now + CACHE_TTL_MS });
|
|
73
|
+
return [];
|
|
74
|
+
}
|
|
75
|
+
const item = unmarshall(result.Item);
|
|
76
|
+
// Extract scopes from the policy structure
|
|
77
|
+
// The existing schema stores policies with statements containing resources
|
|
78
|
+
// We need to convert this to scopes
|
|
79
|
+
const scopes = extractScopesFromPolicy(item);
|
|
80
|
+
// Update cache
|
|
81
|
+
cache.set(roleId, {
|
|
82
|
+
scopes,
|
|
83
|
+
expiresAt: now + CACHE_TTL_MS,
|
|
84
|
+
});
|
|
85
|
+
return scopes;
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
console.error(`[PermissionCache] Error fetching role ${roleId}:`, error);
|
|
89
|
+
return [];
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Extract scopes from the existing policy structure
|
|
94
|
+
*
|
|
95
|
+
* Converts path-based policies to scope format:
|
|
96
|
+
* - /contracts/* GET → contract:read
|
|
97
|
+
* - /contracts/* POST → contract:create
|
|
98
|
+
* - /contracts/* PUT,PATCH → contract:update
|
|
99
|
+
* - /contracts/* DELETE → contract:delete
|
|
100
|
+
* - /* * → * (superuser)
|
|
101
|
+
*/
|
|
102
|
+
function extractScopesFromPolicy(item) {
|
|
103
|
+
// If the item already has scopes array, use it directly
|
|
104
|
+
if (item.scopes && Array.isArray(item.scopes)) {
|
|
105
|
+
return item.scopes;
|
|
106
|
+
}
|
|
107
|
+
// Otherwise, check if isActive is false
|
|
108
|
+
if (item.isActive === false) {
|
|
109
|
+
return [];
|
|
110
|
+
}
|
|
111
|
+
// Convert path-based policy to scopes
|
|
112
|
+
const scopes = new Set();
|
|
113
|
+
const policy = item.policy;
|
|
114
|
+
if (!policy?.statements) {
|
|
115
|
+
return [];
|
|
116
|
+
}
|
|
117
|
+
for (const statement of policy.statements) {
|
|
118
|
+
if (statement.effect !== 'Allow')
|
|
119
|
+
continue;
|
|
120
|
+
for (const resource of statement.resources || []) {
|
|
121
|
+
const path = resource.path || '';
|
|
122
|
+
const methods = resource.methods || [];
|
|
123
|
+
// Extract resource name from path
|
|
124
|
+
const resourceName = extractResourceName(path);
|
|
125
|
+
for (const method of methods) {
|
|
126
|
+
const action = methodToAction(method);
|
|
127
|
+
if (resourceName === '*' || action === '*') {
|
|
128
|
+
scopes.add('*');
|
|
129
|
+
}
|
|
130
|
+
else if (resourceName && action) {
|
|
131
|
+
scopes.add(`${resourceName}:${action}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return Array.from(scopes);
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Extract resource name from path pattern
|
|
140
|
+
* /contracts/* → contract
|
|
141
|
+
* /users/:id → user
|
|
142
|
+
* /* → *
|
|
143
|
+
*/
|
|
144
|
+
function extractResourceName(path) {
|
|
145
|
+
if (path === '/*' || path === '*') {
|
|
146
|
+
return '*';
|
|
147
|
+
}
|
|
148
|
+
// Remove leading slash and get first segment
|
|
149
|
+
const segments = path.replace(/^\//, '').split('/');
|
|
150
|
+
const firstSegment = segments[0] || '';
|
|
151
|
+
// Singularize common resources
|
|
152
|
+
const singularMap = {
|
|
153
|
+
'contracts': 'contract',
|
|
154
|
+
'users': 'user',
|
|
155
|
+
'properties': 'property',
|
|
156
|
+
'payments': 'payment',
|
|
157
|
+
'tenants': 'tenant',
|
|
158
|
+
'documents': 'document',
|
|
159
|
+
'phases': 'phase',
|
|
160
|
+
'installments': 'installment',
|
|
161
|
+
};
|
|
162
|
+
return singularMap[firstSegment] || firstSegment.replace(/s$/, '');
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Convert HTTP method to action
|
|
166
|
+
* GET → read
|
|
167
|
+
* POST → create
|
|
168
|
+
* PUT/PATCH → update
|
|
169
|
+
* DELETE → delete
|
|
170
|
+
* * → *
|
|
171
|
+
*/
|
|
172
|
+
function methodToAction(method) {
|
|
173
|
+
const upper = method.toUpperCase();
|
|
174
|
+
const methodMap = {
|
|
175
|
+
'GET': 'read',
|
|
176
|
+
'POST': 'create',
|
|
177
|
+
'PUT': 'update',
|
|
178
|
+
'PATCH': 'update',
|
|
179
|
+
'DELETE': 'delete',
|
|
180
|
+
'*': '*',
|
|
181
|
+
};
|
|
182
|
+
return methodMap[upper] || 'read';
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Resolve all scopes for a list of roles (deduped)
|
|
186
|
+
*
|
|
187
|
+
* @param roles - Array of role IDs/names
|
|
188
|
+
* @returns Deduplicated array of all scopes from all roles
|
|
189
|
+
*/
|
|
190
|
+
export async function resolveScopes(roles) {
|
|
191
|
+
if (!roles || roles.length === 0) {
|
|
192
|
+
return [];
|
|
193
|
+
}
|
|
194
|
+
const scopeSets = await Promise.all(roles.map(getRoleScopes));
|
|
195
|
+
const allScopes = scopeSets.flat();
|
|
196
|
+
// Dedupe and return
|
|
197
|
+
return [...new Set(allScopes)];
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Warm the permission cache on cold start
|
|
201
|
+
* Loads all active roles from DynamoDB into memory
|
|
202
|
+
*
|
|
203
|
+
* @returns Promise that resolves when cache is warmed
|
|
204
|
+
*/
|
|
205
|
+
export async function warmCache() {
|
|
206
|
+
if (cacheWarmed) {
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
try {
|
|
210
|
+
const result = await getClient().send(new ScanCommand({
|
|
211
|
+
TableName: getTableName(),
|
|
212
|
+
FilterExpression: 'SK = :sk',
|
|
213
|
+
ExpressionAttributeValues: {
|
|
214
|
+
':sk': { S: 'POLICY' },
|
|
215
|
+
},
|
|
216
|
+
}));
|
|
217
|
+
const now = Date.now();
|
|
218
|
+
for (const item of result.Items || []) {
|
|
219
|
+
const unmarshalled = unmarshall(item);
|
|
220
|
+
const roleName = unmarshalled.roleName || unmarshalled.PK?.replace('ROLE#', '');
|
|
221
|
+
if (roleName && unmarshalled.isActive !== false) {
|
|
222
|
+
const scopes = extractScopesFromPolicy(unmarshalled);
|
|
223
|
+
cache.set(roleName, {
|
|
224
|
+
scopes,
|
|
225
|
+
expiresAt: now + CACHE_TTL_MS,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
cacheWarmed = true;
|
|
230
|
+
console.log(`[PermissionCache] Cache warmed with ${cache.size} roles`);
|
|
231
|
+
}
|
|
232
|
+
catch (error) {
|
|
233
|
+
console.error('[PermissionCache] Failed to warm cache:', error);
|
|
234
|
+
// Don't throw - allow fallback to per-request fetching
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Check if cache has been warmed
|
|
239
|
+
*/
|
|
240
|
+
export function isCacheWarmed() {
|
|
241
|
+
return cacheWarmed;
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Invalidate a specific role in the cache
|
|
245
|
+
* Call this when role permissions are updated
|
|
246
|
+
*
|
|
247
|
+
* @param roleId - Role ID to invalidate
|
|
248
|
+
*/
|
|
249
|
+
export function invalidateRole(roleId) {
|
|
250
|
+
cache.delete(roleId);
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Clear the entire permission cache
|
|
254
|
+
* Call this for full refresh or testing
|
|
255
|
+
*/
|
|
256
|
+
export function clearCache() {
|
|
257
|
+
cache.clear();
|
|
258
|
+
cacheWarmed = false;
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Get cache statistics for monitoring
|
|
262
|
+
*/
|
|
263
|
+
export function getCacheStats() {
|
|
264
|
+
return {
|
|
265
|
+
size: cache.size,
|
|
266
|
+
warmed: cacheWarmed,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Policy evaluation utilities for scope-based authorization
|
|
3
|
+
*
|
|
4
|
+
* Scopes follow a resource:action pattern with wildcard support:
|
|
5
|
+
* - "contract:read" - specific action on specific resource
|
|
6
|
+
* - "contract:*" - all actions on contracts
|
|
7
|
+
* - "*" - superuser access
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```typescript
|
|
11
|
+
* import { hasScope, requireScope } from '@valentine-efagene/qshelter-common';
|
|
12
|
+
*
|
|
13
|
+
* const scopes = ['contract:read', 'payment:*'];
|
|
14
|
+
*
|
|
15
|
+
* hasScope(scopes, 'contract:read'); // true
|
|
16
|
+
* hasScope(scopes, 'payment:create'); // true (wildcard match)
|
|
17
|
+
* hasScope(scopes, 'user:read'); // false
|
|
18
|
+
*
|
|
19
|
+
* requireScope(scopes, 'contract:read'); // passes
|
|
20
|
+
* requireScope(scopes, 'user:read'); // throws 403 error
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
/**
|
|
24
|
+
* Check if scopes array contains the required scope
|
|
25
|
+
* Supports wildcards: "resource:*" matches "resource:action"
|
|
26
|
+
*
|
|
27
|
+
* @param scopes - Array of scopes the principal has
|
|
28
|
+
* @param required - The scope required for the operation
|
|
29
|
+
* @returns true if the required scope is present (exact or wildcard match)
|
|
30
|
+
*/
|
|
31
|
+
export declare function hasScope(scopes: string[], required: string): boolean;
|
|
32
|
+
/**
|
|
33
|
+
* Throws a 403 Forbidden error if the required scope is missing
|
|
34
|
+
*
|
|
35
|
+
* @param scopes - Array of scopes the principal has
|
|
36
|
+
* @param required - The scope required for the operation
|
|
37
|
+
* @throws AppError with status 403 if scope is missing
|
|
38
|
+
*/
|
|
39
|
+
export declare function requireScope(scopes: string[], required: string): void;
|
|
40
|
+
/**
|
|
41
|
+
* Check if ALL required scopes are present (AND logic)
|
|
42
|
+
*
|
|
43
|
+
* @param scopes - Array of scopes the principal has
|
|
44
|
+
* @param required - Array of scopes required (all must be present)
|
|
45
|
+
* @returns true if all required scopes are present
|
|
46
|
+
*/
|
|
47
|
+
export declare function hasAllScopes(scopes: string[], required: string[]): boolean;
|
|
48
|
+
/**
|
|
49
|
+
* Check if ANY required scope is present (OR logic)
|
|
50
|
+
*
|
|
51
|
+
* @param scopes - Array of scopes the principal has
|
|
52
|
+
* @param required - Array of scopes (any one must be present)
|
|
53
|
+
* @returns true if at least one required scope is present
|
|
54
|
+
*/
|
|
55
|
+
export declare function hasAnyScope(scopes: string[], required: string[]): boolean;
|
|
56
|
+
/**
|
|
57
|
+
* Throws a 403 Forbidden error if none of the required scopes are present
|
|
58
|
+
*
|
|
59
|
+
* @param scopes - Array of scopes the principal has
|
|
60
|
+
* @param required - Array of scopes (any one must be present)
|
|
61
|
+
* @throws AppError with status 403 if no required scope is present
|
|
62
|
+
*/
|
|
63
|
+
export declare function requireAnyScope(scopes: string[], required: string[]): void;
|
|
64
|
+
/**
|
|
65
|
+
* Throws a 403 Forbidden error if not all required scopes are present
|
|
66
|
+
*
|
|
67
|
+
* @param scopes - Array of scopes the principal has
|
|
68
|
+
* @param required - Array of scopes (all must be present)
|
|
69
|
+
* @throws AppError with status 403 if any required scope is missing
|
|
70
|
+
*/
|
|
71
|
+
export declare function requireAllScopes(scopes: string[], required: string[]): void;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Policy evaluation utilities for scope-based authorization
|
|
3
|
+
*
|
|
4
|
+
* Scopes follow a resource:action pattern with wildcard support:
|
|
5
|
+
* - "contract:read" - specific action on specific resource
|
|
6
|
+
* - "contract:*" - all actions on contracts
|
|
7
|
+
* - "*" - superuser access
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```typescript
|
|
11
|
+
* import { hasScope, requireScope } from '@valentine-efagene/qshelter-common';
|
|
12
|
+
*
|
|
13
|
+
* const scopes = ['contract:read', 'payment:*'];
|
|
14
|
+
*
|
|
15
|
+
* hasScope(scopes, 'contract:read'); // true
|
|
16
|
+
* hasScope(scopes, 'payment:create'); // true (wildcard match)
|
|
17
|
+
* hasScope(scopes, 'user:read'); // false
|
|
18
|
+
*
|
|
19
|
+
* requireScope(scopes, 'contract:read'); // passes
|
|
20
|
+
* requireScope(scopes, 'user:read'); // throws 403 error
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
import { AppError } from '../utils/errors';
|
|
24
|
+
/**
|
|
25
|
+
* Check if scopes array contains the required scope
|
|
26
|
+
* Supports wildcards: "resource:*" matches "resource:action"
|
|
27
|
+
*
|
|
28
|
+
* @param scopes - Array of scopes the principal has
|
|
29
|
+
* @param required - The scope required for the operation
|
|
30
|
+
* @returns true if the required scope is present (exact or wildcard match)
|
|
31
|
+
*/
|
|
32
|
+
export function hasScope(scopes, required) {
|
|
33
|
+
if (!scopes || scopes.length === 0) {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
return scopes.some((s) => {
|
|
37
|
+
// Superuser wildcard
|
|
38
|
+
if (s === '*')
|
|
39
|
+
return true;
|
|
40
|
+
// Exact match
|
|
41
|
+
if (s === required)
|
|
42
|
+
return true;
|
|
43
|
+
// Wildcard match: "contract:*" matches "contract:read"
|
|
44
|
+
if (s.endsWith(':*')) {
|
|
45
|
+
const prefix = s.slice(0, -1); // "contract:"
|
|
46
|
+
return required.startsWith(prefix);
|
|
47
|
+
}
|
|
48
|
+
return false;
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Throws a 403 Forbidden error if the required scope is missing
|
|
53
|
+
*
|
|
54
|
+
* @param scopes - Array of scopes the principal has
|
|
55
|
+
* @param required - The scope required for the operation
|
|
56
|
+
* @throws AppError with status 403 if scope is missing
|
|
57
|
+
*/
|
|
58
|
+
export function requireScope(scopes, required) {
|
|
59
|
+
if (!hasScope(scopes, required)) {
|
|
60
|
+
throw new AppError(403, `Forbidden: missing required scope '${required}'`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Check if ALL required scopes are present (AND logic)
|
|
65
|
+
*
|
|
66
|
+
* @param scopes - Array of scopes the principal has
|
|
67
|
+
* @param required - Array of scopes required (all must be present)
|
|
68
|
+
* @returns true if all required scopes are present
|
|
69
|
+
*/
|
|
70
|
+
export function hasAllScopes(scopes, required) {
|
|
71
|
+
return required.every((r) => hasScope(scopes, r));
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Check if ANY required scope is present (OR logic)
|
|
75
|
+
*
|
|
76
|
+
* @param scopes - Array of scopes the principal has
|
|
77
|
+
* @param required - Array of scopes (any one must be present)
|
|
78
|
+
* @returns true if at least one required scope is present
|
|
79
|
+
*/
|
|
80
|
+
export function hasAnyScope(scopes, required) {
|
|
81
|
+
return required.some((r) => hasScope(scopes, r));
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Throws a 403 Forbidden error if none of the required scopes are present
|
|
85
|
+
*
|
|
86
|
+
* @param scopes - Array of scopes the principal has
|
|
87
|
+
* @param required - Array of scopes (any one must be present)
|
|
88
|
+
* @throws AppError with status 403 if no required scope is present
|
|
89
|
+
*/
|
|
90
|
+
export function requireAnyScope(scopes, required) {
|
|
91
|
+
if (!hasAnyScope(scopes, required)) {
|
|
92
|
+
throw new AppError(403, `Forbidden: requires at least one of scopes: ${required.join(', ')}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Throws a 403 Forbidden error if not all required scopes are present
|
|
97
|
+
*
|
|
98
|
+
* @param scopes - Array of scopes the principal has
|
|
99
|
+
* @param required - Array of scopes (all must be present)
|
|
100
|
+
* @throws AppError with status 403 if any required scope is missing
|
|
101
|
+
*/
|
|
102
|
+
export function requireAllScopes(scopes, required) {
|
|
103
|
+
if (!hasAllScopes(scopes, required)) {
|
|
104
|
+
const missing = required.filter((r) => !hasScope(scopes, r));
|
|
105
|
+
throw new AppError(403, `Forbidden: missing required scopes: ${missing.join(', ')}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth types for JWT payloads and authorization context
|
|
3
|
+
*/
|
|
4
|
+
export interface JwtPayload {
|
|
5
|
+
/** User ID or API Key ID */
|
|
6
|
+
sub: string;
|
|
7
|
+
/** User email (for user principals) */
|
|
8
|
+
email?: string;
|
|
9
|
+
/** Tenant ID for multi-tenancy */
|
|
10
|
+
tenantId: string;
|
|
11
|
+
/** Type of principal: user or apiKey */
|
|
12
|
+
principalType: 'user' | 'apiKey';
|
|
13
|
+
/** Role IDs/names (resolved to scopes via cache) */
|
|
14
|
+
roles: string[];
|
|
15
|
+
/** Issued at timestamp */
|
|
16
|
+
iat: number;
|
|
17
|
+
/** Expiration timestamp */
|
|
18
|
+
exp: number;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Authorization context passed to downstream services via API Gateway
|
|
22
|
+
* All values must be strings for API Gateway context
|
|
23
|
+
*
|
|
24
|
+
* @deprecated Use AuthContext from middleware/auth-context.ts for Express services
|
|
25
|
+
*/
|
|
26
|
+
export interface AuthorizerResultContext {
|
|
27
|
+
/** User ID or API Key ID */
|
|
28
|
+
principalId: string;
|
|
29
|
+
/** Tenant ID */
|
|
30
|
+
tenantId: string;
|
|
31
|
+
/** Type of principal: user or apiKey */
|
|
32
|
+
principalType: 'user' | 'apiKey';
|
|
33
|
+
/** JSON stringified array of role names */
|
|
34
|
+
roles: string;
|
|
35
|
+
/** JSON stringified array of resolved scopes */
|
|
36
|
+
scopes: string;
|
|
37
|
+
/** User email (optional) */
|
|
38
|
+
email?: string;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Parsed auth context with scopes (after JSON parsing)
|
|
42
|
+
*/
|
|
43
|
+
export interface ParsedAuthContextWithScopes {
|
|
44
|
+
principalId: string;
|
|
45
|
+
tenantId: string;
|
|
46
|
+
principalType: 'user' | 'apiKey';
|
|
47
|
+
roles: string[];
|
|
48
|
+
scopes: string[];
|
|
49
|
+
email?: string;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Role definition stored in DynamoDB
|
|
53
|
+
*/
|
|
54
|
+
export interface RoleDefinition {
|
|
55
|
+
/** Role ID (partition key) */
|
|
56
|
+
id: string;
|
|
57
|
+
/** Human-readable role name */
|
|
58
|
+
name: string;
|
|
59
|
+
/** Description of the role */
|
|
60
|
+
description?: string;
|
|
61
|
+
/** Scopes granted by this role */
|
|
62
|
+
scopes: string[];
|
|
63
|
+
/** Whether the role is active */
|
|
64
|
+
isActive: boolean;
|
|
65
|
+
/** Tenant ID (null for global roles) */
|
|
66
|
+
tenantId?: string;
|
|
67
|
+
/** Last updated timestamp */
|
|
68
|
+
updatedAt: string;
|
|
69
|
+
}
|
package/dist/src/index.d.ts
CHANGED
package/dist/src/index.js
CHANGED
|
@@ -1,42 +1,47 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* INVERTED APPROACH: All models are tenant-scoped by default.
|
|
3
|
+
* Only list models that are explicitly GLOBAL (no tenant scoping).
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
* Models
|
|
8
|
-
*
|
|
5
|
+
* This reduces the risk of accidentally omitting a new model from tenant scoping.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Models that are intentionally GLOBAL and should NOT be tenant-scoped.
|
|
9
|
+
* These models either:
|
|
10
|
+
* - Don't have a tenantId field (system tables)
|
|
11
|
+
* - Have optional tenantId but are designed to work across tenants (User)
|
|
9
12
|
*/
|
|
10
|
-
const
|
|
11
|
-
//
|
|
12
|
-
"
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
"
|
|
16
|
-
|
|
17
|
-
"
|
|
18
|
-
"
|
|
19
|
-
|
|
20
|
-
"
|
|
21
|
-
"
|
|
22
|
-
"
|
|
23
|
-
// Prequalification & underwriting domain
|
|
24
|
-
"prequalification",
|
|
25
|
-
"underwritingDecision",
|
|
26
|
-
// Payment method changes
|
|
27
|
-
"paymentMethodChangeRequest",
|
|
13
|
+
const GLOBAL_MODELS = [
|
|
14
|
+
// User can exist across tenants or without a tenant
|
|
15
|
+
"user",
|
|
16
|
+
// System/infrastructure tables without tenantId
|
|
17
|
+
"tenant",
|
|
18
|
+
"role",
|
|
19
|
+
"permission",
|
|
20
|
+
"rolePermission",
|
|
21
|
+
"userRole",
|
|
22
|
+
"refreshToken",
|
|
23
|
+
"passwordReset",
|
|
24
|
+
"wallet",
|
|
25
|
+
"domainEvent",
|
|
28
26
|
];
|
|
29
27
|
/**
|
|
30
|
-
* Models that
|
|
31
|
-
*
|
|
28
|
+
* Models that have OPTIONAL tenant scoping (nullable tenantId).
|
|
29
|
+
* These can be global templates (tenantId = null) or tenant-specific.
|
|
30
|
+
* Queries will return both global AND tenant-specific records.
|
|
32
31
|
*/
|
|
33
32
|
const OPTIONAL_TENANT_MODELS = ["paymentPlan"];
|
|
34
|
-
function
|
|
35
|
-
return
|
|
33
|
+
function isGlobalModel(model) {
|
|
34
|
+
return GLOBAL_MODELS.includes(model);
|
|
36
35
|
}
|
|
37
36
|
function isOptionalTenantModel(model) {
|
|
38
37
|
return OPTIONAL_TENANT_MODELS.includes(model);
|
|
39
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* A model is tenant-scoped by default unless explicitly listed as global.
|
|
41
|
+
*/
|
|
42
|
+
function isTenantScopedModel(model) {
|
|
43
|
+
return !isGlobalModel(model);
|
|
44
|
+
}
|
|
40
45
|
/**
|
|
41
46
|
* Creates a tenant-scoped Prisma client that automatically:
|
|
42
47
|
* 1. Filters all queries on tenant-scoped models by tenantId
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@valentine-efagene/qshelter-common",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.63",
|
|
4
4
|
"description": "Shared database schemas and utilities for QShelter services",
|
|
5
5
|
"main": "dist/src/index.js",
|
|
6
6
|
"types": "dist/src/index.d.ts",
|
|
@@ -28,9 +28,11 @@
|
|
|
28
28
|
"prisma"
|
|
29
29
|
],
|
|
30
30
|
"dependencies": {
|
|
31
|
+
"@aws-sdk/client-dynamodb": "^3.962.0",
|
|
31
32
|
"@aws-sdk/client-secrets-manager": "^3.500.0",
|
|
32
33
|
"@aws-sdk/client-sns": "^3.500.0",
|
|
33
34
|
"@aws-sdk/client-ssm": "^3.500.0",
|
|
35
|
+
"@aws-sdk/util-dynamodb": "^3.962.0",
|
|
34
36
|
"@prisma/client": "^7.0.0",
|
|
35
37
|
"dotenv": "^17.2.3",
|
|
36
38
|
"prisma": "^7.0.0"
|