2 Commits
Author SHA1 Message Date
marc a1d6f471e2 release: 1.0.0 2026-10-02 22:48:16 +02:00
marc e470b670c2 docs: add LICENSE, NOTICE, README, CHANGELOG, and npm metadata
The public repo shipped with no license, readme, or changelog. Add them
plus standard package metadata (license field, keywords, author,
homepage, repository, bugs) so the Gitea/npm pages present the package
professionally. README and CHANGELOG describe the exports this repo
actually contains.
2026-07-23 13:34:21 +02:00
32 changed files with 4744 additions and 122 deletions
+25
View File
@@ -0,0 +1,25 @@
# Changelog
All notable changes to this package will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.0.0] - 2026-07-25
### Added
- First stable release of the Node.js companion to the `pgmorbac` PostgreSQL extension.
- `hasPermission` and org-scope helpers backed by `morbac.is_allowed`.
- `withMorbacContext` for running queries under the acting user so RLS applies.
- PostgREST data proxy with per-route permissions and a hard page-size cap.
- `morbacMigrations()` loader that ships the bundled extension SQL.
- Fastify management routes for orgs, roles, rules, users, global rules, activities, and delegations.
- PostgREST-style list-query parsing with allowlisted sort/filter columns.
- JSON schema response fragments and shared domain types.
- Unattributed org target: `UNATTRIBUTED`, `OrgTarget` and `OrgSelection` express
a specific organisation, records with no org, or every org at once.
- `hasAnyPermission` wraps the `morbac.has_permission` capability probe for UI gating.
- `withMorbacContext` accepts an org selection, serialised to `morbac.org_ids`
where a JSON null element names the unattributed bucket.
[1.0.0]: https://git.villains.fr/crudy/pgmorbac-node/releases/tag/v1.0.0
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2025-2026 Marc Villain
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+4
View File
@@ -0,0 +1,4 @@
@crudy/pgmorbac
Copyright 2025-2026 Marc Villain
This product includes software developed as part of the Crudy project.
+119
View File
@@ -0,0 +1,119 @@
# @crudy/pgmorbac
**Multi-OrBAC permission helpers for Fastify and PostgreSQL.**
[![License: Apache 2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://www.apache.org/licenses/LICENSE-2.0)
[![Node](https://img.shields.io/badge/Node-%3E%3D18-brightgreen.svg)](https://nodejs.org/)
This is the Node.js companion to the [`pgmorbac` PostgreSQL extension](https://github.com/crudylabs/pgmorbac). The extension implements the Multi-OrBAC access control model in the database (`morbac` schema); this package wires it into a Fastify backend: permission checks, RLS session context, management routes, a PostgREST data proxy, and the migration loader that ships the extension SQL.
> The SQL extension is a separate project under the MIT license. This helper package is Apache-2.0. See [LICENSE](LICENSE) and [NOTICE](NOTICE).
## Install
```bash
npm install @crudy/pgmorbac
```
Peer dependencies (both optional, install the ones you use): `fastify >=5`, `pg >=8`.
## Exports
The package is split into focused entry points so you only pull in what you need.
| Import | Purpose |
|--------|---------|
| `@crudy/pgmorbac` | RLS session context and shared domain types |
| `@crudy/pgmorbac/permissions` | `hasPermission`, org-scope resolution, admin checks |
| `@crudy/pgmorbac/proxy` | PostgREST data proxy with per-route permissions |
| `@crudy/pgmorbac/plugin` | Fastify management routes (orgs, roles, rules, users, delegations) |
| `@crudy/pgmorbac/list-query` | PostgREST-style sort/filter/pagination parsing |
| `@crudy/pgmorbac/schemas` | JSON schema response fragments |
| `@crudy/pgmorbac/migrations` | Loads the bundled `morbac` extension SQL as migrations |
| `@crudy/pgmorbac/api` | Domain type definitions (`Org`, `Role`, `Rule`, ...) |
## Usage
### Permission checks
Every org-scoped route authorizes with `hasPermission`. Authentication is necessary but not sufficient; the check calls `morbac.is_allowed` and requires an `X-Org-Id` header.
```ts
import { hasPermission } from '@crudy/pgmorbac/permissions';
app.get('/documents', async (req, reply) => {
const orgId = req.headers['x-org-id'] as string;
if (!(await hasPermission(pool, req, reply, orgId, 'read', 'documents'))) return;
// ... authorized: read and return rows
});
```
### RLS session context
Run queries under the acting user so `morbac` row-level security policies apply as a second gate behind `hasPermission`. Required for non-`BYPASSRLS` runtime roles.
```ts
import { withMorbacContext } from '@crudy/pgmorbac';
const rows = await withMorbacContext(pool, { userId, orgId }, async (client) => {
const { rows } = await client.query('SELECT * FROM app.orgs');
return rows;
});
```
### Data proxy
Expose an `app`-schema table through the backend with an explicit permission. A path that is never registered does not exist (fail closed).
```ts
import { createProxyRegistry } from '@crudy/pgmorbac/proxy';
const proxy = createProxyRegistry(pool, process.env.POSTGREST_URL!);
proxy.proxyDataRoute(app, 'GET', '/data/documents', 'documents', { activity: 'read', view: 'documents' });
```
Pass `null` as the permission for any-authenticated-user access with no org check. Page size is capped at `MAX_PAGE_LIMIT` (1000); over-large `limit` values are rejected, never silently clamped.
### Migrations
The extension SQL ships inside this package (`sql/pgmorbac--<version>.sql`, plus `sql/upgrades/`). Feed it to your migration runner before your own DDL.
```ts
import { morbacMigrations } from '@crudy/pgmorbac/migrations';
for (const { filename, sql } of morbacMigrations()) {
await runMigration(filename, sql);
}
```
The base script keeps the historical filename `0001_morbac.sql` so `schema_migrations` rows on existing databases stay valid; upgrade scripts follow in version order.
### Management routes
Register CRUD routes for orgs, roles, rules, users, global rules, activities, and delegations. Supply optional hooks for license-limit enforcement and event emission.
```ts
import { createMgmtRoutes } from '@crudy/pgmorbac/plugin';
createMgmtRoutes({
db: pool,
checkLimit: async (reply, name, sql, params) => { /* enforce quotas */ return true; },
emitOrgEvent: (event) => { /* audit / notify */ },
});
```
## Security model
- **Deny by default.** Unregistered routes do not exist; unauthorized reads return `404` over `403` to avoid enumeration.
- **Reads are checked too.** List and detail routes require the same permission checks as writes.
- **Two gates stacked.** `hasPermission` (application) plus `morbac` RLS (database) when running under a non-`BYPASSRLS` role.
- **Allowlisted queries.** `list-query` parameterizes values and allowlists sortable/filterable columns; user input is never interpolated.
## Related
- [pgmorbac](https://github.com/crudylabs/pgmorbac) — the PostgreSQL extension (MIT)
- [Documentation](https://pgmorbac.crudy.fr) — model, architecture, integration guides
## License
Apache-2.0 (c) 2025-2026 Marc Villain. See [LICENSE](LICENSE) and [NOTICE](NOTICE).
+87 -13
View File
@@ -1,22 +1,91 @@
{ {
"name": "@crudy/pgmorbac", "name": "@crudy/pgmorbac",
"version": "0.1.0", "version": "1.0.0",
"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",
"keywords": [
"morbac",
"orbac",
"rbac",
"authorization",
"access-control",
"postgresql",
"fastify",
"rls",
"multi-tenant"
],
"author": "Marc Villain",
"homepage": "https://pgmorbac.crudy.fr",
"repository": {
"type": "git",
"url": "https://github.com/crudylabs/pgmorbac-node.git"
},
"bugs": {
"url": "https://github.com/crudylabs/pgmorbac-node/issues"
},
"exports": { "exports": {
".": { "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" }, "types": "./dist/index.d.ts",
"./proxy": { "types": "./dist/proxy.d.ts", "source": "./src/proxy.ts", "default": "./dist/proxy.js" }, "source": "./src/index.ts",
"./plugin": { "types": "./dist/plugin.d.ts", "source": "./src/plugin.ts", "default": "./dist/plugin.js" }, "default": "./dist/index.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" } "./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"
},
"./list-query": {
"types": "./dist/list-query.d.ts",
"source": "./src/list-query.ts",
"default": "./dist/list-query.js"
},
"./rls-context": {
"types": "./dist/rls-context.d.ts",
"source": "./src/rls-context.ts",
"default": "./dist/rls-context.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"
},
"./migrations": {
"types": "./dist/migrations.d.ts",
"source": "./src/migrations.ts",
"default": "./dist/migrations.js"
}
}, },
"main": "./dist/index.js", "main": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"files": ["dist", "package.json"], "files": [
"dist",
"sql",
"package.json",
"src",
"README.md",
"CHANGELOG.md",
"LICENSE",
"NOTICE"
],
"scripts": { "scripts": {
"build": "tsc", "build": "tsc",
"prepack": "npm run build" "prepack": "npm run build",
"check": "tsc --noEmit"
}, },
"publishConfig": { "publishConfig": {
"registry": "http://localhost:4873", "registry": "http://localhost:4873",
@@ -30,8 +99,12 @@
"pg": ">=8" "pg": ">=8"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"fastify": { "optional": true }, "fastify": {
"pg": { "optional": true } "optional": true
},
"pg": {
"optional": true
}
}, },
"devDependencies": { "devDependencies": {
"@types/bcryptjs": "^2", "@types/bcryptjs": "^2",
@@ -39,5 +112,6 @@
"fastify": "^5", "fastify": "^5",
"pg": "^8", "pg": "^8",
"typescript": "^5" "typescript": "^5"
} },
"license": "Apache-2.0"
} }
File diff suppressed because it is too large Load Diff
+92 -8
View File
@@ -3,6 +3,8 @@ export interface Org {
name: string; name: string;
parent_id: string | null; parent_id: string | null;
metadata: Record<string, unknown>; metadata: Record<string, unknown>;
/** Names from the nearest visible ancestor down to this org. Server-computed. */
path?: string[];
} }
export interface Role { export interface Role {
@@ -18,6 +20,23 @@ export interface RoleDetail {
org_name: string; org_name: string;
name: string; name: string;
description: string | null; description: string | null;
profile_module_id?: string | null;
profile_key?: string | null;
profile_synced?: boolean | null;
}
export interface PermissionProfileRule {
activity: string;
view: string;
modality: 'permission' | 'prohibition';
scope: string;
}
export interface PermissionProfile {
module_id: string;
key: string;
content_hash: string;
rules: PermissionProfileRule[];
} }
export interface Activity { export interface Activity {
@@ -28,6 +47,10 @@ export interface Activity {
export interface View { export interface View {
name: string; name: string;
description: string | null; description: string | null;
/** Rows in this view carry no org, so grant it with an unattributed (or all)
* scope rule, a user rule with no org, or a global rule. A tree-scoped rule
* could never match. */
no_org?: boolean;
} }
export interface Rule { export interface Rule {
@@ -41,6 +64,9 @@ export interface Rule {
modality: 'permission' | 'prohibition'; modality: 'permission' | 'prohibition';
priority: number | null; priority: number | null;
scope: string; scope: string;
valid_from: string | null;
valid_until: string | null;
is_active: boolean;
} }
export interface CrossOrgRule { export interface CrossOrgRule {
@@ -60,10 +86,11 @@ export interface CrossOrgRule {
export interface UserRule { export interface UserRule {
id: string; id: string;
user_id: string; user_id: string;
org_id: string; /** null = unattributed: the rule targets objects with no org. */
org_id: string | null;
user_name: string; user_name: string;
user_email: string | null; user_email: string | null;
org_name: string; org_name: string | null;
activity: string; activity: string;
view: string; view: string;
modality: 'permission' | 'prohibition'; modality: 'permission' | 'prohibition';
@@ -165,6 +192,7 @@ export type ActivityViewBinding = { activity: string; view: string };
export interface MorbacApi { export interface MorbacApi {
checkPermission(token: string, orgId: string, activity: string, view: string): Promise<boolean>; checkPermission(token: string, orgId: string, activity: string, view: string): Promise<boolean>;
clearPermissionCache(): void;
getOrgs(token: string, orgIds: string[]): Promise<Org[]>; getOrgs(token: string, orgIds: string[]): Promise<Org[]>;
createOrg(token: string, orgIds: string[], data: { name: string; parent_id?: string }): Promise<Org>; createOrg(token: string, orgIds: string[], data: { name: string; parent_id?: string }): Promise<Org>;
updateOrg(token: string, orgIds: string[], id: string, data: { name?: string; parent_id?: string | null }): Promise<Org>; updateOrg(token: string, orgIds: string[], id: string, data: { name?: string; parent_id?: string | null }): Promise<Org>;
@@ -173,6 +201,10 @@ export interface MorbacApi {
createRole(token: string, orgIds: string[], data: { org_id: string; name: string; description?: string }): Promise<RoleDetail>; createRole(token: string, orgIds: string[], data: { org_id: string; name: string; description?: string }): Promise<RoleDetail>;
updateRole(token: string, orgIds: string[], id: string, data: { name?: string; description?: string }): Promise<RoleDetail>; updateRole(token: string, orgIds: string[], id: string, data: { name?: string; description?: string }): Promise<RoleDetail>;
deleteRole(token: string, orgIds: string[], id: string): Promise<null>; deleteRole(token: string, orgIds: string[], id: string): Promise<null>;
getPermissionProfiles(token: string, orgIds: string[], moduleId?: string): Promise<PermissionProfile[]>;
createRoleFromProfile(token: string, orgIds: string[], data: { org_id: string; name: string; description?: string; sync?: boolean; profile: { module_id: string; key: string } }): Promise<RoleDetail>;
attachRoleProfile(token: string, orgIds: string[], roleId: string, data: { profile: { module_id: string; key: string }; sync?: boolean }): Promise<{ role_id: string; module_id: string; profile_key: string; synced: boolean; applied_hash: string }>;
detachRoleProfile(token: string, orgIds: string[], roleId: string): Promise<null>;
getRoleHierarchy(token: string, orgIds: string[]): Promise<RoleHierarchy[]>; getRoleHierarchy(token: string, orgIds: string[]): Promise<RoleHierarchy[]>;
addRoleHierarchy(token: string, orgIds: string[], data: { senior_role_id: string; junior_role_id: string }): Promise<unknown>; addRoleHierarchy(token: string, orgIds: string[], data: { senior_role_id: string; junior_role_id: string }): Promise<unknown>;
removeRoleHierarchy(token: string, orgIds: string[], data: { senior_role_id: string; junior_role_id: string }): Promise<unknown>; removeRoleHierarchy(token: string, orgIds: string[], data: { senior_role_id: string; junior_role_id: string }): Promise<unknown>;
@@ -186,19 +218,21 @@ export interface MorbacApi {
createCrossOrgRule(token: string, orgIds: string[], data: { source_org_id: string; source_role_id: string; target_org_id: string; activity: string; view: string; modality: 'permission' | 'prohibition'; priority?: number }): Promise<CrossOrgRule>; createCrossOrgRule(token: string, orgIds: string[], data: { source_org_id: string; source_role_id: string; target_org_id: string; activity: string; view: string; modality: 'permission' | 'prohibition'; priority?: number }): Promise<CrossOrgRule>;
deleteCrossOrgRule(token: string, orgIds: string[], id: string): Promise<null>; deleteCrossOrgRule(token: string, orgIds: string[], id: string): Promise<null>;
getUserRules(token: string, orgIds: string[]): Promise<UserRule[]>; getUserRules(token: string, orgIds: string[]): Promise<UserRule[]>;
createUserRule(token: string, orgIds: string[], data: { user_id: string; org_id: string; activity: string; view: string; modality: 'permission' | 'prohibition'; priority?: number }): Promise<UserRule>; createUserRule(token: string, orgIds: string[], data: { user_id: string; org_id: string | null; activity: string; view: string; modality: 'permission' | 'prohibition'; priority?: number }): Promise<UserRule>;
deleteUserRule(token: string, orgIds: string[], id: string): Promise<null>; deleteUserRule(token: string, orgIds: string[], id: string): Promise<null>;
getGlobalRules(token: string, orgIds: string[]): Promise<GlobalRule[]>; getGlobalRules(token: string, orgIds: string[]): Promise<GlobalRule[]>;
createGlobalRule(token: string, orgIds: string[], data: { user_id?: string | null; activity?: string | null; view?: string | null; modality: 'permission' | 'prohibition'; priority?: number }): Promise<GlobalRule>; createGlobalRule(token: string, orgIds: string[], data: { user_id?: string | null; activity?: string | null; view?: string | null; modality: 'permission' | 'prohibition'; priority?: number }): Promise<GlobalRule>;
deleteGlobalRule(token: string, orgIds: string[], id: string): Promise<null>; deleteGlobalRule(token: string, orgIds: string[], id: string): Promise<null>;
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; account_type?: 'user' | 'service' }): Promise<UserDetail>; createUser(token: string, orgIds: string[], data: { email: string; name: string; account_type?: 'user' | 'service'; placement?: { role_id: string; org_id: string } }): Promise<UserDetail>;
updateUser(token: string, orgIds: string[], id: string, data: { name?: string; email?: string; is_active?: boolean; reset_password?: boolean }): Promise<UserDetail>; updateUser(token: string, orgIds: string[], id: string, data: { name?: string; email?: string; is_active?: boolean; reset_password?: boolean }): Promise<UserDetail>;
deleteUser(token: string, orgIds: string[], id: string): Promise<null>; deleteUser(token: string, orgIds: string[], id: string): Promise<null>;
getUserKeys(token: string, orgIds: string[], userId: string): Promise<ServiceAccountKey[]>; getUserKeys(token: string, orgIds: string[], userId: string): Promise<ServiceAccountKey[]>;
getUserPermissionOptions(token: string, orgIds: string[], userId: string): Promise<Array<{ activity: string; view: string }>>; getUserPermissionOptions(token: string, orgIds: string[], userId: string): Promise<Array<{ activity: string; view: string }>>;
createUserKey(token: string, orgIds: string[], userId: string, data: { name: string; description?: string; readonly?: boolean; expires_at?: string | null; permission_scope?: 'inherit' | Array<{ activity: string; view: string }> }): Promise<ServiceAccountKey>; createUserKey(token: string, orgIds: string[], userId: string, data: { name: string; description?: string; readonly?: boolean; expires_at?: string | null; permission_scope?: 'inherit' | Array<{ activity: string; view: string }> }): Promise<ServiceAccountKey>;
deleteUserKey(token: string, orgIds: string[], userId: string, keyId: string): Promise<null>; deleteUserKey(token: string, orgIds: string[], userId: string, keyId: string): Promise<null>;
getUserOrgFilter(token: string, orgIds: string[], userId: string): Promise<{ org_filter: string[] }>;
updateUserOrgFilter(token: string, orgIds: string[], userId: string, orgFilter: string[]): Promise<{ org_filter: string[] }>;
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>;
removeRole(token: string, orgIds: string[], data: { user_id: string; role_id: string; org_id: string }): Promise<unknown>; removeRole(token: string, orgIds: string[], data: { user_id: string; role_id: string; org_id: string }): Promise<unknown>;
getActivityUsers(token: string, orgIds: string[], name: string): Promise<RuleUser[]>; getActivityUsers(token: string, orgIds: string[], name: string): Promise<RuleUser[]>;
@@ -228,12 +262,23 @@ export function createMorbacApi(apiUrl: string): MorbacApi {
}); });
if (res.status === 204) return null; if (res.status === 204) return null;
const data = await res.json(); const data = await res.json();
if (!res.ok) throw new Error((data as { error?: string }).error || 'Request failed'); if (!res.ok) {
const d = data as { error?: string; message?: string; errorId?: string };
const err = new Error(d.message || d.error || `Request failed (HTTP ${res.status})`) as
Error & { status: number; code?: string; errorId?: string };
err.status = res.status;
err.code = d.error;
err.errorId = d.errorId;
throw err;
}
return data; return data;
} }
return { const permCache = new Map<string, { value: boolean; expires: number }>();
checkPermission: async (token, orgId, activity, view) => { const permInflight = new Map<string, Promise<boolean>>();
const PERM_TTL_MS = 30_000;
async function fetchPermission(token: string, orgId: string, activity: string, view: string): Promise<boolean> {
const res = await fetch(`${apiUrl}/me/permissions/check`, { const res = await fetch(`${apiUrl}/me/permissions/check`, {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -244,8 +289,37 @@ export function createMorbacApi(apiUrl: string): MorbacApi {
credentials: 'include', credentials: 'include',
body: JSON.stringify({ p_activity: activity, p_view: view }), body: JSON.stringify({ p_activity: activity, p_view: view }),
}); });
if (!res.ok) return false; if (!res.ok) {
const d = await res.json().catch(() => ({})) as { error?: string; message?: string; errorId?: string };
const err = new Error(d.message || d.error || `Permission check failed (HTTP ${res.status})`) as
Error & { status: number; code?: string; errorId?: string };
err.status = res.status;
err.code = d.error;
err.errorId = d.errorId;
throw err;
}
return res.json() as Promise<boolean>; return res.json() as Promise<boolean>;
}
return {
checkPermission: (token, orgId, activity, view) => {
const key = `${token}\x00${orgId}\x00${activity}\x00${view}`;
const cached = permCache.get(key);
if (cached && cached.expires > Date.now()) return Promise.resolve(cached.value);
const existing = permInflight.get(key);
if (existing) return existing;
const p = fetchPermission(token, orgId, activity, view).then((value) => {
permCache.set(key, { value, expires: Date.now() + PERM_TTL_MS });
return value;
});
permInflight.set(key, p);
void p.finally(() => permInflight.delete(key)).catch(() => {});
return p;
},
clearPermissionCache: () => {
permCache.clear();
permInflight.clear();
}, },
getOrgs: (t, orgIds) => api('GET', '/orgs', t, orgIds) as Promise<Org[]>, getOrgs: (t, orgIds) => api('GET', '/orgs', t, orgIds) as Promise<Org[]>,
@@ -258,6 +332,13 @@ export function createMorbacApi(apiUrl: string): MorbacApi {
updateRole: (t, orgIds, id, d) => api('PATCH', `/roles/${id}`, t, orgIds, d) as Promise<RoleDetail>, updateRole: (t, orgIds, id, d) => api('PATCH', `/roles/${id}`, t, orgIds, d) as Promise<RoleDetail>,
deleteRole: (t, orgIds, id) => api('DELETE', `/roles/${id}`, t, orgIds) as Promise<null>, deleteRole: (t, orgIds, id) => api('DELETE', `/roles/${id}`, t, orgIds) as Promise<null>,
getPermissionProfiles: (t, orgIds, moduleId) =>
api('GET', moduleId ? `/permission-profiles?module_id=${encodeURIComponent(moduleId)}` : '/permission-profiles', t, orgIds) as Promise<PermissionProfile[]>,
createRoleFromProfile: (t, orgIds, d) => api('POST', '/roles/from-profile', t, orgIds, d) as Promise<RoleDetail>,
attachRoleProfile: (t, orgIds, roleId, d) =>
api('POST', `/roles/${roleId}/profile-sync`, t, orgIds, d) as Promise<{ role_id: string; module_id: string; profile_key: string; synced: boolean; applied_hash: string }>,
detachRoleProfile: (t, orgIds, roleId) => api('DELETE', `/roles/${roleId}/profile-sync`, t, orgIds) as Promise<null>,
getRoleHierarchy: (t, orgIds) => api('GET', '/role-hierarchy', t, orgIds) as Promise<RoleHierarchy[]>, getRoleHierarchy: (t, orgIds) => api('GET', '/role-hierarchy', t, orgIds) as Promise<RoleHierarchy[]>,
addRoleHierarchy: (t, orgIds, d) => api('POST', '/role-hierarchy', t, orgIds, d), addRoleHierarchy: (t, orgIds, d) => api('POST', '/role-hierarchy', t, orgIds, d),
removeRoleHierarchy: (t, orgIds, d) => api('DELETE', '/role-hierarchy', t, orgIds, d), removeRoleHierarchy: (t, orgIds, d) => api('DELETE', '/role-hierarchy', t, orgIds, d),
@@ -292,6 +373,9 @@ export function createMorbacApi(apiUrl: string): MorbacApi {
createUserKey: (t, orgIds, userId, d) => api('POST', `/users/${userId}/keys`, t, orgIds, d) as Promise<ServiceAccountKey>, createUserKey: (t, orgIds, userId, d) => api('POST', `/users/${userId}/keys`, t, orgIds, d) as Promise<ServiceAccountKey>,
deleteUserKey: (t, orgIds, userId, keyId) => api('DELETE', `/users/${userId}/keys/${keyId}`, t, orgIds) as Promise<null>, deleteUserKey: (t, orgIds, userId, keyId) => api('DELETE', `/users/${userId}/keys/${keyId}`, t, orgIds) as Promise<null>,
getUserOrgFilter: (t, orgIds, userId) => api('GET', `/users/${encodeURIComponent(userId)}/org-filter`, t, orgIds) as Promise<{ org_filter: string[] }>,
updateUserOrgFilter: (t, orgIds, userId, orgFilter) => api('PATCH', `/users/${encodeURIComponent(userId)}/org-filter`, t, orgIds, { org_filter: orgFilter }) as Promise<{ org_filter: string[] }>,
assignRole: (t, orgIds, d) => api('POST', `/users/${d.user_id}/roles`, t, orgIds, { role_id: d.role_id, org_id: d.org_id }), assignRole: (t, orgIds, d) => api('POST', `/users/${d.user_id}/roles`, t, orgIds, { role_id: d.role_id, org_id: d.org_id }),
removeRole: (t, orgIds, d) => api('DELETE', `/users/${d.user_id}/roles`, t, orgIds, { role_id: d.role_id, org_id: d.org_id }), removeRole: (t, orgIds, d) => api('DELETE', `/users/${d.user_id}/roles`, t, orgIds, { role_id: d.role_id, org_id: d.org_id }),
+11
View File
@@ -1,3 +1,14 @@
export { withOrgPaths, readableOrgPaths } from './org-paths.js';
export { withMorbacContext, type MorbacContext } from './rls-context.js';
export {
UNATTRIBUTED,
UNATTRIBUTED_ORG_TOKEN,
orgSelectionToSetting,
orgTargetsToHeader,
hasAnyPermission,
type OrgTarget,
type OrgSelection,
} from './permissions.js';
export type { DataPermission } from './proxy.js'; export type { DataPermission } from './proxy.js';
export type { export type {
Org, Org,
+164
View File
@@ -0,0 +1,164 @@
import type { FastifyReply } from 'fastify';
/**
* Shared list-query handling for management routes, so every endpoint that
* lists things offers the same sort, filter and pagination the data tables
* expect. The frontend speaks a PostgREST-style querystring
* (`?name=ilike.*ada*&order=created_at.desc&limit=25&offset=0`); this parses
* that into SQL fragments the route composes into its own, already
* permission-scoped, query.
*
* Columns are allowlisted, values are parameterised, and the caller supplies
* the parameter start index so the filter params slot after its own.
*/
/**
* Maps an API column name (what the client sends) to its SQL expression (what
* the query uses, e.g. 'r.name' in a joined query). Values are developer
* authored and trusted; keys are matched against the request and never
* interpolated.
*/
export type ColumnMap = Record<string, string>;
export interface ListParamsConfig {
/** Columns allowed in ORDER BY, mapped to their SQL expression. */
sortable: ColumnMap;
/** Columns allowed to filter on, mapped to their SQL expression. */
filterable: ColumnMap;
/** Applied when the request sets no order, e.g. 'name.asc'. */
defaultOrder?: string;
/** Largest page a caller may request. Default 200. */
maxLimit?: number;
}
export interface ParsedListParams {
/** True when the request asked to paginate (sent limit or offset). */
paginated: boolean;
/** The page size, or the returned-row count when unpaginated. */
limit: number;
offset: number;
/** 'ORDER BY "col" ASC' or ''. */
orderSql: string;
/** Conditions joined by AND, without a WHERE or leading AND, or ''. The route
* composes these with its own scope conditions. */
filterSql: string;
/** Filter values, numbered from startIndex. */
params: unknown[];
/** ' LIMIT $n OFFSET $m', or '' when the request did not ask to paginate. */
pageSql: string;
/** All params in order: filters, then limit and offset when paginating. */
allParams: unknown[];
}
export class ListQueryError extends Error {
constructor(message: string) { super(message); this.name = 'ListQueryError'; }
}
const RESERVED = new Set(['order', 'limit', 'offset', 'select', 'include_archived']);
const OPERATORS: Record<string, (col: string, i: number) => { sql: string; wildcard?: boolean }> = {
eq: (c, i) => ({ sql: `${c} = $${i}` }),
neq: (c, i) => ({ sql: `${c} <> $${i}` }),
gt: (c, i) => ({ sql: `${c} > $${i}` }),
gte: (c, i) => ({ sql: `${c} >= $${i}` }),
lt: (c, i) => ({ sql: `${c} < $${i}` }),
lte: (c, i) => ({ sql: `${c} <= $${i}` }),
like: (c, i) => ({ sql: `${c} LIKE $${i}`, wildcard: true }),
ilike: (c, i) => ({ sql: `${c} ILIKE $${i}`, wildcard: true }),
};
function parseInteger(value: string | undefined, fallback: number): number {
if (value === undefined) return fallback;
const n = Number(value);
if (!Number.isInteger(n) || n < 0) throw new ListQueryError(`invalid_integer:${value}`);
return n;
}
function buildOrder(raw: string | undefined, sortable: ColumnMap, fallback: string | undefined): string {
const spec = raw ?? fallback;
if (!spec) return '';
const parts: string[] = [];
for (const segment of spec.split(',')) {
const [col, dir = 'asc'] = segment.trim().split('.');
const expr = sortable[col];
if (!expr) throw new ListQueryError(`unsortable_column:${col}`);
const direction = dir.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
parts.push(`${expr} ${direction}`);
}
return parts.length ? `ORDER BY ${parts.join(', ')}` : '';
}
/**
* Parses the querystring into SQL fragments. `qs` is req.query. `startIndex` is
* one past the count of params the route already uses, so filter params do not
* collide with the route's own.
*/
export function parseListParams(
qs: Record<string, unknown>,
config: ListParamsConfig,
startIndex = 1,
): ParsedListParams {
const maxLimit = config.maxLimit ?? 200;
const first = (v: unknown): string | undefined =>
Array.isArray(v) ? (v[0] as string | undefined) : (v as string | undefined);
// Pagination is opt-in: a request that sends neither limit nor offset gets
// every matching row, so existing clients that expect the full list are
// unchanged. A request that paginates is clamped to the max page.
const paginated = first(qs.limit) !== undefined || first(qs.offset) !== undefined;
const requestedLimit = parseInteger(first(qs.limit), maxLimit);
const limit = Math.min(requestedLimit, maxLimit);
const offset = parseInteger(first(qs.offset), 0);
const orderSql = buildOrder(first(qs.order), config.sortable, config.defaultOrder);
const conditions: string[] = [];
const params: unknown[] = [];
let index = startIndex;
for (const [key, rawValue] of Object.entries(qs)) {
if (RESERVED.has(key)) continue;
const value = first(rawValue);
if (value === undefined || value === '') continue;
const expr = config.filterable[key];
if (!expr) throw new ListQueryError(`unfilterable_column:${key}`);
const dot = value.indexOf('.');
const op = dot === -1 ? 'eq' : value.slice(0, dot).toLowerCase();
const raw = dot === -1 ? value : value.slice(dot + 1);
const build = OPERATORS[op];
if (!build) throw new ListQueryError(`unsupported_operator:${op}`);
const { sql, wildcard } = build(expr, index);
conditions.push(sql);
params.push(wildcard ? raw.replace(/\*/g, '%') : raw);
index += 1;
}
const filterSql = conditions.join(' AND ');
const pageSql = paginated ? ` LIMIT $${index} OFFSET $${index + 1}` : '';
return {
paginated,
limit,
offset,
orderSql,
filterSql,
params,
pageSql,
allParams: paginated ? [...params, limit, offset] : [...params],
};
}
/** Joins scope conditions with the parsed filter conditions into a WHERE, or ''. */
export function composeWhere(...conditions: Array<string | undefined | null>): string {
const kept = conditions.filter((c): c is string => Boolean(c && c.trim()));
return kept.length ? `WHERE ${kept.join(' AND ')}` : '';
}
/** Sets the Content-Range header the frontend reads for the total row count. */
export function setContentRange(reply: FastifyReply, offset: number, returned: number, total: number): void {
const end = returned > 0 ? offset + returned - 1 : offset;
reply.header('Content-Range', `${offset}-${end}/${total}`);
}
+40
View File
@@ -0,0 +1,40 @@
import { readFileSync, readdirSync, existsSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
const sqlDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'sql');
export interface MorbacMigration {
filename: string;
sql: string;
}
// The morbac schema ships with this package as a PGXN-style extension script
// (sql/pgmorbac--<version>.sql) plus upgrade scripts (sql/upgrades/). For the
// crudy migration runner they are exposed as plain migrations: the base script
// keeps the historical filename 0001_morbac.sql so schema_migrations rows on
// existing databases stay valid, and upgrade scripts follow in version order
// as 0001_morbac--<old>--<new>.sql.
export function morbacMigrations(): MorbacMigration[] {
const base = readdirSync(sqlDir)
.filter(f => /^pgmorbac--\d+\.\d+\.\d+\.sql$/.test(f))
.sort();
if (base.length !== 1) {
throw new Error(`[pgmorbac] Expected exactly one base script in ${sqlDir}, found ${base.length}`);
}
const migrations: MorbacMigration[] = [{
filename: '0001_morbac.sql',
sql: readFileSync(join(sqlDir, base[0]), 'utf8'),
}];
const upgradesDir = join(sqlDir, 'upgrades');
if (existsSync(upgradesDir)) {
for (const f of readdirSync(upgradesDir).filter(n => n.endsWith('.sql')).sort()) {
migrations.push({
filename: `0001_morbac${f.replace(/^pgmorbac/, '').replace(/\.sql$/, '')}.sql`,
sql: readFileSync(join(upgradesDir, f), 'utf8'),
});
}
}
return migrations;
}
+35
View File
@@ -0,0 +1,35 @@
import type { Pool } from 'pg';
/**
* Wraps a query selecting the visible orgs (id, name, parent_id, metadata) and
* computes each org's path as the names from the nearest visible ancestor down
* to it. Recursion is seeded from orgs whose parent is absent from the visible
* set, so a path can never name an ancestor the caller cannot read. Rows come
* back parent-first (ORDER BY path).
*/
export function withOrgPaths(visSql: string): string {
return `WITH RECURSIVE vis AS (${visSql}),
tree AS (
SELECT v.id, v.name, v.parent_id, v.metadata, ARRAY[v.name]::text[] AS path
FROM vis v
WHERE v.parent_id IS NULL OR NOT EXISTS (SELECT 1 FROM vis p WHERE p.id = v.parent_id)
UNION ALL
SELECT v.id, v.name, v.parent_id, v.metadata, t.path || v.name
FROM vis v JOIN tree t ON v.parent_id = t.id
)
SELECT id, name, parent_id, metadata, path FROM tree ORDER BY path`;
}
/**
* Ancestor-path names, keyed by org id, for every org the user may read under
* `view`. The truncation is the point: a caller who cannot read a parent sees
* the path start at the topmost org they can read, never above it.
*/
export async function readableOrgPaths(db: Pool, userId: string, view: string): Promise<Map<string, string[]>> {
const { rows } = await db.query<{ id: string; path: string[] }>(
withOrgPaths(
`SELECT id, name, parent_id, NULL::jsonb AS metadata
FROM morbac.orgs WHERE morbac.is_allowed($1::UUID, id, 'read', $2)`),
[userId, view]);
return new Map(rows.map((r) => [r.id, r.path]));
}
+240
View File
@@ -3,6 +3,38 @@ import type { Pool } from 'pg';
export const SYSTEM_ORG_ID = '00000000-0000-0000-0000-000000000000'; 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 { export function getUser(req: FastifyRequest): { id: string } | null {
return ((req as unknown) as Record<string, unknown>).user as { id: string } | null ?? 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); const multiVal = Array.isArray(multi) ? multi[0] : (multi ?? null);
if (multiVal) { if (multiVal) {
const ids = multiVal.split(',').map((s) => s.trim()).filter(Boolean); 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; 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); 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( export async function hasPermission(
db: Pool, db: Pool,
req: FastifyRequest, req: FastifyRequest,
@@ -84,3 +287,40 @@ export async function hasPermission(
} }
return true; 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;
}
+2
View File
@@ -7,6 +7,7 @@ import { createUserRoutes } from './routes/users.js';
import { createGlobalRoutes } from './routes/globals.js'; import { createGlobalRoutes } from './routes/globals.js';
import { createActivityRoutes } from './routes/activities.js'; import { createActivityRoutes } from './routes/activities.js';
import { createDelegationRoutes } from './routes/delegations.js'; import { createDelegationRoutes } from './routes/delegations.js';
import { createPermissionProfileRoutes } from './routes/permission-profiles.js';
export type CheckLimitFn = ( export type CheckLimitFn = (
reply: FastifyReply, reply: FastifyReply,
@@ -43,6 +44,7 @@ export function createMgmtRoutes(opts: MgmtPluginOptions) {
return async function mgmtRoutes(app: FastifyInstance) { return async function mgmtRoutes(app: FastifyInstance) {
await app.register(createOrgRoutes(db, checkLimit, emitOrgEvent)); await app.register(createOrgRoutes(db, checkLimit, emitOrgEvent));
await app.register(createRoleRoutes(db, checkLimit, emitRoleEvent)); await app.register(createRoleRoutes(db, checkLimit, emitRoleEvent));
await app.register(createPermissionProfileRoutes(db, checkLimit, emitRoleEvent));
await app.register(createRuleRoutes(db)); await app.register(createRuleRoutes(db));
await app.register(createUserRoutes(db, checkLimit)); await app.register(createUserRoutes(db, checkLimit));
await app.register(createGlobalRoutes(db)); await app.register(createGlobalRoutes(db));
+61 -7
View File
@@ -1,11 +1,38 @@
import type { FastifyRequest, FastifyReply } from 'fastify'; import type { FastifyRequest, FastifyReply } from 'fastify';
import type { Pool } from 'pg'; import type { Pool } from 'pg';
import { hasPermission, hasPermissionAny } from './permissions.js';
export interface DataPermission { export interface DataPermission {
activity: string; activity: string;
view: string; view: string;
} }
/** Rows returned by a GET that does not ask for a page size. */
export const DEFAULT_PAGE_LIMIT = 100;
/**
* Largest page a caller may ask for. Deliberately well above the table's
* page-size options; bulk reads belong on the export routes, which stream
* from a cursor instead of buffering a PostgREST response.
*
* Not configurable: an env override would be an escape hatch around the cap.
*/
export const MAX_PAGE_LIMIT = 1000;
/**
* Rejects rather than clamps. A clamp answers 200 with a short Content-Range
* that the caller reads as authoritative, so a truncated read looks like a
* complete one and surfaces later as wrong numbers. A 400 surfaces at the
* call site immediately.
*/
function checkLimit(rawQs: string): { ok: true } | { ok: false } {
for (const value of new URLSearchParams(rawQs).getAll('limit')) {
const n = Number(value);
if (!Number.isInteger(n) || n < 0 || n > MAX_PAGE_LIMIT) return { ok: false };
}
return { ok: true };
}
export type ProxyDataRouteFn = ( export type ProxyDataRouteFn = (
app: import('fastify').FastifyInstance, app: import('fastify').FastifyInstance,
method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE', method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE',
@@ -44,6 +71,7 @@ export function createProxyRegistry(db: Pool, postgrestUrl: string): ProxyRegist
method, method,
url: publicPath, url: publicPath,
schema: { hide: true } as Record<string, unknown>, schema: { hide: true } as Record<string, unknown>,
config: { permission },
handler: makeHandler(upstreamResource, permission), handler: makeHandler(upstreamResource, permission),
}); });
} }
@@ -56,16 +84,42 @@ export function createProxyRegistry(db: Pool, postgrestUrl: string): ProxyRegist
if (permission !== null) { if (permission !== null) {
const orgId = req.headers['x-org-id']; const orgId = req.headers['x-org-id'];
const orgIdVal = Array.isArray(orgId) ? orgId[0] : orgId; const orgIdVal = Array.isArray(orgId) ? orgId[0] : orgId;
if (!orgIdVal) return reply.code(400).send({ error: 'X-Org-Id header is required' }); const orgIds = req.headers['x-org-ids'];
const orgIdsVal = Array.isArray(orgIds) ? orgIds[0] : orgIds;
const { rows } = await db.query<{ allowed: boolean }>( // Goes through hasPermission rather than is_allowed directly: it is
'SELECT morbac.is_allowed($1::UUID, $2::UUID, $3, $4) AS allowed', // the only place an API key's permission_scope is applied, so an
[user.id, orgIdVal, permission.activity, permission.view], // inline check would let a narrowly scoped key act as its full user.
); if (orgIdVal) {
if (!rows[0]?.allowed) return reply.code(403).send({ error: 'Forbidden' }); if (!await hasPermission(db, req, reply, orgIdVal, permission.activity, permission.view)) {
return reply;
}
} else if (orgIdsVal && orgIdsVal.trim()) {
// A multi-org list read (e.g. "all organizations"): scoped to the
// set via X-Org-Ids. RLS filters each row, so the gate only needs
// permission in one of them.
const ids = orgIdsVal.split(',').map(s => s.trim()).filter(Boolean);
if (!await hasPermissionAny(db, req, reply, ids, permission.activity, permission.view)) {
return reply;
}
} else {
return reply.code(400).send({ error: 'X-Org-Id header is required' });
}
}
const qsIndex = req.url.indexOf('?');
const rawQs = qsIndex === -1 ? '' : req.url.slice(qsIndex + 1);
if (!checkLimit(rawQs).ok) return reply.code(400).send({ error: 'limit_exceeded' });
// Only reads get a default page. On PATCH and DELETE, PostgREST reads
// limit as a bound on the affected rows, so injecting one would
// silently truncate a bulk mutation.
let qs = rawQs ? `?${rawQs}` : '';
if (req.method === 'GET' && !new URLSearchParams(rawQs).has('limit')) {
qs = rawQs ? `?${rawQs}&limit=${DEFAULT_PAGE_LIMIT}` : `?limit=${DEFAULT_PAGE_LIMIT}`;
} }
const qs = req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : '';
const url = `${postgrestUrl}/${upstreamResource}${qs}`; const url = `${postgrestUrl}/${upstreamResource}${qs}`;
const headers: Record<string, string> = {}; const headers: Record<string, string> = {};
+55
View File
@@ -0,0 +1,55 @@
import type { Pool, PoolClient } from 'pg';
import { orgSelectionToSetting, type OrgSelection } from './permissions.js';
export interface MorbacContext {
userId: string;
/** Pin to a single organisation. Unattributed records are excluded by a
* pin; use `orgs` to select them. */
orgId?: string | null;
/** Which records to return: 'all' (every org, unattributed included), or a
* list of targets where an UNATTRIBUTED (null) entry selects records that
* have no org. Ignored when `orgId` is set. */
orgs?: OrgSelection;
}
/**
* Runs fn on a dedicated client inside a transaction with the morbac session
* GUCs set, so morbac RLS policies see the acting user.
*
* Whether that is load bearing depends on which pool it is given. Under
* app_runtime, the role without BYPASSRLS that the metrics read path uses, it
* turns RLS into a second authorization gate behind hasPermission. Under
* app_backend, which every other caller still uses, the role attribute exempts
* it and the GUCs only feed the audit triggers.
*
* Routes not yet converted fail closed under app_runtime: with no GUC set,
* morbac.current_user_id() is NULL and the policies return no rows.
*/
export async function withMorbacContext<T>(
pool: Pool,
ctx: MorbacContext,
fn: (client: PoolClient) => Promise<T>,
): Promise<T> {
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query(
`SELECT set_config('morbac.user_id', $1, true),
set_config('morbac.org_id', $2, true),
set_config('morbac.org_ids', $3, true)`,
[
ctx.userId,
ctx.orgId ?? '',
(ctx.orgs === undefined ? null : orgSelectionToSetting(ctx.orgs)) ?? '',
],
);
const result = await fn(client);
await client.query('COMMIT');
return result;
} catch (err) {
await client.query('ROLLBACK').catch(() => undefined);
throw err;
} finally {
client.release();
}
}
+22 -2
View File
@@ -9,7 +9,7 @@ export function createActivityRoutes(db: Pool) {
schema: { schema: {
tags: ['Management'], tags: ['Management'],
summary: 'List activities', summary: 'List activities',
description: 'Read-only list of permission verbs (read, create, update, delete). Managed at the database level.', description: 'Read-only list of permission verbs (read, create, update, delete). Managed at the database level. Requires `read`/`rules`.',
security: [{ oauth2: [] }], security: [{ oauth2: [] }],
response: { response: {
200: { 200: {
@@ -23,11 +23,15 @@ export function createActivityRoutes(db: Pool) {
}, },
}, },
401: unauthorized, 401: unauthorized,
403: forbidden,
}, },
}, },
}, async (req, reply) => { }, async (req, reply) => {
const user = getUser(req); const user = getUser(req);
if (!user) return reply.code(401).send({ error: 'Authentication required' }); if (!user) return reply.code(401).send({ error: 'Authentication required' });
const orgIds = getOrgScope(req, reply, false);
if (orgIds === null) return reply;
if (!await hasPermission(db, req, reply, orgIds[0] ?? null, 'read', 'rules')) return reply;
const { rows } = await db.query('SELECT name, description FROM morbac.activities ORDER BY name'); const { rows } = await db.query('SELECT name, description FROM morbac.activities ORDER BY name');
return rows; return rows;
}); });
@@ -115,16 +119,28 @@ export function createActivityRoutes(db: Pool) {
properties: { properties: {
name: { type: 'string' }, name: { type: 'string' },
description: { type: 'string', nullable: true }, description: { type: 'string', nullable: true },
no_org: { type: 'boolean' },
}, },
}, },
}, },
401: unauthorized, 401: unauthorized,
403: forbidden,
}, },
}, },
}, async (req, reply) => { }, async (req, reply) => {
const user = getUser(req); const user = getUser(req);
if (!user) return reply.code(401).send({ error: 'Authentication required' }); if (!user) return reply.code(401).send({ error: 'Authentication required' });
const { rows } = await db.query('SELECT name, description FROM morbac.views ORDER BY name'); const orgIds = getOrgScope(req, reply, false);
if (orgIds === null) return reply;
if (!await hasPermission(db, req, reply, orgIds[0] ?? null, 'read', 'rules')) return reply;
// no_org marks a view whose rows carry no org, so callers can offer the
// unattributed grant instead of a tree-scoped rule that could never match.
const { rows } = await db.query(
`SELECT v.name, v.description, COALESCE(f.no_org, FALSE) AS no_org
FROM morbac.views v
LEFT JOIN app.view_flags f ON f.view = v.name
ORDER BY v.name`,
);
return rows; return rows;
}); });
@@ -215,11 +231,15 @@ export function createActivityRoutes(db: Pool) {
}, },
}, },
401: unauthorized, 401: unauthorized,
403: forbidden,
}, },
}, },
}, async (req, reply) => { }, async (req, reply) => {
const user = getUser(req); const user = getUser(req);
if (!user) return reply.code(401).send({ error: 'Authentication required' }); if (!user) return reply.code(401).send({ error: 'Authentication required' });
const orgIds = getOrgScope(req, reply, false);
if (orgIds === null) return reply;
if (!await hasPermission(db, req, reply, orgIds[0] ?? null, 'read', 'rules')) return reply;
const { rows } = await db.query( const { rows } = await db.query(
'SELECT activity, view FROM morbac.activity_view_bindings ORDER BY activity, view', 'SELECT activity, view FROM morbac.activity_view_bindings ORDER BY activity, view',
); );
+9 -1
View File
@@ -1,6 +1,6 @@
import type { FastifyInstance } from 'fastify'; import type { FastifyInstance } from 'fastify';
import type { Pool } from 'pg'; import type { Pool } from 'pg';
import { getUser, getOrgScope, orgScopeExpr, hasPermission, SYSTEM_ORG_ID } from '../permissions.js'; import { getUser, getOrgScope, orgScopeExpr, hasPermission, canActOnUser, SYSTEM_ORG_ID } from '../permissions.js';
import { delegationResponse, notFound, forbidden, unauthorized, badRequest } from '../schemas.js'; import { delegationResponse, notFound, forbidden, unauthorized, badRequest } from '../schemas.js';
const SELECT_COLUMNS = ` const SELECT_COLUMNS = `
@@ -84,6 +84,14 @@ export function createDelegationRoutes(db: Pool) {
if (!await hasPermission(db, req, reply, org_id, 'create', 'delegations')) return reply; if (!await hasPermission(db, req, reply, org_id, 'create', 'delegations')) return reply;
// The caller must be the delegator, or hold authority over them. Otherwise
// any create/delegations holder could self-grant a powerful role held by
// another principal by naming them as delegator.
if (delegator_id !== user.id
&& !await canActOnUser(db, user.id, delegator_id, 'create', 'user_roles')) {
return reply.code(403).send({ error: 'Cannot delegate on behalf of this user' });
}
const from = valid_from ? new Date(valid_from) : new Date(); const from = valid_from ? new Date(valid_from) : new Date();
const until = new Date(valid_until); const until = new Date(valid_until);
if (Number.isNaN(until.getTime()) || until <= from) { if (Number.isNaN(until.getTime()) || until <= from) {
+12 -4
View File
@@ -1,7 +1,7 @@
import type { FastifyInstance } from 'fastify'; import type { FastifyInstance } from 'fastify';
import type { Pool } from 'pg'; import type { Pool } from 'pg';
import { getUser, hasPermission, SYSTEM_ORG_ID } from '../permissions.js'; import { getUser, hasPermission, isActivityViewBound, SYSTEM_ORG_ID } from '../permissions.js';
import { globalRuleResponse, notFound, forbidden, unauthorized } from '../schemas.js'; import { globalRuleResponse, notFound, forbidden, unauthorized, badRequest } from '../schemas.js';
export function createGlobalRoutes(db: Pool) { export function createGlobalRoutes(db: Pool) {
return async function globalRoutes(app: FastifyInstance) { return async function globalRoutes(app: FastifyInstance) {
@@ -57,7 +57,7 @@ export function createGlobalRoutes(db: Pool) {
}, },
response: { response: {
201: globalRuleResponse, 201: globalRuleResponse,
401: unauthorized, 403: forbidden, 400: badRequest, 401: unauthorized, 403: forbidden,
409: { description: 'Rule already exists.' }, 409: { description: 'Rule already exists.' },
}, },
}, },
@@ -79,6 +79,12 @@ export function createGlobalRoutes(db: Pool) {
if (!await hasPermission(db, req, reply, SYSTEM_ORG_ID, 'create', 'rules')) return reply; if (!await hasPermission(db, req, reply, SYSTEM_ORG_ID, 'create', 'rules')) return reply;
// activity/view null = wildcard (e.g. the global admin rule) - not a concrete
// pair to validate against the bindings whitelist.
if (activity && view && !await isActivityViewBound(db, activity, view)) {
return reply.code(400).send({ error: `Activity "${activity}" is not valid for view "${view}"` });
}
try { try {
const { rows } = await db.query( const { rows } = await db.query(
`INSERT INTO morbac.global_rules (user_id, activity, view, context_id, modality, priority) `INSERT INTO morbac.global_rules (user_id, activity, view, context_id, modality, priority)
@@ -93,7 +99,9 @@ export function createGlobalRoutes(db: Pool) {
); );
return reply.code(201).send(rows[0]); return reply.code(201).send(rows[0]);
} catch (e: unknown) { } catch (e: unknown) {
if ((e as { code?: string }).code === '23505') return reply.code(409).send({ error: 'Rule already exists' }); const code = (e as { code?: string }).code;
if (code === '23505') return reply.code(409).send({ error: 'Rule already exists' });
if (code === '23503') return reply.code(400).send({ error: 'Unknown activity or view' });
throw e; throw e;
} }
}); });
+43 -17
View File
@@ -1,4 +1,5 @@
import type { FastifyInstance } from 'fastify'; import type { FastifyInstance } from 'fastify';
import { withMorbacContext } from '../rls-context.js';
import type { Pool } from 'pg'; import type { Pool } from 'pg';
import type { CheckLimitFn, OrgEventSink } from '../plugin.js'; import type { CheckLimitFn, OrgEventSink } from '../plugin.js';
import { import {
@@ -6,8 +7,11 @@ import {
} from '../permissions.js'; } from '../permissions.js';
import { orgResponse, notFound, forbidden, unauthorized, badRequest } from '../schemas.js'; import { orgResponse, notFound, forbidden, unauthorized, badRequest } from '../schemas.js';
import { withOrgPaths } from '../org-paths.js';
interface OrgRow { id: string; name: string; parent_id: string | null; metadata?: Record<string, unknown> } interface OrgRow { id: string; name: string; parent_id: string | null; metadata?: Record<string, unknown> }
export function createOrgRoutes(db: Pool, checkLimit?: CheckLimitFn, emitOrgEvent?: OrgEventSink) { 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', {
@@ -29,22 +33,23 @@ export function createOrgRoutes(db: Pool, checkLimit?: CheckLimitFn, emitOrgEven
if (orgIds === null) return reply; if (orgIds === null) return reply;
if (orgIds.length === 0) { if (orgIds.length === 0) {
const { rows: adminCheck } = await db.query<{ allowed: boolean }>( // Unpinned: the caller's whole perimeter. Two ways in, and both
'SELECT morbac.is_allowed($1::UUID, NULL::UUID, $2, $3) AS allowed', // are needed. A rule scoped to a subtree, a user rule or a
[user.id, 'read', 'orgs'], // cross-org rule reaches orgs the caller holds no role in, and
); // those belong in the org filter. A role in an org puts the
if (adminCheck[0]?.allowed) { // caller in it even with no read/orgs rule, and the org filter is
const { rows } = await db.query( // what supplies X-Org-Id, so dropping those orgs leaves such a
'SELECT id, name, parent_id, metadata FROM morbac.orgs ORDER BY name', // user with an empty perimeter and an app that does nothing.
); const { rows } = await withMorbacContext(db, { userId: user.id }, (client) =>
return rows; client.query(
} withOrgPaths(
const { rows } = await db.query( `SELECT id, name, parent_id, metadata FROM morbac.orgs o
`SELECT DISTINCT o.id, o.name, o.parent_id, o.metadata WHERE morbac.is_allowed($1::UUID, o.id, 'read', 'orgs')
FROM morbac.orgs o OR EXISTS (SELECT 1 FROM morbac.user_roles ur
INNER JOIN morbac.user_roles ur ON ur.org_id = o.id AND ur.user_id = $1 WHERE ur.org_id = o.id AND ur.user_id = $1)`,
ORDER BY o.name`, ),
[user.id], [user.id],
),
); );
return rows; return rows;
} }
@@ -53,9 +58,11 @@ export function createOrgRoutes(db: Pool, checkLimit?: CheckLimitFn, emitOrgEven
const isSystemScope = orgIds.includes(SYSTEM_ORG_ID); const isSystemScope = orgIds.includes(SYSTEM_ORG_ID);
const { expr, params } = isSystemScope ? { expr: '', params: [] } : orgScopeExpr(orgIds, 'id'); const { expr, params } = isSystemScope ? { expr: '', params: [] } : orgScopeExpr(orgIds, 'id');
const { rows } = await db.query( const { rows } = await withMorbacContext(db, { userId: user.id, orgId: orgIds[0] }, (client) =>
`SELECT id, name, parent_id, metadata FROM morbac.orgs ${expr ? `WHERE ${expr}` : ''} ORDER BY name`, client.query(
withOrgPaths(`SELECT id, name, parent_id, metadata FROM morbac.orgs ${expr ? `WHERE ${expr}` : ''}`),
params, params,
),
); );
return rows; return rows;
}); });
@@ -140,6 +147,25 @@ export function createOrgRoutes(db: Pool, checkLimit?: CheckLimitFn, emitOrgEven
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 };
// Reparenting must be authorized against the destination, not just the
// moved org, and must not create a cycle (new parent within own subtree).
if ('parent_id' in body) {
if (body.parent_id === null) {
if (!await hasPermission(db, req, reply, id, 'create', 'root_orgs')) return reply;
} else if (body.parent_id) {
if (body.parent_id === id) return reply.code(400).send({ error: 'invalid_parent' });
const { rows: [cycle] } = await db.query<{ inside: boolean }>(
`SELECT EXISTS (
SELECT 1 FROM morbac.get_org_scope($1::UUID, 'subtree') AS s(org_id)
WHERE s.org_id = $2::UUID
) AS inside`,
[id, body.parent_id],
);
if (cycle?.inside) return reply.code(400).send({ error: 'invalid_parent' });
if (!await hasPermission(db, req, reply, body.parent_id, 'create', 'orgs')) return reply;
}
}
const { rows } = await db.query<OrgRow & { reparented: boolean }>( const { rows } = await db.query<OrgRow & { reparented: boolean }>(
`WITH prev AS (SELECT parent_id AS old_parent FROM morbac.orgs WHERE id = $4) `WITH prev AS (SELECT parent_id AS old_parent FROM morbac.orgs WHERE id = $4)
UPDATE morbac.orgs o UPDATE morbac.orgs o
+254
View File
@@ -0,0 +1,254 @@
import type { FastifyInstance } from 'fastify';
import type { Pool } from 'pg';
import type { CheckLimitFn, RoleEventSink } from '../plugin.js';
import { getUser, getOrgScope, hasPermission, SYSTEM_ORG_ID } from '../permissions.js';
import { roleResponse, notFound, forbidden, unauthorized, badRequest } from '../schemas.js';
import { loadProfile, validateGrantable, applyProfileRules } from './profile-apply.js';
const profileResponse = {
type: 'object',
properties: {
module_id: { type: 'string' },
key: { type: 'string' },
content_hash: { type: 'string' },
rules: {
type: 'array',
items: {
type: 'object',
properties: {
activity: { type: 'string' },
view: { type: 'string' },
modality: { type: 'string' },
scope: { type: 'string' },
},
},
},
},
};
const profileRef = {
type: 'object',
required: ['module_id', 'key'],
properties: {
module_id: { type: 'string', maxLength: 128 },
key: { type: 'string', maxLength: 128 },
},
};
export function createPermissionProfileRoutes(db: Pool, checkLimit?: CheckLimitFn, emitRoleEvent?: RoleEventSink) {
return async function permissionProfileRoutes(app: FastifyInstance) {
app.get('/permission-profiles', {
schema: {
tags: ['Management'],
summary: 'List permission profiles',
description: 'Returns role presets declared by modules. Requires `read`/`roles`.',
security: [{ oauth2: [] }],
querystring: {
type: 'object',
properties: { module_id: { type: 'string', maxLength: 128 } },
},
response: {
200: { type: 'array', items: profileResponse },
400: badRequest, 401: unauthorized, 403: forbidden,
},
},
}, async (req, reply) => {
const user = getUser(req);
if (!user) return reply.code(401).send({ error: 'Authentication required' });
const orgIds = getOrgScope(req, reply, false);
if (orgIds === null) return reply;
if (!await hasPermission(db, req, reply, orgIds[0] ?? null, 'read', 'roles')) return reply;
const { module_id } = req.query as { module_id?: string };
const { rows } = await db.query(
`SELECT module_id, key, content_hash, rules
FROM app.permission_profiles
${module_id ? 'WHERE module_id = $1' : ''}
ORDER BY module_id, key`,
module_id ? [module_id] : [],
);
return rows;
});
app.post('/roles/from-profile', {
schema: {
tags: ['Management'],
summary: 'Create a role from a profile',
description:
'Creates a role in the target org and seeds its rules from a module profile. '
+ 'Requires `create`/`roles`, and the caller must already hold every granted permission. '
+ 'When `sync` is true the role tracks future profile changes.',
security: [{ oauth2: [] }],
body: {
type: 'object',
required: ['org_id', 'name', 'profile'],
properties: {
org_id: { type: 'string', format: 'uuid' },
name: { type: 'string', minLength: 1, maxLength: 200 },
description: { type: 'string', maxLength: 500 },
sync: { type: 'boolean', default: false },
profile: profileRef,
},
},
response: {
201: roleResponse,
400: badRequest, 401: unauthorized, 403: forbidden, 404: notFound,
},
},
}, async (req, reply) => {
const user = getUser(req);
if (!user) return reply.code(401).send({ error: 'Authentication required' });
const { org_id, name, description, sync, profile } = req.body as {
org_id: string; name: string; description?: string; sync?: boolean;
profile: { module_id: string; key: string };
};
if (org_id === SYSTEM_ORG_ID) return reply.code(403).send({ error: 'System entities are immutable' });
if (!await hasPermission(db, req, reply, org_id, 'create', 'roles')) return reply;
const prof = await loadProfile(db, profile.module_id, profile.key);
if (!prof) return reply.code(404).send({ error: 'Not found' });
if (checkLimit) {
const ok = await checkLimit(reply, 'max_roles', 'SELECT count(*)::TEXT AS c FROM morbac.roles');
if (!ok) return reply;
}
if (!await validateGrantable(db, reply, user.id, org_id, prof.rules)) return reply;
const client = await db.connect();
try {
await client.query('BEGIN');
const { rows: [role] } = await client.query(
`INSERT INTO morbac.roles (org_id, name, description) VALUES ($1, $2, $3)
RETURNING id, org_id, name, description`,
[org_id, name, description ?? null],
);
const nonSelf = await applyProfileRules(client, org_id, role.id, profile.module_id, profile.key, prof.rules);
if (sync) {
await client.query(
`INSERT INTO app.role_profile_links (role_id, module_id, profile_key, synced, applied_hash)
VALUES ($1, $2, $3, TRUE, $4)`,
[role.id, profile.module_id, profile.key, prof.content_hash],
);
}
await client.query('COMMIT');
if (nonSelf) await db.query('SELECT morbac.invalidate_cache()');
const org = await db.query('SELECT name FROM morbac.orgs WHERE id = $1', [org_id]);
emitRoleEvent?.({ action: 'created', role, actorId: user.id });
return reply.code(201).send({ ...role, org_name: org.rows[0]?.name });
} catch (e) {
await client.query('ROLLBACK');
throw e;
} finally {
client.release();
}
});
app.post('/roles/:id/profile-sync', {
schema: {
tags: ['Management'],
summary: 'Attach a profile to an existing role',
description:
'Applies a module profile to an existing role and records the link. Requires '
+ '`update`/`roles`, and the caller must already hold every granted permission. '
+ 'When `sync` is true the role tracks future profile changes.',
security: [{ oauth2: [] }],
params: { type: 'object', properties: { id: { type: 'string', format: 'uuid' } } },
body: {
type: 'object',
required: ['profile'],
properties: {
profile: profileRef,
sync: { type: 'boolean', default: true },
},
},
response: {
200: {
type: 'object',
properties: {
role_id: { type: 'string', format: 'uuid' },
module_id: { type: 'string' },
profile_key: { type: 'string' },
synced: { type: 'boolean' },
applied_hash: { type: 'string' },
},
},
400: badRequest, 401: unauthorized, 403: forbidden, 404: notFound,
},
},
}, async (req, reply) => {
const user = getUser(req);
if (!user) return reply.code(401).send({ error: 'Authentication required' });
const { id } = req.params as { id: string };
const { profile, sync } = req.body as { profile: { module_id: string; key: string }; sync?: boolean };
const synced = sync !== false;
const { rows: [roleRow] } = await db.query<{ org_id: string }>(
'SELECT org_id FROM morbac.roles WHERE id = $1', [id],
);
if (!roleRow) return reply.code(404).send({ error: 'Not found' });
if (roleRow.org_id === SYSTEM_ORG_ID) return reply.code(403).send({ error: 'System entities are immutable' });
if (!await hasPermission(db, req, reply, roleRow.org_id, 'update', 'roles')) return reply;
const prof = await loadProfile(db, profile.module_id, profile.key);
if (!prof) return reply.code(404).send({ error: 'Not found' });
if (!await validateGrantable(db, reply, user.id, roleRow.org_id, prof.rules)) return reply;
const client = await db.connect();
try {
await client.query('BEGIN');
const nonSelf = await applyProfileRules(client, roleRow.org_id, id, profile.module_id, profile.key, prof.rules);
await client.query(
`INSERT INTO app.role_profile_links (role_id, module_id, profile_key, synced, applied_hash, applied_at)
VALUES ($1, $2, $3, $4, $5, now())
ON CONFLICT (role_id)
DO UPDATE SET module_id = EXCLUDED.module_id, profile_key = EXCLUDED.profile_key,
synced = EXCLUDED.synced, applied_hash = EXCLUDED.applied_hash, applied_at = now()`,
[id, profile.module_id, profile.key, synced, prof.content_hash],
);
await client.query('COMMIT');
if (nonSelf) await db.query('SELECT morbac.invalidate_cache()');
return reply.send({ role_id: id, module_id: profile.module_id, profile_key: profile.key, synced, applied_hash: prof.content_hash });
} catch (e) {
await client.query('ROLLBACK');
throw e;
} finally {
client.release();
}
});
app.delete('/roles/:id/profile-sync', {
schema: {
tags: ['Management'],
summary: 'Detach a role from its profile',
description:
"Removes the profile link so the role stops tracking the profile. Requires "
+ "`update`/`roles`. Already-applied rules are left in place.",
security: [{ oauth2: [] }],
params: { type: 'object', properties: { id: { type: 'string', format: 'uuid' } } },
response: {
204: { type: 'null', description: 'Link removed.' },
400: badRequest, 401: unauthorized, 403: forbidden, 404: notFound,
},
},
}, async (req, reply) => {
const user = getUser(req);
if (!user) return reply.code(401).send({ error: 'Authentication required' });
const { id } = req.params as { id: string };
const { rows: [roleRow] } = await db.query<{ org_id: string }>(
'SELECT org_id FROM morbac.roles WHERE id = $1', [id],
);
if (!roleRow) return reply.code(404).send({ error: 'Not found' });
if (roleRow.org_id === SYSTEM_ORG_ID) return reply.code(403).send({ error: 'System entities are immutable' });
if (!await hasPermission(db, req, reply, roleRow.org_id, 'update', 'roles')) return reply;
await db.query('DELETE FROM app.role_profile_links WHERE role_id = $1', [id]);
return reply.code(204).send();
});
};
}
+75
View File
@@ -0,0 +1,75 @@
import type { Pool, PoolClient } from 'pg';
import type { FastifyReply } from 'fastify';
import { canGrantRule, isActivityViewBound } from '../permissions.js';
export interface ProfileRule {
activity: string;
view: string;
modality: 'permission' | 'prohibition';
scope: string;
}
export async function loadProfile(
db: Pool, moduleId: string, key: string,
): Promise<{ rules: ProfileRule[]; content_hash: string } | null> {
const { rows: [row] } = await db.query<{ rules: ProfileRule[]; content_hash: string }>(
'SELECT rules, content_hash FROM app.permission_profiles WHERE module_id = $1 AND key = $2',
[moduleId, key],
);
return row ?? null;
}
/** Anti-escalation gate for the caller-driven apply: every rule must be a valid
* binding, and the caller must already hold every permission it would grant.
* Sends the reply and returns false on the first failure. */
export async function validateGrantable(
db: Pool, reply: FastifyReply, userId: string, orgId: string, rules: ProfileRule[],
): Promise<boolean> {
for (const r of rules) {
if (!await isActivityViewBound(db, r.activity, r.view)) {
reply.code(400).send({ error: `Activity "${r.activity}" is not valid for view "${r.view}"` });
return false;
}
if (r.modality === 'permission'
&& !await canGrantRule(db, userId, orgId, r.scope, r.activity, r.view)) {
reply.code(403).send({ error: 'Cannot grant a permission you do not hold on the target orgs' });
return false;
}
}
return true;
}
/** Insert/adopt every profile rule (tagged) and revoke this profile's tagged rules
* no longer in the set. Manual (untagged) rules are never touched. Returns whether
* any touched rule was non-self, so the caller can flush the morbac cache. */
export async function applyProfileRules(
client: PoolClient, orgId: string, roleId: string, moduleId: string, key: string, rules: ProfileRule[],
): Promise<boolean> {
const tag = JSON.stringify({ profile: { module_id: moduleId, key } });
let touchedNonSelf = false;
for (const r of rules) {
await client.query(
`INSERT INTO morbac.rules
(org_id, role_id, activity, view, context_id, modality, scope, is_active, metadata)
VALUES ($1, $2, $3, $4,
(SELECT id FROM morbac.contexts WHERE name = 'always'),
$5::morbac.modality, $6, TRUE, $7::jsonb)
ON CONFLICT (org_id, role_id, activity, view, context_id, modality, scope)
DO UPDATE SET metadata = EXCLUDED.metadata, is_active = TRUE`,
[orgId, roleId, r.activity, r.view, r.modality, r.scope, tag],
);
if (r.scope !== 'self') touchedNonSelf = true;
}
const keep = rules.map(r => `${r.activity}|${r.view}|${r.modality}|${r.scope}`);
const { rows: removed } = await client.query<{ scope: string }>(
`DELETE FROM morbac.rules
WHERE role_id = $1
AND metadata->'profile'->>'module_id' = $2
AND metadata->'profile'->>'key' = $3
AND (activity || '|' || view || '|' || modality::text || '|' || scope) <> ALL($4::text[])
RETURNING scope`,
[roleId, moduleId, key, keep],
);
if (removed.some(row => row.scope !== 'self')) touchedNonSelf = true;
return touchedNonSelf;
}
+18
View File
@@ -0,0 +1,18 @@
import type { Pool } from 'pg';
/**
* Whether the caller may hand out this role at all: assigning it is exercising
* authority over the organization the role lives in, not over the user.
*/
export async function callerControlsRoleOrg(
db: Pool, callerId: string, roleId: string, activity: string,
): Promise<boolean> {
const { rows } = await db.query<{ ok: boolean }>(
`SELECT EXISTS (
SELECT 1 FROM morbac.roles r
WHERE r.id = $2::UUID AND morbac.is_allowed($1::UUID, r.org_id, $3, 'user_roles')
) AS ok`,
[callerId, roleId, activity],
);
return rows[0]?.ok ?? false;
}
+29 -5
View File
@@ -4,8 +4,12 @@ import type { CheckLimitFn, RoleEventSink } 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 { parseListParams, composeWhere, setContentRange, ListQueryError } from '../list-query.js';
import { roleResponse, notFound, forbidden, unauthorized, badRequest } from '../schemas.js'; import { roleResponse, notFound, forbidden, unauthorized, badRequest } from '../schemas.js';
const ROLE_SORTABLE = { name: 'r.name', org_name: 'o.name' };
const ROLE_FILTERABLE = { name: 'r.name', description: 'r.description', org_id: 'r.org_id' };
export function createRoleRoutes(db: Pool, checkLimit?: CheckLimitFn, emitRoleEvent?: RoleEventSink) { export function createRoleRoutes(db: Pool, checkLimit?: CheckLimitFn, emitRoleEvent?: RoleEventSink) {
return async function roleRoutes(app: FastifyInstance) { return async function roleRoutes(app: FastifyInstance) {
app.get('/roles', { app.get('/roles', {
@@ -29,15 +33,35 @@ export function createRoleRoutes(db: Pool, checkLimit?: CheckLimitFn, emitRoleEv
if (!await hasPermission(db, req, reply, orgIds[0] ?? null, 'read', 'roles')) return reply; if (!await hasPermission(db, req, reply, orgIds[0] ?? null, 'read', 'roles')) return reply;
const { expr, params } = orgScopeExpr(orgIds, 'r.org_id'); const { expr, params } = orgScopeExpr(orgIds, 'r.org_id');
let list;
try {
list = parseListParams(req.query as Record<string, unknown>, {
sortable: ROLE_SORTABLE,
filterable: ROLE_FILTERABLE,
defaultOrder: 'org_name.asc,name.asc',
}, params.length + 1);
} catch (e) {
if (e instanceof ListQueryError) return reply.code(400).send({ error: 'invalid_query' });
throw e;
}
const where = composeWhere(expr, list.filterSql);
const { rows } = await db.query( const { rows } = await db.query(
`SELECT r.id, r.org_id, r.name, r.description, o.name AS org_name `SELECT r.id, r.org_id, r.name, r.description, o.name AS org_name,
rpl.module_id AS profile_module_id, rpl.profile_key, rpl.synced AS profile_synced,
count(*) OVER() AS _total
FROM morbac.roles r FROM morbac.roles r
JOIN morbac.orgs o ON o.id = r.org_id JOIN morbac.orgs o ON o.id = r.org_id
${expr ? `WHERE ${expr}` : ''} LEFT JOIN app.role_profile_links rpl ON rpl.role_id = r.id
ORDER BY o.name, r.name`, ${where}
params, ${list.orderSql || 'ORDER BY o.name, r.name'}
${list.pageSql}`,
[...params, ...list.allParams],
); );
return rows;
const total = rows.length > 0 ? Number((rows[0] as { _total: string })._total) : 0;
setContentRange(reply, list.offset, rows.length, total);
return (rows as Array<Record<string, unknown>>).map(({ _total, ...rest }) => rest);
}); });
app.post('/roles', { app.post('/roles', {
+9 -2
View File
@@ -1,6 +1,6 @@
import type { FastifyInstance } from 'fastify'; import type { FastifyInstance } from 'fastify';
import type { Pool } from 'pg'; import type { Pool } from 'pg';
import { getUser, getOrgScope, orgScopeExpr, hasPermission, SYSTEM_ORG_ID } from '../permissions.js'; import { getUser, getOrgScope, orgScopeExpr, hasPermission, isActivityViewBound, SYSTEM_ORG_ID } from '../permissions.js';
import { crossOrgRuleResponse, notFound, forbidden, unauthorized, badRequest } from '../schemas.js'; import { crossOrgRuleResponse, notFound, forbidden, unauthorized, badRequest } from '../schemas.js';
export function createCrossOrgRuleRoutes(db: Pool) { export function createCrossOrgRuleRoutes(db: Pool) {
@@ -87,6 +87,10 @@ export function createCrossOrgRuleRoutes(db: Pool) {
if (source_org_id === SYSTEM_ORG_ID) return reply.code(403).send({ error: 'System entities are immutable' }); if (source_org_id === SYSTEM_ORG_ID) return reply.code(403).send({ error: 'System entities are immutable' });
if (!await hasPermission(db, req, reply, source_org_id, 'create', 'cross_org_rules')) return reply; if (!await hasPermission(db, req, reply, source_org_id, 'create', 'cross_org_rules')) return reply;
if (!await isActivityViewBound(db, activity, view)) {
return reply.code(400).send({ error: `Activity "${activity}" is not valid for view "${view}"` });
}
try { try {
const { rows } = await db.query( const { rows } = await db.query(
`INSERT INTO morbac.cross_org_rules `INSERT INTO morbac.cross_org_rules
@@ -106,7 +110,10 @@ export function createCrossOrgRuleRoutes(db: Pool) {
); );
return reply.code(201).send(rows[0]); return reply.code(201).send(rows[0]);
} catch (e: unknown) { } catch (e: unknown) {
if ((e as { code?: string }).code === '23505') return reply.code(409).send({ error: 'Rule already exists' }); const code = (e as { code?: string }).code;
if (code === '23505') return reply.code(409).send({ error: 'Rule already exists' });
if (code === '23503') return reply.code(400).send({ error: 'Unknown activity or view' });
if (code === 'P0001') return reply.code(400).send({ error: `Activity "${activity}" is not valid for view "${view}"` });
throw e; throw e;
} }
}); });
+41 -7
View File
@@ -1,6 +1,6 @@
import type { FastifyInstance } from 'fastify'; import type { FastifyInstance } from 'fastify';
import type { Pool } from 'pg'; import type { Pool } from 'pg';
import { getUser, getOrgScope, orgScopeExpr, hasPermission, SYSTEM_ORG_ID } from '../permissions.js'; import { getUser, getOrgScope, orgScopeExpr, hasPermission, canGrantRule, isActivityViewBound, SYSTEM_ORG_ID } from '../permissions.js';
import { ruleResponse, notFound, forbidden, unauthorized, badRequest } from '../schemas.js'; import { ruleResponse, notFound, forbidden, unauthorized, badRequest } from '../schemas.js';
export function createRoleRuleRoutes(db: Pool) { export function createRoleRuleRoutes(db: Pool) {
@@ -28,7 +28,10 @@ export function createRoleRuleRoutes(db: Pool) {
const { expr, params } = orgScopeExpr(orgIds, 'r.org_id'); const { expr, params } = orgScopeExpr(orgIds, 'r.org_id');
const { rows } = await db.query( const { rows } = await db.query(
`SELECT r.id, r.role_id, r.org_id, o.name AS org_name, ro.name AS role_name, `SELECT r.id, r.role_id, r.org_id, o.name AS org_name, ro.name AS role_name,
r.activity, r.view, r.modality, r.priority, r.scope r.activity, r.view, r.modality, r.priority, r.scope,
r.valid_from, r.valid_until,
(r.valid_from IS NULL OR r.valid_from <= now())
AND (r.valid_until IS NULL OR r.valid_until > now()) AS is_active
FROM morbac.rules r FROM morbac.rules r
JOIN morbac.orgs o ON o.id = r.org_id JOIN morbac.orgs o ON o.id = r.org_id
JOIN morbac.roles ro ON ro.id = r.role_id JOIN morbac.roles ro ON ro.id = r.role_id
@@ -57,8 +60,11 @@ export function createRoleRuleRoutes(db: Pool) {
priority: { type: 'integer' }, priority: { type: 'integer' },
scope: { scope: {
type: 'string', type: 'string',
enum: ['self', 'children', 'descendants', 'subtree', 'parent', 'ancestors', 'lineage', 'root'], enum: ['self', 'children', 'descendants', 'subtree', 'parent', 'ancestors', 'lineage', 'root', 'unattributed', 'all'],
default: 'self', default: 'self',
description:
'Which objects the rule reaches: a specific org via the tree scopes, '
+ 'unattributed for objects with no org, or all for every org including unattributed.',
}, },
}, },
}, },
@@ -79,6 +85,23 @@ export function createRoleRuleRoutes(db: Pool) {
if (!await hasPermission(db, req, reply, org_id, 'create', 'rules')) return reply; if (!await hasPermission(db, req, reply, org_id, 'create', 'rules')) return reply;
if (!await isActivityViewBound(db, activity, view)) {
return reply.code(400).send({ error: `Activity "${activity}" is not valid for view "${view}"` });
}
const { rows: [roleRow] } = await db.query<{ org_id: string }>(
'SELECT org_id FROM morbac.roles WHERE id = $1', [role_id],
);
if (!roleRow) return reply.code(404).send({ error: 'Not found' });
// Anti-escalation: only permission rules are constrained (prohibitions
// only ever reduce access). A caller may not grant, via any scope, a
// permission they do not themselves hold on every org that scope covers.
if (modality === 'permission'
&& !await canGrantRule(db, user.id, org_id, scope, activity, view)) {
return reply.code(403).send({ error: 'Cannot grant a permission you do not hold on the target orgs' });
}
try { try {
const { rows } = await db.query( const { rows } = await db.query(
`INSERT INTO morbac.rules `INSERT INTO morbac.rules
@@ -91,12 +114,20 @@ export function createRoleRuleRoutes(db: Pool) {
RETURNING id, $2::UUID AS role_id, $1::UUID AS org_id, RETURNING id, $2::UUID AS role_id, $1::UUID AS org_id,
(SELECT name FROM morbac.orgs WHERE id = $1) AS org_name, (SELECT name FROM morbac.orgs WHERE id = $1) AS org_name,
(SELECT name FROM morbac.roles WHERE id = $2) AS role_name, (SELECT name FROM morbac.roles WHERE id = $2) AS role_name,
activity, view, modality, priority, scope`, activity, view, modality, priority, scope,
valid_from, valid_until, is_active`,
[org_id, role_id, activity, view, modality, scope, priority ?? null], [org_id, role_id, activity, view, modality, scope, priority ?? null],
); );
// The morbac AFTER trigger only clears cache for the rule's own org.
// A non-self scope also affects other orgs (ancestors/descendants), so
// flush the whole cache to avoid stale grant/revocation decisions.
if (scope !== 'self') await db.query('SELECT morbac.invalidate_cache()');
return reply.code(201).send(rows[0]); return reply.code(201).send(rows[0]);
} catch (e: unknown) { } catch (e: unknown) {
if ((e as { code?: string }).code === '23505') return reply.code(409).send({ error: 'Rule already exists' }); const code = (e as { code?: string }).code;
if (code === '23505') return reply.code(409).send({ error: 'Rule already exists' });
if (code === '23503') return reply.code(400).send({ error: 'Unknown activity or view' });
if (code === 'P0001') return reply.code(400).send({ error: `Activity "${activity}" is not valid for view "${view}"` });
throw e; throw e;
} }
}); });
@@ -118,14 +149,17 @@ export function createRoleRuleRoutes(db: Pool) {
if (!user) return reply.code(401).send({ error: 'Authentication required' }); if (!user) return reply.code(401).send({ error: 'Authentication required' });
const { id } = req.params as { id: string }; const { id } = req.params as { id: string };
const { rows: [rule] } = await db.query<{ org_id: string }>( const { rows: [rule] } = await db.query<{ org_id: string; scope: string }>(
'SELECT org_id FROM morbac.rules WHERE id = $1', [id], 'SELECT org_id, scope FROM morbac.rules WHERE id = $1', [id],
); );
if (!rule) return reply.code(404).send({ error: 'Not found' }); if (!rule) return reply.code(404).send({ error: 'Not found' });
if (rule.org_id === SYSTEM_ORG_ID) return reply.code(403).send({ error: 'System entities are immutable' }); if (rule.org_id === SYSTEM_ORG_ID) return reply.code(403).send({ error: 'System entities are immutable' });
if (!await hasPermission(db, req, reply, rule.org_id, 'delete', 'rules')) return reply; if (!await hasPermission(db, req, reply, rule.org_id, 'delete', 'rules')) return reply;
await db.query('DELETE FROM morbac.rules WHERE id = $1', [id]); await db.query('DELETE FROM morbac.rules WHERE id = $1', [id]);
// See POST: a non-self scope revocation must flush cache for the
// affected orgs, which the morbac trigger does not cover.
if (rule.scope !== 'self') await db.query('SELECT morbac.invalidate_cache()');
return reply.code(204).send(); return reply.code(204).send();
}); });
}; };
+14 -5
View File
@@ -1,6 +1,6 @@
import type { FastifyInstance } from 'fastify'; import type { FastifyInstance } from 'fastify';
import type { Pool } from 'pg'; import type { Pool } from 'pg';
import { getUser, getOrgScope, orgScopeExpr, hasPermission } from '../permissions.js'; import { getUser, getOrgScope, orgScopeExpr, hasPermission, isActivityViewBound } from '../permissions.js';
import { userRuleResponse, notFound, forbidden, unauthorized, badRequest } from '../schemas.js'; import { userRuleResponse, notFound, forbidden, unauthorized, badRequest } from '../schemas.js';
export function createUserRuleRoutes(db: Pool) { export function createUserRuleRoutes(db: Pool) {
@@ -33,7 +33,7 @@ export function createUserRuleRoutes(db: Pool) {
o.name AS org_name, o.name AS org_name,
ur.activity, ur.view, ur.modality, ur.priority ur.activity, ur.view, ur.modality, ur.priority
FROM morbac.user_rules ur FROM morbac.user_rules ur
JOIN morbac.orgs o ON o.id = ur.org_id LEFT JOIN morbac.orgs o ON o.id = ur.org_id
LEFT JOIN app.users u ON u.id = ur.user_id LEFT JOIN app.users u ON u.id = ur.user_id
${expr ? `WHERE ${expr}` : ''} ${expr ? `WHERE ${expr}` : ''}
ORDER BY o.name, ur.activity, ur.view`, ORDER BY o.name, ur.activity, ur.view`,
@@ -53,7 +53,10 @@ export function createUserRuleRoutes(db: Pool) {
required: ['user_id', 'org_id', 'activity', 'view', 'modality'], required: ['user_id', 'org_id', 'activity', 'view', 'modality'],
properties: { properties: {
user_id: { type: 'string', format: 'uuid' }, user_id: { type: 'string', format: 'uuid' },
org_id: { type: 'string', format: 'uuid' }, org_id: {
type: 'string', format: 'uuid', nullable: true,
description: 'Organisation the rule targets, or null for unattributed objects (those with no org).',
},
activity: { type: 'string' }, activity: { type: 'string' },
view: { type: 'string' }, view: { type: 'string' },
modality: { type: 'string', enum: ['permission', 'prohibition'] }, modality: { type: 'string', enum: ['permission', 'prohibition'] },
@@ -71,12 +74,16 @@ export function createUserRuleRoutes(db: Pool) {
if (!user) return reply.code(401).send({ error: 'Authentication required' }); if (!user) return reply.code(401).send({ error: 'Authentication required' });
const { user_id, org_id, activity, view, modality, priority } = req.body as { const { user_id, org_id, activity, view, modality, priority } = req.body as {
user_id: string; org_id: string; activity: string; user_id: string; org_id: string | null; activity: string;
view: string; modality: 'permission' | 'prohibition'; priority?: number; view: string; modality: 'permission' | 'prohibition'; priority?: number;
}; };
if (!await hasPermission(db, req, reply, org_id, 'create', 'user_rules')) return reply; if (!await hasPermission(db, req, reply, org_id, 'create', 'user_rules')) return reply;
if (!await isActivityViewBound(db, activity, view)) {
return reply.code(400).send({ error: `Activity "${activity}" is not valid for view "${view}"` });
}
try { try {
const { rows } = await db.query( const { rows } = await db.query(
`INSERT INTO morbac.user_rules `INSERT INTO morbac.user_rules
@@ -95,7 +102,9 @@ export function createUserRuleRoutes(db: Pool) {
); );
return reply.code(201).send(rows[0]); return reply.code(201).send(rows[0]);
} catch (e: unknown) { } catch (e: unknown) {
if ((e as { code?: string }).code === '23505') return reply.code(409).send({ error: 'Rule already exists' }); const code = (e as { code?: string }).code;
if (code === '23505') return reply.code(409).send({ error: 'Rule already exists' });
if (code === '23503') return reply.code(400).send({ error: 'Unknown activity or view' });
throw e; throw e;
} }
}); });
+62 -18
View File
@@ -3,10 +3,12 @@ import type { Pool } from 'pg';
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import type { CheckLimitFn } from '../plugin.js'; import type { CheckLimitFn } from '../plugin.js';
import { import {
getUser, getOrgScope, orgScopeExpr, hasPermission, getAccountType, viewForType, SYSTEM_ORG_ID, getUser, getOrgScope, orgScopeExpr, hasPermission, canActOnUser, checkApiKeyScope,
getAccountType, viewForType, SYSTEM_ORG_ID,
} from '../permissions.js'; } from '../permissions.js';
import { userResponse, notFound, forbidden, unauthorized, badRequest } from '../schemas.js'; import { userResponse, notFound, forbidden, unauthorized, badRequest } from '../schemas.js';
import { generatePassword } from '../password.js'; import { generatePassword } from '../password.js';
import { callerControlsRoleOrg } from './role-authority.js';
export function createUserCrudRoutes(db: Pool, checkLimit?: CheckLimitFn) { export function createUserCrudRoutes(db: Pool, checkLimit?: CheckLimitFn) {
return async function userCrudRoutes(app: FastifyInstance) { return async function userCrudRoutes(app: FastifyInstance) {
@@ -31,7 +33,11 @@ export function createUserCrudRoutes(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, globalRes] = await Promise.all([ // A user awaiting placement belongs to no org, so seeing one is the
// no-org perimeter (a NULL org), not a claim about every org. Any
// grant that reaches it answers: a role rule scoped unattributed or
// all, a user rule with no org, or a global rule.
const [sysRes, svcRes, noOrgRes] = 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',
@@ -41,11 +47,11 @@ export function createUserCrudRoutes(db: Pool, checkLimit?: CheckLimitFn) {
]); ]);
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 canReadUnattached = noOrgRes.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 placement = canReadUnattached ? ' 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})${placement} OR u.account_type IN ('system', 'service'))` ? `WHERE (u.id IN (SELECT ur2.user_id FROM morbac.user_roles ur2 WHERE ${expr})${placement} OR u.account_type IN ('system', 'service'))`
: ''; : '';
@@ -80,7 +86,7 @@ export function createUserCrudRoutes(db: Pool, checkLimit?: CheckLimitFn) {
schema: { schema: {
tags: ['Management'], tags: ['Management'],
summary: 'Create a user', summary: 'Create a user',
description: 'Creates a user account. For `user` accounts a strong password is auto-generated and returned once as `generated_password`; the account must change it on first login. `service` accounts have no password (API-only). Requires `create`/`users` (or `create`/`service_users`) permission.', description: 'Creates a user account. For `user` accounts a strong password is auto-generated and returned once as `generated_password`; the account must change it on first login. `service` accounts have no password (API-only). Requires `create`/`users` (or `create`/`service_users`) permission. Pass `placement` to give the account a role in one go: an account created without one is unplaced, and only a holder of unattributed `read`/`users` can see it afterwards, so a manager who creates a user without placing it loses sight of it.',
security: [{ oauth2: [] }], security: [{ oauth2: [] }],
body: { body: {
type: 'object', type: 'object',
@@ -90,6 +96,15 @@ export function createUserCrudRoutes(db: Pool, checkLimit?: CheckLimitFn) {
name: { type: 'string', minLength: 1, maxLength: 255 }, name: { type: 'string', minLength: 1, maxLength: 255 },
account_type: { type: 'string', enum: ['user', 'service'], default: 'user' }, account_type: { type: 'string', enum: ['user', 'service'], default: 'user' },
is_active: { type: 'boolean', default: true }, is_active: { type: 'boolean', default: true },
placement: {
type: 'object',
required: ['role_id', 'org_id'],
additionalProperties: false,
properties: {
role_id: { type: 'string', format: 'uuid' },
org_id: { type: 'string', format: 'uuid' },
},
},
}, },
}, },
response: { response: {
@@ -106,14 +121,25 @@ export function createUserCrudRoutes(db: Pool, checkLimit?: CheckLimitFn) {
if (orgIds === null) return reply; if (orgIds === null) return reply;
const orgId = orgIds[0] ?? null; const orgId = orgIds[0] ?? null;
const { email: rawEmail, name, account_type = 'user', is_active = true } = req.body as { const { email: rawEmail, name, account_type = 'user', is_active = true, placement } = req.body as {
email: string; name: string; email: string; name: string;
account_type?: 'user' | 'service'; is_active?: boolean; account_type?: 'user' | 'service'; is_active?: boolean;
placement?: { role_id: string; org_id: string };
}; };
const email = rawEmail.trim().toLowerCase(); const email = rawEmail.trim().toLowerCase();
const view = viewForType(account_type); const view = viewForType(account_type);
if (!await hasPermission(db, req, reply, orgId, 'create', view)) return reply; if (!await hasPermission(db, req, reply, orgId, 'create', view)) return reply;
// Placing the account is a second act of authority, checked before
// anything is written so a refused placement creates no account.
if (placement) {
const assignView = account_type === 'service' ? 'service_users' : 'user_roles';
if (!await hasPermission(db, req, reply, placement.org_id, 'create', assignView)) return reply;
if (!await callerControlsRoleOrg(db, user.id, placement.role_id, 'create')) {
return reply.code(403).send({ error: 'Cannot assign a role outside your authority' });
}
}
if (checkLimit) { if (checkLimit) {
const limitName = account_type === 'service' ? 'max_service_users' : 'max_users'; const limitName = account_type === 'service' ? 'max_service_users' : 'max_users';
const ok = await checkLimit( const ok = await checkLimit(
@@ -129,17 +155,35 @@ export function createUserCrudRoutes(db: Pool, checkLimit?: CheckLimitFn) {
// accounts are API-only and have no password. // accounts are API-only and have no password.
const generated = account_type === 'user' ? generatePassword() : null; const generated = account_type === 'user' ? generatePassword() : null;
const hash = generated ? await bcrypt.hash(generated, 12) : null; const hash = generated ? await bcrypt.hash(generated, 12) : null;
const tx = await db.connect();
try { try {
const { rows } = await db.query( await tx.query('BEGIN');
const { rows } = await tx.query(
`INSERT INTO app.users (email, name, password_hash, account_type, is_active, must_change_password) `INSERT INTO app.users (email, name, password_hash, account_type, is_active, must_change_password)
VALUES ($1, $2, $3, $4, $5, $6) VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, email, name, account_type, is_active, needs_placement, created_at`, RETURNING id, email, name, account_type, is_active, needs_placement, created_at`,
[email, name, hash, account_type, is_active, generated !== null], [email, name, hash, account_type, is_active, generated !== null],
); );
return reply.code(201).send({ ...rows[0], roles: [], ...(generated && { generated_password: generated }) }); const created = rows[0] as { id: string; needs_placement: boolean };
const roles: { role_id: string; org_id: string }[] = [];
if (placement) {
await tx.query('SELECT morbac.assign_role($1::UUID, $2::UUID, $3::UUID)',
[created.id, placement.role_id, placement.org_id]);
await tx.query('UPDATE app.users SET needs_placement = FALSE WHERE id = $1', [created.id]);
created.needs_placement = false;
roles.push(placement);
}
await tx.query('COMMIT');
return reply.code(201).send({ ...created, roles, ...(generated && { generated_password: generated }) });
} catch (e: unknown) { } catch (e: unknown) {
await tx.query('ROLLBACK').catch(() => undefined);
if ((e as { code?: string }).code === '23505') return reply.code(409).send({ error: 'Email already in use' }); if ((e as { code?: string }).code === '23505') return reply.code(409).send({ error: 'Email already in use' });
if ((e as { message?: string }).message?.includes('Separation of Duty')) {
return reply.code(409).send({ error: 'Role assignment would violate Separation of Duty constraints' });
}
throw e; throw e;
} finally {
tx.release();
} }
}); });
@@ -168,15 +212,15 @@ export function createUserCrudRoutes(db: Pool, checkLimit?: CheckLimitFn) {
const user = getUser(req); const user = getUser(req);
if (!user) return reply.code(401).send({ error: 'Authentication required' }); if (!user) return reply.code(401).send({ error: 'Authentication required' });
const orgIds = getOrgScope(req, reply, false);
if (orgIds === null) return reply;
const orgId = orgIds[0] ?? null;
const { id } = req.params as { id: string }; const { id } = req.params as { id: string };
const type = await getAccountType(db, id); const type = await getAccountType(db, id);
if (!type) return reply.code(404).send({ error: 'Not found' }); if (!type) return reply.code(404).send({ error: 'Not found' });
if (type === 'system') return reply.code(403).send({ error: 'System entities are immutable' }); if (type === 'system') return reply.code(403).send({ error: 'System entities are immutable' });
if (!await hasPermission(db, req, reply, orgId, 'update', viewForType(type))) return reply; const view = viewForType(type);
if (!checkApiKeyScope(req, reply, 'update', view)) return reply;
if (!await canActOnUser(db, user.id, id, 'update', view)) {
return reply.code(404).send({ error: 'Not found' });
}
const { name, email, is_active, reset_password } = req.body as { const { name, email, is_active, reset_password } = req.body as {
name?: string; email?: string; is_active?: boolean; reset_password?: boolean; name?: string; email?: string; is_active?: boolean; reset_password?: boolean;
@@ -215,15 +259,15 @@ export function createUserCrudRoutes(db: Pool, checkLimit?: CheckLimitFn) {
const user = getUser(req); const user = getUser(req);
if (!user) return reply.code(401).send({ error: 'Authentication required' }); if (!user) return reply.code(401).send({ error: 'Authentication required' });
const orgIds = getOrgScope(req, reply, false);
if (orgIds === null) return reply;
const orgId = orgIds[0] ?? null;
const { id } = req.params as { id: string }; const { id } = req.params as { id: string };
const type = await getAccountType(db, id); const type = await getAccountType(db, id);
if (!type) return reply.code(404).send({ error: 'Not found' }); if (!type) return reply.code(404).send({ error: 'Not found' });
if (type === 'system') return reply.code(403).send({ error: 'System entities are immutable' }); if (type === 'system') return reply.code(403).send({ error: 'System entities are immutable' });
if (!await hasPermission(db, req, reply, orgId, 'delete', viewForType(type))) return reply; const view = viewForType(type);
if (!checkApiKeyScope(req, reply, 'delete', view)) return reply;
if (!await canActOnUser(db, user.id, id, 'delete', view)) {
return reply.code(404).send({ error: 'Not found' });
}
await db.query('DELETE FROM app.users WHERE id = $1', [id]); await db.query('DELETE FROM app.users WHERE id = $1', [id]);
return reply.code(204).send(); return reply.code(204).send();
+9 -13
View File
@@ -1,7 +1,7 @@
import { randomBytes, createHash } from 'node:crypto'; import { randomBytes, createHash } from 'node:crypto';
import type { FastifyInstance, FastifyReply } from 'fastify'; import type { FastifyInstance, FastifyReply } from 'fastify';
import type { Pool } from 'pg'; import type { Pool } from 'pg';
import { getUser, getOrgScope, hasPermission, getAccountType } from '../permissions.js'; import { getUser, canActOnUser, checkApiKeyScope, getAccountType } from '../permissions.js';
import { notFound, forbidden, unauthorized, badRequest } from '../schemas.js'; import { notFound, forbidden, unauthorized, badRequest } from '../schemas.js';
function generateKey(): { raw: string; hash: string; prefix: string } { function generateKey(): { raw: string; hash: string; prefix: string } {
@@ -78,11 +78,10 @@ export function createUserKeyRoutes(db: Pool) {
}, async (req, reply) => { }, async (req, reply) => {
const user = getUser(req); const user = getUser(req);
if (!user) return reply.code(401).send({ error: 'Authentication required' }); if (!user) return reply.code(401).send({ error: 'Authentication required' });
const orgIds = getOrgScope(req, reply, false);
if (orgIds === null) return reply;
const { id } = req.params as { id: string }; const { id } = req.params as { id: string };
if (!await ensureService(id, reply)) return reply; if (!await ensureService(id, reply)) return reply;
if (!await hasPermission(db, req, reply, orgIds[0] ?? null, 'read', 'service_users')) return reply; if (!checkApiKeyScope(req, reply, 'read', 'service_users')) return reply;
if (!await canActOnUser(db, user.id, id, 'read', 'service_users')) return reply.code(404).send({ error: 'Not found' });
const { rows } = await db.query( const { rows } = await db.query(
`SELECT id, name, description, key_prefix, readonly, permission_scope, expires_at, last_used_at, created_at `SELECT id, name, description, key_prefix, readonly, permission_scope, expires_at, last_used_at, created_at
@@ -107,11 +106,10 @@ export function createUserKeyRoutes(db: Pool) {
}, async (req, reply) => { }, async (req, reply) => {
const user = getUser(req); const user = getUser(req);
if (!user) return reply.code(401).send({ error: 'Authentication required' }); if (!user) return reply.code(401).send({ error: 'Authentication required' });
const orgIds = getOrgScope(req, reply, false);
if (orgIds === null) return reply;
const { id } = req.params as { id: string }; const { id } = req.params as { id: string };
if (!await ensureService(id, reply)) return reply; if (!await ensureService(id, reply)) return reply;
if (!await hasPermission(db, req, reply, orgIds[0] ?? null, 'read', 'service_users')) return reply; if (!checkApiKeyScope(req, reply, 'read', 'service_users')) return reply;
if (!await canActOnUser(db, user.id, id, 'read', 'service_users')) return reply.code(404).send({ error: 'Not found' });
return heldPerms(db, id); return heldPerms(db, id);
}); });
@@ -142,11 +140,10 @@ export function createUserKeyRoutes(db: Pool) {
}, async (req, reply) => { }, async (req, reply) => {
const user = getUser(req); const user = getUser(req);
if (!user) return reply.code(401).send({ error: 'Authentication required' }); if (!user) return reply.code(401).send({ error: 'Authentication required' });
const orgIds = getOrgScope(req, reply, false);
if (orgIds === null) return reply;
const { id } = req.params as { id: string }; const { id } = req.params as { id: string };
if (!await ensureService(id, reply)) return reply; if (!await ensureService(id, reply)) return reply;
if (!await hasPermission(db, req, reply, orgIds[0] ?? null, 'create', 'service_users')) return reply; if (!checkApiKeyScope(req, reply, 'create', 'service_users')) return reply;
if (!await canActOnUser(db, user.id, id, 'create', 'service_users')) return reply.code(404).send({ error: 'Not found' });
const { name, description, readonly = false, expires_at, permission_scope } = req.body as { const { name, description, readonly = false, expires_at, permission_scope } = req.body as {
name: string; description?: string; readonly?: boolean; expires_at?: string | null; permission_scope?: Scope; name: string; description?: string; readonly?: boolean; expires_at?: string | null; permission_scope?: Scope;
@@ -201,11 +198,10 @@ export function createUserKeyRoutes(db: Pool) {
}, async (req, reply) => { }, async (req, reply) => {
const user = getUser(req); const user = getUser(req);
if (!user) return reply.code(401).send({ error: 'Authentication required' }); if (!user) return reply.code(401).send({ error: 'Authentication required' });
const orgIds = getOrgScope(req, reply, false);
if (orgIds === null) return reply;
const { id, keyId } = req.params as { id: string; keyId: string }; const { id, keyId } = req.params as { id: string; keyId: string };
if (!await ensureService(id, reply)) return reply; if (!await ensureService(id, reply)) return reply;
if (!await hasPermission(db, req, reply, orgIds[0] ?? null, 'delete', 'service_users')) return reply; if (!checkApiKeyScope(req, reply, 'delete', 'service_users')) return reply;
if (!await canActOnUser(db, user.id, id, 'delete', 'service_users')) return reply.code(404).send({ error: 'Not found' });
await db.query('DELETE FROM app.api_keys WHERE id = $1 AND user_id = $2', [keyId, id]); await db.query('DELETE FROM app.api_keys WHERE id = $1 AND user_id = $2', [keyId, id]);
return reply.code(204).send(); return reply.code(204).send();
+132
View File
@@ -0,0 +1,132 @@
import type { FastifyInstance, FastifyReply } from 'fastify';
import type { Pool } from 'pg';
import { getUser, canActOnUser, checkApiKeyScope, getAccountType, viewForType } from '../permissions.js';
import { notFound, forbidden, unauthorized, badRequest } from '../schemas.js';
const MAX_ORG_FILTER = 100;
const orgFilterArray = {
type: 'array',
maxItems: MAX_ORG_FILTER,
items: { type: 'string', format: 'uuid' },
} as const;
const orgFilterResponse = {
type: 'object',
properties: { org_filter: orgFilterArray },
} as const;
/** Subset of `ids` that `userId` is allowed to read as orgs. Used both to filter
* what a caller may see and to validate what a caller may write, so neither
* direction can be used to probe orgs outside the caller's reach. */
async function visibleOrgIds(db: Pool, userId: string, ids: string[]): Promise<string[]> {
if (ids.length === 0) return [];
const { rows } = await db.query<{ id: string }>(
`SELECT o.id FROM morbac.orgs o
WHERE o.id = ANY($2::UUID[]) AND morbac.is_allowed($1::UUID, o.id, 'read', 'orgs')`,
[userId, ids],
);
return rows.map((r) => r.id);
}
async function storedOrgFilter(db: Pool, userId: string): Promise<string[]> {
const { rows } = await db.query<{ org_filter: unknown }>(
`SELECT ui_preferences->'orgFilter' AS org_filter FROM app.users WHERE id = $1`,
[userId],
);
const value = rows[0]?.org_filter;
if (!Array.isArray(value)) return [];
return value.filter((id): id is string => typeof id === 'string');
}
/** The org filter is a display preference, never an authorization input: every
* route still authorizes the org ids it receives on the wire. */
export function createUserOrgFilterRoutes(db: Pool) {
return async function userOrgFilterRoutes(app: FastifyInstance) {
async function resolveView(id: string, reply: FastifyReply): Promise<string | null> {
const type = await getAccountType(db, id);
if (type === null || type === 'system') {
await reply.code(404).send({ error: 'Not found' });
return null;
}
return viewForType(type);
}
app.get('/users/:id/org-filter', {
schema: {
tags: ['Management'],
summary: 'Read a user organization filter',
description: 'Organization filter stored in the user preferences, restricted to the orgs the caller can read. Requires `read`/`users` (or `service_users`).',
security: [{ oauth2: [] }],
params: { type: 'object', properties: { id: { type: 'string', format: 'uuid' } } },
response: { 200: orgFilterResponse, 401: unauthorized, 403: forbidden, 404: notFound },
},
}, async (req, reply) => {
const user = getUser(req);
if (!user) return reply.code(401).send({ error: 'Authentication required' });
const { id } = req.params as { id: string };
const view = await resolveView(id, reply);
if (!view) return reply;
if (!checkApiKeyScope(req, reply, 'read', view)) return reply;
if (!await canActOnUser(db, user.id, id, 'read', view)) return reply.code(404).send({ error: 'Not found' });
const stored = await storedOrgFilter(db, id);
return { org_filter: await visibleOrgIds(db, user.id, stored) };
});
app.patch('/users/:id/org-filter', {
schema: {
tags: ['Management'],
summary: 'Replace a user organization filter',
description: 'Replaces the slice of the organization filter the caller can see. Ids outside the caller reach are preserved untouched. Requires `update`/`users` (or `service_users`).',
security: [{ oauth2: [] }],
params: { type: 'object', properties: { id: { type: 'string', format: 'uuid' } } },
body: {
type: 'object',
required: ['org_filter'],
additionalProperties: false,
properties: { org_filter: orgFilterArray },
},
response: { 200: orgFilterResponse, 400: badRequest, 401: unauthorized, 403: forbidden, 404: notFound },
},
}, async (req, reply) => {
const user = getUser(req);
if (!user) return reply.code(401).send({ error: 'Authentication required' });
const { id } = req.params as { id: string };
const type = await getAccountType(db, id);
if (type === null) return reply.code(404).send({ error: 'Not found' });
if (type === 'system') return reply.code(403).send({ error: 'System entities are immutable' });
const view = viewForType(type);
if (!checkApiKeyScope(req, reply, 'update', view)) return reply;
if (!await canActOnUser(db, user.id, id, 'update', view)) return reply.code(404).send({ error: 'Not found' });
const submitted = [...new Set((req.body as { org_filter: string[] }).org_filter)];
// Only the caller's own reach is validated. The target's is deliberately
// not: the filter is intersected with what the target can see at render
// time, so an id they cannot see shows nothing and grants nothing, and
// forbidding it would forbid preserving the very ids below.
const visibleToCaller = await visibleOrgIds(db, user.id, submitted);
if (visibleToCaller.length !== submitted.length) {
return reply.code(400).send({ error: 'Invalid organization' });
}
const stored = await storedOrgFilter(db, id);
const callerVisibleStored = new Set(await visibleOrgIds(db, user.id, stored));
const preserved = stored.filter((orgId) => !callerVisibleStored.has(orgId));
const next = [...new Set([...submitted, ...preserved])];
await db.query(
`UPDATE app.users
SET ui_preferences = COALESCE(ui_preferences, '{}'::jsonb) || jsonb_build_object('orgFilter', $1::jsonb)
WHERE id = $2`,
[JSON.stringify(next), id],
);
return { org_filter: submitted };
});
};
}
+8
View File
@@ -1,7 +1,9 @@
import type { FastifyInstance } from 'fastify'; import type { FastifyInstance } from 'fastify';
import type { Pool } from 'pg'; import type { Pool } from 'pg';
import { getUser, getOrgScope, hasPermission, getAccountType } from '../permissions.js'; import { getUser, getOrgScope, hasPermission, getAccountType } from '../permissions.js';
import { notFound, forbidden, unauthorized, badRequest } from '../schemas.js'; import { notFound, forbidden, unauthorized, badRequest } from '../schemas.js';
import { callerControlsRoleOrg } from './role-authority.js';
export function createUserRoleRoutes(db: Pool) { export function createUserRoleRoutes(db: Pool) {
return async function userRoleRoutes(app: FastifyInstance) { return async function userRoleRoutes(app: FastifyInstance) {
@@ -44,6 +46,9 @@ export function createUserRoleRoutes(db: Pool) {
if (assignType === 'system') return reply.code(403).send({ error: 'System account roles are immutable' }); if (assignType === 'system') return reply.code(403).send({ error: 'System account roles are immutable' });
const assignView = assignType === 'service' ? 'service_users' : 'user_roles'; const assignView = assignType === 'service' ? 'service_users' : 'user_roles';
if (!await hasPermission(db, req, reply, org_id, 'create', assignView)) return reply; if (!await hasPermission(db, req, reply, org_id, 'create', assignView)) return reply;
if (!await callerControlsRoleOrg(db, caller.id, role_id, 'create')) {
return reply.code(403).send({ error: 'Cannot assign a role outside your authority' });
}
try { try {
await db.query( await db.query(
@@ -172,6 +177,9 @@ export function createUserRoleRoutes(db: Pool) {
if (revokeType === 'system') return reply.code(403).send({ error: 'System account roles are immutable' }); if (revokeType === 'system') return reply.code(403).send({ error: 'System account roles are immutable' });
const revokeView = revokeType === 'service' ? 'service_users' : 'user_roles'; const revokeView = revokeType === 'service' ? 'service_users' : 'user_roles';
if (!await hasPermission(db, req, reply, org_id, 'delete', revokeView)) return reply; if (!await hasPermission(db, req, reply, org_id, 'delete', revokeView)) return reply;
if (!await callerControlsRoleOrg(db, caller.id, role_id, 'delete')) {
return reply.code(403).send({ error: 'Cannot revoke a role outside your authority' });
}
await db.query( await db.query(
'SELECT morbac.revoke_role($1::UUID, $2::UUID, $3::UUID)', 'SELECT morbac.revoke_role($1::UUID, $2::UUID, $3::UUID)',
+2
View File
@@ -4,11 +4,13 @@ import type { CheckLimitFn } from '../plugin.js';
import { createUserCrudRoutes } from './user-crud.js'; import { createUserCrudRoutes } from './user-crud.js';
import { createUserRoleRoutes } from './user-roles.js'; import { createUserRoleRoutes } from './user-roles.js';
import { createUserKeyRoutes } from './user-keys.js'; import { createUserKeyRoutes } from './user-keys.js';
import { createUserOrgFilterRoutes } from './user-org-filter.js';
export function createUserRoutes(db: Pool, checkLimit?: CheckLimitFn) { export function createUserRoutes(db: Pool, checkLimit?: CheckLimitFn) {
return async function userRoutes(app: FastifyInstance) { return async function userRoutes(app: FastifyInstance) {
await app.register(createUserCrudRoutes(db, checkLimit)); await app.register(createUserCrudRoutes(db, checkLimit));
await app.register(createUserRoleRoutes(db)); await app.register(createUserRoleRoutes(db));
await app.register(createUserKeyRoutes(db)); await app.register(createUserKeyRoutes(db));
await app.register(createUserOrgFilterRoutes(db));
}; };
} }
+10 -2
View File
@@ -5,6 +5,7 @@ export const orgResponse = {
name: { type: 'string' }, name: { type: 'string' },
parent_id: { type: 'string', format: 'uuid', nullable: true }, parent_id: { type: 'string', format: 'uuid', nullable: true },
metadata: { type: 'object' }, metadata: { type: 'object' },
path: { type: 'array', items: { type: 'string' } },
}, },
}; };
@@ -16,6 +17,9 @@ export const roleResponse = {
org_name: { type: 'string' }, org_name: { type: 'string' },
name: { type: 'string' }, name: { type: 'string' },
description: { type: 'string', nullable: true }, description: { type: 'string', nullable: true },
profile_module_id: { type: 'string', nullable: true },
profile_key: { type: 'string', nullable: true },
profile_synced: { type: 'boolean', nullable: true },
}, },
}; };
@@ -32,6 +36,9 @@ export const ruleResponse = {
modality: { type: 'string', enum: ['permission', 'prohibition'] }, modality: { type: 'string', enum: ['permission', 'prohibition'] },
priority: { type: 'integer', nullable: true }, priority: { type: 'integer', nullable: true },
scope: { type: 'string' }, scope: { type: 'string' },
valid_from: { type: 'string', format: 'date-time', nullable: true },
valid_until: { type: 'string', format: 'date-time', nullable: true },
is_active: { type: 'boolean' },
}, },
}; };
@@ -82,10 +89,11 @@ export const userRuleResponse = {
properties: { properties: {
id: { type: 'string', format: 'uuid' }, id: { type: 'string', format: 'uuid' },
user_id: { type: 'string', format: 'uuid' }, user_id: { type: 'string', format: 'uuid' },
org_id: { type: 'string', format: 'uuid' }, // null org = unattributed (the rule targets objects with no org)
org_id: { type: 'string', format: 'uuid', nullable: true },
user_name: { type: 'string' }, user_name: { type: 'string' },
user_email: { type: 'string', nullable: true }, user_email: { type: 'string', nullable: true },
org_name: { type: 'string' }, org_name: { type: 'string', nullable: true },
activity: { type: 'string' }, activity: { type: 'string' },
view: { type: 'string' }, view: { type: 'string' },
modality: { type: 'string', enum: ['permission', 'prohibition'] }, modality: { type: 'string', enum: ['permission', 'prohibition'] },