├── .github └── workflows │ ├── publish.yaml │ └── tests.yaml ├── .gitignore ├── .vscode └── settings.json ├── CHANGELOG.md ├── LICENSE.txt ├── README.md ├── biome.json ├── docker-compose.yaml ├── package.json ├── pnpm-lock.yaml ├── scripts └── publish.sh ├── src ├── api.ts ├── backend.ts ├── clause.ts ├── cli.ts ├── constants.ts ├── index.ts ├── oso.ts ├── parser.ts ├── pg-backend.ts ├── sql.ts ├── sql │ └── pg │ │ └── revoke_all_from_role.sql └── utils.ts ├── test ├── clause.test.ts ├── e2e.test.ts ├── envs │ ├── basic │ │ ├── setup.sql │ │ └── teardown.sql │ ├── cast │ │ ├── setup.sql │ │ └── teardown.sql │ ├── complete-1 │ │ ├── setup.sql │ │ └── teardown.sql │ ├── complete-2 │ │ ├── setup.sql │ │ └── teardown.sql │ ├── func-condition │ │ ├── setup.sql │ │ └── teardown.sql │ ├── functions-and-procedures │ │ ├── setup.sql │ │ └── teardown.sql │ ├── group │ │ ├── setup.sql │ │ └── teardown.sql │ ├── long-table-name │ │ ├── setup.sql │ │ └── teardown.sql │ ├── multi-table │ │ ├── setup.sql │ │ └── teardown.sql │ ├── partial-rls │ │ ├── setup.sql │ │ └── teardown.sql │ ├── rls-mutation │ │ ├── setup.sql │ │ └── teardown.sql │ ├── sequence │ │ ├── setup.sql │ │ └── teardown.sql │ └── view │ │ ├── setup.sql │ │ └── teardown.sql ├── rules │ ├── basic-1.polar │ ├── basic-2.polar │ ├── basic-3.polar │ ├── basic-4.polar │ ├── basic-5.polar │ ├── basic-6.polar │ ├── basic-7.polar │ ├── basic-8.polar │ ├── basic-all-actors-1.polar │ ├── basic-invalid-object-type-1.polar │ ├── basic-invalid-object-type-2.polar │ ├── basic-invalid-privilege-1.polar │ ├── basic-non-existant-actor-1.polar │ ├── basic-non-existant-actor-2.polar │ ├── cast-1.polar │ ├── complete-1.polar │ ├── func-condition-1.polar │ ├── functions-and-procedures-1.polar │ ├── functions-and-procedures-2.polar │ ├── functions-and-procedures-3.polar │ ├── functions-and-procedures-4.polar │ ├── group-1.polar │ ├── long-table-name.polar │ ├── multi-table-1.polar │ ├── multi-table-2.polar │ ├── multi-table-3.polar │ ├── multi-table-4.polar │ ├── partial-rls-1.polar │ ├── rls-mutation-1.polar │ ├── sequence-1.polar │ ├── sequence-2.polar │ ├── sequence-3.polar │ ├── sequence-4.polar │ ├── view-1.polar │ ├── view-2.polar │ ├── view-3.polar │ └── view-4.polar ├── sql-literal.test.ts └── utils.ts ├── tsconfig.build.json └── tsconfig.json /.github/workflows/publish.yaml: -------------------------------------------------------------------------------- 1 | name: Publish package 2 | 3 | on: 4 | create: 5 | tags: 6 | - v* 7 | 8 | jobs: 9 | publish: 10 | permissions: 11 | contents: write 12 | runs-on: ubuntu-latest 13 | timeout-minutes: 15 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: actions/setup-node@v4 18 | with: 19 | node-version: '20' 20 | registry-url: 'https://registry.npmjs.org/' 21 | # PNPM stuff 22 | - name: Install pnpm 23 | uses: pnpm/action-setup@v4 24 | id: pnpm-install 25 | with: 26 | version: 8 27 | run_install: false 28 | - name: Get pnpm store directory 29 | id: pnpm-cache 30 | run: | 31 | echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT 32 | - uses: actions/cache@v4 33 | name: Setup pnpm cache 34 | with: 35 | path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} 36 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 37 | restore-keys: | 38 | ${{ runner.os }}-pnpm-store- 39 | - name: Install node dependencies 40 | run: pnpm install 41 | - name: Build package 42 | run: pnpm build 43 | - name: Pack package 44 | run: pnpm pack 45 | - name: Publish package 46 | run: pnpm npm-publish 47 | env: 48 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 49 | - name: Create Release 50 | uses: softprops/action-gh-release@v1 51 | with: 52 | files: sqlauthz-*.tgz 53 | -------------------------------------------------------------------------------- /.github/workflows/tests.yaml: -------------------------------------------------------------------------------- 1 | name: Run tests 2 | 3 | on: 4 | push 5 | 6 | concurrency: 7 | group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | timeout-minutes: 15 13 | strategy: 14 | matrix: 15 | include: 16 | - node-version: "18" 17 | postgres-version: "16" 18 | - node-version: "20" 19 | postgres-version: "16" 20 | - node-version: "20" 21 | postgres-version: "15" 22 | - node-version: "20" 23 | postgres-version: "14" 24 | - node-version: "20" 25 | postgres-version: "13" 26 | - node-version: "20" 27 | postgres-version: "12" 28 | 29 | services: 30 | postgres: 31 | image: postgres:${{ matrix.postgres-version }} 32 | env: 33 | POSTGRES_PASSWORD: password 34 | POSTGRES_DB: db 35 | ports: 36 | - 5432/tcp 37 | # Set health checks to wait until postgres has started 38 | options: >- 39 | --health-cmd pg_isready 40 | --health-interval 10s 41 | --health-timeout 5s 42 | --health-retries 5 43 | 44 | steps: 45 | - uses: actions/checkout@v4 46 | - uses: actions/setup-node@v4 47 | with: 48 | node-version: ${{ matrix.node-version }} 49 | # PNPM stuff 50 | - name: Install pnpm 51 | uses: pnpm/action-setup@v4 52 | id: pnpm-install 53 | with: 54 | version: 8 55 | run_install: false 56 | - name: Get pnpm store directory 57 | id: pnpm-cache 58 | run: | 59 | echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT 60 | - uses: actions/cache@v4 61 | name: Setup pnpm cache 62 | with: 63 | path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} 64 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 65 | restore-keys: | 66 | ${{ runner.os }}-pnpm-store- 67 | - name: Install node dependencies 68 | run: pnpm install 69 | - name: Run tests 70 | run: pnpm test 71 | env: 72 | TEST_DATABASE_URL: postgresql://postgres:password@localhost:${{ job.services.postgres.ports[5432] }}/db 73 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .env 3 | /dist 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "oso.polarLanguageServer.validations": "library" 3 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## Unreleased 9 | 10 | ## [1.0.6] - 2024-11-19 11 | 12 | ### Fixed 13 | 14 | - When there is an error compiling permission queries in the CLI, exit w/ a code of 1 to indicate failure. 15 | 16 | ## [1.0.4] - 2024-09-08 17 | 18 | ### Fixed 19 | 20 | - Handle cases where RLS is already enabled on a table, and may potentially already have permissive policies. 21 | 22 | - Fixed missing `WITH CHECK` constraint for `UPDATE` RLS policies. 23 | 24 | ## [1.0.3] - 2024-08-14 25 | 26 | ### Fixed 27 | 28 | - Fixed RLS policies with long table names causing duplicate policies. ([#2](https://github.com/cfeenstra67/sqlauthz/pull/2), thanks @pnispel) 29 | 30 | ## [1.0.2] - 2024-07-06 31 | 32 | ### Added 33 | 34 | - Updated README to include recommendations for integrating into a production application. 35 | 36 | ### Changed 37 | 38 | - Updated `picomatch` version. 39 | 40 | ## [1.0.0] - 2024-06-19 41 | 42 | - Made actor handling more strict. Now referencing a user/group that doesn't exist explicitly in rules will cause an error, including a user that doesn't exist in a user revoke policy (`revokeUsers`) will cause an error, and attempting to grant permissions to a user outside the scope of the user revoke policy will cause an error. 43 | 44 | - Exclude default postgres groups (identified by those starting with `pg_` when granting permissions) 45 | 46 | - Made privilege/action handling more strict. Now referencing a privilege that does not exist for any type of object will cause an error. 47 | 48 | - Made resource type handling more strict. Now referencing an invalid object type e.g. `resource.type == "table2"` will cause an error. 49 | 50 | ## [0.7.0] - 2023-12-24 51 | 52 | ### Added 53 | 54 | - `--var` and `--var-file` arguments to allow injection of variables into rules scope. 55 | 56 | ## [0.6.0] - 2023-12-24 57 | 58 | ### Added 59 | 60 | - Add support for globs in `--rules` argument 61 | 62 | - Add support for `sql.cast()` 63 | 64 | ### Fixed 65 | 66 | - Error preventing usage of `--dry-run` and `--dry-run-short` 67 | 68 | ## [0.5.0] - 2023-12-22 69 | 70 | ### Added 71 | 72 | - Add support for managing sequence permissions. 73 | 74 | ## [0.4.0] - 2023-12-22 75 | 76 | ### Added 77 | 78 | - Add support for managing function and procedure permissions. 79 | 80 | ## [0.3.0] - 2023-12-22 81 | 82 | ### Added 83 | 84 | - Add support for assigning permissions to groups 85 | 86 | - Add support for managing view permissions 87 | 88 | ### Fixed 89 | 90 | - Fixed error constructing table columns when views exist 91 | 92 | - Fixed issue with using `resource.type` for tables 93 | 94 | ## [0.2.1] - 2023-12-18 95 | 96 | ### Fixed 97 | 98 | - Only revoke privileges that can be granted with `sqlauthz`. 99 | 100 | ## [0.2.0] - 2023-12-18 101 | 102 | ### Added 103 | 104 | - Support for SQL functions in row-level security clauses, with a few limitations. 105 | 106 | - Support for assigning permissions to groups 107 | 108 | - Add all permissions for tables and schemas 109 | 110 | ## [0.1.2] - 2023-12-16 111 | 112 | ### Added 113 | 114 | - Added `NO_DOTENV` environment variable to disable loading environment variables from `.env` file. 115 | 116 | ### Changed 117 | 118 | - Quote row-level security policy names in `CREATE POLICY` queries. 119 | 120 | ## [0.1.1] - 2023-12-16 121 | 122 | ### Fixed 123 | 124 | - Fixed `allowAnyUser` argument to actually work 125 | 126 | ## [0.1.0] - 2023-12-16 127 | 128 | ### Added 129 | 130 | - Exposed user revoke strategies and `allowAnyUser` in the CLI. 131 | 132 | - Added basic documentation in the README. 133 | 134 | ## [0.0.3] - 2023-12-15 135 | 136 | ### Fixed 137 | 138 | - Include `src` directory in package 139 | 140 | ## [0.0.2] - 2023-12-15 141 | 142 | ### Added 143 | 144 | - Initial implementation of `sqlauthz` 145 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright 2023 Cameron Feenstra 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.4.1/schema.json", 3 | "organizeImports": { 4 | "enabled": true 5 | }, 6 | "formatter": { 7 | "enabled": true, 8 | "indentStyle": "space" 9 | }, 10 | "linter": { 11 | "enabled": true, 12 | "rules": { 13 | "recommended": true, 14 | "style": { 15 | "noNonNullAssertion": "off" 16 | }, 17 | "nursery": { 18 | "noUnusedImports": "error" 19 | } 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | services: 3 | postgres: 4 | image: postgres:16 5 | ports: 6 | - "5432:5432" 7 | environment: 8 | POSTGRES_DB: db 9 | POSTGRES_PASSWORD: password 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sqlauthz", 3 | "type": "module", 4 | "version": "1.0.6", 5 | "description": "Declarative permission management for PostgreSQL", 6 | "main": "dist/index.js", 7 | "types": "dist/index.d.ts", 8 | "scripts": { 9 | "run-ts": "node --no-warnings --loader ts-node/esm", 10 | "cli": "node --no-warnings --loader ts-node/esm src/cli.ts", 11 | "test": "find ./test -name '*.test.ts' | xargs node --no-warnings --loader ts-node/esm --test", 12 | "check": "biome check --apply src test", 13 | "clean": "rm -rf dist", 14 | "build": "pnpm clean && tsc -p tsconfig.build.json && chmod +x dist/cli.js && cp -r src/sql dist/sql", 15 | "bumpversion": "bump --commit --tag", 16 | "npm-publish": "scripts/publish.sh" 17 | }, 18 | "bin": { 19 | "sqlauthz": "./dist/cli.js" 20 | }, 21 | "keywords": [ 22 | "postgresql", 23 | "oso", 24 | "rbac", 25 | "pg", 26 | "permission", 27 | "declarative" 28 | ], 29 | "homepage": "https://github.com/cfeenstra67/sqlauthz", 30 | "bugs": { 31 | "url": "https://github.com/cfeenstra67/sqlauthz/issues" 32 | }, 33 | "files": [ 34 | "package.json", 35 | "README.md", 36 | "LICENSE.txt", 37 | "src", 38 | "dist" 39 | ], 40 | "author": "cameron.l.feenstra@gmail.com", 41 | "repository": { 42 | "type": "git", 43 | "url": "https://github.com/cfeenstra67/sqlauthz" 44 | }, 45 | "license": "MIT", 46 | "dependencies": { 47 | "dotenv": "^16.3.1", 48 | "fdir": "^6.1.1", 49 | "oso": "^0.27.0", 50 | "pg": "^8.11.3", 51 | "picomatch": "^3.0.1", 52 | "yargs": "^17.7.2" 53 | }, 54 | "devDependencies": { 55 | "@biomejs/biome": "^1.4.1", 56 | "@jsdevtools/version-bump-prompt": "^6.1.0", 57 | "@swc/core": "^1.3.99", 58 | "@types/node": "^20.10.0", 59 | "@types/pg": "^8.10.9", 60 | "@types/yargs": "^17.0.32", 61 | "ts-node": "^10.9.1", 62 | "typescript": "^5.5.4" 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /scripts/publish.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | VERSION=$(cat package.json | jq -r .version) 4 | 5 | TAG=$( [[ $VERSION =~ -([a-z]+)\.[0-9]+$ ]] && echo ${BASH_REMATCH[1]} || echo latest ) 6 | 7 | pnpm publish --access public --no-git-checks --tag $TAG 8 | -------------------------------------------------------------------------------- /src/api.ts: -------------------------------------------------------------------------------- 1 | import { SQLBackend, SQLEntities } from "./backend.js"; 2 | import { CreateOsoArgs, createOso } from "./oso.js"; 3 | import { 4 | UserRevokePolicy, 5 | deduplicatePermissions, 6 | getRevokeActors, 7 | parsePermissions, 8 | } from "./parser.js"; 9 | import { constructFullQuery } from "./sql.js"; 10 | 11 | export interface CompileQueryArgs extends Omit { 12 | backend: SQLBackend; 13 | entities?: SQLEntities; 14 | userRevokePolicy?: UserRevokePolicy; 15 | includeSetupAndTeardown?: boolean; 16 | includeTransaction?: boolean; 17 | strictFields?: boolean; 18 | allowAnyActor?: boolean; 19 | debug?: boolean; 20 | } 21 | 22 | export interface CompileQuerySuccess { 23 | type: "success"; 24 | query: string; 25 | } 26 | 27 | export interface CompileQueryError { 28 | type: "error"; 29 | errors: string[]; 30 | } 31 | 32 | export type CompileQueryResult = CompileQuerySuccess | CompileQueryError; 33 | 34 | export async function compileQuery({ 35 | backend, 36 | entities, 37 | userRevokePolicy, 38 | includeSetupAndTeardown, 39 | includeTransaction, 40 | debug, 41 | strictFields, 42 | allowAnyActor, 43 | paths, 44 | vars, 45 | }: CompileQueryArgs): Promise { 46 | if (entities === undefined) { 47 | entities = await backend.fetchEntities(); 48 | } 49 | 50 | const { oso, literalsContext } = await createOso({ 51 | paths, 52 | functions: entities.functions, 53 | vars, 54 | }); 55 | 56 | const result = await parsePermissions({ 57 | oso, 58 | entities, 59 | debug, 60 | strictFields, 61 | allowAnyActor, 62 | literalsContext, 63 | }); 64 | 65 | if (result.type !== "success") { 66 | return result; 67 | } 68 | 69 | const permissions = deduplicatePermissions(result.permissions); 70 | 71 | const actorsToRevoke = getRevokeActors({ 72 | userRevokePolicy, 73 | permissions, 74 | entities, 75 | }); 76 | 77 | if (actorsToRevoke.type !== "success") { 78 | return actorsToRevoke; 79 | } 80 | 81 | const context = await backend.getContext(entities); 82 | 83 | const fullQuery = constructFullQuery({ 84 | entities, 85 | context, 86 | permissions, 87 | revokeUsers: actorsToRevoke.users, 88 | includeSetupAndTeardown, 89 | includeTransaction, 90 | }); 91 | 92 | return { type: "success", query: fullQuery }; 93 | } 94 | -------------------------------------------------------------------------------- /src/backend.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Permission, 3 | SQLActor, 4 | SQLFunction, 5 | SQLGroup, 6 | SQLProcedure, 7 | SQLRowLevelSecurityPolicy, 8 | SQLSchema, 9 | SQLSequence, 10 | SQLTableMetadata, 11 | SQLUser, 12 | SQLView, 13 | } from "./sql.js"; 14 | 15 | export interface SQLEntities { 16 | users: SQLUser[]; 17 | groups: SQLGroup[]; 18 | schemas: SQLSchema[]; 19 | tables: SQLTableMetadata[]; 20 | views: SQLView[]; 21 | rlsPolicies: SQLRowLevelSecurityPolicy[]; 22 | functions: SQLFunction[]; 23 | procedures: SQLProcedure[]; 24 | sequences: SQLSequence[]; 25 | } 26 | 27 | export interface SQLBackendContext { 28 | setupQuery?: string; 29 | teardownQuery?: string; 30 | transactionStartQuery?: string; 31 | transactionCommitQuery?: string; 32 | removeAllPermissionsFromActorsQueries: ( 33 | users: SQLActor[], 34 | entities: SQLEntities, 35 | ) => string[]; 36 | compileGrantQueries: ( 37 | permissions: Permission[], 38 | entities: SQLEntities, 39 | ) => string[]; 40 | } 41 | 42 | export interface SQLBackend { 43 | fetchEntities: () => Promise; 44 | 45 | getContext: (entities: SQLEntities) => Promise; 46 | } 47 | -------------------------------------------------------------------------------- /src/clause.ts: -------------------------------------------------------------------------------- 1 | import { Variable } from "oso"; 2 | import { Expression } from "oso/dist/src/Expression.js"; 3 | import { Pattern } from "oso/dist/src/Pattern.js"; 4 | import { Predicate } from "oso/dist/src/Predicate.js"; 5 | import { PolarOperator } from "oso/dist/src/types.js"; 6 | import { arrayProduct } from "./utils.js"; 7 | 8 | export interface Literal { 9 | readonly type: "value"; 10 | readonly value: unknown; 11 | } 12 | 13 | export interface FunctionCall { 14 | readonly type: "function-call"; 15 | readonly schema: string; 16 | readonly name: string; 17 | readonly args: Value[]; 18 | } 19 | 20 | export interface Column { 21 | readonly type: "column"; 22 | readonly value: string; 23 | } 24 | 25 | export type Value = Literal | Column | FunctionCall; 26 | 27 | export interface ExpressionClause { 28 | readonly type: "expression"; 29 | readonly operator: PolarOperator; 30 | readonly values: readonly [Value, Value]; 31 | } 32 | 33 | export interface NotClause { 34 | readonly type: "not"; 35 | readonly clause: Clause; 36 | } 37 | 38 | export interface AndClause { 39 | readonly type: "and"; 40 | readonly clauses: readonly Clause[]; 41 | } 42 | 43 | export interface OrClause { 44 | readonly type: "or"; 45 | readonly clauses: readonly Clause[]; 46 | } 47 | 48 | export type Clause = 49 | | ExpressionClause 50 | | NotClause 51 | | AndClause 52 | | OrClause 53 | | Value; 54 | 55 | export const TrueClause = { 56 | type: "and", 57 | clauses: [], 58 | } as const satisfies AndClause; 59 | 60 | export const FalseClause = { 61 | type: "or", 62 | clauses: [], 63 | } as const satisfies OrClause; 64 | 65 | export function isTrueClause( 66 | clause: Clause, 67 | ): clause is AndClause & { clauses: [] } { 68 | return clause.type === "and" && clause.clauses.length === 0; 69 | } 70 | 71 | export function isFalseClause( 72 | clause: Clause, 73 | ): clause is OrClause & { clauses: [] } { 74 | return clause.type === "or" && clause.clauses.length === 0; 75 | } 76 | 77 | export function isColumn(clause: Clause): clause is Column { 78 | return clause.type === "column"; 79 | } 80 | 81 | export function isValue(clause: Clause): clause is Value { 82 | return clause.type === "value"; 83 | } 84 | 85 | export function mapClauses( 86 | clause: Clause, 87 | func: (clause: Clause) => Clause, 88 | ): Clause { 89 | if (clause.type === "and" || clause.type === "or") { 90 | const subClauses = clause.clauses.map((subClause) => 91 | mapClauses(subClause, func), 92 | ); 93 | return func({ 94 | type: clause.type, 95 | clauses: subClauses, 96 | }); 97 | } 98 | if (clause.type === "not") { 99 | const subClause = mapClauses(clause.clause, func); 100 | return func({ 101 | type: "not", 102 | clause: subClause, 103 | }); 104 | } 105 | if (clause.type === "expression") { 106 | const newValues = clause.values.map((value) => mapClauses(value, func)) as [ 107 | Value, 108 | Value, 109 | ]; 110 | return func({ 111 | type: "expression", 112 | operator: clause.operator, 113 | values: newValues, 114 | }); 115 | } 116 | if (clause.type === "function-call") { 117 | const values = clause.args.map((arg) => mapClauses(arg, func)) as Value[]; 118 | return func({ 119 | type: "function-call", 120 | schema: clause.schema, 121 | name: clause.name, 122 | args: values, 123 | }); 124 | } 125 | return func(clause); 126 | } 127 | 128 | function clausesEqual(clause1: Clause, clause2: Clause): boolean { 129 | if (clause1.type !== clause2.type) { 130 | return false; 131 | } 132 | if ( 133 | (clause1.type === "and" && clause2.type === "and") || 134 | (clause1.type === "or" && clause2.type === "or") 135 | ) { 136 | const deduped1 = deduplicateClauses(clause1.clauses); 137 | const deduped2 = deduplicateClauses(clause2.clauses); 138 | return ( 139 | deduped1.length === deduped2.length && 140 | deduped1.every((clause, idx) => clausesEqual(clause, deduped2[idx]!)) 141 | ); 142 | } 143 | if (clause1.type === "not" && clause2.type === "not") { 144 | return clausesEqual(clause1.clause, clause2.clause); 145 | } 146 | if (clause1.type === "expression" && clause2.type === "expression") { 147 | return ( 148 | clause1.operator === clause2.operator && 149 | clause1.values.every((value, idx) => 150 | clausesEqual(value, clause2.values[idx]!), 151 | ) 152 | ); 153 | } 154 | if ( 155 | (clause1.type === "value" && clause2.type === "value") || 156 | (clause1.type === "column" && clause2.type === "column") 157 | ) { 158 | return clause1.value === clause2.value; 159 | } 160 | if (clause1.type === "function-call" && clause2.type === "function-call") { 161 | return ( 162 | clause1.name === clause2.name && 163 | clause1.schema === clause2.schema && 164 | clause1.args.length === clause2.args.length && 165 | clause1.args.every((arg, idx) => arg === clause2.args[idx]) 166 | ); 167 | } 168 | return false; 169 | } 170 | 171 | function deduplicateClauses(clauses: readonly Clause[]): readonly Clause[] { 172 | if (clauses.length <= 1) { 173 | return clauses; 174 | } 175 | if (clauses.length === 2) { 176 | if (clausesEqual(clauses[0]!, clauses[1]!)) { 177 | return [clauses[0]!]; 178 | } 179 | return clauses; 180 | } 181 | const first = clauses[0]!; 182 | const rest = deduplicateClauses(clauses.slice(1)); 183 | const out: Clause[] = [first]; 184 | for (const clause of rest) { 185 | if (!clausesEqual(first, clause)) { 186 | out.push(clause); 187 | } 188 | } 189 | return out; 190 | } 191 | 192 | export function optimizeClause(clause: Clause): Clause { 193 | if (clause.type === "and") { 194 | const outClauses: Clause[] = []; 195 | for (const subClause of deduplicateClauses(clause.clauses)) { 196 | const optimized = optimizeClause(subClause); 197 | if (isTrueClause(optimized)) { 198 | continue; 199 | } 200 | if (isFalseClause(optimized)) { 201 | return FalseClause; 202 | } 203 | if (optimized.type === "and") { 204 | outClauses.push(...optimized.clauses); 205 | continue; 206 | } 207 | outClauses.push(optimized); 208 | } 209 | 210 | if (outClauses.length === 1) { 211 | return outClauses[0]!; 212 | } 213 | 214 | return { 215 | type: "and", 216 | clauses: outClauses, 217 | }; 218 | } 219 | 220 | if (clause.type === "or") { 221 | const outClauses: Clause[] = []; 222 | for (const subClause of deduplicateClauses(clause.clauses)) { 223 | const optimized = optimizeClause(subClause); 224 | if (isTrueClause(optimized)) { 225 | return TrueClause; 226 | } 227 | if (isFalseClause(optimized)) { 228 | continue; 229 | } 230 | if (optimized.type === "or") { 231 | outClauses.push(...optimized.clauses); 232 | continue; 233 | } 234 | outClauses.push(optimized); 235 | } 236 | 237 | if (outClauses.length === 1) { 238 | return outClauses[0]!; 239 | } 240 | 241 | return { type: "or", clauses: outClauses }; 242 | } 243 | 244 | if (clause.type === "not") { 245 | const optimized = optimizeClause(clause.clause); 246 | if (optimized.type === "and") { 247 | const orClause: OrClause = { 248 | type: "or", 249 | clauses: optimized.clauses.map((subClause) => { 250 | return { type: "not", clause: subClause }; 251 | }), 252 | }; 253 | return optimizeClause(orClause); 254 | } 255 | if (optimized.type === "or") { 256 | const andClause: AndClause = { 257 | type: "and", 258 | clauses: optimized.clauses.map((subClause) => ({ 259 | type: "not", 260 | clause: subClause, 261 | })), 262 | }; 263 | return optimizeClause(andClause); 264 | } 265 | return { type: "not", clause: optimized }; 266 | } 267 | 268 | return clause; 269 | } 270 | 271 | export function valueToClause(value: unknown): Clause { 272 | if (value instanceof Expression) { 273 | if (value.operator === "And") { 274 | const outClauses = value.args.map((arg) => valueToClause(arg)); 275 | 276 | return { type: "and", clauses: outClauses }; 277 | } 278 | if (value.operator === "Or") { 279 | const outClauses = value.args.map((arg) => valueToClause(arg)); 280 | 281 | return { type: "or", clauses: outClauses }; 282 | } 283 | if (value.operator === "Dot") { 284 | if (typeof value.args[0] === "string") { 285 | const col: Column = { 286 | type: "column", 287 | value: ["_this", value.args[1]].join("."), 288 | }; 289 | 290 | return { 291 | type: "and", 292 | clauses: [ 293 | col, 294 | { 295 | type: "expression", 296 | operator: "Eq", 297 | values: [ 298 | { type: "column", value: "_this" }, 299 | { type: "value", value: value.args[0] }, 300 | ], 301 | }, 302 | ], 303 | }; 304 | } 305 | 306 | const args = value.args.map((arg) => valueToClause(arg)); 307 | const src = args[0] as Value | AndClause; 308 | const name = args[1] as Value; 309 | 310 | // TODO: is this the right behavior? 311 | if (src.type === "function-call" || name.type === "function-call") { 312 | throw new Error("Unexpected function call"); 313 | } 314 | 315 | if (src.type === "and") { 316 | const col = src.clauses[0] as Column; 317 | const newCol: Column = { 318 | type: "column", 319 | value: [col.value, name.value].join("."), 320 | }; 321 | 322 | return { 323 | type: "and", 324 | clauses: [newCol, ...src.clauses.slice(1)], 325 | }; 326 | } 327 | 328 | return { 329 | type: "column", 330 | value: [src.value, name.value].join("."), 331 | }; 332 | } 333 | if (value.operator === "Not") { 334 | const subClause = valueToClause(value.args[0]); 335 | 336 | return { 337 | type: "not", 338 | clause: subClause, 339 | }; 340 | // Ignore these operators 341 | } 342 | if ( 343 | value.operator === "Cut" || 344 | value.operator === "Assign" || 345 | value.operator === "ForAll" || 346 | value.operator === "Isa" || 347 | value.operator === "Print" 348 | ) { 349 | return TrueClause; 350 | } 351 | const clauses: Clause[] = []; 352 | const leftClause = valueToClause(value.args[0]) as Value | AndClause; 353 | let left: Value; 354 | if (leftClause.type === "and") { 355 | left = leftClause.clauses[0] as Value; 356 | clauses.push(...leftClause.clauses.slice(1)); 357 | } else { 358 | left = leftClause; 359 | } 360 | 361 | const rightClause = valueToClause(value.args[1]) as Value | AndClause; 362 | let right: Value; 363 | if (rightClause.type === "and") { 364 | right = rightClause.clauses[0] as Value; 365 | clauses.push(...rightClause.clauses.slice(1)); 366 | } else { 367 | right = rightClause; 368 | } 369 | 370 | const operator = value.operator === "Unify" ? "Eq" : value.operator; 371 | 372 | const newClause: ExpressionClause = { 373 | type: "expression", 374 | operator, 375 | values: [left, right], 376 | }; 377 | 378 | if (clauses.length > 0) { 379 | return { type: "and", clauses: [newClause, ...clauses] }; 380 | } 381 | return newClause; 382 | } 383 | if (value instanceof Variable) { 384 | return { 385 | type: "column", 386 | value: value.name, 387 | }; 388 | } 389 | if (value instanceof Pattern) { 390 | // TODO 391 | return TrueClause; 392 | } 393 | if (value instanceof Predicate) { 394 | const parts = value.name.split("."); 395 | let schema: string; 396 | let name: string; 397 | if (parts.length === 1) { 398 | schema = ""; 399 | name = parts[0]!; 400 | } else { 401 | schema = parts[0]!; 402 | name = parts[1]!; 403 | } 404 | 405 | const clauses: Clause[] = []; 406 | const args: Value[] = []; 407 | for (const arg of value.args) { 408 | const subClause = valueToClause(arg) as Value | AndClause; 409 | if (subClause.type === "and") { 410 | args.push(subClause.clauses[0] as Value); 411 | clauses.push(...subClause.clauses.slice(1)); 412 | } else { 413 | args.push(subClause); 414 | } 415 | } 416 | 417 | const newClause: FunctionCall = { 418 | type: "function-call", 419 | schema: schema!, 420 | name: name!, 421 | args, 422 | }; 423 | 424 | if (clauses.length > 0) { 425 | return { type: "and", clauses: [newClause, ...clauses] }; 426 | } 427 | return newClause; 428 | } 429 | 430 | return { type: "value", value }; 431 | } 432 | 433 | export function factorOrClauses(clause: Clause): Clause[] { 434 | const inner = (clause: Clause): Clause[] => { 435 | if (clause.type === "and") { 436 | const subOrs = clause.clauses.map((subClause) => 437 | factorOrClauses(subClause), 438 | ); 439 | 440 | return Array.from(arrayProduct(subOrs)).map((subClauses) => ({ 441 | type: "and", 442 | clauses: subClauses, 443 | })); 444 | } 445 | 446 | if (clause.type === "or") { 447 | return clause.clauses.flatMap((subClause) => factorOrClauses(subClause)); 448 | } 449 | 450 | if (clause.type === "not") { 451 | const subClauses = factorOrClauses(clause.clause); 452 | if (subClauses.length > 1) { 453 | const negativeAndClause: AndClause = { 454 | type: "and", 455 | clauses: subClauses.map((subClause) => ({ 456 | type: "not", 457 | clause: subClause, 458 | })), 459 | }; 460 | return factorOrClauses(negativeAndClause); 461 | } 462 | return [{ type: "not", clause: subClauses[0]! }]; 463 | } 464 | 465 | return [clause]; 466 | }; 467 | 468 | return inner(optimizeClause(clause)).map((subClause) => 469 | optimizeClause(subClause), 470 | ); 471 | } 472 | 473 | export interface EvaluateClauseArgs { 474 | clause: Clause; 475 | evaluate: ( 476 | expr: Exclude, 477 | ) => EvaluateClauseResult; 478 | strictFields?: boolean; 479 | } 480 | 481 | export interface EvaluateClauseSuccess { 482 | type: "success"; 483 | result: boolean; 484 | } 485 | 486 | export interface EvaluateClauseError { 487 | type: "error"; 488 | errors: string[]; 489 | } 490 | 491 | export type EvaluateClauseResult = EvaluateClauseSuccess | EvaluateClauseError; 492 | 493 | export function evaluateClause({ 494 | clause, 495 | evaluate, 496 | strictFields, 497 | }: EvaluateClauseArgs): EvaluateClauseResult { 498 | if (clause.type === "and") { 499 | const errors: string[] = []; 500 | let result = true; 501 | for (const subClause of clause.clauses) { 502 | const clauseResult = evaluateClause({ clause: subClause, evaluate }); 503 | if (clauseResult.type === "success") { 504 | result &&= clauseResult.result; 505 | } else { 506 | errors.push(...clauseResult.errors); 507 | } 508 | } 509 | if ((strictFields || result) && errors.length > 0) { 510 | return { type: "error", errors }; 511 | } 512 | return { type: "success", result }; 513 | } 514 | if (clause.type === "or") { 515 | const errors: string[] = []; 516 | let result = false; 517 | for (const subClause of clause.clauses) { 518 | const clauseResult = evaluateClause({ clause: subClause, evaluate }); 519 | if (clauseResult.type === "success") { 520 | result ||= clauseResult.result; 521 | } else { 522 | errors.push(...clauseResult.errors); 523 | } 524 | } 525 | if (errors.length > 0) { 526 | return { type: "error", errors }; 527 | } 528 | return { type: "success", result }; 529 | } 530 | if (clause.type === "not") { 531 | const clauseResult = evaluateClause({ clause: clause.clause, evaluate }); 532 | if (clauseResult.type === "success") { 533 | return { type: "success", result: !clauseResult.result }; 534 | } 535 | return { type: "error", errors: clauseResult.errors }; 536 | } 537 | 538 | return evaluate(clause); 539 | } 540 | 541 | export interface SimpleEvaluatorArgs { 542 | variableName: string; 543 | errorVariableName: string; 544 | // biome-ignore lint/suspicious/noExplicitAny: needed here 545 | getValue: (value: Value) => any; 546 | } 547 | 548 | export function simpleEvaluator({ 549 | variableName, 550 | errorVariableName, 551 | getValue, 552 | }: SimpleEvaluatorArgs): EvaluateClauseArgs["evaluate"] { 553 | const func: EvaluateClauseArgs["evaluate"] = (expr) => { 554 | if (expr.type === "column" && expr.value === variableName) { 555 | return { type: "success", result: true }; 556 | } 557 | if (expr.type === "column") { 558 | return { 559 | type: "error", 560 | errors: [`${errorVariableName}: invalid reference: ${expr.value}`], 561 | }; 562 | } 563 | if (expr.type === "value") { 564 | return func({ 565 | type: "expression", 566 | operator: "Eq", 567 | values: [{ type: "column", value: "_this" }, expr], 568 | }); 569 | } 570 | if (expr.type === "function-call") { 571 | // TODO: is this the right behavior? 572 | return { 573 | type: "error", 574 | errors: [`${errorVariableName}: unexpected function call`], 575 | }; 576 | } 577 | let operatorFunc: (a: unknown, b: unknown) => boolean; 578 | if (expr.operator === "Eq") { 579 | operatorFunc = (a, b) => a === b; 580 | } else if (expr.operator === "Neq") { 581 | operatorFunc = (a, b) => a !== b; 582 | } else if (expr.operator === "Geq") { 583 | operatorFunc = (a, b) => (a as string | number) >= (b as string | number); 584 | } else if (expr.operator === "Gt") { 585 | operatorFunc = (a, b) => (a as string | number) > (b as string | number); 586 | } else if (expr.operator === "Lt") { 587 | operatorFunc = (a, b) => (a as string | number) < (b as string | number); 588 | } else if (expr.operator === "Leq") { 589 | operatorFunc = (a, b) => (a as string | number) <= (b as string | number); 590 | } else { 591 | return { 592 | type: "error", 593 | errors: [ 594 | `${errorVariableName}: unsupported operator: ${expr.operator}`, 595 | ], 596 | }; 597 | } 598 | if (expr.values[0].type === "value" && expr.values[1].type === "value") { 599 | return { 600 | type: "success", 601 | result: operatorFunc(expr.values[0].value, expr.values[1].value), 602 | }; 603 | } 604 | const errors: string[] = []; 605 | // biome-ignore lint/suspicious/noExplicitAny: needed here 606 | let left: any; 607 | // biome-ignore lint/suspicious/noExplicitAny: needed here 608 | let right: any; 609 | try { 610 | left = getValue(expr.values[0]); 611 | } catch (error) { 612 | if (error instanceof ValidationError) { 613 | errors.push(error.message); 614 | } else { 615 | throw error; 616 | } 617 | } 618 | try { 619 | right = getValue(expr.values[1]); 620 | } catch (error) { 621 | if (error instanceof ValidationError) { 622 | errors.push(error.message); 623 | } else { 624 | throw error; 625 | } 626 | } 627 | 628 | if (errors.length > 0) { 629 | return { type: "error", errors }; 630 | } 631 | 632 | return { type: "success", result: operatorFunc(left, right) }; 633 | }; 634 | 635 | return func; 636 | } 637 | 638 | export class ValidationError extends Error { 639 | constructor(readonly message: string) { 640 | super(message); 641 | Object.setPrototypeOf(this, new.target.prototype); 642 | } 643 | } 644 | -------------------------------------------------------------------------------- /src/cli.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import fs from "node:fs"; 3 | import path from "node:path"; 4 | import pg from "pg"; 5 | import yargs from "yargs"; 6 | import { hideBin } from "yargs/helpers"; 7 | import { compileQuery } from "./api.js"; 8 | import { OsoError } from "./oso.js"; 9 | import { UserRevokePolicy } from "./parser.js"; 10 | import { PostgresBackend } from "./pg-backend.js"; 11 | import { PathNotFound, strictGlob } from "./utils.js"; 12 | 13 | function parseVar(value: string): [string, unknown] { 14 | const parts = value.split("=", 2); 15 | if (parts.length !== 2) { 16 | throw new Error( 17 | `Invalid variable value: ${value}. Must use name=value syntax`, 18 | ); 19 | } 20 | const key = parts[0]!; 21 | let outValue = parts[1]!; 22 | try { 23 | outValue = JSON.parse(outValue); 24 | } catch (error) { 25 | if (!(error instanceof SyntaxError)) { 26 | throw error; 27 | } 28 | } 29 | return [key, outValue]; 30 | } 31 | 32 | async function main() { 33 | if (!process.env.NO_DOTENV) { 34 | await import("dotenv/config"); 35 | } 36 | 37 | const args = await yargs(hideBin(process.argv)) 38 | .scriptName("sqlauthz") 39 | .usage("$0 [args]", "Declaratively manage PostgreSQL permissions") 40 | .option("rules", { 41 | alias: "r", 42 | type: "string", 43 | description: 44 | "Polar rule file(s) defining permissions. " + 45 | "Globs (e.g. `sqlauthz/*.polar`) are supported.", 46 | default: ["sqlauthz.polar"], 47 | array: true, 48 | demandOption: true, 49 | }) 50 | .option("database-url", { 51 | alias: "d", 52 | type: "string", 53 | description: 54 | "Database URL to connect to. Note that you can " + 55 | "specify a value with the format with env: to " + 56 | "read this from a specified environment variable.", 57 | demandOption: true, 58 | }) 59 | .option("revoke-referenced", { 60 | type: "boolean", 61 | description: 62 | "Revoke existing permissions from any user who matches " + 63 | "one of the rules in your .polar file(s) before applying " + 64 | "new permissions. This is the default. Note that only one " + 65 | '"revoke" strategy may be specified.', 66 | conflicts: ["revoke-all", "revoke-users"], 67 | }) 68 | .option("revoke-all", { 69 | type: "boolean", 70 | description: 71 | "Revoke permissions from all users in the database other " + 72 | "than superusers before applying new permissions. Note that " + 73 | 'only one "revoke" strategy may be specified.', 74 | conflicts: ["revoke-users", "revoke-referenced"], 75 | }) 76 | .option("revoke-users", { 77 | type: "string", 78 | array: true, 79 | description: 80 | "Revoke permissions from an explicit list of users before " + 81 | 'applying new permissions. Note that only one "revoke" strategy ' + 82 | "may be specified.", 83 | conflicts: ["revoke-all", "revoke-referenced"], 84 | }) 85 | .option("allow-any-actor", { 86 | type: "boolean", 87 | description: 88 | "Allow rules that do not limit the `actor` in any way. This is " + 89 | "potentially dangerous, so it will fail by default. However " + 90 | "providing this argument can disable that so that empty actor " + 91 | "queries will be allowed", 92 | default: false, 93 | }) 94 | .option("var", { 95 | type: "string", 96 | array: true, 97 | description: 98 | "Define variable(s) that can be referenced in your rules files " + 99 | "by specifying a value of `varname=varvalue`. The variables " + 100 | "will be attempted to be parsed as JSON, otherwise they will " + 101 | "be treated as strings. Variables can be access in rules files " + 102 | "via `var.`. For more flexibility, also see --var-file", 103 | }) 104 | .option("var-file", { 105 | type: "string", 106 | array: true, 107 | description: 108 | "File paths to .js scripts or JSON files that will be loaded, " + 109 | "and the exports will be available in your rules files as " + 110 | "var..", 111 | }) 112 | .option("dry-run", { 113 | type: "boolean", 114 | description: 115 | "Print full SQL query that would be executed; --dry-run-short only " + 116 | "includes grants.", 117 | conflicts: ["dry-run-short"], 118 | }) 119 | .option("dry-run-short", { 120 | type: "boolean", 121 | description: 122 | "Print GRANT statements that would be generated without running them.", 123 | conflicts: ["dry-run"], 124 | }) 125 | .option("debug", { 126 | type: "boolean", 127 | description: "Print more detailed error information for debugging issues", 128 | default: false, 129 | }) 130 | .pkgConf("sqlauthz") 131 | .env("SQLAUTHZ") 132 | .strict() 133 | .parseAsync(); 134 | 135 | let userRevokePolicy: UserRevokePolicy; 136 | if (args.revokeAll) { 137 | userRevokePolicy = { type: "all" }; 138 | } else if (args.revokeUsers) { 139 | userRevokePolicy = { type: "users", users: args.revokeUsers }; 140 | } else { 141 | userRevokePolicy = { type: "referenced" }; 142 | } 143 | 144 | let rulesPaths: string[]; 145 | try { 146 | rulesPaths = await strictGlob(...args.rules); 147 | } catch (error) { 148 | if (error instanceof PathNotFound) { 149 | console.error("Path not found:", error.path); 150 | process.exit(1); 151 | } 152 | console.error("Unexpected error finding rules files:", error); 153 | process.exit(1); 154 | } 155 | 156 | if (rulesPaths.length === 0) { 157 | console.error(`No rules files matched glob(s): ${args.rules.join(", ")}`); 158 | process.exit(1); 159 | } 160 | 161 | const vars: Record = {}; 162 | let varFiles: string[]; 163 | try { 164 | varFiles = args.varFile ? await strictGlob(...args.varFile) : []; 165 | } catch (error) { 166 | if (error instanceof PathNotFound) { 167 | console.error("Path not found:", error.path); 168 | process.exit(1); 169 | } 170 | console.error("Unexpected error finding variable files:", error); 171 | process.exit(1); 172 | } 173 | 174 | for (const varFile of varFiles) { 175 | if (varFile.endsWith(".json")) { 176 | const content = await fs.promises.readFile(varFile, { encoding: "utf8" }); 177 | let obj: unknown; 178 | try { 179 | obj = JSON.parse(content); 180 | } catch (_) { 181 | console.error(`Unable to parse JSON in ${varFile}`); 182 | process.exit(1); 183 | } 184 | Object.assign(vars, obj); 185 | } else if (varFile.endsWith(".js")) { 186 | const fullPath = path.resolve(varFile); 187 | const mod = await import(fullPath); 188 | Object.assign(vars, mod); 189 | } else { 190 | console.error( 191 | `Invalid var file: ${varFile}. Extension must be .js or .json`, 192 | ); 193 | process.exit(1); 194 | } 195 | } 196 | 197 | try { 198 | for (const varString of args.var ?? []) { 199 | const [key, value] = parseVar(varString); 200 | vars[key] = value; 201 | } 202 | } catch (error) { 203 | console.error("Error parsing variables:", error); 204 | process.exit(1); 205 | } 206 | 207 | const envVariablePrefix = "env:"; 208 | let databaseUrl: string; 209 | if (args.databaseUrl.startsWith(envVariablePrefix)) { 210 | const envVariableName = args.databaseUrl.slice(envVariablePrefix.length); 211 | const envVariable = process.env[envVariableName]; 212 | if (!envVariable) { 213 | console.error( 214 | `Invalid environment variable specified for databaseUrl: ${envVariableName}`, 215 | ); 216 | process.exit(1); 217 | } 218 | databaseUrl = envVariable; 219 | } else { 220 | databaseUrl = args.databaseUrl; 221 | } 222 | 223 | const client = new pg.Client(databaseUrl); 224 | try { 225 | await client.connect(); 226 | } catch (error) { 227 | console.error(`Could not connect to database at '${databaseUrl}':`, error); 228 | process.exit(1); 229 | } 230 | 231 | const backend = new PostgresBackend(client); 232 | 233 | try { 234 | const query = await compileQuery({ 235 | backend, 236 | paths: rulesPaths, 237 | userRevokePolicy, 238 | allowAnyActor: args.allowAnyActor, 239 | includeSetupAndTeardown: !args.dryRunShort, 240 | includeTransaction: !args.dryRunShort, 241 | debug: args.debug, 242 | vars: { var: vars }, 243 | }); 244 | if (query.type !== "success") { 245 | console.error("Unable to compile permission queries. Errors:"); 246 | for (const error of query.errors) { 247 | console.error(error); 248 | } 249 | process.exit(1); 250 | } 251 | 252 | if (args.dryRun || args.dryRunShort) { 253 | if (query.query) { 254 | console.log(query.query); 255 | } else { 256 | console.log("No permissions granted to any users"); 257 | } 258 | return; 259 | } 260 | 261 | await client.query(query.query); 262 | console.log("Permissions updated successfully"); 263 | } catch (error) { 264 | if (error instanceof OsoError) { 265 | console.error("Error loading rules:", error); 266 | } else { 267 | console.error("Unexpected error:", error); 268 | } 269 | process.exit(1); 270 | } finally { 271 | await client.end(); 272 | } 273 | } 274 | 275 | main(); 276 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | import pkg from "../package.json" with { type: "json" }; 2 | 3 | export const VERSION = pkg.version; 4 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { compileQuery } from "./api.js"; 2 | export { PostgresBackend } from "./pg-backend.js"; 3 | -------------------------------------------------------------------------------- /src/oso.ts: -------------------------------------------------------------------------------- 1 | import { AsyncLocalStorage } from "node:async_hooks"; 2 | import { Oso, Variable } from "oso"; 3 | import { Predicate } from "oso/dist/src/Predicate.js"; 4 | import { Value, valueToClause } from "./clause.js"; 5 | import { 6 | FunctionPrivileges, 7 | ProcedurePrivileges, 8 | SQLFunction, 9 | SchemaPrivileges, 10 | SequencePrivileges, 11 | TablePrivileges, 12 | ViewPrivileges, 13 | } from "./sql.js"; 14 | 15 | export interface LiteralsContext { 16 | use: (func: () => Promise) => Promise; 17 | get: () => Map; 18 | } 19 | 20 | export function registerFunctions( 21 | oso: Oso, 22 | functions: SQLFunction[], 23 | ): LiteralsContext { 24 | const storage = new AsyncLocalStorage>(); 25 | let varIndex = 1; 26 | 27 | const get = () => { 28 | const map = storage.getStore(); 29 | if (!map) { 30 | throw new Error("Not in SQL literal context"); 31 | } 32 | return map; 33 | }; 34 | 35 | const osoFunctionCaller = (name: string, schema: string) => { 36 | const fullName = `${schema}.${name}`; 37 | // biome-ignore lint/complexity/useArrowFunction: Need the `name` attribute 38 | const result = function (...args: unknown[]) { 39 | return new Predicate(fullName, args); 40 | }; 41 | Object.defineProperty(result, "name", { value: fullName, writable: false }); 42 | return result; 43 | }; 44 | 45 | const schemaFunctions: Record< 46 | string, 47 | Record Predicate> 48 | > = {}; 49 | const topLevelFunctions: Record< 50 | string, 51 | (...args: unknown[]) => Predicate | Variable 52 | > = {}; 53 | 54 | for (const sqlFunc of functions) { 55 | const osoFunc = osoFunctionCaller(sqlFunc.name, sqlFunc.schema); 56 | if (sqlFunc.builtin) { 57 | topLevelFunctions[sqlFunc.name] = osoFunc; 58 | } 59 | schemaFunctions[sqlFunc.schema] ??= {}; 60 | schemaFunctions[sqlFunc.schema]![sqlFunc.name] = osoFunc; 61 | } 62 | 63 | Object.assign(topLevelFunctions, schemaFunctions); 64 | 65 | topLevelFunctions.lit = function lit(arg) { 66 | const varName = `lit_${varIndex}`; 67 | varIndex++; 68 | const map = get(); 69 | map.set(varName, valueToClause(arg) as Value); 70 | return new Variable(varName); 71 | }; 72 | 73 | topLevelFunctions.cast = function cast(arg, type) { 74 | return new Predicate("cast", [arg, type]); 75 | }; 76 | 77 | oso.registerConstant(topLevelFunctions, "sql"); 78 | 79 | const permissions = { 80 | schema: SchemaPrivileges, 81 | table: TablePrivileges, 82 | view: ViewPrivileges, 83 | function: FunctionPrivileges, 84 | procedure: ProcedurePrivileges, 85 | sequence: SequencePrivileges, 86 | }; 87 | 88 | oso.registerConstant(permissions, "permissions"); 89 | 90 | return { 91 | use: (func) => storage.run(new Map(), func), 92 | get, 93 | }; 94 | } 95 | 96 | export interface CreateOsoArgs { 97 | paths: string[]; 98 | functions: SQLFunction[]; 99 | vars?: Record; 100 | } 101 | 102 | export interface CreateOsoResult { 103 | oso: Oso; 104 | literalsContext: LiteralsContext; 105 | } 106 | 107 | export async function createOso({ 108 | paths, 109 | functions, 110 | vars, 111 | }: CreateOsoArgs): Promise { 112 | const oso = new Oso(); 113 | 114 | const literalsContext = registerFunctions(oso, functions); 115 | 116 | for (const [key, value] of Object.entries(vars ?? {})) { 117 | oso.registerConstant(value, key); 118 | } 119 | 120 | try { 121 | await oso.loadFiles(paths); 122 | } catch (error) { 123 | if (error instanceof Error) { 124 | throw new OsoError(error.message); 125 | } 126 | throw error; 127 | } 128 | 129 | return { oso, literalsContext }; 130 | } 131 | 132 | export class OsoError extends Error {} 133 | -------------------------------------------------------------------------------- /src/parser.ts: -------------------------------------------------------------------------------- 1 | import { Oso, Variable } from "oso"; 2 | import { SQLEntities } from "./backend.js"; 3 | import { 4 | Clause, 5 | Column, 6 | EvaluateClauseArgs, 7 | ValidationError, 8 | Value, 9 | evaluateClause, 10 | factorOrClauses, 11 | isColumn, 12 | isTrueClause, 13 | isValue, 14 | mapClauses, 15 | optimizeClause, 16 | simpleEvaluator, 17 | valueToClause, 18 | } from "./clause.js"; 19 | import { LiteralsContext } from "./oso.js"; 20 | import { 21 | FunctionPermission, 22 | FunctionPrivileges, 23 | Permission, 24 | Privilege, 25 | ProcedurePermission, 26 | ProcedurePrivileges, 27 | SQLActor, 28 | SQLFunction, 29 | SQLProcedure, 30 | SQLSchema, 31 | SQLSequence, 32 | SQLTableMetadata, 33 | SQLView, 34 | SchemaPermission, 35 | SchemaPrivileges, 36 | SequencePermission, 37 | SequencePrivileges, 38 | TablePermission, 39 | TablePrivileges, 40 | ViewPermission, 41 | ViewPrivileges, 42 | formatQualifiedName, 43 | } from "./sql.js"; 44 | import { arrayProduct } from "./utils.js"; 45 | 46 | export interface ConvertPermissionSuccess

