Skip to content

Commit 9495c49

Browse files
committed
WIP
1 parent db2e89d commit 9495c49

11 files changed

Lines changed: 770 additions & 481 deletions

File tree

packages/uma/config/routes/policies.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@
66
{
77
"@id": "urn:uma:default:PolicyController",
88
"@type": "PolicyController",
9-
"store": { "@id": "urn:uma:default:RulesStorage" }
9+
"baseUrl": { "@id": "urn:uma:variables:baseUrl" },
10+
"policySuffix": "/policies/",
11+
"store": { "@id": "urn:uma:default:RulesStorage" },
12+
"ownershipStore": { "@id": "urn:uma:default:OwnershipStore" }
1013
},
1114
{
1215
"@id": "urn:uma:default:PolicyHandler",
Lines changed: 270 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,278 @@
1-
import { UCRulesStorage } from "../ucp/storage/UCRulesStorage";
2-
import { BaseController } from "./BaseController";
31
import {
4-
deletePolicy,
5-
getPolicies,
6-
getPolicy,
7-
patchPolicy,
8-
postPolicy
9-
} from "../util/routeSpecific";
2+
BadRequestHttpError,
3+
ForbiddenHttpError,
4+
joinUrl,
5+
KeyValueStorage,
6+
NotFoundHttpError,
7+
} from '@solid/community-server';
8+
import { DataFactory as DF, Parser, Store } from 'n3';
9+
import { randomUUID } from 'node:crypto';
10+
import { ODRL } from 'odrl-evaluator';
11+
import { UCRulesStorage } from '../ucp/storage/UCRulesStorage';
12+
import { DC, RDF } from '../ucp/util/Vocabularies';
13+
import { HttpHandlerResponse } from '../util/http/models/HttpHandler';
14+
import { ParsedPolicy, PolicyParser } from '../util/PolicyParser';
15+
import { patchPolicy, queryEngine } from '../util/routeSpecific';
16+
import { BaseController } from './BaseController';
17+
18+
// TODO: make sure to let partners know early if policy API changes
1019

