14 Commits
Author SHA1 Message Date
marc 11ef1715ab build: count files without a failing arithmetic command
release / release (push) Canceled after 0s
Under set -e, ((n++)) with n at 0 evaluates to 0 and exits the script. Bash
3.2 on macOS ignores that case, so local builds passed while the release
runner's bash 5 stopped silently on the first file.
2026-10-15 08:03:53 +02:00
marc 7e59978f07 ci: fetch the release tag with basic auth
GitHub's git endpoint rejects a bearer header; it takes the job token as the
password of x-access-token, as actions/checkout does. The release tag is also
resolved in the job environment.
2026-10-15 08:00:51 +02:00
marc 4ffd755fe7 ci: resolve the release tag in the job environment 2026-10-15 07:59:08 +02:00
marc acfe534938 ci: release without third-party actions; manual dispatch for an existing tag
The organisation allows only its own actions, so the release job failed at
startup. Checkout uses git with the job token and Node comes from the official
tarball verified against its SHA-256. A manual dispatch builds an existing tag,
so a tag cut before this change is released without moving it.
2026-10-15 07:54:52 +02:00
marc 1cf23c37ce fix: is_allowed_nocache resolves roles once per org, not per rule
release / release (push) Canceled after 0s
Steps 1, 2, 4 and 5 called get_comprehensive_roles() for every row of
morbac.rules (and org_in_scope() per row), so a decision cost grew with
every tenant's rules: ~240 ms with 2376 rules across 27 orgs. Roles are now
resolved once per org where the user can hold one and that has a rule for
the request, rules are read through those (org, role) pairs, and
org_in_scope() runs once per (org, scope) pair. Decisions are unchanged.

Release 1.0.1 with the 1.0.0 -> 1.0.1 upgrade script. Test 17 compares
every decision with the 1.0.0 body on a seeded random fixture and checks
the cost no longer follows unrelated tenants' rules. bump_version.sh no
longer rewrites the PGXN meta-spec version.
2026-10-14 23:41:00 +02:00
marc 3e8380d4f7 build: release on GitHub, crudylabs/pgmorbac and pgmorbac.crudy.fr
The signed distribution is published as a GitHub release by the release
workflow on v* tags, replacing the Gitea release and its Drone pipeline.
The release body and the dist README take the homepage from META.json.
2026-10-03 15:32:06 +02:00
marcandClaude Opus 5.5 1546e272d8 docs: refresh brand logos (corrected wordmark layout)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 11:15:48 +02:00
marc 493c2ec429 refactor: the npm package moves to pgmorbac-node
This repository is the SQL extension only. @crudy/pgmorbac is published from
pgmorbac-node, which packs the SQL of each signed release.
2026-10-03 09:57:08 +02:00
marc cfe370f761 docs: release process covers the npm package
bump_version.sh also bumps package.json, so prepack's version check holds.
2026-10-03 02:19:06 +02:00
marc 4b725b16e1 fix: npm-normalised repository url 2026-10-03 01:35:27 +02:00
marc 8385cea640 feat: npm package @crudy/pgmorbac ships the extension SQL
The package carries the install and upgrade scripts, built from src/ on prepack
(sql/ is not committed), with extensionSql(), upgradeScripts(), version and sqlDir.
2026-10-02 23:59:58 +02:00
marc 697e8bd682 docs: brand logo header and social preview image 2026-10-02 23:56:33 +02:00
marc 13dab7e614 docs: default branch is master 2026-10-02 22:50:56 +02:00
marc e51bbf2312 docs: GitHub (crudylabs) is the public home for source and issues 2026-10-02 21:24:17 +02:00
21 changed files with 1127 additions and 192 deletions
-34
View File
@@ -1,34 +0,0 @@
---
kind: pipeline
type: docker
name: release
# Cuts the signed PGXN distribution and publishes it as a Gitea release.
# Triggered by version tags (v<major>.<minor>.<patch>).
#
# Repo must be Trusted in Drone. Required secrets:
# pgmorbac_release_key Ed25519 private key PEM (content, not a path)
# gitea_token Gitea API token with write access to crudy/pgmorbac
trigger:
event:
- tag
ref:
- refs/tags/v*
steps:
- name: build-and-release
image: node:24-alpine
environment:
PGMORBAC_RELEASE_KEY_PEM:
from_secret: pgmorbac_release_key
GITEA_TOKEN:
from_secret: gitea_token
GITEA_URL: https://git.villains.fr
GITEA_REPO: crudy/pgmorbac
commands:
- apk add --no-cache zip bash
- npm install --no-audit --no-fund
- umask 077 && printf '%s\n' "$PGMORBAC_RELEASE_KEY_PEM" > /tmp/release-key.pem
- PGMORBAC_RELEASE_KEY=/tmp/release-key.pem node scripts/build-dist.mjs
- node scripts/gitea-release.mjs
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 14 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

