From dfe8c8c322862dc8f9a6af28e5e375505cd37fd7 Mon Sep 17 00:00:00 2001 From: Marc Villain Date: Wed, 3 Jun 2026 19:54:20 +0200 Subject: [PATCH] feat(mappings): rework interface --- package.json | 12 ++++++------ src/api.ts | 3 ++- src/plugin.ts | 13 +++++++++++-- src/routes/orgs.ts | 30 +++++++++++++++++++++--------- src/routes/users.ts | 25 +++++++++++++++---------- src/schemas.ts | 7 ++++--- 6 files changed, 59 insertions(+), 31 deletions(-) diff --git a/package.json b/package.json index 89b3c70..67e80e4 100644 --- a/package.json +++ b/package.json @@ -4,12 +4,12 @@ "type": "module", "description": "Crudy pgmorbac — Multi-OrBAC permission helpers for Fastify and PostgreSQL", "exports": { - ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }, - "./permissions": { "types": "./dist/permissions.d.ts", "default": "./dist/permissions.js" }, - "./proxy": { "types": "./dist/proxy.d.ts", "default": "./dist/proxy.js" }, - "./plugin": { "types": "./dist/plugin.d.ts", "default": "./dist/plugin.js" }, - "./schemas": { "types": "./dist/schemas.d.ts", "default": "./dist/schemas.js" }, - "./api": { "types": "./dist/api.d.ts", "default": "./dist/api.js" } + ".": { "types": "./dist/index.d.ts", "source": "./src/index.ts", "default": "./dist/index.js" }, + "./permissions": { "types": "./dist/permissions.d.ts", "source": "./src/permissions.ts", "default": "./dist/permissions.js" }, + "./proxy": { "types": "./dist/proxy.d.ts", "source": "./src/proxy.ts", "default": "./dist/proxy.js" }, + "./plugin": { "types": "./dist/plugin.d.ts", "source": "./src/plugin.ts", "default": "./dist/plugin.js" }, + "./schemas": { "types": "./dist/schemas.d.ts", "source": "./src/schemas.ts", "default": "./dist/schemas.js" }, + "./api": { "types": "./dist/api.d.ts", "source": "./src/api.ts", "default": "./dist/api.js" } }, "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/src/api.ts b/src/api.ts index 95318e8..e6a0abd 100644 --- a/src/api.ts +++ b/src/api.ts @@ -106,6 +106,7 @@ export interface UserDetail { name: string; account_type: 'user' | 'system' | 'service'; is_active: boolean; + needs_placement?: boolean; created_at: string; roles: { role_id: string; role_name: string; org_id: string; org_name: string }[]; } @@ -166,7 +167,7 @@ export interface MorbacApi { 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; + 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; deleteUser(token: string, orgIds: string[], id: string): Promise; assignRole(token: string, orgIds: string[], data: { user_id: string; role_id: string; org_id: string }): Promise; diff --git a/src/plugin.ts b/src/plugin.ts index 807935a..b473c51 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -14,15 +14,24 @@ export type CheckLimitFn = ( params?: unknown[], ) => Promise; +export interface OrgEvent { + action: 'created' | 'updated' | 'deleted'; + org: { id: string; name?: string; parent_id?: string | null; metadata?: Record; reparented?: boolean }; + actorId: string | null; +} + +export type OrgEventSink = (event: OrgEvent) => void; + export interface MgmtPluginOptions { db: Pool; checkLimit?: CheckLimitFn; + emitOrgEvent?: OrgEventSink; } export function createMgmtRoutes(opts: MgmtPluginOptions) { - const { db, checkLimit } = opts; + const { db, checkLimit, emitOrgEvent } = opts; return async function mgmtRoutes(app: FastifyInstance) { - await app.register(createOrgRoutes(db, checkLimit)); + await app.register(createOrgRoutes(db, checkLimit, emitOrgEvent)); await app.register(createRoleRoutes(db, checkLimit)); await app.register(createRuleRoutes(db)); await app.register(createUserRoutes(db, checkLimit)); diff --git a/src/routes/orgs.ts b/src/routes/orgs.ts index 82763c9..25dc2a0 100644 --- a/src/routes/orgs.ts +++ b/src/routes/orgs.ts @@ -1,12 +1,14 @@ import type { FastifyInstance } from 'fastify'; import type { Pool } from 'pg'; -import type { CheckLimitFn } from '../plugin.js'; +import type { CheckLimitFn, OrgEventSink } from '../plugin.js'; import { getUser, getOrgScope, orgScopeExpr, hasPermission, SYSTEM_ORG_ID, } from '../permissions.js'; import { orgResponse, notFound, forbidden, unauthorized, badRequest } from '../schemas.js'; -export function createOrgRoutes(db: Pool, checkLimit?: CheckLimitFn) { +interface OrgRow { id: string; name: string; parent_id: string | null; metadata?: Record } + +export function createOrgRoutes(db: Pool, checkLimit?: CheckLimitFn, emitOrgEvent?: OrgEventSink) { return async function orgRoutes(app: FastifyInstance) { app.get('/orgs', { schema: { @@ -102,10 +104,11 @@ export function createOrgRoutes(db: Pool, checkLimit?: CheckLimitFn) { if (!ok) return reply; } - const { rows } = await db.query( + const { rows } = await db.query( 'INSERT INTO morbac.orgs (name, parent_id) VALUES ($1, $2) RETURNING id, name, parent_id, metadata', [name, parent_id ?? null], ); + emitOrgEvent?.({ action: 'created', org: rows[0], actorId: user.id }); return reply.code(201).send(rows[0]); }); @@ -137,16 +140,21 @@ export function createOrgRoutes(db: Pool, checkLimit?: CheckLimitFn) { if (!await hasPermission(db, req, reply, id, 'update', 'orgs')) return reply; const body = req.body as { name?: string; parent_id?: string | null }; - const { rows } = await db.query( - `UPDATE morbac.orgs + const { rows } = await db.query( + `WITH prev AS (SELECT parent_id AS old_parent FROM morbac.orgs WHERE id = $4) + UPDATE morbac.orgs o SET name = CASE WHEN $1::text IS NOT NULL THEN $1 ELSE name END, parent_id = CASE WHEN $2 THEN NULL WHEN $3::text IS NOT NULL THEN $3::uuid ELSE parent_id END - WHERE id = $4 - RETURNING id, name, parent_id, metadata`, + FROM prev + WHERE o.id = $4 + RETURNING o.id, o.name, o.parent_id, o.metadata, + (o.parent_id IS DISTINCT FROM prev.old_parent) AS reparented`, [body.name ?? null, body.parent_id === null, body.parent_id ?? null, id], ); if (!rows[0]) return reply.code(404).send({ error: 'Not found' }); - return rows[0]; + const { reparented, ...org } = rows[0]; + emitOrgEvent?.({ action: 'updated', org: { ...org, reparented }, actorId: user.id }); + return org; }); app.delete('/orgs/:id', { @@ -169,7 +177,11 @@ export function createOrgRoutes(db: Pool, checkLimit?: CheckLimitFn) { if (id === SYSTEM_ORG_ID) return reply.code(403).send({ error: 'System entities are immutable' }); if (!await hasPermission(db, req, reply, id, 'delete', 'orgs')) return reply; - await db.query('DELETE FROM morbac.orgs WHERE id = $1', [id]); + const { rows } = await db.query( + 'DELETE FROM morbac.orgs WHERE id = $1 RETURNING id, name, parent_id, metadata', + [id], + ); + if (rows[0]) emitOrgEvent?.({ action: 'deleted', org: rows[0], actorId: user.id }); return reply.code(204).send(); }); }; diff --git a/src/routes/users.ts b/src/routes/users.ts index 87fa330..aff38c8 100644 --- a/src/routes/users.ts +++ b/src/routes/users.ts @@ -30,23 +30,27 @@ export function createUserRoutes(db: Pool, checkLimit?: CheckLimitFn) { if (!await hasPermission(db, req, reply, orgIds[0] ?? null, 'read', 'users')) return reply; const checkOrg = orgIds[0] ?? null; - const [sysRes, svcRes] = await Promise.all([ + const [sysRes, svcRes, globalRes] = await Promise.all([ db.query<{ allowed: boolean }>('SELECT morbac.is_allowed($1::UUID, $2::UUID, $3, $4) AS allowed', [user.id, checkOrg, 'read', 'system_users']), db.query<{ allowed: boolean }>('SELECT morbac.is_allowed($1::UUID, $2::UUID, $3, $4) AS allowed', [user.id, checkOrg, 'read', 'service_users']), + db.query<{ allowed: boolean }>('SELECT morbac.is_allowed($1::UUID, NULL, $2, $3) AS allowed', + [user.id, 'read', 'users']), ]); - const canSeeSystem = sysRes.rows[0]?.allowed ?? false; - const canSeeService = svcRes.rows[0]?.allowed ?? false; + const canSeeSystem = sysRes.rows[0]?.allowed ?? false; + const canSeeService = svcRes.rows[0]?.allowed ?? false; + const canReadGlobally = globalRes.rows[0]?.allowed ?? false; const { expr, params } = orgScopeExpr(orgIds, 'ur2.org_id'); 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})` + ? `WHERE (u.id IN (SELECT ur2.user_id FROM morbac.user_roles ur2 WHERE ${expr})${placement})` : ''; const { rows } = await db.query( `SELECT - u.id, u.email, u.name, u.account_type, u.is_active, u.created_at, + u.id, u.email, u.name, u.account_type, u.is_active, u.needs_placement, u.created_at, COALESCE( json_agg(json_build_object( 'role_id', ur.role_id, @@ -79,7 +83,7 @@ export function createUserRoutes(db: Pool, checkLimit?: CheckLimitFn) { security: [{ oauth2: [] }], body: { type: 'object', - required: ['email', 'name', 'password'], + required: ['email', 'name'], properties: { email: { type: 'string', format: 'email', maxLength: 320 }, name: { type: 'string', minLength: 1, maxLength: 255 }, @@ -103,7 +107,7 @@ export function createUserRoutes(db: Pool, checkLimit?: CheckLimitFn) { 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; + email: string; name: string; password?: string; account_type?: 'user' | 'service'; is_active?: boolean; }; const email = rawEmail.trim().toLowerCase(); @@ -120,12 +124,12 @@ export function createUserRoutes(db: Pool, checkLimit?: CheckLimitFn) { if (!ok) return reply; } - const hash = await bcrypt.hash(password, 12); + const hash = password ? await bcrypt.hash(password, 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) - RETURNING id, email, name, account_type, is_active, created_at`, + RETURNING id, email, name, account_type, is_active, needs_placement, created_at`, [email, name, hash, account_type, is_active], ); return reply.code(201).send({ ...rows[0], roles: [] }); @@ -181,7 +185,7 @@ export function createUserRoutes(db: Pool, checkLimit?: CheckLimitFn) { is_active = COALESCE($3, is_active), password_hash = COALESCE($4, password_hash) WHERE id = $5 - RETURNING id, email, name, account_type, is_active, created_at`, + 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' }); @@ -269,6 +273,7 @@ export function createUserRoutes(db: Pool, checkLimit?: CheckLimitFn) { } throw e; } + await db.query('UPDATE app.users SET needs_placement = FALSE WHERE id = $1 AND needs_placement', [user_id]); return reply.code(201).send({ user_id, role_id, org_id }); }); diff --git a/src/schemas.ts b/src/schemas.ts index 63a9448..fa5747e 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -41,9 +41,10 @@ export const userResponse = { id: { type: 'string', format: 'uuid' }, email: { type: 'string', format: 'email' }, name: { type: 'string' }, - account_type: { type: 'string', enum: ['user', 'system', 'service'] }, - is_active: { type: 'boolean' }, - created_at: { type: 'string', format: 'date-time' }, + account_type: { type: 'string', enum: ['user', 'system', 'service'] }, + is_active: { type: 'boolean' }, + needs_placement: { type: 'boolean' }, + created_at: { type: 'string', format: 'date-time' }, roles: { type: 'array', items: {