release: 1.0.0
This commit is contained in:
+11
-3
@@ -5,13 +5,21 @@ 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/),
|
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).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
## [0.1.0] - 2026-07-22
|
## [1.0.0] - 2026-07-25
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- Initial release of the Node.js companion to the `pgmorbac` PostgreSQL extension.
|
- First stable release of the Node.js companion to the `pgmorbac` PostgreSQL extension.
|
||||||
- `hasPermission` and org-scope helpers backed by `morbac.is_allowed`.
|
- `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.
|
- 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.
|
- 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.
|
- 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.
|
||||||
|
|
||||||
[0.1.0]: https://git.villains.fr/crudy/pgmorbac-node/releases/tag/v0.1.0
|
[1.0.0]: https://git.villains.fr/crudy/pgmorbac-node/releases/tag/v1.0.0
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
[](https://www.apache.org/licenses/LICENSE-2.0)
|
[](https://www.apache.org/licenses/LICENSE-2.0)
|
||||||
[](https://nodejs.org/)
|
[](https://nodejs.org/)
|
||||||
|
|
||||||
This is the Node.js companion to the [`pgmorbac` PostgreSQL extension](https://git.villains.fr/crudy/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, a PostgREST data proxy, management routes, and the JSON schemas and domain types that go with them.
|
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).
|
> The SQL extension is a separate project under the MIT license. This helper package is Apache-2.0. See [LICENSE](LICENSE) and [NOTICE](NOTICE).
|
||||||
|
|
||||||
@@ -23,12 +23,14 @@ The package is split into focused entry points so you only pull in what you need
|
|||||||
|
|
||||||
| Import | Purpose |
|
| Import | Purpose |
|
||||||
|--------|---------|
|
|--------|---------|
|
||||||
| `@crudy/pgmorbac` | Shared domain types (`DataPermission`, `Org`, `Role`, `Rule`, ...) |
|
| `@crudy/pgmorbac` | RLS session context and shared domain types |
|
||||||
| `@crudy/pgmorbac/permissions` | `hasPermission`, org-scope resolution, admin checks |
|
| `@crudy/pgmorbac/permissions` | `hasPermission`, org-scope resolution, admin checks |
|
||||||
| `@crudy/pgmorbac/proxy` | PostgREST data proxy with per-route permissions |
|
| `@crudy/pgmorbac/proxy` | PostgREST data proxy with per-route permissions |
|
||||||
| `@crudy/pgmorbac/plugin` | Fastify management routes (orgs, roles, rules, users, delegations) |
|
| `@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/schemas` | JSON schema response fragments |
|
||||||
| `@crudy/pgmorbac/api` | Domain type definitions |
|
| `@crudy/pgmorbac/migrations` | Loads the bundled `morbac` extension SQL as migrations |
|
||||||
|
| `@crudy/pgmorbac/api` | Domain type definitions (`Org`, `Role`, `Rule`, ...) |
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
@@ -46,6 +48,19 @@ app.get('/documents', async (req, reply) => {
|
|||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 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
|
### 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).
|
Expose an `app`-schema table through the backend with an explicit permission. A path that is never registered does not exist (fail closed).
|
||||||
@@ -59,6 +74,20 @@ proxy.proxyDataRoute(app, 'GET', '/data/documents', 'documents', { activity: 're
|
|||||||
|
|
||||||
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.
|
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
|
### 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.
|
Register CRUD routes for orgs, roles, rules, users, global rules, activities, and delegations. Supply optional hooks for license-limit enforcement and event emission.
|
||||||
@@ -77,12 +106,13 @@ createMgmtRoutes({
|
|||||||
|
|
||||||
- **Deny by default.** Unregistered routes do not exist; unauthorized reads return `404` over `403` to avoid enumeration.
|
- **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.
|
- **Reads are checked too.** List and detail routes require the same permission checks as writes.
|
||||||
- **Allowlisted queries.** Values are parameterized and columns allowlisted; user input is never interpolated.
|
- **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
|
## Related
|
||||||
|
|
||||||
- [pgmorbac](https://git.villains.fr/crudy/pgmorbac) — the PostgreSQL extension (MIT)
|
- [pgmorbac](https://github.com/crudylabs/pgmorbac) — the PostgreSQL extension (MIT)
|
||||||
- [Documentation](https://pgmorbac.villains.fr) — model, architecture, integration guides
|
- [Documentation](https://pgmorbac.crudy.fr) — model, architecture, integration guides
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
+69
-16
@@ -1,9 +1,8 @@
|
|||||||
{
|
{
|
||||||
"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",
|
||||||
"license": "Apache-2.0",
|
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"morbac",
|
"morbac",
|
||||||
"orbac",
|
"orbac",
|
||||||
@@ -16,28 +15,77 @@
|
|||||||
"multi-tenant"
|
"multi-tenant"
|
||||||
],
|
],
|
||||||
"author": "Marc Villain",
|
"author": "Marc Villain",
|
||||||
"homepage": "https://pgmorbac.villains.fr",
|
"homepage": "https://pgmorbac.crudy.fr",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.villains.fr/crudy/pgmorbac-node.git"
|
"url": "https://github.com/crudylabs/pgmorbac-node.git"
|
||||||
},
|
},
|
||||||
"bugs": {
|
"bugs": {
|
||||||
"url": "https://git.villains.fr/crudy/pgmorbac-node/issues"
|
"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", "README.md", "CHANGELOG.md", "LICENSE", "NOTICE"],
|
"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",
|
||||||
@@ -51,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",
|
||||||
@@ -60,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
+101
-17
@@ -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,24 +262,64 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const permCache = new Map<string, { value: boolean; expires: number }>();
|
||||||
|
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`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Org-Id': orgId,
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ p_activity: activity, p_view: view }),
|
||||||
|
});
|
||||||
|
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 {
|
return {
|
||||||
checkPermission: async (token, orgId, activity, view) => {
|
checkPermission: (token, orgId, activity, view) => {
|
||||||
const res = await fetch(`${apiUrl}/me/permissions/check`, {
|
const key = `${token}\x00${orgId}\x00${activity}\x00${view}`;
|
||||||
method: 'POST',
|
const cached = permCache.get(key);
|
||||||
headers: {
|
if (cached && cached.expires > Date.now()) return Promise.resolve(cached.value);
|
||||||
Authorization: `Bearer ${token}`,
|
const existing = permInflight.get(key);
|
||||||
'Content-Type': 'application/json',
|
if (existing) return existing;
|
||||||
'X-Org-Id': orgId,
|
const p = fetchPermission(token, orgId, activity, view).then((value) => {
|
||||||
},
|
permCache.set(key, { value, expires: Date.now() + PERM_TTL_MS });
|
||||||
credentials: 'include',
|
return value;
|
||||||
body: JSON.stringify({ p_activity: activity, p_view: view }),
|
|
||||||
});
|
});
|
||||||
if (!res.ok) return false;
|
permInflight.set(key, p);
|
||||||
return res.json() as Promise<boolean>;
|
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 }),
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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]));
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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> = {};
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
+45
-19
@@ -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(
|
||||||
params,
|
withOrgPaths(`SELECT id, name, parent_id, metadata FROM morbac.orgs ${expr ? `WHERE ${expr}` : ''}`),
|
||||||
|
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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -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', {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
+64
-20
@@ -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',
|
||||||
@@ -39,13 +45,13 @@ export function createUserCrudRoutes(db: Pool, checkLimit?: CheckLimitFn) {
|
|||||||
db.query<{ allowed: boolean }>('SELECT morbac.is_allowed($1::UUID, NULL, $2, $3) AS allowed',
|
db.query<{ allowed: boolean }>('SELECT morbac.is_allowed($1::UUID, NULL, $2, $3) AS allowed',
|
||||||
[user.id, 'read', 'users']),
|
[user.id, 'read', 'users']),
|
||||||
]);
|
]);
|
||||||
const canSeeSystem = sysRes.rows[0]?.allowed ?? false;
|
const canSeeSystem = sysRes.rows[0]?.allowed ?? false;
|
||||||
const canSeeService = svcRes.rows[0]?.allowed ?? false;
|
const canSeeService = svcRes.rows[0]?.allowed ?? false;
|
||||||
const canReadGlobally = globalRes.rows[0]?.allowed ?? false;
|
const 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
@@ -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();
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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)',
|
||||||
|
|||||||
@@ -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));
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-7
@@ -5,17 +5,21 @@ 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' } },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const roleResponse = {
|
export const roleResponse = {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
id: { type: 'string', format: 'uuid' },
|
id: { type: 'string', format: 'uuid' },
|
||||||
org_id: { type: 'string', format: 'uuid' },
|
org_id: { type: 'string', format: 'uuid' },
|
||||||
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'] },
|
||||||
|
|||||||
Reference in New Issue
Block a user