Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/run-tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,10 @@ jobs:
env: {}
- package: graphql/test
env: {}
- package: graphile/graphile-authz
env: {}
- package: graphile/postgraphile-plugin-pgvector
env: {}
# - package: graphql/playwright-test
# env: {}
# - package: jobs/knative-job-worker
Expand Down
40 changes: 40 additions & 0 deletions graphile/graphile-authz/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# graphile-authz

Authorization plugin for PostGraphile v5 that provides declarative, composable authorization rules.

## Features

- **Declarative Rules**: Define authorization rules using a simple, composable API
- **SQL Generation**: Automatically generates efficient SQL WHERE clauses for row-level security
- **Multiple Authz Nodes**: Support for various authorization patterns:
- `DirectOwner` - Check if the current user owns the resource
- `Membership` - Check membership in a group/organization
- `MembershipByField` - Check membership via a field reference
- `OrgHierarchy` - Check organization hierarchy access
- `Temporal` - Time-based access control
- `Publishable` - Check if content is published
- `ArrayContainsActor` - Check if user is in an array field
- `Composite` - Combine multiple rules with AND/OR logic

## Installation

```bash
pnpm add graphile-authz
```

## Usage

```typescript
import { createAuthzPreset, defineRules, CommonRules } from 'graphile-authz';

const rules = defineRules({
posts: CommonRules.ownDataOnly('author_id'),
comments: CommonRules.publishedOnly('is_published'),
});

const preset = createAuthzPreset({ rules });
```

## License

MIT
Original file line number Diff line number Diff line change
@@ -1,23 +1,13 @@
{
"name": "graphile-simple-inflector",
"version": "1.0.4",
"description": "Simple inflector plugin for Graphile/PostGraphile",
"name": "graphile-authz",
"version": "1.0.0",
"author": "Constructive <developers@constructive.io>",
"homepage": "https://github.com/constructive-io/constructive",
"license": "MIT",
"description": "Dynamic PostGraphile v5 authorization plugin based on Authz node types from constructive-db",
"main": "index.js",
"module": "esm/index.js",
"types": "index.d.ts",
"scripts": {
"clean": "makage clean",
"copy": "makage assets",
"prepack": "npm run build",
"build": "makage build",
"build:dev": "makage build --dev",
"lint": "eslint . --fix",
"test": "jest --passWithNoTests",
"test:watch": "jest --watch"
},
"homepage": "https://github.com/constructive-io/constructive",
"license": "MIT",
"publishConfig": {
"access": "public",
"directory": "dist"
Expand All @@ -26,29 +16,51 @@
"type": "git",
"url": "https://github.com/constructive-io/constructive"
},
"keywords": [
"graphile",
"postgraphile",
"postgres",
"graphql",
"inflection",
"plugin",
"constructive",
"pgpm"
],
"bugs": {
"url": "https://github.com/constructive-io/constructive/issues"
},
"exports": {
".": {
"import": "./esm/index.js",
"require": "./index.js",
"types": "./index.d.ts"
},
"./types": {
"import": "./esm/types/index.js",
"require": "./types/index.js",
"types": "./types/index.d.ts"
},
"./evaluators": {
"import": "./esm/evaluators/index.js",
"require": "./evaluators/index.js",
"types": "./evaluators/index.d.ts"
}
},
"scripts": {
"clean": "makage clean",
"prepack": "npm run build",
"build": "makage build",
"build:dev": "makage build --dev",
"lint": "eslint . --fix",
"test": "jest --passWithNoTests",
"test:watch": "jest --watch"
},
"devDependencies": {
"graphile-test": "workspace:^",
"graphql-tag": "2.12.6",
"makage": "^0.1.10",
"pgsql-test": "workspace:^"
"@types/node": "^22.19.1",
"makage": "^0.1.10"
},
"dependencies": {
"graphile-build": "^5.0.0-rc.3",
"graphile-build-pg": "^5.0.0-rc.3",
"graphile-config": "1.0.0-rc.3",
"inflekt": "^0.3.0"
}
"graphile-config": "1.0.0-rc.3"
},
"keywords": [
"postgraphile",
"graphql",
"postgresql",
"authorization",
"authz",
"security",
"constructive"
]
}
187 changes: 187 additions & 0 deletions graphile/graphile-authz/src/__tests__/plugin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@

import {
createAuthzPlugin,
createAuthzPreset,
defineRule,
defineRules,
CommonRules,
} from '../plugin';
import type { AuthzRule } from '../types/rules';

