feat(api-keys): reusable key-creation modal for profile and service accounts
- Extract the profile API-key UI into a shared ApiKeysManager component (modal create form, full/readonly toggle, and detailed permission-scope picker). Profile and the service-account detail page both use it, so the two interfaces stay consistent and the code isn't duplicated. - Add GET /users/:id/permission-options (service accounts), mirroring GET /me/permissions, so the scope picker shows exactly the pairs the account holds; the same query caps the key server-side. - Service and system accounts never receive notifications: recipient resolution now targets account_type='user' across explicit, org-wide, and org-scoped paths (also fixes a stale 'human' literal that matched nothing). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+4
-2
@@ -112,7 +112,7 @@ export interface ServiceAccountKey {
|
|||||||
description: string | null;
|
description: string | null;
|
||||||
key_prefix: string;
|
key_prefix: string;
|
||||||
readonly: boolean;
|
readonly: boolean;
|
||||||
permission_scope: unknown;
|
permission_scope?: 'inherit' | Array<{ activity: string; view: string }>;
|
||||||
expires_at: string | null;
|
expires_at: string | null;
|
||||||
last_used_at: string | null;
|
last_used_at: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
@@ -196,7 +196,8 @@ export interface MorbacApi {
|
|||||||
updateUser(token: string, orgIds: string[], id: string, data: { name?: string; email?: string; is_active?: boolean; reset_password?: boolean }): 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>;
|
deleteUser(token: string, orgIds: string[], id: string): Promise<null>;
|
||||||
getUserKeys(token: string, orgIds: string[], userId: string): Promise<ServiceAccountKey[]>;
|
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>;
|
getUserPermissionOptions(token: string, orgIds: string[], userId: string): Promise<Array<{ activity: string; view: string }>>;
|
||||||
|
createUserKey(token: string, orgIds: string[], userId: string, data: { name: string; description?: string; readonly?: boolean; expires_at?: string | null; permission_scope?: 'inherit' | Array<{ activity: string; view: string }> }): Promise<ServiceAccountKey>;
|
||||||
deleteUserKey(token: string, orgIds: string[], userId: string, keyId: string): Promise<null>;
|
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>;
|
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>;
|
removeRole(token: string, orgIds: string[], data: { user_id: string; role_id: string; org_id: string }): Promise<unknown>;
|
||||||
@@ -287,6 +288,7 @@ export function createMorbacApi(apiUrl: string): MorbacApi {
|
|||||||
deleteUser: (t, orgIds, id) => api('DELETE', `/users/${id}`, t, orgIds) as Promise<null>,
|
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[]>,
|
getUserKeys: (t, orgIds, userId) => api('GET', `/users/${userId}/keys`, t, orgIds) as Promise<ServiceAccountKey[]>,
|
||||||
|
getUserPermissionOptions: (t, orgIds, userId) => api('GET', `/users/${userId}/permission-options`, t, orgIds) as Promise<Array<{ activity: string; view: string }>>,
|
||||||
createUserKey: (t, orgIds, userId, d) => api('POST', `/users/${userId}/keys`, t, orgIds, d) 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>,
|
deleteUserKey: (t, orgIds, userId, keyId) => api('DELETE', `/users/${userId}/keys/${keyId}`, t, orgIds) as Promise<null>,
|
||||||
|
|
||||||
|
|||||||
+52
-20
@@ -28,6 +28,33 @@ const keyResponse = {
|
|||||||
|
|
||||||
type Scope = 'inherit' | Array<{ activity: string; view: string }>;
|
type Scope = 'inherit' | Array<{ activity: string; view: string }>;
|
||||||
|
|
||||||
|
// Distinct (activity, view) pairs a user/service account currently holds. Used
|
||||||
|
// both to populate the scope picker and to cap a key's scope server-side, so the
|
||||||
|
// two can never diverge. Mirrors GET /me/permissions for the target account.
|
||||||
|
const HELD_PERMS_SQL = `
|
||||||
|
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`;
|
||||||
|
|
||||||
|
async function heldPerms(db: Pool, userId: string): Promise<Array<{ activity: string; view: string }>> {
|
||||||
|
const { rows } = await db.query<{ activity: string; view: string }>(HELD_PERMS_SQL, [userId]);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
// API keys are only manageable for service accounts. Minting a key for a human
|
// 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
|
// ('user') or 'system' account would be impersonation, so those are treated as
|
||||||
// non-existent here (404). Keys inherit the service account's own permissions.
|
// non-existent here (404). Keys inherit the service account's own permissions.
|
||||||
@@ -65,6 +92,30 @@ export function createUserKeyRoutes(db: Pool) {
|
|||||||
return rows;
|
return rows;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get('/users/:id/permission-options', {
|
||||||
|
schema: {
|
||||||
|
tags: ['Management'],
|
||||||
|
summary: 'List a service account delegatable permissions',
|
||||||
|
description: 'Distinct (activity, view) pairs the service account holds, for restricting an API key scope. Requires `read`/`service_users`.',
|
||||||
|
security: [{ oauth2: [] }],
|
||||||
|
params: { type: 'object', properties: { id: { type: 'string', format: 'uuid' } } },
|
||||||
|
response: {
|
||||||
|
200: { type: 'array', items: { type: 'object', properties: { activity: { type: 'string' }, view: { type: 'string' } } } },
|
||||||
|
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;
|
||||||
|
|
||||||
|
return heldPerms(db, id);
|
||||||
|
});
|
||||||
|
|
||||||
app.post('/users/:id/keys', {
|
app.post('/users/:id/keys', {
|
||||||
schema: {
|
schema: {
|
||||||
tags: ['Management'],
|
tags: ['Management'],
|
||||||
@@ -114,26 +165,7 @@ export function createUserKeyRoutes(db: Pool) {
|
|||||||
return reply.code(400).send({ error: 'permission_scope entries must be {activity, view} strings' });
|
return reply.code(400).send({ error: 'permission_scope entries must be {activity, view} strings' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const { rows: held } = await db.query<{ activity: string; view: string }>(
|
const held = await heldPerms(db, id);
|
||||||
`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}`));
|
const heldSet = new Set(held.map((p) => `${p.activity} ${p.view}`));
|
||||||
for (const pair of scope) {
|
for (const pair of scope) {
|
||||||
if (!heldSet.has(`${pair.activity} ${pair.view}`)) {
|
if (!heldSet.has(`${pair.activity} ${pair.view}`)) {
|
||||||
|
|||||||
Reference in New Issue
Block a user