{ 47 | type: "success"; 48 | permissions: P[]; 49 | } 50 | 51 | export interface ConvertPermissionError { 52 | type: "error"; 53 | errors: string[]; 54 | } 55 | 56 | export type ConvertPermissionResult

= 57 | | ConvertPermissionSuccess

58 | | ConvertPermissionError; 59 | 60 | interface ActorEvaluatorArgs { 61 | actor: SQLActor; 62 | debug?: boolean; 63 | } 64 | 65 | function actorEvaluator({ 66 | actor, 67 | debug, 68 | }: ActorEvaluatorArgs): EvaluateClauseArgs["evaluate"] { 69 | const variableName = "actor"; 70 | const errorVariableName = debug 71 | ? actor.type === "user" 72 | ? `user(${actor.name})` 73 | : `group(${actor.name})` 74 | : variableName; 75 | return simpleEvaluator({ 76 | variableName, 77 | errorVariableName, 78 | getValue: (value) => { 79 | if (value.type === "value") { 80 | return value.value; 81 | } 82 | if (value.type === "function-call") { 83 | throw new ValidationError( 84 | `${errorVariableName}: invalid function call`, 85 | ); 86 | } 87 | if (value.value === "_this" || value.value === "_this.name") { 88 | return actor.name; 89 | } 90 | if (value.value === "_this.type") { 91 | return actor.type; 92 | } 93 | throw new ValidationError( 94 | `${errorVariableName}: invalid user field: ${value.value}`, 95 | ); 96 | }, 97 | }); 98 | } 99 | 100 | function validateActorClause( 101 | clause: Clause, 102 | actorNames: Set, 103 | ): ConvertPermissionError | null { 104 | const validateTopLevel = (clause: Clause): ConvertPermissionError | null => { 105 | if (clause.type === "value") { 106 | if (typeof clause.value !== "string" || !actorNames.has(clause.value)) { 107 | return { 108 | type: "error", 109 | errors: [`Invalid user or group name: ${clause.value}`], 110 | }; 111 | } 112 | return null; 113 | } 114 | 115 | if (clause.type === "expression") { 116 | const columnValue = clause.values.filter(isColumn).at(0); 117 | const valueValue = clause.values.filter(isValue).at(0); 118 | if ( 119 | columnValue && 120 | valueValue && 121 | clause.operator === "Eq" && 122 | (columnValue.value === "_this" || columnValue.value === "_this.name") 123 | ) { 124 | return validateTopLevel(valueValue); 125 | } 126 | 127 | return null; 128 | } 129 | 130 | if (clause.type === "and" || clause.type === "or") { 131 | const results = clause.clauses.map(validateTopLevel); 132 | const errors: string[] = []; 133 | 134 | for (const result of results) { 135 | if (result === null) { 136 | continue; 137 | } 138 | errors.push(...result.errors); 139 | } 140 | 141 | return errors.length > 0 ? { type: "error", errors } : null; 142 | } 143 | 144 | if (clause.type === "not") { 145 | return validateTopLevel(clause.clause); 146 | } 147 | 148 | return null; 149 | }; 150 | 151 | return validateTopLevel(clause); 152 | } 153 | 154 | function validateResourceClause(clause: Clause): ConvertPermissionError | null { 155 | const resourceTypes = new Set(Object.keys(handlers)); 156 | 157 | const validateTopLevel = (clause: Clause): ConvertPermissionError | null => { 158 | if (clause.type === "value") { 159 | return null; 160 | } 161 | 162 | if (clause.type === "expression") { 163 | const columnValue = clause.values.filter(isColumn).at(0); 164 | const valueValue = clause.values.filter(isValue).at(0); 165 | if ( 166 | columnValue && 167 | valueValue && 168 | clause.operator === "Eq" && 169 | columnValue.value === "_this.type" && 170 | valueValue.type === "value" 171 | ) { 172 | if (typeof valueValue.value !== "string") { 173 | return { 174 | type: "error", 175 | errors: [`Invalid object type: '${valueValue.value}'`], 176 | }; 177 | } 178 | 179 | const lowerValue = valueValue.value.toLowerCase(); 180 | if (!resourceTypes.has(lowerValue)) { 181 | return { 182 | type: "error", 183 | errors: [`Invalid object type: '${valueValue.value}'`], 184 | }; 185 | } 186 | 187 | return null; 188 | } 189 | 190 | return null; 191 | } 192 | 193 | if (clause.type === "and" || clause.type === "or") { 194 | const results = clause.clauses.map(validateTopLevel); 195 | const errors: string[] = []; 196 | 197 | for (const result of results) { 198 | if (result === null) { 199 | continue; 200 | } 201 | errors.push(...result.errors); 202 | } 203 | 204 | return errors.length > 0 ? { type: "error", errors } : null; 205 | } 206 | 207 | if (clause.type === "not") { 208 | return validateTopLevel(clause.clause); 209 | } 210 | 211 | return null; 212 | }; 213 | 214 | return validateTopLevel(clause); 215 | } 216 | 217 | function getReferencedPrivileges(clause: Clause): unknown[] { 218 | const validateTopLevel = (clause: Clause): unknown[] => { 219 | if (clause.type === "value") { 220 | return [clause.value]; 221 | } 222 | 223 | if (clause.type === "expression") { 224 | const columnValue = clause.values.filter(isColumn).at(0); 225 | const valueValue = clause.values.filter(isValue).at(0); 226 | if ( 227 | columnValue && 228 | valueValue && 229 | clause.operator === "Eq" && 230 | columnValue.value === "_this" 231 | ) { 232 | return validateTopLevel(valueValue); 233 | } 234 | 235 | return []; 236 | } 237 | 238 | if (clause.type === "and" || clause.type === "or") { 239 | const results = clause.clauses.map(validateTopLevel); 240 | const privileges: unknown[] = []; 241 | 242 | for (const result of results) { 243 | privileges.push(...result); 244 | } 245 | 246 | return privileges; 247 | } 248 | 249 | if (clause.type === "not") { 250 | return validateTopLevel(clause.clause); 251 | } 252 | 253 | return []; 254 | }; 255 | 256 | return validateTopLevel(clause); 257 | } 258 | 259 | type TableEvaluatorMatch = { 260 | type: "match"; 261 | columnClause: Clause; 262 | rowClause: Clause; 263 | }; 264 | 265 | type TableEvaluatorNoMatch = { 266 | type: "no-match"; 267 | }; 268 | 269 | type TableEvaluatorError = { 270 | type: "error"; 271 | errors: string[]; 272 | }; 273 | 274 | type TableEvaluatorResult = 275 | | TableEvaluatorMatch 276 | | TableEvaluatorNoMatch 277 | | TableEvaluatorError; 278 | 279 | interface TableEvaluatorArgs { 280 | table: SQLTableMetadata; 281 | clause: Clause; 282 | debug?: boolean; 283 | strictFields?: boolean; 284 | } 285 | 286 | function tableEvaluator({ 287 | table, 288 | clause, 289 | debug, 290 | strictFields, 291 | }: TableEvaluatorArgs): TableEvaluatorResult { 292 | const tableName = formatQualifiedName(table.table.schema, table.table.name); 293 | const variableName = "resource"; 294 | const errorVariableName = debug ? `table(${tableName})` : variableName; 295 | 296 | const metaEvaluator = simpleEvaluator({ 297 | variableName, 298 | errorVariableName, 299 | getValue: (value) => { 300 | if (value.type === "value") { 301 | return value.value; 302 | } 303 | if (value.type === "function-call") { 304 | throw new ValidationError( 305 | `${errorVariableName}: invalid function call`, 306 | ); 307 | } 308 | if (value.value === "_this") { 309 | return tableName; 310 | } 311 | if (value.value === "_this.name") { 312 | return table.table.name; 313 | } 314 | if (value.value === "_this.schema") { 315 | return table.table.schema; 316 | } 317 | if (value.value === "_this.type") { 318 | return table.table.type; 319 | } 320 | throw new ValidationError( 321 | `${errorVariableName}: invalid table field: ${value.value}`, 322 | ); 323 | }, 324 | }); 325 | 326 | const andParts = clause.type === "and" ? clause.clauses : [clause]; 327 | 328 | const getColumnSpecifier = (column: Column) => { 329 | let rest: string; 330 | if (column.value.startsWith("_this.")) { 331 | rest = column.value.slice("_this.".length); 332 | } else if (column.value.startsWith(`${tableName}.`)) { 333 | rest = column.value.slice(`${tableName}.`.length); 334 | } else { 335 | return null; 336 | } 337 | const restParts = rest.split("."); 338 | if (restParts[0] === "col" && restParts.length === 1) { 339 | return { type: "col" } as const; 340 | } 341 | if (restParts[0] === "row" && restParts.length === 2) { 342 | return { type: "row", row: restParts[1]! } as const; 343 | } 344 | return null; 345 | }; 346 | 347 | const isColumnClause = (clause: Clause) => { 348 | if (clause.type === "not") { 349 | return isColumnClause(clause.clause); 350 | } 351 | if (clause.type === "expression") { 352 | let colCount = 0; 353 | for (const value of clause.values) { 354 | if (value.type === "value") { 355 | continue; 356 | } 357 | if (value.type === "function-call") { 358 | const someCol = value.args.some((arg) => isColumnClause(arg)); 359 | if (someCol) { 360 | colCount++; 361 | } 362 | continue; 363 | } 364 | const spec = getColumnSpecifier(value); 365 | if (spec && spec.type === "col") { 366 | colCount++; 367 | continue; 368 | } 369 | return false; 370 | } 371 | return colCount > 0; 372 | } 373 | if (clause.type === "function-call") { 374 | let colCount = 0; 375 | for (const value of clause.args) { 376 | if (value.type === "value") { 377 | continue; 378 | } 379 | if (value.type === "function-call") { 380 | const someCol = value.args.some((arg) => isColumnClause(arg)); 381 | if (someCol) { 382 | colCount++; 383 | } 384 | continue; 385 | } 386 | const spec = getColumnSpecifier(value); 387 | if (spec && spec.type === "col") { 388 | colCount++; 389 | continue; 390 | } 391 | return false; 392 | } 393 | return colCount > 0; 394 | } 395 | return false; 396 | }; 397 | 398 | const isRowClause = (clause: Clause) => { 399 | if (clause.type === "not") { 400 | return isRowClause(clause.clause); 401 | } 402 | if (clause.type === "expression") { 403 | let colCount = 0; 404 | for (const value of clause.values) { 405 | if (value.type === "value") { 406 | continue; 407 | } 408 | if (value.type === "function-call") { 409 | const someCol = value.args.some((arg) => isRowClause(arg)); 410 | if (someCol) { 411 | colCount++; 412 | } 413 | continue; 414 | } 415 | const spec = getColumnSpecifier(value); 416 | if (spec && spec.type === "row") { 417 | colCount++; 418 | continue; 419 | } 420 | return false; 421 | } 422 | return colCount > 0; 423 | } 424 | if (clause.type === "function-call") { 425 | let colCount = 0; 426 | for (const value of clause.args) { 427 | if (value.type === "value") { 428 | continue; 429 | } 430 | if (value.type === "function-call") { 431 | const someCol = value.args.some((arg) => isRowClause(arg)); 432 | if (someCol) { 433 | colCount++; 434 | } 435 | continue; 436 | } 437 | const spec = getColumnSpecifier(value); 438 | if (spec && spec.type === "row") { 439 | colCount++; 440 | continue; 441 | } 442 | return false; 443 | } 444 | return colCount > 0; 445 | } 446 | if (clause.type === "column") { 447 | const spec = getColumnSpecifier(clause); 448 | return spec && spec.type === "row"; 449 | } 450 | return false; 451 | }; 452 | 453 | const metaClauses: Clause[] = []; 454 | const colClauses: Clause[] = []; 455 | const rowClauses: Clause[] = []; 456 | for (const clause of andParts) { 457 | if (isColumnClause(clause)) { 458 | colClauses.push(clause); 459 | } else if (isRowClause(clause)) { 460 | rowClauses.push(clause); 461 | } else { 462 | metaClauses.push(clause); 463 | } 464 | } 465 | 466 | const rawColClause: Clause = 467 | colClauses.length === 1 468 | ? colClauses[0]! 469 | : { type: "and", clauses: colClauses }; 470 | 471 | const errors: string[] = []; 472 | 473 | const columnClause = mapClauses(rawColClause, (clause) => { 474 | if (clause.type === "column") { 475 | return { type: "column", value: "col" }; 476 | } 477 | if (clause.type === "value") { 478 | if (typeof clause.value !== "string") { 479 | errors.push( 480 | `${errorVariableName}: invalid column specifier: ${clause.value}`, 481 | ); 482 | } else if (!table.columns.includes(clause.value)) { 483 | errors.push( 484 | `${errorVariableName}: invalid column for ${tableName}: ${clause.value}`, 485 | ); 486 | } 487 | } 488 | return clause; 489 | }); 490 | 491 | const rawRowClause: Clause = 492 | rowClauses.length === 1 493 | ? rowClauses[0]! 494 | : { type: "and", clauses: rowClauses }; 495 | 496 | const rowClause = mapClauses(rawRowClause, (clause) => { 497 | if (clause.type === "column") { 498 | let key: string; 499 | if (clause.value.startsWith(`${tableName}.`)) { 500 | key = clause.value.slice(`${tableName}.row.`.length); 501 | } else { 502 | key = clause.value.slice("_this.row.".length); 503 | } 504 | if (!table.columns.includes(key)) { 505 | errors.push( 506 | `${errorVariableName}: invalid column for ${tableName}: ${key}`, 507 | ); 508 | } 509 | return { type: "column", value: key }; 510 | } 511 | return clause; 512 | }); 513 | 514 | const evalResult = evaluateClause({ 515 | clause: { type: "and", clauses: metaClauses }, 516 | evaluate: metaEvaluator, 517 | strictFields, 518 | }); 519 | if (evalResult.type === "error") { 520 | return evalResult; 521 | } 522 | if (!evalResult.result) { 523 | return { type: "no-match" }; 524 | } 525 | if (errors.length > 0) { 526 | return { type: "error", errors }; 527 | } 528 | return { 529 | type: "match", 530 | columnClause, 531 | rowClause, 532 | }; 533 | } 534 | 535 | interface SchemaEvaluatorArgs { 536 | schema: SQLSchema; 537 | debug?: boolean; 538 | } 539 | 540 | function schemaEvaluator({ 541 | schema, 542 | debug, 543 | }: SchemaEvaluatorArgs): EvaluateClauseArgs["evaluate"] { 544 | const variableName = "resource"; 545 | const errorVariableName = debug ? `schema(${schema.name})` : variableName; 546 | return simpleEvaluator({ 547 | variableName, 548 | errorVariableName, 549 | getValue: (value) => { 550 | if (value.type === "value") { 551 | return value.value; 552 | } 553 | if (value.type === "function-call") { 554 | throw new ValidationError( 555 | `${errorVariableName}: invalid function call`, 556 | ); 557 | } 558 | if ( 559 | value.value === "_this" || 560 | value.value === "_this.name" || 561 | value.value === "_this.schema" 562 | ) { 563 | return schema.name; 564 | } 565 | if (value.value === "_this.type") { 566 | return schema.type; 567 | } 568 | throw new ValidationError( 569 | `${errorVariableName}: invalid schema field: ${value.value}`, 570 | ); 571 | }, 572 | }); 573 | } 574 | 575 | interface SimpleSchemaQualifiedObjectEvaluatorFactoryArgs { 576 | type: Permission["type"]; 577 | getName: (obj: T) => string; 578 | getSchema: (obj: T) => string; 579 | } 580 | 581 | interface SimpleSchemaQualifiedObjectEvaluatorArgs { 582 | obj: T; 583 | debug?: boolean; 584 | } 585 | 586 | function simpleSchemaQualifiedObjectEvaluatorFactory({ 587 | type, 588 | getName, 589 | getSchema, 590 | }: SimpleSchemaQualifiedObjectEvaluatorFactoryArgs): ( 591 | args: SimpleSchemaQualifiedObjectEvaluatorArgs, 592 | ) => EvaluateClauseArgs["evaluate"] { 593 | return ({ obj, debug }) => { 594 | const variableName = "resource"; 595 | const schema = getSchema(obj); 596 | const name = getName(obj); 597 | const qualifiedName = formatQualifiedName(schema, name); 598 | const errorVariableName = debug 599 | ? `${type}(${qualifiedName})` 600 | : variableName; 601 | return simpleEvaluator({ 602 | variableName, 603 | errorVariableName, 604 | getValue: (value) => { 605 | if (value.type === "value") { 606 | return value.value; 607 | } 608 | if (value.type === "function-call") { 609 | throw new ValidationError( 610 | `${errorVariableName}: invalid function call`, 611 | ); 612 | } 613 | if (value.value === "_this") { 614 | return qualifiedName; 615 | } 616 | if (value.value === "_this.name") { 617 | return name; 618 | } 619 | if (value.value === "_this.schema") { 620 | return schema; 621 | } 622 | if (value.value === "_this.type") { 623 | return type; 624 | } 625 | throw new ValidationError( 626 | `${errorVariableName}: invalid view field: ${value.value}`, 627 | ); 628 | }, 629 | }); 630 | }; 631 | } 632 | 633 | const viewEvaluator = simpleSchemaQualifiedObjectEvaluatorFactory({ 634 | type: "view", 635 | getName: (obj) => obj.name, 636 | getSchema: (obj) => obj.schema, 637 | }); 638 | 639 | const functionEvaluator = 640 | simpleSchemaQualifiedObjectEvaluatorFactory({ 641 | type: "function", 642 | getName: (obj) => obj.name, 643 | getSchema: (obj) => obj.schema, 644 | }); 645 | 646 | const procedureEvaluator = 647 | simpleSchemaQualifiedObjectEvaluatorFactory({ 648 | type: "procedure", 649 | getName: (obj) => obj.name, 650 | getSchema: (obj) => obj.schema, 651 | }); 652 | 653 | const sequenceEvaluator = 654 | simpleSchemaQualifiedObjectEvaluatorFactory({ 655 | type: "sequence", 656 | getName: (obj) => obj.name, 657 | getSchema: (obj) => obj.schema, 658 | }); 659 | 660 | interface PermissionEvaluatorArgs { 661 | permission: string; 662 | debug?: boolean; 663 | } 664 | 665 | function permissionEvaluator({ 666 | permission, 667 | debug, 668 | }: PermissionEvaluatorArgs): EvaluateClauseArgs["evaluate"] { 669 | const variableName = "action"; 670 | const errorVariableName = debug ? `permission(${permission})` : variableName; 671 | 672 | return simpleEvaluator({ 673 | variableName, 674 | errorVariableName, 675 | getValue: (value) => { 676 | if (value.type === "function-call") { 677 | throw new ValidationError( 678 | `${errorVariableName}: invalid function call`, 679 | ); 680 | } 681 | if (value.type === "value" && typeof value.value === "string") { 682 | return value.value.toUpperCase(); 683 | } 684 | if (value.type === "value") { 685 | return value.value; 686 | } 687 | if (value.value === "_this" || value.value === "_this.name") { 688 | return permission.toUpperCase(); 689 | } 690 | throw new ValidationError( 691 | `${errorVariableName}: invalid permission field: ${value.value}`, 692 | ); 693 | }, 694 | }); 695 | } 696 | 697 | function isIdentityClause(clause: Clause, variable: string): boolean { 698 | return clause.type === "column" && clause.value === variable; 699 | } 700 | 701 | interface GetPermissionsArgs

