release: 1.0.0

This commit is contained in:
2026-10-02 22:48:16 +02:00
parent e470b670c2
commit a1d6f471e2
30 changed files with 4424 additions and 134 deletions
+240
View File
@@ -3,6 +3,38 @@ import type { Pool } from 'pg';
export const SYSTEM_ORG_ID = '00000000-0000-0000-0000-000000000000';
/** The no-org bucket. An object whose org is UNATTRIBUTED belongs to no
* organisation (typically awaiting attribution). Mirrors the SQL model where a
* NULL org means unattributed -- it never means "any org" or "all orgs". */
export const UNATTRIBUTED = null;
/** A single org target: an organisation id, or UNATTRIBUTED. */
export type OrgTarget = string | typeof UNATTRIBUTED;
/** Which records a listing should return. Either 'all' -- every organisation,
* unattributed included -- or an explicit list of targets, where an
* UNATTRIBUTED entry selects objects that have no org. */
export type OrgSelection = 'all' | OrgTarget[];
/** Serialises an OrgSelection for the morbac.org_ids session variable.
* 'all' returns null: no restriction, which the extension reads as every
* authorized record including unattributed ones. */
export function orgSelectionToSetting(selection: OrgSelection): string | null {
return selection === 'all' ? null : JSON.stringify(selection);
}
/** The X-Org-Ids token naming the no-org bucket on a data request. An org id is
* always a UUID, so the token cannot collide with one. The pre-request hook
* turns it into the JSON null element of morbac.org_ids. */
export const UNATTRIBUTED_ORG_TOKEN = 'unattributed';
/** Builds an X-Org-Ids value from org targets, naming the no-org bucket with
* UNATTRIBUTED_ORG_TOKEN. Data requests only: the management routes below are
* org-scoped listings and reject the token. */
export function orgTargetsToHeader(targets: OrgTarget[]): string {
return targets.map((t) => t ?? UNATTRIBUTED_ORG_TOKEN).join(',');
}
export function getUser(req: FastifyRequest): { id: string } | null {
return ((req as unknown) as Record<string, unknown>).user as { id: string } | null ?? null;
}
@@ -16,6 +48,12 @@ export function getOrgScope(req: FastifyRequest, reply: FastifyReply, required =
const multiVal = Array.isArray(multi) ? multi[0] : (multi ?? null);
if (multiVal) {
const ids = multiVal.split(',').map((s) => s.trim()).filter(Boolean);
// These routes enumerate orgs via get_org_scope, which the no-org bucket
// is not part of. Rejecting beats letting the token reach a UUID cast.
if (ids.some((id) => id.toLowerCase() === UNATTRIBUTED_ORG_TOKEN)) {
reply.code(400).send({ error: `${UNATTRIBUTED_ORG_TOKEN} is only valid on data requests, not management routes` });
return null;
}
if (ids.length > 0) return ids;
}
@@ -56,6 +94,171 @@ function scopeAllows(scope: ApiKeyScope | undefined, activity: string, target: s
return scope.some((p) => p.activity === activity && p.view === target);
}
/** True if the caller may exercise (activity, view) over the target user.
* Authorizes against the target's ACTUAL membership orgs (or a global rule),
* never a caller-supplied header. A target with no membership can only be
* managed by a global admin. Prevents cross-org account takeover. */
/** Enforces an API key's permission_scope for management routes that no longer
* route through hasPermission (they authorize against target ownership instead).
* Sends 403 and returns false when the key's scope excludes (activity, view). */
export function checkApiKeyScope(
req: FastifyRequest,
reply: FastifyReply,
activity: string,
view: string,
): boolean {
const apiKeyScope = ((req as unknown) as Record<string, unknown>).apiKeyScope as ApiKeyScope | undefined;
if (!scopeAllows(apiKeyScope, activity, view)) {
reply.code(403).send({ error: 'Insufficient permissions' });
return false;
}
return true;
}
export async function canActOnUser(
db: Pool,
callerId: string,
targetUserId: string,
activity: string,
view: string,
): Promise<boolean> {
const { rows } = await db.query<{ ok: boolean }>(
`SELECT (
morbac.is_allowed($1::UUID, NULL::UUID, $3, $4)
OR EXISTS (
SELECT 1 FROM morbac.user_roles ur
WHERE ur.user_id = $2::UUID
AND morbac.is_allowed($1::UUID, ur.org_id, $3, $4)
)
) AS ok`,
[callerId, targetUserId, activity, view],
);
return rows[0]?.ok ?? false;
}
/** True if the caller may reach org (activity, view) at every org that a rule
* with (ruleOrgId, scope) would cover. Enforces that a rule can never grant a
* permission the caller does not already hold on the affected orgs, killing
* scope-based vertical privilege escalation.
*
* The 'unattributed' and 'all' scopes reach objects with no org, which
* get_org_scope does not enumerate (unattributed is not an organisation).
* Those scopes additionally require the caller to hold the permission on
* unattributed objects, otherwise 'unattributed' would pass vacuously. */
export async function canGrantRule(
db: Pool,
callerId: string,
ruleOrgId: string,
scope: string,
activity: string,
view: string,
): Promise<boolean> {
const { rows } = await db.query<{ ok: boolean }>(
`SELECT (
NOT EXISTS (
SELECT 1 FROM morbac.get_org_scope($2::UUID, $3) AS s(org_id)
WHERE NOT morbac.is_allowed($1::UUID, s.org_id, $4, $5)
)
AND (
$3 NOT IN ('unattributed', 'all')
OR morbac.is_allowed($1::UUID, NULL::UUID, $4, $5)
)
) AS ok`,
[callerId, ruleOrgId, scope, activity, view],
);
return rows[0]?.ok ?? false;
}
/** True iff (activity, view) has an explicit row in morbac.activity_view_bindings.
* The DB trigger only restricts an activity once it has at least one binding
* (empty = unconstrained); this closes that gap at the application layer so an
* activity/view pair with no binding is rejected rather than silently allowed. */
export async function isActivityViewBound(db: Pool, activity: string, view: string): Promise<boolean> {
const { rows } = await db.query<{ ok: boolean }>(
`SELECT EXISTS(SELECT 1 FROM morbac.activity_view_bindings WHERE activity = $1 AND view = $2) AS ok`,
[activity, view],
);
return rows[0]?.ok ?? false;
}
/** True iff the caller holds the global wildcard rule (activity IS NULL, view IS NULL).
* Used by framework-level surfaces (audit, monitoring) that are not org-scoped. */
export async function isGlobalAdmin(db: Pool, userId: string): Promise<boolean> {
const { rows } = await db.query<{ is_admin: boolean }>(
`SELECT EXISTS(
SELECT 1 FROM morbac.global_rules
WHERE user_id = $1::UUID AND activity IS NULL AND view IS NULL AND modality = 'permission'
) AS is_admin`,
[userId],
);
return rows[0]?.is_admin === true;
}
/** Sends 401/403 and returns false unless the caller is authenticated and holds
* the global wildcard rule. Use for routes with no org concept (e.g. monitoring). */
export async function requireGlobalAdmin(db: Pool, req: FastifyRequest, reply: FastifyReply): Promise<boolean> {
const user = getUser(req);
if (!user) {
reply.code(401).send({ error: 'Authentication required' });
return false;
}
if (!(await isGlobalAdmin(db, user.id))) {
reply.code(403).send({ error: 'Insufficient permissions' });
return false;
}
return true;
}
/** True if the user may perform (activity, view) in ANY context: any org they
* belong to, unattributed objects, or via a global rule. For deciding whether
* to expose a feature at all -- never a substitute for the per-object check. */
export async function hasAnyPermission(
db: Pool,
userId: string,
activity: string,
view: string,
): Promise<boolean> {
const { rows } = await db.query<{ allowed: boolean }>(
'SELECT morbac.has_permission($1::UUID, $2, $3) AS allowed',
[userId, activity, view],
);
return rows[0]?.allowed === true;
}
/**
* The same decision `hasPermission` makes, without touching the reply.
*
* For callers that must ask about many permissions and decide what to do with
* the answers: a report fan-out drops the metrics the runner cannot read
* rather than failing, so it needs the boolean, not a 403 on the wire. Honours
* an API key's scope exactly as `hasPermission` does, so a restricted key never
* reads more through this door than through that one.
*/
export async function canPerform(
db: Pool,
req: FastifyRequest,
orgId: string | null,
activity: string,
target: string,
): Promise<boolean> {
const user = getUser(req);
if (!user) return false;
const apiKeyScope = ((req as unknown) as Record<string, unknown>).apiKeyScope as ApiKeyScope | undefined;
if (!scopeAllows(apiKeyScope, activity, target)) return false;
const { rows } = await db.query<{ allowed: boolean }>(
'SELECT morbac.is_allowed($1::UUID, $2::UUID, $3, $4) AS allowed',
[user.id, orgId, activity, target],
);
return rows[0]?.allowed === true;
}
/** Reads an API key's scope off a request, for callers that must reason about it. */
export function apiKeyScopeOf(req: FastifyRequest): ApiKeyScope | undefined {
return ((req as unknown) as Record<string, unknown>).apiKeyScope as ApiKeyScope | undefined;
}
/** Authorization for a single object. Pass the object's org, or UNATTRIBUTED
* when the object has no org. */
export async function hasPermission(
db: Pool,
req: FastifyRequest,
@@ -84,3 +287,40 @@ export async function hasPermission(
}
return true;
}
/**
* A coarse gate for a multi-org list read (e.g. the "all organizations" filter):
* the caller may proceed if allowed in at least one of the requested orgs. RLS
* still filters each row by its own org, so a row the caller cannot see in one
* of the listed orgs is dropped regardless.
*/
export async function hasPermissionAny(
db: Pool,
req: FastifyRequest,
reply: FastifyReply,
orgIds: string[],
activity: string,
target: string,
): Promise<boolean> {
const user = getUser(req);
if (!user) {
reply.code(401).send({ error: 'Authentication required' });
return false;
}
const apiKeyScope = ((req as unknown) as Record<string, unknown>).apiKeyScope as ApiKeyScope | undefined;
if (!scopeAllows(apiKeyScope, activity, target)) {
reply.code(403).send({ error: 'Insufficient permissions' });
return false;
}
const real = orgIds.filter((v) => v && v.toLowerCase() !== 'unattributed');
const { rows } = await db.query<{ allowed: boolean }>(
`SELECT bool_or(morbac.is_allowed($1::UUID, o::UUID, $3, $4)) AS allowed
FROM unnest($2::UUID[]) o`,
[user.id, real, activity, target],
);
if (!rows[0]?.allowed) {
reply.code(403).send({ error: 'Insufficient permissions' });
return false;
}
return true;
}