diff --git a/CHANGELOG.md b/CHANGELOG.md index a53aa5d..ae2d30c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # ODRL Evaluator release notes +## [0.5.1](https://github.com/SolidLabResearch/ODRL-Evaluator/compare/v0.5.0...v0.5.2) (2025-12-02) + + +### Features + +* introduce generic request constraints + add utilities for generating policies and requests + add utility for parsing compliance reports ([7a4878f](https://github.com/SolidLabResearch/ODRL-Evaluator/commit/7a4878f3aee50f2d2ad1caf257273c515b05790a)) + + +### Fixes + +* add missing utility function for the test suite ([2f8576e](https://github.com/SolidLabResearch/ODRL-Evaluator/commit/2f8576ee9ed72512467f6adba41407ae1457d2f3)) +* update js-yaml in package-lock for vulnerability reasons ([9d71463](https://github.com/SolidLabResearch/ODRL-Evaluator/commit/9d71463142b295cc0df412b1ef7d3fce83f107dd)) + ## [0.5.0](https://github.com/SolidLabResearch/ODRL-Evaluator/compare/v0.4.0...v0.5.0) (2025-10-15) ### Features diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index ec0c602..b039f1f 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,5 +1,16 @@ # ODRL Evaluator +## 0.5.1 + +### New Features + +* Support for all constraints (so for all Left Operands) following the approach of `sotw:Context` +* Addition of utilities for generating simple ODRL Policies and the ODRL Request as Quad[] + * `src/util/policy/PolicyUtil.ts` + * `src/util/request/RequestUtil.ts` +* Addition of a parser for the Compliance Report Util + * `src/util/report/ComplianceReportUtil.ts` + ## v0.5.0 ### New features diff --git a/package-lock.json b/package-lock.json index 73ebfad..24d364d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "odrl-evaluator", - "version": "0.4.0", + "version": "0.5.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "odrl-evaluator", - "version": "0.4.0", + "version": "0.5.1", "license": "MIT", "dependencies": { "@jeswr/pretty-turtle": "^1.8.2", @@ -4842,9 +4842,9 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" }, "node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", "dev": true, "dependencies": { "argparse": "^1.0.7", diff --git a/package.json b/package.json index d2b5a61..c33a3f5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "odrl-evaluator", - "version": "0.5.0", + "version": "0.5.1", "description": "An open implementation of an ODRL Evaluator that evaluates ODRL policies by generating Compliance Reports", "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/src/evaluator/Atomizer.ts b/src/evaluator/Atomizer.ts index 7e3eece..2b236c4 100644 --- a/src/evaluator/Atomizer.ts +++ b/src/evaluator/Atomizer.ts @@ -14,7 +14,7 @@ import { replaceSubject } from "../util/RDFUtil"; /** * Represents an ODRL policy containing its identifier and categorized rules. */ -export type Policy = { +type Policy = { /** Unique identifier for the policy */ identifier: Term, /** List of permission rules associated with the policy */ @@ -28,7 +28,7 @@ export type Policy = { /** * Represents a single ODRL rule. */ -export type Rule = { +type Rule = { /** Identifier of the policy this rule belongs to */ policyID: Term, /** Unique identifier for the rule */ diff --git a/src/index.core.ts b/src/index.core.ts index 6735ca6..fc49335 100644 --- a/src/index.core.ts +++ b/src/index.core.ts @@ -16,4 +16,7 @@ export * from './util/Vocabularies' export * from './util/Prefixes' export * from './util/report/ComplianceReportTypes' +export * from './util/report/ComplianceReportUtil' +export * from './util/policy/PolicyUtil' +export * from './util/request/RequestUtil' export * from './util/report/ComplianceReportUtil' \ No newline at end of file diff --git a/src/rules/Rules.ts b/src/rules/Rules.ts index 0e732e9..94e0bb3 100644 --- a/src/rules/Rules.ts +++ b/src/rules/Rules.ts @@ -94,53 +94,59 @@ export const RULES: string[] = [`@prefix string: { - ?premiseReport report:constraintLeftOperand "" . #TODO: do we need a default purpose? + ?premiseReport report:constraintLeftOperand "" . #TODO: do we need a default null left operand value? }. -# Create empty premise report for purposes (one present in the evaluation request) +# Create empty premise report for leftOperands (one present in the evaluation request) { + # acceptable ODRL Left Operands + ?leftOperand list:in ( odrl:absolutePosition odrl:absoluteSize odrl:absoluteSpatialPosition odrl:absoluteTemporalPosition odrl:count odrl:delayPeriod odrl:deliveryChannel odrl:device odrl:elapsedTime odrl:event odrl:fileFormat odrl:industry odrl:language odrl:media odrl:meteredTime odrl:payAmount odrl:percentage odrl:product odrl:purpose odrl:recipient odrl:relativePosition odrl:relativeSize odrl:relativeSpatialPosition odrl:relativeTemporalPosition odrl:resolution odrl:spatial odrl:spatialCoordinates odrl:system odrl:systemDevice odrl:timeInterval odrl:unitOfCount odrl:version odrl:virtualLocation ) . + + ?requestPermission sotw:context ?requestContextConstraint . - ?requestContextConstraint odrl:leftOperand odrl:purpose . + ?requestContextConstraint odrl:leftOperand ?leftOperand . ?requestContextConstraint odrl:rightOperand ?requestedPurpose . - # check for number of purposes in evaluation request + # check for number of leftOperands in evaluation request ( ?template { ?requestPermission sotw:context ?requestContextConstraint . - ?requestContextConstraint odrl:leftOperand odrl:purpose . + ?requestContextConstraint odrl:leftOperand ?leftOperand . } ?L ) log:collectAllIn ?SCOPE . - # number of purposes in evaluation request is 1 + # number of leftOperands in evaluation request is 1 ?L list:length 1 . # a rule with a purpose constraint _:a odrl:constraint ?constraint . - ?constraint odrl:leftOperand odrl:purpose . + ?constraint odrl:leftOperand ?leftOperand . # created premiseReport ?premiseReport report:constraint ?constraint . diff --git a/src/rules/constraints.n3 b/src/rules/constraints.n3 index c1bdb7e..971fa46 100644 --- a/src/rules/constraints.n3 +++ b/src/rules/constraints.n3 @@ -57,53 +57,59 @@ ?premiseReport report:constraintLeftOperand ?dateTime . }. -# odrl:purpose -# https://www.w3.org/TR/odrl-vocab/#term-purpose -# Create empty premise report for purposes (no purpose present in the evaluation request) +# Other ODRL constraint Left operands +# Create empty premise report for leftOperands (no leftOperand present in the evaluation request) { - # check for number of purposes in evaluation request + # acceptable ODRL Left Operands + ?leftOperand list:in ( odrl:absolutePosition odrl:absoluteSize odrl:absoluteSpatialPosition odrl:absoluteTemporalPosition odrl:count odrl:delayPeriod odrl:deliveryChannel odrl:device odrl:elapsedTime odrl:event odrl:fileFormat odrl:industry odrl:language odrl:media odrl:meteredTime odrl:payAmount odrl:percentage odrl:product odrl:purpose odrl:recipient odrl:relativePosition odrl:relativeSize odrl:relativeSpatialPosition odrl:relativeTemporalPosition odrl:resolution odrl:spatial odrl:spatialCoordinates odrl:system odrl:systemDevice odrl:timeInterval odrl:unitOfCount odrl:version odrl:virtualLocation ) . + + # check for number of leftOperands in evaluation request ( ?template { ?requestPermission sotw:context ?requestContextConstraint . - ?requestContextConstraint odrl:leftOperand odrl:purpose . + ?requestContextConstraint odrl:leftOperand ?leftOperand . } ?L ) log:collectAllIn ?SCOPE . - # number of purposes in evaluation request is 0 + # number of leftOperands in evaluation request is 0 ?L list:length 0 . - # a rule with a purpose constraint + # a rule with a leftOperand constraint _:a odrl:constraint ?constraint . - ?constraint odrl:leftOperand odrl:purpose . + ?constraint odrl:leftOperand ?leftOperand . # created premiseReport ?premiseReport report:constraint ?constraint . } => { - ?premiseReport report:constraintLeftOperand "" . #TODO: do we need a default purpose? + ?premiseReport report:constraintLeftOperand "" . #TODO: do we need a default null left operand value? }. -# Create empty premise report for purposes (one present in the evaluation request) +# Create empty premise report for leftOperands (one present in the evaluation request) { + # acceptable ODRL Left Operands + ?leftOperand list:in ( odrl:absolutePosition odrl:absoluteSize odrl:absoluteSpatialPosition odrl:absoluteTemporalPosition odrl:count odrl:delayPeriod odrl:deliveryChannel odrl:device odrl:elapsedTime odrl:event odrl:fileFormat odrl:industry odrl:language odrl:media odrl:meteredTime odrl:payAmount odrl:percentage odrl:product odrl:purpose odrl:recipient odrl:relativePosition odrl:relativeSize odrl:relativeSpatialPosition odrl:relativeTemporalPosition odrl:resolution odrl:spatial odrl:spatialCoordinates odrl:system odrl:systemDevice odrl:timeInterval odrl:unitOfCount odrl:version odrl:virtualLocation ) . + + ?requestPermission sotw:context ?requestContextConstraint . - ?requestContextConstraint odrl:leftOperand odrl:purpose . + ?requestContextConstraint odrl:leftOperand ?leftOperand . ?requestContextConstraint odrl:rightOperand ?requestedPurpose . - # check for number of purposes in evaluation request + # check for number of leftOperands in evaluation request ( ?template { ?requestPermission sotw:context ?requestContextConstraint . - ?requestContextConstraint odrl:leftOperand odrl:purpose . + ?requestContextConstraint odrl:leftOperand ?leftOperand . } ?L ) log:collectAllIn ?SCOPE . - # number of purposes in evaluation request is 1 + # number of leftOperands in evaluation request is 1 ?L list:length 1 . # a rule with a purpose constraint _:a odrl:constraint ?constraint . - ?constraint odrl:leftOperand odrl:purpose . + ?constraint odrl:leftOperand ?leftOperand . # created premiseReport ?premiseReport report:constraint ?constraint . diff --git a/src/util/Prefixes.ts b/src/util/Prefixes.ts index 914bdc6..2514201 100644 --- a/src/util/Prefixes.ts +++ b/src/util/Prefixes.ts @@ -6,5 +6,6 @@ export const prefixes = { odrl: "http://www.w3.org/ns/odrl/2/", schema: "https://schema.org/", xsd: "http://www.w3.org/2001/XMLSchema#", - dpv: "https://w3id.org/dpv#" + dpv: "https://w3id.org/dpv#", + sotw: "https://w3id.org/force/sotw#" } \ No newline at end of file diff --git a/src/util/Util.ts b/src/util/Util.ts new file mode 100644 index 0000000..68f62f0 --- /dev/null +++ b/src/util/Util.ts @@ -0,0 +1,9 @@ +import { v4 as uuidv4 } from 'uuid'; + +/** + * Helper to generate URNs from UUIDs. + * Produces identifiers in the form: urn:uuid: + */ +export function createRandomUrn(): string { + return `urn:uuid:${uuidv4()}`; +} \ No newline at end of file diff --git a/src/util/Vocabularies.ts b/src/util/Vocabularies.ts index 7907189..93f0863 100644 --- a/src/util/Vocabularies.ts +++ b/src/util/Vocabularies.ts @@ -2,7 +2,9 @@ import { createVocabulary } from 'rdf-vocabulary'; export const DC = createVocabulary( 'http://purl.org/dc/terms/', - 'created' + 'created', + 'title', + 'description' ) export const RDF = createVocabulary( 'http://www.w3.org/1999/02/22-rdf-syntax-ns#', @@ -266,4 +268,28 @@ export const REPORT = createVocabulary('https://w3id.org/force/compliance-report 'deonticState', 'constraint', 'satisfactionState', -) \ No newline at end of file +) + +export const SOTW = createVocabulary('https://w3id.org/force/sotw#', + 'context', + 'SotW', + 'currentTime', + 'currentLocation', + 'assetCollection', + 'partyCollection', + 'existingReport', + 'count', + 'event', + 'accumulatedTime', + 'recipient', + 'paidAmount', +) + + +export const XSD = createVocabulary( + 'http://www.w3.org/2001/XMLSchema#', + 'dateTime', + 'duration', + 'integer', + 'string', +); \ No newline at end of file diff --git a/src/util/policy/PolicyUtil.ts b/src/util/policy/PolicyUtil.ts new file mode 100644 index 0000000..c579070 --- /dev/null +++ b/src/util/policy/PolicyUtil.ts @@ -0,0 +1,230 @@ +import { Quad, DataFactory, Writer, Store } from 'n3'; +import { RDF, ODRL, XSD, DC } from '../Vocabularies'; +import { createRandomUrn } from '../Util'; +const { namedNode, quad, literal } = DataFactory; +/** + * A generic ODRL Policy. + * + * A Policy groups one or more Rules (Permissions, Prohibitions, Duties). + */ +export interface Policy { + /** Globally unique identifier for the Policy. */ + identifier: string; + + /** The type of Policy (Policy, Offer, Agreement, Request). */ + type: string + + /** The set of rules contained in this Policy. */ + rules: Rule[]; + + /** A description of the request. */ + description?: string; +} + +/** + * A generic ODRL Rule. + * + * A Rule defines the assignee, target resource, action, and optional constraints. + * It can be specialized into Permission, Prohibition, or Duty. + */ +export interface Rule { + /** Globally unique identifier for the Rule. */ + identifier: string; + + /** The party to whom the Rule applies (ODRL Party). */ + assignee: string; + + /** The asset under policy control (ODRL Asset). */ + target: string; + + /** The action being permitted, prohibited, or obliged (ODRL Action). */ + action: string; + + /** The type of Rule (Permission, Prohibition, Duty). */ + type: string + + /** Optional constraints refining applicability of the Rule. Empty list means no constraints. */ + constraints: Constraint[]; +} + +/** + * An ODRL Constraint. + * + * Defines logical conditions that must hold for a Rule to apply. + */ +export interface Constraint { + /** Globally unique identifier for the Constraint. */ + identifier: string; + + /** The attribute being tested (ODRL leftOperand). */ + leftOperand: string; + + /** The comparison operator (ODRL Operator). */ + operator: string; + + /** The value against which the leftOperand is compared. */ + rightOperand: string; +} + +/** + * Constructs a new Policy object with unique identifiers. + * + * - The Policy itself is assigned a URN identifier via createRandomUrn(). + * - The Policy be default is an odrl:Agreement. + * - Each Rule is assigned a URN identifier via createRandomUrn(). + * - Each Constraint is assigned a URN identifier via createRandomUrn(). + * + * @param input - The policy definition including type and rules. + * @returns A Policy object. + */ +export function createPolicy(input: { + type?: string; + rules: { + assignee: string; + target: string; + action: string; + type: string + constraints?: { + leftOperand: string; + rightOperand: string; + operator: string; + }[]; + }[]; +}): Policy { + const normalizedRules: Rule[] = input.rules.map(r => { + const normalizedConstraints: Constraint[] = r.constraints?.map(c => ({ + identifier: createRandomUrn(), + leftOperand: c.leftOperand, + rightOperand: c.rightOperand, + operator: c.operator + })) ?? []; + + return { + identifier: createRandomUrn(), + assignee: r.assignee, + target: r.target, + action: r.action, + type: r.type, + constraints: normalizedConstraints + }; + }); + + return { + identifier: createRandomUrn(), + type: input.type ?? ODRL.Agreement, + rules: normalizedRules + }; +} + + +/** + * Serialize a Policy into RDF quads. + */ +export function makeRDFPolicy(policy: Policy): Quad[] { + const quads: Quad[] = []; + const policyNode = namedNode(policy.identifier); + + // Policy triples + quads.push(quad(policyNode, RDF.terms.type, namedNode(policy.type))); + quads.push(quad(policyNode, ODRL.terms.uid, policyNode)); + if (policy.description) { quads.push(quad(policyNode, DC.terms.description, literal(policy.description))); } + + // Rules + policy.rules.forEach(rule => { + const ruleNode = namedNode(rule.identifier); + + // Link policy to rule depending on type + let linkPredicate; + let ruleClass; + switch (rule.type) { + case ODRL.Permission: + linkPredicate = ODRL.terms.permission; + ruleClass = ODRL.terms.Permission; + break; + case ODRL.Prohibition: + linkPredicate = ODRL.terms.prohibition; + ruleClass = ODRL.terms.Prohibition; + break; + case ODRL.Duty: + linkPredicate = ODRL.terms.duty; + ruleClass = ODRL.terms.Duty; + break; + default: + throw new Error(`Unknown rule type: ${rule.type}`); + } + + quads.push(quad(policyNode, linkPredicate, ruleNode)); + quads.push(quad(ruleNode, RDF.terms.type, ruleClass)); + + // Rule properties + quads.push(quad(ruleNode, ODRL.terms.assignee, namedNode(rule.assignee))); + quads.push(quad(ruleNode, ODRL.terms.action, namedNode(rule.action))); + quads.push(quad(ruleNode, ODRL.terms.target, namedNode(rule.target))); + + // Constraints + rule.constraints?.forEach(c => { + const constraintNode = namedNode(c.identifier); + quads.push(quad(ruleNode, ODRL.terms.constraint, constraintNode)); + + quads.push(...makeRDFConstraint(c)) + }); + }); + + return quads; +} + +export function makeRDFConstraint(constraint: Constraint) { + const quads: Quad[] = [] + const constraintNode = namedNode(constraint.identifier); + quads.push(quad(constraintNode, RDF.terms.type, ODRL.terms.Constraint)); + quads.push(quad(constraintNode, ODRL.terms.leftOperand, namedNode(constraint.leftOperand))); + quads.push(quad(constraintNode, ODRL.terms.operator, namedNode(constraint.operator))); + switch (constraint.leftOperand) { + case ODRL.dateTime: + quads.push(quad(constraintNode, ODRL.terms.rightOperand, + literal(constraint.rightOperand, XSD.terms.dateTime))); + break; + case ODRL.purpose: + quads.push(quad(constraintNode, ODRL.terms.rightOperand, namedNode(constraint.rightOperand))); + break; + case ODRL.deliveryChannel: + quads.push(quad(constraintNode, ODRL.terms.rightOperand, namedNode(constraint.rightOperand))); + break; + default: + throw Error("This kind of ODRL constraint has not been thought about properly, feel free to add. We accept Pull Requests."); + } + + return quads +} + +/** + * Get the identifier of an ODRL policy. + * The expectation is that there is only one present. + * + * Classes of policies that are taken into account: Policy, Offer, Set and Agreement. + * As disccused in issue 5 (https://github.com/woutslabbinck/UCR-test-suite/issues/5) + * Context: + * The policy must either be a Set or an Agreement (see Formal Semantics §2). + * But, it should still be possible to evaluate an Offer. + * Note: if you use policy, you use the default which is Set (ODRL Information model §2.1) + * + * @param quads + * @returns + */ +export function getPolicyIdentifier(quads: Quad[]): string { + const store = new Store(quads); + const policyNodes: Quad[] = []; + policyNodes.push( + ...store.getQuads(null, RDF.terms.type, ODRL.terms.Set, null), + ...store.getQuads(null, RDF.terms.type, ODRL.terms.Agreement, null), + ...store.getQuads(null, RDF.terms.type, ODRL.terms.Policy, null), + ...store.getQuads(null, RDF.terms.type, ODRL.terms.Offer, null) + ); + const policyIdentifiers: Set = new Set(policyNodes.map(quad => quad.subject.id)); + + if (policyIdentifiers.size !== 1) { + throw Error(`Expected one ODRL policy. Found ${policyIdentifiers.size}`); + } + + return Array.from(policyIdentifiers)[0]; +} \ No newline at end of file diff --git a/src/util/report/ComplianceReportUtil.ts b/src/util/report/ComplianceReportUtil.ts index ed07554..96c2e4c 100644 --- a/src/util/report/ComplianceReportUtil.ts +++ b/src/util/report/ComplianceReportUtil.ts @@ -1,8 +1,31 @@ import { Literal, NamedNode, Quad_Subject, Store } from 'n3'; import { DC, RDF, REPORT } from '../Vocabularies'; import { ActivationState, PolicyReport, PremiseReport, PremiseReportType, RuleReport, RuleReportType, SatisfactionState } from './ComplianceReportTypes'; +import { Quad } from '@rdfjs/types'; +/** + * Parses a single compliance report from a given set of RDF quads. + * Ensures that exactly one PolicyReport exists in the provided quads. + * + * @param quads - The RDF quads representing the compliance report data. + * @returns The parsed PolicyReport object. + * @throws Error if there is not exactly one PolicyReport in the quads. + */ +export function parseSingleComplianceReport(quads: Quad[]): PolicyReport { + const store = new Store(quads); + const identifiers = store.getQuads(null, RDF.type, REPORT.PolicyReport, null) + if (identifiers.length !== 1) { throw Error("Expected only one Compliance Report") } + return parseComplianceReport(identifiers[0].subject, store); +} +/** + * Parses a compliance report identified by a subject node from the RDF store. + * + * @param identifier - The RDF subject identifying the PolicyReport. + * @param store - The RDF store containing the quads to parse. + * @returns The parsed PolicyReport object. + * @throws Error if no PolicyReport exists for the given identifier. + */ export function parseComplianceReport(identifier: Quad_Subject, store: Store): PolicyReport { const exists = store.getQuads(identifier, RDF.type, REPORT.PolicyReport, null).length === 1; if (!exists) { throw Error(`No Policy Report found with: ${identifier}.`); } @@ -35,12 +58,27 @@ export function parseRuleReport(identifier: Quad_Subject, store: Store): RuleRep } /** -* Parses Premise Reports, including premises of a Premise Report itself. -* Note that if for some reason there are circular premise reports, this will result into an infinite loop -* @param identifier -* @param store -*/ -export function parsePremiseReport(identifier: Quad_Subject, store: Store): PremiseReport { + * Parses Premise Reports, including nested premises of a Premise Report itself. + * Detects and prevents infinite recursion caused by circular premise references. + * + * @param identifier - The RDF subject identifying the PremiseReport. + * @param store - The RDF store containing the quads to parse. + * @param visited - A set of identifiers already visited in the current recursion chain. + * Used internally to detect cycles. Defaults to an empty set. + * @returns The parsed PremiseReport object. + * @throws Error if a circular premise report is detected. + */ +export function parsePremiseReport( + identifier: Quad_Subject, + store: Store, + visited: Set = new Set() +): PremiseReport { + const idStr = (identifier as NamedNode).value; + if (visited.has(idStr)) { + throw new Error(`Circular premise report detected at ${idStr}`); + } + visited.add(idStr); + const nestedPremises = store.getObjects(identifier, REPORT.PremiseReport, null) as NamedNode[]; return { id: identifier as NamedNode, diff --git a/src/util/request/RequestUtil.ts b/src/util/request/RequestUtil.ts new file mode 100644 index 0000000..d239f16 --- /dev/null +++ b/src/util/request/RequestUtil.ts @@ -0,0 +1,117 @@ +import { DataFactory, Quad, Store } from "n3"; +import { createRandomUrn } from "../Util"; +import { DC, ODRL, RDF, SOTW } from "../Vocabularies"; +import { Constraint, makeRDFConstraint } from "../policy/PolicyUtil"; +const { namedNode, quad, literal } = DataFactory; +/** + * Represents an ODRL-Evaluator-style Request. + * + * A Request models an assignment of an Action on a Resource to an Assignee, + * together with contextual Constraints. + */ +export interface Request { + /** The party to whom the policy applies (ODRL Party). */ + assignee: string; + + /** The asset being acted upon (ODRL Asset). */ + resource: string; + + /** The operation permitted, prohibited, or obliged (ODRL Action). */ + action: string; + + /** Globally unique URN identifier for this Request. */ + identifier: string; + + /** List of constraints (ODRL Constraint) defining the context. */ + context: Constraint[]; + + /** A description of the request. */ + description?: string; +} + +/** + * Constructs a new Request object with normalized constraints and unique identifiers. + * + * - Each Constraint is assigned a URN identifier via createRandomUrn(). + * - The operator defaults to odrl:eq if not provided. + * - The Request itself is assigned a URN identifier. + * + * @param input - The request definition including assignee, resource, action, and constraints. + * @returns A fully normalized Request object ready for ODRL-compliant evaluation. + */ +export function createRequest(input: { + assignee: string; + resource: string; + action: string; + context: { + leftOperand: string; + rightOperand: string; + operator?: string; // optional, defaults to ODRL.eq + }[]; +}): Request { + const normalizedContext: Constraint[] = input.context.map(c => ({ + identifier: createRandomUrn(), + leftOperand: c.leftOperand, + rightOperand: c.rightOperand, + operator: c.operator ?? ODRL.eq + })); + + return { + assignee: input.assignee, + resource: input.resource, + action: input.action, + identifier: createRandomUrn(), + context: normalizedContext + }; +} + +/** + * + * @param request + * @param permissionID TODO: must be removed for the future version of the evaluation request + * @returns + */ +export function makeRDFRequest(request: Request, permissionID?: string): Quad[] { + const quads: Quad[] = []; + const requestNode = namedNode(request.identifier); + + // Create a Permission node + const permissionId = permissionID ?? createRandomUrn(); + const permissionNode = namedNode(permissionId); + + // Request triples + quads.push(quad(requestNode, RDF.terms.type, ODRL.terms.Request)); + quads.push(quad(requestNode, ODRL.terms.uid, requestNode)); + quads.push(quad(requestNode, ODRL.terms.permission, permissionNode)); + if (request.description) {quads.push(quad(requestNode, DC.terms.description, literal(request.description)));} + + // Permission triples + quads.push(quad(permissionNode, RDF.terms.type, ODRL.terms.Permission)); + quads.push(quad(permissionNode, ODRL.terms.assignee, namedNode(request.assignee))); + quads.push(quad(permissionNode, ODRL.terms.action, namedNode(request.action))); // NOTE: no check performed whether it is an actual ODRL Action + quads.push(quad(permissionNode, ODRL.terms.target, namedNode(request.resource))); + + request.context.forEach(c => { + const constraintNode = namedNode(c.identifier); + quads.push(quad(permissionNode, SOTW.terms.context, constraintNode)); + + quads.push(...makeRDFConstraint(c)) + }); + return quads +} + +/** + * Get the identifier of an ODRL request. + * The expectation is that there is only one present. + * @param quads + * @returns + */ +export function getRequestIdentifier(quads: Quad[]): string { + const store = new Store(quads); + const requestNodes = store.getQuads(null, RDF.terms.type, ODRL.terms.Request, null); + + if (requestNodes.length !== 1) { + throw Error(`Expected one ODRL request identifier. Found ${requestNodes.length}`); + } + return requestNodes[0].subject.id; +} \ No newline at end of file diff --git a/test/integration/ODRL-newTestCases.test.ts b/test/integration/ODRL-newTestCases.test.ts index 9968407..29c9b22 100644 --- a/test/integration/ODRL-newTestCases.test.ts +++ b/test/integration/ODRL-newTestCases.test.ts @@ -1,6 +1,15 @@ import "jest-rdf"; import { Parser, Quad } from "n3"; -import { blanknodeify, ODRLEngineMultipleSteps, ODRLEvaluator } from "../../src"; +import { ODRLEvaluator } from "../../src/evaluator/Evaluate"; +import { ODRLEngineMultipleSteps } from "../../src/evaluator/Engine"; +import { ODRL, REPORT } from "../../src/util/Vocabularies" +import { blanknodeify } from "../../src/util/RDFUtil" +import { createPolicy, makeRDFPolicy, Policy } from "../../src/util/policy/PolicyUtil"; +import { makeRDFRequest, Request } from "../../src/util/request/RequestUtil"; +import { createRandomUrn } from "../../src/util/Util"; +import { write } from "@jeswr/pretty-turtle/dist"; +import { ActivationState, parseComplianceReport, parseSingleComplianceReport, prefixes } from "../../src"; +import { countSatisfiedPremises } from "../util/ReportTest"; // request 1 const request1 = ` @@ -648,10 +657,164 @@ ex:purpose a dpv:AccountManagement . const report = await odrlEvaluator.evaluate( odrlPolicyQuads, odrlRequestQuads, - stateOfTheWorldQuads); + stateOfTheWorldQuads); expect(blanknodeify(report as any as Quad[])).toBeRdfIsomorphic(blanknodeify(expectedReportQuads)) }) }) + describe('handling requests with extra context.', () => { + const requestID = "urn:uuid:ce9fc20e-7c79-474e-8afe-7605accccee8"; + const requestPermissionID = "urn:uuid:ce9fc20" // TODO: remove when iterated upon compliance report + + const assignee = "http://example.org/alice"; + const resource = "http://example.org/x"; + const action = ODRL.read; + + const policyID = "urn:uuid:5ee1a7dd-ccba-49a4-92e8-6cc375509efd"; + const policyType = ODRL.Agreement; + const permissionID = "urn:uuid:e51a43e4-616f-4f32-906b-2359955228e5"; + const ruleType = ODRL.Permission + + const constraintID = "urn:uuid:963698fe-3b44-4b88-8527-501b6c5765a6"; + let policy: Policy; + let request: Request; + + const sotwQuads = parser.parse(sotwTemporal); + + beforeEach(() => { + policy = { + identifier: policyID, + type: policyType, + rules: [{ + assignee: assignee, + target: resource, + action: action, + type: ruleType, + identifier: permissionID, + constraints: [] + }] + } + request = { + assignee: assignee, + resource: resource, + action: action, + identifier: requestID, + context: [] + } + }) + + it('Happy flow evaluation of an ODRL policy with a deliveryChannel constraint.', async () => { + const leftOperand = ODRL.deliveryChannel; + const operator = ODRL.eq; + const rightOperand = "https://podpro.dev/id"; + policy.rules[0].constraints.push({ + leftOperand: leftOperand, + rightOperand: rightOperand, + operator: operator, + identifier: constraintID + }) + const policyQuads = makeRDFPolicy(policy) + + request.context.push({ + leftOperand: leftOperand, + rightOperand: rightOperand, + operator: operator, + identifier: createRandomUrn() + }) + const requestQuads = makeRDFRequest(request, requestPermissionID) + + const report = await odrlEvaluator.evaluate(policyQuads, requestQuads, sotwQuads); + const policyReport = parseSingleComplianceReport(report); + expect(policyReport.ruleReport.length).toBe(1) + expect(policyReport.ruleReport[0].activationState).toBe(REPORT.Active) + expect(policyReport.ruleReport[0].premiseReport.length).toBe(4); + expect(countSatisfiedPremises(policyReport.ruleReport[0].premiseReport)).toBe(4); + }); + + it('Bad evaluation of an ODRL policy with a deliveryChannel constraint.', async () => { + const leftOperand = ODRL.deliveryChannel; + const operator = ODRL.eq; + const rightOperand = "https://podpro.dev/id"; + policy.rules[0].constraints.push({ + leftOperand: leftOperand, + rightOperand: rightOperand, + operator: operator, + identifier: constraintID + }) + const policyQuads = makeRDFPolicy(policy) + + request.context.push({ + leftOperand: leftOperand, + rightOperand: "https://someOtherClient.id", + operator: operator, + identifier: createRandomUrn() + }) + const requestQuads = makeRDFRequest(request, requestPermissionID) + + const report = await odrlEvaluator.evaluate(policyQuads, requestQuads, sotwQuads); + const policyReport = parseSingleComplianceReport(report); + expect(policyReport.ruleReport.length).toBe(1) + expect(policyReport.ruleReport[0].activationState).toBe(REPORT.Inactive) + expect(policyReport.ruleReport[0].premiseReport.length).toBe(4); + expect(countSatisfiedPremises(policyReport.ruleReport[0].premiseReport)).toBe(3); + }); + + it('Happy flow evaluation of an ODRL policy with a purpose constraint.', async () => { + const leftOperand = ODRL.purpose; + const operator = ODRL.eq; + const rightOperand = "https://w3id.org/dpv#AccountManagement"; + + policy.rules[0].constraints.push({ + leftOperand: leftOperand, + rightOperand: rightOperand, + operator: operator, + identifier: constraintID + }) + const policyQuads = makeRDFPolicy(policy) + + request.context.push({ + leftOperand: leftOperand, + rightOperand: rightOperand, + operator: operator, + identifier: createRandomUrn() + }) + const requestQuads = makeRDFRequest(request, requestPermissionID) + + const report = await odrlEvaluator.evaluate(policyQuads, requestQuads, sotwQuads); + const policyReport = parseSingleComplianceReport(report); + expect(policyReport.ruleReport.length).toBe(1) + expect(policyReport.ruleReport[0].activationState).toBe(REPORT.Active) + expect(policyReport.ruleReport[0].premiseReport.length).toBe(4); + expect(countSatisfiedPremises(policyReport.ruleReport[0].premiseReport)).toBe(4); + }) + it('Bad evaluation of an ODRL policy with a purpose constraint.', async () => { + const leftOperand = ODRL.purpose; + const operator = ODRL.eq; + const rightOperand = "https://w3id.org/dpv#AccountManagement"; + + policy.rules[0].constraints.push({ + leftOperand: leftOperand, + rightOperand: rightOperand, + operator: operator, + identifier: constraintID + }) + const policyQuads = makeRDFPolicy(policy) + + request.context.push({ + leftOperand: ODRL.deliveryChannel, + rightOperand: rightOperand, + operator: operator, + identifier: createRandomUrn() + }) + const requestQuads = makeRDFRequest(request, requestPermissionID) + + const report = await odrlEvaluator.evaluate(policyQuads, requestQuads, sotwQuads); + const policyReport = parseSingleComplianceReport(report); + expect(policyReport.ruleReport.length).toBe(1) + expect(policyReport.ruleReport[0].activationState).toBe(REPORT.Inactive) + expect(policyReport.ruleReport[0].premiseReport.length).toBe(4); + expect(countSatisfiedPremises(policyReport.ruleReport[0].premiseReport)).toBe(3); + }) + }) }); \ No newline at end of file diff --git a/test/unit/evaluator/Atomizer.test.ts b/test/unit/evaluator/Atomizer.test.ts index 05bd6b6..7dbc4a7 100644 --- a/test/unit/evaluator/Atomizer.test.ts +++ b/test/unit/evaluator/Atomizer.test.ts @@ -5,6 +5,7 @@ import { ODRLEngineMultipleSteps } from "../../../src/evaluator/Engine"; import { ODRLEvaluator } from "../../../src/evaluator/Evaluate"; import { EyeReasoner } from "../../../src/reasoner/EyeReasoner"; import { ODRL, RDF, REPORT } from "../../../src/util/Vocabularies"; +import { countSatisfiedPremises } from "../../util/ReportTest"; const normalPolicy = ` @prefix ex: . @@ -281,7 +282,7 @@ odrl:assignee . expect(report.ruleReport[0].rule.id).toBe(permission1ID); expect(report.ruleReport[0].activationState).toBe(ActivationState.Inactive) - expect(report.ruleReport[0].premiseReport.filter(premiseReport => premiseReport.satisfactionState === SatisfactionState.Satisfied).length).toBe(2) + expect(countSatisfiedPremises(report.ruleReport[0].premiseReport)).toBe(2) }) }) diff --git a/test/util/ReportTest.ts b/test/util/ReportTest.ts new file mode 100644 index 0000000..04b9fbf --- /dev/null +++ b/test/util/ReportTest.ts @@ -0,0 +1,6 @@ +import { PremiseReport, SatisfactionState } from "../../src/util/report/ComplianceReportTypes"; + + +export function countSatisfiedPremises(premiseReports: PremiseReport[]): number { + return premiseReports.filter(premiseReport => premiseReport.satisfactionState === SatisfactionState.Satisfied).length +} \ No newline at end of file