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:
+26
-14
@@ -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
@@ -10,8 +10,8 @@ export type {
|
||||
UserRule,
|
||||
GlobalRule,
|
||||
RoleHierarchy,
|
||||
SystemPrincipal,
|
||||
UserDetail,
|
||||
ServiceAccountKey,
|
||||
RuleUser,
|
||||
EffectivePermission,
|
||||
Delegation,
|
||||
|
||||
@@ -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
@@ -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;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
+26
-19
@@ -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,7 +147,7 @@ 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: {
|
||||
@@ -151,12 +155,12 @@ export function createUserCrudRoutes(db: Pool, checkLimit?: CheckLimitFn) {
|
||||
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' },
|
||||
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', {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -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));
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user