Compare commits
8
Commits
a1d6f471e2
...
v1.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f7db7e99e | ||
|
|
12964793cf | ||
|
|
bc05d312a6 | ||
|
|
ee15ee0adf | ||
|
|
9b7003a691 | ||
|
|
10fb0858f6 | ||
|
|
73d3bc36c8 | ||
|
|
94f1f13302 |
-56
@@ -1,56 +0,0 @@
|
||||
# Per-repo Drone pipeline template for an extracted @crudy/* package or module (RFC 0006).
|
||||
# Copy this to .drone.yml in the standalone repo. It consumes dependencies from Verdaccio and
|
||||
# publishes the package on a tag. Set the `verdaccio_url` and `npm_token` Drone secrets.
|
||||
---
|
||||
kind: pipeline
|
||||
type: docker
|
||||
name: default
|
||||
|
||||
trigger:
|
||||
event:
|
||||
- push
|
||||
- pull_request
|
||||
- tag
|
||||
|
||||
steps:
|
||||
- name: install
|
||||
image: node:24
|
||||
environment:
|
||||
VERDACCIO:
|
||||
from_secret: verdaccio_url
|
||||
commands:
|
||||
- npm ci --registry "$VERDACCIO"
|
||||
|
||||
- name: lint
|
||||
image: node:24
|
||||
commands:
|
||||
- npm run --if-present lint -- --max-warnings=0
|
||||
|
||||
- name: typecheck
|
||||
image: node:24
|
||||
commands:
|
||||
- npx tsc --noEmit
|
||||
|
||||
- name: test
|
||||
image: node:24
|
||||
commands:
|
||||
- npm test --if-present
|
||||
|
||||
- name: build
|
||||
image: node:24
|
||||
commands:
|
||||
- npm run --if-present build
|
||||
|
||||
- name: publish
|
||||
image: node:24
|
||||
environment:
|
||||
VERDACCIO:
|
||||
from_secret: verdaccio_url
|
||||
NPM_TOKEN:
|
||||
from_secret: npm_token
|
||||
commands:
|
||||
- echo "//${VERDACCIO#*://}:_authToken=$NPM_TOKEN" > .npmrc
|
||||
- npm publish --registry "$VERDACCIO"
|
||||
when:
|
||||
event:
|
||||
- tag
|
||||
@@ -0,0 +1,53 @@
|
||||
# Stages @crudy/pgmorbac-fastify to npmjs on version tags (v<major>.<minor>.<patch>)
|
||||
# through npm trusted publishing: GitHub's OIDC token stands in for an npm
|
||||
# token, and npm attaches a provenance statement naming this repository and
|
||||
# commit. The trusted publisher may only stage: a staged version goes live once
|
||||
# a maintainer approves it with 2FA (npm stage approve, or npmjs.com). The
|
||||
# organisation only allows its own actions, so checkout and Node setup are
|
||||
# plain shell. prepack builds dist from src.
|
||||
name: publish
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
NODE_VERSION: v24.12.0
|
||||
steps:
|
||||
- name: Check out the tag
|
||||
run: |
|
||||
case "$GITHUB_REF_NAME" in v[0-9]*.[0-9]*.[0-9]*) ;; *) echo "not a version tag: $GITHUB_REF_NAME" >&2; exit 1 ;; esac
|
||||
git init -q .
|
||||
git fetch -q --depth 1 "https://github.com/$GITHUB_REPOSITORY" "refs/tags/$GITHUB_REF_NAME:refs/tags/$GITHUB_REF_NAME"
|
||||
git checkout -q "refs/tags/$GITHUB_REF_NAME"
|
||||
- name: Install Node
|
||||
run: |
|
||||
base="https://nodejs.org/dist/$NODE_VERSION"
|
||||
file="node-$NODE_VERSION-linux-x64.tar.xz"
|
||||
curl -fsSLO "$base/$file"
|
||||
curl -fsSL "$base/SHASUMS256.txt" | grep " $file\$" | sha256sum -c -
|
||||
mkdir -p "$RUNNER_TEMP/node"
|
||||
tar -xJf "$file" -C "$RUNNER_TEMP/node" --strip-components=1
|
||||
rm "$file"
|
||||
echo "$RUNNER_TEMP/node/bin" >> "$GITHUB_PATH"
|
||||
- name: Install npm with staged publishing
|
||||
run: npm install -g npm@11.21.1
|
||||
- run: npm install --no-audit --no-fund
|
||||
- run: npm run check
|
||||
- name: Publish
|
||||
run: |
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
[ "v$VERSION" = "$GITHUB_REF_NAME" ] || { echo "tag $GITHUB_REF_NAME does not match package version $VERSION" >&2; exit 1; }
|
||||
NAME=$(node -p "require('./package.json').name")
|
||||
if npm view "$NAME@$VERSION" version >/dev/null 2>&1; then
|
||||
echo "$NAME@$VERSION is already published"
|
||||
else
|
||||
npm stage publish
|
||||
fi
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
package-lock.json
|
||||
+28
-1
@@ -5,6 +5,33 @@ 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.3.0] - 2026-11-02
|
||||
|
||||
### Added
|
||||
- `listScope(db, req, scope, activity, view, column)`: the filter a list applies, over the orgs under the requested ones (every org when none is named), each kept only where the caller holds the permission once the hooks have run.
|
||||
|
||||
### Fixed
|
||||
- The org, user, role, role hierarchy, rule, user rule, cross-org rule and delegation lists answered for the whole subtree of each permitted org, and a request naming the system org skipped the org filter entirely. Every org they answer for is now decided by the permission system with the hooks applied, so an org the hooks refuse never shows in a list, pinned or not, and the system org is an org like any other.
|
||||
|
||||
## [1.2.0] - 2026-11-02
|
||||
|
||||
### Added
|
||||
- `permittedScope(db, req, reply, orgIds, activity, view)`: the orgs of a list request the caller may act in, through the hooks; 403 only when none passes.
|
||||
|
||||
### Fixed
|
||||
- The list routes for users, roles, role settings, role, user and cross-org rules, delegations, permission profiles, user roles and activities checked only the first org of `X-Org-Ids`, then answered for every listed org. A caller allowed in the first org read the others' rows, and a caller refused in the first org got 403 for the whole list. They now answer for the permitted orgs only.
|
||||
- The unpinned org list, the stored org filter and `readableOrgPaths` decided with `morbac.is_allowed` in SQL, which skips the permission hooks. They now go through them, so an org the hooks refuse is never offered.
|
||||
|
||||
## [1.1.0] - 2026-10-21
|
||||
|
||||
### Added
|
||||
- `addPermissionHook('pre' | 'post', hook)`: application rules every permission decision runs. Hooks can only refuse; a throwing hook fails the decision.
|
||||
- `isAllowed` and `permittedOrgs`: the hooked decision without a reply, for one org or several.
|
||||
|
||||
### Changed
|
||||
- `hasPermission`, `hasPermissionAny`, `canPerform`, `canActOnUser`, `canGrantRule`, the user listing and role assignment decide through `isAllowed`, so hooks apply to each.
|
||||
- The data proxy forwards only the `X-Org-Ids` the caller is allowed in, instead of every listed org once one passed. Rows are unchanged without hooks: RLS already hid the others.
|
||||
|
||||
## [1.0.0] - 2026-07-25
|
||||
|
||||
### Added
|
||||
@@ -22,4 +49,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- `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
|
||||
[1.0.0]: https://github.com/crudylabs/pgmorbac-fastify/releases/tag/v1.0.0
|
||||
|
||||
@@ -1,201 +1,21 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
MIT License
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
Copyright (c) 2025-2026 Marc Villain
|
||||
|
||||
1. Definitions.
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
"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.
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
@crudy/pgmorbac
|
||||
Copyright 2025-2026 Marc Villain
|
||||
|
||||
This product includes software developed as part of the Crudy project.
|
||||
@@ -1,18 +1,18 @@
|
||||
# @crudy/pgmorbac
|
||||
# @crudy/pgmorbac-fastify
|
||||
|
||||
**Multi-OrBAC permission helpers for Fastify and PostgreSQL.**
|
||||
|
||||
[](https://www.apache.org/licenses/LICENSE-2.0)
|
||||
[](LICENSE)
|
||||
[](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.
|
||||
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 for the extension SQL from [`@crudy/pgmorbac`](https://www.npmjs.com/package/@crudy/pgmorbac).
|
||||
|
||||
> The SQL extension is a separate project under the MIT license. This helper package is Apache-2.0. See [LICENSE](LICENSE) and [NOTICE](NOTICE).
|
||||
> MIT, like the pgmorbac SQL extension it builds on. See [LICENSE](LICENSE).
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npm install @crudy/pgmorbac
|
||||
npm install @crudy/pgmorbac-fastify
|
||||
```
|
||||
|
||||
Peer dependencies (both optional, install the ones you use): `fastify >=5`, `pg >=8`.
|
||||
@@ -23,14 +23,14 @@ 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`, ...) |
|
||||
| `@crudy/pgmorbac-fastify` | RLS session context and shared domain types |
|
||||
| `@crudy/pgmorbac-fastify/permissions` | `hasPermission`, `isAllowed`, `permittedOrgs`, permission hooks, org-scope resolution, admin checks |
|
||||
| `@crudy/pgmorbac-fastify/proxy` | PostgREST data proxy with per-route permissions |
|
||||
| `@crudy/pgmorbac-fastify/plugin` | Fastify management routes (orgs, roles, rules, users, delegations) |
|
||||
| `@crudy/pgmorbac-fastify/list-query` | PostgREST-style sort/filter/pagination parsing |
|
||||
| `@crudy/pgmorbac-fastify/schemas` | JSON schema response fragments |
|
||||
| `@crudy/pgmorbac-fastify/migrations` | Loads the bundled `morbac` extension SQL as migrations |
|
||||
| `@crudy/pgmorbac-fastify/api` | Domain type definitions (`Org`, `Role`, `Rule`, ...) |
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -39,7 +39,7 @@ The package is split into focused entry points so you only pull in what you need
|
||||
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';
|
||||
import { hasPermission } from '@crudy/pgmorbac-fastify/permissions';
|
||||
|
||||
app.get('/documents', async (req, reply) => {
|
||||
const orgId = req.headers['x-org-id'] as string;
|
||||
@@ -48,12 +48,27 @@ app.get('/documents', async (req, reply) => {
|
||||
});
|
||||
```
|
||||
|
||||
For a decision without a reply, use `isAllowed(pool, userId, orgId, activity, view)`; for several orgs at once, `permittedOrgs`. Never query `morbac.is_allowed` directly: it skips the hooks below.
|
||||
|
||||
### Permission hooks
|
||||
|
||||
An application adds its own rules to every decision with `addPermissionHook`. A `pre` hook runs before morbac is asked and a `post` hook receives morbac's answer. Either can only refuse: a decision is allowed when morbac allows it and every hook returns `true`. A hook that throws fails the decision with its error.
|
||||
|
||||
```ts
|
||||
import { addPermissionHook } from '@crudy/pgmorbac-fastify/permissions';
|
||||
|
||||
// No action in an org whose subscription lapsed.
|
||||
addPermissionHook('pre', async ({ orgId }) => orgId === null || await subscriptionActive(orgId));
|
||||
```
|
||||
|
||||
The hooks run in `hasPermission`, `hasPermissionAny`, `canPerform`, `canActOnUser`, `canGrantRule`, `isAllowed`, `permittedOrgs` and the data proxy, which forwards only the `X-Org-Ids` the caller is allowed in. `hasAnyPermission` names no org and runs none. Row-level security evaluates morbac alone, so a rule that must also hide rows belongs in the database.
|
||||
|
||||
### 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';
|
||||
import { withMorbacContext } from '@crudy/pgmorbac-fastify';
|
||||
|
||||
const rows = await withMorbacContext(pool, { userId, orgId }, async (client) => {
|
||||
const { rows } = await client.query('SELECT * FROM app.orgs');
|
||||
@@ -66,7 +81,7 @@ const rows = await withMorbacContext(pool, { userId, orgId }, async (client) =>
|
||||
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';
|
||||
import { createProxyRegistry } from '@crudy/pgmorbac-fastify/proxy';
|
||||
|
||||
const proxy = createProxyRegistry(pool, process.env.POSTGREST_URL!);
|
||||
proxy.proxyDataRoute(app, 'GET', '/data/documents', 'documents', { activity: 'read', view: 'documents' });
|
||||
@@ -76,10 +91,10 @@ Pass `null` as the permission for any-authenticated-user access with no org chec
|
||||
|
||||
### 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.
|
||||
`morbacMigrations()` returns the extension SQL from `@crudy/pgmorbac` (install script plus upgrade scripts) ready for your migration runner. Feed it before your own DDL.
|
||||
|
||||
```ts
|
||||
import { morbacMigrations } from '@crudy/pgmorbac/migrations';
|
||||
import { morbacMigrations } from '@crudy/pgmorbac-fastify/migrations';
|
||||
|
||||
for (const { filename, sql } of morbacMigrations()) {
|
||||
await runMigration(filename, sql);
|
||||
@@ -93,7 +108,7 @@ The base script keeps the historical filename `0001_morbac.sql` so `schema_migra
|
||||
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';
|
||||
import { createMgmtRoutes } from '@crudy/pgmorbac-fastify/plugin';
|
||||
|
||||
createMgmtRoutes({
|
||||
db: pool,
|
||||
@@ -107,6 +122,7 @@ createMgmtRoutes({
|
||||
- **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.
|
||||
- **Hooks only narrow.** An application rule added with `addPermissionHook` can refuse a decision morbac allowed, never allow one it refused.
|
||||
- **Allowlisted queries.** `list-query` parameterizes values and allowlists sortable/filterable columns; user input is never interpolated.
|
||||
|
||||
## Related
|
||||
@@ -116,4 +132,4 @@ createMgmtRoutes({
|
||||
|
||||
## License
|
||||
|
||||
Apache-2.0 (c) 2025-2026 Marc Villain. See [LICENSE](LICENSE) and [NOTICE](NOTICE).
|
||||
MIT (c) 2025-2026 Marc Villain. See [LICENSE](LICENSE).
|
||||
|
||||
+19
-28
@@ -1,72 +1,65 @@
|
||||
{
|
||||
"name": "@crudy/pgmorbac",
|
||||
"version": "1.0.0",
|
||||
"name": "@crudy/pgmorbac-fastify",
|
||||
"version": "1.3.0",
|
||||
"type": "module",
|
||||
"description": "Crudy pgmorbac - Multi-OrBAC permission helpers for Fastify and PostgreSQL",
|
||||
"description": "Fastify layer over pgmorbac: permission checks, RLS session context, management routes, PostgREST data proxy, migration loader",
|
||||
"keywords": [
|
||||
"morbac",
|
||||
"orbac",
|
||||
"rbac",
|
||||
"authorization",
|
||||
"access-control",
|
||||
"postgresql",
|
||||
"authorization",
|
||||
"fastify",
|
||||
"rls",
|
||||
"multi-tenant"
|
||||
"morbac",
|
||||
"multi-tenant",
|
||||
"orbac",
|
||||
"pgmorbac",
|
||||
"postgresql",
|
||||
"rbac",
|
||||
"rls"
|
||||
],
|
||||
"author": "Marc Villain",
|
||||
"homepage": "https://pgmorbac.crudy.fr",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/crudylabs/pgmorbac-node.git"
|
||||
"url": "git+https://github.com/crudylabs/pgmorbac-fastify.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/crudylabs/pgmorbac-node/issues"
|
||||
"url": "https://github.com/crudylabs/pgmorbac-fastify/issues"
|
||||
},
|
||||
"license": "MIT",
|
||||
"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"
|
||||
},
|
||||
"./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"
|
||||
}
|
||||
},
|
||||
@@ -74,24 +67,23 @@
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": [
|
||||
"dist",
|
||||
"sql",
|
||||
"package.json",
|
||||
"src",
|
||||
"README.md",
|
||||
"CHANGELOG.md",
|
||||
"LICENSE",
|
||||
"NOTICE"
|
||||
"LICENSE"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"prepack": "npm run build",
|
||||
"check": "tsc --noEmit"
|
||||
"check": "tsc --noEmit",
|
||||
"test": "tsc && node --test 'test/*.test.mjs'"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "http://localhost:4873",
|
||||
"access": "public"
|
||||
},
|
||||
"dependencies": {
|
||||
"@crudy/pgmorbac": "^1.0.0",
|
||||
"bcryptjs": "^2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -112,6 +104,5 @@
|
||||
"fastify": "^5",
|
||||
"pg": "^8",
|
||||
"typescript": "^5"
|
||||
},
|
||||
"license": "Apache-2.0"
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+6
-3
@@ -243,7 +243,10 @@ export interface MorbacApi {
|
||||
revokeDelegation(token: string, orgIds: string[], id: string): Promise<null>;
|
||||
}
|
||||
|
||||
export function createMorbacApi(apiUrl: string): MorbacApi {
|
||||
// apiUrl may be a function so a browser caller resolves the origin per request
|
||||
// rather than when its module loads.
|
||||
export function createMorbacApi(apiUrl: string | (() => string)): MorbacApi {
|
||||
const base = typeof apiUrl === 'function' ? apiUrl : () => apiUrl;
|
||||
async function api(method: string, path: string, token: string, orgIds: string[], body?: unknown): Promise<unknown> {
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${token}`,
|
||||
@@ -254,7 +257,7 @@ export function createMorbacApi(apiUrl: string): MorbacApi {
|
||||
headers['X-Org-Ids'] = orgIds.join(',');
|
||||
}
|
||||
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
||||
const res = await fetch(`${apiUrl}${path}`, {
|
||||
const res = await fetch(`${base()}${path}`, {
|
||||
method,
|
||||
headers,
|
||||
credentials: 'include',
|
||||
@@ -279,7 +282,7 @@ export function createMorbacApi(apiUrl: string): MorbacApi {
|
||||
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(`${base()}/me/permissions/check`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
|
||||
@@ -6,9 +6,14 @@ export {
|
||||
orgSelectionToSetting,
|
||||
orgTargetsToHeader,
|
||||
hasAnyPermission,
|
||||
isAllowed,
|
||||
permittedOrgs,
|
||||
permittedScope,
|
||||
listScope,
|
||||
type OrgTarget,
|
||||
type OrgSelection,
|
||||
} from './permissions.js';
|
||||
export { addPermissionHook, type PermissionCheck, type PrePermissionHook, type PostPermissionHook } from './permission-hooks.js';
|
||||
export type { DataPermission } from './proxy.js';
|
||||
export type {
|
||||
Org,
|
||||
|
||||
+20
-32
@@ -1,40 +1,28 @@
|
||||
import { readFileSync, readdirSync, existsSync } from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
|
||||
const sqlDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'sql');
|
||||
import { extensionSql, upgradeScripts, version } from '@crudy/pgmorbac';
|
||||
|
||||
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.
|
||||
// The pgmorbac install and upgrade scripts (from @crudy/pgmorbac) as plain
|
||||
// migrations for a migration runner. Without CREATE EXTENSION the morbac schema is
|
||||
// created up front, and the installed version is recorded. The base keeps the
|
||||
// historical filename 0001_morbac.sql so schema_migrations rows on existing
|
||||
// databases stay valid; its bytes must not change for an unchanged extension
|
||||
// version, or boot re-runs it. Upgrades follow 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;
|
||||
const lines = extensionSql().split('\n');
|
||||
lines.splice(lines.indexOf('') + 1, 0, 'CREATE SCHEMA IF NOT EXISTS morbac;');
|
||||
const sql = lines.join('\n') + `
|
||||
-- Record the installed schema version (readable via
|
||||
-- morbac.get_config('schema_version'); upgrade scripts bump it).
|
||||
INSERT INTO morbac.config (key, value, description)
|
||||
VALUES ('schema_version', '${version}', 'Installed pgmorbac schema version')
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = CURRENT_TIMESTAMP;
|
||||
`;
|
||||
return [
|
||||
{ filename: '0001_morbac.sql', sql },
|
||||
...upgradeScripts().map(u => ({ filename: `0001_morbac--${u.from}--${u.to}.sql`, sql: u.sql })),
|
||||
];
|
||||
}
|
||||
|
||||
+6
-4
@@ -1,4 +1,5 @@
|
||||
import type { Pool } from 'pg';
|
||||
import { permittedOrgs } from './permissions.js';
|
||||
|
||||
/**
|
||||
* Wraps a query selecting the visible orgs (id, name, parent_id, metadata) and
|
||||
@@ -26,10 +27,11 @@ export function withOrgPaths(visSql: string): string {
|
||||
* 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: candidates } = await db.query<{ id: string }>(
|
||||
`SELECT id FROM morbac.orgs WHERE morbac.is_allowed($1::UUID, id, 'read', $2)`, [userId, view]);
|
||||
const readable = await permittedOrgs(db, userId, candidates.map((c) => c.id), 'read', view);
|
||||
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]);
|
||||
withOrgPaths('SELECT id, name, parent_id, NULL::jsonb AS metadata FROM morbac.orgs WHERE id = ANY($1::UUID[])'),
|
||||
[readable]);
|
||||
return new Map(rows.map((r) => [r.id, r.path]));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import type { Pool } from 'pg';
|
||||
|
||||
/** One permission decision: may this user perform (activity, view) in this org. */
|
||||
export interface PermissionCheck {
|
||||
userId: string;
|
||||
/** The org acted in, or null for the no-org perimeter. */
|
||||
orgId: string | null;
|
||||
activity: string;
|
||||
view: string;
|
||||
/** The request behind the decision, when there is one. */
|
||||
req?: FastifyRequest;
|
||||
}
|
||||
|
||||
/** Runs before morbac is asked. Returning false refuses without asking it. */
|
||||
export type PrePermissionHook = (check: PermissionCheck, db: Pool) => boolean | Promise<boolean>;
|
||||
|
||||
/** Runs after morbac answered, with that answer. Returning false refuses. */
|
||||
export type PostPermissionHook = (check: PermissionCheck, allowed: boolean, db: Pool) => boolean | Promise<boolean>;
|
||||
|
||||
const pre: PrePermissionHook[] = [];
|
||||
const post: PostPermissionHook[] = [];
|
||||
|
||||
/**
|
||||
* Adds an application rule to every permission decision the helpers make. A
|
||||
* hook can only narrow: a decision is allowed when morbac allows it and every
|
||||
* hook agrees, so no hook can grant what morbac refused. A hook that throws
|
||||
* fails the decision with its error; nothing is swallowed into an allow.
|
||||
* Returns a function that removes the hook.
|
||||
*/
|
||||
export function addPermissionHook(phase: 'pre', hook: PrePermissionHook): () => void;
|
||||
export function addPermissionHook(phase: 'post', hook: PostPermissionHook): () => void;
|
||||
export function addPermissionHook(phase: 'pre' | 'post', hook: PrePermissionHook | PostPermissionHook): () => void {
|
||||
const list = (phase === 'pre' ? pre : post) as unknown[];
|
||||
list.push(hook);
|
||||
return () => {
|
||||
const i = list.indexOf(hook);
|
||||
if (i !== -1) list.splice(i, 1);
|
||||
};
|
||||
}
|
||||
|
||||
export async function runPreHooks(check: PermissionCheck, db: Pool): Promise<boolean> {
|
||||
for (const hook of pre) {
|
||||
if (await hook(check, db) !== true) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function runPostHooks(check: PermissionCheck, allowed: boolean, db: Pool): Promise<boolean> {
|
||||
let result = allowed;
|
||||
for (const hook of post) {
|
||||
if (await hook(check, allowed, db) !== true) result = false;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
+127
-42
@@ -1,5 +1,8 @@
|
||||
import type { FastifyRequest, FastifyReply } from 'fastify';
|
||||
import type { Pool } from 'pg';
|
||||
import { runPostHooks, runPreHooks } from './permission-hooks.js';
|
||||
|
||||
export { addPermissionHook, type PermissionCheck, type PrePermissionHook, type PostPermissionHook } from './permission-hooks.js';
|
||||
|
||||
export const SYSTEM_ORG_ID = '00000000-0000-0000-0000-000000000000';
|
||||
|
||||
@@ -72,6 +75,32 @@ export function orgScopeExpr(orgIds: string[], column: string, paramOffset = 0):
|
||||
return { expr: `${column} IN (${parts.join(' UNION ')})`, params: orgIds };
|
||||
}
|
||||
|
||||
/**
|
||||
* The filter a list applies to `column`: the orgs it answers for are those under
|
||||
* the orgs of `scope` (every org when the request named none), kept only where
|
||||
* the caller holds (activity, target) once the permission hooks have run. A row
|
||||
* with no org shows only on an unpinned list, which `permittedScope` let through
|
||||
* on the no-org perimeter.
|
||||
*/
|
||||
export async function listScope(
|
||||
db: Pool,
|
||||
req: FastifyRequest,
|
||||
scope: string[],
|
||||
activity: string,
|
||||
target: string,
|
||||
column: string,
|
||||
paramOffset = 0,
|
||||
): Promise<{ expr: string; params: [string[]] }> {
|
||||
const user = getUser(req);
|
||||
const { rows } = scope.length
|
||||
? await db.query<{ id: string }>(
|
||||
"SELECT DISTINCT s.org_id AS id FROM unnest($1::UUID[]) o, morbac.get_org_scope(o, 'subtree') s", [scope])
|
||||
: await db.query<{ id: string }>('SELECT id FROM morbac.orgs');
|
||||
const orgs = user ? await permittedOrgs(db, user.id, rows.map((r) => r.id), activity, target, req) : [];
|
||||
const own = `${column} = ANY($${paramOffset + 1}::UUID[])`;
|
||||
return { expr: scope.length ? own : `(${own} OR ${column} IS NULL)`, params: [orgs] };
|
||||
}
|
||||
|
||||
export function viewForType(type: 'user' | 'service'): string {
|
||||
return type === 'service' ? 'service_users' : 'users';
|
||||
}
|
||||
@@ -122,18 +151,12 @@ export async function canActOnUser(
|
||||
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],
|
||||
if (await isAllowed(db, callerId, null, activity, view)) return true;
|
||||
const { rows } = await db.query<{ org_id: string }>(
|
||||
'SELECT DISTINCT org_id FROM morbac.user_roles WHERE user_id = $1::UUID',
|
||||
[targetUserId],
|
||||
);
|
||||
return rows[0]?.ok ?? false;
|
||||
return (await permittedOrgs(db, callerId, rows.map((r) => r.org_id), activity, view)).length > 0;
|
||||
}
|
||||
|
||||
/** True if the caller may reach org (activity, view) at every org that a rule
|
||||
@@ -153,20 +176,14 @@ export async function canGrantRule(
|
||||
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],
|
||||
const { rows } = await db.query<{ org_id: string }>(
|
||||
'SELECT org_id FROM morbac.get_org_scope($1::UUID, $2) AS s(org_id)',
|
||||
[ruleOrgId, scope],
|
||||
);
|
||||
return rows[0]?.ok ?? false;
|
||||
const covered = rows.map((r) => r.org_id);
|
||||
if ((await permittedOrgs(db, callerId, covered, activity, view)).length !== covered.length) return false;
|
||||
if (scope !== 'unattributed' && scope !== 'all') return true;
|
||||
return isAllowed(db, callerId, null, activity, view);
|
||||
}
|
||||
|
||||
/** True iff (activity, view) has an explicit row in morbac.activity_view_bindings.
|
||||
@@ -211,7 +228,8 @@ export async function requireGlobalAdmin(db: Pool, req: FastifyRequest, reply: F
|
||||
|
||||
/** 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. */
|
||||
* to expose a feature at all -- never a substitute for the per-object check.
|
||||
* Names no org, so permission hooks do not run. */
|
||||
export async function hasAnyPermission(
|
||||
db: Pool,
|
||||
userId: string,
|
||||
@@ -245,11 +263,7 @@ export async function canPerform(
|
||||
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;
|
||||
return isAllowed(db, user.id, orgId, activity, target, req);
|
||||
}
|
||||
|
||||
/** Reads an API key's scope off a request, for callers that must reason about it. */
|
||||
@@ -277,11 +291,7 @@ export async function hasPermission(
|
||||
reply.code(403).send({ error: 'Insufficient permissions' });
|
||||
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],
|
||||
);
|
||||
if (!rows[0]?.allowed) {
|
||||
if (!await isAllowed(db, user.id, orgId, activity, target, req)) {
|
||||
reply.code(403).send({ error: 'Insufficient permissions' });
|
||||
return false;
|
||||
}
|
||||
@@ -312,15 +322,90 @@ export async function hasPermissionAny(
|
||||
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) {
|
||||
const real = orgIds.filter((v) => v && v.toLowerCase() !== UNATTRIBUTED_ORG_TOKEN);
|
||||
if ((await permittedOrgs(db, user.id, real, activity, target, req)).length === 0) {
|
||||
reply.code(403).send({ error: 'Insufficient permissions' });
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The orgs of a list request the caller may read in, or null once a 401 or 403
|
||||
* is sent. An unpinned request (no org header) is decided on the no-org
|
||||
* perimeter and answers []. A pinned one keeps only the orgs that pass, hooks
|
||||
* included, and is refused only when none does: one refused org neither fails
|
||||
* the list nor widens it to orgs the caller cannot read.
|
||||
*/
|
||||
export async function permittedScope(
|
||||
db: Pool,
|
||||
req: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
orgIds: string[],
|
||||
activity: string,
|
||||
target: string,
|
||||
): Promise<string[] | null> {
|
||||
if (orgIds.length === 0) return await hasPermission(db, req, reply, null, activity, target) ? [] : null;
|
||||
const user = getUser(req);
|
||||
if (!user) {
|
||||
reply.code(401).send({ error: 'Authentication required' });
|
||||
return null;
|
||||
}
|
||||
const apiKeyScope = ((req as unknown) as Record<string, unknown>).apiKeyScope as ApiKeyScope | undefined;
|
||||
const allowed = scopeAllows(apiKeyScope, activity, target)
|
||||
? await permittedOrgs(db, user.id, orgIds, activity, target, req)
|
||||
: [];
|
||||
if (allowed.length === 0) {
|
||||
reply.code(403).send({ error: 'Insufficient permissions' });
|
||||
return null;
|
||||
}
|
||||
return allowed;
|
||||
}
|
||||
|
||||
/**
|
||||
* The one permission decision every helper makes: morbac's answer, narrowed by
|
||||
* the application's permission hooks (`addPermissionHook`). Use it instead of
|
||||
* querying `morbac.is_allowed` directly, which would skip those hooks.
|
||||
*/
|
||||
export async function isAllowed(
|
||||
db: Pool,
|
||||
userId: string,
|
||||
orgId: string | null,
|
||||
activity: string,
|
||||
view: string,
|
||||
req?: FastifyRequest,
|
||||
): Promise<boolean> {
|
||||
const check = { userId, orgId, activity, view, req };
|
||||
if (!await runPreHooks(check, db)) return false;
|
||||
const { rows } = await db.query<{ allowed: boolean }>(
|
||||
'SELECT morbac.is_allowed($1::UUID, $2::UUID, $3, $4) AS allowed',
|
||||
[userId, orgId, activity, view],
|
||||
);
|
||||
return runPostHooks(check, rows[0]?.allowed === true, db);
|
||||
}
|
||||
|
||||
/** The orgs among `orgIds` where `isAllowed` would answer yes, in their given order, asked of morbac in one query. */
|
||||
export async function permittedOrgs(
|
||||
db: Pool,
|
||||
userId: string,
|
||||
orgIds: string[],
|
||||
activity: string,
|
||||
view: string,
|
||||
req?: FastifyRequest,
|
||||
): Promise<string[]> {
|
||||
const candidates: string[] = [];
|
||||
for (const orgId of new Set(orgIds)) {
|
||||
if (await runPreHooks({ userId, orgId, activity, view, req }, db)) candidates.push(orgId);
|
||||
}
|
||||
if (candidates.length === 0) return [];
|
||||
const { rows } = await db.query<{ o: string }>(
|
||||
'SELECT o FROM unnest($2::UUID[]) o WHERE morbac.is_allowed($1::UUID, o, $3, $4)',
|
||||
[userId, candidates, activity, view],
|
||||
);
|
||||
const allowed = new Set(rows.map((r) => r.o.toLowerCase()));
|
||||
const out: string[] = [];
|
||||
for (const orgId of candidates) {
|
||||
if (await runPostHooks({ userId, orgId, activity, view, req }, allowed.has(orgId.toLowerCase()), db)) out.push(orgId);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
+11
-7
@@ -1,6 +1,6 @@
|
||||
import type { FastifyRequest, FastifyReply } from 'fastify';
|
||||
import type { Pool } from 'pg';
|
||||
import { hasPermission, hasPermissionAny } from './permissions.js';
|
||||
import { checkApiKeyScope, hasPermission, permittedOrgs, UNATTRIBUTED_ORG_TOKEN } from './permissions.js';
|
||||
|
||||
export interface DataPermission {
|
||||
activity: string;
|
||||
@@ -95,13 +95,17 @@ export function createProxyRegistry(db: Pool, postgrestUrl: string): ProxyRegist
|
||||
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.
|
||||
// A multi-org list read (e.g. "all organizations"). Only the orgs
|
||||
// where the caller is allowed go upstream: RLS filters rows by
|
||||
// morbac alone, so an org a permission hook refused would
|
||||
// otherwise still show its rows.
|
||||
if (!checkApiKeyScope(req, reply, permission.activity, permission.view)) return reply;
|
||||
const ids = orgIdsVal.split(',').map(s => s.trim()).filter(Boolean);
|
||||
if (!await hasPermissionAny(db, req, reply, ids, permission.activity, permission.view)) {
|
||||
return reply;
|
||||
}
|
||||
const unattributed = ids.some((id) => id.toLowerCase() === UNATTRIBUTED_ORG_TOKEN);
|
||||
const real = ids.filter((id) => id.toLowerCase() !== UNATTRIBUTED_ORG_TOKEN);
|
||||
const allowed = await permittedOrgs(db, user.id, real, permission.activity, permission.view, req);
|
||||
if (allowed.length === 0) return reply.code(403).send({ error: 'Insufficient permissions' });
|
||||
req.headers['x-org-ids'] = (unattributed ? [...allowed, UNATTRIBUTED_ORG_TOKEN] : allowed).join(',');
|
||||
} else {
|
||||
return reply.code(400).send({ error: 'X-Org-Id header is required' });
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { Pool } from 'pg';
|
||||
import { getUser, getOrgScope, hasPermission } from '../permissions.js';
|
||||
import { getUser, getOrgScope, permittedScope } from '../permissions.js';
|
||||
import { unauthorized, badRequest, forbidden } from '../schemas.js';
|
||||
|
||||
export function createActivityRoutes(db: Pool) {
|
||||
@@ -31,7 +31,8 @@ export function createActivityRoutes(db: Pool) {
|
||||
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 scope = await permittedScope(db, req, reply, orgIds, 'read', 'rules');
|
||||
if (!scope) return reply;
|
||||
const { rows } = await db.query('SELECT name, description FROM morbac.activities ORDER BY name');
|
||||
return rows;
|
||||
});
|
||||
@@ -70,7 +71,8 @@ export function createActivityRoutes(db: Pool) {
|
||||
|
||||
const orgIds = getOrgScope(req, reply, false);
|
||||
if (orgIds === null) return reply;
|
||||
if (!await hasPermission(db, req, reply, orgIds[0] ?? null, 'read', 'users')) return reply;
|
||||
const scope = await permittedScope(db, req, reply, orgIds, 'read', 'users');
|
||||
if (!scope) return reply;
|
||||
|
||||
const { name } = req.params as { name: string };
|
||||
const { rows } = await db.query(
|
||||
@@ -132,7 +134,8 @@ export function createActivityRoutes(db: Pool) {
|
||||
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 scope = await permittedScope(db, req, reply, orgIds, 'read', 'rules');
|
||||
if (!scope) 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(
|
||||
@@ -178,7 +181,8 @@ export function createActivityRoutes(db: Pool) {
|
||||
|
||||
const orgIds = getOrgScope(req, reply, false);
|
||||
if (orgIds === null) return reply;
|
||||
if (!await hasPermission(db, req, reply, orgIds[0] ?? null, 'read', 'users')) return reply;
|
||||
const scope = await permittedScope(db, req, reply, orgIds, 'read', 'users');
|
||||
if (!scope) return reply;
|
||||
|
||||
const { name } = req.params as { name: string };
|
||||
const { rows } = await db.query(
|
||||
@@ -239,7 +243,8 @@ export function createActivityRoutes(db: Pool) {
|
||||
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 scope = await permittedScope(db, req, reply, orgIds, 'read', 'rules');
|
||||
if (!scope) return reply;
|
||||
const { rows } = await db.query(
|
||||
'SELECT activity, view FROM morbac.activity_view_bindings ORDER BY activity, view',
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { Pool } from 'pg';
|
||||
import { getUser, getOrgScope, orgScopeExpr, hasPermission, canActOnUser, SYSTEM_ORG_ID } from '../permissions.js';
|
||||
import { getUser, getOrgScope, listScope, hasPermission, permittedScope, canActOnUser, SYSTEM_ORG_ID } from '../permissions.js';
|
||||
import { delegationResponse, notFound, forbidden, unauthorized, badRequest } from '../schemas.js';
|
||||
|
||||
const SELECT_COLUMNS = `
|
||||
@@ -30,9 +30,10 @@ export function createDelegationRoutes(db: Pool) {
|
||||
const orgIds = getOrgScope(req, reply, false);
|
||||
if (orgIds === null) return reply;
|
||||
|
||||
if (!await hasPermission(db, req, reply, orgIds[0] ?? null, 'read', 'delegations')) return reply;
|
||||
const scope = await permittedScope(db, req, reply, orgIds, 'read', 'delegations');
|
||||
if (!scope) return reply;
|
||||
|
||||
const { expr, params } = orgScopeExpr(orgIds, 'd.org_id');
|
||||
const { expr, params } = await listScope(db, req, scope, 'read', 'delegations', 'd.org_id');
|
||||
const { rows } = await db.query(
|
||||
`SELECT ${SELECT_COLUMNS}
|
||||
FROM morbac.delegations d
|
||||
|
||||
+26
-14
@@ -3,11 +3,12 @@ import { withMorbacContext } from '../rls-context.js';
|
||||
import type { Pool } from 'pg';
|
||||
import type { CheckLimitFn, OrgEventSink } from '../plugin.js';
|
||||
import {
|
||||
getUser, getOrgScope, orgScopeExpr, hasPermission, SYSTEM_ORG_ID,
|
||||
getUser, getOrgScope, listScope, hasPermission, permittedScope, permittedOrgs, SYSTEM_ORG_ID,
|
||||
} from '../permissions.js';
|
||||
import { orgResponse, notFound, forbidden, unauthorized, badRequest } from '../schemas.js';
|
||||
|
||||
import { withOrgPaths } from '../org-paths.js';
|
||||
import { runPreHooks, runPostHooks } from '../permission-hooks.js';
|
||||
|
||||
interface OrgRow { id: string; name: string; parent_id: string | null; metadata?: Record<string, unknown> }
|
||||
|
||||
@@ -40,28 +41,39 @@ export function createOrgRoutes(db: Pool, checkLimit?: CheckLimitFn, emitOrgEven
|
||||
// caller in it even with no read/orgs rule, and the org filter is
|
||||
// what supplies X-Org-Id, so dropping those orgs leaves such a
|
||||
// user with an empty perimeter and an app that does nothing.
|
||||
// Both ways in still go through the permission hooks, so an org
|
||||
// the hooks refuse never reaches the filter and every request
|
||||
// pinned to it fails.
|
||||
const { rows: candidates } = await db.query<{ id: string; member: boolean }>(
|
||||
`SELECT o.id, EXISTS (SELECT 1 FROM morbac.user_roles ur WHERE ur.org_id = o.id AND ur.user_id = $1) AS member
|
||||
FROM morbac.orgs o
|
||||
WHERE morbac.is_allowed($1::UUID, o.id, 'read', 'orgs')
|
||||
OR EXISTS (SELECT 1 FROM morbac.user_roles ur WHERE ur.org_id = o.id AND ur.user_id = $1)`,
|
||||
[user.id],
|
||||
);
|
||||
const readable = new Set(await permittedOrgs(db, user.id, candidates.map((c) => c.id), 'read', 'orgs', req));
|
||||
const visible: string[] = [];
|
||||
for (const c of candidates) {
|
||||
const check = { userId: user.id, orgId: c.id, activity: 'read', view: 'orgs', req };
|
||||
if (readable.has(c.id) || (c.member && await runPreHooks(check, db) && await runPostHooks(check, true, db))) visible.push(c.id);
|
||||
}
|
||||
const { rows } = await withMorbacContext(db, { userId: user.id }, (client) =>
|
||||
client.query(
|
||||
withOrgPaths(
|
||||
`SELECT id, name, parent_id, metadata FROM morbac.orgs o
|
||||
WHERE morbac.is_allowed($1::UUID, o.id, 'read', 'orgs')
|
||||
OR EXISTS (SELECT 1 FROM morbac.user_roles ur
|
||||
WHERE ur.org_id = o.id AND ur.user_id = $1)`,
|
||||
),
|
||||
[user.id],
|
||||
withOrgPaths('SELECT id, name, parent_id, metadata FROM morbac.orgs WHERE id = ANY($1::UUID[])'),
|
||||
[visible],
|
||||
),
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
if (!await hasPermission(db, req, reply, orgIds[0], 'read', 'orgs')) return reply;
|
||||
const scope = await permittedScope(db, req, reply, orgIds, 'read', 'orgs');
|
||||
if (!scope) return reply;
|
||||
|
||||
const isSystemScope = orgIds.includes(SYSTEM_ORG_ID);
|
||||
const { expr, params } = isSystemScope ? { expr: '', params: [] } : orgScopeExpr(orgIds, 'id');
|
||||
const { rows } = await withMorbacContext(db, { userId: user.id, orgId: orgIds[0] }, (client) =>
|
||||
const { params: [visible] } = await listScope(db, req, scope, 'read', 'orgs', 'id');
|
||||
const { rows } = await withMorbacContext(db, { userId: user.id, orgId: scope[0] }, (client) =>
|
||||
client.query(
|
||||
withOrgPaths(`SELECT id, name, parent_id, metadata FROM morbac.orgs ${expr ? `WHERE ${expr}` : ''}`),
|
||||
params,
|
||||
withOrgPaths('SELECT id, name, parent_id, metadata FROM morbac.orgs WHERE id = ANY($1::UUID[])'),
|
||||
[visible],
|
||||
),
|
||||
);
|
||||
return rows;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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 { getUser, getOrgScope, hasPermission, permittedScope, SYSTEM_ORG_ID } from '../permissions.js';
|
||||
import { roleResponse, notFound, forbidden, unauthorized, badRequest } from '../schemas.js';
|
||||
import { loadProfile, validateGrantable, applyProfileRules } from './profile-apply.js';
|
||||
|
||||
@@ -58,7 +58,8 @@ export function createPermissionProfileRoutes(db: Pool, checkLimit?: CheckLimitF
|
||||
|
||||
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 scope = await permittedScope(db, req, reply, orgIds, 'read', 'roles');
|
||||
if (!scope) return reply;
|
||||
|
||||
const { module_id } = req.query as { module_id?: string };
|
||||
const { rows } = await db.query(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Pool } from 'pg';
|
||||
import { isAllowed } from '../permissions.js';
|
||||
|
||||
/**
|
||||
* Whether the caller may hand out this role at all: assigning it is exercising
|
||||
@@ -7,12 +8,6 @@ import type { Pool } from 'pg';
|
||||
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;
|
||||
const { rows } = await db.query<{ org_id: string | null }>('SELECT org_id FROM morbac.roles WHERE id = $1::UUID', [roleId]);
|
||||
return rows.length > 0 && isAllowed(db, callerId, rows[0].org_id, activity, 'user_roles');
|
||||
}
|
||||
|
||||
+7
-5
@@ -2,7 +2,7 @@ import type { FastifyInstance } from 'fastify';
|
||||
import type { Pool } from 'pg';
|
||||
import type { CheckLimitFn, RoleEventSink } from '../plugin.js';
|
||||
import {
|
||||
getUser, getOrgScope, orgScopeExpr, hasPermission, SYSTEM_ORG_ID,
|
||||
getUser, getOrgScope, listScope, hasPermission, permittedScope, SYSTEM_ORG_ID,
|
||||
} from '../permissions.js';
|
||||
import { parseListParams, composeWhere, setContentRange, ListQueryError } from '../list-query.js';
|
||||
import { roleResponse, notFound, forbidden, unauthorized, badRequest } from '../schemas.js';
|
||||
@@ -30,9 +30,10 @@ export function createRoleRoutes(db: Pool, checkLimit?: CheckLimitFn, emitRoleEv
|
||||
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 scope = await permittedScope(db, req, reply, orgIds, 'read', 'roles');
|
||||
if (!scope) return reply;
|
||||
|
||||
const { expr, params } = orgScopeExpr(orgIds, 'r.org_id');
|
||||
const { expr, params } = await listScope(db, req, scope, 'read', 'roles', 'r.org_id');
|
||||
let list;
|
||||
try {
|
||||
list = parseListParams(req.query as Record<string, unknown>, {
|
||||
@@ -214,9 +215,10 @@ export function createRoleRoutes(db: Pool, checkLimit?: CheckLimitFn, emitRoleEv
|
||||
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 scope = await permittedScope(db, req, reply, orgIds, 'read', 'roles');
|
||||
if (!scope) return reply;
|
||||
|
||||
const { expr, params } = orgScopeExpr(orgIds, 'rs.org_id');
|
||||
const { expr, params } = await listScope(db, req, scope, 'read', 'roles', 'rs.org_id');
|
||||
const { rows } = await db.query(
|
||||
`SELECT rh.senior_role_id, rs.name AS senior_name,
|
||||
rh.junior_role_id, rj.name AS junior_name,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { Pool } from 'pg';
|
||||
import { getUser, getOrgScope, orgScopeExpr, hasPermission, isActivityViewBound, SYSTEM_ORG_ID } from '../permissions.js';
|
||||
import { getUser, getOrgScope, listScope, hasPermission, permittedScope, isActivityViewBound, SYSTEM_ORG_ID } from '../permissions.js';
|
||||
import { crossOrgRuleResponse, notFound, forbidden, unauthorized, badRequest } from '../schemas.js';
|
||||
|
||||
export function createCrossOrgRuleRoutes(db: Pool) {
|
||||
@@ -23,14 +23,11 @@ export function createCrossOrgRuleRoutes(db: Pool) {
|
||||
const orgIds = getOrgScope(req, reply, false);
|
||||
if (orgIds === null) return reply;
|
||||
|
||||
if (!await hasPermission(db, req, reply, orgIds[0] ?? null, 'read', 'cross_org_rules')) return reply;
|
||||
const scope = await permittedScope(db, req, reply, orgIds, 'read', 'cross_org_rules');
|
||||
if (!scope) return reply;
|
||||
|
||||
const { expr: srcExpr, params: srcParams } = orgScopeExpr(orgIds, 'cor.source_org_id');
|
||||
const { expr: tgtExpr, params: tgtParams } = orgScopeExpr(orgIds, 'cor.target_org_id', srcParams.length);
|
||||
const where = srcExpr && tgtExpr ? `WHERE (${srcExpr} OR ${tgtExpr})`
|
||||
: srcExpr ? `WHERE ${srcExpr}`
|
||||
: tgtExpr ? `WHERE ${tgtExpr}`
|
||||
: '';
|
||||
const { params } = await listScope(db, req, scope, 'read', 'cross_org_rules', 'cor.source_org_id');
|
||||
const where = 'WHERE cor.source_org_id = ANY($1::UUID[]) OR cor.target_org_id = ANY($1::UUID[])';
|
||||
|
||||
const { rows } = await db.query(
|
||||
`SELECT cor.id,
|
||||
@@ -45,7 +42,7 @@ export function createCrossOrgRuleRoutes(db: Pool) {
|
||||
JOIN morbac.orgs "to" ON "to".id = cor.target_org_id
|
||||
${where}
|
||||
ORDER BY so.name, sr.name, "to".name, cor.activity, cor.view`,
|
||||
[...srcParams, ...tgtParams],
|
||||
params,
|
||||
);
|
||||
return rows;
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { Pool } from 'pg';
|
||||
import { getUser, getOrgScope, orgScopeExpr, hasPermission, canGrantRule, isActivityViewBound, SYSTEM_ORG_ID } from '../permissions.js';
|
||||
import { getUser, getOrgScope, listScope, hasPermission, permittedScope, canGrantRule, isActivityViewBound, SYSTEM_ORG_ID } from '../permissions.js';
|
||||
import { ruleResponse, notFound, forbidden, unauthorized, badRequest } from '../schemas.js';
|
||||
|
||||
export function createRoleRuleRoutes(db: Pool) {
|
||||
@@ -23,9 +23,10 @@ export function createRoleRuleRoutes(db: Pool) {
|
||||
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 scope = await permittedScope(db, req, reply, orgIds, 'read', 'rules');
|
||||
if (!scope) return reply;
|
||||
|
||||
const { expr, params } = orgScopeExpr(orgIds, 'r.org_id');
|
||||
const { expr, params } = await listScope(db, req, scope, 'read', 'rules', 'r.org_id');
|
||||
const { rows } = await db.query(
|
||||
`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,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { Pool } from 'pg';
|
||||
import { getUser, getOrgScope, orgScopeExpr, hasPermission, isActivityViewBound } from '../permissions.js';
|
||||
import { getUser, getOrgScope, listScope, hasPermission, permittedScope, isActivityViewBound } from '../permissions.js';
|
||||
import { userRuleResponse, notFound, forbidden, unauthorized, badRequest } from '../schemas.js';
|
||||
|
||||
export function createUserRuleRoutes(db: Pool) {
|
||||
@@ -23,9 +23,10 @@ export function createUserRuleRoutes(db: Pool) {
|
||||
const orgIds = getOrgScope(req, reply, false);
|
||||
if (orgIds === null) return reply;
|
||||
|
||||
if (!await hasPermission(db, req, reply, orgIds[0] ?? null, 'read', 'user_rules')) return reply;
|
||||
const scope = await permittedScope(db, req, reply, orgIds, 'read', 'user_rules');
|
||||
if (!scope) return reply;
|
||||
|
||||
const { expr, params } = orgScopeExpr(orgIds, 'ur.org_id');
|
||||
const { expr, params } = await listScope(db, req, scope, 'read', 'user_rules', 'ur.org_id');
|
||||
const { rows } = await db.query(
|
||||
`SELECT ur.id, ur.user_id, ur.org_id,
|
||||
COALESCE(u.name, ur.user_id::TEXT) AS user_name,
|
||||
|
||||
+12
-20
@@ -3,8 +3,8 @@ import type { Pool } from 'pg';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import type { CheckLimitFn } from '../plugin.js';
|
||||
import {
|
||||
getUser, getOrgScope, orgScopeExpr, hasPermission, canActOnUser, checkApiKeyScope,
|
||||
getAccountType, viewForType, SYSTEM_ORG_ID,
|
||||
getUser, getOrgScope, listScope, hasPermission, permittedScope, canActOnUser, checkApiKeyScope,
|
||||
getAccountType, viewForType, isAllowed,
|
||||
} from '../permissions.js';
|
||||
import { userResponse, notFound, forbidden, unauthorized, badRequest } from '../schemas.js';
|
||||
import { generatePassword } from '../password.js';
|
||||
@@ -30,31 +30,23 @@ export function createUserCrudRoutes(db: Pool, checkLimit?: CheckLimitFn) {
|
||||
const orgIds = getOrgScope(req, reply, false);
|
||||
if (orgIds === null) return reply;
|
||||
|
||||
if (!await hasPermission(db, req, reply, orgIds[0] ?? null, 'read', 'users')) return reply;
|
||||
const scope = await permittedScope(db, req, reply, orgIds, 'read', 'users');
|
||||
if (!scope) return reply;
|
||||
|
||||
const checkOrg = orgIds[0] ?? null;
|
||||
const checkOrg = scope[0] ?? null;
|
||||
// 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',
|
||||
[user.id, checkOrg, 'read', 'system_users']),
|
||||
db.query<{ allowed: boolean }>('SELECT morbac.is_allowed($1::UUID, $2::UUID, $3, $4) AS allowed',
|
||||
[user.id, checkOrg, 'read', 'service_users']),
|
||||
db.query<{ allowed: boolean }>('SELECT morbac.is_allowed($1::UUID, NULL, $2, $3) AS allowed',
|
||||
[user.id, 'read', 'users']),
|
||||
const [canSeeSystem, canSeeService, canReadUnattached] = await Promise.all([
|
||||
isAllowed(db, user.id, checkOrg, 'read', 'system_users', req),
|
||||
isAllowed(db, user.id, checkOrg, 'read', 'service_users', req),
|
||||
isAllowed(db, user.id, null, 'read', 'users', req),
|
||||
]);
|
||||
const canSeeSystem = sysRes.rows[0]?.allowed ?? false;
|
||||
const canSeeService = svcRes.rows[0]?.allowed ?? false;
|
||||
const canReadUnattached = noOrgRes.rows[0]?.allowed ?? false;
|
||||
|
||||
const { expr, params } = orgScopeExpr(orgIds, 'ur2.org_id');
|
||||
const isSystemScope = orgIds.includes(SYSTEM_ORG_ID);
|
||||
const { params } = await listScope(db, req, scope, 'read', 'users', 'ur2.org_id');
|
||||
const placement = canReadUnattached ? ' OR u.needs_placement' : '';
|
||||
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'))`
|
||||
: '';
|
||||
const whereClause = `WHERE (u.id IN (SELECT ur2.user_id FROM morbac.user_roles ur2 WHERE ur2.org_id = ANY($1::UUID[]))${placement} OR u.account_type IN ('system', 'service'))`;
|
||||
const { rows } = await db.query(
|
||||
`SELECT
|
||||
u.id, u.email, u.name, u.account_type, u.is_active, u.needs_placement, u.created_at,
|
||||
@@ -73,7 +65,7 @@ export function createUserCrudRoutes(db: Pool, checkLimit?: CheckLimitFn) {
|
||||
LEFT JOIN morbac.orgs o ON o.id = ur.org_id
|
||||
${whereClause}
|
||||
GROUP BY u.id ORDER BY u.name`,
|
||||
whereClause ? params : [],
|
||||
params,
|
||||
);
|
||||
return (rows as { account_type: string }[]).filter((u) => {
|
||||
if (u.account_type === 'system' && !canSeeSystem) return false;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { FastifyInstance, FastifyReply } from 'fastify';
|
||||
import type { Pool } from 'pg';
|
||||
import { getUser, canActOnUser, checkApiKeyScope, getAccountType, viewForType } from '../permissions.js';
|
||||
import { getUser, canActOnUser, permittedOrgs, checkApiKeyScope, getAccountType, viewForType } from '../permissions.js';
|
||||
import { notFound, forbidden, unauthorized, badRequest } from '../schemas.js';
|
||||
|
||||
const MAX_ORG_FILTER = 100;
|
||||
@@ -21,12 +21,8 @@ const orgFilterResponse = {
|
||||
* 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);
|
||||
const { rows } = await db.query<{ id: string }>('SELECT id FROM morbac.orgs WHERE id = ANY($1::UUID[])', [ids]);
|
||||
return permittedOrgs(db, userId, rows.map((r) => r.id), 'read', 'orgs');
|
||||
}
|
||||
|
||||
async function storedOrgFilter(db: Pool, userId: string): Promise<string[]> {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { Pool } from 'pg';
|
||||
import { getUser, getOrgScope, hasPermission, getAccountType } from '../permissions.js';
|
||||
import { getUser, getOrgScope, hasPermission, permittedScope, getAccountType } from '../permissions.js';
|
||||
|
||||
import { notFound, forbidden, unauthorized, badRequest } from '../schemas.js';
|
||||
import { callerControlsRoleOrg } from './role-authority.js';
|
||||
@@ -99,7 +99,8 @@ export function createUserRoleRoutes(db: Pool) {
|
||||
|
||||
const orgIds = getOrgScope(req, reply, false);
|
||||
if (orgIds === null) return reply;
|
||||
if (!await hasPermission(db, req, reply, orgIds[0] ?? null, 'read', 'users')) return reply;
|
||||
const scope = await permittedScope(db, req, reply, orgIds, 'read', 'users');
|
||||
if (!scope) return reply;
|
||||
|
||||
const { id } = req.params as { id: string };
|
||||
const exists = await db.query('SELECT 1 FROM app.users WHERE id = $1', [id]);
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { test, beforeEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { addPermissionHook, listScope } from '../dist/index.js';
|
||||
|
||||
const ROOT = '00000000-0000-0000-0000-000000000000';
|
||||
const A = '00000000-0000-4000-8000-00000000000a';
|
||||
const B = '00000000-0000-4000-8000-00000000000b';
|
||||
const TREE = { [ROOT]: [ROOT, A, B], [A]: [A], [B]: [B] };
|
||||
|
||||
function fakeDb(allowed) {
|
||||
return {
|
||||
async query(sql, params) {
|
||||
if (sql.includes('get_org_scope')) return { rows: [...new Set(params[0].flatMap((o) => TREE[o]))].map((id) => ({ id })) };
|
||||
if (sql.includes('FROM morbac.orgs')) return { rows: Object.keys(TREE).map((id) => ({ id })) };
|
||||
if (sql.includes('morbac.is_allowed')) return { rows: params[1].filter((o) => allowed.has(o)).map((o) => ({ o })) };
|
||||
throw new Error(`unexpected query ${sql}`);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const req = { user: { id: 'u' } };
|
||||
let removers = [];
|
||||
beforeEach(() => { removers.forEach((r) => r()); removers = []; });
|
||||
|
||||
test('the root org stands for its subtree, decided org by org', async () => {
|
||||
const { expr, params } = await listScope(fakeDb(new Set([ROOT, A])), req, [ROOT], 'read', 'users', 'x.org_id');
|
||||
assert.equal(expr, 'x.org_id = ANY($1::UUID[])');
|
||||
assert.deepEqual(params[0].sort(), [ROOT, A].sort());
|
||||
});
|
||||
|
||||
test('an org the hooks refuse is left out of every list', async () => {
|
||||
removers.push(addPermissionHook('pre', (c) => c.orgId !== B));
|
||||
const { params } = await listScope(fakeDb(new Set([ROOT, A, B])), req, [], 'read', 'users', 'x.org_id');
|
||||
assert.deepEqual(params[0].sort(), [ROOT, A].sort());
|
||||
});
|
||||
|
||||
test('an unpinned list also shows rows with no org', async () => {
|
||||
const { expr } = await listScope(fakeDb(new Set([A])), req, [], 'read', 'roles', 'r.org_id', 2);
|
||||
assert.equal(expr, '(r.org_id = ANY($3::UUID[]) OR r.org_id IS NULL)');
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { test, beforeEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { addPermissionHook, isAllowed, permittedOrgs } from '../dist/index.js';
|
||||
import { hasPermission, canActOnUser, canGrantRule } from '../dist/permissions.js';
|
||||
|
||||
const A = '00000000-0000-4000-8000-00000000000a';
|
||||
const B = '00000000-0000-4000-8000-00000000000b';
|
||||
|
||||
// morbac allows everything in `allowed` (null = the no-org perimeter).
|
||||
function fakeDb(allowed, extra = {}) {
|
||||
return {
|
||||
async query(sql, params) {
|
||||
if (sql.includes('unnest')) return { rows: params[1].filter((o) => allowed.has(o)).map((o) => ({ o })) };
|
||||
if (sql.includes('morbac.is_allowed')) return { rows: [{ allowed: allowed.has(params[1]) }] };
|
||||
if (sql.includes('user_roles')) return { rows: (extra.memberships ?? []).map((org_id) => ({ org_id })) };
|
||||
if (sql.includes('get_org_scope')) return { rows: (extra.scope ?? []).map((org_id) => ({ org_id })) };
|
||||
throw new Error(`unexpected query ${sql}`);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
let removers = [];
|
||||
beforeEach(() => { removers.forEach((r) => r()); removers = []; });
|
||||
const hook = (phase, fn) => removers.push(addPermissionHook(phase, fn));
|
||||
|
||||
test('without hooks the answer is morbac\'s', async () => {
|
||||
assert.equal(await isAllowed(fakeDb(new Set([A])), 'u', A, 'read', 'x'), true);
|
||||
assert.equal(await isAllowed(fakeDb(new Set()), 'u', A, 'read', 'x'), false);
|
||||
});
|
||||
|
||||
test('a pre hook refuses without asking morbac', async () => {
|
||||
const db = fakeDb(new Set([A]));
|
||||
let asked = false;
|
||||
const query = db.query.bind(db);
|
||||
db.query = async (...a) => { asked = true; return query(...a); };
|
||||
hook('pre', (c) => c.orgId !== A);
|
||||
assert.equal(await isAllowed(db, 'u', A, 'read', 'x'), false);
|
||||
assert.equal(asked, false);
|
||||
});
|
||||
|
||||
test('no hook can grant what morbac refused', async () => {
|
||||
hook('pre', () => true);
|
||||
hook('post', () => true);
|
||||
assert.equal(await isAllowed(fakeDb(new Set()), 'u', A, 'read', 'x'), false);
|
||||
});
|
||||
|
||||
test('a post hook sees the answer and can refuse it', async () => {
|
||||
const seen = [];
|
||||
hook('post', (c, allowed) => { seen.push(allowed); return c.activity !== 'delete'; });
|
||||
assert.equal(await isAllowed(fakeDb(new Set([A])), 'u', A, 'delete', 'x'), false);
|
||||
assert.equal(await isAllowed(fakeDb(new Set([A])), 'u', A, 'read', 'x'), true);
|
||||
assert.deepEqual(seen, [true, true]);
|
||||
});
|
||||
|
||||
test('a hook that is not exactly true refuses', async () => {
|
||||
hook('pre', () => undefined);
|
||||
assert.equal(await isAllowed(fakeDb(new Set([A])), 'u', A, 'read', 'x'), false);
|
||||
});
|
||||
|
||||
test('a throwing hook fails the decision rather than allowing it', async () => {
|
||||
hook('pre', () => { throw new Error('boom'); });
|
||||
await assert.rejects(isAllowed(fakeDb(new Set([A])), 'u', A, 'read', 'x'), /boom/);
|
||||
});
|
||||
|
||||
test('permittedOrgs drops the orgs a hook refuses', async () => {
|
||||
hook('pre', (c) => c.orgId !== B);
|
||||
assert.deepEqual(await permittedOrgs(fakeDb(new Set([A, B])), 'u', [A, B], 'read', 'x'), [A]);
|
||||
});
|
||||
|
||||
test('hasPermission answers 403 when a hook refuses', async () => {
|
||||
hook('pre', () => false);
|
||||
let code;
|
||||
const reply = { code(c) { code = c; return this; }, send() { return this; } };
|
||||
assert.equal(await hasPermission(fakeDb(new Set([A])), { user: { id: 'u' } }, reply, A, 'read', 'x'), false);
|
||||
assert.equal(code, 403);
|
||||
});
|
||||
|
||||
test('canActOnUser and canGrantRule go through the hooks', async () => {
|
||||
hook('pre', (c) => c.orgId !== B);
|
||||
assert.equal(await canActOnUser(fakeDb(new Set([B]), { memberships: [B] }), 'u', 't', 'update', 'users'), false);
|
||||
assert.equal(await canGrantRule(fakeDb(new Set([A, B]), { scope: [A, B] }), 'u', A, 'subtree', 'read', 'x'), false);
|
||||
assert.equal(await canGrantRule(fakeDb(new Set([A])), 'u', A, 'self', 'read', 'x'), true);
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { test, beforeEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { addPermissionHook, permittedScope } from '../dist/index.js';
|
||||
|
||||
const A = '00000000-0000-4000-8000-00000000000a';
|
||||
const B = '00000000-0000-4000-8000-00000000000b';
|
||||
|
||||
function fakeDb(allowed) {
|
||||
return {
|
||||
async query(sql, params) {
|
||||
if (sql.includes('unnest')) return { rows: params[1].filter((o) => allowed.has(o)).map((o) => ({ o })) };
|
||||
if (sql.includes('morbac.is_allowed')) return { rows: [{ allowed: allowed.has(params[1]) }] };
|
||||
throw new Error(`unexpected query ${sql}`);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function fakeReply() {
|
||||
return { status: 200, code(c) { this.status = c; return this; }, send() { return this; } };
|
||||
}
|
||||
|
||||
const req = { user: { id: 'u' } };
|
||||
let removers = [];
|
||||
beforeEach(() => { removers.forEach((r) => r()); removers = []; });
|
||||
|
||||
test('a pinned list keeps only the orgs the caller may read', async () => {
|
||||
const reply = fakeReply();
|
||||
assert.deepEqual(await permittedScope(fakeDb(new Set([B])), req, reply, [A, B], 'read', 'users'), [B]);
|
||||
assert.equal(reply.status, 200);
|
||||
});
|
||||
|
||||
test('a refused first org no longer fails the list', async () => {
|
||||
removers.push(addPermissionHook('pre', (c) => c.orgId !== A));
|
||||
assert.deepEqual(await permittedScope(fakeDb(new Set([A, B])), req, fakeReply(), [A, B], 'read', 'users'), [B]);
|
||||
});
|
||||
|
||||
test('a list with no permitted org is refused', async () => {
|
||||
const reply = fakeReply();
|
||||
assert.equal(await permittedScope(fakeDb(new Set()), req, reply, [A, B], 'read', 'users'), null);
|
||||
assert.equal(reply.status, 403);
|
||||
});
|
||||
|
||||
test('an unpinned list is decided on the no-org perimeter', async () => {
|
||||
assert.deepEqual(await permittedScope(fakeDb(new Set([null])), req, fakeReply(), [], 'read', 'users'), []);
|
||||
const reply = fakeReply();
|
||||
assert.equal(await permittedScope(fakeDb(new Set()), req, reply, [], 'read', 'users'), null);
|
||||
assert.equal(reply.status, 403);
|
||||
});
|
||||
|
||||
test('an API key outside its scope gets nothing', async () => {
|
||||
const reply = fakeReply();
|
||||
const keyed = { ...req, apiKeyScope: [{ activity: 'read', view: 'roles' }] };
|
||||
assert.equal(await permittedScope(fakeDb(new Set([A])), keyed, reply, [A], 'read', 'users'), null);
|
||||
assert.equal(reply.status, 403);
|
||||
});
|
||||
Reference in New Issue
Block a user