@valentine-efagene/qshelter-common 2.0.61 → 2.0.64
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 +2 -0
- package/dist/src/auth/index.js +2 -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/package.json +3 -1
- package/prisma/migrations/20260105130633_add_api_keys/migration.sql +26 -0
- package/prisma/schema.prisma +57 -0
|
@@ -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
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.64",
|
|
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"
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
-- CreateTable
|
|
2
|
+
CREATE TABLE `api_keys` (
|
|
3
|
+
`id` VARCHAR(191) NOT NULL,
|
|
4
|
+
`tenantId` VARCHAR(191) NOT NULL,
|
|
5
|
+
`name` VARCHAR(191) NOT NULL,
|
|
6
|
+
`description` TEXT NULL,
|
|
7
|
+
`provider` VARCHAR(191) NOT NULL,
|
|
8
|
+
`secretRef` VARCHAR(191) NOT NULL,
|
|
9
|
+
`scopes` JSON NOT NULL,
|
|
10
|
+
`enabled` BOOLEAN NOT NULL DEFAULT true,
|
|
11
|
+
`expiresAt` DATETIME(3) NULL,
|
|
12
|
+
`lastUsedAt` DATETIME(3) NULL,
|
|
13
|
+
`revokedAt` DATETIME(3) NULL,
|
|
14
|
+
`revokedBy` VARCHAR(191) NULL,
|
|
15
|
+
`createdBy` VARCHAR(191) NULL,
|
|
16
|
+
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
|
17
|
+
`updatedAt` DATETIME(3) NOT NULL,
|
|
18
|
+
|
|
19
|
+
INDEX `api_keys_tenantId_idx`(`tenantId`),
|
|
20
|
+
INDEX `api_keys_provider_idx`(`provider`),
|
|
21
|
+
INDEX `api_keys_enabled_idx`(`enabled`),
|
|
22
|
+
PRIMARY KEY (`id`)
|
|
23
|
+
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
|
24
|
+
|
|
25
|
+
-- AddForeignKey
|
|
26
|
+
ALTER TABLE `api_keys` ADD CONSTRAINT `api_keys_tenantId_fkey` FOREIGN KEY (`tenantId`) REFERENCES `tenants`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
package/prisma/schema.prisma
CHANGED
|
@@ -321,10 +321,67 @@ model Tenant {
|
|
|
321
321
|
documentTemplates DocumentTemplate[]
|
|
322
322
|
offerLetters OfferLetter[]
|
|
323
323
|
|
|
324
|
+
// API keys for third-party integrations
|
|
325
|
+
apiKeys ApiKey[]
|
|
326
|
+
|
|
324
327
|
@@index([subdomain])
|
|
325
328
|
@@map("tenants")
|
|
326
329
|
}
|
|
327
330
|
|
|
331
|
+
// =============================================================================
|
|
332
|
+
// API KEYS - Third-party integration credentials
|
|
333
|
+
// =============================================================================
|
|
334
|
+
// ApiKey enables partners/integrations to authenticate via token exchange.
|
|
335
|
+
//
|
|
336
|
+
// Flow:
|
|
337
|
+
// 1. Admin creates API key for a partner (POST /api-keys)
|
|
338
|
+
// 2. System generates secret, stores in Secrets Manager, returns id.secret ONCE
|
|
339
|
+
// 3. Partner calls token endpoint with id.secret (POST /api-keys/:id/token)
|
|
340
|
+
// 4. Token endpoint validates via Secrets Manager, returns short-lived JWT
|
|
341
|
+
// 5. Partner uses JWT for API requests; authorizer validates + resolves scopes
|
|
342
|
+
//
|
|
343
|
+
// Security:
|
|
344
|
+
// - Raw secret stored ONLY in AWS Secrets Manager (secretRef = ARN)
|
|
345
|
+
// - Secret returned only once at creation; admin must rotate if lost
|
|
346
|
+
// - Scopes define allowed operations (e.g., ["contract:read", "payment:read"])
|
|
347
|
+
// - Short-lived JWTs (5-15 min) minimize exposure on key compromise
|
|
348
|
+
// =============================================================================
|
|
349
|
+
|
|
350
|
+
model ApiKey {
|
|
351
|
+
id String @id @default(cuid())
|
|
352
|
+
tenantId String
|
|
353
|
+
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
|
354
|
+
|
|
355
|
+
// Identification
|
|
356
|
+
name String // Human-readable name (e.g., "Paystack Integration")
|
|
357
|
+
description String? @db.Text // Optional description
|
|
358
|
+
provider String // Partner/vendor name (e.g., "paystack", "flutterwave")
|
|
359
|
+
|
|
360
|
+
// Secret management (NEVER store raw secret in DB)
|
|
361
|
+
secretRef String // AWS Secrets Manager ARN or name
|
|
362
|
+
|
|
363
|
+
// Permissions - scopes this API key is allowed to request
|
|
364
|
+
// Examples: ["contract:read", "payment:*", "property:read"]
|
|
365
|
+
scopes Json // JSON array of scope strings
|
|
366
|
+
|
|
367
|
+
// Lifecycle
|
|
368
|
+
enabled Boolean @default(true)
|
|
369
|
+
expiresAt DateTime? // Optional expiration date
|
|
370
|
+
lastUsedAt DateTime? // Updated on each token exchange
|
|
371
|
+
revokedAt DateTime? // Set when key is revoked
|
|
372
|
+
revokedBy String? // User ID who revoked
|
|
373
|
+
|
|
374
|
+
// Audit
|
|
375
|
+
createdBy String? // User ID who created
|
|
376
|
+
createdAt DateTime @default(now())
|
|
377
|
+
updatedAt DateTime @updatedAt
|
|
378
|
+
|
|
379
|
+
@@index([tenantId])
|
|
380
|
+
@@index([provider])
|
|
381
|
+
@@index([enabled])
|
|
382
|
+
@@map("api_keys")
|
|
383
|
+
}
|
|
384
|
+
|
|
328
385
|
model RefreshToken {
|
|
329
386
|
id String @id @default(cuid())
|
|
330
387
|
// Use the JWT `jti` for indexed lookups and keep the raw JWT (optional)
|