describe('Plugin', () => {
describe('createAuthzPlugin', () => {
it('creates a plugin with the correct name', () => {
const plugin = createAuthzPlugin({ rules: [] });
expect(plugin.name).toBe('AuthzPlugin');
expect(plugin.version).toBe('1.0.0');
});

it('includes schema hooks', () => {
const plugin = createAuthzPlugin({ rules: [] });
expect(plugin.schema).toBeDefined();
expect(plugin.schema?.hooks).toBeDefined();
});
});

describe('createAuthzPreset', () => {
it('creates a preset with the plugin', () => {
const preset = createAuthzPreset({ rules: [] });
expect(preset.plugins).toHaveLength(1);
expect(preset.plugins?.[0].name).toBe('AuthzPlugin');
});
});

describe('defineRule', () => {
it('returns the rule unchanged', () => {
const rule: AuthzRule = {
id: 'test',
name: 'Test Rule',
target: { schema: 'public', table: 'users' },
policy: { AuthzDirectOwner: { entity_field: 'owner_id' } },
};
expect(defineRule(rule)).toBe(rule);
});
});

describe('defineRules', () => {
it('returns the rules unchanged', () => {
const rules: AuthzRule[] = [
{
id: 'test1',
name: 'Test Rule 1',
target: { schema: 'public', table: 'users' },
policy: { AuthzDirectOwner: { entity_field: 'owner_id' } },
},
{
id: 'test2',
name: 'Test Rule 2',
target: { schema: 'public', table: 'posts' },
policy: { AuthzAllowAll: {} },
},
];
expect(defineRules(rules)).toBe(rules);
});
});

describe('CommonRules', () => {
describe('ownDataOnly', () => {
it('creates a direct owner rule with defaults', () => {
const rule = CommonRules.ownDataOnly();
expect(rule.id).toBe('own-data-only');
expect(rule.target.schema).toBe('*');
expect(rule.target.table).toBe('*');
expect(rule.policy).toEqual({
AuthzDirectOwner: { entity_field: 'owner_id' },
});
});

it('accepts custom options', () => {
const rule = CommonRules.ownDataOnly({
schema: 'public',
table: 'user_profiles',
ownerField: 'user_id',
operations: ['select', 'update'],
});
expect(rule.target.schema).toBe('public');
expect(rule.target.table).toBe('user_profiles');
expect(rule.target.operations).toEqual(['select', 'update']);
expect(rule.policy).toEqual({
AuthzDirectOwner: { entity_field: 'user_id' },
});
});
});

describe('orgMemberAccess', () => {
it('creates an org membership rule with defaults', () => {
const rule = CommonRules.orgMemberAccess();
expect(rule.id).toBe('org-member-access');
expect(rule.policy).toEqual({
AuthzMembershipByField: {
entity_field: 'org_id',
membership_type: 2,
permission: undefined,
},
});
});

it('accepts permission option', () => {
const rule = CommonRules.orgMemberAccess({ permission: 'read' });
expect(rule.policy).toEqual({
AuthzMembershipByField: {
entity_field: 'org_id',
membership_type: 2,
permission: 'read',
},
});
});
});

describe('publishedOnly', () => {
it('creates a publishable rule with defaults', () => {
const rule = CommonRules.publishedOnly();
expect(rule.id).toBe('published-only');
expect(rule.target.operations).toEqual(['select']);
expect(rule.policy).toEqual({
AuthzPublishable: {
is_published_field: undefined,
published_at_field: undefined,
},
});
});

it('accepts custom field names', () => {
const rule = CommonRules.publishedOnly({
isPublishedField: 'visible',
publishedAtField: 'made_public_at',
});
expect(rule.policy).toEqual({
AuthzPublishable: {
is_published_field: 'visible',
published_at_field: 'made_public_at',
},
});
});
});

describe('adminFullAccess', () => {
it('creates an admin rule with high priority', () => {
const rule = CommonRules.adminFullAccess();
expect(rule.id).toBe('admin-full-access');
expect(rule.priority).toBe(100);
expect(rule.policy).toEqual({
AuthzMembership: {
membership_type: 1,
is_admin: true,
},
});
});

it('accepts custom membership type', () => {
const rule = CommonRules.adminFullAccess({ membershipType: 'org' });
expect(rule.policy).toEqual({
AuthzMembership: {
membership_type: 'org',
is_admin: true,
},
});
});
});

describe('denyAll', () => {
it('creates a deny rule with low priority', () => {
const rule = CommonRules.denyAll();
expect(rule.id).toBe('deny-all');
expect(rule.priority).toBe(-100);
expect(rule.policy).toEqual({ AuthzDenyAll: {} });
});
});

describe('allowAll', () => {
it('creates an allow rule', () => {
const rule = CommonRules.allowAll();
expect(rule.id).toBe('allow-all');
expect(rule.policy).toEqual({ AuthzAllowAll: {} });
});
});
});
});
Loading