{ 702 | clause: Clause; 703 | users: SQLActor[]; 704 | privileges: P["privilege"][]; 705 | entities: SQLEntities; 706 | strictFields?: boolean; 707 | debug?: boolean; 708 | } 709 | 710 | interface DatabaseObjectTypeHandler

{ 711 | privileges: readonly P["privilege"][]; 712 | getPermissions: (args: GetPermissionsArgs

) => ConvertPermissionResult

; 713 | getDeduplicationKey: (permission: P) => string; 714 | deduplicate: (permissions: P[]) => P; 715 | } 716 | 717 | type Handlers = { 718 | [P in Permission as P["type"]]: DatabaseObjectTypeHandler

; 719 | }; 720 | 721 | const handlers: Handlers = { 722 | table: { 723 | privileges: TablePrivileges, 724 | getPermissions: ({ 725 | clause, 726 | users, 727 | privileges, 728 | entities, 729 | strictFields, 730 | debug, 731 | }) => { 732 | if (privileges.length === 0) { 733 | return { type: "success", permissions: [] }; 734 | } 735 | const errors: string[] = []; 736 | const permissions: TablePermission[] = []; 737 | for (const table of entities.tables) { 738 | const result = tableEvaluator({ 739 | table, 740 | clause, 741 | strictFields, 742 | debug, 743 | }); 744 | if (result.type === "error") { 745 | errors.push(...result.errors); 746 | } else if (result.type === "match") { 747 | for (const [user, privilege] of arrayProduct([users, privileges])) { 748 | permissions.push({ 749 | type: "table", 750 | table: table.table, 751 | user, 752 | privilege, 753 | columnClause: result.columnClause, 754 | rowClause: result.rowClause, 755 | }); 756 | } 757 | } 758 | } 759 | if (errors.length > 0) { 760 | return { type: "error", errors }; 761 | } 762 | return { type: "success", permissions }; 763 | }, 764 | getDeduplicationKey: (permission) => { 765 | return [ 766 | permission.type, 767 | permission.privilege, 768 | permission.user.name, 769 | formatQualifiedName(permission.table.schema, permission.table.name), 770 | ].join(","); 771 | }, 772 | deduplicate: (permissions) => { 773 | const [first, ...rest] = permissions; 774 | const rowClause = optimizeClause({ 775 | type: "or", 776 | clauses: [first!.rowClause, ...rest.map((perm) => perm.rowClause)], 777 | }); 778 | const columnClause = optimizeClause({ 779 | type: "or", 780 | clauses: [ 781 | first!.columnClause, 782 | ...rest.map((perm) => perm.columnClause), 783 | ], 784 | }); 785 | return { 786 | type: "table", 787 | user: first!.user, 788 | table: first!.table, 789 | privilege: first!.privilege, 790 | rowClause, 791 | columnClause, 792 | }; 793 | }, 794 | }, 795 | schema: { 796 | privileges: SchemaPrivileges, 797 | getPermissions: ({ 798 | clause, 799 | users, 800 | privileges, 801 | entities, 802 | strictFields, 803 | debug, 804 | }) => { 805 | if (privileges.length === 0) { 806 | return { type: "success", permissions: [] }; 807 | } 808 | 809 | const errors: string[] = []; 810 | const schemas: SQLSchema[] = []; 811 | const permissions: SchemaPermission[] = []; 812 | for (const schema of entities.schemas) { 813 | const result = evaluateClause({ 814 | clause, 815 | evaluate: schemaEvaluator({ schema, debug }), 816 | strictFields, 817 | }); 818 | if (result.type === "error") { 819 | errors.push(...result.errors); 820 | } else if (result.result) { 821 | schemas.push(schema); 822 | } 823 | } 824 | 825 | for (const [user, privilege, schema] of arrayProduct([ 826 | users, 827 | privileges, 828 | schemas, 829 | ])) { 830 | permissions.push({ 831 | type: "schema", 832 | schema, 833 | privilege, 834 | user, 835 | }); 836 | } 837 | 838 | if (errors.length > 0) { 839 | return { type: "error", errors }; 840 | } 841 | return { type: "success", permissions }; 842 | }, 843 | getDeduplicationKey: (permission) => { 844 | return [ 845 | permission.type, 846 | permission.privilege, 847 | permission.user.name, 848 | permission.schema.name, 849 | ].join(","); 850 | }, 851 | deduplicate: (permissions) => { 852 | return permissions[0]!; 853 | }, 854 | }, 855 | view: { 856 | privileges: ViewPrivileges, 857 | getPermissions: ({ 858 | clause, 859 | users, 860 | privileges, 861 | entities, 862 | strictFields, 863 | debug, 864 | }) => { 865 | const views: SQLView[] = []; 866 | const errors: string[] = []; 867 | const permissions: ViewPermission[] = []; 868 | for (const view of entities.views) { 869 | const result = evaluateClause({ 870 | clause, 871 | evaluate: viewEvaluator({ obj: view, debug }), 872 | strictFields, 873 | }); 874 | if (result.type === "error") { 875 | errors.push(...result.errors); 876 | } else if (result.result) { 877 | views.push(view); 878 | } 879 | } 880 | 881 | for (const [user, privilege, view] of arrayProduct([ 882 | users, 883 | privileges, 884 | views, 885 | ])) { 886 | permissions.push({ 887 | type: "view", 888 | view, 889 | privilege, 890 | user, 891 | }); 892 | } 893 | 894 | if (errors.length > 0) { 895 | return { type: "error", errors }; 896 | } 897 | return { type: "success", permissions }; 898 | }, 899 | getDeduplicationKey: (permission) => { 900 | return [ 901 | permission.type, 902 | permission.privilege, 903 | permission.user.name, 904 | formatQualifiedName(permission.view.schema, permission.view.name), 905 | ].join(","); 906 | }, 907 | deduplicate: (permissions) => { 908 | return permissions[0]!; 909 | }, 910 | }, 911 | function: { 912 | privileges: FunctionPrivileges, 913 | getPermissions: ({ 914 | clause, 915 | users, 916 | privileges, 917 | entities, 918 | strictFields, 919 | debug, 920 | }) => { 921 | const functions: SQLFunction[] = []; 922 | const errors: string[] = []; 923 | const permissions: FunctionPermission[] = []; 924 | for (const func of entities.functions) { 925 | if (func.builtin) { 926 | continue; 927 | } 928 | const result = evaluateClause({ 929 | clause, 930 | evaluate: functionEvaluator({ obj: func, debug }), 931 | strictFields, 932 | }); 933 | if (result.type === "error") { 934 | errors.push(...result.errors); 935 | } else if (result.result) { 936 | functions.push(func); 937 | } 938 | } 939 | 940 | for (const [user, privilege, func] of arrayProduct([ 941 | users, 942 | privileges, 943 | functions, 944 | ])) { 945 | permissions.push({ 946 | type: "function", 947 | function: func, 948 | privilege, 949 | user, 950 | }); 951 | } 952 | 953 | if (errors.length > 0) { 954 | return { type: "error", errors }; 955 | } 956 | return { type: "success", permissions }; 957 | }, 958 | getDeduplicationKey: (permission) => { 959 | return [ 960 | permission.type, 961 | permission.privilege, 962 | permission.user.name, 963 | formatQualifiedName( 964 | permission.function.schema, 965 | permission.function.name, 966 | ), 967 | ].join(","); 968 | }, 969 | deduplicate: (permissions) => { 970 | return permissions[0]!; 971 | }, 972 | }, 973 | procedure: { 974 | privileges: ProcedurePrivileges, 975 | getPermissions: ({ 976 | clause, 977 | users, 978 | privileges, 979 | entities, 980 | strictFields, 981 | debug, 982 | }) => { 983 | const procedures: SQLProcedure[] = []; 984 | const errors: string[] = []; 985 | const permissions: ProcedurePermission[] = []; 986 | for (const proc of entities.procedures) { 987 | if (proc.builtin) { 988 | continue; 989 | } 990 | const result = evaluateClause({ 991 | clause, 992 | evaluate: procedureEvaluator({ obj: proc, debug }), 993 | strictFields, 994 | }); 995 | if (result.type === "error") { 996 | errors.push(...result.errors); 997 | } else if (result.result) { 998 | procedures.push(proc); 999 | } 1000 | } 1001 | 1002 | for (const [user, privilege, proc] of arrayProduct([ 1003 | users, 1004 | privileges, 1005 | procedures, 1006 | ])) { 1007 | permissions.push({ 1008 | type: "procedure", 1009 | procedure: proc, 1010 | privilege, 1011 | user, 1012 | }); 1013 | } 1014 | 1015 | if (errors.length > 0) { 1016 | return { type: "error", errors }; 1017 | } 1018 | return { type: "success", permissions }; 1019 | }, 1020 | getDeduplicationKey: (permission) => { 1021 | return [ 1022 | permission.type, 1023 | permission.privilege, 1024 | permission.user.name, 1025 | formatQualifiedName( 1026 | permission.procedure.schema, 1027 | permission.procedure.name, 1028 | ), 1029 | ].join(","); 1030 | }, 1031 | deduplicate: (permissions) => { 1032 | return permissions[0]!; 1033 | }, 1034 | }, 1035 | sequence: { 1036 | privileges: SequencePrivileges, 1037 | getPermissions: ({ 1038 | clause, 1039 | users, 1040 | privileges, 1041 | entities, 1042 | strictFields, 1043 | debug, 1044 | }) => { 1045 | const sequences: SQLSequence[] = []; 1046 | const errors: string[] = []; 1047 | const permissions: SequencePermission[] = []; 1048 | for (const sequence of entities.sequences) { 1049 | const result = evaluateClause({ 1050 | clause, 1051 | evaluate: sequenceEvaluator({ obj: sequence, debug }), 1052 | strictFields, 1053 | }); 1054 | if (result.type === "error") { 1055 | errors.push(...result.errors); 1056 | } else if (result.result) { 1057 | sequences.push(sequence); 1058 | } 1059 | } 1060 | 1061 | for (const [user, privilege, sequence] of arrayProduct([ 1062 | users, 1063 | privileges, 1064 | sequences, 1065 | ])) { 1066 | permissions.push({ 1067 | type: "sequence", 1068 | sequence, 1069 | privilege, 1070 | user, 1071 | }); 1072 | } 1073 | 1074 | if (errors.length > 0) { 1075 | return { type: "error", errors }; 1076 | } 1077 | return { type: "success", permissions }; 1078 | }, 1079 | getDeduplicationKey: (permission) => { 1080 | return [ 1081 | permission.type, 1082 | permission.privilege, 1083 | permission.user.name, 1084 | formatQualifiedName( 1085 | permission.sequence.schema, 1086 | permission.sequence.name, 1087 | ), 1088 | ].join(","); 1089 | }, 1090 | deduplicate: (permissions) => { 1091 | return permissions[0]!; 1092 | }, 1093 | }, 1094 | }; 1095 | 1096 | export interface ConvertPermissionArgs { 1097 | result: Map; 1098 | entities: SQLEntities; 1099 | allowAnyActor?: boolean; 1100 | strictFields?: boolean; 1101 | debug?: boolean; 1102 | literals: Map; 1103 | } 1104 | 1105 | export function convertPermission({ 1106 | result, 1107 | entities, 1108 | allowAnyActor, 1109 | strictFields, 1110 | debug, 1111 | literals, 1112 | }: ConvertPermissionArgs): ConvertPermissionResult { 1113 | const resource = result.get("resource"); 1114 | const action = result.get("action"); 1115 | const actor = result.get("actor"); 1116 | 1117 | const getClause = (arg: unknown): Clause => { 1118 | const clause = valueToClause(arg); 1119 | return mapClauses(clause, (subClause) => { 1120 | if (subClause.type !== "column") { 1121 | return subClause; 1122 | } 1123 | const literal = literals.get(subClause.value); 1124 | if (!literal) { 1125 | return subClause; 1126 | } 1127 | return literal; 1128 | }); 1129 | }; 1130 | 1131 | const actorClause = getClause(actor); 1132 | const actionClause = getClause(action); 1133 | const resourceClause = getClause(resource); 1134 | 1135 | const actorOrs = factorOrClauses(actorClause); 1136 | const actionOrs = factorOrClauses(actionClause); 1137 | const resourceOrs = factorOrClauses(resourceClause); 1138 | 1139 | const errors: string[] = []; 1140 | const permissions: Permission[] = []; 1141 | 1142 | const allActors = (entities.users as SQLActor[]).concat(entities.groups); 1143 | const actorNames = new Set(allActors.map((actor) => actor.name)); 1144 | 1145 | for (const actorOr of actorOrs) { 1146 | const result = validateActorClause(actorOr, actorNames); 1147 | if (result !== null) { 1148 | errors.push(...result.errors); 1149 | } 1150 | } 1151 | 1152 | for (const resourceOr of resourceOrs) { 1153 | const result = validateResourceClause(resourceOr); 1154 | if (result !== null) { 1155 | errors.push(...result.errors); 1156 | } 1157 | } 1158 | 1159 | for (const [actorOr, actionOr, resourceOr] of arrayProduct([ 1160 | actorOrs, 1161 | actionOrs, 1162 | resourceOrs, 1163 | ])) { 1164 | if ( 1165 | !allowAnyActor && 1166 | (isTrueClause(actorOr) || isIdentityClause(actorOr, "actor")) 1167 | ) { 1168 | errors.push("rule does not specify a user"); 1169 | } 1170 | 1171 | const users: SQLActor[] = []; 1172 | for (const actor of allActors) { 1173 | const result = evaluateClause({ 1174 | clause: actorOr, 1175 | evaluate: actorEvaluator({ actor, debug }), 1176 | strictFields, 1177 | }); 1178 | if (result.type === "error") { 1179 | errors.push(...result.errors); 1180 | } else if (result.result) { 1181 | users.push(actor); 1182 | } 1183 | } 1184 | 1185 | if (users.length === 0) { 1186 | continue; 1187 | } 1188 | 1189 | const allPrivileges = new Set(); 1190 | for (const handler of Object.values(handlers)) { 1191 | const privileges: Privilege[] = []; 1192 | for (const privilege of handler.privileges) { 1193 | const result = evaluateClause({ 1194 | clause: actionOr, 1195 | evaluate: permissionEvaluator({ permission: privilege, debug }), 1196 | strictFields, 1197 | }); 1198 | if (result.type === "error") { 1199 | errors.push(...result.errors); 1200 | } else if (result.result) { 1201 | privileges.push(privilege); 1202 | allPrivileges.add(privilege); 1203 | } 1204 | } 1205 | 1206 | const result = handler.getPermissions({ 1207 | clause: resourceOr, 1208 | // biome-ignore lint/suspicious/noExplicitAny: tricky type situation 1209 | privileges: privileges as any, 1210 | users, 1211 | entities, 1212 | strictFields, 1213 | debug, 1214 | }); 1215 | 1216 | if (result.type === "success") { 1217 | permissions.push(...result.permissions); 1218 | } else { 1219 | errors.push(...result.errors); 1220 | } 1221 | } 1222 | 1223 | if (allPrivileges.size === 0) { 1224 | const referenced = getReferencedPrivileges(actionOr); 1225 | for (const ref of referenced) { 1226 | errors.push(`Invalid privilege name: '${ref}'`); 1227 | } 1228 | } 1229 | } 1230 | 1231 | if (errors.length > 0) { 1232 | return { 1233 | type: "error", 1234 | errors, 1235 | }; 1236 | } 1237 | 1238 | return { 1239 | type: "success", 1240 | permissions, 1241 | }; 1242 | } 1243 | 1244 | export interface ParsePermissionsArgs { 1245 | oso: Oso; 1246 | entities: SQLEntities; 1247 | allowAnyActor?: boolean; 1248 | strictFields?: boolean; 1249 | debug?: boolean; 1250 | literalsContext: LiteralsContext; 1251 | } 1252 | 1253 | export async function parsePermissions({ 1254 | oso, 1255 | entities, 1256 | allowAnyActor, 1257 | strictFields, 1258 | debug, 1259 | literalsContext, 1260 | }: ParsePermissionsArgs): Promise { 1261 | return await literalsContext.use(async () => { 1262 | const result = oso.queryRule( 1263 | { 1264 | acceptExpression: true, 1265 | }, 1266 | "allow", 1267 | new Variable("actor"), 1268 | new Variable("action"), 1269 | new Variable("resource"), 1270 | ); 1271 | 1272 | const permissions: Permission[] = []; 1273 | const errors: string[] = []; 1274 | 1275 | for await (const item of result) { 1276 | const result = convertPermission({ 1277 | result: item, 1278 | entities, 1279 | allowAnyActor, 1280 | strictFields, 1281 | debug, 1282 | literals: literalsContext.get(), 1283 | }); 1284 | if (result.type === "success") { 1285 | permissions.push(...result.permissions); 1286 | } else { 1287 | errors.push(...result.errors); 1288 | } 1289 | } 1290 | 1291 | if (errors.length > 0) { 1292 | return { 1293 | type: "error", 1294 | errors: Array.from(new Set(errors)), 1295 | }; 1296 | } 1297 | 1298 | return { 1299 | type: "success", 1300 | permissions, 1301 | }; 1302 | }); 1303 | } 1304 | 1305 | export function deduplicatePermissions( 1306 | permissions: Permission[], 1307 | ): Permission[] { 1308 | const permissionsByKey: Record = {}; 1309 | for (const permission of permissions) { 1310 | const handler = handlers[permission.type]; 1311 | // biome-ignore lint/suspicious/noExplicitAny: deep type intersection 1312 | const key = handler.getDeduplicationKey(permission as any); 1313 | 1314 | permissionsByKey[key] ??= []; 1315 | permissionsByKey[key]!.push(permission); 1316 | } 1317 | 1318 | const outPermissions: Permission[] = []; 1319 | for (const groupedPermissions of Object.values(permissionsByKey)) { 1320 | const first = groupedPermissions[0]!; 1321 | const handler = handlers[first.type]; 1322 | // biome-ignore lint/suspicious/noExplicitAny: deep type intersection 1323 | const newPermission = handler.deduplicate(groupedPermissions as any); 1324 | outPermissions.push(newPermission); 1325 | } 1326 | 1327 | return outPermissions; 1328 | } 1329 | 1330 | export interface UserRevokePolicyAll { 1331 | type: "all"; 1332 | } 1333 | 1334 | export interface UserRevokePolicyReferenced { 1335 | type: "referenced"; 1336 | } 1337 | 1338 | export interface UserRevokePolicyExplicit { 1339 | type: "users"; 1340 | users: string[]; 1341 | } 1342 | 1343 | export type UserRevokePolicy = 1344 | | UserRevokePolicyAll 1345 | | UserRevokePolicyReferenced 1346 | | UserRevokePolicyExplicit; 1347 | 1348 | export interface GetRevokeActorsArgs { 1349 | userRevokePolicy?: UserRevokePolicy; 1350 | permissions: Permission[]; 1351 | entities: SQLEntities; 1352 | } 1353 | 1354 | export interface GetRevokeActorsSuccessResult { 1355 | type: "success"; 1356 | users: SQLActor[]; 1357 | } 1358 | 1359 | export interface GetRevokeActorsErrorResult { 1360 | type: "error"; 1361 | errors: string[]; 1362 | } 1363 | 1364 | export type GetRevokeActorsResult = 1365 | | GetRevokeActorsSuccessResult 1366 | | GetRevokeActorsErrorResult; 1367 | 1368 | function deduplicateArray(array: T[], key: (item: T) => string): T[] { 1369 | const itemsByKey = Object.fromEntries(array.map((item) => [key(item), item])); 1370 | return Object.values(itemsByKey); 1371 | } 1372 | 1373 | export function getRevokeActors({ 1374 | userRevokePolicy, 1375 | permissions, 1376 | entities, 1377 | }: GetRevokeActorsArgs): GetRevokeActorsResult { 1378 | const revokePolicy = userRevokePolicy ?? { type: "referenced" }; 1379 | const referencedActors = deduplicateArray( 1380 | permissions.map((permission) => permission.user), 1381 | (actor) => actor.name, 1382 | ); 1383 | 1384 | const allActors = deduplicateArray( 1385 | (entities.users as SQLActor[]).concat(entities.groups), 1386 | (actor) => actor.name, 1387 | ); 1388 | const actorsByName = Object.fromEntries( 1389 | allActors.map((actor) => [actor.name, actor]), 1390 | ); 1391 | 1392 | let usersToRevoke: SQLActor[]; 1393 | const errors: string[] = []; 1394 | switch (revokePolicy.type) { 1395 | case "all": 1396 | usersToRevoke = allActors; 1397 | break; 1398 | case "users": { 1399 | usersToRevoke = []; 1400 | for (const user of revokePolicy.users) { 1401 | const actor = actorsByName[user]; 1402 | if (!actor) { 1403 | errors.push(`Invalid user or group in user revoke policy: ${user}`); 1404 | continue; 1405 | } 1406 | usersToRevoke.push(actor); 1407 | } 1408 | break; 1409 | } 1410 | case "referenced": { 1411 | usersToRevoke = referencedActors; 1412 | } 1413 | } 1414 | 1415 | const usersToRevokeByName = Object.fromEntries( 1416 | usersToRevoke.map((user) => [user.name, user]), 1417 | ); 1418 | const notFoundUsers = new Set(); 1419 | 1420 | for (const permission of permissions) { 1421 | if (!usersToRevokeByName[permission.user.name]) { 1422 | if (notFoundUsers.has(permission.user.name)) { 1423 | continue; 1424 | } 1425 | notFoundUsers.add(permission.user.name); 1426 | errors.push( 1427 | `Permission granted to ${permission.user.type} outside of ` + 1428 | `revoke policy: ${permission.user.name}`, 1429 | ); 1430 | } 1431 | } 1432 | 1433 | if (errors.length > 0) { 1434 | return { type: "error", errors }; 1435 | } 1436 | 1437 | return { type: "success", users: usersToRevoke }; 1438 | } 1439 | -------------------------------------------------------------------------------- /src/pg-backend.ts: -------------------------------------------------------------------------------- 1 | import crypto from "node:crypto"; 2 | import fs from "node:fs"; 3 | import path from "node:path"; 4 | import url from "node:url"; 5 | import pg from "pg"; 6 | import { SQLBackend, SQLBackendContext, SQLEntities } from "./backend.js"; 7 | import { 8 | Clause, 9 | Literal, 10 | ValidationError, 11 | evaluateClause, 12 | isTrueClause, 13 | simpleEvaluator, 14 | } from "./clause.js"; 15 | import { VERSION } from "./constants.js"; 16 | import { 17 | FunctionPermission, 18 | Permission, 19 | SQLActor, 20 | SQLFunction, 21 | SQLGroup, 22 | SQLProcedure, 23 | SQLRowLevelSecurityPolicy, 24 | SQLRowLevelSecurityPolicyPrivilege, 25 | SQLRowLevelSecurityPolicyPrivileges, 26 | SQLSchema, 27 | SQLSequence, 28 | SQLTable, 29 | SQLTableMetadata, 30 | SQLUser, 31 | SQLView, 32 | SchemaPermission, 33 | TablePermission, 34 | ViewPermission, 35 | } from "./sql.js"; 36 | import { valueToSqlLiteral } from "./utils.js"; 37 | 38 | const ProjectDir = url.fileURLToPath(new URL(".", import.meta.url)); 39 | 40 | const SqlDir = path.join(ProjectDir, "sql/pg"); 41 | 42 | export class PostgresBackend implements SQLBackend { 43 | constructor(private readonly client: pg.Client) {} 44 | 45 | async fetchEntities(): Promise { 46 | const getUsers = () => 47 | this.client.query<{ name: string; id: number }>( 48 | ` 49 | SELECT 50 | usename as "name", 51 | usesysid as "id" 52 | FROM 53 | pg_catalog.pg_user 54 | WHERE NOT usesuper 55 | `, 56 | ); 57 | 58 | const getGroups = () => 59 | this.client.query<{ name: string; userIds: number[]; id: number }>( 60 | ` 61 | SELECT 62 | groname as "name", 63 | grolist as "userIds", 64 | grosysid as "id" 65 | FROM 66 | pg_catalog.pg_group 67 | WHERE NOT groname LIKE 'pg_%' 68 | `, 69 | ); 70 | 71 | const getTables = () => 72 | this.client.query<{ 73 | schema: string; 74 | name: string; 75 | rlsEnabled: boolean; 76 | }>( 77 | ` 78 | SELECT 79 | schemaname as "schema", 80 | tablename as "name", 81 | rowsecurity as "rlsEnabled" 82 | FROM 83 | pg_tables 84 | WHERE 85 | schemaname != 'information_schema' 86 | AND schemaname != 'pg_catalog' 87 | AND schemaname != 'pg_toast' 88 | `, 89 | ); 90 | 91 | const getTableColumns = () => 92 | this.client.query<{ 93 | schema: string; 94 | table: string; 95 | name: string; 96 | }>( 97 | ` 98 | SELECT 99 | table_schema as "schema", 100 | table_name as "table", 101 | column_name as "name" 102 | FROM 103 | information_schema.columns 104 | WHERE 105 | table_schema != 'information_schema' 106 | AND table_schema != 'pg_catalog' 107 | AND table_schema != 'pg_toast' 108 | `, 109 | ); 110 | 111 | const getSchemas = () => 112 | this.client.query<{ name: string }>( 113 | ` 114 | SELECT 115 | schema_name as "name" 116 | FROM 117 | information_schema.schemata 118 | WHERE 119 | schema_name != 'information_schema' 120 | AND schema_name != 'pg_catalog' 121 | AND schema_name != 'pg_toast' 122 | `, 123 | ); 124 | 125 | const getViews = () => 126 | this.client.query<{ schema: string; name: string }>( 127 | ` 128 | SELECT 129 | table_schema as "schema", 130 | table_name as "name" 131 | FROM 132 | information_schema.views 133 | WHERE 134 | table_schema != 'information_schema' 135 | AND table_schema != 'pg_catalog' 136 | AND table_schema != 'pg_toast' 137 | `, 138 | ); 139 | 140 | const getPolicies = () => 141 | this.client.query<{ 142 | schema: string; 143 | table: string; 144 | permissive: "PERMISSIVE" | "RESTRICTIVE"; 145 | cmd: string; 146 | name: string; 147 | users: string; 148 | }>( 149 | ` 150 | SELECT 151 | schemaname as "schema", 152 | tablename as "table", 153 | policyname as "name", 154 | permissive, 155 | cmd, 156 | roles as "users" 157 | FROM 158 | pg_policies 159 | WHERE 160 | schemaname != 'information_schema' 161 | AND schemaname != 'pg_catalog' 162 | AND schemaname != 'pg_toast' 163 | `, 164 | ); 165 | 166 | const getFunctionsAndProcedures = () => 167 | this.client.query<{ 168 | schema: string; 169 | name: string; 170 | isProcedure: boolean; 171 | builtin: boolean; 172 | }>( 173 | ` 174 | SELECT 175 | n.nspname as "schema", 176 | p.proname as "name", 177 | p.prokind = 'p' as "isProcedure", 178 | n.nspname = 'pg_catalog' as "builtin" 179 | FROM 180 | pg_catalog.pg_proc p 181 | LEFT JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace 182 | WHERE 183 | n.nspname NOT IN ('pg_catalog', 'information_schema', 'pg_toast') 184 | OR pg_catalog.pg_function_is_visible(p.oid); 185 | `, 186 | ); 187 | 188 | const getSequences = () => 189 | this.client.query<{ name: string; schema: string }>( 190 | ` 191 | SELECT 192 | sequence_name as "name", 193 | sequence_schema as "schema" 194 | FROM 195 | information_schema.sequences s 196 | WHERE 197 | sequence_schema != 'information_schema' 198 | AND sequence_schema != 'pg_catalog' 199 | AND sequence_schema != 'pg_toast' 200 | `, 201 | ); 202 | 203 | const [ 204 | users, 205 | groups, 206 | tables, 207 | tableColumns, 208 | schemas, 209 | views, 210 | policies, 211 | functionsAndProcedures, 212 | sequences, 213 | ] = await Promise.all([ 214 | getUsers(), 215 | getGroups(), 216 | getTables(), 217 | getTableColumns(), 218 | getSchemas(), 219 | getViews(), 220 | getPolicies(), 221 | getFunctionsAndProcedures(), 222 | getSequences(), 223 | ]); 224 | 225 | const tableItems: Record = {}; 226 | for (const table of tables.rows) { 227 | const fullName = `${table.schema}.${table.name}`; 228 | tableItems[fullName] = { 229 | type: "table-metadata", 230 | table: { type: "table", name: table.name, schema: table.schema }, 231 | rlsEnabled: table.rlsEnabled, 232 | columns: [], 233 | }; 234 | } 235 | 236 | for (const row of tableColumns.rows) { 237 | const fullName = `${row.schema}.${row.table}`; 238 | if (tableItems[fullName]) { 239 | tableItems[fullName]!.columns.push(row.name); 240 | } 241 | } 242 | 243 | const parseArray = (value: string): string[] => { 244 | return value.slice(1, -1).split(","); 245 | }; 246 | 247 | const functions: SQLFunction[] = []; 248 | const procedures: SQLProcedure[] = []; 249 | for (const { 250 | schema, 251 | name, 252 | builtin, 253 | isProcedure, 254 | } of functionsAndProcedures.rows) { 255 | if (isProcedure) { 256 | procedures.push({ 257 | type: "procedure", 258 | name, 259 | schema, 260 | builtin, 261 | }); 262 | } else { 263 | functions.push({ 264 | type: "function", 265 | name, 266 | schema, 267 | builtin, 268 | }); 269 | } 270 | } 271 | 272 | const usersById: Record = Object.fromEntries( 273 | users.rows.map((row) => [row.id, { type: "user", name: row.name }]), 274 | ); 275 | const usersByName = Object.fromEntries( 276 | Object.values(usersById).map((user) => [user.name, user]), 277 | ); 278 | const groupsByName: Record = {}; 279 | for (const group of groups.rows) { 280 | const users = group.userIds.flatMap((userId) => 281 | usersById[userId] ? [usersById[userId]] : [], 282 | ); 283 | groupsByName[group.name] = { type: "group", name: group.name, users }; 284 | } 285 | 286 | const rlsPolicies: SQLRowLevelSecurityPolicy[] = []; 287 | for (const row of policies.rows) { 288 | const users: SQLUser[] = []; 289 | const groups: SQLGroup[] = []; 290 | let isDefault = false; 291 | for (const role of parseArray(row.users)) { 292 | if (role === "public") { 293 | isDefault = true; 294 | continue; 295 | } 296 | if (groupsByName[role]) { 297 | groups.push(groupsByName[role]); 298 | } 299 | if (usersByName[role]) { 300 | users.push(usersByName[role]); 301 | } 302 | } 303 | let privileges: Set; 304 | if (row.cmd === "ALL") { 305 | privileges = new Set(SQLRowLevelSecurityPolicyPrivileges); 306 | } else { 307 | privileges = new Set([row.cmd as SQLRowLevelSecurityPolicyPrivilege]); 308 | } 309 | 310 | rlsPolicies.push({ 311 | type: "rls-policy", 312 | name: row.name, 313 | isDefault, 314 | table: { type: "table", schema: row.schema, name: row.table }, 315 | permissive: row.permissive, 316 | privileges, 317 | users, 318 | groups, 319 | }); 320 | } 321 | 322 | return { 323 | users: Object.values(usersById), 324 | groups: Object.values(groupsByName), 325 | schemas: schemas.rows.map((row) => ({ type: "schema", name: row.name })), 326 | views: views.rows.map((row) => ({ 327 | type: "view", 328 | schema: row.schema, 329 | name: row.name, 330 | })), 331 | tables: Object.values(tableItems), 332 | rlsPolicies, 333 | functions, 334 | procedures, 335 | sequences: sequences.rows.map((row) => ({ type: "sequence", ...row })), 336 | }; 337 | } 338 | 339 | private quoteIdentifier(identifier: string): string { 340 | return JSON.stringify(identifier); 341 | } 342 | 343 | private quoteTopLevelName(schema: SQLSchema | SQLActor): string { 344 | return this.quoteIdentifier(schema.name); 345 | } 346 | 347 | private quoteQualifiedName( 348 | table: SQLTable | SQLView | SQLFunction | SQLProcedure | SQLSequence, 349 | ): string { 350 | return [ 351 | this.quoteIdentifier(table.schema), 352 | this.quoteIdentifier(table.name), 353 | ].join("."); 354 | } 355 | 356 | private async loadSqlFile( 357 | name: string, 358 | variables: Record, 359 | debug?: boolean, 360 | ): Promise { 361 | const filePath = path.join(SqlDir, name); 362 | let content = await fs.promises.readFile(filePath, { encoding: "utf8" }); 363 | for (const [key, value] of Object.entries(variables)) { 364 | content = content.replaceAll(`{{${key}}}`, value); 365 | } 366 | if (!debug) { 367 | // Strip comments 368 | content = content.replace(/\s--\s.+$/gm, ""); 369 | // Trim whitespace 370 | content = content.replace(/\s+/gm, " ").trim(); 371 | // Add pointer to original source code 372 | const baseUrl = `https://github.com/cfeenstra67/sqlauthz/blob/v${VERSION}/src/sql/pg`; 373 | content += ` -- Formatted version: ${baseUrl}/${name}`; 374 | } 375 | 376 | return content; 377 | } 378 | 379 | async getContext(entities: SQLEntities): Promise { 380 | let tmpSchema = ""; 381 | const tries = 0; 382 | 383 | const schemaNames = new Set(entities.schemas.map((schema) => schema.name)); 384 | while (tries < 100 && !tmpSchema) { 385 | const newName = `tmp_${crypto.randomInt(10000)}`; 386 | if (!schemaNames.has(newName)) { 387 | tmpSchema = newName; 388 | } 389 | } 390 | 391 | if (!tmpSchema) { 392 | throw new Error("Unable to choose a temporary schema name"); 393 | } 394 | 395 | const setupQuery = [ 396 | `CREATE SCHEMA ${this.quoteIdentifier(tmpSchema)};`, 397 | await this.loadSqlFile("revoke_all_from_role.sql", { tmpSchema }), 398 | ].join("\n"); 399 | 400 | const teardownQuery = `DROP SCHEMA ${this.quoteIdentifier( 401 | tmpSchema, 402 | )} CASCADE;`; 403 | 404 | return { 405 | setupQuery, 406 | teardownQuery, 407 | transactionStartQuery: "BEGIN;", 408 | transactionCommitQuery: "COMMIT;", 409 | removeAllPermissionsFromActorsQueries: (users, entities) => { 410 | const revokeQueries = users.map( 411 | (user) => `SELECT ${tmpSchema}.revoke_all_from_role('${user.name}');`, 412 | ); 413 | 414 | const userNames = new Set(users.map((user) => user.name)); 415 | 416 | const policiesToDrop = entities.rlsPolicies.filter( 417 | (policy) => 418 | policy.permissive === "RESTRICTIVE" && 419 | policy.users.some((user) => userNames.has(user.name)), 420 | ); 421 | const dropQueries = policiesToDrop.map( 422 | (policy) => 423 | `DROP POLICY ${this.quoteIdentifier(policy.name)} ` + 424 | `ON ${this.quoteQualifiedName(policy.table)};`, 425 | ); 426 | 427 | return revokeQueries.concat(dropQueries); 428 | }, 429 | compileGrantQueries: (permissions, entities) => { 430 | const metaByTable = Object.fromEntries( 431 | entities.tables.map((table) => [ 432 | this.quoteQualifiedName(table.table), 433 | table, 434 | ]), 435 | ); 436 | 437 | const tablesWithDefaultPermissivePolicies: Record< 438 | string, 439 | Set 440 | > = {}; 441 | const tablesWithPermissivePolicies: Record< 442 | string, 443 | Record> 444 | > = {}; 445 | for (const policy of entities.rlsPolicies) { 446 | if (policy.permissive === "PERMISSIVE") { 447 | const tableName = this.quoteQualifiedName(policy.table); 448 | if (policy.isDefault) { 449 | tablesWithDefaultPermissivePolicies[tableName] ??= new Set(); 450 | const perms = tablesWithDefaultPermissivePolicies[tableName]; 451 | for (const perm of policy.privileges) { 452 | perms.add(perm); 453 | } 454 | continue; 455 | } 456 | 457 | tablesWithPermissivePolicies[tableName] ??= {}; 458 | const users = tablesWithPermissivePolicies[tableName]; 459 | const policyUsers = [...policy.users]; 460 | for (const group of policy.groups) { 461 | users[group.name] ??= new Set(); 462 | const groupPerms = users[group.name]!; 463 | for (const perm of policy.privileges) { 464 | groupPerms.add(perm); 465 | } 466 | policyUsers.push(...group.users); 467 | } 468 | for (const user of policyUsers) { 469 | users[user.name] ??= new Set(); 470 | const userPerms = users[user.name]!; 471 | for (const perm of policy.privileges) { 472 | userPerms.add(perm); 473 | } 474 | } 475 | } 476 | } 477 | 478 | const tablesToAddRlsTo = new Set(); 479 | for (const perm of permissions) { 480 | if (perm.type !== "table") { 481 | continue; 482 | } 483 | if (isTrueClause(perm.rowClause)) { 484 | continue; 485 | } 486 | if ( 487 | !SQLRowLevelSecurityPolicyPrivileges.includes( 488 | perm.privilege as SQLRowLevelSecurityPolicyPrivilege, 489 | ) 490 | ) { 491 | continue; 492 | } 493 | const tableName = this.quoteQualifiedName(perm.table); 494 | const table = metaByTable[tableName]; 495 | if (!table) { 496 | continue; 497 | } 498 | if (!table.rlsEnabled) { 499 | tablesToAddRlsTo.add(tableName); 500 | } 501 | } 502 | 503 | const defaultPoliciesToCreate: Record< 504 | string, 505 | Record> 506 | > = {}; 507 | for (const perm of permissions) { 508 | if (perm.type !== "table") { 509 | continue; 510 | } 511 | if ( 512 | !SQLRowLevelSecurityPolicyPrivileges.includes( 513 | perm.privilege as SQLRowLevelSecurityPolicyPrivilege, 514 | ) 515 | ) { 516 | continue; 517 | } 518 | 519 | const tableName = this.quoteQualifiedName(perm.table); 520 | const table = metaByTable[tableName]; 521 | if (!table) { 522 | continue; 523 | } 524 | if (!table.rlsEnabled || tablesToAddRlsTo.has(tableName)) { 525 | continue; 526 | } 527 | 528 | const usersWithPolicies = 529 | tablesWithPermissivePolicies[tableName]?.[perm.user.name]; 530 | const tableDefaultPerms = 531 | tablesWithDefaultPermissivePolicies[tableName]; 532 | const missingPerms = new Set(); 533 | for (const perm of SQLRowLevelSecurityPolicyPrivileges) { 534 | if ( 535 | !usersWithPolicies?.has(perm) && 536 | !tableDefaultPerms?.has(perm) 537 | ) { 538 | missingPerms.add(perm); 539 | } 540 | } 541 | 542 | if (missingPerms.size === 0) { 543 | continue; 544 | } 545 | 546 | defaultPoliciesToCreate[tableName] ??= {}; 547 | defaultPoliciesToCreate[tableName]![perm.user.name] = missingPerms; 548 | } 549 | 550 | const enableRlsQueries = Array.from(tablesToAddRlsTo).flatMap( 551 | (tableName) => [ 552 | `ALTER TABLE ${tableName} ENABLE ROW LEVEL SECURITY;`, 553 | // biome-ignore lint: best way to do this 554 | `CREATE POLICY "default_access" ON ${tableName} AS PERMISSIVE FOR ` + 555 | "ALL TO PUBLIC USING (true);", 556 | ], 557 | ); 558 | 559 | const addDefaultPolicyQueries = Object.entries( 560 | defaultPoliciesToCreate, 561 | ).flatMap(([tableName, userPerms]) => 562 | Object.entries(userPerms).flatMap(([userName, perms]) => { 563 | const getQuery = (perm: string) => { 564 | const extra: string[] = []; 565 | if ( 566 | perm === "ALL" || 567 | perm === "DELETE" || 568 | perm === "SELECT" || 569 | perm === "UPDATE" 570 | ) { 571 | extra.push("USING (true)"); 572 | } 573 | if (perm === "ALL" || perm === "INSERT" || perm === "UPDATE") { 574 | extra.push("WITH CHECK (true)"); 575 | } 576 | const name = `${userName}_${perm.toLowerCase()}`; 577 | return ( 578 | `CREATE POLICY ${this.quoteIdentifier(name)} ` + 579 | `ON ${tableName} AS PERMISSIVE FOR ${perm} TO ` + 580 | `${this.quoteIdentifier(userName)} ${extra.join(" ")};` 581 | ); 582 | }; 583 | 584 | if (perms.size === SQLRowLevelSecurityPolicyPrivileges.length) { 585 | return [getQuery("ALL")]; 586 | } 587 | 588 | return Array.from(perms).map((perm) => getQuery(perm)); 589 | }), 590 | ); 591 | 592 | const rlsQueries = enableRlsQueries.concat(addDefaultPolicyQueries); 593 | 594 | const individualGrantQueries = permissions.flatMap((perm) => 595 | this.compileGrantQuery(perm, entities), 596 | ); 597 | 598 | return rlsQueries.concat(individualGrantQueries); 599 | }, 600 | }; 601 | } 602 | 603 | private evalColumnQuery(clause: Clause, column: string): boolean { 604 | const evaluate = simpleEvaluator({ 605 | variableName: "col", 606 | errorVariableName: "col", 607 | getValue: (value) => { 608 | if (value.type === "function-call") { 609 | throw new ValidationError("col: invalid function call"); 610 | } 611 | if (value.type === "value") { 612 | return value.value; 613 | } 614 | if (value.value === "col") { 615 | return column; 616 | } 617 | throw new ValidationError(`col: invalid clause value: ${value.value}`); 618 | }, 619 | }); 620 | 621 | const result = evaluateClause({ clause, evaluate }); 622 | return result.type === "success" && result.result; 623 | } 624 | 625 | private clauseToSql(clause: Clause): string { 626 | if (clause.type === "and" || clause.type === "or") { 627 | const subClauses = clause.clauses.map((subClause) => 628 | this.clauseToSql(subClause), 629 | ); 630 | return `(${subClauses.join(` ${clause.type} `)})`; 631 | } 632 | if (clause.type === "not") { 633 | const subClause = this.clauseToSql(clause.clause); 634 | return `not ${subClause}`; 635 | } 636 | if (clause.type === "expression") { 637 | const values = clause.values.map((value) => this.clauseToSql(value)); 638 | let operator: string; 639 | switch (clause.operator) { 640 | case "Eq": 641 | operator = "="; 642 | break; 643 | case "Gt": 644 | operator = ">"; 645 | break; 646 | case "Lt": 647 | operator = "<"; 648 | break; 649 | case "Geq": 650 | operator = ">="; 651 | break; 652 | case "Leq": 653 | operator = "<="; 654 | break; 655 | case "Neq": 656 | operator = "!="; 657 | break; 658 | default: 659 | throw new Error(`Unhandled operator: ${clause.operator}`); 660 | } 661 | return values.join(` ${operator} `); 662 | } 663 | if (clause.type === "column") { 664 | return this.quoteIdentifier(clause.value); 665 | } 666 | if (clause.type === "function-call") { 667 | if (clause.schema) { 668 | const name = `${this.quoteIdentifier( 669 | clause.schema, 670 | )}.${this.quoteIdentifier(clause.name)}`; 671 | const args = clause.args.map((arg) => this.clauseToSql(arg)); 672 | return `${name}(${args.join(", ")})`; 673 | } 674 | if (clause.name === "cast") { 675 | const arg = this.clauseToSql(clause.args[0]!); 676 | return `CAST(${arg} AS ${(clause.args[1] as Literal).value})`; 677 | } 678 | throw new Error(`Unrecognized function: ${clause.name}`); 679 | } 680 | if (typeof clause.value === "string") { 681 | return `'${clause.value}'`; 682 | } 683 | return valueToSqlLiteral(clause.value); 684 | } 685 | 686 | private compileGrantQuery( 687 | permission: Permission, 688 | entities: SQLEntities, 689 | ): string[] { 690 | switch (permission.type) { 691 | case "schema": 692 | switch (permission.privilege) { 693 | case "USAGE": 694 | case "CREATE": 695 | return [ 696 | `GRANT ${permission.privilege} ON SCHEMA ${this.quoteTopLevelName( 697 | permission.schema, 698 | )} TO ${this.quoteTopLevelName(permission.user)};`, 699 | ]; 700 | default: { 701 | const _: never = permission; 702 | throw new Error( 703 | `Invalid schema privilege: ${ 704 | (permission as SchemaPermission).privilege 705 | };`, 706 | ); 707 | } 708 | } 709 | case "table": { 710 | let columnPart = ""; 711 | if (!isTrueClause(permission.columnClause)) { 712 | const table = entities.tables.filter( 713 | (table) => 714 | table.table.schema === permission.table.schema && 715 | table.table.name === permission.table.name, 716 | )[0]!; 717 | const columnNames = table.columns.filter((column) => 718 | this.evalColumnQuery(permission.columnClause, column), 719 | ); 720 | const colNameList = columnNames.map((col) => 721 | this.quoteIdentifier(col), 722 | ); 723 | columnPart = ` (${colNameList.join(", ")})`; 724 | } 725 | 726 | switch (permission.privilege) { 727 | case "SELECT": { 728 | const out = [ 729 | `GRANT SELECT${columnPart} ON ${this.quoteQualifiedName( 730 | permission.table, 731 | )} TO ${this.quoteTopLevelName(permission.user)};`, 732 | ]; 733 | if (!isTrueClause(permission.rowClause)) { 734 | const policyName = [permission.privilege, permission.user.name] 735 | .join("_") 736 | .toLowerCase(); 737 | 738 | out.push( 739 | `CREATE POLICY ${this.quoteIdentifier( 740 | policyName, 741 | )} ON ${this.quoteQualifiedName( 742 | permission.table, 743 | )} AS RESTRICTIVE FOR SELECT TO ${this.quoteTopLevelName( 744 | permission.user, 745 | )} USING (${this.clauseToSql(permission.rowClause)});`, 746 | ); 747 | } 748 | return out; 749 | } 750 | case "INSERT": { 751 | const out = [ 752 | `GRANT INSERT${columnPart} ON ${this.quoteQualifiedName( 753 | permission.table, 754 | )} TO ${this.quoteTopLevelName(permission.user)};`, 755 | ]; 756 | if (!isTrueClause(permission.rowClause)) { 757 | const policyName = [permission.privilege, permission.user.name] 758 | .join("_") 759 | .toLowerCase(); 760 | 761 | out.push( 762 | `CREATE POLICY ${this.quoteIdentifier( 763 | policyName, 764 | )} ON ${this.quoteQualifiedName( 765 | permission.table, 766 | )} AS RESTRICTIVE FOR INSERT TO ${this.quoteTopLevelName( 767 | permission.user, 768 | )} WITH CHECK (${this.clauseToSql(permission.rowClause)});`, 769 | ); 770 | } 771 | return out; 772 | } 773 | case "UPDATE": { 774 | const out = [ 775 | `GRANT UPDATE${columnPart} ON ${this.quoteQualifiedName( 776 | permission.table, 777 | )} TO ${this.quoteTopLevelName(permission.user)};`, 778 | ]; 779 | if (!isTrueClause(permission.rowClause)) { 780 | const policyName = [permission.privilege, permission.user.name] 781 | .join("_") 782 | .toLowerCase(); 783 | 784 | const rowClauseSql = this.clauseToSql(permission.rowClause); 785 | 786 | out.push( 787 | `CREATE POLICY ${this.quoteIdentifier( 788 | policyName, 789 | )} ON ${this.quoteQualifiedName( 790 | permission.table, 791 | )} AS RESTRICTIVE FOR UPDATE TO ${this.quoteTopLevelName( 792 | permission.user, 793 | )} USING (${rowClauseSql}) WITH CHECK (${rowClauseSql});`, 794 | ); 795 | } 796 | return out; 797 | } 798 | case "DELETE": { 799 | const out = [ 800 | `GRANT DELETE ON ${this.quoteQualifiedName(permission.table)} ` + 801 | `TO ${this.quoteTopLevelName(permission.user)};`, 802 | ]; 803 | if (!isTrueClause(permission.rowClause)) { 804 | const policyName = [permission.privilege, permission.user.name] 805 | .join("_") 806 | .toLowerCase(); 807 | 808 | out.push( 809 | `CREATE POLICY ${this.quoteIdentifier( 810 | policyName, 811 | )} ON ${this.quoteQualifiedName( 812 | permission.table, 813 | )} AS RESTRICTIVE FOR DELETE TO ${this.quoteTopLevelName( 814 | permission.user, 815 | )} USING (${this.clauseToSql(permission.rowClause)});`, 816 | ); 817 | } 818 | return out; 819 | } 820 | case "TRUNCATE": 821 | return [ 822 | `GRANT TRUNCATE ON ${this.quoteQualifiedName( 823 | permission.table, 824 | )} TO ${this.quoteTopLevelName(permission.user)};`, 825 | ]; 826 | case "TRIGGER": 827 | return [ 828 | `GRANT TRIGGER ON ${this.quoteQualifiedName(permission.table)} ` + 829 | `TO ${this.quoteTopLevelName(permission.user)};`, 830 | ]; 831 | case "REFERENCES": 832 | return [ 833 | `GRANT REFERENCES ON ${this.quoteQualifiedName( 834 | permission.table, 835 | )} TO ${this.quoteTopLevelName(permission.user)};`, 836 | ]; 837 | default: { 838 | const _: never = permission; 839 | throw new Error( 840 | `Invalid table privilege: ${ 841 | (permission as TablePermission).privilege 842 | }`, 843 | ); 844 | } 845 | } 846 | } 847 | case "view": { 848 | switch (permission.privilege) { 849 | case "DELETE": 850 | case "INSERT": 851 | case "SELECT": 852 | case "TRIGGER": 853 | case "UPDATE": 854 | return [ 855 | `GRANT ${permission.privilege} ON ${this.quoteQualifiedName( 856 | permission.view, 857 | )} TO ${this.quoteTopLevelName(permission.user)};`, 858 | ]; 859 | default: { 860 | const _: never = permission; 861 | throw new Error( 862 | `Invalid view privilege: ${ 863 | (permission as ViewPermission).privilege 864 | }`, 865 | ); 866 | } 867 | } 868 | } 869 | case "function": { 870 | switch (permission.privilege) { 871 | case "EXECUTE": 872 | return [ 873 | `GRANT ${permission.privilege} ON FUNCTION ` + 874 | `${this.quoteQualifiedName(permission.function)} ` + 875 | `TO ${this.quoteTopLevelName(permission.user)};`, 876 | ]; 877 | default: { 878 | const _: never = permission; 879 | throw new Error( 880 | `Invalid function privilege: ${ 881 | (permission as FunctionPermission).privilege 882 | }`, 883 | ); 884 | } 885 | } 886 | } 887 | case "procedure": { 888 | switch (permission.privilege) { 889 | case "EXECUTE": 890 | return [ 891 | `GRANT ${permission.privilege} ON PROCEDURE ` + 892 | `${this.quoteQualifiedName(permission.procedure)} ` + 893 | `TO ${this.quoteTopLevelName(permission.user)};`, 894 | ]; 895 | default: { 896 | const _: never = permission; 897 | throw new Error( 898 | `Invalid procedure privilege: ${ 899 | (permission as FunctionPermission).privilege 900 | }`, 901 | ); 902 | } 903 | } 904 | } 905 | case "sequence": { 906 | switch (permission.privilege) { 907 | case "USAGE": 908 | case "SELECT": 909 | case "UPDATE": 910 | return [ 911 | `GRANT ${permission.privilege} ON SEQUENCE ` + 912 | `${this.quoteQualifiedName(permission.sequence)} ` + 913 | `TO ${this.quoteTopLevelName(permission.user)};`, 914 | ]; 915 | default: { 916 | const _: never = permission; 917 | throw new Error( 918 | `Invalid sequence privilege: ${ 919 | (permission as FunctionPermission).privilege 920 | }`, 921 | ); 922 | } 923 | } 924 | } 925 | default: { 926 | const _: never = permission; 927 | throw new Error( 928 | `Invalid permission: ${(permission as Permission).type}`, 929 | ); 930 | } 931 | } 932 | } 933 | } 934 | -------------------------------------------------------------------------------- /src/sql.ts: -------------------------------------------------------------------------------- 1 | import { SQLBackendContext, SQLEntities } from "./backend.js"; 2 | import { Clause } from "./clause.js"; 3 | 4 | export interface SQLTable { 5 | type: "table"; 6 | schema: string; 7 | name: string; 8 | } 9 | 10 | export interface SQLView { 11 | type: "view"; 12 | schema: string; 13 | name: string; 14 | } 15 | 16 | export interface SQLTableMetadata { 17 | type: "table-metadata"; 18 | table: SQLTable; 19 | rlsEnabled: boolean; 20 | columns: string[]; 21 | } 22 | 23 | export interface SQLSchema { 24 | type: "schema"; 25 | name: string; 26 | } 27 | 28 | export const SQLRowLevelSecurityPolicyPrivileges = [ 29 | "SELECT", 30 | "INSERT", 31 | "UPDATE", 32 | "DELETE", 33 | ] as const satisfies TablePrivilege[]; 34 | 35 | export type SQLRowLevelSecurityPolicyPrivilege = 36 | (typeof SQLRowLevelSecurityPolicyPrivileges)[number]; 37 | 38 | export interface SQLRowLevelSecurityPolicy { 39 | type: "rls-policy"; 40 | name: string; 41 | table: SQLTable; 42 | permissive: "PERMISSIVE" | "RESTRICTIVE"; 43 | privileges: Set; 44 | isDefault: boolean; 45 | users: SQLUser[]; 46 | groups: SQLGroup[]; 47 | } 48 | 49 | export interface SQLFunction { 50 | type: "function"; 51 | schema: string; 52 | name: string; 53 | builtin: boolean; 54 | } 55 | 56 | export interface SQLProcedure { 57 | type: "procedure"; 58 | schema: string; 59 | name: string; 60 | builtin: boolean; 61 | } 62 | 63 | export interface SQLSequence { 64 | type: "sequence"; 65 | schema: string; 66 | name: string; 67 | } 68 | 69 | export interface SQLUser { 70 | type: "user"; 71 | name: string; 72 | } 73 | 74 | export interface SQLGroup { 75 | type: "group"; 76 | name: string; 77 | users: SQLUser[]; 78 | } 79 | 80 | export type SQLActor = SQLUser | SQLGroup; 81 | 82 | export const TablePrivileges = [ 83 | "SELECT", 84 | "INSERT", 85 | "UPDATE", 86 | "DELETE", 87 | "TRUNCATE", 88 | "REFERENCES", 89 | "TRIGGER", 90 | ] as const; 91 | 92 | export type TablePrivilege = (typeof TablePrivileges)[number]; 93 | 94 | export const ViewPrivileges = [ 95 | "SELECT", 96 | "INSERT", 97 | "UPDATE", 98 | "DELETE", 99 | "TRIGGER", 100 | ] as const; 101 | 102 | export type ViewPrivilege = (typeof ViewPrivileges)[number]; 103 | 104 | export const SchemaPrivileges = ["USAGE", "CREATE"] as const; 105 | 106 | export type SchemaPrivilege = (typeof SchemaPrivileges)[number]; 107 | 108 | export const FunctionPrivileges = ["EXECUTE"] as const; 109 | 110 | export type FunctionPrivilege = (typeof FunctionPrivileges)[number]; 111 | 112 | export const ProcedurePrivileges = ["EXECUTE"] as const; 113 | 114 | export type ProcedurePrivilege = (typeof FunctionPrivileges)[number]; 115 | 116 | export const SequencePrivileges = ["USAGE", "SELECT", "UPDATE"] as const; 117 | 118 | export type SequencePrivilege = (typeof SequencePrivileges)[number]; 119 | 120 | export interface BasePermission { 121 | user: SQLActor; 122 | } 123 | 124 | export interface TablePermission extends BasePermission { 125 | type: "table"; 126 | table: SQLTable; 127 | privilege: TablePrivilege; 128 | columnClause: Clause; 129 | rowClause: Clause; 130 | } 131 | 132 | export interface SchemaPermission extends BasePermission { 133 | type: "schema"; 134 | schema: SQLSchema; 135 | privilege: SchemaPrivilege; 136 | } 137 | 138 | export interface ViewPermission extends BasePermission { 139 | type: "view"; 140 | view: SQLView; 141 | privilege: ViewPrivilege; 142 | } 143 | 144 | export interface FunctionPermission extends BasePermission { 145 | type: "function"; 146 | function: SQLFunction; 147 | privilege: FunctionPrivilege; 148 | } 149 | 150 | export interface ProcedurePermission extends BasePermission { 151 | type: "procedure"; 152 | procedure: SQLProcedure; 153 | privilege: ProcedurePrivilege; 154 | } 155 | 156 | export interface SequencePermission extends BasePermission { 157 | type: "sequence"; 158 | sequence: SQLSequence; 159 | privilege: SequencePrivilege; 160 | } 161 | 162 | export type Permission = 163 | | TablePermission 164 | | SchemaPermission 165 | | ViewPermission 166 | | FunctionPermission 167 | | ProcedurePermission 168 | | SequencePermission; 169 | 170 | export type Privilege = { 171 | [P in Permission as P["type"]]: P["privilege"]; 172 | }[Permission["type"]]; 173 | 174 | export function parseQualifiedName(tableName: string): [string, string] | null { 175 | const parts = tableName.split("."); 176 | if (parts.length !== 2) { 177 | return null; 178 | } 179 | return parts as [string, string]; 180 | } 181 | 182 | export function formatQualifiedName(schema: string, name: string): string { 183 | return `${schema}.${name}`; 184 | } 185 | 186 | export interface ConstructFullQueryArgs { 187 | context: SQLBackendContext; 188 | entities: SQLEntities; 189 | revokeUsers: SQLActor[]; 190 | permissions: Permission[]; 191 | includeSetupAndTeardown?: boolean; 192 | includeTransaction?: boolean; 193 | } 194 | 195 | export function constructFullQuery({ 196 | entities, 197 | context, 198 | revokeUsers, 199 | permissions, 200 | includeSetupAndTeardown, 201 | includeTransaction, 202 | }: ConstructFullQueryArgs): string { 203 | if (includeSetupAndTeardown === undefined) { 204 | includeSetupAndTeardown = true; 205 | } 206 | if (includeTransaction === undefined) { 207 | includeTransaction = true; 208 | } 209 | 210 | const queryParts: string[] = []; 211 | 212 | if (context.transactionStartQuery && includeTransaction) { 213 | queryParts.push(context.transactionStartQuery); 214 | } 215 | 216 | if (context.setupQuery && includeSetupAndTeardown) { 217 | queryParts.push(context.setupQuery); 218 | } 219 | 220 | if (includeSetupAndTeardown) { 221 | const removeQueries = context.removeAllPermissionsFromActorsQueries( 222 | revokeUsers, 223 | entities, 224 | ); 225 | 226 | queryParts.push(...removeQueries); 227 | } 228 | 229 | const grantQueries = context.compileGrantQueries(permissions, entities); 230 | queryParts.push(...grantQueries); 231 | 232 | if (context.teardownQuery && includeSetupAndTeardown) { 233 | queryParts.push(context.teardownQuery); 234 | } 235 | if (context.transactionCommitQuery && includeTransaction) { 236 | queryParts.push(context.transactionCommitQuery); 237 | } 238 | 239 | return queryParts.join("\n"); 240 | } 241 | -------------------------------------------------------------------------------- /src/sql/pg/revoke_all_from_role.sql: -------------------------------------------------------------------------------- 1 | CREATE FUNCTION {{tmpSchema}}.revoke_all_from_role(username TEXT) RETURNS TEXT AS $$ 2 | declare 3 | role_row record; 4 | schema_row record; 5 | begin 6 | -- Revoke all existing roles 7 | FOR role_row IN 8 | SELECT 9 | m.roleid::regrole::text as rolename 10 | FROM 11 | pg_roles r 12 | JOIN pg_auth_members m ON r.oid = m.member 13 | WHERE 14 | r.rolname = username 15 | LOOP 16 | execute format('REVOKE %I FROM %I', role_row.rolename, username); 17 | END LOOP; 18 | -- Revoke all existing privileges 19 | FOR schema_row IN 20 | SELECT DISTINCT schema_name FROM information_schema.schemata 21 | WHERE schema_name NOT IN ('information_schema', 'pg_catalog', 'pg_toast') 22 | LOOP 23 | execute format( 24 | 'REVOKE USAGE ON SCHEMA %I FROM %I CASCADE', 25 | schema_row.schema_name, 26 | username 27 | ); 28 | execute format( 29 | 'REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA %I FROM %I CASCADE', 30 | schema_row.schema_name, 31 | username 32 | ); 33 | -- execute format( 34 | -- 'REVOKE ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA %I FROM %I CASCADE', 35 | -- schema_row.schema_name, 36 | -- username 37 | -- ); 38 | execute format( 39 | 'REVOKE ALL PRIVILEGES ON SCHEMA %I FROM %I CASCADE', 40 | schema_row.schema_name, 41 | username 42 | ); 43 | execute format( 44 | 'REVOKE ALL PRIVILEGES ON ALL FUNCTIONS IN SCHEMA %I FROM %I CASCADE', 45 | schema_row.schema_name, 46 | username 47 | ); 48 | execute format( 49 | 'REVOKE ALL PRIVILEGES ON ALL PROCEDURES IN SCHEMA %I FROM %I CASCADE', 50 | schema_row.schema_name, 51 | username 52 | ); 53 | END LOOP; 54 | 55 | return username; 56 | end; 57 | $$ LANGUAGE plpgsql STRICT SECURITY INVOKER; 58 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import fs from "node:fs"; 2 | import { fdir } from "fdir"; 3 | import { Variable } from "oso"; 4 | import { Expression } from "oso/dist/src/Expression.js"; 5 | import { Pattern } from "oso/dist/src/Pattern.js"; 6 | import { Predicate } from "oso/dist/src/Predicate.js"; 7 | 8 | export function printExpression(obj: unknown): string { 9 | const prefix = " "; 10 | if (obj instanceof Expression) { 11 | const lines: string[] = [`${obj.operator}:`]; 12 | for (const arg of obj.args) { 13 | const output = printExpression(arg).split("\n"); 14 | const withPrefix = output.map((line) => prefix + line); 15 | lines.push(...withPrefix); 16 | } 17 | return lines.join("\n"); 18 | } 19 | if (obj instanceof Pattern) { 20 | const fields = JSON.stringify(obj.fields); 21 | return `pattern(${obj.tag}, ${fields})`; 22 | } 23 | if (obj instanceof Variable) { 24 | return `var(${obj.name})`; 25 | } 26 | if (obj instanceof Predicate) { 27 | const lines: string[] = [`${obj.name}:`]; 28 | for (const arg of obj.args) { 29 | const output = printExpression(arg).split("\n"); 30 | const withPrefix = output.map((line) => prefix + line); 31 | lines.push(...withPrefix); 32 | } 33 | return lines.join("\n"); 34 | } 35 | // biome-ignore lint/suspicious/noExplicitAny: debugging code 36 | return (obj as any).toString(); 37 | } 38 | 39 | export function printQuery(query: Map): string { 40 | const lines: string[] = []; 41 | const prefix = " "; 42 | for (const [key, value] of query.entries()) { 43 | lines.push(`${key}:`); 44 | const exprLines = printExpression(value).split("\n"); 45 | const withPrefix = exprLines.map((line) => prefix + line); 46 | lines.push(...withPrefix); 47 | } 48 | return lines.join("\n"); 49 | } 50 | 51 | export function valueToSqlLiteral(value: unknown): string { 52 | if (typeof value === "string") { 53 | return `'${value.replaceAll("'", "''")}'`; 54 | } 55 | if (typeof value === "number") { 56 | if (!Number.isFinite(value)) { 57 | return "null"; 58 | } 59 | return value.toString(); 60 | } 61 | if (typeof value === "boolean") { 62 | return value.toString(); 63 | } 64 | if (value === null || value === undefined) { 65 | return "null"; 66 | } 67 | if (value instanceof Date) { 68 | return valueToSqlLiteral(value.toISOString()); 69 | } 70 | throw new Error(`Unhandled SQL literal type: ${value}`); 71 | } 72 | 73 | // biome-ignore lint/suspicious/noExplicitAny: generic 74 | type ArrayProductItem = 75 | A extends readonly [] 76 | ? readonly [] 77 | : // biome-ignore lint/suspicious/noExplicitAny: generic 78 | A extends readonly [infer T extends readonly any[]] 79 | ? readonly [T[number]] 80 | : A extends readonly [ 81 | // biome-ignore lint/suspicious/noExplicitAny: generic 82 | infer T extends readonly any[], 83 | // biome-ignore lint/suspicious/noExplicitAny: generic 84 | ...infer R extends readonly (readonly any[])[], 85 | ] 86 | ? readonly [T[number], ...ArrayProductItem] 87 | : A extends readonly (infer T)[] 88 | ? T 89 | : never; 90 | 91 | // biome-ignore lint/suspicious/noExplicitAny: generic 92 | export type ArrayProduct = Generator< 93 | ArrayProductItem 94 | >; 95 | 96 | // biome-ignore lint/suspicious/noExplicitAny: generic 97 | export function arrayProduct( 98 | inputs: A, 99 | ): ArrayProduct; 100 | // biome-ignore lint/suspicious/noExplicitAny: generic 101 | export function arrayProduct( 102 | inputs: A, 103 | ): ArrayProduct; 104 | // biome-ignore lint/suspicious/noExplicitAny: generic 105 | export function* arrayProduct( 106 | inputs: A, 107 | ): ArrayProduct { 108 | if (inputs.length === 0) { 109 | return; 110 | } 111 | 112 | if (inputs.length === 1) { 113 | for (const item of inputs[0]!) { 114 | yield [item] as ArrayProductItem; 115 | } 116 | 117 | return; 118 | } 119 | 120 | for (const item of inputs[0]!) { 121 | for (const rest of arrayProduct(inputs.slice(1))) { 122 | yield [item].concat(rest) as ArrayProductItem; 123 | } 124 | } 125 | 126 | // Old implementation based on my clever little algo; keeping it around 127 | // for now. The implementation above is much easier to understand though. 128 | 129 | // const cumulativeProducts: number[] = []; 130 | // let totalCombinations = 1; 131 | // for (const clauses of inputs) { 132 | // cumulativeProducts.push(totalCombinations); 133 | // totalCombinations *= clauses.length; 134 | // } 135 | 136 | // for (let i = 0; i < totalCombinations; i++) { 137 | // const outItems = inputs.map((values, idx) => { 138 | // const cumulativeProduct = cumulativeProducts[idx]!; 139 | // const cycle = values.length * cumulativeProduct; 140 | // const remainder = i % cycle; 141 | // const outIdx = Math.floor(remainder / cumulativeProduct); 142 | // return values[outIdx]!; 143 | // }); 144 | // yield outItems as ArrayProductItem; 145 | // } 146 | } 147 | 148 | export async function strictGlob(...globs: string[]): Promise { 149 | const out = new Set(); 150 | for (const pattern of globs) { 151 | if (pattern.includes("*")) { 152 | const result = await new fdir() 153 | .glob(pattern) 154 | .withBasePath() 155 | .crawl(".") 156 | .withPromise(); 157 | for (const item of result) { 158 | out.add(item); 159 | } 160 | } else { 161 | if (!fs.existsSync(pattern)) { 162 | throw new PathNotFound(pattern); 163 | } 164 | out.add(pattern); 165 | } 166 | } 167 | 168 | return Array.from(out); 169 | } 170 | 171 | export class PathNotFound extends Error { 172 | constructor(readonly path: string) { 173 | super(`File not found: ${path}`); 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /test/clause.test.ts: -------------------------------------------------------------------------------- 1 | import assert from "node:assert"; 2 | import { describe, it } from "node:test"; 3 | import { Variable } from "oso"; 4 | import { Expression } from "oso/dist/src/Expression.js"; 5 | import { Clause, optimizeClause, valueToClause } from "../src/clause.js"; 6 | 7 | describe(valueToClause.name, async () => { 8 | interface TestCase { 9 | id: string; 10 | input: unknown; 11 | output: Clause; 12 | } 13 | 14 | const testCases: TestCase[] = [ 15 | { 16 | id: "expression-1", 17 | input: new Expression("Gt", [new Variable("a"), 2]), 18 | output: { 19 | type: "expression", 20 | operator: "Gt", 21 | values: [ 22 | { type: "column", value: "a" }, 23 | { type: "value", value: 2 }, 24 | ], 25 | }, 26 | }, 27 | { 28 | id: "column-1", 29 | input: new Variable("blah"), 30 | output: { 31 | type: "column", 32 | value: "blah", 33 | }, 34 | }, 35 | { 36 | id: "value-boolean-1", 37 | input: false, 38 | output: { 39 | type: "value", 40 | value: false, 41 | }, 42 | }, 43 | { 44 | id: "and-1", 45 | input: new Expression("And", [ 46 | new Expression("Neq", [1, 2]), 47 | true, 48 | false, 49 | ]), 50 | output: { 51 | type: "and", 52 | clauses: [ 53 | { 54 | type: "expression", 55 | operator: "Neq", 56 | values: [ 57 | { type: "value", value: 1 }, 58 | { type: "value", value: 2 }, 59 | ], 60 | }, 61 | { 62 | type: "value", 63 | value: true, 64 | }, 65 | { 66 | type: "value", 67 | value: false, 68 | }, 69 | ], 70 | }, 71 | }, 72 | { 73 | id: "or-1", 74 | input: new Expression("Or", [new Expression("Neq", [1, 2]), true, false]), 75 | output: { 76 | type: "or", 77 | clauses: [ 78 | { 79 | type: "expression", 80 | operator: "Neq", 81 | values: [ 82 | { type: "value", value: 1 }, 83 | { type: "value", value: 2 }, 84 | ], 85 | }, 86 | { 87 | type: "value", 88 | value: true, 89 | }, 90 | { 91 | type: "value", 92 | value: false, 93 | }, 94 | ], 95 | }, 96 | }, 97 | { 98 | id: "not-1", 99 | input: new Expression("Not", [new Expression("Neq", [1, 2])]), 100 | output: { 101 | type: "not", 102 | clause: { 103 | type: "expression", 104 | operator: "Neq", 105 | values: [ 106 | { type: "value", value: 1 }, 107 | { type: "value", value: 2 }, 108 | ], 109 | }, 110 | }, 111 | }, 112 | ]; 113 | 114 | for (const testCase of testCases) { 115 | await it(testCase.id, () => { 116 | const result = valueToClause(testCase.input); 117 | assert.deepEqual(result, testCase.output); 118 | }); 119 | } 120 | }); 121 | 122 | describe(optimizeClause.name, async () => { 123 | interface TestCase { 124 | id: string; 125 | input: Clause; 126 | output: Clause; 127 | } 128 | 129 | const testCases: TestCase[] = [ 130 | { 131 | id: "single-and-1", 132 | input: { type: "and", clauses: [{ type: "value", value: true }] }, 133 | output: { type: "value", value: true }, 134 | }, 135 | { 136 | id: "single-or-1", 137 | input: { type: "or", clauses: [{ type: "value", value: true }] }, 138 | output: { type: "value", value: true }, 139 | }, 140 | { 141 | id: "not-and-with-dupe-1", 142 | input: { 143 | type: "not", 144 | clause: { 145 | type: "and", 146 | clauses: [ 147 | { type: "column", value: "blah" }, 148 | { type: "column", value: "blah" }, 149 | { type: "value", value: true }, 150 | ], 151 | }, 152 | }, 153 | output: { 154 | type: "or", 155 | clauses: [ 156 | { type: "not", clause: { type: "column", value: "blah" } }, 157 | { type: "not", clause: { type: "value", value: true } }, 158 | ], 159 | }, 160 | }, 161 | ]; 162 | 163 | for (const testCase of testCases) { 164 | await it(testCase.id, () => { 165 | const result = optimizeClause(testCase.input); 166 | assert.deepEqual(result, testCase.output); 167 | }); 168 | } 169 | }); 170 | -------------------------------------------------------------------------------- /test/envs/basic/setup.sql: -------------------------------------------------------------------------------- 1 | BEGIN; 2 | 3 | CREATE SCHEMA test; 4 | 5 | CREATE TABLE test.articles ( 6 | id SERIAL PRIMARY KEY, 7 | title VARCHAR(255) NOT NULL, 8 | content TEXT NOT NULL, 9 | author VARCHAR(100), 10 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 11 | updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 12 | ); 13 | 14 | INSERT INTO test.articles (title, content, author, created_at, updated_at) VALUES 15 | ('Article 1', 'Content for article 1', 'Author A', '2023-01-01 10:00:00', '2023-01-01 10:00:00'), 16 | ('Article 2', 'Content for article 2', 'Author B', '2023-01-02 10:00:00', '2023-01-02 11:00:00'), 17 | ('Article 3', 'Content for article 3', 'Author C', '2023-01-03 10:00:00', '2023-01-03 12:00:00'), 18 | ('Article 4', 'Content for article 4', 'Author A', '2023-01-04 10:00:00', '2023-01-04 13:00:00'), 19 | ('Article 5', 'Content for article 5', 'Author B', '2023-01-05 10:00:00', '2023-01-05 14:00:00'), 20 | ('Article 6', 'Content for article 6', 'Author C', '2023-01-06 10:00:00', '2023-01-06 15:00:00'), 21 | ('Article 7', 'Content for article 7', 'Author A', '2023-01-07 10:00:00', '2023-01-07 16:00:00'), 22 | ('Article 8', 'Content for article 8', 'Author B', '2023-01-08 10:00:00', '2023-01-08 17:00:00'), 23 | ('Article 9', 'Content for article 9', 'Author C', '2023-01-09 10:00:00', '2023-01-09 18:00:00'), 24 | ('Article 10', 'Content for article 10', 'Author A', '2023-01-10 10:00:00', '2023-01-10 19:00:00'), 25 | ('Article 11', 'Content for article 11', 'Author B', '2023-01-11 10:00:00', '2023-01-11 20:00:00'), 26 | ('Article 12', 'Content for article 12', 'Author C', '2023-01-12 10:00:00', '2023-01-12 21:00:00'); 27 | 28 | CREATE USER {{user1}} WITH PASSWORD 'blah'; 29 | 30 | CREATE USER {{user2}} WITH PASSWORD 'blah'; 31 | 32 | COMMIT; 33 | -------------------------------------------------------------------------------- /test/envs/basic/teardown.sql: -------------------------------------------------------------------------------- 1 | DROP ROLE {{user1}}; 2 | DROP ROLE {{user2}}; 3 | -------------------------------------------------------------------------------- /test/envs/cast/setup.sql: -------------------------------------------------------------------------------- 1 | BEGIN; 2 | 3 | CREATE SCHEMA app; 4 | 5 | CREATE TABLE app.users ( 6 | id SERIAL PRIMARY KEY, 7 | name VARCHAR(100), 8 | org_id BIGINT, 9 | internal_notes VARCHAR(100), 10 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 11 | updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 12 | ); 13 | 14 | INSERT INTO app.users (name, org_id, internal_notes) VALUES 15 | ('User 1', 12, 'Notes here'), 16 | ('User 2', 32, 'Notes here'); 17 | 18 | CREATE USER {{user1}} WITH PASSWORD 'blah'; 19 | 20 | COMMIT; 21 | -------------------------------------------------------------------------------- /test/envs/cast/teardown.sql: -------------------------------------------------------------------------------- 1 | DROP ROLE {{user1}}; 2 | -------------------------------------------------------------------------------- /test/envs/complete-1/setup.sql: -------------------------------------------------------------------------------- 1 | BEGIN; 2 | 3 | CREATE SCHEMA app; 4 | 5 | CREATE TABLE app.articles ( 6 | id SERIAL PRIMARY KEY, 7 | title VARCHAR(255) NOT NULL, 8 | content TEXT NOT NULL, 9 | author VARCHAR(100), 10 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 11 | updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 12 | ); 13 | 14 | INSERT INTO app.articles (title, content, author, created_at, updated_at) VALUES 15 | ('Article 1', 'Content for article 1', 'Author A', '2023-01-01 10:00:00', '2023-01-01 10:00:00'), 16 | ('Article 2', 'Content for article 2', 'Author B', '2023-01-02 10:00:00', '2023-01-02 11:00:00'), 17 | ('Article 3', 'Content for article 3', 'Author C', '2023-01-03 10:00:00', '2023-01-03 12:00:00'), 18 | ('Article 4', 'Content for article 4', 'Author A', '2023-01-04 10:00:00', '2023-01-04 13:00:00'), 19 | ('Article 5', 'Content for article 5', 'Author B', '2023-01-05 10:00:00', '2023-01-05 14:00:00'), 20 | ('Article 6', 'Content for article 6', 'Author C', '2023-01-06 10:00:00', '2023-01-06 15:00:00'), 21 | ('Article 7', 'Content for article 7', 'Author A', '2023-01-07 10:00:00', '2023-01-07 16:00:00'), 22 | ('Article 8', 'Content for article 8', 'Author B', '2023-01-08 10:00:00', '2023-01-08 17:00:00'), 23 | ('Article 9', 'Content for article 9', 'Author C', '2023-01-09 10:00:00', '2023-01-09 18:00:00'), 24 | ('Article 10', 'Content for article 10', 'Author A', '2023-01-10 10:00:00', '2023-01-10 19:00:00'), 25 | ('Article 11', 'Content for article 11', 'Author B', '2023-01-11 10:00:00', '2023-01-11 20:00:00'), 26 | ('Article 12', 'Content for article 12', 'Author C', '2023-01-12 10:00:00', '2023-01-12 21:00:00'); 27 | 28 | CREATE TABLE app.users ( 29 | id SERIAL PRIMARY KEY, 30 | name VARCHAR(100), 31 | org_id VARCHAR(100), 32 | internal_notes VARCHAR(100), 33 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 34 | updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 35 | ); 36 | 37 | INSERT INTO app.users (name, org_id, internal_notes) VALUES 38 | ('User 1', '12', 'Notes here'), 39 | ('User 2', '32', 'Notes here'); 40 | 41 | CREATE VIEW app.articles_view AS ( 42 | SELECT * FROM app.articles 43 | ); 44 | 45 | CREATE SCHEMA sensitive; 46 | 47 | CREATE TABLE sensitive.internal ( 48 | id SERIAL PRIMARY KEY, 49 | name VARCHAR(100) 50 | ); 51 | 52 | CREATE USER {{user1}} WITH PASSWORD 'blah'; 53 | 54 | CREATE USER {{user2}} WITH PASSWORD 'blah'; 55 | 56 | CREATE USER {{user3}} WITH PASSWORD 'blah'; 57 | 58 | COMMIT; 59 | -------------------------------------------------------------------------------- /test/envs/complete-1/teardown.sql: -------------------------------------------------------------------------------- 1 | DROP ROLE {{user1}}; 2 | DROP ROLE {{user2}}; 3 | DROP ROLE {{user3}}; 4 | -------------------------------------------------------------------------------- /test/envs/complete-2/setup.sql: -------------------------------------------------------------------------------- 1 | BEGIN; 2 | 3 | CREATE SCHEMA app; 4 | 5 | CREATE TABLE app.articles ( 6 | id SERIAL PRIMARY KEY, 7 | title VARCHAR(255) NOT NULL, 8 | content TEXT NOT NULL, 9 | author VARCHAR(100), 10 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 11 | updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 12 | ); 13 | 14 | INSERT INTO app.articles (title, content, author, created_at, updated_at) VALUES 15 | ('Article 1', 'Content for article 1', 'Author A', '2023-01-01 10:00:00', '2023-01-01 10:00:00'), 16 | ('Article 2', 'Content for article 2', 'Author B', '2023-01-02 10:00:00', '2023-01-02 11:00:00'), 17 | ('Article 3', 'Content for article 3', 'Author C', '2023-01-03 10:00:00', '2023-01-03 12:00:00'), 18 | ('Article 4', 'Content for article 4', 'Author A', '2023-01-04 10:00:00', '2023-01-04 13:00:00'), 19 | ('Article 5', 'Content for article 5', 'Author B', '2023-01-05 10:00:00', '2023-01-05 14:00:00'), 20 | ('Article 6', 'Content for article 6', 'Author C', '2023-01-06 10:00:00', '2023-01-06 15:00:00'), 21 | ('Article 7', 'Content for article 7', 'Author A', '2023-01-07 10:00:00', '2023-01-07 16:00:00'), 22 | ('Article 8', 'Content for article 8', 'Author B', '2023-01-08 10:00:00', '2023-01-08 17:00:00'), 23 | ('Article 9', 'Content for article 9', 'Author C', '2023-01-09 10:00:00', '2023-01-09 18:00:00'), 24 | ('Article 10', 'Content for article 10', 'Author A', '2023-01-10 10:00:00', '2023-01-10 19:00:00'), 25 | ('Article 11', 'Content for article 11', 'Author B', '2023-01-11 10:00:00', '2023-01-11 20:00:00'), 26 | ('Article 12', 'Content for article 12', 'Author C', '2023-01-12 10:00:00', '2023-01-12 21:00:00'); 27 | 28 | ALTER TABLE app.articles ENABLE ROW LEVEL SECURITY; 29 | 30 | CREATE TABLE app.users ( 31 | id SERIAL PRIMARY KEY, 32 | name VARCHAR(100), 33 | org_id VARCHAR(100), 34 | internal_notes VARCHAR(100), 35 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 36 | updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 37 | ); 38 | 39 | INSERT INTO app.users (name, org_id, internal_notes) VALUES 40 | ('User 1', '12', 'Notes here'), 41 | ('User 2', '32', 'Notes here'); 42 | 43 | CREATE VIEW app.articles_view AS ( 44 | SELECT * FROM app.articles 45 | ); 46 | 47 | CREATE SCHEMA sensitive; 48 | 49 | CREATE TABLE sensitive.internal ( 50 | id SERIAL PRIMARY KEY, 51 | name VARCHAR(100) 52 | ); 53 | 54 | CREATE USER {{user1}} WITH PASSWORD 'blah'; 55 | 56 | CREATE USER {{user2}} WITH PASSWORD 'blah'; 57 | 58 | CREATE USER {{user3}} WITH PASSWORD 'blah'; 59 | 60 | COMMIT; 61 | -------------------------------------------------------------------------------- /test/envs/complete-2/teardown.sql: -------------------------------------------------------------------------------- 1 | DROP ROLE {{user1}}; 2 | DROP ROLE {{user2}}; 3 | DROP ROLE {{user3}}; 4 | -------------------------------------------------------------------------------- /test/envs/func-condition/setup.sql: -------------------------------------------------------------------------------- 1 | BEGIN; 2 | 3 | CREATE SCHEMA test; 4 | 5 | CREATE FUNCTION test.is_1(value INT) RETURNS boolean 6 | AS $$ SELECT value = 1 $$ 7 | LANGUAGE SQL; 8 | 9 | CREATE TABLE test.articles ( 10 | id SERIAL PRIMARY KEY, 11 | title VARCHAR(255) NOT NULL 12 | ); 13 | 14 | INSERT INTO test.articles (id, title) VALUES 15 | (1, 'Title 1'), 16 | (2, 'Title 2'); 17 | 18 | CREATE USER {{user1}} WITH PASSWORD 'blah'; 19 | 20 | COMMIT; 21 | -------------------------------------------------------------------------------- /test/envs/func-condition/teardown.sql: -------------------------------------------------------------------------------- 1 | DROP ROLE {{user1}}; 2 | -------------------------------------------------------------------------------- /test/envs/functions-and-procedures/setup.sql: -------------------------------------------------------------------------------- 1 | BEGIN; 2 | 3 | CREATE SCHEMA test; 4 | 5 | ALTER DEFAULT PRIVILEGES 6 | REVOKE ALL PRIVILEGES ON ROUTINES FROM PUBLIC; 7 | 8 | CREATE FUNCTION test.test_func() RETURNS integer 9 | AS $$ SELECT 1 $$ 10 | LANGUAGE SQL; 11 | 12 | CREATE TABLE test.articles ( 13 | id SERIAL PRIMARY KEY, 14 | title VARCHAR(255) NOT NULL 15 | ); 16 | 17 | CREATE PROCEDURE test.insert_article(newtitle VARCHAR(255)) 18 | LANGUAGE SQL 19 | SECURITY DEFINER 20 | AS $$ 21 | INSERT INTO test.articles (title) VALUES (newtitle) 22 | $$; 23 | 24 | CREATE USER {{user1}} WITH PASSWORD 'blah'; 25 | 26 | COMMIT; 27 | -------------------------------------------------------------------------------- /test/envs/functions-and-procedures/teardown.sql: -------------------------------------------------------------------------------- 1 | DROP ROLE {{user1}}; 2 | -------------------------------------------------------------------------------- /test/envs/group/setup.sql: -------------------------------------------------------------------------------- 1 | BEGIN; 2 | 3 | CREATE SCHEMA test; 4 | 5 | CREATE TABLE test.articles ( 6 | id SERIAL PRIMARY KEY, 7 | title VARCHAR(255) NOT NULL, 8 | content TEXT NOT NULL, 9 | author VARCHAR(100), 10 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 11 | updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 12 | ); 13 | 14 | INSERT INTO test.articles (title, content, author, created_at, updated_at) VALUES 15 | ('Article 1', 'Content for article 1', 'Author A', '2023-01-01 10:00:00', '2023-01-01 10:00:00'), 16 | ('Article 2', 'Content for article 2', 'Author B', '2023-01-02 10:00:00', '2023-01-02 11:00:00'), 17 | ('Article 3', 'Content for article 3', 'Author C', '2023-01-03 10:00:00', '2023-01-03 12:00:00'), 18 | ('Article 4', 'Content for article 4', 'Author A', '2023-01-04 10:00:00', '2023-01-04 13:00:00'), 19 | ('Article 5', 'Content for article 5', 'Author B', '2023-01-05 10:00:00', '2023-01-05 14:00:00'), 20 | ('Article 6', 'Content for article 6', 'Author C', '2023-01-06 10:00:00', '2023-01-06 15:00:00'), 21 | ('Article 7', 'Content for article 7', 'Author A', '2023-01-07 10:00:00', '2023-01-07 16:00:00'), 22 | ('Article 8', 'Content for article 8', 'Author B', '2023-01-08 10:00:00', '2023-01-08 17:00:00'), 23 | ('Article 9', 'Content for article 9', 'Author C', '2023-01-09 10:00:00', '2023-01-09 18:00:00'), 24 | ('Article 10', 'Content for article 10', 'Author A', '2023-01-10 10:00:00', '2023-01-10 19:00:00'), 25 | ('Article 11', 'Content for article 11', 'Author B', '2023-01-11 10:00:00', '2023-01-11 20:00:00'), 26 | ('Article 12', 'Content for article 12', 'Author C', '2023-01-12 10:00:00', '2023-01-12 21:00:00'); 27 | 28 | CREATE USER {{user1}} WITH PASSWORD 'blah'; 29 | 30 | CREATE USER {{user2}} WITH PASSWORD 'blah'; 31 | 32 | CREATE GROUP {{user3}} WITH USER {{user1}}, {{user2}}; 33 | 34 | COMMIT; 35 | -------------------------------------------------------------------------------- /test/envs/group/teardown.sql: -------------------------------------------------------------------------------- 1 | DROP ROLE {{user1}}; 2 | DROP ROLE {{user2}}; 3 | DROP GROUP {{user3}}; 4 | -------------------------------------------------------------------------------- /test/envs/long-table-name/setup.sql: -------------------------------------------------------------------------------- 1 | BEGIN; 2 | 3 | CREATE SCHEMA test; 4 | 5 | CREATE TABLE test.articles_but_with_an_extremely_long_table_name ( 6 | id SERIAL PRIMARY KEY, 7 | title VARCHAR(255) NOT NULL, 8 | content TEXT NOT NULL, 9 | author VARCHAR(100), 10 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 11 | updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 12 | ); 13 | 14 | CREATE TABLE test.articles_but_with_an_extremely_long_table_name_2 ( 15 | id SERIAL PRIMARY KEY, 16 | title VARCHAR(255) NOT NULL, 17 | content TEXT NOT NULL, 18 | author VARCHAR(100), 19 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 20 | updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 21 | ); 22 | 23 | INSERT INTO test.articles_but_with_an_extremely_long_table_name (title, content, author, created_at, updated_at) VALUES 24 | ('Article 1', 'Content for article 1', 'Author A', '2023-01-01 10:00:00', '2023-01-01 10:00:00'), 25 | ('Article 2', 'Content for article 2', 'Author B', '2023-01-02 10:00:00', '2023-01-02 11:00:00'), 26 | ('Article 3', 'Content for article 3', 'Author C', '2023-01-03 10:00:00', '2023-01-03 12:00:00'), 27 | ('Article 4', 'Content for article 4', 'Author A', '2023-01-04 10:00:00', '2023-01-04 13:00:00'), 28 | ('Article 5', 'Content for article 5', 'Author B', '2023-01-05 10:00:00', '2023-01-05 14:00:00'), 29 | ('Article 6', 'Content for article 6', 'Author C', '2023-01-06 10:00:00', '2023-01-06 15:00:00'), 30 | ('Article 7', 'Content for article 7', 'Author A', '2023-01-07 10:00:00', '2023-01-07 16:00:00'), 31 | ('Article 8', 'Content for article 8', 'Author B', '2023-01-08 10:00:00', '2023-01-08 17:00:00'), 32 | ('Article 9', 'Content for article 9', 'Author C', '2023-01-09 10:00:00', '2023-01-09 18:00:00'), 33 | ('Article 10', 'Content for article 10', 'Author A', '2023-01-10 10:00:00', '2023-01-10 19:00:00'), 34 | ('Article 11', 'Content for article 11', 'Author B', '2023-01-11 10:00:00', '2023-01-11 20:00:00'), 35 | ('Article 12', 'Content for article 12', 'Author C', '2023-01-12 10:00:00', '2023-01-12 21:00:00'); 36 | 37 | INSERT INTO test.articles_but_with_an_extremely_long_table_name_2 (title, content, author, created_at, updated_at) VALUES 38 | ('Article 1', 'Content for article 1', 'Author A', '2023-01-01 10:00:00', '2023-01-01 10:00:00'), 39 | ('Article 2', 'Content for article 2', 'Author B', '2023-01-02 10:00:00', '2023-01-02 11:00:00'); 40 | 41 | CREATE USER {{user1}} WITH PASSWORD 'blah'; 42 | 43 | CREATE USER {{user2}} WITH PASSWORD 'blah'; 44 | 45 | COMMIT; 46 | -------------------------------------------------------------------------------- /test/envs/long-table-name/teardown.sql: -------------------------------------------------------------------------------- 1 | DROP ROLE {{user1}}; 2 | DROP ROLE {{user2}}; 3 | -------------------------------------------------------------------------------- /test/envs/multi-table/setup.sql: -------------------------------------------------------------------------------- 1 | BEGIN; 2 | 3 | CREATE SCHEMA test; 4 | 5 | CREATE SCHEMA test2; 6 | 7 | CREATE TABLE test.articles ( 8 | id SERIAL PRIMARY KEY, 9 | title VARCHAR(255) NOT NULL, 10 | content TEXT NOT NULL, 11 | author VARCHAR(100), 12 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 13 | updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 14 | ); 15 | 16 | INSERT INTO test.articles (title, content, author, created_at, updated_at) VALUES 17 | ('Article 1', 'Content for article 1', 'Author A', '2023-01-01 10:00:00', '2023-01-01 10:00:00'), 18 | ('Article 2', 'Content for article 2', 'Author B', '2023-01-02 10:00:00', '2023-01-02 11:00:00'), 19 | ('Article 3', 'Content for article 3', 'Author C', '2023-01-03 10:00:00', '2023-01-03 12:00:00'), 20 | ('Article 4', 'Content for article 4', 'Author A', '2023-01-04 10:00:00', '2023-01-04 13:00:00'), 21 | ('Article 5', 'Content for article 5', 'Author B', '2023-01-05 10:00:00', '2023-01-05 14:00:00'), 22 | ('Article 6', 'Content for article 6', 'Author C', '2023-01-06 10:00:00', '2023-01-06 15:00:00'), 23 | ('Article 7', 'Content for article 7', 'Author A', '2023-01-07 10:00:00', '2023-01-07 16:00:00'), 24 | ('Article 8', 'Content for article 8', 'Author B', '2023-01-08 10:00:00', '2023-01-08 17:00:00'), 25 | ('Article 9', 'Content for article 9', 'Author C', '2023-01-09 10:00:00', '2023-01-09 18:00:00'), 26 | ('Article 10', 'Content for article 10', 'Author A', '2023-01-10 10:00:00', '2023-01-10 19:00:00'), 27 | ('Article 11', 'Content for article 11', 'Author B', '2023-01-11 10:00:00', '2023-01-11 20:00:00'), 28 | ('Article 12', 'Content for article 12', 'Author C', '2023-01-12 10:00:00', '2023-01-12 21:00:00'); 29 | 30 | CREATE TABLE test.articles2 ( 31 | id2 SERIAL PRIMARY KEY, 32 | title2 VARCHAR(255) NOT NULL, 33 | content2 TEXT NOT NULL, 34 | author2 VARCHAR(100), 35 | created_at2 TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 36 | updated_at2 TIMESTAMP DEFAULT CURRENT_TIMESTAMP 37 | ); 38 | 39 | INSERT INTO test.articles2 (title2, content2, author2, created_at2, updated_at2) VALUES 40 | ('Article 1', 'Content for article 1', 'Author A', '2023-01-01 10:00:00', '2023-01-01 10:00:00'), 41 | ('Article 2', 'Content for article 2', 'Author B', '2023-01-02 10:00:00', '2023-01-02 11:00:00'), 42 | ('Article 3', 'Content for article 3', 'Author C', '2023-01-03 10:00:00', '2023-01-03 12:00:00'), 43 | ('Article 4', 'Content for article 4', 'Author A', '2023-01-04 10:00:00', '2023-01-04 13:00:00'), 44 | ('Article 5', 'Content for article 5', 'Author B', '2023-01-05 10:00:00', '2023-01-05 14:00:00'), 45 | ('Article 6', 'Content for article 6', 'Author C', '2023-01-06 10:00:00', '2023-01-06 15:00:00'), 46 | ('Article 7', 'Content for article 7', 'Author A', '2023-01-07 10:00:00', '2023-01-07 16:00:00'), 47 | ('Article 8', 'Content for article 8', 'Author B', '2023-01-08 10:00:00', '2023-01-08 17:00:00'), 48 | ('Article 9', 'Content for article 9', 'Author C', '2023-01-09 10:00:00', '2023-01-09 18:00:00'), 49 | ('Article 10', 'Content for article 10', 'Author A', '2023-01-10 10:00:00', '2023-01-10 19:00:00'), 50 | ('Article 11', 'Content for article 11', 'Author B', '2023-01-11 10:00:00', '2023-01-11 20:00:00'), 51 | ('Article 12', 'Content for article 12', 'Author C', '2023-01-12 10:00:00', '2023-01-12 21:00:00'); 52 | 53 | CREATE TABLE test2.articles ( 54 | id SERIAL PRIMARY KEY, 55 | title VARCHAR(255) NOT NULL, 56 | content TEXT NOT NULL, 57 | author VARCHAR(100), 58 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 59 | updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 60 | ); 61 | 62 | INSERT INTO test2.articles (title, content, author, created_at, updated_at) VALUES 63 | ('Article 1', 'Content for article 1', 'Author A', '2023-01-01 10:00:00', '2023-01-01 10:00:00'), 64 | ('Article 2', 'Content for article 2', 'Author B', '2023-01-02 10:00:00', '2023-01-02 11:00:00'), 65 | ('Article 3', 'Content for article 3', 'Author C', '2023-01-03 10:00:00', '2023-01-03 12:00:00'), 66 | ('Article 4', 'Content for article 4', 'Author A', '2023-01-04 10:00:00', '2023-01-04 13:00:00'), 67 | ('Article 5', 'Content for article 5', 'Author B', '2023-01-05 10:00:00', '2023-01-05 14:00:00'), 68 | ('Article 6', 'Content for article 6', 'Author C', '2023-01-06 10:00:00', '2023-01-06 15:00:00'), 69 | ('Article 7', 'Content for article 7', 'Author A', '2023-01-07 10:00:00', '2023-01-07 16:00:00'), 70 | ('Article 8', 'Content for article 8', 'Author B', '2023-01-08 10:00:00', '2023-01-08 17:00:00'), 71 | ('Article 9', 'Content for article 9', 'Author C', '2023-01-09 10:00:00', '2023-01-09 18:00:00'), 72 | ('Article 10', 'Content for article 10', 'Author A', '2023-01-10 10:00:00', '2023-01-10 19:00:00'), 73 | ('Article 11', 'Content for article 11', 'Author B', '2023-01-11 10:00:00', '2023-01-11 20:00:00'), 74 | ('Article 12', 'Content for article 12', 'Author C', '2023-01-12 10:00:00', '2023-01-12 21:00:00'); 75 | 76 | CREATE TABLE test2.articles2 ( 77 | id2 SERIAL PRIMARY KEY, 78 | title2 VARCHAR(255) NOT NULL, 79 | content2 TEXT NOT NULL, 80 | author2 VARCHAR(100), 81 | created_at2 TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 82 | updated_at2 TIMESTAMP DEFAULT CURRENT_TIMESTAMP 83 | ); 84 | 85 | INSERT INTO test2.articles2 (title2, content2, author2, created_at2, updated_at2) VALUES 86 | ('Article 1', 'Content for article 1', 'Author A', '2023-01-01 10:00:00', '2023-01-01 10:00:00'), 87 | ('Article 2', 'Content for article 2', 'Author B', '2023-01-02 10:00:00', '2023-01-02 11:00:00'), 88 | ('Article 3', 'Content for article 3', 'Author C', '2023-01-03 10:00:00', '2023-01-03 12:00:00'), 89 | ('Article 4', 'Content for article 4', 'Author A', '2023-01-04 10:00:00', '2023-01-04 13:00:00'), 90 | ('Article 5', 'Content for article 5', 'Author B', '2023-01-05 10:00:00', '2023-01-05 14:00:00'), 91 | ('Article 6', 'Content for article 6', 'Author C', '2023-01-06 10:00:00', '2023-01-06 15:00:00'), 92 | ('Article 7', 'Content for article 7', 'Author A', '2023-01-07 10:00:00', '2023-01-07 16:00:00'), 93 | ('Article 8', 'Content for article 8', 'Author B', '2023-01-08 10:00:00', '2023-01-08 17:00:00'), 94 | ('Article 9', 'Content for article 9', 'Author C', '2023-01-09 10:00:00', '2023-01-09 18:00:00'), 95 | ('Article 10', 'Content for article 10', 'Author A', '2023-01-10 10:00:00', '2023-01-10 19:00:00'), 96 | ('Article 11', 'Content for article 11', 'Author B', '2023-01-11 10:00:00', '2023-01-11 20:00:00'), 97 | ('Article 12', 'Content for article 12', 'Author C', '2023-01-12 10:00:00', '2023-01-12 21:00:00'); 98 | 99 | CREATE USER {{user1}} WITH PASSWORD 'blah'; 100 | 101 | CREATE USER {{user2}} WITH PASSWORD 'blah'; 102 | 103 | COMMIT; 104 | -------------------------------------------------------------------------------- /test/envs/multi-table/teardown.sql: -------------------------------------------------------------------------------- 1 | DROP ROLE {{user1}}; 2 | DROP ROLE {{user2}}; 3 | -------------------------------------------------------------------------------- /test/envs/partial-rls/setup.sql: -------------------------------------------------------------------------------- 1 | BEGIN; 2 | 3 | CREATE SCHEMA test; 4 | 5 | CREATE TABLE test.articles ( 6 | id SERIAL PRIMARY KEY, 7 | title VARCHAR(255) NOT NULL, 8 | content TEXT NOT NULL, 9 | author VARCHAR(100), 10 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 11 | updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 12 | ); 13 | 14 | INSERT INTO test.articles (title, content, author, created_at, updated_at) VALUES 15 | ('Article 1', 'Content for article 1', 'Author A', '2023-01-01 10:00:00', '2023-01-01 10:00:00'), 16 | ('Article 2', 'Content for article 2', 'Author B', '2023-01-02 10:00:00', '2023-01-02 11:00:00'), 17 | ('Article 3', 'Content for article 3', 'Author C', '2023-01-03 10:00:00', '2023-01-03 12:00:00'), 18 | ('Article 4', 'Content for article 4', 'Author A', '2023-01-04 10:00:00', '2023-01-04 13:00:00'), 19 | ('Article 5', 'Content for article 5', 'Author B', '2023-01-05 10:00:00', '2023-01-05 14:00:00'), 20 | ('Article 6', 'Content for article 6', 'Author C', '2023-01-06 10:00:00', '2023-01-06 15:00:00'), 21 | ('Article 7', 'Content for article 7', 'Author A', '2023-01-07 10:00:00', '2023-01-07 16:00:00'), 22 | ('Article 8', 'Content for article 8', 'Author B', '2023-01-08 10:00:00', '2023-01-08 17:00:00'), 23 | ('Article 9', 'Content for article 9', 'Author C', '2023-01-09 10:00:00', '2023-01-09 18:00:00'), 24 | ('Article 10', 'Content for article 10', 'Author A', '2023-01-10 10:00:00', '2023-01-10 19:00:00'), 25 | ('Article 11', 'Content for article 11', 'Author B', '2023-01-11 10:00:00', '2023-01-11 20:00:00'), 26 | ('Article 12', 'Content for article 12', 'Author C', '2023-01-12 10:00:00', '2023-01-12 21:00:00'); 27 | 28 | ALTER TABLE test.articles ENABLE ROW LEVEL SECURITY; 29 | 30 | CREATE TABLE test.articles2 ( 31 | id SERIAL PRIMARY KEY, 32 | title VARCHAR(255) NOT NULL, 33 | content TEXT NOT NULL, 34 | author VARCHAR(100), 35 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 36 | updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 37 | ); 38 | 39 | INSERT INTO test.articles2 (title, content, author, created_at, updated_at) VALUES 40 | ('Article 1', 'Content for article 1', 'Author A', '2023-01-01 10:00:00', '2023-01-01 10:00:00'), 41 | ('Article 2', 'Content for article 2', 'Author B', '2023-01-02 10:00:00', '2023-01-02 11:00:00'), 42 | ('Article 3', 'Content for article 3', 'Author A', '2023-01-01 10:00:00', '2023-01-01 10:00:00'); 43 | 44 | ALTER TABLE test.articles2 ENABLE ROW LEVEL SECURITY; 45 | 46 | CREATE USER {{user1}} WITH PASSWORD 'blah'; 47 | 48 | CREATE USER {{user2}} WITH PASSWORD 'blah'; 49 | 50 | CREATE POLICY "limit_user_1" ON test.articles2 AS PERMISSIVE FOR SELECT TO {{user1}} USING ("title" = 'Article 1'); 51 | 52 | COMMIT; 53 | -------------------------------------------------------------------------------- /test/envs/partial-rls/teardown.sql: -------------------------------------------------------------------------------- 1 | DROP ROLE {{user1}}; 2 | DROP ROLE {{user2}}; 3 | -------------------------------------------------------------------------------- /test/envs/rls-mutation/setup.sql: -------------------------------------------------------------------------------- 1 | BEGIN; 2 | 3 | CREATE SCHEMA test; 4 | 5 | CREATE TABLE test.articles ( 6 | id SERIAL PRIMARY KEY, 7 | author VARCHAR(255) NOT NULL, 8 | title VARCHAR(255) NOT NULL 9 | ); 10 | 11 | INSERT INTO test.articles (author, title) 12 | VALUES 13 | ('Author A', 'Unique Title 1'), 14 | ('Author A', 'Unique Title 2'), 15 | ('Author A', 'Unique Title 3'), 16 | ('Author A', 'Unique Title 4'), 17 | ('Author A', 'Unique Title 5'), 18 | ('Author B', 'Unique Title 6'), 19 | ('Author B', 'Unique Title 7'), 20 | ('Author B', 'Unique Title 8'), 21 | ('Author B', 'Unique Title 9'), 22 | ('Author B', 'Unique Title 10'); 23 | 24 | ALTER TABLE test.articles ENABLE ROW LEVEL SECURITY; 25 | 26 | CREATE USER {{user1}} WITH PASSWORD 'blah'; 27 | 28 | CREATE USER {{user2}} WITH PASSWORD 'blah'; 29 | 30 | CREATE POLICY "limit_user_1_update" ON test.articles AS PERMISSIVE FOR UPDATE TO {{user1}} USING ("author" = 'Author A') WITH CHECK ("author" = 'Author A'); 31 | 32 | CREATE POLICY "limit_user_2_delete" ON test.articles AS PERMISSIVE FOR DELETE TO {{user2}} USING ("author" = 'Author B'); 33 | 34 | COMMIT; 35 | -------------------------------------------------------------------------------- /test/envs/rls-mutation/teardown.sql: -------------------------------------------------------------------------------- 1 | DROP ROLE {{user1}}; 2 | DROP ROLE {{user2}}; 3 | -------------------------------------------------------------------------------- /test/envs/sequence/setup.sql: -------------------------------------------------------------------------------- 1 | BEGIN; 2 | 3 | CREATE SCHEMA test; 4 | 5 | CREATE SEQUENCE test.seq1 START 99; 6 | 7 | CREATE SEQUENCE test.seq2 START 72; 8 | 9 | CREATE USER {{user1}} WITH PASSWORD 'blah'; 10 | 11 | COMMIT; 12 | -------------------------------------------------------------------------------- /test/envs/sequence/teardown.sql: -------------------------------------------------------------------------------- 1 | DROP ROLE {{user1}}; 2 | -------------------------------------------------------------------------------- /test/envs/view/setup.sql: -------------------------------------------------------------------------------- 1 | BEGIN; 2 | 3 | CREATE SCHEMA test; 4 | 5 | CREATE TABLE test.articles ( 6 | id SERIAL PRIMARY KEY, 7 | title VARCHAR(255) NOT NULL, 8 | content TEXT NOT NULL, 9 | author VARCHAR(100), 10 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 11 | updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 12 | ); 13 | 14 | CREATE VIEW test.author_a_articles AS ( 15 | SELECT * FROM test.articles WHERE author = 'Author A' 16 | ); 17 | 18 | INSERT INTO test.articles (title, content, author, created_at, updated_at) VALUES 19 | ('Article 1', 'Content for article 1', 'Author A', '2023-01-01 10:00:00', '2023-01-01 10:00:00'), 20 | ('Article 2', 'Content for article 2', 'Author B', '2023-01-02 10:00:00', '2023-01-02 11:00:00'), 21 | ('Article 3', 'Content for article 3', 'Author C', '2023-01-03 10:00:00', '2023-01-03 12:00:00'), 22 | ('Article 4', 'Content for article 4', 'Author A', '2023-01-04 10:00:00', '2023-01-04 13:00:00'), 23 | ('Article 5', 'Content for article 5', 'Author B', '2023-01-05 10:00:00', '2023-01-05 14:00:00'), 24 | ('Article 6', 'Content for article 6', 'Author C', '2023-01-06 10:00:00', '2023-01-06 15:00:00'), 25 | ('Article 7', 'Content for article 7', 'Author A', '2023-01-07 10:00:00', '2023-01-07 16:00:00'), 26 | ('Article 8', 'Content for article 8', 'Author B', '2023-01-08 10:00:00', '2023-01-08 17:00:00'), 27 | ('Article 9', 'Content for article 9', 'Author C', '2023-01-09 10:00:00', '2023-01-09 18:00:00'), 28 | ('Article 10', 'Content for article 10', 'Author A', '2023-01-10 10:00:00', '2023-01-10 19:00:00'), 29 | ('Article 11', 'Content for article 11', 'Author B', '2023-01-11 10:00:00', '2023-01-11 20:00:00'), 30 | ('Article 12', 'Content for article 12', 'Author C', '2023-01-12 10:00:00', '2023-01-12 21:00:00'); 31 | 32 | CREATE USER {{user1}} WITH PASSWORD 'blah'; 33 | 34 | COMMIT; 35 | -------------------------------------------------------------------------------- /test/envs/view/teardown.sql: -------------------------------------------------------------------------------- 1 | DROP ROLE {{user1}}; 2 | -------------------------------------------------------------------------------- /test/rules/basic-1.polar: -------------------------------------------------------------------------------- 1 | 2 | allow(actor, "usage", "test") if isAppUser(actor); 3 | 4 | allow(user, action, table) 5 | if isAppUser(user) 6 | and action in ["select", "insert", "update", "delete"] 7 | and table == "test.articles" 8 | and table.col in ["id", "title"] 9 | and table.row.author = "Author A" 10 | and table.row.id < 5; 11 | 12 | isAppUser(actor) if actor in [user1]; 13 | -------------------------------------------------------------------------------- /test/rules/basic-2.polar: -------------------------------------------------------------------------------- 1 | 2 | allow(actor, "usage", "test") if actor == user1; 3 | -------------------------------------------------------------------------------- /test/rules/basic-3.polar: -------------------------------------------------------------------------------- 1 | 2 | allow(actor, "usage", "test") if isAppUser(actor); 3 | 4 | allow(user, _, table) 5 | if isAppUser(user) 6 | and table == "test.articles" 7 | and table.col in ["id", "title"] 8 | and table.row.author = "Author A" 9 | and table.row.id < 5; 10 | 11 | isAppUser(actor) if actor in [user1]; 12 | -------------------------------------------------------------------------------- /test/rules/basic-4.polar: -------------------------------------------------------------------------------- 1 | 2 | allow(actor, "usage", "test") if actor == user1; 3 | 4 | allow(actor, _, table) 5 | if actor == user1 6 | and table == "test.articles" 7 | and table.col in ["author"] 8 | and table.row.author == sql.current_setting("my.author"); 9 | -------------------------------------------------------------------------------- /test/rules/basic-5.polar: -------------------------------------------------------------------------------- 1 | 2 | allow(actor, "usage", "test") if actor == user1; 3 | 4 | allow(actor, _, table) 5 | if actor == user1 6 | and table == "test.articles" 7 | and table.row.created_at == sql.date_trunc("hour", table.row.updated_at); 8 | -------------------------------------------------------------------------------- /test/rules/basic-6.polar: -------------------------------------------------------------------------------- 1 | 2 | allow(actor, "usage", "test") if actor == user1; 3 | 4 | allow(actor, _, table) 5 | if actor == user1 6 | and table == "test.articles" 7 | and sql.lit("2023-01-01 10:00:00") == sql.date_trunc("hour", table.row.updated_at); 8 | -------------------------------------------------------------------------------- /test/rules/basic-7.polar: -------------------------------------------------------------------------------- 1 | 2 | allow(actor, _, resource) 3 | if actor == user1 4 | and resource.type == "schema"; 5 | 6 | allow(actor, _, resource) 7 | if actor == user1 8 | and resource.type == "table" 9 | and resource.name == "articles"; 10 | -------------------------------------------------------------------------------- /test/rules/basic-8.polar: -------------------------------------------------------------------------------- 1 | 2 | allow(actor, "usage", "test") if actor == user1; 3 | 4 | allow(actor, "select", resource) 5 | if actor == user1 6 | and resource == "test.articles" 7 | and forall(x in ["author", "title"], resource.col != x); 8 | -------------------------------------------------------------------------------- /test/rules/basic-all-actors-1.polar: -------------------------------------------------------------------------------- 1 | 2 | allow(_, "select", "test.articles"); 3 | -------------------------------------------------------------------------------- /test/rules/basic-invalid-object-type-1.polar: -------------------------------------------------------------------------------- 1 | 2 | allow(actor, "select", resource) 3 | if actor == user1 4 | and resource.type == "does_not_exist" 5 | and resource == "api.articles"; 6 | -------------------------------------------------------------------------------- /test/rules/basic-invalid-object-type-2.polar: -------------------------------------------------------------------------------- 1 | 2 | allow(actor, "select", resource) 3 | if actor == user1 4 | and resource.type == 1 5 | and resource == "api.articles"; 6 | -------------------------------------------------------------------------------- /test/rules/basic-invalid-privilege-1.polar: -------------------------------------------------------------------------------- 1 | 2 | allow(actor, "does_not_exist", "test.articles") if actor == user1; 3 | -------------------------------------------------------------------------------- /test/rules/basic-non-existant-actor-1.polar: -------------------------------------------------------------------------------- 1 | 2 | allow("does_not_exist", "select", "test.articles"); 3 | -------------------------------------------------------------------------------- /test/rules/basic-non-existant-actor-2.polar: -------------------------------------------------------------------------------- 1 | 2 | allow(actor, "select", "test.articles") 3 | if actor.type == "user" 4 | and actor.name == "does_not_exist"; 5 | -------------------------------------------------------------------------------- /test/rules/cast-1.polar: -------------------------------------------------------------------------------- 1 | 2 | allow(actor, "usage", "app") if actor == user1; 3 | 4 | allow(actor, "select", table) 5 | if actor == user1 6 | and table == "app.users" 7 | and table.row.org_id == sql.cast(sql.current_setting("user.org_id"), "bigint"); 8 | -------------------------------------------------------------------------------- /test/rules/complete-1.polar: -------------------------------------------------------------------------------- 1 | isDev(actor) 2 | if name in [user1] 3 | and actor.type == "user" 4 | and actor == name; 5 | 6 | isQA(actor) if actor == user2; 7 | 8 | isQA(actor) if isDev(actor); 9 | 10 | isApp(actor) if actor == user3; 11 | 12 | allow(actor, "usage", resource) 13 | if isQA(actor) 14 | and resource.type == "schema" 15 | and resource != "sensitive"; 16 | 17 | allow(actor, "usage", "sensitive") if isDev(actor); 18 | 19 | allow(actor, "select", resource) 20 | if isQA(actor) 21 | and obj_type in ["table", "view"] 22 | and resource.type == obj_type 23 | and resource.schema != "sensitive"; 24 | 25 | allow(actor, permission, resource) 26 | if isDev(actor) 27 | and permission != "truncate" 28 | and obj_type in ["table", "view", "sequence"] 29 | and resource.type == obj_type; 30 | 31 | allow(actor, "usage", "app") if isApp(actor); 32 | 33 | allow(actor, "usage", "app.users_id_seq") if isApp(actor); 34 | 35 | allow(actor, permission, resource) 36 | if isApp(actor) 37 | and permission in ["select", "insert", "update", "delete"] 38 | and resource == "app.users" 39 | and resource.col != "internal_notes" 40 | and resource.row.org_id == sql.current_setting("user.org_id"); 41 | -------------------------------------------------------------------------------- /test/rules/func-condition-1.polar: -------------------------------------------------------------------------------- 1 | 2 | allow(actor, "usage", "test") if actor == user1; 3 | 4 | allow(actor, "select", table) 5 | if actor == user1 6 | and table == "test.articles" 7 | and sql.test.is_1(table.row.id) == sql.lit(true); 8 | -------------------------------------------------------------------------------- /test/rules/functions-and-procedures-1.polar: -------------------------------------------------------------------------------- 1 | 2 | allow(actor, "usage", "test") if actor == user1; 3 | 4 | allow(actor, _, resource) 5 | if actor == user1 6 | and resource.type == "function"; 7 | -------------------------------------------------------------------------------- /test/rules/functions-and-procedures-2.polar: -------------------------------------------------------------------------------- 1 | 2 | allow(actor, "usage", "test") if actor == user1; 3 | 4 | allow(actor, _, resource) 5 | if actor == user1 6 | and resource == "test.test_func"; 7 | -------------------------------------------------------------------------------- /test/rules/functions-and-procedures-3.polar: -------------------------------------------------------------------------------- 1 | 2 | allow(actor, "usage", "test") if actor == user1; 3 | 4 | allow(actor, _, resource) 5 | if actor == user1 6 | and resource.type == "procedure"; 7 | -------------------------------------------------------------------------------- /test/rules/functions-and-procedures-4.polar: -------------------------------------------------------------------------------- 1 | 2 | allow(actor, "usage", "test") if actor == user1; 3 | 4 | allow(actor, _, resource) 5 | if actor == user1 6 | and resource == "test.insert_article"; 7 | -------------------------------------------------------------------------------- /test/rules/group-1.polar: -------------------------------------------------------------------------------- 1 | 2 | allow(actor, _, schema) 3 | if actor == user3 4 | and schema == "test"; 5 | 6 | allow(actor, _, table) 7 | if actor == user3 8 | and table == "test.articles"; 9 | -------------------------------------------------------------------------------- /test/rules/long-table-name.polar: -------------------------------------------------------------------------------- 1 | 2 | allow(actor, "usage", "test") if isAppUser(actor); 3 | 4 | allow(user, action, table) 5 | if isAppUser(user) 6 | and action in ["select", "insert", "update", "delete"] 7 | and table_name in [ 8 | "test.articles_but_with_an_extremely_long_table_name", 9 | "test.articles_but_with_an_extremely_long_table_name_2" 10 | ] 11 | and table == table_name 12 | and table.row.author = "Author A"; 13 | 14 | isAppUser(actor) if actor in [user1, user2]; 15 | -------------------------------------------------------------------------------- /test/rules/multi-table-1.polar: -------------------------------------------------------------------------------- 1 | 2 | allow(_1, _2, _3); 3 | -------------------------------------------------------------------------------- /test/rules/multi-table-2.polar: -------------------------------------------------------------------------------- 1 | 2 | allow(user, _, resource) 3 | if user == user1 4 | and resource.schema == "test"; 5 | -------------------------------------------------------------------------------- /test/rules/multi-table-3.polar: -------------------------------------------------------------------------------- 1 | 2 | allow(_, action, _) 3 | if action in ["usage", "select"]; 4 | -------------------------------------------------------------------------------- /test/rules/multi-table-4.polar: -------------------------------------------------------------------------------- 1 | 2 | allow(user, "usage", resource) 3 | if user in [user1, user2] 4 | and resource in ["test", "test2"]; 5 | 6 | allow(user, _, resource) 7 | if user == user1 8 | and resource == "test.articles" 9 | and resource.row.id = 5; 10 | -------------------------------------------------------------------------------- /test/rules/partial-rls-1.polar: -------------------------------------------------------------------------------- 1 | 2 | allow(user, "usage", "test") if user in [user1, user2]; 3 | 4 | allow(user, "select", resource) 5 | if user in [user1, user2] 6 | and table_name in ["test.articles", "test.articles2"] 7 | and table_name == resource 8 | and resource.row.author == "Author A"; 9 | -------------------------------------------------------------------------------- /test/rules/rls-mutation-1.polar: -------------------------------------------------------------------------------- 1 | 2 | allow(user1, "usage", "test"); 3 | allow(user2, "usage", "test"); 4 | allow(user1, "usage", "test.articles_id_seq"); 5 | allow(user2, "usage", "test.articles_id_seq"); 6 | allow(user1, "select", "test.articles"); 7 | allow(user2, "select", "test.articles"); 8 | allow(user1, "update", "test.articles"); 9 | allow(user2, "delete", "test.articles"); 10 | 11 | allow(user2, perm, resource) 12 | if resource == "test.articles" 13 | and perm in ["update", "insert"] 14 | and resource.row.author == "Author B"; 15 | 16 | allow(user1, perm, resource) 17 | if resource == "test.articles" 18 | and perm in ["delete", "insert"] 19 | and resource.row.author == "Author A"; 20 | -------------------------------------------------------------------------------- /test/rules/sequence-1.polar: -------------------------------------------------------------------------------- 1 | 2 | allow(actor, "usage", "test") if actor == user1; 3 | 4 | allow(actor, _, resource) 5 | if actor == user1 6 | and resource.type == "sequence"; 7 | -------------------------------------------------------------------------------- /test/rules/sequence-2.polar: -------------------------------------------------------------------------------- 1 | 2 | allow(actor, "usage", "test") if actor == user1; 3 | 4 | allow(actor, _, resource) 5 | if actor == user1 6 | and name in ["test.seq1", "test.seq2"] 7 | and resource == name; 8 | -------------------------------------------------------------------------------- /test/rules/sequence-3.polar: -------------------------------------------------------------------------------- 1 | 2 | allow(actor, "usage", "test") if actor == user1; 3 | 4 | allow(actor, _, resource) 5 | if actor == user1 6 | and resource.schema == "test" 7 | and resource.name == "seq1"; 8 | -------------------------------------------------------------------------------- /test/rules/sequence-4.polar: -------------------------------------------------------------------------------- 1 | 2 | allow(actor, "usage", "test") if actor == user1; 3 | 4 | allow(actor, _, resource) 5 | if actor == user1 6 | and resource == "test.seq1"; 7 | -------------------------------------------------------------------------------- /test/rules/view-1.polar: -------------------------------------------------------------------------------- 1 | 2 | allow(actor, _, resource) 3 | if actor == user1 4 | and resource.type == "schema"; 5 | 6 | allow(actor, "select", resource) 7 | if actor == user1 8 | and resource == "test.author_a_articles"; 9 | -------------------------------------------------------------------------------- /test/rules/view-2.polar: -------------------------------------------------------------------------------- 1 | 2 | allow(actor, _, resource) 3 | if actor == user1 4 | and resource == "test"; 5 | 6 | allow(actor, "select", resource) 7 | if actor == user1 8 | and resource.type == "view"; 9 | -------------------------------------------------------------------------------- /test/rules/view-3.polar: -------------------------------------------------------------------------------- 1 | 2 | allow(actor, "usage", "test") if actor == user1; 3 | 4 | allow(actor, _, resource) 5 | if actor == user1 6 | and resource == "test.articles"; 7 | -------------------------------------------------------------------------------- /test/rules/view-4.polar: -------------------------------------------------------------------------------- 1 | 2 | allow(actor, "usage", "test") if actor == user1; 3 | 4 | allow(actor, _, resource) 5 | if actor == user1 6 | and resource.type == "table"; 7 | -------------------------------------------------------------------------------- /test/sql-literal.test.ts: -------------------------------------------------------------------------------- 1 | import assert from "node:assert"; 2 | import { describe, it } from "node:test"; 3 | import { valueToSqlLiteral } from "../src/utils.js"; 4 | 5 | describe("sql-literal", async () => { 6 | interface TestCase { 7 | id: string; 8 | value: unknown; 9 | error?: boolean; 10 | output: string; 11 | } 12 | 13 | const cases: TestCase[] = [ 14 | { 15 | id: "number-1", 16 | value: 12, 17 | output: "12", 18 | }, 19 | { 20 | id: "number-2", 21 | value: 12.32, 22 | output: "12.32", 23 | }, 24 | { 25 | id: "boolean-1", 26 | value: true, 27 | output: "true", 28 | }, 29 | { 30 | id: "boolean-2", 31 | value: false, 32 | output: "false", 33 | }, 34 | { 35 | id: "null-1", 36 | value: null, 37 | output: "null", 38 | }, 39 | { 40 | id: "null-2", 41 | value: undefined, 42 | output: "null", 43 | }, 44 | { 45 | id: "object-1", 46 | value: {}, 47 | error: true, 48 | output: "", 49 | }, 50 | { 51 | id: "date-1", 52 | value: new Date(Date.UTC(2023, 12, 3, 12, 4, 3)), 53 | output: "'2024-01-03T12:04:03.000Z'", 54 | }, 55 | { 56 | id: "string-1", 57 | value: "abc", 58 | output: "'abc'", 59 | }, 60 | { 61 | id: "string-2", 62 | value: "def'ghi", 63 | output: "'def''ghi'", 64 | }, 65 | ]; 66 | 67 | for (const caseVal of cases) { 68 | await it(`${caseVal.id}: formats correctly`, async () => { 69 | if (caseVal.error) { 70 | assert.throws(() => valueToSqlLiteral(caseVal.value)); 71 | } else { 72 | const output = valueToSqlLiteral(caseVal.value); 73 | assert.equal(output, caseVal.output); 74 | } 75 | }); 76 | } 77 | }); 78 | -------------------------------------------------------------------------------- /test/utils.ts: -------------------------------------------------------------------------------- 1 | import fs from "node:fs"; 2 | import path from "node:path"; 3 | import url from "node:url"; 4 | import pg from "pg"; 5 | import { CompileQueryArgs, compileQuery } from "../src/api.js"; 6 | import { CreateOsoArgs } from "../src/oso.js"; 7 | import { PostgresBackend } from "../src/pg-backend.js"; 8 | 9 | const TestDir = url.fileURLToPath(new URL(".", import.meta.url)); 10 | 11 | const EnvsDir = path.join(TestDir, "envs"); 12 | 13 | const RulesDir = path.join(TestDir, "rules"); 14 | 15 | export async function loadEnv( 16 | name: string, 17 | vars: Record, 18 | ): Promise<[string, string]> { 19 | const interpolate = (value: string) => { 20 | let currentValue = value; 21 | for (const [key, keyVal] of Object.entries(vars)) { 22 | currentValue = currentValue.replaceAll(`{{${key}}}`, keyVal); 23 | } 24 | return currentValue; 25 | }; 26 | 27 | const envDir = path.join(EnvsDir, name); 28 | const setupScript = path.join(envDir, "setup.sql"); 29 | const teardownScript = path.join(envDir, "teardown.sql"); 30 | const setup = await fs.promises.readFile(setupScript, { encoding: "utf8" }); 31 | const teardown = await fs.promises.readFile(teardownScript, { 32 | encoding: "utf8", 33 | }); 34 | return [interpolate(setup), interpolate(teardown)]; 35 | } 36 | 37 | export const rootDbUrl = 38 | process.env.TEST_DATABASE_URL ?? 39 | "postgresql://postgres:password@localhost:5432/db"; 40 | 41 | export function rulesFile(name: string): string { 42 | return path.join(RulesDir, `${name}.polar`); 43 | } 44 | 45 | export function dbUrl(username: string, password: string, db: string): string { 46 | const newUrl = new URL(rootDbUrl); 47 | newUrl.username = username; 48 | newUrl.password = password; 49 | newUrl.pathname = `/${db}`; 50 | return newUrl.href; 51 | } 52 | 53 | const rootDbUrlObj = new URL(rootDbUrl); 54 | 55 | export const rootUser = rootDbUrlObj.username; 56 | 57 | export const rootPassword = rootDbUrlObj.password; 58 | 59 | export const rootDb = rootDbUrlObj.pathname.slice(1); 60 | 61 | function nameGenerator(prefix: string): () => string { 62 | let i = 0; 63 | return () => { 64 | i++; 65 | return [prefix, process.pid, i].join("_"); 66 | }; 67 | } 68 | 69 | export const userNameGenerator = nameGenerator("user"); 70 | 71 | export const dbNameGenerator = nameGenerator("db"); 72 | 73 | export function dbClientGenerator(url: string) { 74 | return async (func: (client: pg.Client) => Promise): Promise => { 75 | const client = new pg.Client(url); 76 | await client.connect(); 77 | try { 78 | return await func(client); 79 | } finally { 80 | await client.end(); 81 | } 82 | }; 83 | } 84 | 85 | export async function setupEnv( 86 | env: string, 87 | rules: string, 88 | db: string, 89 | vars: Record, 90 | opts?: Omit, 91 | iterations?: number, 92 | ): Promise<() => Promise> { 93 | const numIterations = iterations ?? 1; 94 | 95 | const [setup, teardown] = await loadEnv(env, vars); 96 | 97 | const client = new pg.Client(rootDbUrl); 98 | const backendClient = new pg.Client(dbUrl(rootUser, rootPassword, db)); 99 | 100 | const teardowns: [string, () => Promise][] = []; 101 | const teardownFunc = async () => { 102 | let errors = 0; 103 | for (const [name, func] of teardowns.reverse()) { 104 | try { 105 | await func(); 106 | } catch (error) { 107 | errors++; 108 | console.error(`Error in teardown: ${name}`, error); 109 | } 110 | } 111 | if (errors > 0) { 112 | throw new Error("Teardowns failed"); 113 | } 114 | }; 115 | 116 | try { 117 | await client.connect(); 118 | teardowns.push(["Close root client", () => client.end()]); 119 | await client.query(`CREATE DATABASE ${db}`); 120 | 121 | await backendClient.connect(); 122 | await backendClient.query(setup); 123 | 124 | teardowns.push([ 125 | "Tear down test objects", 126 | async () => { 127 | await client.query(teardown); 128 | }, 129 | ]); 130 | teardowns.push([ 131 | "Drop test db", 132 | async () => { 133 | await client.query(`DROP DATABASE ${db}`); 134 | }, 135 | ]); 136 | teardowns.push(["Close backend client", () => backendClient.end()]); 137 | 138 | const backend = new PostgresBackend(backendClient); 139 | for (let i = 0; i < numIterations; i++) { 140 | const result = await compileQuery({ 141 | backend, 142 | paths: [rulesFile(rules)], 143 | vars, 144 | ...opts, 145 | }); 146 | 147 | if (result.type === "error") { 148 | throw new Error( 149 | `Parse error: ${JSON.stringify(result.errors, null, 2)}`, 150 | ); 151 | } 152 | 153 | await backendClient.query(result.query); 154 | } 155 | 156 | return teardownFunc; 157 | } catch (error) { 158 | await teardownFunc(); 159 | throw error; 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["src"], 4 | "compilerOptions": { 5 | "noEmit": false, 6 | "rootDir": "./src", 7 | "outDir": "./dist" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "ts-node": { 3 | "swc": true 4 | }, 5 | "compilerOptions": { 6 | "module": "NodeNext", 7 | "moduleResolution": "NodeNext", 8 | "resolveJsonModule": true, 9 | "declaration": true, 10 | "emitDecoratorMetadata": true, 11 | "experimentalDecorators": true, 12 | "allowSyntheticDefaultImports": true, 13 | "target": "ESNext", 14 | "sourceMap": true, 15 | "incremental": false, 16 | "skipLibCheck": false, 17 | "strict": true, 18 | "noImplicitAny": false, 19 | "forceConsistentCasingInFileNames": false, 20 | "noFallthroughCasesInSwitch": true, 21 | "esModuleInterop": true, 22 | "noUncheckedIndexedAccess": true, 23 | "noEmit": true 24 | } 25 | } 26 | --------------------------------------------------------------------------------