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
143 changes: 94 additions & 49 deletions packages/uma/src/policies/authorizers/SimpleOdrlAuthorizer.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { NamedNode } from '@rdfjs/types';
import { NamedNode, Term } from '@rdfjs/types';
import { getLoggerFor } from 'global-logger-factory';
import jp from 'jsonpath';
import { DataFactory as DF, Quad_Subject } from 'n3';
import { DataFactory as DF, Quad_Object, Quad_Subject } from 'n3';
import { ODRL } from 'odrl-evaluator';
import { CLIENTID, VC, WEBID } from '../../credentials/Claims';
import { ClaimSet } from '../../credentials/ClaimSet';
Expand Down Expand Up @@ -33,6 +33,12 @@ const claimOperandMap: Record<string, string> = {
[ODRL.deliveryChannel]: CLIENTID,
} as const;

interface PolicyData {
store: ReadOnlyStore;
lists: Readonly<Record<string, Term[]>>;
claims: Readonly<ClaimSet>;
}

/**
* A simple authorizer that can handle basic ODRL policies with direct permissions and prohibitions,
* without any complex constraints or inheritance.
Expand All @@ -53,12 +59,15 @@ export class SimpleOdrlAuthorizer implements Authorizer {
}

const store = await this.policies.getStore();
const lists = store.extractLists();

const data: PolicyData = { store, lists, claims };

let permissions: Permission[] = [];
for (const { resource_id, resource_scopes } of query) {
const allowedScopes: string[] = [];
for (const scope of resource_scopes) {
const result = this.getPermissions(store, claims, resource_id, scope);
const result = this.getPermissions(data, resource_id, scope);
if (!result) {
// Too difficult to handle internally so need to call complete authorizer
return this.authorizer.permissions(claims, query);
Expand All @@ -76,11 +85,11 @@ export class SimpleOdrlAuthorizer implements Authorizer {
return permissions;
}

protected getPermissions(policies: ReadOnlyStore, claims: ClaimSet, resource: string, scope: string):
protected getPermissions(data: PolicyData, resource: string, scope: string):
string[] | 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));
this.logger.info(`Evaluating Request ${scope}, ${resource} with claims ${JSON.stringify(data.claims)}`);
const targets = [ DF.namedNode(resource), ...data.store.getObjects(resource, ODRL.terms.partOf, null)];
let rules = targets.flatMap(target => data.store.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 [];
Expand All @@ -95,24 +104,24 @@ export class SimpleOdrlAuthorizer implements Authorizer {
// Note that this only catches this specific super action
const superAction = scope === ODRL.append || scope === ODRL.write ? ODRL.terms.modify : undefined;
rules = rules.filter(rule =>
policies.has(DF.quad(rule, ODRL.terms.action, DF.namedNode(scope))) ||
(superAction !== undefined && policies.has(DF.quad(rule, ODRL.terms.action, superAction)))
data.store.has(DF.quad(rule, ODRL.terms.action, DF.namedNode(scope))) ||
(superAction !== undefined && data.store.has(DF.quad(rule, ODRL.terms.action, superAction)))
);
if (rules.length === 0) {
this.logger.warn('Rejecting request because no rules with a matching action were found');
return [];
}

let assignees: NamedNode[] = [ ANONYMOUS ];
for (const user of claims[WEBID] ?? []) {
for (const user of data.claims[WEBID] ?? []) {
if (typeof user === 'string') {
const userNode = DF.namedNode(user);
assignees.push(userNode);
assignees.push(...(policies.getObjects(user, ODRL.terms.partOf, null) as NamedNode[]));
assignees.push(...(data.store.getObjects(user, ODRL.terms.partOf, null) as NamedNode[]));
}
}
rules = rules.filter(rule => {
const ruleAssignees = policies.getObjects(rule, ODRL.terms.assignee, null);
const ruleAssignees = data.store.getObjects(rule, ODRL.terms.assignee, null);
if (ruleAssignees.length === 0) {
// Public access
return true;
Expand All @@ -127,8 +136,8 @@ export class SimpleOdrlAuthorizer implements Authorizer {
// Check simple constraints
const validRules: Quad_Subject[] = [];
for (const rule of rules) {
const constraintResponse = this.validateConstraints(rule, policies, claims);
const vcConstraintResponse = this.validateOvcConstraints(rule, policies, claims);
const constraintResponse = this.validateConstraints(rule, data);
const vcConstraintResponse = this.validateOvcConstraints(rule, data);
if (constraintResponse && vcConstraintResponse) {
validRules.push(rule);
} else if (constraintResponse === undefined || vcConstraintResponse === undefined) {
Expand All @@ -140,13 +149,13 @@ export class SimpleOdrlAuthorizer implements Authorizer {
return [];
}

const predicates = validRules.map(rule => policies.getPredicates(null, rule, null));
const predicates = validRules.map(rule => data.store.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');
this.logger.warn('Rejecting request because matching prohibitions were found');
return [];
}
// This implies we have an unsupported type of rule
Expand All @@ -165,11 +174,11 @@ export class SimpleOdrlAuthorizer implements Authorizer {
* and undefined if any constraint is too complex to evaluate.
* Only supports deliveryChannel (for client ID), purpose, and dateTime constraints.
*/
protected validateConstraints(rule: Quad_Subject, policies: ReadOnlyStore, 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],
protected validateConstraints(rule: Quad_Subject, data: PolicyData): boolean | undefined {
const constraints = data.store.getObjects(rule, ODRL.terms.constraint, null).map(constraint => ({
leftOperand: data.store.getObjects(constraint, ODRL.terms.leftOperand, null)[0],
operator: data.store.getObjects(constraint, ODRL.terms.operator, null)[0],
rightOperand: data.store.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)) {
Expand All @@ -178,7 +187,6 @@ export class SimpleOdrlAuthorizer implements Authorizer {
// TODO: would want middleware step where credentials and other stuff are already extracted into RDF values
// so both ODRL authorizers don't have to bother with this
for (const constraint of constraints) {
const claimValues = claims[constraint.leftOperand.value];
// Return undefined if any of these are too complex or unknown
if (constraint.leftOperand.equals(ODRL.terms.dateTime)) {
const comparisonDate = new Date(constraint.rightOperand.value);
Expand All @@ -189,33 +197,27 @@ export class SimpleOdrlAuthorizer implements Authorizer {
if (!comparator(new Date(), comparisonDate)) {
return false;
}
} else if (claimOperandMap[constraint.leftOperand.value]) {
const claimKey = claimOperandMap[constraint.leftOperand.value];
if (!constraint.operator.equals(ODRL.terms.eq)) {
return false;
}
if (!claims[claimKey]?.some(claim => claim === constraint.rightOperand.value)) {
return false;
}
} else if (claimValues?.every(claim => typeof claim === 'string') && constraint.operator.equals(ODRL.terms.eq)) {
if (!claimValues?.some(claim => claim === constraint.rightOperand.value)) {
return false;
}
} else {
// Unsupported constraint
return;
const claimKey = claimOperandMap[constraint.leftOperand.value] ?? constraint.leftOperand.value;
const claimValues = data.claims[claimKey];
const rightValues = data.lists[constraint.rightOperand.value] ?? [constraint.rightOperand];
const result = this.verifyConstraint(claimValues ?? [], constraint.operator, rightValues);
// Catches both false and undefined
if (!result) {
return result;
}
}
}
return true;
}

// https://gitlab.com/gaia-x/lab/policy-reasoning/odrl-vc-profile
protected validateOvcConstraints(rule: Quad_Subject, policies: ReadOnlyStore, claims: ClaimSet): boolean | undefined {
const constraints = policies.getObjects(rule, OVC.terms.constraint, null).map(constraint => ({
leftOperand: policies.getObjects(constraint, OVC.terms.leftOperand, null)[0],
operator: policies.getObjects(constraint, ODRL.terms.operator, null)[0],
rightOperand: policies.getObjects(constraint, ODRL.terms.rightOperand, null)[0],
credentialSubjectType: policies.getObjects(constraint, OVC.terms.credentialSubjectType, null)[0],
protected validateOvcConstraints(rule: Quad_Subject, data: PolicyData): boolean | undefined {
const constraints = data.store.getObjects(rule, OVC.terms.constraint, null).map(constraint => ({
leftOperand: data.store.getObjects(constraint, OVC.terms.leftOperand, null)[0],
operator: data.store.getObjects(constraint, ODRL.terms.operator, null)[0],
rightOperand: data.store.getObjects(constraint, ODRL.terms.rightOperand, null)[0],
credentialSubjectType: data.store.getObjects(constraint, OVC.terms.credentialSubjectType, null)[0],
}));
// If any of these are undefined this is too complex to handle here (credentialSubjectType can be undefined)
if (constraints.some(({ leftOperand, operator, rightOperand }) => !leftOperand || !operator || !rightOperand)) {
Expand All @@ -225,20 +227,19 @@ export class SimpleOdrlAuthorizer implements Authorizer {
return true;
}
// Can't match a VC constraint if there is no VC input
const vcs = claims[VC];
const vcs = data.claims[VC];
if (!vcs || vcs?.length === 0) {
return false;
}

for (const constraint of constraints) {
// Only support odrl:eq for now
if (!constraint.operator.equals(ODRL.terms.eq)) {
return;
}
const foundMatchedVc = vcs.some(vc => {
const results = jp.query(vc, constraint.leftOperand.value).flat();
if (!results.some(result => result === constraint.rightOperand.value)) {
return false;
const rightValues = data.lists[constraint.rightOperand.value] ?? [constraint.rightOperand];
const result = this.verifyConstraint(results, constraint.operator, rightValues);
// Catches both false and undefined
if (!result) {
return result;
}
if (constraint.credentialSubjectType) {
const types = jp.query(vc, '$.type').flat();
Expand All @@ -255,4 +256,48 @@ export class SimpleOdrlAuthorizer implements Authorizer {

return true;
}

protected verifyConstraint(left: unknown[], operator: Quad_Object, right: Term[]): boolean | undefined {
if (left.length === 0 || right.length === 0) {
return;
}

const leftStrings = left.map(val => {
if (typeof val === 'string') {
return val;
}
if (typeof (val as { value: unknown }).value === 'string') {
return (val as { value: string }).value;
}
return;
}).filter((val): val is string => val !== undefined);
const rightStrings = right.map(term => term.value);

// TODO: Not supporting more than 1 left value until we have decided on the semantics
if (leftStrings.length > 1) {
return;
}
const leftString = leftStrings[0];

if (operator) {
switch (operator.value) {
case ODRL.eq:
if (rightStrings.length > 1) {
return;
}
return leftString === rightStrings[0];
case ODRL.neq:
if (rightStrings.length > 1) {
return;
}
return leftString !== rightStrings[0];
case ODRL.isAnyOf:
return rightStrings.includes(leftString);
case ODRL.isNoneOf:
return !rightStrings.includes(leftString);
default:
return;
}
}
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { NamedNode } from '@rdfjs/types';
import type { NamedNode, Quad, Quad_Subject, Quad_Object, Quad_Predicate } from '@rdfjs/types';
import { DataFactory as DF, Store } from 'n3';
import { randomUUID } from 'node:crypto';
import { ODRL } from 'odrl-evaluator';
Expand All @@ -11,6 +11,29 @@ import { UCRulesStorage } from '../../../../src/ucp/storage/UCRulesStorage';
import { OVC } from '../../../../src/ucp/util/Vocabularies';
import { Permission } from '../../../../src/views/Permission';

function generateListTriples(subject: Quad_Subject, predicate: Quad_Predicate, objects: Quad_Object[]): Quad[] {
if (objects.length === 0) {
return [];
}
const triples: Quad[] = [];
let current = DF.blankNode();
triples.push(DF.quad(subject, predicate, current));
for (let i = 0; i < objects.length; i++) {
triples.push(DF.quad(current, DF.namedNode('http://www.w3.org/1999/02/22-rdf-syntax-ns#first'), objects[i]));
if (i === objects.length - 1) {
triples.push(DF.quad(
current,
DF.namedNode('http://www.w3.org/1999/02/22-rdf-syntax-ns#rest'),
DF.namedNode('http://www.w3.org/1999/02/22-rdf-syntax-ns#nil')));
} else {
const next = DF.blankNode();
triples.push(DF.quad(current, DF.namedNode('http://www.w3.org/1999/02/22-rdf-syntax-ns#rest'), next));
current = next;
}
}
return triples;
}

describe('SimpleOdrlAuthorizer', () => {
const resource = 'res';
const scope = 'urn:example:css:modes:read';
Expand Down Expand Up @@ -228,6 +251,84 @@ describe('SimpleOdrlAuthorizer', () => {
expect(fallback.permissions).not.toHaveBeenCalled();
});

it('supports neq constraints.', async(): Promise<void> => {
const rule = addRule({});
addConstraint({
rule,
leftOperand: ODRL.terms.purpose,
operator: ODRL.terms.neq,
rightOperand: 'http://example.com/purpose-a',
});
const claims = { [PURPOSE]: [ 'http://example.com/purpose-b' ] };

await expect(authorizer.permissions(claims, query))
.resolves.toEqual([{ resource_id: resource, resource_scopes: [scope] }]);
expect(fallback.permissions).not.toHaveBeenCalled();

claims[PURPOSE] = [ 'http://example.com/purpose-a' ];
await expect(authorizer.permissions(claims, query))
.resolves.toEqual([]);
expect(fallback.permissions).not.toHaveBeenCalled();
});

it('supports isAnyOf constraints.', async(): Promise<void> => {
const rule = addRule({});
const constraint = DF.namedNode(`constraint-${randomUUID()}`);
store.addQuad(rule, ODRL.terms.constraint, constraint);
store.addQuad(constraint, ODRL.terms.leftOperand, ODRL.terms.purpose);
store.addQuad(constraint, ODRL.terms.operator, ODRL.terms.isAnyOf);
store.addQuads(generateListTriples(constraint, ODRL.terms.rightOperand, [
DF.namedNode('http://example.com/purpose-a'),
DF.namedNode('http://example.com/purpose-b')
]));
const claims = { [PURPOSE]: [ 'http://example.com/purpose-b' ] };

await expect(authorizer.permissions(claims, query))
.resolves.toEqual([{ resource_id: resource, resource_scopes: [scope] }]);
expect(fallback.permissions).not.toHaveBeenCalled();

claims[PURPOSE] = [ 'http://example.com/purpose-c' ];
await expect(authorizer.permissions(claims, query))
.resolves.toEqual([]);
expect(fallback.permissions).not.toHaveBeenCalled();
});

it('supports isNoneOf constraints.', async(): Promise<void> => {
const rule = addRule({});
const constraint = DF.namedNode(`constraint-${randomUUID()}`);
store.addQuad(rule, ODRL.terms.constraint, constraint);
store.addQuad(constraint, ODRL.terms.leftOperand, ODRL.terms.purpose);
store.addQuad(constraint, ODRL.terms.operator, ODRL.terms.isNoneOf);
store.addQuads(generateListTriples(constraint, ODRL.terms.rightOperand, [
DF.namedNode('http://example.com/purpose-a'),
DF.namedNode('http://example.com/purpose-b')
]));
const claims = { [PURPOSE]: [ 'http://example.com/purpose-c' ] };

await expect(authorizer.permissions(claims, query))
.resolves.toEqual([{ resource_id: resource, resource_scopes: [scope] }]);
expect(fallback.permissions).not.toHaveBeenCalled();

claims[PURPOSE] = [ 'http://example.com/purpose-b' ];
await expect(authorizer.permissions(claims, query))
.resolves.toEqual([]);
expect(fallback.permissions).not.toHaveBeenCalled();
});

it('does not support multiple claims for the same left operand.', async(): Promise<void> => {
const rule = addRule({});
addConstraint({
rule,
leftOperand: ODRL.terms.purpose,
operator: ODRL.terms.eq,
rightOperand: 'http://example.com/purpose-a',
});
const claims = { [PURPOSE]: [ 'http://example.com/purpose-a', 'http://example.com/purpose-b' ] };

await expect(authorizer.permissions(claims, query)).resolves.toEqual(fallbackPermissions);
expect(fallback.permissions).toHaveBeenCalledWith(claims, query);
});

it('delegates to fallback if OVC constraint is too complex', async () => {
const rule = addRule({});
store.addQuad(rule, OVC.terms.constraint, DF.namedNode('constraint3'));
Expand Down
Loading