feat(mappings): rework interface
This commit is contained in:
+6
-6
@@ -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",
|
||||
|
||||
+2
-1
@@ -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<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>;
|
||||
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>;
|
||||
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>;
|
||||
|
||||
+11
-2
@@ -14,15 +14,24 @@ export type CheckLimitFn = (
|
||||
params?: unknown[],
|
||||
) => 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 {
|
||||
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));
|
||||
|
||||
+21
-9
@@ -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<string, unknown> }
|
||||
|
||||
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<OrgRow>(
|
||||
'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<OrgRow & { reparented: boolean }>(
|
||||
`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<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();
|
||||
});
|
||||
};
|
||||
|
||||
+15
-10
@@ -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 });
|
||||
});
|
||||
|
||||
|
||||
+4
-3
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user