+59
View File
@@ -0,0 +1,59 @@
# Cuts the signed PGXN distribution and publishes it as a GitHub release on
# version tags (v<major>.<minor>.<patch>), or for an existing tag on manual
# dispatch. Secret: PGMORBAC_RELEASE_KEY, the Ed25519 private key PEM (content,
# not a path). The organisation only allows its own actions, so checkout and
# Node setup are plain shell: git over the job token, and the official Node
# tarball checked against its published SHA-256.
name: release
on:
push:
tags: ['v*']
workflow_dispatch:
inputs:
tag:
description: Existing version tag to release (e.g. v1.0.1)
required: true
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
env:
NODE_VERSION: v24.12.0
RELEASE_REF: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
steps:
- name: Check out the tag
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
case "$RELEASE_REF" in v[0-9]*.[0-9]*.[0-9]*) ;; *) echo "not a version tag: $RELEASE_REF" >&2; exit 1 ;; esac
git init -q .
basic=$(printf 'x-access-token:%s' "$GITHUB_TOKEN" | base64 -w0)
git -c http.extraheader="AUTHORIZATION: basic $basic" fetch -q --depth 1 \
"https://github.com/$GITHUB_REPOSITORY" "refs/tags/$RELEASE_REF:refs/tags/$RELEASE_REF"
git checkout -q "refs/tags/$RELEASE_REF"
- name: Install Node
run: |
base="https://nodejs.org/dist/$NODE_VERSION"
file="node-$NODE_VERSION-linux-x64.tar.xz"
curl -fsSLO "$base/$file"
curl -fsSL "$base/SHASUMS256.txt" | grep " $file\$" | sha256sum -c -
mkdir -p "$RUNNER_TEMP/node"
tar -xJf "$file" -C "$RUNNER_TEMP/node" --strip-components=1
rm "$file"
echo "$RUNNER_TEMP/node/bin" >> "$GITHUB_PATH"
- run: npm install --no-audit --no-fund
- name: Build and sign
env:
PGMORBAC_RELEASE_KEY_PEM: ${{ secrets.PGMORBAC_RELEASE_KEY }}
run: |
umask 077
printf '%s\n' "$PGMORBAC_RELEASE_KEY_PEM" > "$RUNNER_TEMP/release-key.pem"
PGMORBAC_RELEASE_KEY="$RUNNER_TEMP/release-key.pem" node scripts/build-dist.mjs
- name: Publish
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: node scripts/github-release.mjs
+3
View File
@@ -20,3 +20,6 @@ Thumbs.db
node_modules/ node_modules/
dist/ dist/
package-lock.json package-lock.json
# Upgrade scripts are sources, not build output
!src/upgrades/pgmorbac--*.sql
+18 -1
View File
@@ -5,6 +5,22 @@ All notable changes to this project 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).
## [1.0.1] - 2026-10-14
### Fixed
- `is_allowed_nocache()` (and so every uncached `is_allowed()`) resolved the
user's roles once per row of the whole `rules` table, calling
`get_comprehensive_roles()` and `org_in_scope()` for every rule of every
tenant: about 240 ms per decision with 2376 rules across 27 orgs. Roles are
now resolved once per org where the user can hold one and that has a rule for
the requested activity and view, and matching rules are read through those
(org, role) pairs; `org_in_scope()` runs once per (org, scope) pair. Cost no
longer grows with other tenants' rules. Decisions are unchanged, checked
against the 1.0.0 implementation on a randomized fixture
(`tests/17_is_allowed_equivalence.sql`).
- `tools/bump_version.sh` no longer rewrites the PGXN `meta-spec` version in
`META.json`.
## [1.0.0] - 2026-07-25 ## [1.0.0] - 2026-07-25
### Added ### Added
@@ -55,4 +71,5 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`rls_check(activity, view, row_org_id[, row_user_id])` for row-scoped tables `rls_check(activity, view, row_org_id[, row_user_id])` for row-scoped tables
where a NULL `row_org_id` means the record is unattributed where a NULL `row_org_id` means the record is unattributed
[1.0.0]: https://git.villains.fr/crudy/pgmorbac/releases/tag/v1.0.0 [1.0.1]: https://github.com/crudylabs/pgmorbac/releases/tag/v1.0.1
[1.0.0]: https://github.com/crudylabs/pgmorbac/releases/tag/v1.0.0
+7 -7
View File
@@ -2,7 +2,7 @@
"name": "pgmorbac", "name": "pgmorbac",
"abstract": "Multi-OrBAC: Organization-Based Access Control with multi-organization support", "abstract": "Multi-OrBAC: Organization-Based Access Control with multi-organization support",
"description": "A PostgreSQL extension implementing the Multi-OrBAC access control model \u2014 enabling organization-centric, context-aware, and hierarchical access control with delegation, separation of duty, and cross-organizational policies.", "description": "A PostgreSQL extension implementing the Multi-OrBAC access control model \u2014 enabling organization-centric, context-aware, and hierarchical access control with delegation, separation of duty, and cross-organizational policies.",
"version": "1.0.0", "version": "1.0.1",
"maintainer": [ "maintainer": [
"Marc VILLAIN <marc.villain@epita.fr>" "Marc VILLAIN <marc.villain@epita.fr>"
], ],
@@ -10,8 +10,8 @@
"provides": { "provides": {
"pgmorbac": { "pgmorbac": {
"abstract": "Multi-OrBAC access control extension", "abstract": "Multi-OrBAC access control extension",
"file": "pgmorbac--1.0.0.sql", "file": "pgmorbac--1.0.1.sql",
"version": "1.0.0" "version": "1.0.1"
} }
}, },
"prereqs": { "prereqs": {
@@ -22,13 +22,13 @@
} }
}, },
"resources": { "resources": {
"homepage": "https://pgmorbac.villains.fr", "homepage": "https://pgmorbac.crudy.fr",
"bugtracker": { "bugtracker": {
"web": "https://git.villains.fr/crudy/pgmorbac/issues" "web": "https://github.com/crudylabs/pgmorbac/issues"
}, },
"repository": { "repository": {
"url": "https://git.villains.fr/crudy/pgmorbac.git", "url": "https://github.com/crudylabs/pgmorbac.git",
"web": "https://git.villains.fr/crudy/pgmorbac", "web": "https://github.com/crudylabs/pgmorbac",
"type": "git" "type": "git"
} }
}, },
+27 -11
View File
@@ -1,12 +1,20 @@
# pgmorbac <h1 align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset=".github/brand/logo-dark.svg">
<img alt="pgmorbac" src=".github/brand/logo-light.svg" height="110">
</picture>
</h1>
**Multi-Organization Based Access Control for PostgreSQL** <p align="center"><strong>Multi-Organization Based Access Control for PostgreSQL</strong></p>
<p align="center">
<a href="LICENSE"><img alt="License: MIT" src="https://img.shields.io/badge/License-MIT-10b981.svg"></a>
<a href="https://www.npmjs.com/package/@crudy/pgmorbac"><img alt="npm" src="https://img.shields.io/npm/v/@crudy/pgmorbac.svg?color=10b981"></a>
<a href="https://www.postgresql.org/"><img alt="PostgreSQL 13+" src="https://img.shields.io/badge/PostgreSQL-13%2B-047857.svg"></a>
</p>
A PostgreSQL extension implementing the Multi-OrBAC access control model - enabling organization-centric, context-aware, and hierarchical access control with advanced features like delegation, separation of duty, and cross-organizational policies. A PostgreSQL extension implementing the Multi-OrBAC access control model - enabling organization-centric, context-aware, and hierarchical access control with advanced features like delegation, separation of duty, and cross-organizational policies.
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![PostgreSQL 13+](https://img.shields.io/badge/PostgreSQL-13%2B-blue.svg)](https://www.postgresql.org/)
## Features ## Features
- Multi-organization with organizational hierarchy - Multi-organization with organizational hierarchy
@@ -34,7 +42,7 @@ A PostgreSQL extension implementing the Multi-OrBAC access control model - enabl
```bash ```bash
# Clone repository # Clone repository
git clone https://git.villains.fr/crudy/pgmorbac.git git clone https://github.com/crudylabs/pgmorbac.git
cd pgmorbac cd pgmorbac
# Build and install extension # Build and install extension
@@ -59,16 +67,24 @@ sudo ./tools/install.sh
```bash ```bash
# Build versioned file from source # Build versioned file from source
make build # Concatenates src/ files into pgmorbac--1.0.0.sql make build # Concatenates src/ files into pgmorbac--1.0.1.sql
# Copy files to PostgreSQL extension directory # Copy files to PostgreSQL extension directory
sudo cp pgmorbac.control $(pg_config --sharedir)/extension/ sudo cp pgmorbac.control $(pg_config --sharedir)/extension/
sudo cp pgmorbac--1.0.0.sql $(pg_config --sharedir)/extension/ sudo cp pgmorbac--1.0.1.sql $(pg_config --sharedir)/extension/
# Enable in PostgreSQL # Enable in PostgreSQL
psql -d mydb -c "CREATE EXTENSION pgmorbac;" psql -d mydb -c "CREATE EXTENSION pgmorbac;"
``` ```
### From Node.js (npm)
[`@crudy/pgmorbac`](https://www.npmjs.com/package/@crudy/pgmorbac) ships the
install and upgrade scripts of each signed release for Node projects and migration
runners, and [`@crudy/pgmorbac-fastify`](https://www.npmjs.com/package/@crudy/pgmorbac-fastify)
adds Fastify helpers on top. Sources: [pgmorbac-node](https://github.com/crudylabs/pgmorbac-node),
[pgmorbac-fastify](https://github.com/crudylabs/pgmorbac-fastify).
## Quick Start ## Quick Start
```sql ```sql
@@ -257,7 +273,7 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
```bash ```bash
git clone https://git.villains.fr/crudy/pgmorbac.git git clone https://github.com/crudylabs/pgmorbac.git
cd pgmorbac cd pgmorbac
# Edit source files in src/ # Edit source files in src/
@@ -277,8 +293,8 @@ git push --tags
## Support ## Support
- Read the [documentation](https://pgmorbac.villains.fr) - Read the [documentation](https://pgmorbac.crudy.fr)
- Report bugs via [Gitea Issues](https://git.villains.fr/crudy/pgmorbac/issues) - Report bugs via [GitHub Issues](https://github.com/crudylabs/pgmorbac/issues)
- Security issues: see [SECURITY.md](SECURITY.md) - Security issues: see [SECURITY.md](SECURITY.md)
## Comparison with Traditional RBAC ## Comparison with Traditional RBAC
+15 -3
View File
@@ -56,17 +56,29 @@ dropdb mytest
## Release Process ## Release Process
1. Update version in `pgmorbac.control` 1. `./tools/bump_version.sh X.Y.Z` (control file, META.json, README)
2. Update `CHANGELOG.md` 2. Update `CHANGELOG.md`
3. Run `make build` and `make check` 3. Run `make build` and `make check`
4. Commit and tag: 4. Commit and tag:
```bash ```bash
git commit -m "Release vX.Y.Z" git commit -am "release: X.Y.Z"
git tag vX.Y.Z git tag vX.Y.Z
git push origin main --tags git push origin master --tags
``` ```
5. Signed SQL distribution, attached to the GitHub release. The `release`
workflow does this on the tag, signing with the `PGMORBAC_RELEASE_KEY`
repository secret. By hand:
```bash
PGMORBAC_RELEASE_KEY=<path to the Ed25519 key> GITHUB_REPOSITORY=crudylabs/pgmorbac \
GITHUB_TOKEN=<token> npm run release
```
6. The npm package `@crudy/pgmorbac` is released from the `pgmorbac-node`
repository, which packs the SQL of this signed release.
## Makefile Targets ## Makefile Targets
```bash ```bash
+1 -1
View File
@@ -6,7 +6,7 @@
"description": "Release tooling for the pgmorbac PostgreSQL extension (build + sign + publish the PGXN distribution).", "description": "Release tooling for the pgmorbac PostgreSQL extension (build + sign + publish the PGXN distribution).",
"scripts": { "scripts": {
"dist": "node scripts/build-dist.mjs", "dist": "node scripts/build-dist.mjs",
"release": "node scripts/build-dist.mjs && node scripts/gitea-release.mjs" "release": "node scripts/build-dist.mjs && node scripts/github-release.mjs"
}, },
"devDependencies": { "devDependencies": {
"jose": "^5" "jose": "^5"
+1 -1
View File
@@ -1,6 +1,6 @@
# pgmorbac extension # pgmorbac extension
# Multi-OrBAC access control model for PostgreSQL # Multi-OrBAC access control model for PostgreSQL
comment = 'Multi-OrBAC: Organization-Based Access Control with multi-organization support' comment = 'Multi-OrBAC: Organization-Based Access Control with multi-organization support'
default_version = '1.0.0' default_version = '1.0.1'
relocatable = false relocatable = false
schema = morbac schema = morbac
+1 -1
View File
@@ -66,7 +66,7 @@ include $(PGXS)
writeFileSync(join(stageDir, 'README-INSTALL.md'), `# Installing pgmorbac ${version} writeFileSync(join(stageDir, 'README-INSTALL.md'), `# Installing pgmorbac ${version}
Full documentation: https://pgmorbac.villains.fr Full documentation: ${meta.resources.homepage}
## As a PostgreSQL extension (recommended) ## As a PostgreSQL extension (recommended)
-84
View File
@@ -1,84 +0,0 @@
#!/usr/bin/env node
// Creates (or reuses) release v<version> on Gitea and attaches the dist assets.
// Run after build-dist.mjs. Requires GITEA_URL, GITEA_REPO, GITEA_TOKEN.
import { readFileSync, existsSync } from 'fs';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const version = readFileSync(join(root, 'pgmorbac.control'), 'utf8').match(/default_version\s*=\s*'([^']+)'/)?.[1];
if (!version) {
console.error('[gitea-release] could not read default_version from pgmorbac.control');
process.exit(1);
}
const base = process.env.GITEA_URL;
const repo = process.env.GITEA_REPO;
const token = process.env.GITEA_TOKEN;
if (!base || !repo || !token) {
console.error('[gitea-release] GITEA_URL, GITEA_REPO and GITEA_TOKEN are required');
process.exit(1);
}
const distDir = join(root, 'dist');
const files = [
`pgmorbac-${version}.zip`,
`pgmorbac-${version}.zip.sha256`,
`pgmorbac-${version}.manifest.jwt`,
`pgmorbac-${version}.pub.pem`,
];
for (const f of files) {
if (!existsSync(join(distDir, f))) {
console.error(`[gitea-release] Missing artifact ${f} - run build-dist.mjs first`);
process.exit(1);
}
}
const api = (path) => `${base}/api/v1/repos/${repo}${path}`;
const headers = { Authorization: `token ${token}` };
async function ensureRelease() {
const create = await fetch(api('/releases'), {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({
tag_name: `v${version}`,
name: `pgmorbac ${version}`,
body: `PGXN-compatible SQL distribution. Verify with sha256sum -c and the signed manifest (public key attached; see https://pgmorbac.villains.fr/download).`,
draft: false,
prerelease: false,
}),
});
if (create.ok) return (await create.json()).id;
if (create.status !== 409) {
console.error(`[gitea-release] Release creation failed: ${create.status} ${await create.text()}`);
process.exit(1);
}
const existing = await fetch(api(`/releases/tags/v${version}`), { headers });
if (!existing.ok) {
console.error(`[gitea-release] Release exists but lookup failed: ${existing.status}`);
process.exit(1);
}
return (await existing.json()).id;
}
const releaseId = await ensureRelease();
for (const f of files) {
const form = new FormData();
form.set('attachment', new Blob([readFileSync(join(distDir, f))]), f);
const res = await fetch(api(`/releases/${releaseId}/assets?name=${encodeURIComponent(f)}`), {
method: 'POST', headers, body: form,
});
if (!res.ok) {
const text = await res.text();
if (res.status === 409 || text.includes('already exist')) {
console.log(`[gitea-release] ${f} already attached - skipping`);
continue;
}
console.error(`[gitea-release] Upload of ${f} failed: ${res.status} ${text}`);
process.exit(1);
}
console.log(`[gitea-release] Attached ${f}`);
}
console.log(`[gitea-release] Release v${version} ready on ${repo}`);
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env node
// Creates (or reuses) release v<version> on GitHub and attaches the dist assets.
// Run after build-dist.mjs. Requires GITHUB_REPOSITORY (owner/name) and GITHUB_TOKEN.
import { readFileSync, existsSync } from 'fs';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const version = readFileSync(join(root, 'pgmorbac.control'), 'utf8').match(/default_version\s*=\s*'([^']+)'/)?.[1];
if (!version) {
console.error('[github-release] could not read default_version from pgmorbac.control');
process.exit(1);
}
const homepage = JSON.parse(readFileSync(join(root, 'META.json'), 'utf8')).resources.homepage;
const repo = process.env.GITHUB_REPOSITORY;
const token = process.env.GITHUB_TOKEN;
if (!repo || !token) {
console.error('[github-release] GITHUB_REPOSITORY and GITHUB_TOKEN are required');
process.exit(1);
}
const distDir = join(root, 'dist');
const files = [
`pgmorbac-${version}.zip`,
`pgmorbac-${version}.zip.sha256`,
`pgmorbac-${version}.manifest.jwt`,
`pgmorbac-${version}.pub.pem`,
];
for (const f of files) {
if (!existsSync(join(distDir, f))) {
console.error(`[github-release] Missing artifact ${f} - run build-dist.mjs first`);
process.exit(1);
}
}
const headers = { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' };
const api = (path) => `https://api.github.com/repos/${repo}${path}`;
async function ensureRelease() {
const existing = await fetch(api(`/releases/tags/v${version}`), { headers });
if (existing.ok) return existing.json();
if (existing.status !== 404) {
console.error(`[github-release] Release lookup failed: ${existing.status}`);
process.exit(1);
}
const create = await fetch(api('/releases'), {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({
tag_name: `v${version}`,
name: `pgmorbac ${version}`,
body: `PGXN-compatible SQL distribution. Verify with sha256sum -c and the signed manifest (public key attached; see ${homepage}/download).`,
draft: false,
prerelease: false,
}),
});
if (!create.ok) {
console.error(`[github-release] Release creation failed: ${create.status} ${await create.text()}`);
process.exit(1);
}
return create.json();
}
const release = await ensureRelease();
const attached = new Set((release.assets ?? []).map((a) => a.name));
for (const f of files) {
if (attached.has(f)) {
console.log(`[github-release] ${f} already attached - skipping`);
continue;
}
const res = await fetch(`https://uploads.github.com/repos/${repo}/releases/${release.id}/assets?name=${encodeURIComponent(f)}`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/octet-stream' },
body: readFileSync(join(distDir, f)),
});
if (!res.ok) {
console.error(`[github-release] Upload of ${f} failed: ${res.status} ${await res.text()}`);
process.exit(1);
}
console.log(`[github-release] Attached ${f}`);
}
console.log(`[github-release] Release v${version} ready on ${repo}`);
+117 -44
View File
@@ -23,6 +23,13 @@
-- is a specific org, or NULL for unattributed objects. -- is a specific org, or NULL for unattributed objects.
-- - global_rules apply to every org, unattributed included. -- - global_rules apply to every org, unattributed included.
-- Role-bound rules (rules, cross_org_rules) apply only in orgs where the user
-- holds a role. Those roles are resolved once per org that has a direct
-- assignment, delegation or derived role for the user and a rule matching the
-- request, then the matching rules are read through that (org, role) set.
-- Resolving roles per rule row instead made the cost grow with every rule of
-- every tenant.
CREATE OR REPLACE FUNCTION morbac.is_allowed_nocache( CREATE OR REPLACE FUNCTION morbac.is_allowed_nocache(
p_user_id UUID, p_user_id UUID,
p_org_id UUID, p_org_id UUID,
@@ -39,25 +46,106 @@ DECLARE
v_max_prohibition_priority INTEGER := NULL; v_max_prohibition_priority INTEGER := NULL;
v_max_permission_priority INTEGER := NULL; v_max_permission_priority INTEGER := NULL;
v_is_system_principal BOOLEAN; v_is_system_principal BOOLEAN;
v_activities TEXT[];
v_views TEXT[];
v_held_org UUID[];
v_held_role UUID[];
v_local_modality morbac.modality[];
v_local_context UUID[];
v_local_prio INTEGER[];
v_cross_modality morbac.modality[];
v_cross_context UUID[];
v_cross_prio INTEGER[];
BEGIN BEGIN
v_is_system_principal := EXISTS ( v_is_system_principal := EXISTS (
SELECT 1 FROM morbac.system_principals WHERE user_id = p_user_id SELECT 1 FROM morbac.system_principals WHERE user_id = p_user_id
); );
v_activities := ARRAY(SELECT activity FROM morbac.get_effective_activities(p_activity));
v_views := ARRAY(SELECT view FROM morbac.get_effective_views(p_view));
-- Roles the user holds, per org. get_comprehensive_roles() returns nothing
-- in an org without a direct assignment, delegation or derived role for the
-- user, and its result only matters where a rule matches the request.
WITH orgs AS MATERIALIZED (
SELECT o.org_id
FROM (
SELECT ur.org_id FROM morbac.user_roles ur WHERE ur.user_id = p_user_id
UNION
SELECT d.org_id FROM morbac.delegations d WHERE d.delegatee_id = p_user_id
UNION
SELECT ro.org_id FROM morbac.derived_roles dr
INNER JOIN morbac.roles ro ON ro.id = dr.role_id
) o
WHERE EXISTS (
SELECT 1 FROM morbac.rules r
WHERE r.org_id = o.org_id
AND r.activity = ANY (v_activities)
AND r.view = ANY (v_views)
)
OR EXISTS (
SELECT 1 FROM morbac.cross_org_rules cr
WHERE cr.source_org_id = o.org_id
AND cr.target_org_id = p_org_id
AND cr.activity = ANY (v_activities)
AND cr.view = ANY (v_views)
)
),
held AS (
SELECT DISTINCT c.org_id, g.role_id
FROM orgs c
CROSS JOIN LATERAL morbac.get_comprehensive_roles(p_user_id, c.org_id) g
)
SELECT array_agg(h.org_id ORDER BY h.org_id, h.role_id),
array_agg(h.role_id ORDER BY h.org_id, h.role_id)
INTO v_held_org, v_held_role
FROM held h;
-- Local rules applicable at p_org_id (prohibitions are not collected for
-- system principals, which skip them)
WITH matched AS MATERIALIZED (
SELECT r.org_id, r.scope, r.modality, r.context_id, COALESCE(r.priority, 0) AS prio
FROM unnest(v_held_org, v_held_role) AS h(org_id, role_id)
INNER JOIN morbac.rules r ON r.org_id = h.org_id AND r.role_id = h.role_id
WHERE r.activity = ANY (v_activities)
AND r.view = ANY (v_views)
AND (r.modality = 'permission' OR (r.modality = 'prohibition' AND NOT v_is_system_principal))
AND morbac.is_rule_valid(r.valid_from, r.valid_until)
),
in_scope AS MATERIALIZED (
SELECT s.org_id, s.scope
FROM (SELECT DISTINCT m.org_id, m.scope FROM matched m) s
WHERE morbac.org_in_scope(p_org_id, s.org_id, s.scope)
)
SELECT array_agg(m.modality ORDER BY m.prio DESC, m.modality, m.context_id),
array_agg(m.context_id ORDER BY m.prio DESC, m.modality, m.context_id),
array_agg(m.prio ORDER BY m.prio DESC, m.modality, m.context_id)
INTO v_local_modality, v_local_context, v_local_prio
FROM matched m
INNER JOIN in_scope s ON s.org_id = m.org_id AND s.scope = m.scope;
-- Cross-org rules into p_org_id whose role the user holds in the source org
SELECT array_agg(cr.modality ORDER BY COALESCE(cr.priority, 0) DESC, cr.modality, cr.context_id),
array_agg(cr.context_id ORDER BY COALESCE(cr.priority, 0) DESC, cr.modality, cr.context_id),
array_agg(COALESCE(cr.priority, 0) ORDER BY COALESCE(cr.priority, 0) DESC, cr.modality, cr.context_id)
INTO v_cross_modality, v_cross_context, v_cross_prio
FROM unnest(v_held_org, v_held_role) AS h(org_id, role_id)
INNER JOIN morbac.cross_org_rules cr ON cr.source_org_id = h.org_id AND cr.role_id = h.role_id
WHERE cr.target_org_id = p_org_id
AND cr.activity = ANY (v_activities)
AND cr.view = ANY (v_views)
AND (cr.modality = 'permission' OR (cr.modality = 'prohibition' AND NOT v_is_system_principal))
AND morbac.is_rule_valid(cr.valid_from, cr.valid_until);
-- STEPS 1-3.5: Prohibitions - skipped entirely for system principals -- STEPS 1-3.5: Prohibitions - skipped entirely for system principals
IF NOT v_is_system_principal THEN IF NOT v_is_system_principal THEN
-- STEP 1: Local prohibitions - find the highest-priority applicable one -- STEP 1: Local prohibitions - find the highest-priority applicable one
FOR v_rule IN FOR v_rule IN
SELECT r.context_id, COALESCE(r.priority, 0) AS prio SELECT u.context_id, u.prio
FROM morbac.rules r FROM unnest(v_local_modality, v_local_context, v_local_prio) AS u(modality, context_id, prio)
WHERE morbac.org_in_scope(p_org_id, r.org_id, r.scope) WHERE u.modality = 'prohibition'
AND r.modality = 'prohibition' ORDER BY u.prio DESC
AND r.activity IN (SELECT activity FROM morbac.get_effective_activities(p_activity))
AND r.view IN (SELECT view FROM morbac.get_effective_views(p_view))
AND r.role_id IN (SELECT role_id FROM morbac.get_comprehensive_roles(p_user_id, r.org_id))
AND morbac.is_rule_valid(r.valid_from, r.valid_until)
ORDER BY COALESCE(r.priority, 0) DESC
LOOP LOOP
IF morbac.eval_context(v_rule.context_id) THEN IF morbac.eval_context(v_rule.context_id) THEN
v_max_prohibition_priority := v_rule.prio; v_max_prohibition_priority := v_rule.prio;
@@ -67,15 +155,10 @@ BEGIN
-- STEP 2: Cross-org prohibitions - update max if a higher priority is found -- STEP 2: Cross-org prohibitions - update max if a higher priority is found
FOR v_rule IN FOR v_rule IN
SELECT cr.context_id, COALESCE(cr.priority, 0) AS prio SELECT u.context_id, u.prio
FROM morbac.cross_org_rules cr FROM unnest(v_cross_modality, v_cross_context, v_cross_prio) AS u(modality, context_id, prio)
WHERE cr.target_org_id = p_org_id WHERE u.modality = 'prohibition'
AND cr.modality = 'prohibition' ORDER BY u.prio DESC
AND cr.activity IN (SELECT activity FROM morbac.get_effective_activities(p_activity))
AND cr.view IN (SELECT view FROM morbac.get_effective_views(p_view))
AND cr.role_id IN (SELECT role_id FROM morbac.get_comprehensive_roles(p_user_id, cr.source_org_id))
AND morbac.is_rule_valid(cr.valid_from, cr.valid_until)
ORDER BY COALESCE(cr.priority, 0) DESC
LOOP LOOP
IF morbac.eval_context(v_rule.context_id) THEN IF morbac.eval_context(v_rule.context_id) THEN
IF v_max_prohibition_priority IS NULL OR v_rule.prio > v_max_prohibition_priority THEN IF v_max_prohibition_priority IS NULL OR v_rule.prio > v_max_prohibition_priority THEN
@@ -92,8 +175,8 @@ BEGIN
WHERE ur.user_id = p_user_id WHERE ur.user_id = p_user_id
AND ur.org_id IS NOT DISTINCT FROM p_org_id AND ur.org_id IS NOT DISTINCT FROM p_org_id
AND ur.modality = 'prohibition' AND ur.modality = 'prohibition'
AND ur.activity IN (SELECT activity FROM morbac.get_effective_activities(p_activity)) AND ur.activity = ANY (v_activities)
AND ur.view IN (SELECT view FROM morbac.get_effective_views(p_view)) AND ur.view = ANY (v_views)
AND morbac.is_rule_valid(ur.valid_from, ur.valid_until) AND morbac.is_rule_valid(ur.valid_from, ur.valid_until)
ORDER BY COALESCE(ur.priority, 0) DESC ORDER BY COALESCE(ur.priority, 0) DESC
LOOP LOOP
@@ -111,8 +194,8 @@ BEGIN
FROM morbac.global_rules gr FROM morbac.global_rules gr
WHERE (gr.user_id IS NULL OR gr.user_id = p_user_id) WHERE (gr.user_id IS NULL OR gr.user_id = p_user_id)
AND gr.modality = 'prohibition' AND gr.modality = 'prohibition'
AND (gr.activity IS NULL OR gr.activity IN (SELECT activity FROM morbac.get_effective_activities(p_activity))) AND (gr.activity IS NULL OR gr.activity = ANY (v_activities))
AND (gr.view IS NULL OR gr.view IN (SELECT view FROM morbac.get_effective_views(p_view))) AND (gr.view IS NULL OR gr.view = ANY (v_views))
AND morbac.is_rule_valid(gr.valid_from, gr.valid_until) AND morbac.is_rule_valid(gr.valid_from, gr.valid_until)
ORDER BY COALESCE(gr.priority, 0) DESC ORDER BY COALESCE(gr.priority, 0) DESC
LOOP LOOP
@@ -128,15 +211,10 @@ BEGIN
-- STEP 4: Local permissions - find the highest-priority applicable one -- STEP 4: Local permissions - find the highest-priority applicable one
FOR v_rule IN FOR v_rule IN
SELECT r.context_id, COALESCE(r.priority, 0) AS prio SELECT u.context_id, u.prio
FROM morbac.rules r FROM unnest(v_local_modality, v_local_context, v_local_prio) AS u(modality, context_id, prio)
WHERE morbac.org_in_scope(p_org_id, r.org_id, r.scope) WHERE u.modality = 'permission'
AND r.modality = 'permission' ORDER BY u.prio DESC
AND r.activity IN (SELECT activity FROM morbac.get_effective_activities(p_activity))
AND r.view IN (SELECT view FROM morbac.get_effective_views(p_view))
AND r.role_id IN (SELECT role_id FROM morbac.get_comprehensive_roles(p_user_id, r.org_id))
AND morbac.is_rule_valid(r.valid_from, r.valid_until)
ORDER BY COALESCE(r.priority, 0) DESC
LOOP LOOP
IF morbac.eval_context(v_rule.context_id) THEN IF morbac.eval_context(v_rule.context_id) THEN
v_max_permission_priority := v_rule.prio; v_max_permission_priority := v_rule.prio;
@@ -146,15 +224,10 @@ BEGIN
-- STEP 5: Cross-org permissions - update max if higher found -- STEP 5: Cross-org permissions - update max if higher found
FOR v_rule IN FOR v_rule IN
SELECT cr.context_id, COALESCE(cr.priority, 0) AS prio SELECT u.context_id, u.prio
FROM morbac.cross_org_rules cr FROM unnest(v_cross_modality, v_cross_context, v_cross_prio) AS u(modality, context_id, prio)
WHERE cr.target_org_id = p_org_id WHERE u.modality = 'permission'
AND cr.modality = 'permission' ORDER BY u.prio DESC
AND cr.activity IN (SELECT activity FROM morbac.get_effective_activities(p_activity))
AND cr.view IN (SELECT view FROM morbac.get_effective_views(p_view))
AND cr.role_id IN (SELECT role_id FROM morbac.get_comprehensive_roles(p_user_id, cr.source_org_id))
AND morbac.is_rule_valid(cr.valid_from, cr.valid_until)
ORDER BY COALESCE(cr.priority, 0) DESC
LOOP LOOP
IF morbac.eval_context(v_rule.context_id) THEN IF morbac.eval_context(v_rule.context_id) THEN
IF v_max_permission_priority IS NULL OR v_rule.prio > v_max_permission_priority THEN IF v_max_permission_priority IS NULL OR v_rule.prio > v_max_permission_priority THEN
@@ -171,8 +244,8 @@ BEGIN
WHERE ur.user_id = p_user_id WHERE ur.user_id = p_user_id
AND ur.org_id IS NOT DISTINCT FROM p_org_id AND ur.org_id IS NOT DISTINCT FROM p_org_id
AND ur.modality = 'permission' AND ur.modality = 'permission'
AND ur.activity IN (SELECT activity FROM morbac.get_effective_activities(p_activity)) AND ur.activity = ANY (v_activities)
AND ur.view IN (SELECT view FROM morbac.get_effective_views(p_view)) AND ur.view = ANY (v_views)
AND morbac.is_rule_valid(ur.valid_from, ur.valid_until) AND morbac.is_rule_valid(ur.valid_from, ur.valid_until)
ORDER BY COALESCE(ur.priority, 0) DESC ORDER BY COALESCE(ur.priority, 0) DESC
LOOP LOOP
@@ -190,8 +263,8 @@ BEGIN
FROM morbac.global_rules gr FROM morbac.global_rules gr
WHERE (gr.user_id IS NULL OR gr.user_id = p_user_id) WHERE (gr.user_id IS NULL OR gr.user_id = p_user_id)
AND gr.modality = 'permission' AND gr.modality = 'permission'
AND (gr.activity IS NULL OR gr.activity IN (SELECT activity FROM morbac.get_effective_activities(p_activity))) AND (gr.activity IS NULL OR gr.activity = ANY (v_activities))
AND (gr.view IS NULL OR gr.view IN (SELECT view FROM morbac.get_effective_views(p_view))) AND (gr.view IS NULL OR gr.view = ANY (v_views))
AND morbac.is_rule_valid(gr.valid_from, gr.valid_until) AND morbac.is_rule_valid(gr.valid_from, gr.valid_until)
ORDER BY COALESCE(gr.priority, 0) DESC ORDER BY COALESCE(gr.priority, 0) DESC
LOOP LOOP
+276
View File
@@ -0,0 +1,276 @@
-- pgmorbac 1.0.0 -> 1.0.1
--
-- is_allowed_nocache() resolved the user's roles once per row of the whole
-- rules table, so its cost grew with every tenant's rules. It now resolves
-- them once per org where the user can hold a role, before reading rules.
-- Decisions are unchanged.
-- Role-bound rules (rules, cross_org_rules) apply only in orgs where the user
-- holds a role. Those roles are resolved once per org that has a direct
-- assignment, delegation or derived role for the user and a rule matching the
-- request, then the matching rules are read through that (org, role) set.
-- Resolving roles per rule row instead made the cost grow with every rule of
-- every tenant.
CREATE OR REPLACE FUNCTION morbac.is_allowed_nocache(
p_user_id UUID,
p_org_id UUID,
p_activity TEXT,
p_view TEXT
)
RETURNS BOOLEAN
LANGUAGE plpgsql
STABLE
SECURITY DEFINER
AS $$
DECLARE
v_rule RECORD;
v_max_prohibition_priority INTEGER := NULL;
v_max_permission_priority INTEGER := NULL;
v_is_system_principal BOOLEAN;
v_activities TEXT[];
v_views TEXT[];
v_held_org UUID[];
v_held_role UUID[];
v_local_modality morbac.modality[];
v_local_context UUID[];
v_local_prio INTEGER[];
v_cross_modality morbac.modality[];
v_cross_context UUID[];
v_cross_prio INTEGER[];
BEGIN
v_is_system_principal := EXISTS (
SELECT 1 FROM morbac.system_principals WHERE user_id = p_user_id
);
v_activities := ARRAY(SELECT activity FROM morbac.get_effective_activities(p_activity));
v_views := ARRAY(SELECT view FROM morbac.get_effective_views(p_view));
-- Roles the user holds, per org. get_comprehensive_roles() returns nothing
-- in an org without a direct assignment, delegation or derived role for the
-- user, and its result only matters where a rule matches the request.
WITH orgs AS MATERIALIZED (
SELECT o.org_id
FROM (
SELECT ur.org_id FROM morbac.user_roles ur WHERE ur.user_id = p_user_id
UNION
SELECT d.org_id FROM morbac.delegations d WHERE d.delegatee_id = p_user_id
UNION
SELECT ro.org_id FROM morbac.derived_roles dr
INNER JOIN morbac.roles ro ON ro.id = dr.role_id
) o
WHERE EXISTS (
SELECT 1 FROM morbac.rules r
WHERE r.org_id = o.org_id
AND r.activity = ANY (v_activities)
AND r.view = ANY (v_views)
)
OR EXISTS (
SELECT 1 FROM morbac.cross_org_rules cr
WHERE cr.source_org_id = o.org_id
AND cr.target_org_id = p_org_id
AND cr.activity = ANY (v_activities)
AND cr.view = ANY (v_views)
)
),
held AS (
SELECT DISTINCT c.org_id, g.role_id
FROM orgs c
CROSS JOIN LATERAL morbac.get_comprehensive_roles(p_user_id, c.org_id) g
)
SELECT array_agg(h.org_id ORDER BY h.org_id, h.role_id),
array_agg(h.role_id ORDER BY h.org_id, h.role_id)
INTO v_held_org, v_held_role
FROM held h;
-- Local rules applicable at p_org_id (prohibitions are not collected for
-- system principals, which skip them)
WITH matched AS MATERIALIZED (
SELECT r.org_id, r.scope, r.modality, r.context_id, COALESCE(r.priority, 0) AS prio
FROM unnest(v_held_org, v_held_role) AS h(org_id, role_id)
INNER JOIN morbac.rules r ON r.org_id = h.org_id AND r.role_id = h.role_id
WHERE r.activity = ANY (v_activities)
AND r.view = ANY (v_views)
AND (r.modality = 'permission' OR (r.modality = 'prohibition' AND NOT v_is_system_principal))
AND morbac.is_rule_valid(r.valid_from, r.valid_until)
),
in_scope AS MATERIALIZED (
SELECT s.org_id, s.scope
FROM (SELECT DISTINCT m.org_id, m.scope FROM matched m) s
WHERE morbac.org_in_scope(p_org_id, s.org_id, s.scope)
)
SELECT array_agg(m.modality ORDER BY m.prio DESC, m.modality, m.context_id),
array_agg(m.context_id ORDER BY m.prio DESC, m.modality, m.context_id),
array_agg(m.prio ORDER BY m.prio DESC, m.modality, m.context_id)
INTO v_local_modality, v_local_context, v_local_prio
FROM matched m
INNER JOIN in_scope s ON s.org_id = m.org_id AND s.scope = m.scope;
-- Cross-org rules into p_org_id whose role the user holds in the source org
SELECT array_agg(cr.modality ORDER BY COALESCE(cr.priority, 0) DESC, cr.modality, cr.context_id),
array_agg(cr.context_id ORDER BY COALESCE(cr.priority, 0) DESC, cr.modality, cr.context_id),
array_agg(COALESCE(cr.priority, 0) ORDER BY COALESCE(cr.priority, 0) DESC, cr.modality, cr.context_id)
INTO v_cross_modality, v_cross_context, v_cross_prio
FROM unnest(v_held_org, v_held_role) AS h(org_id, role_id)
INNER JOIN morbac.cross_org_rules cr ON cr.source_org_id = h.org_id AND cr.role_id = h.role_id
WHERE cr.target_org_id = p_org_id
AND cr.activity = ANY (v_activities)
AND cr.view = ANY (v_views)
AND (cr.modality = 'permission' OR (cr.modality = 'prohibition' AND NOT v_is_system_principal))
AND morbac.is_rule_valid(cr.valid_from, cr.valid_until);
-- STEPS 1-3.5: Prohibitions - skipped entirely for system principals
IF NOT v_is_system_principal THEN
-- STEP 1: Local prohibitions - find the highest-priority applicable one
FOR v_rule IN
SELECT u.context_id, u.prio
FROM unnest(v_local_modality, v_local_context, v_local_prio) AS u(modality, context_id, prio)
WHERE u.modality = 'prohibition'
ORDER BY u.prio DESC
LOOP
IF morbac.eval_context(v_rule.context_id) THEN
v_max_prohibition_priority := v_rule.prio;
EXIT;
END IF;
END LOOP;
-- STEP 2: Cross-org prohibitions - update max if a higher priority is found
FOR v_rule IN
SELECT u.context_id, u.prio
FROM unnest(v_cross_modality, v_cross_context, v_cross_prio) AS u(modality, context_id, prio)
WHERE u.modality = 'prohibition'
ORDER BY u.prio DESC
LOOP
IF morbac.eval_context(v_rule.context_id) THEN
IF v_max_prohibition_priority IS NULL OR v_rule.prio > v_max_prohibition_priority THEN
v_max_prohibition_priority := v_rule.prio;
END IF;
EXIT;
END IF;
END LOOP;
-- STEP 3: User-level prohibitions - direct user rules, update max if higher
FOR v_rule IN
SELECT ur.context_id, COALESCE(ur.priority, 0) AS prio
FROM morbac.user_rules ur
WHERE ur.user_id = p_user_id
AND ur.org_id IS NOT DISTINCT FROM p_org_id
AND ur.modality = 'prohibition'
AND ur.activity = ANY (v_activities)
AND ur.view = ANY (v_views)
AND morbac.is_rule_valid(ur.valid_from, ur.valid_until)
ORDER BY COALESCE(ur.priority, 0) DESC
LOOP
IF morbac.eval_context(v_rule.context_id) THEN
IF v_max_prohibition_priority IS NULL OR v_rule.prio > v_max_prohibition_priority THEN
v_max_prohibition_priority := v_rule.prio;
END IF;
EXIT;
END IF;
END LOOP;
-- STEP 3.5: Global prohibitions
FOR v_rule IN
SELECT gr.context_id, COALESCE(gr.priority, 0) AS prio
FROM morbac.global_rules gr
WHERE (gr.user_id IS NULL OR gr.user_id = p_user_id)
AND gr.modality = 'prohibition'
AND (gr.activity IS NULL OR gr.activity = ANY (v_activities))
AND (gr.view IS NULL OR gr.view = ANY (v_views))
AND morbac.is_rule_valid(gr.valid_from, gr.valid_until)
ORDER BY COALESCE(gr.priority, 0) DESC
LOOP
IF morbac.eval_context(v_rule.context_id) THEN
IF v_max_prohibition_priority IS NULL OR v_rule.prio > v_max_prohibition_priority THEN
v_max_prohibition_priority := v_rule.prio;
END IF;
EXIT;
END IF;
END LOOP;
END IF; -- v_is_system_principal
-- STEP 4: Local permissions - find the highest-priority applicable one
FOR v_rule IN
SELECT u.context_id, u.prio
FROM unnest(v_local_modality, v_local_context, v_local_prio) AS u(modality, context_id, prio)
WHERE u.modality = 'permission'
ORDER BY u.prio DESC
LOOP
IF morbac.eval_context(v_rule.context_id) THEN
v_max_permission_priority := v_rule.prio;
EXIT;
END IF;
END LOOP;
-- STEP 5: Cross-org permissions - update max if higher found
FOR v_rule IN
SELECT u.context_id, u.prio
FROM unnest(v_cross_modality, v_cross_context, v_cross_prio) AS u(modality, context_id, prio)
WHERE u.modality = 'permission'
ORDER BY u.prio DESC
LOOP
IF morbac.eval_context(v_rule.context_id) THEN
IF v_max_permission_priority IS NULL OR v_rule.prio > v_max_permission_priority THEN
v_max_permission_priority := v_rule.prio;
END IF;
EXIT;
END IF;
END LOOP;
-- STEP 6: User-level permissions - direct user rules, update max if higher
FOR v_rule IN
SELECT ur.context_id, COALESCE(ur.priority, 0) AS prio
FROM morbac.user_rules ur
WHERE ur.user_id = p_user_id
AND ur.org_id IS NOT DISTINCT FROM p_org_id
AND ur.modality = 'permission'
AND ur.activity = ANY (v_activities)
AND ur.view = ANY (v_views)
AND morbac.is_rule_valid(ur.valid_from, ur.valid_until)
ORDER BY COALESCE(ur.priority, 0) DESC
LOOP
IF morbac.eval_context(v_rule.context_id) THEN
IF v_max_permission_priority IS NULL OR v_rule.prio > v_max_permission_priority THEN
v_max_permission_priority := v_rule.prio;
END IF;
EXIT;
END IF;
END LOOP;
-- STEP 6.5: Global permissions
FOR v_rule IN
SELECT gr.context_id, COALESCE(gr.priority, 0) AS prio
FROM morbac.global_rules gr
WHERE (gr.user_id IS NULL OR gr.user_id = p_user_id)
AND gr.modality = 'permission'
AND (gr.activity IS NULL OR gr.activity = ANY (v_activities))
AND (gr.view IS NULL OR gr.view = ANY (v_views))
AND morbac.is_rule_valid(gr.valid_from, gr.valid_until)
ORDER BY COALESCE(gr.priority, 0) DESC
LOOP
IF morbac.eval_context(v_rule.context_id) THEN
IF v_max_permission_priority IS NULL OR v_rule.prio > v_max_permission_priority THEN
v_max_permission_priority := v_rule.prio;
END IF;
EXIT;
END IF;
END LOOP;
-- STEP 7: Priority resolution
IF v_max_prohibition_priority IS NULL THEN
RETURN v_max_permission_priority IS NOT NULL;
END IF;
IF v_max_permission_priority IS NOT NULL
AND v_max_permission_priority > v_max_prohibition_priority THEN
RETURN TRUE;
END IF;
RETURN FALSE;
END;
$$;
COMMENT ON FUNCTION morbac.is_allowed_nocache(UUID, UUID, TEXT, TEXT) IS
'Authorization without cache - use for debugging or when cache must be bypassed';
+510
View File
@@ -0,0 +1,510 @@
-- =============================================================================
-- is_allowed_nocache() equivalence with the 1.0.0 implementation
-- =============================================================================
-- 1.0.1 resolves roles once per org instead of once per rule row. This file
-- keeps the 1.0.0 function as a reference and compares both on a seeded random
-- fixture: org forest, role hierarchy, negative assignments, delegations,
-- derived roles, activity/view hierarchies, every rule scope, priorities,
-- prohibitions, contexts that are false or NULL, validity windows, cross-org,
-- user and global rules, and a system principal holding roles.
--
-- It then adds rules for many unrelated tenants and checks that the cost of a
-- decision no longer follows the total rule count.
--
-- Prerequisites: 00_setup.sql
-- =============================================================================
\echo ''
\echo '================================================================'
\echo '17 - is_allowed_nocache EQUIVALENCE AND SCALING'
\echo '================================================================'
SET client_min_messages = error;
-- Reference: the 1.0.0 body, unchanged except for its name
CREATE OR REPLACE FUNCTION morbac.is_allowed_nocache_1_0_0(
p_user_id UUID,
p_org_id UUID,
p_activity TEXT,
p_view TEXT
)
RETURNS BOOLEAN
LANGUAGE plpgsql
STABLE
SECURITY DEFINER
AS $$
DECLARE
v_rule RECORD;
v_max_prohibition_priority INTEGER := NULL;
v_max_permission_priority INTEGER := NULL;
v_is_system_principal BOOLEAN;
BEGIN
v_is_system_principal := EXISTS (
SELECT 1 FROM morbac.system_principals WHERE user_id = p_user_id
);
-- STEPS 1-3.5: Prohibitions - skipped entirely for system principals
IF NOT v_is_system_principal THEN
-- STEP 1: Local prohibitions - find the highest-priority applicable one
FOR v_rule IN
SELECT r.context_id, COALESCE(r.priority, 0) AS prio
FROM morbac.rules r
WHERE morbac.org_in_scope(p_org_id, r.org_id, r.scope)
AND r.modality = 'prohibition'
AND r.activity IN (SELECT activity FROM morbac.get_effective_activities(p_activity))
AND r.view IN (SELECT view FROM morbac.get_effective_views(p_view))
AND r.role_id IN (SELECT role_id FROM morbac.get_comprehensive_roles(p_user_id, r.org_id))
AND morbac.is_rule_valid(r.valid_from, r.valid_until)
ORDER BY COALESCE(r.priority, 0) DESC
LOOP
IF morbac.eval_context(v_rule.context_id) THEN
v_max_prohibition_priority := v_rule.prio;
EXIT;
END IF;
END LOOP;
-- STEP 2: Cross-org prohibitions - update max if a higher priority is found
FOR v_rule IN
SELECT cr.context_id, COALESCE(cr.priority, 0) AS prio
FROM morbac.cross_org_rules cr
WHERE cr.target_org_id = p_org_id
AND cr.modality = 'prohibition'
AND cr.activity IN (SELECT activity FROM morbac.get_effective_activities(p_activity))
AND cr.view IN (SELECT view FROM morbac.get_effective_views(p_view))
AND cr.role_id IN (SELECT role_id FROM morbac.get_comprehensive_roles(p_user_id, cr.source_org_id))
AND morbac.is_rule_valid(cr.valid_from, cr.valid_until)
ORDER BY COALESCE(cr.priority, 0) DESC
LOOP
IF morbac.eval_context(v_rule.context_id) THEN
IF v_max_prohibition_priority IS NULL OR v_rule.prio > v_max_prohibition_priority THEN
v_max_prohibition_priority := v_rule.prio;
END IF;
EXIT;
END IF;
END LOOP;
-- STEP 3: User-level prohibitions - direct user rules, update max if higher
FOR v_rule IN
SELECT ur.context_id, COALESCE(ur.priority, 0) AS prio
FROM morbac.user_rules ur
WHERE ur.user_id = p_user_id
AND ur.org_id IS NOT DISTINCT FROM p_org_id
AND ur.modality = 'prohibition'
AND ur.activity IN (SELECT activity FROM morbac.get_effective_activities(p_activity))
AND ur.view IN (SELECT view FROM morbac.get_effective_views(p_view))
AND morbac.is_rule_valid(ur.valid_from, ur.valid_until)
ORDER BY COALESCE(ur.priority, 0) DESC
LOOP
IF morbac.eval_context(v_rule.context_id) THEN
IF v_max_prohibition_priority IS NULL OR v_rule.prio > v_max_prohibition_priority THEN
v_max_prohibition_priority := v_rule.prio;
END IF;
EXIT;
END IF;
END LOOP;
-- STEP 3.5: Global prohibitions
FOR v_rule IN
SELECT gr.context_id, COALESCE(gr.priority, 0) AS prio
FROM morbac.global_rules gr
WHERE (gr.user_id IS NULL OR gr.user_id = p_user_id)
AND gr.modality = 'prohibition'
AND (gr.activity IS NULL OR gr.activity IN (SELECT activity FROM morbac.get_effective_activities(p_activity)))
AND (gr.view IS NULL OR gr.view IN (SELECT view FROM morbac.get_effective_views(p_view)))
AND morbac.is_rule_valid(gr.valid_from, gr.valid_until)
ORDER BY COALESCE(gr.priority, 0) DESC
LOOP
IF morbac.eval_context(v_rule.context_id) THEN
IF v_max_prohibition_priority IS NULL OR v_rule.prio > v_max_prohibition_priority THEN
v_max_prohibition_priority := v_rule.prio;
END IF;
EXIT;
END IF;
END LOOP;
END IF; -- v_is_system_principal
-- STEP 4: Local permissions - find the highest-priority applicable one
FOR v_rule IN
SELECT r.context_id, COALESCE(r.priority, 0) AS prio
FROM morbac.rules r
WHERE morbac.org_in_scope(p_org_id, r.org_id, r.scope)
AND r.modality = 'permission'
AND r.activity IN (SELECT activity FROM morbac.get_effective_activities(p_activity))
AND r.view IN (SELECT view FROM morbac.get_effective_views(p_view))
AND r.role_id IN (SELECT role_id FROM morbac.get_comprehensive_roles(p_user_id, r.org_id))
AND morbac.is_rule_valid(r.valid_from, r.valid_until)
ORDER BY COALESCE(r.priority, 0) DESC
LOOP
IF morbac.eval_context(v_rule.context_id) THEN
v_max_permission_priority := v_rule.prio;
EXIT;
END IF;
END LOOP;
-- STEP 5: Cross-org permissions - update max if higher found
FOR v_rule IN
SELECT cr.context_id, COALESCE(cr.priority, 0) AS prio
FROM morbac.cross_org_rules cr
WHERE cr.target_org_id = p_org_id
AND cr.modality = 'permission'
AND cr.activity IN (SELECT activity FROM morbac.get_effective_activities(p_activity))
AND cr.view IN (SELECT view FROM morbac.get_effective_views(p_view))
AND cr.role_id IN (SELECT role_id FROM morbac.get_comprehensive_roles(p_user_id, cr.source_org_id))
AND morbac.is_rule_valid(cr.valid_from, cr.valid_until)
ORDER BY COALESCE(cr.priority, 0) DESC
LOOP
IF morbac.eval_context(v_rule.context_id) THEN
IF v_max_permission_priority IS NULL OR v_rule.prio > v_max_permission_priority THEN
v_max_permission_priority := v_rule.prio;
END IF;
EXIT;
END IF;
END LOOP;
-- STEP 6: User-level permissions - direct user rules, update max if higher
FOR v_rule IN
SELECT ur.context_id, COALESCE(ur.priority, 0) AS prio
FROM morbac.user_rules ur
WHERE ur.user_id = p_user_id
AND ur.org_id IS NOT DISTINCT FROM p_org_id
AND ur.modality = 'permission'
AND ur.activity IN (SELECT activity FROM morbac.get_effective_activities(p_activity))
AND ur.view IN (SELECT view FROM morbac.get_effective_views(p_view))
AND morbac.is_rule_valid(ur.valid_from, ur.valid_until)
ORDER BY COALESCE(ur.priority, 0) DESC
LOOP
IF morbac.eval_context(v_rule.context_id) THEN
IF v_max_permission_priority IS NULL OR v_rule.prio > v_max_permission_priority THEN
v_max_permission_priority := v_rule.prio;
END IF;
EXIT;
END IF;
END LOOP;
-- STEP 6.5: Global permissions
FOR v_rule IN
SELECT gr.context_id, COALESCE(gr.priority, 0) AS prio
FROM morbac.global_rules gr
WHERE (gr.user_id IS NULL OR gr.user_id = p_user_id)
AND gr.modality = 'permission'
AND (gr.activity IS NULL OR gr.activity IN (SELECT activity FROM morbac.get_effective_activities(p_activity)))
AND (gr.view IS NULL OR gr.view IN (SELECT view FROM morbac.get_effective_views(p_view)))
AND morbac.is_rule_valid(gr.valid_from, gr.valid_until)
ORDER BY COALESCE(gr.priority, 0) DESC
LOOP
IF morbac.eval_context(v_rule.context_id) THEN
IF v_max_permission_priority IS NULL OR v_rule.prio > v_max_permission_priority THEN
v_max_permission_priority := v_rule.prio;
END IF;
EXIT;
END IF;
END LOOP;
-- STEP 7: Priority resolution
IF v_max_prohibition_priority IS NULL THEN
RETURN v_max_permission_priority IS NOT NULL;
END IF;
IF v_max_permission_priority IS NOT NULL
AND v_max_permission_priority > v_max_prohibition_priority THEN
RETURN TRUE;
END IF;
RETURN FALSE;
END;
$$;
CREATE OR REPLACE FUNCTION morbac.eq_context_never() RETURNS BOOLEAN LANGUAGE sql STABLE AS 'SELECT FALSE';
CREATE OR REPLACE FUNCTION morbac.eq_context_null() RETURNS BOOLEAN LANGUAGE sql STABLE AS 'SELECT NULL::BOOLEAN';
CREATE OR REPLACE FUNCTION morbac.eq_derived(p_user_id UUID, p_org_id UUID) RETURNS BOOLEAN
LANGUAGE sql STABLE AS $$
SELECT get_byte(decode(md5(p_user_id::text || p_org_id::text), 'hex'), 0) % 3 = 0
$$;
INSERT INTO morbac.contexts (name, evaluator) VALUES
('eq_never', 'morbac.eq_context_never'::regproc),
('eq_null', 'morbac.eq_context_null'::regproc);
INSERT INTO morbac.activities (name) VALUES ('eq_read'), ('eq_write'), ('eq_admin');
INSERT INTO morbac.activity_hierarchy (senior_activity, junior_activity) VALUES
('eq_admin', 'eq_write'), ('eq_write', 'eq_read');
INSERT INTO morbac.views (name) VALUES ('eq_doc'), ('eq_secret'), ('eq_misc');
INSERT INTO morbac.view_hierarchy (senior_view, junior_view) VALUES ('eq_secret', 'eq_doc');
CREATE TABLE morbac.eq_users (n INT PRIMARY KEY, id UUID NOT NULL);
CREATE TABLE morbac.eq_orgs (n INT PRIMARY KEY, id UUID NOT NULL);
CREATE TABLE morbac.eq_roles (n INT PRIMARY KEY, id UUID NOT NULL);
DO $$
DECLARE
v_ctx_always UUID := (SELECT id FROM morbac.contexts WHERE name = 'always');
v_ctx_never UUID := (SELECT id FROM morbac.contexts WHERE name = 'eq_never');
v_ctx_null UUID := (SELECT id FROM morbac.contexts WHERE name = 'eq_null');
v_scopes TEXT[] := ARRAY['self','children','descendants','subtree','parent','ancestors','lineage','root','unattributed','all'];
v_acts TEXT[] := ARRAY['eq_read','eq_write','eq_admin'];
v_views TEXT[] := ARRAY['eq_doc','eq_secret','eq_misc'];
v_org UUID;
v_role UUID;
v_x DOUBLE PRECISION;
v_from TIMESTAMPTZ;
v_until TIMESTAMPTZ;
v_rec RECORD;
v_src INT;
v_dst INT;
BEGIN
PERFORM setseed(0.17);
-- Org forest: 12 orgs, each either a new root or the child of an earlier one
FOR i IN 0..11 LOOP
v_org := md5('eq_org_' || i)::uuid;
INSERT INTO morbac.orgs (id, name, parent_id) VALUES (
v_org, 'eq_org_' || i,
CASE WHEN i = 0 OR random() < 0.15 THEN NULL
ELSE md5('eq_org_' || floor(random() * i)::int)::uuid END
);
INSERT INTO morbac.eq_orgs VALUES (i, v_org);
END LOOP;
-- Roles, with an acyclic hierarchy (senior n -> junior m only when n < m)
FOR i IN 0..13 LOOP
v_role := md5('eq_role_' || i)::uuid;
INSERT INTO morbac.roles (id, org_id, name)
VALUES (v_role, md5('eq_org_' || floor(random() * 12)::int)::uuid, 'eq_role_' || i);
INSERT INTO morbac.eq_roles VALUES (i, v_role);
END LOOP;
FOR i IN 0..13 LOOP
FOR j IN (i + 1)..13 LOOP
IF random() < 0.12 THEN
INSERT INTO morbac.role_hierarchy (senior_role_id, junior_role_id)
SELECT a.id, b.id FROM morbac.eq_roles a, morbac.eq_roles b WHERE a.n = i AND b.n = j;
END IF;
END LOOP;
END LOOP;
INSERT INTO morbac.derived_roles (role_id, condition_evaluator)
SELECT id, 'morbac.eq_derived'::regproc FROM morbac.eq_roles WHERE n IN (3, 11);
-- Users and their assignments: mostly in the role's own org, sometimes elsewhere
FOR i IN 0..10 LOOP
INSERT INTO morbac.eq_users VALUES (i, md5('eq_user_' || i)::uuid);
END LOOP;
FOR i IN 0..10 LOOP
FOR k IN 1..(1 + floor(random() * 3)::int) LOOP
v_role := md5('eq_role_' || floor(random() * 14)::int)::uuid;
v_org := (SELECT r.org_id FROM morbac.roles r WHERE r.id = v_role);
IF random() < 0.25 THEN
v_org := md5('eq_org_' || floor(random() * 12)::int)::uuid;
END IF;
INSERT INTO morbac.user_roles (user_id, role_id, org_id)
VALUES ((SELECT id FROM morbac.eq_users WHERE n = i), v_role, v_org)
ON CONFLICT DO NOTHING;
END LOOP;
END LOOP;
-- Negative assignments: some cancel a held role, some target an inherited one
INSERT INTO morbac.negative_role_assignments (user_id, role_id, org_id)
SELECT ur.user_id, ur.role_id, ur.org_id FROM morbac.user_roles ur
JOIN morbac.eq_users u ON u.id = ur.user_id
ORDER BY md5(ur.user_id::text || ur.role_id::text) LIMIT 3;
INSERT INTO morbac.negative_role_assignments (user_id, role_id, org_id)
SELECT ur.user_id, rh.junior_role_id, ur.org_id FROM morbac.user_roles ur
JOIN morbac.eq_users u ON u.id = ur.user_id
JOIN morbac.role_hierarchy rh ON rh.senior_role_id = ur.role_id
ORDER BY md5(ur.user_id::text || rh.junior_role_id::text) LIMIT 2
ON CONFLICT DO NOTHING;
-- Delegations: active, expired, future and revoked
FOR v_rec IN
SELECT ur.user_id, ur.role_id, ur.org_id, row_number() OVER (ORDER BY md5(ur.user_id::text || ur.role_id::text)) AS k
FROM morbac.user_roles ur JOIN morbac.eq_users u ON u.id = ur.user_id
LIMIT 8
LOOP
INSERT INTO morbac.delegations (delegator_id, delegatee_id, role_id, org_id, valid_from, valid_until, revoked)
SELECT v_rec.user_id, u.id, v_rec.role_id, v_rec.org_id,
CASE v_rec.k % 4 WHEN 1 THEN now() - interval '2 days' WHEN 2 THEN now() + interval '1 day' ELSE now() - interval '1 day' END,
CASE v_rec.k % 4 WHEN 1 THEN now() - interval '1 day' WHEN 2 THEN now() + interval '2 days' ELSE now() + interval '1 day' END,
v_rec.k % 4 = 3
FROM morbac.eq_users u
WHERE u.id <> v_rec.user_id
ORDER BY md5(u.id::text || v_rec.k::text) LIMIT 1;
END LOOP;
-- Role-bound rules
FOR i IN 1..450 LOOP
v_x := random();
v_from := NULL; v_until := NULL;
IF v_x < 0.08 THEN
v_from := now() - interval '2 days'; v_until := now() - interval '1 day';
ELSIF v_x < 0.16 THEN
v_from := now() + interval '1 day';
ELSIF v_x < 0.22 THEN
v_from := now() - interval '1 day'; v_until := now() + interval '1 day';
END IF;
INSERT INTO morbac.rules (org_id, role_id, activity, view, context_id, modality, scope, priority, valid_from, valid_until)
VALUES (
md5('eq_org_' || floor(random() * 12)::int)::uuid,
md5('eq_role_' || floor(random() * 14)::int)::uuid,
v_acts[1 + floor(random() * 3)::int],
v_views[1 + floor(random() * 3)::int],
CASE WHEN random() < 0.7 THEN v_ctx_always WHEN random() < 0.5 THEN v_ctx_never ELSE v_ctx_null END,
(CASE WHEN random() < 0.55 THEN 'permission' WHEN random() < 0.8 THEN 'prohibition' ELSE 'obligation' END)::morbac.modality,
v_scopes[1 + floor(random() * 10)::int],
CASE WHEN random() < 0.4 THEN NULL ELSE floor(random() * 5)::int END,
v_from, v_until
) ON CONFLICT DO NOTHING;
END LOOP;
FOR i IN 1..70 LOOP
v_src := floor(random() * 12)::int;
v_dst := floor(random() * 12)::int;
CONTINUE WHEN v_src = v_dst;
INSERT INTO morbac.cross_org_rules (source_org_id, target_org_id, role_id, activity, view, context_id, modality, priority, valid_from)
VALUES (md5('eq_org_' || v_src)::uuid, md5('eq_org_' || v_dst)::uuid,
md5('eq_role_' || floor(random() * 14)::int)::uuid,
v_acts[1 + floor(random() * 3)::int],
v_views[1 + floor(random() * 3)::int],
CASE WHEN random() < 0.8 THEN v_ctx_always ELSE v_ctx_never END,
(CASE WHEN random() < 0.6 THEN 'permission' ELSE 'prohibition' END)::morbac.modality,
CASE WHEN random() < 0.4 THEN NULL ELSE floor(random() * 5)::int END,
CASE WHEN random() < 0.1 THEN now() + interval '1 day' END
) ON CONFLICT DO NOTHING;
END LOOP;
FOR i IN 1..60 LOOP
INSERT INTO morbac.user_rules (user_id, org_id, activity, view, context_id, modality, priority)
VALUES (
md5('eq_user_' || floor(random() * 10)::int)::uuid,
CASE WHEN random() < 0.2 THEN NULL ELSE md5('eq_org_' || floor(random() * 12)::int)::uuid END,
v_acts[1 + floor(random() * 3)::int],
v_views[1 + floor(random() * 3)::int],
CASE WHEN random() < 0.8 THEN v_ctx_always ELSE v_ctx_never END,
(CASE WHEN random() < 0.5 THEN 'permission' ELSE 'prohibition' END)::morbac.modality,
CASE WHEN random() < 0.4 THEN NULL ELSE floor(random() * 5)::int END
) ON CONFLICT DO NOTHING;
END LOOP;
FOR i IN 1..10 LOOP
INSERT INTO morbac.global_rules (user_id, activity, view, context_id, modality, priority)
VALUES (
CASE WHEN i <= 2 THEN NULL ELSE md5('eq_user_' || floor(random() * 10)::int)::uuid END,
CASE WHEN random() < 0.3 THEN NULL ELSE v_acts[1 + floor(random() * 3)::int] END,
CASE WHEN random() < 0.3 THEN NULL ELSE v_views[1 + floor(random() * 3)::int] END,
v_ctx_always,
(CASE WHEN i % 2 = 0 THEN 'permission' ELSE 'prohibition' END)::morbac.modality,
floor(random() * 5)::int
) ON CONFLICT DO NOTHING;
END LOOP;
-- User 10 holds roles, then becomes a system principal: its prohibitions are skipped
INSERT INTO morbac.system_principals (user_id, description)
SELECT id, 'eq system principal' FROM morbac.eq_users WHERE n = 10;
END;
$$;
CREATE TABLE morbac.eq_cases AS
SELECT u.id AS user_id, o.id AS org_id, a.activity, v.view
FROM morbac.eq_users u
CROSS JOIN (SELECT id FROM morbac.eq_orgs UNION ALL SELECT NULL::UUID) o
CROSS JOIN (VALUES ('eq_read'), ('eq_write'), ('eq_admin')) a(activity)
CROSS JOIN (VALUES ('eq_doc'), ('eq_secret'), ('eq_misc')) v(view);
CREATE TABLE morbac.eq_results AS
SELECT c.*,
morbac.is_allowed_nocache_1_0_0(c.user_id, c.org_id, c.activity, c.view) AS expected,
morbac.is_allowed_nocache(c.user_id, c.org_id, c.activity, c.view) AS actual
FROM morbac.eq_cases c;
SELECT CASE WHEN count(*) FILTER (WHERE expected IS DISTINCT FROM actual) = 0
THEN 'CHECK PASS: ' || count(*) || ' decisions identical to 1.0.0'
ELSE 'CHECK FAIL: ' || count(*) FILTER (WHERE expected IS DISTINCT FROM actual) || ' of ' || count(*) || ' decisions differ from 1.0.0'
END
FROM morbac.eq_results;
SELECT morbac.t('fixture yields both allowed and denied decisions',
(SELECT count(*) FILTER (WHERE expected) >= 50 AND count(*) FILTER (WHERE NOT expected) >= 50 FROM morbac.eq_results), TRUE);
SELECT morbac.t('fixture rules cover every scope',
(SELECT count(DISTINCT r.scope) FROM morbac.rules r JOIN morbac.eq_orgs o ON o.id = r.org_id) = 10, TRUE);
SELECT morbac.t('system principal is allowed somewhere',
(SELECT bool_or(actual) FROM morbac.eq_results r JOIN morbac.eq_users u ON u.id = r.user_id WHERE u.n = 10), TRUE);
-- Scaling: 60 unrelated tenants, each with its own roles and ~40 rules on the
-- same activities and views. The fixture users hold no role there.
DO $$
DECLARE
v_org UUID;
v_role UUID;
v_ctx UUID := (SELECT id FROM morbac.contexts WHERE name = 'always');
BEGIN
FOR t IN 1..60 LOOP
v_org := gen_random_uuid();
v_role := gen_random_uuid();
INSERT INTO morbac.orgs (id, name) VALUES (v_org, 'eq_tenant_' || t);
INSERT INTO morbac.roles (id, org_id, name) VALUES (v_role, v_org, 'tenant_role');
INSERT INTO morbac.user_roles (user_id, role_id, org_id) VALUES (gen_random_uuid(), v_role, v_org);
INSERT INTO morbac.rules (org_id, role_id, activity, view, context_id, modality, scope)
SELECT v_org, v_role, a, v, v_ctx, 'permission', s
FROM unnest(ARRAY['eq_read','eq_write','eq_admin']) a,
unnest(ARRAY['eq_doc','eq_secret','eq_misc']) v,
unnest(ARRAY['self','subtree','children','lineage']) s;
END LOOP;
END;
$$;
SELECT CASE WHEN count(*) FILTER (WHERE r.expected IS DISTINCT FROM morbac.is_allowed_nocache(r.user_id, r.org_id, r.activity, r.view)) = 0
THEN 'CHECK PASS: decisions unchanged after adding unrelated tenants'
ELSE 'CHECK FAIL: unrelated tenants changed ' || count(*) FILTER (WHERE r.expected IS DISTINCT FROM morbac.is_allowed_nocache(r.user_id, r.org_id, r.activity, r.view)) || ' decisions'
END
FROM morbac.eq_results r;
-- Old and new on the same sample after the tenants were added. The 1.0.0 cost
-- grows with every rule in the table; the new one must stay well below it.
CREATE TABLE morbac.eq_sample AS
SELECT * FROM morbac.eq_results ORDER BY md5(user_id::text || COALESCE(org_id::text, '') || activity || view) LIMIT 8;
DO $$
DECLARE
v_rec RECORD;
v_t0 TIMESTAMPTZ;
v_old INTERVAL;
v_new INTERVAL;
v_ok BOOLEAN := TRUE;
BEGIN
v_t0 := clock_timestamp();
FOR v_rec IN SELECT * FROM morbac.eq_sample LOOP
IF morbac.is_allowed_nocache_1_0_0(v_rec.user_id, v_rec.org_id, v_rec.activity, v_rec.view) IS DISTINCT FROM v_rec.expected THEN
v_ok := FALSE;
END IF;
END LOOP;
v_old := clock_timestamp() - v_t0;
v_t0 := clock_timestamp();
FOR i IN 1..10 LOOP
FOR v_rec IN SELECT * FROM morbac.eq_sample LOOP
IF morbac.is_allowed_nocache(v_rec.user_id, v_rec.org_id, v_rec.activity, v_rec.view) IS DISTINCT FROM v_rec.expected THEN
v_ok := FALSE;
END IF;
END LOOP;
END LOOP;
v_new := (clock_timestamp() - v_t0) / 10;
CREATE TEMP TABLE eq_timing AS SELECT v_old AS old_cost, v_new AS new_cost, v_ok AS ok;
END;
$$;
SELECT morbac.t('1.0.0 reference still agrees after adding tenants', (SELECT ok FROM eq_timing), TRUE);
SELECT morbac.t('decision cost is at least 10x below 1.0.0 with ' || (SELECT count(*) FROM morbac.rules) || ' rules (1.0.0 '
|| (SELECT round(extract(epoch FROM old_cost) * 1000 / 8, 2) FROM eq_timing) || ' ms, now '
|| (SELECT round(extract(epoch FROM new_cost) * 1000 / 8, 2) FROM eq_timing) || ' ms per call)',
(SELECT new_cost * 10 < old_cost FROM eq_timing), TRUE);
DROP TABLE eq_timing;
DROP TABLE morbac.eq_sample, morbac.eq_results, morbac.eq_cases, morbac.eq_roles, morbac.eq_orgs, morbac.eq_users;
DROP FUNCTION morbac.is_allowed_nocache_1_0_0(UUID, UUID, TEXT, TEXT);
RESET client_min_messages;
\echo ''
\echo '=== Equivalence Tests Completed ==='
+1 -1
View File
@@ -51,7 +51,7 @@ for filename in "${BUILD_FILES[@]}"; do
if [ -f "$filepath" ]; then if [ -f "$filepath" ]; then
cat "$filepath" >> "$OUTPUT" cat "$filepath" >> "$OUTPUT"
echo "" >> "$OUTPUT" echo "" >> "$OUTPUT"
((file_count++)) file_count=$((file_count + 1))
else else
echo "Warning: $filename not found, skipping" >&2 echo "Warning: $filename not found, skipping" >&2
fi fi
+2 -1
View File
@@ -40,8 +40,9 @@ sed -i.bak "s/default_version = '$OLD_VERSION'/default_version = '$NEW_VERSION'/
echo " Updated $CONTROL_FILE" echo " Updated $CONTROL_FILE"
# --- META.json --- # --- META.json ---
# The meta-spec block carries the PGXN spec version, not ours
sed -i.bak \ sed -i.bak \
-e "s/\"version\": \"$OLD_VERSION\"/\"version\": \"$NEW_VERSION\"/g" \ -e "/\"meta-spec\"/,/}/!s/\"version\": \"$OLD_VERSION\"/\"version\": \"$NEW_VERSION\"/g" \
-e "s/pgmorbac--$OLD_VERSION\.sql/pgmorbac--$NEW_VERSION.sql/g" \ -e "s/pgmorbac--$OLD_VERSION\.sql/pgmorbac--$NEW_VERSION.sql/g" \
"$META_FILE" "$META_FILE"
echo " Updated $META_FILE" echo " Updated $META_FILE"
+3 -3
View File
@@ -45,7 +45,7 @@ for test_file in "$TEST_DIR"/[0-9][0-9]_*.sql; do
[ -f "$test_file" ] || continue [ -f "$test_file" ] || continue
test_name=$(basename "$test_file" .sql) test_name=$(basename "$test_file" .sql)
((total_files++)) total_files=$((total_files + 1))
output=$(psql -U "$PG_USER" -d "$DB" -f "$test_file" 2>&1) output=$(psql -U "$PG_USER" -d "$DB" -f "$test_file" 2>&1)
exit_code=$? exit_code=$?
@@ -59,7 +59,7 @@ for test_file in "$TEST_DIR"/[0-9][0-9]_*.sql; do
total_fail=$((total_fail + file_fail)) total_fail=$((total_fail + file_fail))
if [ $exit_code -ne 0 ] || [ "$file_fail" -gt 0 ] || [ "$file_errors" -gt 0 ]; then if [ $exit_code -ne 0 ] || [ "$file_fail" -gt 0 ] || [ "$file_errors" -gt 0 ]; then
((failed_files++)) failed_files=$((failed_files + 1))
echo " [FAIL] $test_name — ${file_pass} passed, ${file_fail} failed, ${file_errors} SQL error(s)" echo " [FAIL] $test_name — ${file_pass} passed, ${file_fail} failed, ${file_errors} SQL error(s)"
# Print only failing lines for a focused view # Print only failing lines for a focused view
if [ "$file_errors" -gt 0 ]; then if [ "$file_errors" -gt 0 ]; then
@@ -69,7 +69,7 @@ for test_file in "$TEST_DIR"/[0-9][0-9]_*.sql; do
echo "$output" | grep "CHECK FAIL:" | sed 's/^/ /' echo "$output" | grep "CHECK FAIL:" | sed 's/^/ /'
fi fi
else else
((passed_files++)) passed_files=$((passed_files + 1))
echo " [PASS] $test_name — ${file_pass} assertion(s) passed" echo " [PASS] $test_name — ${file_pass} assertion(s) passed"
fi fi
done done