@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.
@@ -0,0 +1,2 @@
1
+ export * from './policy-evaluator';
2
+ export * from './types';
@@ -0,0 +1,2 @@
1
+ export * from './policy-evaluator';
2
+ export * from './types';
@@ -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
+ }
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Auth types for JWT payloads and authorization context
3
+ */
4
+ export {};
@@ -8,3 +8,4 @@ export * from './prisma/user';
8
8
  export * from './prisma/tenant';
9
9
  export * from './middleware';
10
10
  export * from './events';
11
+ export * from './auth';
package/dist/src/index.js CHANGED
@@ -8,3 +8,4 @@ export * from './prisma/user';
8
8
  export * from './prisma/tenant';
9
9
  export * from './middleware';
10
10
  export * from './events';
11
+ export * from './auth';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@valentine-efagene/qshelter-common",
3
- "version": "2.0.61",
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;
@@ -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)