feat(mappings): rework interface
This commit is contained in:
+6
-6
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
+13
-8
@@ -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 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export const userResponse = {
|
|||||||
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' },
|
||||||
|
needs_placement: { type: 'boolean' },
|
||||||
created_at: { type: 'string', format: 'date-time' },
|
created_at: { type: 'string', format: 'date-time' },
|
||||||
roles: {
|
roles: {
|
||||||
type: 'array',
|
type: 'array',
|
||||||
|
|||||||
Reference in New Issue
Block a user