From 1c9b8d4cb90d5707f460e4a21c6874988572f0b0 Mon Sep 17 00:00:00 2001 From: Ramon Snir Date: Mon, 24 Oct 2022 22:01:37 +0000 Subject: [PATCH] schema builder --- examples/simpleMembershipWithAdmin.ts | 127 +++++++++----------------- examples/simpleOwnership.ts | 58 ++++-------- package-lock.json | 4 +- package.json | 2 +- src/schema.ts | 96 ++++++++++++++++++- src/solver.test.ts | 62 ++++++------- src/solver.ts | 4 +- 7 files changed, 193 insertions(+), 160 deletions(-) diff --git a/examples/simpleMembershipWithAdmin.ts b/examples/simpleMembershipWithAdmin.ts index aba4cbf..1decaf9 100644 --- a/examples/simpleMembershipWithAdmin.ts +++ b/examples/simpleMembershipWithAdmin.ts @@ -1,4 +1,4 @@ -import type { Schema } from '../src'; +import { Schema, SchemaBuilder } from '../src'; export class User { constructor(public id: string, public admin: boolean) {} @@ -29,91 +29,52 @@ const nodes = [ { node: Membership, tableName: 'memberships', primaryColumn: 'id' }, ] as const; +const edges = [ + { + source: Project, + sourceColumn: 'owner_id', + sourceRelation: 'owner', + target: User, + targetColumn: 'id', + }, + { + source: Membership, + sourceColumn: 'user_id', + sourcePreloadedProperty: 'user', + sourceRelation: 'user', + target: User, + targetColumn: 'id', + }, + { + source: Membership, + sourceColumn: 'project_id', + sourceRelation: 'project', + target: Project, + targetColumn: 'id', + targetPreloadedProperty: 'memberships', + targetRelation: 'memberships', + }, +] as const; + type Relation = 'owner' | 'user' | 'project' | 'memberships'; type Role = 'Stranger' | 'Member' | 'Owner' | 'Admin'; type Permission = 'view' | 'edit' | 'manage'; -export const simpleMembershipWithAdminSchema: Schema = { - nodes, - grants: new Map([ - [ - Project, - { - view: ['Stranger', 'Member', 'Owner'], - edit: ['Member', 'Owner'], - manage: ['Owner', 'Admin'], - }, - ], - ]), - edges: [ - { - kind: '1:n', - source: Project, - sourceColumn: 'owner_id', - sourceRelation: 'owner', - target: User, - targetColumn: 'id', - }, - { - kind: '1:n', - source: Membership, - sourceColumn: 'user_id', - sourcePreloadedProperty: 'user', - sourceRelation: 'user', - target: User, - targetColumn: 'id', - }, - { - kind: '1:n', - source: Membership, - sourceColumn: 'project_id', - sourceRelation: 'project', - target: Project, - targetColumn: 'id', - targetPreloadedProperty: 'memberships', - targetRelation: 'memberships', - }, - ], - rules: [ - { - kind: 'direct', - node: Project, - role: 'Stranger', - actorNode: User, - }, - { - kind: 'direct', - node: Project, - role: 'Owner', - actorNode: User, - throughRelation: 'owner', - }, - { - kind: 'direct', - node: Membership, - role: 'Member', - actorNode: User, - throughRelation: 'user', - }, - { - kind: 'extension', - node: Project, - role: 'Member', - actorNode: User, - extend: { - linkNode: Membership, - linkRole: 'Member', - throughRelation: 'memberships', - }, - }, - { - kind: 'direct', - node: Project, - role: 'Admin', - actorNode: User, - actorFilters: [{ column: 'admin', condition: { kind: '=', value: true } }], - }, - ], -}; +export const simpleMembershipWithAdminSchema: Schema = + new SchemaBuilder(nodes, edges) + .grant(Project, 'Owner', ['view', 'edit', 'manage']) + .grant(Project, 'Admin', 'manage') + .grant(Project, 'Member', ['view', 'edit']) + .grant(Project, 'Stranger', 'view') + .assignDirectly(Project, [], User, [], 'Stranger') + .assignDirectly(Project, [], User, [{ column: 'admin', condition: { kind: '=', value: true } }], 'Admin') + .assignThroughRelation(Project, [], 'owner', User, [], 'Owner') + .assignThroughRelation(Membership, [], 'user', User, [], 'Member') + .assignByExtension(Project, [], User, [], 'Member', { + linkNode: Membership, + linkRole: 'Member', + throughRelation: 'memberships', + }) + .finalize(); diff --git a/examples/simpleOwnership.ts b/examples/simpleOwnership.ts index 0c047ff..15db2c5 100644 --- a/examples/simpleOwnership.ts +++ b/examples/simpleOwnership.ts @@ -1,4 +1,4 @@ -import type { Schema } from '../src'; +import { Schema, SchemaBuilder } from '../src'; export class Person { constructor(public id: string) {} @@ -13,47 +13,27 @@ const nodes = [ { node: Car, tableName: 'cars', primaryColumn: 'id' }, ] as const; +const edges = [ + { + source: Car, + sourceColumn: 'owner_id', + sourcePreloadedProperty: 'owner', + sourceRelation: 'owner', + target: Person, + targetColumn: 'id', + }, +] as const; + type Relation = 'owner'; type Role = 'Stranger' | 'Owner'; type Permission = 'view' | 'drive'; -export const simpleOwnershipSchema: Schema = { - nodes, - grants: new Map([ - [ - Car, - { - view: ['Stranger', 'Owner'], - drive: ['Owner'], - }, - ], - ]), - edges: [ - { - kind: '1:n', - source: Car, - sourceColumn: 'owner_id', - sourcePreloadedProperty: 'owner', - sourceRelation: 'owner', - target: Person, - targetColumn: 'id', - }, - ], - rules: [ - { - kind: 'direct', - node: Car, - role: 'Stranger', - actorNode: Person, - }, - { - kind: 'direct', - node: Car, - role: 'Owner', - actorNode: Person, - throughRelation: 'owner', - }, - ], -}; +export const simpleOwnershipSchema: Schema = + new SchemaBuilder(nodes, edges) + .grant(Car, 'Owner', ['view', 'drive']) + .grant(Car, 'Stranger', 'view') + .assignDirectly(Car, [], Person, [], 'Stranger') + .assignThroughRelation(Car, [], 'owner', Person, [], 'Owner') + .finalize(); diff --git a/package-lock.json b/package-lock.json index ab3ca28..c18714c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sunbear", - "version": "0.0.8", + "version": "0.1.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "sunbear", - "version": "0.0.8", + "version": "0.1.0", "license": "MIT", "dependencies": { "@types/pg": "^8.6.5", diff --git a/package.json b/package.json index 9e61cd6..d9ebe92 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sunbear", - "version": "0.0.8", + "version": "0.1.0", "description": "Authorization library for SQL", "main": "dist/src/index.js", "types": "dist/src/index.d.ts", diff --git a/src/schema.ts b/src/schema.ts index becbff9..5450bea 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -1,5 +1,4 @@ export interface Edge { - kind: '1:1' | '1:n' | 'n:1' | 'n:n'; source: Node; sourceColumn: string; /** Set to #this for a self-referencing relation. */ @@ -120,3 +119,98 @@ export function getNodeDefinition { + grants = new Map>>(); + rules: Rule[] = []; + + constructor(public nodes: readonly NodeDefinition[], public edges: readonly Edge[]) {} + + finalize(): Schema { + return { + nodes: this.nodes, + grants: this.grants, + edges: this.edges, + rules: this.rules, + }; + } + + grant(node: Node, role: Role, permission: Permission): this; + grant(node: Node, role: Role, permissions: readonly Permission[]): this; + grant(node: Node, roles: readonly Role[], permission: Permission): this; + grant(node: Node, roles: Role | readonly Role[], permissions: Permission | readonly Permission[]): this { + let grant = this.grants.get(node); + if (!grant) { + grant = {}; + this.grants.set(node, grant); + } + for (const permission of [permissions as Permission[]].flat()) { + let grantees = grant[permission]; + if (!grantees) { + grantees = []; + grant[permission] = grantees; + } + for (const role of [roles as Role[]].flat()) { + if (!grantees.includes(role)) { + grantees.push(role); + } + } + } + return this; + } + + assignDirectly( + node: Node, + filters: readonly RuleFilter[], + actorNode: Node, + actorFilters: readonly RuleFilter[], + role: Role, + ): this { + this.rules.push({ kind: 'direct', node, filters, role, actorNode, actorFilters }); + return this; + } + + assignThroughRelation( + node: Node, + filters: readonly RuleFilter[], + throughRelation: Relation, + actorNode: Node, + actorFilters: readonly RuleFilter[], + role: Role, + ): this { + this.rules.push({ kind: 'direct', node, filters, role, actorNode, actorFilters, throughRelation }); + return this; + } + + assignByExtension( + node: Node, + filters: readonly RuleFilter[], + actorNode: Node, + actorFilters: readonly RuleFilter[], + role: Role, + extend: RuleExtension['extend'], + ): this { + this.rules.push({ + kind: 'extension', + node, + filters, + role, + actorNode, + actorFilters, + extend, + }); + return this; + } + + assignByOtherRole( + node: Node, + filters: readonly RuleFilter[], + otherRole: Role, + actorNode: Node, + actorFilters: readonly RuleFilter[], + role: Role, + ): this { + this.rules.push({ kind: 'superset', node, filters, role, actorNode, actorFilters, ofRole: otherRole }); + return this; + } +} diff --git a/src/solver.test.ts b/src/solver.test.ts index 6090653..5eb8e0e 100644 --- a/src/solver.test.ts +++ b/src/solver.test.ts @@ -14,8 +14,9 @@ describe('solver', () => { actorNode: Person, actorFilters: [], node: Car, + role: 'Owner', filters: [], - role: 'Stranger', + throughRelation: 'owner', }, extensions: [], }, @@ -27,9 +28,8 @@ describe('solver', () => { actorNode: Person, actorFilters: [], node: Car, - role: 'Owner', filters: [], - throughRelation: 'owner', + role: 'Stranger', }, extensions: [], }, @@ -56,6 +56,20 @@ describe('solver', () => { expect( solveByPermission(simpleMembershipWithAdminSchema, User, undefined, 'edit', Project, undefined), ).toStrictEqual([ + { + actorId: undefined, + goalIds: undefined, + direct: { + kind: 'direct', + node: Project, + filters: [], + role: 'Owner', + actorNode: User, + actorFilters: [], + throughRelation: 'owner', + }, + extensions: [], + }, { actorId: undefined, goalIds: undefined, @@ -84,20 +98,6 @@ describe('solver', () => { }, ], }, - { - actorId: undefined, - goalIds: undefined, - direct: { - kind: 'direct', - node: Project, - filters: [], - role: 'Owner', - actorNode: User, - actorFilters: [], - throughRelation: 'owner', - }, - extensions: [], - }, ]); }); @@ -117,6 +117,20 @@ describe('solver', () => { new Project('project1', 'otheruser2', undefined, undefined), ]), ).toStrictEqual([ + { + actorId: 'user15', + goalIds: ['project1'], + direct: { + kind: 'direct', + node: Project, + filters: [], + role: 'Owner', + actorNode: User, + actorFilters: [], + throughRelation: 'owner', + }, + extensions: [], + }, { actorId: 'user15', goalIds: ['project1'], @@ -145,20 +159,6 @@ describe('solver', () => { }, ], }, - { - actorId: 'user15', - goalIds: ['project1'], - direct: { - kind: 'direct', - node: Project, - filters: [], - role: 'Owner', - actorNode: User, - actorFilters: [], - throughRelation: 'owner', - }, - extensions: [], - }, ]); }); diff --git a/src/solver.ts b/src/solver.ts index 1e29b03..c2a839a 100644 --- a/src/solver.ts +++ b/src/solver.ts @@ -191,9 +191,7 @@ function evaluateSolution