1120
/**
1221
* Controller for routes concerning policies and related rules
1322
*/
1423
export class PolicyController extends BaseController {
15-
constructor(
16-
store: UCRulesStorage
17-
) {
18-
super(
19-
store,
20-
postPolicy,
21-
deletePolicy,
22-
getPolicies,
23-
getPolicy,
24-
patchPolicy,
25-
);
24+
constructor(
25+
protected readonly baseUrl: string,
26+
protected readonly policySuffix: string,
27+
protected readonly store: UCRulesStorage,
28+
protected readonly ownershipStore: KeyValueStorage<string, string[]>,
29+
) {
30+
super(
31+
store,
32+
null as any,
33+
null as any,
34+
null as any,
35+
null as any,
36+
patchPolicy,
37+
);
38+
this.sanitizeGet = this.getSinglePolicy.bind(this);
39+
this.sanitizeGets = this.getAllPolicies.bind(this);
40+
this.sanitizePost = this.addPolicy.bind(this);
41+
this.sanitizeDelete = this.deletePolicy.bind(this);
42+
}
43+
44+
// TODO: for all functions: investigate similarities with access request controller and determine better ways to mitigate duplicaton
45+
46+
// TODO: something to target rules in a policy specifically?
47+
48+
public async putEntity(data: string, policyId: string, clientId: string): Promise<{ status: number }> {
49+
// Overriding this function as doing a new POST would rename the policy
50+
const store = new Store(new Parser().parse(data));
51+
52+
// TODO: same stuff as POST without rename, and delete old data
53+
54+
// TODO: could catch 404 and allow creating new policies at fixed IDs
55+
// Will throw a 404/403 if one of the parameters is incorrect
56+
const globalStore = await this.store.getStore();
57+
const originalPolicy = await this.getSinglePolicy(globalStore, policyId, clientId);
58+
59+
// TODO: fully duplicated from POST
60+
const parser = new PolicyParser(store);
61+
const result = parser.doStuffTODO();
62+
63+
// Not copying the creator triple as it is always added anyway, but still need to verify
64+
const creatorQuads = result.quads.filter(quad => quad.predicate.equals(DC.terms.creator));
65+
if (creatorQuads.length > 1 || (creatorQuads.length === 1 && creatorQuads[0].object.value !== clientId)) {
66+
throw new BadRequestHttpError('Policy creator does not match user credentials');
67+
} else if (creatorQuads.length === 0) {
68+
// Add creator triple
69+
result.quads.push(DF.quad(result.root, DC.terms.creator, DF.namedNode(clientId)));
70+
}
71+
72+
// Validate ownership requirements
73+
const owned = await this.ownershipStore.get(clientId) ?? [];
74+
for (const { assigners, targets } of result.ruleData) {
75+
if (assigners.length !== 1 || assigners[0].value !== clientId) {
76+
throw new ForbiddenHttpError('The assigner needs to match the request credentials');
77+
}
78+
for (const target of targets) {
79+
// Target could also be a collection
80+
if (!owned.includes(target.value) && globalStore.countQuads(target, DC.terms.creator, clientId, null) === 0) {
81+
throw new ForbiddenHttpError('The assigner needs to be the owner of the target');
82+
}
83+
}
84+
}
85+
86+
const resultStore = new Store(result.quads);
87+
88+
// TODO: now replace original policy with the new one (this is very similar to patch)
89+
const add = resultStore.difference(originalPolicy);
90+
const remove = originalPolicy.difference(resultStore);
91+
92+
if (add.size > 0) {
93+
await this.store.addRule(add as Store);
94+
}
95+
if (remove.size > 0) {
96+
await this.store.removeData(remove as Store);
97+
}
98+
99+
return { status: 204 };
100+
}
101+
102+
// TODO: type can be buffer or string, need to check higher level as well
103+
// TODO: overriding as otherwise it's not sure if we're getting isolated data or not
104+
// TODO: will be similar to access requests though
105+
public async patchEntity(policyId: string, patch: Buffer | string, clientId: string): Promise<HttpHandlerResponse<string>> {
106+
// Will throw a 404/403 if one of the parameters is incorrect
107+
const globalStore = await this.store.getStore();
108+
const originalPolicy = await this.getSinglePolicy(globalStore, policyId, clientId);
109+
110+
await queryEngine.queryVoid(patch.toString(), { sources: [originalPolicy] });
111+
// TODO: now validate if the result is still a valid policy
112+
113+
// TODO: fully duplicated from PUT
114+
const parser = new PolicyParser(originalPolicy);
115+
const result = parser.doStuffTODO();
116+
117+
// Not copying the creator triple as it is always added anyway, but still need to verify
118+
const creatorQuads = result.quads.filter(quad => quad.predicate.equals(DC.terms.creator));
119+
if (creatorQuads.length > 1 || (creatorQuads.length === 1 && creatorQuads[0].object.value !== clientId)) {
120+
throw new BadRequestHttpError('Policy creator does not match user credentials');
121+
} else if (creatorQuads.length === 0) {
122+
// Add creator triple
123+
result.quads.push(DF.quad(result.root, DC.terms.creator, DF.namedNode(clientId)));
124+
}
125+
126+
// Validate ownership requirements
127+
const owned = await this.ownershipStore.get(clientId) ?? [];
128+
for (const { assigners, targets } of result.ruleData) {
129+
if (assigners.length !== 1 || assigners[0].value !== clientId) {
130+
throw new ForbiddenHttpError('The assigner needs to match the request credentials');
131+
}
132+
for (const target of targets) {
133+
// Target could also be a collection
134+
if (!owned.includes(target.value) && globalStore.countQuads(target, DC.terms.creator, clientId, null) === 0) {
135+
throw new ForbiddenHttpError('The assigner needs to be the owner of the target');
136+
}
137+
}
138+
}
139+
140+
// TODO: new rules could have been added through PATCH (and also PUT!!!) which would have to be renamed!
141+
// ^ sub function that also returns the nodes so we know all the nodes and which ones might be new
142+
143+
const resultStore = new Store(result.quads);
144+
145+
// TODO: now replace original policy with the new one (this is very similar to patch)
146+
const add = resultStore.difference(originalPolicy);
147+
const remove = originalPolicy.difference(resultStore);
148+
149+
if (add.size > 0) {
150+
await this.store.addRule(add as Store);
26151
}
152+
if (remove.size > 0) {
153+
await this.store.removeData(remove as Store);
154+
}
155+
156+
return { status: 204 };
157+
}
158+
159+
protected getSinglePolicy(store: Store, policyId: string, clientId: string): Promise<Store> {
160+
return this.getPolicies(store, clientId, policyId);
161+
}
162+
163+
protected getAllPolicies(store: Store, clientId: string): Promise<Store> {
164+
return this.getPolicies(store, clientId);
165+
}
166+
167+
// TODO: instead of the ownership checks, we could just check the assigner field...
168+
protected async getPolicies(store: Store, clientID: string, policyId?: string): Promise<Store> {
169+
const policyStore = new Store();
170+
if (policyId) {
171+
// TODO: bit much to put in single if-block?
172+
const policyNode = DF.namedNode(policyId);
173+
policyStore.addQuads(store.getQuads(policyNode, null, null, null));
174+
if (policyStore.size === 0) {
175+
throw new NotFoundHttpError();
176+
}
177+
if (!policyStore.has(DF.quad(policyNode, DC.terms.creator, DF.namedNode(clientID)))) {
178+
throw new ForbiddenHttpError();
179+
}
180+
return new Store(new PolicyParser(store).doStuffTODO(policyNode).quads);
181+
} else {
182+
// Everything created would also include things such as collections
183+
const created = store.getSubjects(DC.terms.creator, clientID, null);
184+
const policies = created.filter(policy => {
185+
const types = store.getObjects(policy, RDF.terms.type, null);
186+
return types.some(type => [ODRL.terms.Set, ODRL.terms.Offer, ODRL.terms.Agreement]
187+
.some(policyType => type.equals(policyType)));
188+
});
189+
const parser = new PolicyParser(store);
190+
return new Store(policies.flatMap(policy => parser.doStuffTODO(policy).quads));
191+
}
192+
193+
194+
// TODO: for everything below, can just assume data is good because it got added through the API
195+
196+
// TODO: error if we have dangling rules and/or delete them? or if multiple policies?
197+
// TODO: do stuff if no rules
198+
199+
200+
// TODO: if there are no rules for some reason I guess we can just return the empty policy
201+
202+
// TODO: although this should never happen: remove rule references from policy store that target unowned resources
203+
204+
// TODO: how to know what to delete though? everything except collections for now?
205+
206+
// TODO: should just also prevent policies policies from being patched? although might be bad for loama
207+
// -> does this just use the current policy PATCH solution?
208+
}
209+
210+
// TODO: ID returned should potentially be URL encoded
211+
protected async addPolicy(store: Store, clientId: string): Promise<{ result: Store, id: string }> {
212+
const parser = new PolicyParser(store);
213+
const result = parser.doStuffTODO();
214+
215+
// Not copying the creator triple as it is always added anyway, but still need to verify
216+
const creatorQuads = result.quads.filter(quad => quad.predicate.equals(DC.terms.creator));
217+
if (creatorQuads.length > 1 || (creatorQuads.length === 1 && creatorQuads[0].object.value !== clientId)) {
218+
throw new BadRequestHttpError('Policy creator does not match user credentials');
219+
} else if (creatorQuads.length === 0) {
220+
// Add creator triple
221+
result.quads.push(DF.quad(result.root, DC.terms.creator, DF.namedNode(clientId)));
222+
}
223+
224+
// Validate ownership requirements
225+
const globalStore = await this.store.getStore();
226+
const owned = await this.ownershipStore.get(clientId) ?? [];
227+
for (const { assigners, targets } of result.ruleData) {
228+
if (assigners.length !== 1 || assigners[0].value !== clientId) {
229+
throw new ForbiddenHttpError('The assigner needs to match the request credentials');
230+
}
231+
for (const target of targets) {
232+
// Target could also be a collection
233+
if (!owned.includes(target.value) && globalStore.countQuads(target, DC.terms.creator, clientId, null) === 0) {
234+
throw new ForbiddenHttpError('The assigner needs to be the owner of the target');
235+
}
236+
}
237+
}
238+
239+
const renamedResult = this.renamePolicyIdentifiers(result, joinUrl(this.baseUrl, this.policySuffix));
240+
241+
return { result: new Store(renamedResult.quads), id: renamedResult.root.value };
242+
}
243+
244+
// TODO: move more to utilify file?
245+
246+
// TODO: way to add rules to policy without patch?
247+
// TODO: for PATCH: first query everything starting from policy node, apply changes, and then call these functions again to verify correctness
248+
// ^ but in this case the ID can already exist? maybe? sometimes? not for the new things though
249+
250+
protected renamePolicyIdentifiers(policy: ParsedPolicy, prefixUrl: string): ParsedPolicy {
251+
const renameMap = Object.fromEntries(policy.nodes.map(node =>
252+
[node.value, DF.namedNode(joinUrl(prefixUrl, randomUUID()))]));
253+
const updatedQuads = policy.quads.map(quad => {
254+
const updated = [ renameMap[quad.subject.value], renameMap[quad.predicate.value], renameMap[quad.object.value] ];
255+
if (!updated[0] && !updated[1] && !updated[2]) {
256+
return quad;
257+
}
258+
return DF.quad(updated[0] ?? quad.subject, updated[1] ?? quad.predicate, updated[2] ?? quad.object);
259+
});
260+
261+
return {
262+
quads: updatedQuads,
263+
nodes: Object.values(renameMap),
264+
root: renameMap[policy.root.value],
265+
ruleData: policy.ruleData.map(data => ({
266+
assigners: data.assigners.map(assigner => renameMap[assigner.value] ?? assigner),
267+
targets: data.targets.map(target => renameMap[target.value] ?? target),
268+
actions: data.actions.map(action => renameMap[action.value] ?? action),
269+
})),
270+
}
271+
}
272+
273+
protected async deletePolicy(store: Store, policyId: string, clientId: string): Promise<void> {
274+
// This GET check ensures the client is the creator of the policy
275+
const policy = await this.getSinglePolicy(store, policyId, clientId);
276+
store.removeQuads(policy.getQuads(null, null, null, null));
277+
}
27278
}

packages/uma/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ export * from './ucp/util/Vocabularies';
9999
export * from './util/AggregatorUtil';
100100
export * from './util/ConvertUtil';
101101
export * from './util/HttpMessageSignatures';
102+
export * from './util/PolicyParser';
102103
export * from './util/RegistrationStore';
103104
export * from './util/Result';
104105
export * from './util/ReType';

packages/uma/src/ucp/util/Vocabularies.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
1-
import { DC as DC_CSS } from '@solid/community-server';
1+
import { DC as DC_CSS, RDF as RDF_CSS } from '@solid/community-server';
22
import { createVocabulary, extendVocabulary } from 'rdf-vocabulary';
33

4-
export const DC = extendVocabulary(DC_CSS,'creator');
4+
export const DC = extendVocabulary(
5+
DC_CSS,
6+
'coverage',
7+
'creator',
8+
'isReplacedBy',
9+
'issued',
10+
'replaces',
11+
);
512

613
export const ODRL = createVocabulary(
714
'http://www.w3.org/ns/odrl/2/',
@@ -45,6 +52,8 @@ export const OWL = createVocabulary(
4552
'inverseOf',
4653
);
4754

55+
export const RDF = extendVocabulary(RDF_CSS,'value');
56+
4857
export const SOTW = createVocabulary(
4958
'https://w3id.org/force/sotw#',
5059
'EvaluationRequest',

0 commit comments

Comments
 (0)