diff --git a/src/api.ts b/src/api.ts index e23dc5c..01661af 100644 --- a/src/api.ts +++ b/src/api.ts @@ -80,6 +80,7 @@ export interface GlobalRule { modality: 'permission' | 'prohibition'; priority: number | null; is_system_principal: boolean; + is_system_managed: boolean; } export interface RoleHierarchy { @@ -91,15 +92,6 @@ export interface RoleHierarchy { org_name: string; } -export interface SystemPrincipal { - user_id: string; - user_name: string; - user_email: string | null; - description: string | null; - created_at: string; - global_rules: { id: string; activity: string | null; view: string | null; modality: 'permission' | 'prohibition' }[]; -} - export interface UserDetail { id: string; email: string; @@ -109,6 +101,22 @@ export interface UserDetail { needs_placement?: boolean; created_at: string; roles: { role_id: string; role_name: string; org_id: string; org_name: string }[]; + // Present only on the create/reset response for 'user' accounts: the one-time + // generated password the account must change on first login. + generated_password?: string; +} + +export interface ServiceAccountKey { + id: string; + name: string; + description: string | null; + key_prefix: string; + readonly: boolean; + permission_scope: unknown; + expires_at: string | null; + last_used_at: string | null; + created_at: string; + key?: string; } export interface RuleUser { @@ -183,11 +191,13 @@ export interface MorbacApi { getGlobalRules(token: string, orgIds: string[]): Promise; createGlobalRule(token: string, orgIds: string[], data: { user_id?: string | null; activity?: string | null; view?: string | null; modality: 'permission' | 'prohibition'; priority?: number }): Promise; deleteGlobalRule(token: string, orgIds: string[], id: string): Promise; - getSystemPrincipals(token: string, orgIds: string[]): Promise; getUsers(token: string, orgIds: string[]): Promise; - createUser(token: string, orgIds: string[], data: { email: string; name: string; password?: string }): Promise; - updateUser(token: string, orgIds: string[], id: string, data: { name?: string; email?: string; is_active?: boolean; password?: string }): Promise; + createUser(token: string, orgIds: string[], data: { email: string; name: string; password?: string; account_type?: 'user' | 'service' }): Promise; + updateUser(token: string, orgIds: string[], id: string, data: { name?: string; email?: string; is_active?: boolean; reset_password?: boolean }): Promise; deleteUser(token: string, orgIds: string[], id: string): Promise; + getUserKeys(token: string, orgIds: string[], userId: string): Promise; + createUserKey(token: string, orgIds: string[], userId: string, data: { name: string; description?: string; readonly?: boolean; expires_at?: string | null }): Promise; + deleteUserKey(token: string, orgIds: string[], userId: string, keyId: string): Promise; assignRole(token: string, orgIds: string[], data: { user_id: string; role_id: string; org_id: string }): Promise; removeRole(token: string, orgIds: string[], data: { user_id: string; role_id: string; org_id: string }): Promise; getActivityUsers(token: string, orgIds: string[], name: string): Promise; @@ -271,13 +281,15 @@ export function createMorbacApi(apiUrl: string): MorbacApi { createGlobalRule: (t, orgIds, d) => api('POST', '/global-rules', t, orgIds, d) as Promise, deleteGlobalRule: (t, orgIds, id) => api('DELETE', `/global-rules/${id}`, t, orgIds) as Promise, - getSystemPrincipals: (t, orgIds) => api('GET', '/system-principals', t, orgIds) as Promise, - getUsers: (t, orgIds) => api('GET', '/users', t, orgIds) as Promise, createUser: (t, orgIds, d) => api('POST', '/users', t, orgIds, d) as Promise, updateUser: (t, orgIds, id, d) => api('PATCH', `/users/${id}`, t, orgIds, d) as Promise, deleteUser: (t, orgIds, id) => api('DELETE', `/users/${id}`, t, orgIds) as Promise, + getUserKeys: (t, orgIds, userId) => api('GET', `/users/${userId}/keys`, t, orgIds) as Promise, + createUserKey: (t, orgIds, userId, d) => api('POST', `/users/${userId}/keys`, t, orgIds, d) as Promise, + deleteUserKey: (t, orgIds, userId, keyId) => api('DELETE', `/users/${userId}/keys/${keyId}`, t, orgIds) as Promise, + assignRole: (t, orgIds, d) => api('POST', `/users/${d.user_id}/roles`, t, orgIds, { role_id: d.role_id, org_id: d.org_id }), removeRole: (t, orgIds, d) => api('DELETE', `/users/${d.user_id}/roles`, t, orgIds, { role_id: d.role_id, org_id: d.org_id }), diff --git a/src/index.ts b/src/index.ts index 8360e4b..47d7f95 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,8 +10,8 @@ export type { UserRule, GlobalRule, RoleHierarchy, - SystemPrincipal, UserDetail, + ServiceAccountKey, RuleUser, EffectivePermission, Delegation, diff --git a/src/password.ts b/src/password.ts new file mode 100644 index 0000000..670e4ab --- /dev/null +++ b/src/password.ts @@ -0,0 +1,9 @@ +import { randomBytes } from 'node:crypto'; + +// Unambiguous alphabet (no 0/O/1/l/I) so generated passwords are easy to read +// and copy. 20 chars over 54 symbols is ~115 bits of entropy. +const ALPHA = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789'; + +export function generatePassword(length = 20): string { + return Array.from(randomBytes(length)).map((b) => ALPHA[b % ALPHA.length]).join(''); +} diff --git a/src/routes/globals.ts b/src/routes/globals.ts index 394a0c4..ad8ddbc 100644 --- a/src/routes/globals.ts +++ b/src/routes/globals.ts @@ -28,7 +28,8 @@ export function createGlobalRoutes(db: Pool) { COALESCE(u.name, gr.user_id::TEXT) AS user_name, u.email AS user_email, gr.activity, gr.view, gr.modality, gr.priority, - (sp.user_id IS NOT NULL) AS is_system_principal + (sp.user_id IS NOT NULL) AS is_system_principal, + (gr.user_id IS NULL AND gr.view = 'system_users' AND gr.modality = 'prohibition') AS is_system_managed FROM morbac.global_rules gr LEFT JOIN app.users u ON u.id = gr.user_id LEFT JOIN morbac.system_principals sp ON sp.user_id = gr.user_id @@ -86,7 +87,8 @@ export function createGlobalRoutes(db: Pool) { (SELECT name FROM app.users WHERE id = $1) AS user_name, (SELECT email FROM app.users WHERE id = $1) AS user_email, activity, view, modality, priority, - FALSE AS is_system_principal`, + FALSE AS is_system_principal, + (user_id IS NULL AND view = 'system_users' AND modality = 'prohibition') AS is_system_managed`, [user_id ?? null, activity ?? null, view ?? null, modality, priority ?? null], ); return reply.code(201).send(rows[0]); @@ -113,11 +115,15 @@ export function createGlobalRoutes(db: Pool) { if (!user) return reply.code(401).send({ error: 'Authentication required' }); const { id } = req.params as { id: string }; - const { rows: [gr] } = await db.query<{ user_id: string | null }>( - 'SELECT user_id FROM morbac.global_rules WHERE id = $1', [id], + const { rows: [gr] } = await db.query<{ user_id: string | null; is_system_managed: boolean }>( + `SELECT user_id, + (user_id IS NULL AND view = 'system_users' AND modality = 'prohibition') AS is_system_managed + FROM morbac.global_rules WHERE id = $1`, [id], ); if (!gr) return reply.code(404).send({ error: 'Not found' }); + if (gr.is_system_managed) return reply.code(403).send({ error: 'System-managed rules are immutable' }); + if (gr.user_id) { const { rows: [sp] } = await db.query( 'SELECT 1 FROM morbac.system_principals WHERE user_id = $1', [gr.user_id], @@ -130,68 +136,5 @@ export function createGlobalRoutes(db: Pool) { await db.query('DELETE FROM morbac.global_rules WHERE id = $1', [id]); return reply.code(204).send(); }); - - app.get('/system-principals', { - schema: { - tags: ['Management'], - summary: 'List system principals', - description: 'Read-only registry of system-level accounts with immutable global rules. Requires `read`/`rules` in the system org.', - security: [{ oauth2: [] }], - response: { - 200: { - type: 'array', - items: { - type: 'object', - properties: { - user_id: { type: 'string', format: 'uuid' }, - user_name: { type: 'string' }, - user_email: { type: 'string', nullable: true }, - description: { type: 'string', nullable: true }, - global_rules: { - type: 'array', - items: { - type: 'object', - properties: { - id: { type: 'string', format: 'uuid' }, - activity: { type: 'string', nullable: true }, - view: { type: 'string', nullable: true }, - modality: { type: 'string', enum: ['permission', 'prohibition'] }, - }, - }, - }, - }, - }, - }, - 401: unauthorized, 403: forbidden, - }, - }, - }, async (req, reply) => { - const user = getUser(req); - if (!user) return reply.code(401).send({ error: 'Authentication required' }); - - if (!await hasPermission(db, req, reply, SYSTEM_ORG_ID, 'read', 'rules')) return reply; - - const { rows } = await db.query( - `SELECT sp.user_id, - COALESCE(u.name, sp.user_id::TEXT) AS user_name, - u.email AS user_email, - sp.description, - COALESCE( - json_agg(json_build_object( - 'id', gr.id, - 'activity', gr.activity, - 'view', gr.view, - 'modality', gr.modality - )) FILTER (WHERE gr.id IS NOT NULL), - '[]'::json - ) AS global_rules - FROM morbac.system_principals sp - LEFT JOIN app.users u ON u.id = sp.user_id - LEFT JOIN morbac.global_rules gr ON gr.user_id = sp.user_id - GROUP BY sp.user_id, u.name, u.email, sp.description - ORDER BY u.name`, - ); - return rows; - }); }; } diff --git a/src/routes/user-crud.ts b/src/routes/user-crud.ts index 78bed7f..0de2ab7 100644 --- a/src/routes/user-crud.ts +++ b/src/routes/user-crud.ts @@ -6,6 +6,7 @@ import { getUser, getOrgScope, orgScopeExpr, hasPermission, getAccountType, viewForType, SYSTEM_ORG_ID, } from '../permissions.js'; import { userResponse, notFound, forbidden, unauthorized, badRequest } from '../schemas.js'; +import { generatePassword } from '../password.js'; export function createUserCrudRoutes(db: Pool, checkLimit?: CheckLimitFn) { return async function userCrudRoutes(app: FastifyInstance) { @@ -46,7 +47,7 @@ export function createUserCrudRoutes(db: Pool, checkLimit?: CheckLimitFn) { const isSystemScope = orgIds.includes(SYSTEM_ORG_ID); const placement = canReadGlobally ? ' OR u.needs_placement' : ''; const whereClause = expr && !isSystemScope - ? `WHERE (u.id IN (SELECT ur2.user_id FROM morbac.user_roles ur2 WHERE ${expr})${placement})` + ? `WHERE (u.id IN (SELECT ur2.user_id FROM morbac.user_roles ur2 WHERE ${expr})${placement} OR u.account_type IN ('system', 'service'))` : ''; const { rows } = await db.query( `SELECT @@ -79,7 +80,7 @@ export function createUserCrudRoutes(db: Pool, checkLimit?: CheckLimitFn) { schema: { tags: ['Management'], summary: 'Create a user', - description: 'Creates a user account. Requires `create`/`users` (or `create`/`service_users`) permission in the scoped org.', + description: 'Creates a user account. For `user` accounts a strong password is auto-generated and returned once as `generated_password`; the account must change it on first login. `service` accounts have no password (API-only). Requires `create`/`users` (or `create`/`service_users`) permission.', security: [{ oauth2: [] }], body: { type: 'object', @@ -87,13 +88,12 @@ export function createUserCrudRoutes(db: Pool, checkLimit?: CheckLimitFn) { properties: { email: { type: 'string', format: 'email', maxLength: 320 }, name: { type: 'string', minLength: 1, maxLength: 255 }, - password: { type: 'string', minLength: 8, maxLength: 1024 }, account_type: { type: 'string', enum: ['user', 'service'], default: 'user' }, is_active: { type: 'boolean', default: true }, }, }, response: { - 201: userResponse, + 201: { ...userResponse, properties: { ...userResponse.properties, generated_password: { type: 'string' } } }, 400: badRequest, 401: unauthorized, 403: forbidden, 409: { description: 'Email already in use.' }, }, @@ -106,8 +106,8 @@ export function createUserCrudRoutes(db: Pool, checkLimit?: CheckLimitFn) { if (orgIds === null) return reply; const orgId = orgIds[0] ?? null; - const { email: rawEmail, name, password, account_type = 'user', is_active = true } = req.body as { - email: string; name: string; password?: string; + const { email: rawEmail, name, account_type = 'user', is_active = true } = req.body as { + email: string; name: string; account_type?: 'user' | 'service'; is_active?: boolean; }; const email = rawEmail.trim().toLowerCase(); @@ -124,15 +124,19 @@ export function createUserCrudRoutes(db: Pool, checkLimit?: CheckLimitFn) { if (!ok) return reply; } - const hash = password ? await bcrypt.hash(password, 12) : null; + // Passwords are never admin-supplied. User accounts get a strong generated + // password (returned once) and must change it on first login. Service + // accounts are API-only and have no password. + const generated = account_type === 'user' ? generatePassword() : null; + const hash = generated ? await bcrypt.hash(generated, 12) : null; try { const { rows } = await db.query( - `INSERT INTO app.users (email, name, password_hash, account_type, is_active) - VALUES ($1, $2, $3, $4, $5) + `INSERT INTO app.users (email, name, password_hash, account_type, is_active, must_change_password) + VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, email, name, account_type, is_active, needs_placement, created_at`, - [email, name, hash, account_type, is_active], + [email, name, hash, account_type, is_active, generated !== null], ); - return reply.code(201).send({ ...rows[0], roles: [] }); + return reply.code(201).send({ ...rows[0], roles: [], ...(generated && { generated_password: generated }) }); } catch (e: unknown) { if ((e as { code?: string }).code === '23505') return reply.code(409).send({ error: 'Email already in use' }); throw e; @@ -143,20 +147,20 @@ export function createUserCrudRoutes(db: Pool, checkLimit?: CheckLimitFn) { schema: { tags: ['Management'], summary: 'Update a user', - description: "Updates a user's name, email, password, or active status. Requires `update`/`users` (or `update`/`service_users`) permission.", + description: "Updates a user's name, email, or active status. Passwords are never admin-supplied: set `reset_password: true` to generate a new strong password (returned once as `generated_password`) which the user must change on next login. Requires `update`/`users` (or `update`/`service_users`) permission.", security: [{ oauth2: [] }], params: { type: 'object', properties: { id: { type: 'string', format: 'uuid' } } }, body: { type: 'object', properties: { - name: { type: 'string', minLength: 1, maxLength: 255 }, - email: { type: 'string', format: 'email', maxLength: 320 }, - password: { type: 'string', minLength: 8, maxLength: 1024 }, - is_active: { type: 'boolean' }, + name: { type: 'string', minLength: 1, maxLength: 255 }, + email: { type: 'string', format: 'email', maxLength: 320 }, + is_active: { type: 'boolean' }, + reset_password: { type: 'boolean' }, }, }, response: { - 200: userResponse, + 200: { ...userResponse, properties: { ...userResponse.properties, generated_password: { type: 'string' } } }, 400: badRequest, 401: unauthorized, 403: forbidden, 404: notFound, }, }, @@ -174,22 +178,25 @@ export function createUserCrudRoutes(db: Pool, checkLimit?: CheckLimitFn) { if (type === 'system') return reply.code(403).send({ error: 'System entities are immutable' }); if (!await hasPermission(db, req, reply, orgId, 'update', viewForType(type))) return reply; - const { name, email, password, is_active } = req.body as { - name?: string; email?: string; password?: string; is_active?: boolean; + const { name, email, is_active, reset_password } = req.body as { + name?: string; email?: string; is_active?: boolean; reset_password?: boolean; }; - const hash = password ? await bcrypt.hash(password, 12) : null; + // Only 'user' accounts have passwords. Service accounts authenticate via API keys. + const generated = reset_password && type === 'user' ? generatePassword() : null; + const hash = generated ? await bcrypt.hash(generated, 12) : null; const { rows } = await db.query( `UPDATE app.users SET name = COALESCE($1, name), email = COALESCE($2, email), is_active = COALESCE($3, is_active), - password_hash = COALESCE($4, password_hash) + password_hash = COALESCE($4, password_hash), + must_change_password = CASE WHEN $4 IS NOT NULL THEN TRUE ELSE must_change_password END WHERE id = $5 RETURNING id, email, name, account_type, is_active, needs_placement, created_at`, [name ?? null, email ?? null, is_active ?? null, hash, id], ); if (!rows[0]) return reply.code(404).send({ error: 'Not found' }); - return rows[0]; + return reply.send({ ...rows[0], ...(generated && { generated_password: generated }) }); }); app.delete('/users/:id', { diff --git a/src/routes/user-keys.ts b/src/routes/user-keys.ts new file mode 100644 index 0000000..2ab2c46 --- /dev/null +++ b/src/routes/user-keys.ts @@ -0,0 +1,182 @@ +import { randomBytes, createHash } from 'node:crypto'; +import type { FastifyInstance, FastifyReply } from 'fastify'; +import type { Pool } from 'pg'; +import { getUser, getOrgScope, hasPermission, getAccountType } from '../permissions.js'; +import { notFound, forbidden, unauthorized, badRequest } from '../schemas.js'; + +function generateKey(): { raw: string; hash: string; prefix: string } { + const raw = 'pgm_' + randomBytes(16).toString('hex'); + const hash = createHash('sha256').update(raw).digest('hex'); + const prefix = raw.slice(0, 12); + return { raw, hash, prefix }; +} + +const keyResponse = { + type: 'object', + properties: { + id: { type: 'string', format: 'uuid' }, + name: { type: 'string' }, + description: { type: 'string', nullable: true }, + key_prefix: { type: 'string' }, + readonly: { type: 'boolean' }, + permission_scope: {}, + expires_at: { type: 'string', format: 'date-time', nullable: true }, + last_used_at: { type: 'string', format: 'date-time', nullable: true }, + created_at: { type: 'string', format: 'date-time' }, + }, +}; + +type Scope = 'inherit' | Array<{ activity: string; view: string }>; + +// API keys are only manageable for service accounts. Minting a key for a human +// ('user') or 'system' account would be impersonation, so those are treated as +// non-existent here (404). Keys inherit the service account's own permissions. +export function createUserKeyRoutes(db: Pool) { + return async function userKeyRoutes(app: FastifyInstance) { + async function ensureService(id: string, reply: FastifyReply): Promise { + const type = await getAccountType(db, id); + if (type !== 'service') { await reply.code(404).send({ error: 'Not found' }); return false; } + return true; + } + + app.get('/users/:id/keys', { + schema: { + tags: ['Management'], + summary: 'List a service account API keys', + description: 'Lists API keys belonging to a service account. Requires `read`/`service_users`.', + security: [{ oauth2: [] }], + params: { type: 'object', properties: { id: { type: 'string', format: 'uuid' } } }, + response: { 200: { type: 'array', items: keyResponse }, 401: unauthorized, 403: forbidden, 404: notFound }, + }, + }, async (req, reply) => { + const user = getUser(req); + if (!user) return reply.code(401).send({ error: 'Authentication required' }); + const orgIds = getOrgScope(req, reply, false); + if (orgIds === null) return reply; + const { id } = req.params as { id: string }; + if (!await ensureService(id, reply)) return reply; + if (!await hasPermission(db, req, reply, orgIds[0] ?? null, 'read', 'service_users')) return reply; + + const { rows } = await db.query( + `SELECT id, name, description, key_prefix, readonly, permission_scope, expires_at, last_used_at, created_at + FROM app.api_keys WHERE user_id = $1 ORDER BY created_at DESC`, + [id], + ); + return rows; + }); + + app.post('/users/:id/keys', { + schema: { + tags: ['Management'], + summary: 'Create an API key for a service account', + description: 'Mints an API key for a service account. The full key is returned once. Requires `create`/`service_users`.', + security: [{ oauth2: [] }], + params: { type: 'object', properties: { id: { type: 'string', format: 'uuid' } } }, + body: { + type: 'object', + required: ['name'], + properties: { + name: { type: 'string', minLength: 1, maxLength: 255 }, + description: { type: 'string', maxLength: 1000 }, + readonly: { type: 'boolean', default: false }, + expires_at: { type: 'string', format: 'date-time', nullable: true }, + permission_scope: {}, + }, + }, + response: { + 201: { ...keyResponse, properties: { ...keyResponse.properties, key: { type: 'string' } } }, + 400: badRequest, 401: unauthorized, 403: forbidden, 404: notFound, 409: { description: 'A key with this name already exists.' }, + }, + }, + }, async (req, reply) => { + const user = getUser(req); + if (!user) return reply.code(401).send({ error: 'Authentication required' }); + const orgIds = getOrgScope(req, reply, false); + if (orgIds === null) return reply; + const { id } = req.params as { id: string }; + if (!await ensureService(id, reply)) return reply; + if (!await hasPermission(db, req, reply, orgIds[0] ?? null, 'create', 'service_users')) return reply; + + const { name, description, readonly = false, expires_at, permission_scope } = req.body as { + name: string; description?: string; readonly?: boolean; expires_at?: string | null; permission_scope?: Scope; + }; + + const scope: Scope = permission_scope ?? 'inherit'; + if (scope !== 'inherit' && !Array.isArray(scope)) { + return reply.code(400).send({ error: 'permission_scope must be "inherit" or an array of {activity, view}' }); + } + if (Array.isArray(scope)) { + if (scope.length > 200) return reply.code(400).send({ error: 'permission_scope has too many entries' }); + for (const pair of scope) { + if (!pair || typeof pair.activity !== 'string' || typeof pair.view !== 'string' + || pair.activity.length < 1 || pair.activity.length > 64 + || pair.view.length < 1 || pair.view.length > 64) { + return reply.code(400).send({ error: 'permission_scope entries must be {activity, view} strings' }); + } + } + const { rows: held } = await db.query<{ activity: string; view: string }>( + `SELECT DISTINCT activity, view FROM ( + SELECT activity, view FROM morbac.user_rules + WHERE user_id = $1 AND modality = 'permission' AND activity IS NOT NULL AND view IS NOT NULL + UNION + SELECT activity, view FROM morbac.global_rules + WHERE (user_id = $1 OR user_id IS NULL) AND modality = 'permission' AND activity IS NOT NULL AND view IS NOT NULL + UNION + SELECT r.activity, r.view FROM morbac.rules r + JOIN morbac.user_roles ur ON ur.role_id = r.role_id + WHERE ur.user_id = $1 AND r.modality = 'permission' AND r.is_active = TRUE + UNION + SELECT a.name, v.name FROM morbac.activities a CROSS JOIN morbac.views v + WHERE EXISTS ( + SELECT 1 FROM morbac.global_rules gr + WHERE gr.user_id = $1 AND gr.modality = 'permission' AND gr.activity IS NULL AND gr.view IS NULL + ) + ) t`, + [id], + ); + const heldSet = new Set(held.map((p) => `${p.activity} ${p.view}`)); + for (const pair of scope) { + if (!heldSet.has(`${pair.activity} ${pair.view}`)) { + return reply.code(400).send({ error: 'Scope includes a permission the service account does not hold' }); + } + } + } + + const { raw, hash, prefix } = generateKey(); + try { + const { rows } = await db.query( + `INSERT INTO app.api_keys (user_id, name, description, key_hash, key_prefix, readonly, expires_at, permission_scope) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb) + RETURNING id, name, description, key_prefix, readonly, permission_scope, expires_at, last_used_at, created_at`, + [id, name, description ?? null, hash, prefix, readonly, expires_at ?? null, JSON.stringify(scope)], + ); + return reply.code(201).send({ ...rows[0], key: raw }); + } catch (e: unknown) { + if ((e as { code?: string }).code === '23505') return reply.code(409).send({ error: 'A key with this name already exists' }); + throw e; + } + }); + + app.delete('/users/:id/keys/:keyId', { + schema: { + tags: ['Management'], + summary: 'Revoke a service account API key', + description: 'Revokes an API key belonging to a service account. Requires `delete`/`service_users`.', + security: [{ oauth2: [] }], + params: { type: 'object', properties: { id: { type: 'string', format: 'uuid' }, keyId: { type: 'string', format: 'uuid' } } }, + response: { 204: { type: 'null', description: 'Key revoked.' }, 401: unauthorized, 403: forbidden, 404: notFound }, + }, + }, async (req, reply) => { + const user = getUser(req); + if (!user) return reply.code(401).send({ error: 'Authentication required' }); + const orgIds = getOrgScope(req, reply, false); + if (orgIds === null) return reply; + const { id, keyId } = req.params as { id: string; keyId: string }; + if (!await ensureService(id, reply)) return reply; + if (!await hasPermission(db, req, reply, orgIds[0] ?? null, 'delete', 'service_users')) return reply; + + await db.query('DELETE FROM app.api_keys WHERE id = $1 AND user_id = $2', [keyId, id]); + return reply.code(204).send(); + }); + }; +} diff --git a/src/routes/users.ts b/src/routes/users.ts index 66cfeb4..b5dfb6d 100644 --- a/src/routes/users.ts +++ b/src/routes/users.ts @@ -3,10 +3,12 @@ import type { Pool } from 'pg'; import type { CheckLimitFn } from '../plugin.js'; import { createUserCrudRoutes } from './user-crud.js'; import { createUserRoleRoutes } from './user-roles.js'; +import { createUserKeyRoutes } from './user-keys.js'; export function createUserRoutes(db: Pool, checkLimit?: CheckLimitFn) { return async function userRoutes(app: FastifyInstance) { await app.register(createUserCrudRoutes(db, checkLimit)); await app.register(createUserRoleRoutes(db)); + await app.register(createUserKeyRoutes(db)); }; } diff --git a/src/schemas.ts b/src/schemas.ts index 9d40e4a..1d76c04 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -105,6 +105,7 @@ export const globalRuleResponse = { modality: { type: 'string', enum: ['permission', 'prohibition'] }, priority: { type: 'integer', nullable: true }, is_system_principal: { type: 'boolean' }, + is_system_managed: { type: 'boolean' }, }, };