Compare commits
7
Commits
493c2ec429
...
v1.0.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11ef1715ab | ||
|
|
7e59978f07 | ||
|
|
4ffd755fe7 | ||
|
|
acfe534938 | ||
|
|
1cf23c37ce | ||
|
|
3e8380d4f7 | ||
|
|
1546e272d8 |
-34
@@ -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
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 14 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 95 KiB |
@@ -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
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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,7 +22,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"resources": {
|
"resources": {
|
||||||
"homepage": "https://pgmorbac.villains.fr",
|
"homepage": "https://pgmorbac.crudy.fr",
|
||||||
"bugtracker": {
|
"bugtracker": {
|
||||||
"web": "https://github.com/crudylabs/pgmorbac/issues"
|
"web": "https://github.com/crudylabs/pgmorbac/issues"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -67,11 +67,11 @@ 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;"
|
||||||
@@ -293,7 +293,7 @@ git push --tags
|
|||||||
|
|
||||||
## Support
|
## Support
|
||||||
|
|
||||||
- Read the [documentation](https://pgmorbac.villains.fr)
|
- Read the [documentation](https://pgmorbac.crudy.fr)
|
||||||
- Report bugs via [GitHub Issues](https://github.com/crudylabs/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)
|
||||||
|
|
||||||
|
|||||||
+5
-4
@@ -67,12 +67,13 @@ git tag vX.Y.Z
|
|||||||
git push origin master --tags
|
git push origin master --tags
|
||||||
```
|
```
|
||||||
|
|
||||||
5. Signed SQL distribution, attached to the Gitea release (Drone does this on the
|
5. Signed SQL distribution, attached to the GitHub release. The `release`
|
||||||
tag when the repository is activated there):
|
workflow does this on the tag, signing with the `PGMORBAC_RELEASE_KEY`
|
||||||
|
repository secret. By hand:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
PGMORBAC_RELEASE_KEY=<path to the Ed25519 key> GITEA_URL=https://git.villains.fr \
|
PGMORBAC_RELEASE_KEY=<path to the Ed25519 key> GITHUB_REPOSITORY=crudylabs/pgmorbac \
|
||||||
GITEA_REPO=crudy/pgmorbac GITEA_TOKEN=<token> npm run release
|
GITHUB_TOKEN=<token> npm run release
|
||||||
```
|
```
|
||||||
|
|
||||||
6. The npm package `@crudy/pgmorbac` is released from the `pgmorbac-node`
|
6. The npm package `@crudy/pgmorbac` is released from the `pgmorbac-node`
|
||||||
|
|||||||
+1
-1
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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}`);
|
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user