feat(mappings): rework interface

This commit is contained in:
2026-06-03 19:54:20 +02:00
parent 2b389ac66b
commit dfe8c8c322
6 changed files with 59 additions and 31 deletions
+6 -6
View File
@@ -4,12 +4,12 @@
"type": "module", "type": "module",
"description": "Crudy pgmorbac — Multi-OrBAC permission helpers for Fastify and PostgreSQL", "description": "Crudy pgmorbac — Multi-OrBAC permission helpers for Fastify and PostgreSQL",
"exports": { "exports": {
".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }, ".": { "types": "./dist/index.d.ts", "source": "./src/index.ts", "default": "./dist/index.js" },
"./permissions": { "types": "./dist/permissions.d.ts", "default": "./dist/permissions.js" }, "./permissions": { "types": "./dist/permissions.d.ts", "source": "./src/permissions.ts", "default": "./dist/permissions.js" },
"./proxy": { "types": "./dist/proxy.d.ts", "default": "./dist/proxy.js" }, "./proxy": { "types": "./dist/proxy.d.ts", "source": "./src/proxy.ts", "default": "./dist/proxy.js" },
"./plugin": { "types": "./dist/plugin.d.ts", "default": "./dist/plugin.js" }, "./plugin": { "types": "./dist/plugin.d.ts", "source": "./src/plugin.ts", "default": "./dist/plugin.js" },
"./schemas": { "types": "./dist/schemas.d.ts", "default": "./dist/schemas.js" }, "./schemas": { "types": "./dist/schemas.d.ts", "source": "./src/schemas.ts", "default": "./dist/schemas.js" },
"./api": { "types": "./dist/api.d.ts", "default": "./dist/api.js" } "./api": { "types": "./dist/api.d.ts", "source": "./src/api.ts", "default": "./dist/api.js" }
}, },
"main": "./dist/index.js", "main": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
+2 -1
View File
@@ -106,6 +106,7 @@ export interface UserDetail {
name: string; name: string;
account_type: 'user' | 'system' | 'service'; account_type: 'user' | 'system' | 'service';
is_active: boolean; is_active: boolean;
needs_placement?: boolean;
created_at: string; created_at: string;
roles: { role_id: string; role_name: string; org_id: string; org_name: 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<null>; deleteGlobalRule(token: string, orgIds: string[], id: string): Promise<null>;
getSystemPrincipals(token: string, orgIds: string[]): Promise<SystemPrincipal[]>; getSystemPrincipals(token: string, orgIds: string[]): Promise<SystemPrincipal[]>;
getUsers(token: string, orgIds: string[]): Promise<UserDetail[]>; getUsers(token: string, orgIds: string[]): Promise<UserDetail[]>;
createUser(token: string, orgIds: string[], data: { email: string; name: string; password: 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>; updateUser(token: string, orgIds: string[], id: string, data: { name?: string; email?: string; is_active?: boolean; password?: string }): Promise<UserDetail>;
deleteUser(token: string, orgIds: string[], id: string): Promise<null>; deleteUser(token: string, orgIds: string[], id: 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>;
+11 -2
View File
@@ -14,15 +14,24 @@ export type CheckLimitFn = (
params?: unknown[], params?: unknown[],
) => Promise<boolean>; ) => Promise<boolean>;
export interface OrgEvent {
action: 'created' | 'updated' | 'deleted';
org: { id: string; name?: string; parent_id?: string | null; metadata?: Record<string, unknown>; reparented?: boolean };
actorId: string | null;
}
export type OrgEventSink = (event: OrgEvent) => void;
export interface MgmtPluginOptions { export interface MgmtPluginOptions {
db: Pool; db: Pool;
checkLimit?: CheckLimitFn; checkLimit?: CheckLimitFn;
emitOrgEvent?: OrgEventSink;
} }
export function createMgmtRoutes(opts: MgmtPluginOptions) { export function createMgmtRoutes(opts: MgmtPluginOptions) {
const { db, checkLimit } = opts; const { db, checkLimit, emitOrgEvent } = opts;
return async function mgmtRoutes(app: FastifyInstance) { 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(createRoleRoutes(db, checkLimit));
await app.register(createRuleRoutes(db)); await app.register(createRuleRoutes(db));
await app.register(createUserRoutes(db, checkLimit)); await app.register(createUserRoutes(db, checkLimit));
+21 -9
View File
@@ -1,12 +1,14 @@
import type { FastifyInstance } from 'fastify'; import type { FastifyInstance } from 'fastify';
import type { Pool } from 'pg'; import type { Pool } from 'pg';
import type { CheckLimitFn } from '../plugin.js'; import type { CheckLimitFn, OrgEventSink } from '../plugin.js';
import { import {
getUser, getOrgScope, orgScopeExpr, hasPermission, SYSTEM_ORG_ID, getUser, getOrgScope, orgScopeExpr, hasPermission, SYSTEM_ORG_ID,
} from '../permissions.js'; } from '../permissions.js';
import { orgResponse, notFound, forbidden, unauthorized, badRequest } from '../schemas.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<string, unknown> }
export function createOrgRoutes(db: Pool, checkLimit?: CheckLimitFn, emitOrgEvent?: OrgEventSink) {
return async function orgRoutes(app: FastifyInstance) { return async function orgRoutes(app: FastifyInstance) {
app.get('/orgs', { app.get('/orgs', {
schema: { schema: {
@@ -102,10 +104,11 @@ export function createOrgRoutes(db: Pool, checkLimit?: CheckLimitFn) {
if (!ok) return reply; if (!ok) return reply;
} }
const { rows } = await db.query( const { rows } = await db.query<OrgRow>(
'INSERT INTO morbac.orgs (name, parent_id) VALUES ($1, $2) RETURNING id, name, parent_id, metadata', 'INSERT INTO morbac.orgs (name, parent_id) VALUES ($1, $2) RETURNING id, name, parent_id, metadata',
[name, parent_id ?? null], [name, parent_id ?? null],
); );
emitOrgEvent?.({ action: 'created', org: rows[0], actorId: user.id });
return reply.code(201).send(rows[0]); 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; if (!await hasPermission(db, req, reply, id, 'update', 'orgs')) return reply;
const body = req.body as { name?: string; parent_id?: string | null }; const body = req.body as { name?: string; parent_id?: string | null };
const { rows } = await db.query( const { rows } = await db.query<OrgRow & { reparented: boolean }>(
`UPDATE morbac.orgs `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, 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 parent_id = CASE WHEN $2 THEN NULL WHEN $3::text IS NOT NULL THEN $3::uuid ELSE parent_id END
WHERE id = $4 FROM prev
RETURNING id, name, parent_id, metadata`, 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], [body.name ?? null, body.parent_id === null, body.parent_id ?? null, id],
); );
if (!rows[0]) return reply.code(404).send({ error: 'Not found' }); 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', { 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 (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; 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<OrgRow>(
'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(); return reply.code(204).send();
}); });
}; };
+15 -10
View File
@@ -30,23 +30,27 @@ export function createUserRoutes(db: Pool, checkLimit?: CheckLimitFn) {
if (!await hasPermission(db, req, reply, orgIds[0] ?? null, 'read', 'users')) return reply; if (!await hasPermission(db, req, reply, orgIds[0] ?? null, 'read', 'users')) return reply;
const checkOrg = orgIds[0] ?? null; 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', db.query<{ allowed: boolean }>('SELECT morbac.is_allowed($1::UUID, $2::UUID, $3, $4) AS allowed',
[user.id, checkOrg, 'read', 'system_users']), [user.id, checkOrg, 'read', 'system_users']),
db.query<{ allowed: boolean }>('SELECT morbac.is_allowed($1::UUID, $2::UUID, $3, $4) AS allowed', db.query<{ allowed: boolean }>('SELECT morbac.is_allowed($1::UUID, $2::UUID, $3, $4) AS allowed',
[user.id, checkOrg, 'read', 'service_users']), [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 canSeeSystem = sysRes.rows[0]?.allowed ?? false;
const canSeeService = svcRes.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 { expr, params } = orgScopeExpr(orgIds, 'ur2.org_id');
const isSystemScope = orgIds.includes(SYSTEM_ORG_ID); const isSystemScope = orgIds.includes(SYSTEM_ORG_ID);
const placement = canReadGlobally ? ' OR u.needs_placement' : '';
const whereClause = expr && !isSystemScope 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( const { rows } = await db.query(
`SELECT `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( COALESCE(
json_agg(json_build_object( json_agg(json_build_object(
'role_id', ur.role_id, 'role_id', ur.role_id,
@@ -79,7 +83,7 @@ export function createUserRoutes(db: Pool, checkLimit?: CheckLimitFn) {
security: [{ oauth2: [] }], security: [{ oauth2: [] }],
body: { body: {
type: 'object', type: 'object',
required: ['email', 'name', 'password'], required: ['email', 'name'],
properties: { properties: {
email: { type: 'string', format: 'email', maxLength: 320 }, email: { type: 'string', format: 'email', maxLength: 320 },
name: { type: 'string', minLength: 1, maxLength: 255 }, name: { type: 'string', minLength: 1, maxLength: 255 },
@@ -103,7 +107,7 @@ export function createUserRoutes(db: Pool, checkLimit?: CheckLimitFn) {
const orgId = orgIds[0] ?? null; const orgId = orgIds[0] ?? null;
const { email: rawEmail, name, password, account_type = 'user', is_active = true } = req.body as { 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; account_type?: 'user' | 'service'; is_active?: boolean;
}; };
const email = rawEmail.trim().toLowerCase(); const email = rawEmail.trim().toLowerCase();
@@ -120,12 +124,12 @@ export function createUserRoutes(db: Pool, checkLimit?: CheckLimitFn) {
if (!ok) return reply; if (!ok) return reply;
} }
const hash = await bcrypt.hash(password, 12); const hash = password ? await bcrypt.hash(password, 12) : null;
try { try {
const { rows } = await db.query( const { rows } = await db.query(
`INSERT INTO app.users (email, name, password_hash, account_type, is_active) `INSERT INTO app.users (email, name, password_hash, account_type, is_active)
VALUES ($1, $2, $3, $4, $5) 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], [email, name, hash, account_type, is_active],
); );
return reply.code(201).send({ ...rows[0], roles: [] }); 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), is_active = COALESCE($3, is_active),
password_hash = COALESCE($4, password_hash) password_hash = COALESCE($4, password_hash)
WHERE id = $5 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], [name ?? null, email ?? null, is_active ?? null, hash, id],
); );
if (!rows[0]) return reply.code(404).send({ error: 'Not found' }); if (!rows[0]) return reply.code(404).send({ error: 'Not found' });
@@ -269,6 +273,7 @@ export function createUserRoutes(db: Pool, checkLimit?: CheckLimitFn) {
} }
throw e; 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 }); return reply.code(201).send({ user_id, role_id, org_id });
}); });
+4 -3
View File
@@ -41,9 +41,10 @@ export const userResponse = {
id: { type: 'string', format: 'uuid' }, id: { type: 'string', format: 'uuid' },
email: { type: 'string', format: 'email' }, email: { type: 'string', format: 'email' },
name: { type: 'string' }, name: { type: 'string' },
account_type: { type: 'string', enum: ['user', 'system', 'service'] }, account_type: { type: 'string', enum: ['user', 'system', 'service'] },
is_active: { type: 'boolean' }, is_active: { type: 'boolean' },
created_at: { type: 'string', format: 'date-time' }, needs_placement: { type: 'boolean' },
created_at: { type: 'string', format: 'date-time' },
roles: { roles: {
type: 'array', type: 'array',
items: { items: {