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:
@@ -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();
|
||||
});
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user