feat(accounts): service-account management, generated passwords, perms-view fixes

Permissions view
- Restore the System Accounts listing path and audit related tabs.
- Lock the framework system_users deny rules (is_system_managed) and
  system-principal global rules; show them read-only.

System vs service accounts
- Only account_type='system' is immutable. service-bot is no longer a
  system principal; ensureInternalAccounts repairs existing rows.
- GET /users now surfaces system/service accounts (gated by
  read/system_users, read/service_users) and Type is the first column.

Service accounts
- UI to create/edit/delete service accounts (API-only, no login).
- Admin API-key management: GET/POST/DELETE /users/:id/keys, restricted
  to service accounts, scope-capped to the account's own permissions,
  with a key-management card on the user detail page.

Password policy
- Admins never set passwords. Creating a user account auto-generates a
  strong password (shown once) and forces a change on first login;
  admin "Reset password" does the same.
- Forced change flow: login returns password_change_required +
  challenge token; POST /auth/change-password validates strength,
  clears the flag, issues the session. Enforced after password and MFA.
- Strength: length 12-128, reject common passwords and email/name.

UI
- Lock indicators in action columns use a shared LockBtn (tooltip,
  aligned with other action buttons).
- Account-type selection in create is a segmented button selector.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-16 08:52:39 +02:00
parent 8200c4650f
commit 851dd389d7
8 changed files with 260 additions and 104 deletions
+26 -14
View File
@@ -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<GlobalRule[]>;
createGlobalRule(token: string, orgIds: string[], data: { user_id?: string | null; activity?: string | null; view?: string | null; modality: 'permission' | 'prohibition'; priority?: number }): Promise<GlobalRule>;
deleteGlobalRule(token: string, orgIds: string[], id: string): Promise<null>;
getSystemPrincipals(token: string, orgIds: string[]): Promise<SystemPrincipal[]>;
getUsers(token: string, orgIds: string[]): Promise<UserDetail[]>;
createUser(token: string, orgIds: string[], data: { email: string; name: string; password?: string }): Promise<UserDetail>;
updateUser(token: string, orgIds: string[], id: string, data: { name?: string; email?: string; is_active?: boolean; password?: string }): Promise<UserDetail>;
createUser(token: string, orgIds: string[], data: { email: string; name: string; password?: string; account_type?: 'user' | 'service' }): Promise<UserDetail>;
updateUser(token: string, orgIds: string[], id: string, data: { name?: string; email?: string; is_active?: boolean; reset_password?: boolean }): Promise<UserDetail>;
deleteUser(token: string, orgIds: string[], id: string): Promise<null>;
getUserKeys(token: string, orgIds: string[], userId: string): Promise<ServiceAccountKey[]>;
createUserKey(token: string, orgIds: string[], userId: string, data: { name: string; description?: string; readonly?: boolean; expires_at?: string | null }): Promise<ServiceAccountKey>;
deleteUserKey(token: string, orgIds: string[], userId: string, keyId: string): Promise<null>;
assignRole(token: string, orgIds: string[], data: { user_id: string; role_id: string; org_id: string }): Promise<unknown>;
removeRole(token: string, orgIds: string[], data: { user_id: string; role_id: string; org_id: string }): Promise<unknown>;
getActivityUsers(token: string, orgIds: string[], name: string): Promise<RuleUser[]>;
@@ -271,13 +281,15 @@ export function createMorbacApi(apiUrl: string): MorbacApi {
createGlobalRule: (t, orgIds, d) => api('POST', '/global-rules', t, orgIds, d) as Promise<GlobalRule>,
deleteGlobalRule: (t, orgIds, id) => api('DELETE', `/global-rules/${id}`, t, orgIds) as Promise<null>,
getSystemPrincipals: (t, orgIds) => api('GET', '/system-principals', t, orgIds) as Promise<SystemPrincipal[]>,
getUsers: (t, orgIds) => api('GET', '/users', t, orgIds) as Promise<UserDetail[]>,
createUser: (t, orgIds, d) => api('POST', '/users', t, orgIds, d) as Promise<UserDetail>,
updateUser: (t, orgIds, id, d) => api('PATCH', `/users/${id}`, t, orgIds, d) as Promise<UserDetail>,
deleteUser: (t, orgIds, id) => api('DELETE', `/users/${id}`, t, orgIds) as Promise<null>,
getUserKeys: (t, orgIds, userId) => api('GET', `/users/${userId}/keys`, t, orgIds) as Promise<ServiceAccountKey[]>,
createUserKey: (t, orgIds, userId, d) => api('POST', `/users/${userId}/keys`, t, orgIds, d) as Promise<ServiceAccountKey>,
deleteUserKey: (t, orgIds, userId, keyId) => api('DELETE', `/users/${userId}/keys/${keyId}`, t, orgIds) as Promise<null>,
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 }),
+1 -1
View File
@@ -10,8 +10,8 @@ export type {
UserRule,
GlobalRule,
RoleHierarchy,
SystemPrincipal,
UserDetail,
ServiceAccountKey,
RuleUser,
EffectivePermission,
Delegation,
+9
View File
@@ -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('');
}
+10 -67
View File
@@ -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;
});
};
}
+29 -22
View File
@@ -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', {
+182
View File
@@ -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<boolean> {
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();
});
};
}
+2
View File
@@ -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));
};
}
+1
View File
@@ -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' },
},
};