diff --git a/packages/uma/config/policies/authorizers/default.json b/packages/uma/config/policies/authorizers/default.json index 67f2799..ccf0f13 100644 --- a/packages/uma/config/policies/authorizers/default.json +++ b/packages/uma/config/policies/authorizers/default.json @@ -50,12 +50,17 @@ } ], "fallback": { - "@id": "urn:uma:default:OdrlAuthorizer", - "@type": "OdrlAuthorizer", + "@id": "urn:uma:default:SimpleOdrlAuthorizer", + "@type": "SimpleOdrlAuthorizer", "policies": { "@id": "urn:uma:default:RulesStorage", "@type": "FileBackupUCRulesStorage", "filePath": { "@id": "urn:uma:variables:backupFilePath" } + }, + "authorizer": { + "@id": "urn:uma:default:OdrlAuthorizer", + "@type": "OdrlAuthorizer", + "policies": { "@id": "urn:uma:default:RulesStorage" } } }, "registrationStore": { "@id": "urn:uma:default:ResourceRegistrationStore" } diff --git a/packages/uma/src/index.ts b/packages/uma/src/index.ts index 597429e..0f29eb8 100644 --- a/packages/uma/src/index.ts +++ b/packages/uma/src/index.ts @@ -33,6 +33,7 @@ export * from './policies/authorizers/AllAuthorizer'; export * from './policies/authorizers/NamespacedAuthorizer'; export * from './policies/authorizers/NoneAuthorizer'; export * from './policies/authorizers/OdrlAuthorizer'; +export * from './policies/authorizers/SimpleOdrlAuthorizer'; export * from './policies/authorizers/WebIdAuthorizer'; // Contracts diff --git a/packages/uma/src/policies/authorizers/SimpleOdrlAuthorizer.ts b/packages/uma/src/policies/authorizers/SimpleOdrlAuthorizer.ts new file mode 100644 index 0000000..b8daf8c --- /dev/null +++ b/packages/uma/src/policies/authorizers/SimpleOdrlAuthorizer.ts @@ -0,0 +1,190 @@ +import { NamedNode } from '@rdfjs/types'; +import { getLoggerFor } from 'global-logger-factory'; +import { DataFactory as DF, Quad_Subject, Store } from 'n3'; +import { ODRL } from 'odrl-evaluator'; +import { CLIENTID, WEBID } from '../../credentials/Claims'; +import { ClaimSet } from '../../credentials/ClaimSet'; +import { UCRulesStorage } from '../../ucp/storage/UCRulesStorage'; +import { Permission } from '../../views/Permission'; +import { Authorizer } from './Authorizer'; + +const ANONYMOUS = DF.namedNode('urn:solidlab:uma:id:anonymous'); + +// TODO: Mostly copied from ODRL Authorizer. +// One change: append and write are mapped to modify. +// Should be handled by RS. +const scopeCssToOdrl: Map = new Map(); +scopeCssToOdrl.set('urn:example:css:modes:read','http://www.w3.org/ns/odrl/2/read'); +scopeCssToOdrl.set('urn:example:css:modes:append','http://www.w3.org/ns/odrl/2/modify'); +scopeCssToOdrl.set('urn:example:css:modes:create','http://www.w3.org/ns/odrl/2/create'); +scopeCssToOdrl.set('urn:example:css:modes:delete','http://www.w3.org/ns/odrl/2/delete'); +scopeCssToOdrl.set('urn:example:css:modes:write','http://www.w3.org/ns/odrl/2/modify'); + +const dateComparators: NodeJS.Dict<(a: Date, b: Date) => boolean> = { + [ODRL.lt]: (a: Date, b: Date) => a < b, + [ODRL.lteq]: (a: Date, b: Date) => a <= b, + [ODRL.eq]: (a: Date, b: Date) => a === b, + [ODRL.gt]: (a: Date, b: Date) => a > b, + [ODRL.gteq]: (a: Date, b: Date) => a >= b, +}; + +/** + * A simple authorizer that can handle basic ODRL policies with direct permissions and prohibitions, + * without any complex constraints or inheritance. + * If a request doesn't match any permission or prohibition + * in the policies it evaluates, it falls back to a provided authorizer. + */ +export class SimpleOdrlAuthorizer implements Authorizer { + protected readonly logger = getLoggerFor(this); + + public constructor( + protected readonly policies: UCRulesStorage, + protected readonly authorizer: Authorizer, + ) {} + + public async permissions(claims: ClaimSet, query?: Permission[]): Promise { + if (!query) { + return this.authorizer.permissions(claims, query); + } + + const store = await this.policies.getStore(); + + let permissions: Permission[] = []; + for (const { resource_id, resource_scopes } of query) { + for (const scope of resource_scopes) { + const result = this.getPermissions(store, claims, resource_id, scope); + if (!result) { + // Too difficult to handle internally so need to call complete authorizer + return this.authorizer.permissions(claims, query); + } + + permissions.push(...result); + } + } + return permissions; + } + + protected getPermissions(policies: Store, claims: ClaimSet, resource: string, scope: string): + Permission[] | undefined { + this.logger.info(`Evaluating Request ${scope}, ${resource} with claims ${JSON.stringify(claims)}`); + const targets = [ DF.namedNode(resource), ...policies.getObjects(resource, ODRL.terms.partOf, null)]; + let rules = targets.flatMap(target => policies.getSubjects(ODRL.terms.target, target, null)); + if (rules.length === 0) { + this.logger.warn('Rejecting request because no rules with a matching target or asset collection were found'); + return []; + } + + let revertScopeToCssMode = scope.startsWith('urn:example:css:modes:'); + const oldScope = scope; + if (revertScopeToCssMode) { + scope = scopeCssToOdrl.get(scope) ?? scope; + } + + // Note that this does not catch refined actions or superclasses of actions + rules = rules.filter(rule => policies.has(DF.quad(rule, ODRL.terms.action, DF.namedNode(scope)))); + if (rules.length === 0) { + this.logger.warn('Rejecting request because no rules with a matching action were found'); + return []; + } + + let user = claims[WEBID]; + let assignees: NamedNode[] = [ ANONYMOUS ]; + if (typeof user === 'string') { + const userNode = DF.namedNode(user); + assignees.push(userNode); + assignees.push(...(policies.getObjects(user, ODRL.terms.partOf, null) as NamedNode[])); + } + rules = rules.filter(rule => { + const ruleAssignees = policies.getObjects(rule, ODRL.terms.assignee, null); + if (ruleAssignees.length === 0) { + // Public access + return true; + } + return ruleAssignees.some(ruleAssignee => assignees.some(assignee => assignee.equals(ruleAssignee))); + }); + this.logger.warn('Rejecting request because no rules with a matching assignee or party collection were found'); + if (rules.length === 0) { + return []; + } + + // Check simple constraints + const validRules: Quad_Subject[] = []; + for (const rule of rules) { + const constraintResponse = this.validateConstraints(rule, policies, claims); + if (constraintResponse === true) { + validRules.push(rule); + } else if (constraintResponse === undefined) { + return; + } + } + if (validRules.length === 0) { + this.logger.warn('Rejecting request because no rules with fulfilled constraints were found'); + return []; + } + + const predicates = validRules.map(rule => policies.getPredicates(null, rule, null)); + for (const rulePredicates of predicates) { + if (rulePredicates.length === 0) { + return; + } + if (rulePredicates.some(predicate => predicate.equals(ODRL.terms.prohibition))) { + this.logger.warn('Rejecting request because only matching prohibitions were found'); + return []; + } + // This implies we have an unsupported type of rule + if (!rulePredicates.some(predicate => predicate.equals(ODRL.terms.permission))) { + return; + } + } + + return [{ + resource_id: resource, + resource_scopes: [ oldScope ], + }]; + } + + // TODO: 3 modes: valid, not valid, too complicated + /** + * Determines if all constraints for the given rule are valid. + * Returns true if all constraints are valid, false if any constraint is not valid, + * and undefined if any constraint is too complex to evaluate. + * Only supports purpose (for client ID) and dateTime constraints. + */ + protected validateConstraints(rule: Quad_Subject, policies: Store, claims: ClaimSet): boolean | undefined { + const constraints = policies.getObjects(rule, ODRL.terms.constraint, null).map(constraint => ({ + leftOperand: policies.getObjects(constraint, ODRL.terms.leftOperand, null)[0], + operator: policies.getObjects(constraint, ODRL.terms.operator, null)[0], + rightOperand: policies.getObjects(constraint, ODRL.terms.rightOperand, null)[0], + })); + // If any of these are undefined this is too complex to handle here + if (constraints.some(({ leftOperand, operator, rightOperand }) => !leftOperand || !operator || !rightOperand)) { + return; + } + for (const constraint of constraints) { + // Return undefined if any of these are too complex or unknown + // TODO: because of weird hack described in OdrlAuthorizer, needs to change to term that makes more sense + if (constraint.leftOperand.equals(ODRL.terms.purpose)) { + if (!constraint.operator.equals(ODRL.terms.eq)) { + return false; + } + const clientId = claims[CLIENTID]; + if (typeof clientId !== 'string' || constraint.rightOperand.value !== clientId) { + return false; + } + } else if (constraint.leftOperand.equals(ODRL.terms.dateTime)) { + const comparisonDate = new Date(constraint.rightOperand.value); + const comparator = dateComparators[constraint.operator.value]; + if (!comparator) { + return false; + } + if (!comparator(new Date(), comparisonDate)) { + return false; + } + } else { + // Unsupported constraint + return; + } + } + return true; + } +} diff --git a/packages/uma/test/unit/policies/authorizers/SimpleOdrlAuthorizer.test.ts b/packages/uma/test/unit/policies/authorizers/SimpleOdrlAuthorizer.test.ts new file mode 100644 index 0000000..1071952 --- /dev/null +++ b/packages/uma/test/unit/policies/authorizers/SimpleOdrlAuthorizer.test.ts @@ -0,0 +1,244 @@ +import type { NamedNode } from '@rdfjs/types'; +import { DataFactory as DF, Store } from 'n3'; +import { randomUUID } from 'node:crypto'; +import { ODRL } from 'odrl-evaluator'; +import { Mocked } from 'vitest'; +import { Authorizer } from '../../../../src/policies/authorizers/Authorizer'; +import { SimpleOdrlAuthorizer } from '../../../../src/policies/authorizers/SimpleOdrlAuthorizer'; +import { UCRulesStorage } from '../../../../src/ucp/storage/UCRulesStorage'; +import { Permission } from '../../../../src/views/Permission'; +import { WEBID, CLIENTID } from '../../../../src/credentials/Claims'; + +describe('SimpleOdrlAuthorizer', () => { + const resource = 'res'; + const scope = 'urn:example:css:modes:read'; + const odrlScope = 'http://www.w3.org/ns/odrl/2/read'; + const query: Permission[] = [{ resource_id: resource, resource_scopes: [scope] }]; + const fallbackPermissions: Permission[] = [{ resource_id: 'fallback', resource_scopes: ['scope'] }]; + + let policies: Mocked; + let fallback: Mocked; + let store: Store; + let authorizer: SimpleOdrlAuthorizer; + + const addRule = ({ + assignee, + linkPredicate = ODRL.terms.permission, + target = resource, + action = odrlScope, + }: { + assignee?: string; + linkPredicate?: NamedNode; + target?: string; + action?: string; + }): NamedNode => { + const rule = `rule-${randomUUID()}`; + const ruleNode = DF.namedNode(rule); + store.addQuad(ruleNode, ODRL.terms.target, DF.namedNode(target)); + store.addQuad(ruleNode, ODRL.terms.action, DF.namedNode(action)); + if (assignee) { + store.addQuad(ruleNode, ODRL.terms.assignee, DF.namedNode(assignee)); + } + store.addQuad(DF.namedNode(`${rule}:policy`), linkPredicate, ruleNode); + return ruleNode; + }; + + const addConstraint = ({ + rule, + leftOperand, + operator, + rightOperand, + }: { + rule: NamedNode; + leftOperand: NamedNode; + operator: NamedNode; + rightOperand: string; + }): void => { + const constraint = DF.namedNode(`constraint-${randomUUID()}`); + store.addQuad(rule, ODRL.terms.constraint, constraint); + store.addQuad(constraint, ODRL.terms.leftOperand, leftOperand); + store.addQuad(constraint, ODRL.terms.operator, operator); + store.addQuad(constraint, ODRL.terms.rightOperand, DF.literal(rightOperand)); + }; + + beforeEach(() => { + store = new Store(); + + fallback = { + permissions: vi.fn().mockResolvedValue(fallbackPermissions), + } satisfies Partial as unknown as Mocked; + + policies = { + getStore: vi.fn().mockResolvedValue(store), + } satisfies Partial as unknown as Mocked; + + authorizer = new SimpleOdrlAuthorizer(policies, fallback); + }); + + it('delegates to fallback if no query is provided', async () => { + const result = await authorizer.permissions({}); + expect(result).toEqual(fallbackPermissions); + expect(fallback.permissions).toHaveBeenCalledWith({}, undefined); + }); + + it('returns empty if no rules match the resource', async () => { + const result = await authorizer.permissions({}, query); + expect(result).toEqual([]); + expect(fallback.permissions).not.toHaveBeenCalled(); + }); + + it('returns permission if rule matches resource, action, and assignee', async () => { + addRule({ assignee: 'user' }); + const claims = { [WEBID]: 'user' }; + + const result = await authorizer.permissions(claims, query); + + expect(result).toEqual([{ resource_id: resource, resource_scopes: [scope] }]); + expect(fallback.permissions).not.toHaveBeenCalled(); + }); + + it('returns permission for public access (no assignee)', async () => { + addRule({}); + + const result = await authorizer.permissions({}, query); + + expect(result).toEqual([{ resource_id: resource, resource_scopes: [scope] }]); + expect(fallback.permissions).not.toHaveBeenCalled(); + }); + + it('returns empty if assignee does not match', async () => { + addRule({ assignee: 'other' }); + const claims = { [WEBID]: 'user' }; + + const result = await authorizer.permissions(claims, query); + + expect(result).toEqual([]); + expect(fallback.permissions).not.toHaveBeenCalled(); + }); + + it('returns empty if rule is a prohibition', async () => { + addRule({ linkPredicate: ODRL.terms.prohibition }); + + const result = await authorizer.permissions({}, query); + + expect(result).toEqual([]); + expect(fallback.permissions).not.toHaveBeenCalled(); + }); + + it('delegates to fallback if rule has unsupported type', async () => { + addRule({ linkPredicate: DF.namedNode('unsupported') }); + + const result = await authorizer.permissions({}, query); + + expect(result).toEqual(fallbackPermissions); + expect(fallback.permissions).toHaveBeenCalledWith({}, query); + }); + + it('returns empty if constraint is not satisfied (purpose)', async () => { + const rule = addRule({}); + addConstraint({ + rule, + leftOperand: ODRL.terms.purpose, + operator: ODRL.terms.eq, + rightOperand: 'clientA', + }); + const claims = { [CLIENTID]: 'clientB' }; + + const result = await authorizer.permissions(claims, query); + + expect(result).toEqual([]); + expect(fallback.permissions).not.toHaveBeenCalled(); + }); + + it('returns permission if constraint is satisfied (purpose)', async () => { + const rule = addRule({}); + addConstraint({ + rule, + leftOperand: ODRL.terms.purpose, + operator: ODRL.terms.eq, + rightOperand: 'clientA', + }); + const claims = { [CLIENTID]: 'clientA' }; + + const result = await authorizer.permissions(claims, query); + + expect(result).toEqual([{ resource_id: resource, resource_scopes: [scope] }]); + expect(fallback.permissions).not.toHaveBeenCalled(); + }); + + it('delegates to fallback if constraint is too complex', async () => { + const rule = addRule({}); + store.addQuad(rule, ODRL.terms.constraint, DF.namedNode('constraint3')); + + const result = await authorizer.permissions({}, query); + + expect(result).toEqual(fallbackPermissions); + expect(fallback.permissions).toHaveBeenCalledWith({}, query); + }); + + it('returns empty if dateTime constraint is not satisfied', async () => { + const rule = addRule({}); + addConstraint({ + rule, + leftOperand: ODRL.terms.dateTime, + operator: ODRL.terms.gt, + rightOperand: new Date(Date.now() + 1000000).toISOString(), + }); + + const result = await authorizer.permissions({}, query); + + expect(result).toEqual([]); + expect(fallback.permissions).not.toHaveBeenCalled(); + }); + + it('returns permission if dateTime constraint is satisfied', async () => { + const rule = addRule({}); + addConstraint({ + rule, + leftOperand: ODRL.terms.dateTime, + operator: ODRL.terms.lt, + rightOperand: new Date(Date.now() + 1000000).toISOString(), + }); + + const result = await authorizer.permissions({}, query); + + expect(result).toEqual([{ resource_id: resource, resource_scopes: [scope] }]); + expect(fallback.permissions).not.toHaveBeenCalled(); + }); + + it('returns all permissions when multiple query entries are all granted', async () => { + const resource2 = 'res2'; + const scope2 = 'urn:example:css:modes:write'; + const odrlScope2 = 'http://www.w3.org/ns/odrl/2/modify'; + const multiQuery: Permission[] = [ + { resource_id: resource, resource_scopes: [scope] }, + { resource_id: resource2, resource_scopes: [scope2] }, + ]; + addRule({}); + addRule({ target: resource2, action: odrlScope2 }); + + const result = await authorizer.permissions({}, multiQuery); + + expect(result).toEqual([ + { resource_id: resource, resource_scopes: [scope] }, + { resource_id: resource2, resource_scopes: [scope2] }, + ]); + expect(fallback.permissions).not.toHaveBeenCalled(); + }); + + it('delegates entire result to fallback if any query entry cannot be handled', async () => { + const resource2 = 'res2'; + const multiQuery: Permission[] = [ + { resource_id: resource, resource_scopes: [scope] }, + { resource_id: resource2, resource_scopes: [scope] }, + ]; + addRule({}); + // rule for resource2 has an unsupported link predicate, triggering fallback + addRule({ target: resource2, linkPredicate: DF.namedNode('unsupported') }); + + const result = await authorizer.permissions({}, multiQuery); + + expect(result).toEqual(fallbackPermissions); + expect(fallback.permissions).toHaveBeenCalledWith({}, multiQuery); + }); +}); diff --git a/test/integration/Demo.test.ts b/test/integration/Demo.test.ts index 26e8c59..6b88bf0 100644 --- a/test/integration/Demo.test.ts +++ b/test/integration/Demo.test.ts @@ -78,7 +78,7 @@ describe('A demo server setup', (): void => { ex:usagePolicy a odrl:Agreement ; odrl:permission ex:permission . ex:permission a odrl:Permission ; - odrl:action odrl:create, odrl:append ; + odrl:action odrl:create, odrl:modify ; odrl:target , , ,