diff --git a/controller/src/classes/OdrlController.ts b/controller/src/classes/OdrlController.ts index ab49720a..102b2f55 100644 --- a/controller/src/classes/OdrlController.ts +++ b/controller/src/classes/OdrlController.ts @@ -1,11 +1,12 @@ import { getDefaultSession } from "@inrupt/solid-client-authn-browser"; -import { BaseSubject, Index, IndexItem, Permission, ResourcePermissions, Resources } from "../types"; -import { IAccessRequest, IController, IInboxConstructor, IStore, IStoreConstructor, SubjectConfig, SubjectConfigs, SubjectKey, SubjectType } from "../types/modules"; -import { type AccessRequest as AccessRequestObject } from "../types/modules"; -import { AccessRequest } from "./accessRequests/AccessRequest"; -import { ODRLAccessRequest } from "./accessRequests/OdrlAccessRequest"; +import { BaseSubject, Index, Permission, Resources } from "../types"; +import { IController, IInboxConstructor, IStore, IStoreConstructor, SubjectConfig, SubjectConfigs, SubjectKey, SubjectType } from "../types/modules"; +import { type AccessRequest as AccessRequestObject, Policy, Rule, RuleUpdate } from "../types/modules"; import { Mutex } from "./utils/Mutex"; import { ODRLAccessRequestService } from "./utils/OdrlAccessRequestService"; +import { ODRLPolicyService } from "./utils/OdrlPolicyService"; +import { PolicyInterpreter } from "./utils/PolicyInterpreter"; +import { v4 as uuidv4 } from 'uuid'; /** * Controller which makes it calls to the backend AS through ODRL requests. @@ -14,7 +15,6 @@ import { ODRLAccessRequestService } from "./utils/OdrlAccessRequestService"; export class ODRLController>> extends Mutex implements IController { private index: IStore>; private resources: IStore; - private accessRequest: AccessRequest; private subjectConfigs: SubjectConfigs; private authorizationServerURL: string; @@ -24,7 +24,6 @@ export class ODRLController ({ id: "", items: [] })) as IStore>; this.resources = new storeConstructor("resources.json", () => ({ id: "", items: [] })) as IStore;; - this.accessRequest = new ODRLAccessRequest(this as unknown as ODRLController<{}>, inboxConstructor, this.resources); this.subjectConfigs = subjects; this.authorizationServerURL = authorizationServerURL; } @@ -40,10 +39,6 @@ export class ODRLController>(resourceUrl: string, subject: SubjectType, permissions: Permission[], alwaysKeepItem = false) { } - AccessRequest(): IAccessRequest { - return this.accessRequest; - } - async setPodUrl(podUrl: string) { } @@ -60,70 +55,94 @@ export class ODRLController>(resourceUrl: string, subject: SubjectType): Promise | undefined> { - const subjectConfig = this.getSubjectConfig(subject) - const subjects = await subjectConfig.manager.getRemotePermissions(resourceUrl); - const subjectPermission = subjects.find(entry => subjectConfig.resolver.checkMatch(entry.subject, subject)) - if (!subjectPermission) return undefined - return { - id: "string", - requestId: "string", - isEnabled: subjectPermission.isEnabled, - permissions: [...subjectPermission.permissions ?? []], - resource: resourceUrl, - subject: subject, - } as IndexItem - } - - async addPermission>(resourceUrl: string, addedPermission: Permission, subject: SubjectType) { - const release = await this.acquire(); - try { - - // 1. Create a new permission for the subject - await this.getSubjectConfig(subject).manager.createPermissions(resourceUrl, subject, [addedPermission]) - - // 2. Let the manager add the permission, return the updated version - const webId = getDefaultSession().info.webId!; - const permissions = await this.getSubjectConfig(subject).manager.getTargetPermissionsForUser(webId, subject.selector?.url ?? "", resourceUrl); - - return permissions; - } catch (e) { - throw e; - } finally { - release(); + async updatePolicy(updates: RuleUpdate[]): Promise { + + if (updates.length === 0) return; + + const webId = getDefaultSession().info.webId; + if (!webId) throw new Error("User not logged in"); + const service = new ODRLPolicyService(this.authorizationServerURL); + + const store = await service.fetchPolicies(webId); + const interpreter = new PolicyInterpreter(); + const allPolicies = interpreter.storeToPolicies(store); + + const policiesMap = new Map(allPolicies.map(p => [p.id, p])); + const modifiedPolicyIds = new Set(); + + for (const update of updates) { + if (!update.policyId){ + if(update.updateType == 'add'){ + //create policy + const policy: Policy = { + id: `http://example.org/${uuidv4()}`, + rules: [update.rule], + type: 'Agreement' + }; + + policy.rules[0].id = `http://example.org/${policy.id}-Rule-${(uuidv4()).toString()}`; + policiesMap.set(policy.id, policy); + modifiedPolicyIds.add(policy.id); + } + continue; + + } + const policy = policiesMap.get(update.policyId); + if (!policy) continue; + const ruleIndex = policy.rules.findIndex(r => r.id === update.rule.id); + + if(update.rule.id == ""){ + update.rule.id = `http://example.org/${update.policyId}-Rule-${uuidv4()}`; + } + + + if (update.updateType === 'remove' && ruleIndex !== -1) { + policy.rules.splice(ruleIndex, 1); + modifiedPolicyIds.add(policy.id); + } else if (update.updateType === 'edit' && ruleIndex !== -1) { + policy.rules[ruleIndex] = update.rule; + modifiedPolicyIds.add(policy.id); + } else if (update.updateType === 'add') { + if (ruleIndex === -1) policy.rules.push(update.rule); + else policy.rules[ruleIndex] = update.rule; + modifiedPolicyIds.add(policy.id); + } } - } - async removeSubject>(resourceUrl: string, subject: SubjectType) { - const subjectConfig = this.getSubjectConfig(subject); - const item = await this.getItem(resourceUrl, subject); + const savePromises = Array.from(modifiedPolicyIds).map(async (policyId) => { + const policy = policiesMap.get(policyId)!; + if(policy.rules.length == 0){ + await service.deletePolicy(webId, policyId); + } + else{ + // Convert JS Policy object back to Turtle format + const turtleText = interpreter.policyToTurtle(webId, policy); + await service.putPolicy(webId, policyId, turtleText); + } + }); - await subjectConfig.manager.deletePermissions(resourceUrl, subject, item?.permissions ?? []); + await Promise.all(savePromises); } - async removePermission>(resourceUrl: string, removedPermission: Permission, subject: SubjectType) { - const release = await this.acquire() - try { - - // 1. Delete a permission for the subject - await this.getSubjectConfig(subject).manager.deletePermissions(resourceUrl, subject, [removedPermission]); - - // 2. Let the manager delete the permission, return the updated version - const webId = getDefaultSession().info.webId!; - const permissions = await this.getSubjectConfig(subject).manager.getTargetPermissionsForUser(webId, subject.selector!.url, resourceUrl); - - return permissions - - } catch (error) { - return [] - } finally { - release(); + /** + * Get all policies the logged in user has control over + * @param resourceUrl Not Implemented - Can be used to filter down in multi-pod scenarios + * @returns Policy object + */ + async getResourcePolicies(resourceUrl: string): Promise { + const webId = getDefaultSession().info.webId; + if (!webId) { + throw new Error("User not logged in"); } + const store = await new ODRLPolicyService(this.authorizationServerURL).fetchPolicies(webId); + return new PolicyInterpreter().storeToPolicies(store, resourceUrl); } + async enablePermissions>(resource: string, subject: SubjectType) { // won't fix } @@ -132,21 +151,6 @@ export class ODRLController[]> { - return this.getSubjectConfig({ type: "public" } as T[SubjectKey]).manager.getContainerPermissionList(containerUrl); - } - - // NOTE: Do we want to force this to only use the index stored in the store? - async getResourcePermissionList(resourceUrl: string): Promise> { - const result = await this.getSubjectConfig({ type: "public" } as T[SubjectKey]).manager.getRemotePermissions(resourceUrl); - - return { - resourceUrl, - canRequestAccess: true, // TODO - permissionsPerSubject: result - }; - } - isSubjectSupported>(subject: BaseSubject): IController> { if (!this.subjectConfigs[subject.type as unknown as keyof T]) { @@ -156,9 +160,11 @@ export class ODRLController { + async requestAccess(permission: { accessRequest: AccessRequestObject}): Promise { + console.log('aaa-Requesting'); const webid = getDefaultSession().info.webId!; - await new ODRLAccessRequestService(this.authorizationServerURL).requestAccess(permission.resource, webid, permission.action); + permission.accessRequest.requestingParty = webid; + await new ODRLAccessRequestService(this.authorizationServerURL).requestAccess(permission.accessRequest); } async handleAccessRequest(requestId: string, status: 'accepted' | 'denied'): Promise { @@ -172,4 +178,4 @@ export class ODRLController { return new ODRLAccessRequestService(this.authorizationServerURL).retrieveAccessRequests(getDefaultSession().info.webId!); } -} +} \ No newline at end of file diff --git a/controller/src/classes/accessRequests/AccessRequest.ts b/controller/src/classes/accessRequests/AccessRequest.ts deleted file mode 100644 index 687057cb..00000000 --- a/controller/src/classes/accessRequests/AccessRequest.ts +++ /dev/null @@ -1,247 +0,0 @@ -import { getDatetime, getLinkedResourceUrlAll, getResourceInfo, getStringNoLocale, getStringNoLocaleAll, getThingAll, getUrl } from "@inrupt/solid-client"; -import { AccessRequestMessage, Permission, RequestResponseMessage, ResourceAccessRequestNode, Resources } from "../../types"; -import { IAccessRequest, IController, IInbox, IInboxConstructor, IStore } from "../../types/modules"; -import { cacheBustedFetch } from "../../util"; -import { PermissionToACL } from "../utils/Permissions"; - -const REQUEST_RESPONSE_TYPES = ["as:Accept", "as:Reject"] - -export abstract class AccessRequest implements IAccessRequest { - private resources: IStore; - private inbox: IInbox; - private controller: IController<{}>; - - constructor(controller: IController<{}>, inboxConstructor: IInboxConstructor, resourcesStore: IStore) { - this.controller = controller; - this.resources = resourcesStore; - this.inbox = new inboxConstructor("public/loama/inbox/") as IInbox; - } - - async setPodUrl(url: string) { - this.inbox.setPodUrl(url) - // This will make sure we have the inbox & resources.json created in our own container - await this.inbox.getOrCreate(); - await this.resources.getOrCreate(); - - const publicController = this.controller.isSubjectSupported({ type: "public" }); - - // Set permissions for info resources - await publicController.addPermission(this.resources.getDataUrl(), Permission.Read, { type: "public" }); - await publicController.addPermission(this.inbox.getDataUrl(), Permission.Append, { type: "public" }); - } - - unsetPodUrl() { - this.inbox.unsetPodUrl(); - } - - async getRequestableResources(containerUrl: string) { - const requestableResources = await this.resources.getCurrent(); - const filteredRequestableResources = requestableResources.items.filter(item => item.startsWith(containerUrl) && item !== containerUrl); - let masterNode: ResourceAccessRequestNode = { - resourceUrl: containerUrl, - canRequestAccess: requestableResources.items.includes(containerUrl), - children: {}, - }; - - filteredRequestableResources.forEach(resource => { - const pathParts = resource.replace(containerUrl, "").split('/'); - pathParts.reduce(function(parentNode, pathPart, index) { - if (pathPart == "") { - // Requestable is a directory, which ends in "/" - parentNode.resourceUrl += "/"; - parentNode.canRequestAccess = true; - return parentNode; - } - if (!parentNode.children) { - parentNode.children = {}; - } - if (parentNode.children[pathPart]) { - parentNode.children[pathPart].canRequestAccess = pathParts.length === index; - } - return parentNode.children[pathPart] || (parentNode.children[pathPart] = { - resourceUrl: `${parentNode.resourceUrl}${parentNode.resourceUrl.endsWith("/") ? "" : "/"}${pathPart}`, - canRequestAccess: (pathParts.length - 1) === index, - children: {}, - }); - }, masterNode) - }) - - return masterNode; - } - - async canRequestAccessToResource(resourceUrl: string) { - const resources = await this.resources.getCurrent(); - return resources.items.includes(resourceUrl); - } - - async allowAccessRequest(resourceUrl: string) { - const resources = await this.resources.getCurrent(); - if (resources.items.includes(resourceUrl)) { - return; - } - resources.items.push(resourceUrl); - - await this.resources.saveToRemote(); - } - - async disallowAccessRequest(resourceUrl: string) { - const resources = await this.resources.getCurrent(); - if (!resources.items.includes(resourceUrl)) { - return; - } - const idx = resources.items.indexOf(resourceUrl); - resources.items.splice(idx, 1); - - await this.resources.saveToRemote(); - } - - async sendRequestNotification(originWebId: string, resources: string[], permissions: Permission[]) { - const requestableResources = await this.resources.getCurrent(); - const filteredResources = resources.filter(r => requestableResources.items.includes(r)); - - const messages: unknown[] = []; - for (let r of filteredResources) { - // TODO: Replace with our own calls instead of using the inrupt SDK - const resourceInfo = await getResourceInfo(r, { - ignoreAuthenticationErrors: true, - fetch: cacheBustedFetch - }); - const linkedResources = getLinkedResourceUrlAll(resourceInfo) - let acl = linkedResources.acl?.[0]; - if (!acl) { - console.warn(`No acl found for ${r}, using ${r}.acl`); - acl = `${r}.acl`; - } - - messages.push({ - "@context": { - "tbd": "http://example.org/to-be-determined", - "acl": "http://www.w3.org/ns/auth/acl", - "as": "https://www.w3.org/ns/activitystreams", - }, - "@type": "tbd:AppendRequest", - "@id": `urn:loama:${crypto.randomUUID()}`, - "as:actor": originWebId, - "as:target": acl, - "as:published": new Date().toISOString(), - "as:object": { - "@id": `urn:loama:${crypto.randomUUID()}`, - "@type": "acl:Authorization", - "acl:agent": originWebId, - "acl:accessTo": r, - "acl:mode": permissions.map(p => ({ "@id": PermissionToACL(p) })) - } - }); - }; - - const inbox = await this.inbox.getCurrent(); - inbox.push(...messages); - await this.inbox.saveToRemote(); - } - - async sendResponseNotification(type: "accept" | "reject", message: AccessRequestMessage) { - const inbox = await this.inbox.getCurrent(); - inbox.push({ - "@context": { - "as": "https://www.w3.org/ns/activitystreams", - }, - "@type": type == "accept" ? "as:Accept" : "as:Reject", - "@id": `urn:loama:${crypto.randomUUID()}`, - "as:actor": this.inbox.getDataUrl(), - "as:object": { - "@id": `urn:loama:${crypto.randomUUID()}`, - "@type": "acl:Authorization", - "acl:agent": message.actor, - "acl:accessTo": message.target, - "acl:mode": message.permissions.map(p => ({ "@id": p })) - } - }); - await this.inbox.saveToRemote(); - } - - async loadAccessRequests() { - const messages = await this.inbox.getMessages(); - const parsedMessages: AccessRequestMessage[] = []; - for (let [url, message] of Object.entries(messages)) { - const allThings = getThingAll(message) - const appendRequest = allThings.filter(t => t.predicates["http://www.w3.org/1999/02/22-rdf-syntax-ns#type"].namedNodes?.includes("tbd:AppendRequest"))?.[0]; - - // This message does not contain a tbd:AppendRequest - if (!appendRequest) { - continue - } - - const authorizationUrl = getUrl(appendRequest, "as:object"); - if (!authorizationUrl) { - throw new Error(`Message with id ${url} does has no reference to an authorization object`); - } - - const authorizationThing = allThings.filter(t => t.url === authorizationUrl)?.[0]; - if (!authorizationThing) { - throw new Error(`Message with id ${url} does not contain the referenced authorization object`); - } - - const entryTarget = getStringNoLocale(authorizationThing, "acl:accessTo"); - if (!entryTarget) { - console.error("Inbox contains appendRequest without resource target"); - continue; - } - - const entry: AccessRequestMessage = { - id: url, - actor: getStringNoLocale(appendRequest, "as:actor") ?? "Unknown actor", - requestedAt: getDatetime(appendRequest, "as:published") ?? new Date(), - target: entryTarget, - permissions: [...(authorizationThing.predicates["acl:mode"].namedNodes ?? ["acl:Read"])], - } - parsedMessages.push(entry); - } - return parsedMessages; - } - - async loadRequestResponses() { - const messages = await this.inbox.getMessages(); - const parsedMessages: RequestResponseMessage[] = []; - for (let [url, message] of Object.entries(messages)) { - const allThings = getThingAll(message) - const responseThing = allThings.filter(t => { - const type = t.predicates["http://www.w3.org/1999/02/22-rdf-syntax-ns#type"].namedNodes?.[0]; - if (!type) return false; - return REQUEST_RESPONSE_TYPES.includes(type) - })?.[0]; - - // This message does not contain a tbd:AppendRequest - if (!responseThing) { - continue - } - - const authorizationUrl = getUrl(responseThing, "as:object"); - if (!authorizationUrl) { - throw new Error(`Message with id ${url} does has no reference to an authorization object`); - } - - const authorizationThing = allThings.filter(t => t.url === authorizationUrl)?.[0]; - if (!authorizationThing) { - throw new Error(`Message with id ${url} does not contain the referenced authorization object`); - } - - const entryTarget = getStringNoLocale(authorizationThing, "acl:accessTo"); - if (!entryTarget) { - console.error(`Access request response without target resource in inbox`) - continue - } - console.log(authorizationThing) - - const entry: RequestResponseMessage = { - id: url, - target: entryTarget, - isAccepted: responseThing.predicates["http://www.w3.org/1999/02/22-rdf-syntax-ns#type"].namedNodes?.[0] === "as:Accept", - permissions: [...(authorizationThing.predicates["acl:mode"].namedNodes ?? [])] - } - parsedMessages.push(entry); - } - return parsedMessages; - } - - abstract removeRequest(messageUrl: string): Promise; -} diff --git a/controller/src/classes/accessRequests/OdrlAccessRequest.ts b/controller/src/classes/accessRequests/OdrlAccessRequest.ts deleted file mode 100644 index e0a43855..00000000 --- a/controller/src/classes/accessRequests/OdrlAccessRequest.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { IAccessRequest, IController, IInboxConstructor, IStore } from "@/types/modules"; -import { AccessRequest } from "./AccessRequest"; -import { Resources } from "@/types"; -import { getDefaultSession } from "@inrupt/solid-client-authn-browser"; -import { deleteFile } from "@inrupt/solid-client"; - -export class ODRLAccessRequest extends AccessRequest implements IAccessRequest { - constructor(controller: IController<{}>, inboxConstructor: IInboxConstructor, resourcesStore: IStore) { - super(controller, inboxConstructor, resourcesStore) - } - - async removeRequest(messageUrl: string) { - const session = getDefaultSession(); - - await deleteFile(messageUrl, { - fetch: session.fetch - }) - } -} diff --git a/controller/src/classes/permissionManager/odrl/GroupManager.ts b/controller/src/classes/permissionManager/odrl/GroupManager.ts index af987573..252ce42e 100644 --- a/controller/src/classes/permissionManager/odrl/GroupManager.ts +++ b/controller/src/classes/permissionManager/odrl/GroupManager.ts @@ -41,23 +41,5 @@ export class GroupManager>(resourceUrl: string) { - const groupAccess = await this.getGroupAccessAll(resourceUrl) - - if (!groupAccess) { - return []; - } - - return Object.entries(groupAccess).map(([url, access]) => ({ - // @ts-expect-error selector is required for webId - subject: { - type: "webId", - selector: { url }, - } as T[K], - permissions: this.AccessModesToPermissions(access), - isEnabled: true, - })) - } - type = 'groupManager' } diff --git a/controller/src/classes/permissionManager/odrl/OdrlPermissionManager.ts b/controller/src/classes/permissionManager/odrl/OdrlPermissionManager.ts index 61550d56..61812ae3 100644 --- a/controller/src/classes/permissionManager/odrl/OdrlPermissionManager.ts +++ b/controller/src/classes/permissionManager/odrl/OdrlPermissionManager.ts @@ -79,108 +79,5 @@ export abstract class ODRLPermissionManager { - const store: Store = await new ODRLPolicyService(this.authorizationServerURL).fetchPolicies(assignerId); - const target: TargetSubjects = new PolicyInterpreter().permissionsForOneResource(targetId, store); - - // If there are no private permissions, or no private permissions for the assignee, return the public ones (or nothing if they don't exist) - if (!target.private || !target.private.get(assigneeId)) return Array.from(target.public?.permissions! ?? []) - - return Array.from(target.private.get(assigneeId)?.permissions!) ?? [] - } - - - public async getRemotePermissions>(resourceUrl: string): Promise[]> { - // Extract our webID - const session = getDefaultSession(); - const webId = session.info.webId; - - // We must be logged on - if (!webId) { - throw new Error("User not logged in"); - } - - // Retrieve our policies - const store = await new ODRLPolicyService(this.authorizationServerURL).fetchPolicies(webId); - - // Get detailed info about the target - const interpreter = new PolicyInterpreter(); - const target: TargetSubjects = interpreter.permissionsForOneResource(resourceUrl, store); - - - if (target) { - const subjectPermissions: SubjectPermissions[] = []; - // Add the owner information - subjectPermissions.push({ - subject: { - type: "webId", - selector: { url: target.assigner } - } as unknown as T[K], - permissions: [Permission.Append, Permission.Control, Permission.Create, Permission.Read, Permission.Write], - isEnabled: true, - targetId: target.targetUrl - }) - - // Add the public information - if (target.public && target.public.permissions.size > 0) subjectPermissions.push({ - subject: { - type: "public", - } as unknown as T[K], - permissions: Array.from(target.public.permissions), - isEnabled: true, // Not yet implemented, there is no odrl equivalent? - targetId: target.targetUrl - }) - - // Add the private subjects - if (target.private) target.private.forEach(subject => { - if (subject.permissions.size > 0) subjectPermissions.push({ - subject: { - type: "webId", - selector: { url: subject.subject } - } as unknown as T[K], - permissions: Array.from(subject.permissions), - isEnabled: true, // Not yet implemented, there is no odrl equivalent? - targetId: target.targetUrl - }) - }) - return subjectPermissions; - } - - return []; - } - - - async getContainerPermissionList(containerUrl: string, resourceToSkip: string[] = []): Promise[]> { - // Extract our webID - const session = getDefaultSession(); - const webId = session.info.webId; - - // We must be logged on - if (!webId) { - throw new Error("User not logged in"); - } - - const store = await new ODRLPolicyService(this.authorizationServerURL).fetchPolicies(webId); - - // Collect target urls - const targetUrls = Array.from(new Set(store.getQuads(null, ODRL('target'), null, null).map(q => q.object.id))); - const resourcePermissions: ResourcePermissions[] = [] - for (const targetUrl of targetUrls) { - const perms = await this.getRemotePermissions(targetUrl); - resourcePermissions.push({ - resourceUrl: targetUrl, - canRequestAccess: true, // TODO: based on proper access logic - permissionsPerSubject: perms - }) - } - - return resourcePermissions; - } - shouldDeleteOnAllRevoked() { return true } } diff --git a/controller/src/classes/utils/OdrlAccessRequestService.ts b/controller/src/classes/utils/OdrlAccessRequestService.ts index ce9fb3d8..c449141a 100644 --- a/controller/src/classes/utils/OdrlAccessRequestService.ts +++ b/controller/src/classes/utils/OdrlAccessRequestService.ts @@ -14,44 +14,53 @@ export class ODRLAccessRequestService { /** * Place a POST request to create an access request to an UMA backend - * @param resourceURL - URL of the resource to request access for - * @param requestingParty - user credentials of the requesting party - * @param action - the action the user wants to perform on the resource + * @param accessRequest - all infromation regarding an access request */ - public requestAccess = async (resourceURL: string, requestingParty: string, action: string): Promise => { + public requestAccess = async (accessRequest: AccessRequest): Promise => { const response = await fetch( `${this.authorizationServerURL}/requests`, { method: 'POST', headers: { - 'authorization': `WebID ${encodeURIComponent(requestingParty)}`, - 'content-type': 'text/turtle' - }, body: await this.accessRequestToTtl({ - uid: uuid(), - target: resourceURL, - action: action, - requestingParty: requestingParty, - status: 'requested' - }) + 'authorization': `WebID ${encodeURIComponent(accessRequest.requestingParty)}` + }, body: await this.accessRequestToJson(accessRequest) } ); if (response.status !== 201) throw new Error('failed to create access request'); } - private accessRequestToTtl = async (accessRequest: AccessRequest): Promise => ` - @prefix ex: . - @prefix sotw: . - @prefix dcterms: . - @prefix odrl: . - @prefix xsd: . - - ex:${accessRequest.uid} a sotw:EvaluationRequest ; - dcterms:issued "${new Date().toISOString()}"^^xsd:datetime ; - sotw:requestedTarget <${accessRequest.target}> ; - sotw:requestedAction odrl:${accessRequest.action} ; - sotw:requestingParty <${accessRequest.requestingParty}> ; - ex:requestStatus ex:${accessRequest.status} . - `; + /** + * Transform accessrequest to required JSON format + * @param accessRequest - all information regarding an access request + * @returns + */ + private accessRequestToJson = async (accessRequest: AccessRequest): Promise => { + const payload: any = { + resource_id: accessRequest.target, + resource_scopes: + accessRequest.actions.map(action => + action.startsWith('http') ? action : `http://www.w3.org/ns/odrl/2/${action}` + ) + }; + + const constraintsList: any[] = []; + + if (accessRequest.constraint && accessRequest.constraint.length > 0) { + accessRequest.constraint.forEach(con => { + constraintsList.push([ + con.leftOperand, + con.operator, + con.rightOperand[0] //ToDo Work with list + ]); + }); + } + + if (constraintsList.length > 0) { + payload.constraints = constraintsList; + } + + return JSON.stringify(payload, null, 12); + }; /** * Place a PATCH request to update an access request to an UMA backend @@ -118,58 +127,142 @@ export class ODRLAccessRequestService { }; } + /** + * Transform raw bindings to AccessRequest objects + * @param bindings + * @returns + */ private bindingsToAccessRequest = async (bindings: any): Promise => { - const results: AccessRequest[] = []; + const requestsMap = new Map(); for await (const binding of bindings) { - results.push({ - uid: binding.get('uid')?.value!, - target: binding.get('target')?.value!, - action: this.cleanValue(binding.get('action')?.value), - requestingParty: binding.get('requestingParty')?.value!, - status: this.cleanValue(binding.get('status')?.value), - }) + const uid = binding.get('uid')?.value; + if (!uid) continue; + + if (!requestsMap.has(uid)) { + const rawActions = binding.get('actions')?.value ?? ''; + const actions = rawActions + ? rawActions.split(',').map((act: string) => this.cleanValue(act)) + : []; + + requestsMap.set(uid, { + uid, + target: binding.get('target')?.value ?? '', + actions, + constraint: [], + requestingParty: binding.get('requestingParty')?.value ?? '', + status: this.cleanValue(binding.get('status')?.value), + }); + } + + const request = requestsMap.get(uid)!; + + const leftOperand = binding.get('leftOperand')?.value; + const operator = binding.get('operator')?.value; + const rightOperand = binding.get('rightOperand')?.value; + + if (leftOperand && operator && rightOperand) { + const cleanLeft = this.cleanValue(leftOperand); + const cleanOp = this.cleanValue(operator); + const cleanRight = this.cleanValue(rightOperand); + + let existingConstraint = request.constraint.find( + c => c.leftOperand === cleanLeft && c.operator === cleanOp + ); + + if (!existingConstraint) { + existingConstraint = { + leftOperand: cleanLeft, + operator: cleanOp, + rightOperand: [] + }; + request.constraint.push(existingConstraint); + } + + if (!existingConstraint.rightOperand.includes(cleanRight)) { + existingConstraint.rightOperand.push(cleanRight); + } + } } - - return results; - } + return Array.from(requestsMap.values()); + }; + + + /** + * Retrieves last part of URI. + * @param val - URI + */ private readonly cleanValue = (val?: string): string => { if (!val) return ''; - const match = val.match(/^http:\/\/.*\/(.*)$/); + const match = val.match(/([^/#]+)$/); return (match ? match[1] : val).toLowerCase(); } + /** + * Fetches all access requests submitted by a given WebId + * Returns a SPARQL query string + * @param requestingPartyID + * @returns + */ private readonly accessRequestForRequestingParty = (requestingPartyID: string): string => ` PREFIX ex: PREFIX sotw: PREFIX odrl: - SELECT DISTINCT ?uid ?target ?action ?requestingParty ?status + SELECT ?uid ?target ?requestingParty ?status + (GROUP_CONCAT(DISTINCT ?action; separator=",") AS ?actions) + ?constraintUri ?leftOperand ?operator ?rightOperand WHERE { ?uid a sotw:EvaluationRequest ; - sotw:requestedTarget ?target ; - sotw:requestedAction ?action ; - sotw:requestingParty <${requestingPartyID}> ; - ex:requestStatus ?status . + sotw:requestedTarget ?target ; + sotw:requestedAction ?action ; + sotw:requestingParty <${requestingPartyID}> ; + sotw:requestStatus ?status . + + OPTIONAL { + ?uid odrl:constraint ?constraintUri . + ?constraintUri a odrl:Constraint ; + odrl:leftOperand ?leftOperand ; + odrl:operator ?operator ; + odrl:rightOperand ?rightOperand . + } } + GROUP BY ?uid ?target ?requestingParty ?status ?constraintUri ?leftOperand ?operator ?rightOperand `; + /** + * Fetches all access requests controlled by a given WebId + * Returns a SPARQL query string + * @param resourceOwnerID + * @returns + */ private readonly accessRequestForResourceOwner = (resourceOwnerID: string): string => ` PREFIX ex: PREFIX sotw: PREFIX odrl: - SELECT DISTINCT ?uid ?target ?action ?requestingParty ?status + SELECT ?uid ?target ?requestingParty ?status + (GROUP_CONCAT(DISTINCT ?action; separator=",") AS ?actions) + ?constraintUri ?leftOperand ?operator ?rightOperand WHERE { ?policy odrl:target ?target ; odrl:assigner <${resourceOwnerID}> . ?uid a sotw:EvaluationRequest ; - sotw:requestedTarget ?target ; - sotw:requestedAction ?action ; - sotw:requestingParty ?requestingParty ; - ex:requestStatus ?status . + sotw:requestedTarget ?target ; + sotw:requestedAction ?action ; + sotw:requestingParty ?requestingParty ; + sotw:requestStatus ?status . + + OPTIONAL { + ?uid odrl:constraint ?constraintUri . + ?constraintUri a odrl:Constraint ; + odrl:leftOperand ?leftOperand ; + odrl:operator ?operator ; + odrl:rightOperand ?rightOperand . + } } + GROUP BY ?uid ?target ?requestingParty ?status ?constraintUri ?leftOperand ?operator ?rightOperand `; } diff --git a/controller/src/classes/utils/OdrlPolicyService.ts b/controller/src/classes/utils/OdrlPolicyService.ts index 996ba07b..7b2ef7d1 100644 --- a/controller/src/classes/utils/OdrlPolicyService.ts +++ b/controller/src/classes/utils/OdrlPolicyService.ts @@ -25,7 +25,6 @@ export class ODRLPolicyService { } public async fetchPolicies(webId: string) { - // Get all our policies const response = await fetch(UMA_URL(this.authorizationServerURL), { headers: { @@ -34,6 +33,7 @@ export class ODRLPolicyService { } }); + // Extract the target Ids const turtleText = await response.text(); @@ -70,6 +70,26 @@ export class ODRLPolicyService { }) } + public async putPolicy(webId: string, policyId: string, body: string) { + await fetch(UMA_URL(this.authorizationServerURL,`/${encodeURIComponent(policyId)}`), { + method: 'PUT', + headers: { + 'Authorization': `WebID ${encodeURIComponent(webId)}`, + 'Content-type': 'text/turtle' + }, + body: body + }) + } + + public async deletePolicy(webId: string, policyId: string) { + await fetch(UMA_URL(this.authorizationServerURL,`/${encodeURIComponent(policyId)}`), { + method: 'DELETE', + headers: { + 'Authorization': `WebID ${encodeURIComponent(webId)}`, + } + }) + } + public async patchPolicy(webId: string, policyId: string, body: string) { await fetch(UMA_URL(this.authorizationServerURL,`/${encodeURIComponent(policyId)}`), { method: 'PATCH', @@ -241,4 +261,4 @@ DELETE { } } } -} +} \ No newline at end of file diff --git a/controller/src/classes/utils/PolicyInterpreter.ts b/controller/src/classes/utils/PolicyInterpreter.ts index 4cee135d..b2b89089 100644 --- a/controller/src/classes/utils/PolicyInterpreter.ts +++ b/controller/src/classes/utils/PolicyInterpreter.ts @@ -1,9 +1,8 @@ -import { Permission } from "../../types/"; -import { IPolicy, ISpecificTargetInfo, TargetSubjects } from "../../types/modules"; -import { DataFactory, Parser, Store } from "n3"; +import { Constraint, ISpecificTargetInfo, Policy, Rule } from "../../types/modules"; +import { DataFactory, Store, Writer } from "n3"; import { ODRL } from "./PolicyParser"; -const { namedNode } = DataFactory; +const { namedNode, literal } = DataFactory; export class PolicyInterpreter { private fromODRL = (odrlString: string) => odrlString.split('/')[6]; @@ -33,127 +32,162 @@ export class PolicyInterpreter { } /** - * Function that returns the stored Target objects without sanitization - * This function assumes all policies are correct, and only contains information for the logged on client - * - * Currently, it does not check the rule type (permission, prohibition, duty) and it only takes permission into account - * @param store the owned policies in a store - * @returns the target -> subjects -> permissions relation for all owned targets + * transforms N3 data-store object to Policy object + * @param store the fetched policies + * @param resourceUrl if given, only rules targeting this resource are included */ - public ownedPoliciesToObject = (store: Store, specifiedTarget: string = ""): TargetSubjects[] => { - - // 1. Get every odrl:target . quad, or only the rules targetting the specified target - // Note that multiple rules can refer to the same target, and one rule can refer to multiple targets - const relevantRuleSet: Set = new Set((specifiedTarget === "" - ? store.getQuads(null, ODRL('target'), null, null) - : store.getQuads(null, ODRL('target'), namedNode(specifiedTarget), null)) - .map(quad => quad.subject.id)); - - // 2. Add permission information for every target we find - // Every target ID corresponds with the subjects that each have some permissions etc. - const idToTarget: Map = new Map(); - for (const ruleId of relevantRuleSet) { - - // Get the policy information - // Since a valid policy only has unique ID's, we can just search for ' .' quads on the entire store - const policyIDs: Set = new Set(); - for (const relation of ['permission'/*, 'prohibition', 'duty'*/].map(x => ODRL(x))) - store.getQuads(null, relation, namedNode(ruleId), null).forEach(res => policyIDs.add(res.subject.id)); - if (policyIDs.size !== 1) - console.warn("Corrupted Policy"); - const policyId = [...policyIDs][0]; - - // 2.1 Get the every quad defined by the rule (and their children recursively) - const ruleStore = this.extractQuadsRecursive(store, ruleId); - - // 2.2 List all relevant actions for this rule - const permissions = []; - for (const quad of ruleStore.getQuads(null, ODRL('action'), null, null)) { - // TODO: find a way to categorize all actions as one of the Permission types - const action = this.fromODRL(quad.object.id).toLowerCase(); - - switch (action) { - case "read": - permissions.push(Permission.Read); - break; - - case "write": - permissions.push(Permission.Write); - break; - - case "append": - permissions.push(Permission.Append); - break; - - case "control": - permissions.push(Permission.Control); - break; - - case "create": - permissions.push(Permission.Create); - break; - - default: - console.warn(`Unrecognized ODRL action: ${action}`); + public storeToPolicies(store: Store, resourceUrl: string = ""): Policy[] { + let policies: Policy[] = []; + const policyNodes = store.getQuads(null, namedNode("http://www.w3.org/1999/02/22-rdf-syntax-ns#type"), namedNode("http://www.w3.org/ns/odrl/2/Agreement"), null); + const policyIds = policyNodes.map(quad => quad.subject.value); + + policyIds.forEach(polId => { + const policy: Policy = { + id: polId, + rules: [], + type: 'Agreement' + }; + + const permissionNodes = store.getObjects(namedNode(polId), ODRL("permission"), null); + const permissionIds = permissionNodes.map(node => node.value); + permissionIds.forEach(permId => { + const permNamedNode = namedNode(permId); + const actionNodes = store.getObjects(permNamedNode, ODRL("action"), null); + const targetNodes = store.getObjects(permNamedNode, ODRL("target"), null); + const assigneeNodes = store.getObjects(permNamedNode, ODRL("assignee"), null); + const assignerNodes = store.getObjects(permNamedNode, ODRL("assigner"), null); + const constraintNodes = store.getObjects(permNamedNode, ODRL("constraint"), null); + + const actions = actionNodes.map(node => node.value); + const targets = targetNodes.map(node => node.value); + const assignees = assigneeNodes.map(node => node.value); + const assigners = assignerNodes.map(node => node.value); + const constraintIds = constraintNodes.map(node => node.value); + + const permission: Rule = { + id: permId, + type: 'Permission', + subjectId: assignees[0], + action: actions, + resourceIdentifier: targets[0], + constraint: [] } + + constraintIds.forEach(constrId =>{ + const constrNamedNode = namedNode(constrId); + const leftOperandNodes = store.getObjects(constrNamedNode, ODRL("leftOperand"), null); + const operatorNodes = store.getObjects(constrNamedNode, ODRL("operator"), null); + const rightOperandNodes = store.getObjects(constrNamedNode, ODRL("rightOperand"), null); + + const leftOperand = leftOperandNodes[0]?.value; + const operator = operatorNodes[0]?.value; + const rightOperand = rightOperandNodes.map(node => node.value); + + const constraint: Constraint = { + leftOperand: leftOperand, + operator: operator, + rightOperand: rightOperand + }; + + permission.constraint.push(constraint); + }); + policy.rules.push(permission); + }); + policies.push(policy); + }); + + return policies; + } + /** + * Transform policy object to a turtle-string + * @param webId + * @param policy + * @returns + */ + public policyToTurtle(webId: string, policy: Policy): string { + const ODRL = 'http://www.w3.org/ns/odrl/2/'; + const RDF_TYPE = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type'; + + const writer = new Writer({ + prefixes: { + odrl: ODRL, + ex: 'http://example.org/' } + }); - // Get assigner ID - const assigner = ruleStore.getQuads(null, ODRL('assigner'), null, null)[0].object.id; - if (!assigner) throw new Error("Corrupted Policy"); + const policyNode = namedNode(policy.id); - // Get assignee IDs - const subjects: string[] = ruleStore.getQuads(null, ODRL('assignee'), null, null).map(quad => quad.object.id); + // 1. Policy Type & UID + writer.addQuad(policyNode, namedNode(RDF_TYPE), namedNode(`${ODRL}${policy.type}`)); + writer.addQuad(policyNode, namedNode(`${ODRL}uid`), policyNode); - for (const target of ruleStore.getQuads(namedNode(ruleId), ODRL('target'), null, null).map(quad => quad.object.id)) { - // 2.3 Set the target's assigner if not already done - if (!idToTarget.has(target)) idToTarget.set(target, { assigner: assigner, targetUrl: target, policies: new Set(), rules: new Set() }); - idToTarget.get(target)!.policies.add(policyId); - idToTarget.get(target)!.rules.add(ruleId); + // 2. Rules + for (const rule of policy.rules) { + const ruleNode = namedNode(rule.id); + const ruleTypePredicate = rule.type.toLowerCase(); - // 2.4 Add the private assignee information for every target in the rule - for (const subject of subjects) { - // If target does not have subjects yet, set a default object - if (!idToTarget.get(target)!.private) - idToTarget.get(target)!.private = new Map(); + writer.addQuad(policyNode, namedNode(`${ODRL}${ruleTypePredicate}`), ruleNode); + writer.addQuad(ruleNode, namedNode(RDF_TYPE), namedNode(`${ODRL}${rule.type}`)); - // If subject is new to the target, set its permissions to a new set - if (!idToTarget.get(target)!.private!.has(subject)) - idToTarget.get(target)!.private!.set(subject, { uri: target, subject: subject, public: false, permissions: new Set() }); + if (rule.resourceIdentifier) { + writer.addQuad(ruleNode, namedNode(`${ODRL}target`), namedNode(rule.resourceIdentifier)); + } - // Add the permissions of this rule to the subject - const targetObject: ISpecificTargetInfo = idToTarget.get(target)!.private!.get(subject)!; - permissions.forEach(p => targetObject.permissions.add(p)); - } + if (rule.subjectId) { + writer.addQuad(ruleNode, namedNode(`${ODRL}assignee`), namedNode(rule.subjectId)); + } - if (subjects.length === 0) { - // If there is no public permission set, add one - if (!idToTarget.get(target)!.public) - idToTarget.get(target)!.public = { uri: target, public: true, subject: "", permissions: new Set() }; + writer.addQuad(ruleNode, namedNode(`${ODRL}assigner`), namedNode(webId)); - // Add the permissions to the set - const publicPermissions: Set = idToTarget.get(target)!.public!.permissions; - permissions.forEach(p => publicPermissions.add(p)); - } + if (rule.action && rule.action.length > 0) { + for (const act of rule.action) { + const actionURI = act.startsWith('http') ? act : `${ODRL}${act}`; + writer.addQuad(ruleNode, namedNode(`${ODRL}action`), namedNode(actionURI)); + } } - } - // Return the list of target info objects - return Array.from(idToTarget.values()); - } + // 3. Constraints + if (rule.constraint && rule.constraint.length > 0) { + for (let i = 0; i < rule.constraint.length; i++) { + const constraint = rule.constraint[i]; + const constraintNode = namedNode(`${rule.id}/constraint/${i + 1}`); - // Return the subject -> permissions relation for a target - public permissionsForOneResource(resourceUrl: string, store: Store): TargetSubjects { - const targets = this.ownedPoliciesToObject(store, resourceUrl); + writer.addQuad(ruleNode, namedNode(`${ODRL}constraint`), constraintNode); + writer.addQuad(constraintNode, namedNode(RDF_TYPE), namedNode(`${ODRL}Constraint`)); - // Only return the target we need - const target = targets.filter(t => t.targetUrl === resourceUrl); + if (constraint.leftOperand) { + const leftUri = constraint.leftOperand.startsWith('http') + ? constraint.leftOperand + : `${ODRL}${constraint.leftOperand}`; + writer.addQuad(constraintNode, namedNode(`${ODRL}leftOperand`), namedNode(leftUri)); + } + + if (constraint.operator) { + const opUri = constraint.operator.startsWith('http') + ? constraint.operator + : `${ODRL}${constraint.operator}`; + writer.addQuad(constraintNode, namedNode(`${ODRL}operator`), namedNode(opUri)); + } + + if (constraint.rightOperand && constraint.rightOperand.length > 0) { + for (const operand of constraint.rightOperand) { + const isUri = operand.startsWith('http://') || operand.startsWith('https://'); + const rightValue = isUri ? namedNode(operand) : literal(operand); + writer.addQuad(constraintNode, namedNode(`${ODRL}rightOperand`), rightValue); + } + } + } + } + } - if (target.length > 1) console.warn("Something went wrong while getting the permissions for", resourceUrl); - // Handle empty subjects + let turtleText = ''; + writer.end((error, result) => { + if (error) throw error; + turtleText = result; + }); - return target[0]; + return turtleText; } } \ No newline at end of file diff --git a/controller/src/types/modules.ts b/controller/src/types/modules.ts index e1042d90..b25c0708 100644 --- a/controller/src/types/modules.ts +++ b/controller/src/types/modules.ts @@ -12,67 +12,27 @@ export type SubjectConfigs>> { setPodUrl(podUrl: string): Promise; unsetPodUrl(podUrl: string): void; - AccessRequest(): IAccessRequest; getLabelForSubject>(subject: T[K]): string; getOrCreateIndex(): Promise; - getItem>(resourceUrl: string, subject: SubjectType): Promise | undefined>; - addPermission>(resourceUrl: string, addedPermission: Permission, subject: SubjectType): Promise - removePermission>(resourceUrl: string, addedPermission: Permission, subject: SubjectType): Promise + + updatePolicy(updates: RuleUpdate[]): Promise; + getResourcePolicies(resourceUrl: string): Promise; + /** * Enables a the permissions for an existing subject * @throws Error if the item does not exist for the given subject */ enablePermissions>(resource: string, subject: SubjectType): Promise disablePermissions>(resource: string, subject: SubjectType): Promise - removeSubject>(resource: string, subject: SubjectType): Promise - /** - * Retrieve the permissions of the resources in this container. - * Will probably work for a resource, but not guaranteed. Use getItem for that - */ - getContainerPermissionList(containerUrl: string): Promise[]> - - getResourcePermissionList(resourceUrl: string): Promise> isSubjectSupported(subject: BaseSubject): IController>> // ! added for access requests - requestAccess(permission: { action: string, resource: string }): Promise; + requestAccess(permission: { accessRequest: AccessRequest}): Promise; handleAccessRequest(requestId: string, status: string): Promise; getAccessRequests(): Promise<{ asRequestingParty: AccessRequest[]; asResourceOwner: AccessRequest[]; }>; } -export interface IAccessRequest { - /** - * Will return a tree structure starting from the containerUrl with the access requestable (container) resources - */ - getRequestableResources(containerUrl: string): Promise - - /** - * Checks if access to the resource is possible - * This should be based on the content of the resources.json file - */ - canRequestAccessToResource(resourceUrl: string): Promise - /** - * Adds a resource to the shareable resource list (resources.json) - */ - allowAccessRequest(resourceUrl: string): Promise - /** - * Removes a resource from the shareable resource list (resources.json) - */ - disallowAccessRequest(resourceUrl: string): Promise - - // Notifications - sendRequestNotification(originWebId: string, resources: string[], permissions: Permission[]): Promise; - sendResponseNotification(type: "accept" | "reject", message: AccessRequestMessage): Promise; - - loadAccessRequests(): Promise; - loadRequestResponses(): Promise; - /** - * Remove the given message resource from the inbox - */ - removeRequest(messageUrl: string): Promise; -} - export interface IInboxConstructor { new(filePath: string): IInbox } @@ -132,19 +92,7 @@ export interface IPermissionManager>> { // Does not update the index file editPermissions>(resource: string, item: IndexItem, subject: T[K], permissions: Permission[]): Promise deletePermissions>(resource: string, subject: T[K], permissions: Permission[]): Promise - getRemotePermissions>(resourceUrl: string): Promise[]> - /** - * Retrieve the permissions of the resources in this container. - * It will add the skipped resources to the returning object but without the permissions assignments. - * As this is necessary to clean-up the index - * Will probably work for a resource, but not guaranteed. Use getRemotePermissions for that - */ - getContainerPermissionList(containerUrl: string, resourceToSkip?: string[]): Promise[]> - /** - * This indicates if the underlying SDK automatically removes the entry from the SDK if all permissions are revoked - */ shouldDeleteOnAllRevoked(): boolean - getTargetPermissionsForUser(assignerId: string, assigneeId: string, targetId: string): Promise; type: string; } @@ -154,7 +102,7 @@ export interface IPolicy { id: string; } -export type RuleType = 'permission' | 'prohibition' | 'duty'; +export type RuleType = 'Permission' | 'Prohibition'; // Temporal (?) interface to represent a rule within a policy export interface IRule { @@ -177,11 +125,19 @@ export interface IRule { id: string; } +export interface Constraint { + leftOperand: string; + operator: string; + rightOperand: string[]; +} + + // interface to represent access requests export interface AccessRequest { uid: string; target: string; - action: string; + actions: string[]; + constraint: Constraint[]; requestingParty: string; status: string; } @@ -221,3 +177,29 @@ export interface TargetSubjects { // The rules referring to this target rules: Set; } + +export type RuleUpdateType = 'add' | 'edit' | 'remove'; + +export interface RuleUpdate { + updateType: RuleUpdateType; + rule: Rule; + policyId: string | null +} + + +export interface Rule { + id: string; + type: RuleType; + subjectId: string; + action: string[]; + resourceIdentifier: string; + constraint: Constraint[] +} + +export interface Policy { + id: string; + rules: Rule[]; + type: PolicyType; +} + +export type PolicyType = 'Agreement' | 'EvaluationRequest'; diff --git a/documentation/access-requests.md b/documentation/access-requests.md new file mode 100644 index 00000000..22dec750 --- /dev/null +++ b/documentation/access-requests.md @@ -0,0 +1,157 @@ +# Seeding Loama and UMA + +These commands automate the creation of local policies and access requests between two test accounts (Alice and Bob). + +## Environment configuration + +* **Identity Provider:** `http://localhost:3000` +* **Test Accounts:** `bob@example.org`, `alice@example.org` +* **Password:** `abc123` +* **UMA Version:** Repository `SolidLabResearch/user-managed-access` at commit `9b08f27` + +## Installation + +### Loama setup + +```sh +git clone git@github.com:SolidLabResearch/loama.git +cd loama +git checkout feat/odrl +nvm use 20 +npm install +npm run build +``` + +### UMA setup + +```sh +git clone git@github.com:SolidLabResearch/user-managed-access.git +cd user-managed-access +nvm use 22 +yarn install +yarn build +``` + +## Service startup + +### Loama application +1. Start the backend services by following the initialization script in the `SolidLabResearch/user-managed-access` README. +2. Launch the development server: + ```sh + npm run dev + ``` + +### UMA server +```sh +yarn start +``` + +## Mock policy injection + +If no policy matches the target resource, the user interface hides the access request. Run these commands to insert initial policies. + +### Windows (PowerShell) + + +Give Alice append access to Bob's readme, *(This is append since Alice requests read-permissions later in the demo)* +```powershell +curl.exe --location 'http://localhost:4000/uma/policies' --header 'Authorization: WebID http%3A%2F%2Flocalhost%3A3000%2Fbob%2Fprofile%2Fcard%23me' --header 'Content-Type: text/plain' --data-raw '@prefix ex: . @prefix odrl: . @prefix dct: . ex:policy a odrl:Agreement ; odrl:uid ex:policy ; odrl:permission ex:permission .ex:permission a odrl:Permission ; odrl:action odrl:append ; odrl:target ; odrl:assignee ; odrl:assigner .' +``` + +Give bob read acces to Alice's readme +```powershell +curl.exe --location 'http://localhost:4000/uma/policies' --header 'Authorization: WebID http%3A%2F%2Flocalhost%3A3000%2Falice%2Fprofile%2Fcard%23me' --header 'Content-Type: text/turtle' --data-raw '@prefix ex: . @prefix odrl: . @prefix dct: . ex:policy1 a odrl:Agreement ; odrl:uid ex:policy1 ; odrl:permission ex:permission1 . ex:permission1 a odrl:Permission ; odrl:action odrl:read ; odrl:target ; odrl:assignee ; odrl:assigner .' +``` + +### Unix (Bash/Zsh) + +Give Alice append access to Bob's readme, *(This is append since Alice requests read-permissions later in the demo)* +```sh +curl --location 'http://localhost:4000/uma/policies' --header 'Authorization: WebID http%3A%2F%2Flocalhost%3A3000%2Fbob%2Fprofile%2Fcard%23me' --header 'Content-Type: text/plain' --data-raw '@prefix ex: . @prefix odrl: . @prefix dct: . ex:policy a odrl:Agreement ; odrl:uid ex:policy ; odrl:permission ex:permission .ex:permission a odrl:Permission ; odrl:action odrl:append ; odrl:target ; odrl:assignee ; odrl:assigner .' +``` + +Give bob read acces to Alice's readme +```sh +curl --location 'http://localhost:4000/uma/policies' --header 'Authorization: WebID http%3A%2F%2Flocalhost%3A3000%2Falice%2Fprofile%2Fcard%23me' --header 'Content-Type: text/turtle' --data-raw '@prefix ex: . @prefix odrl: . @prefix dct: . ex:policy1 a odrl:Agreement ; odrl:uid ex:policy1 ; odrl:permission ex:permission1 . ex:permission1 a odrl:Permission ; odrl:action odrl:read ; odrl:target ; odrl:assignee ; odrl:assigner .' +``` + +## Verification workflows + +### Session setup +1. Open `http://localhost:5173/` in your standard browser window, use IDP `http://localhost:3000` and authenticate as Bob (`bob@example.org`, `abc123`). +2. Open a private/incognito browser window and authenticate as Alice (`alice@example.org`, `abc123`). + +### Testing the denial flow +1. From Alice's session, navigate to the **Request Access** tab and request access to Bob's resource `http://localhost:3000/bob/README`. +2. In Bob's session, navigate to the **Grant Access** tab. +3. Click **Deny**. +4. Confirm that the request is removed from Bob's pending list. + +### Testing the approval flow +1. From Alice's session, submit another access request to Bob's resource `http://localhost:3000/bob/README`. +2. In Bob's session under the **Grant Access** tab, click **Accept**. +3. Verify that the request appears in Alice's **Accepted** tab. +4. Verify that the request appears in Bob's **Accepted** tab. + +## Known bugs and limitations + +* **UI State Sync:** When you update a policy on a selected resource, the interface does not visually refresh until you manually deselect and reselect that resource. + +## Direct API validation + +### Fallback access request generation (PowerShell) +If the UI buttons fail to create an access request, force execution via the API: + +```powershell +curl.exe --% --location "http://localhost:4000/uma/requests" --header "Authorization: WebID http%3A%2F%2Flocalhost%3A3000%2Falice%2Fprofile%2Fcard%23me" --header "Content-Type: application/json" --data "{\"resource_id\": \"http://localhost:3000/bob/README\", \"resource_scopes\": [\"http://www.w3.org/ns/odrl/2/read\", \"http://www.w3.org/ns/odrl/2/write\"]}" +``` +```powershell +curl.exe --% --location "http://localhost:4000/uma/requests" --header "Authorization: WebID http%3A%2F%2Flocalhost%3A3000%2Fbob%2Fprofile%2Fcard%23me" --header "Content-Type: application/json" --data "{\"resource_id\": \"http://localhost:3000/alice/README\",\"resource_scopes\": [ \"http://www.w3.org/ns/odrl/2/read\" ]}" +``` + +### Request checking +You can evaluate state machine accuracy after an approval using the ``trustflows-client``. *Note we used v0.1.0-alpha.6* + +``` +npm install trustflows-client@0.1.0-alpha.6 +``` + +```ts +import { getDefaultAuth, configureDefaultAuth } from "trustflows-client"; + +async function runClientCredentialsFlow() { + configureDefaultAuth({ + persistTokens: false, + }); + + const auth = getDefaultAuth(); + + try { + console.log("Authenticating via Client Credentials..."); + + await auth.loginClientCredentials( + "http://localhost:3000/alice/profile/card#me", // Your WebID + "alice@example.org", // Account email + "abc123" // Account password + ); + + console.log("Authentication successful."); + + const authFetch = auth.createAuthFetch(); + + const targetUrl = "http://localhost:3000/bob/README"; + console.log(`Fetching: ${targetUrl}`); + + const response = await authFetch(targetUrl); + const data = await response.text(); + + console.log("Response status:", response.status); + console.log("Data payload:", data); + + } catch (error) { + console.error("Execution failed:", error); + } +} + +runClientCredentialsFlow(); +``` \ No newline at end of file diff --git a/loama/package.json b/loama/package.json index b0ac4c17..43c86fa0 100644 --- a/loama/package.json +++ b/loama/package.json @@ -31,6 +31,7 @@ "loama-controller": "^1.0.0", "pinia": "^2.2.2", "primevue": "^4.1.1", + "tom-select": "^2.6.2", "vite-svg-loader": "^5.1.0", "vue": "^3.4.29", "vue-router": "^4.3.3" diff --git a/loama/src/components/access-grants/AccessGrant.vue b/loama/src/components/access-grants/AccessGrant.vue index dc2d5edd..9c64f365 100644 --- a/loama/src/components/access-grants/AccessGrant.vue +++ b/loama/src/components/access-grants/AccessGrant.vue @@ -96,7 +96,6 @@ onBeforeUnmount(() => clearInterval(interval)); gap: 2rem; padding: 2rem; background-color: var(--off-white); - min-height: calc(100vh - var(--base-unit) * 14); } /* Shared card style */ diff --git a/loama/src/components/access-requests/AccessRequest.vue b/loama/src/components/access-requests/AccessRequest.vue index 863771ea..2e371490 100644 --- a/loama/src/components/access-requests/AccessRequest.vue +++ b/loama/src/components/access-requests/AccessRequest.vue @@ -1,8 +1,11 @@ - - + \ No newline at end of file diff --git a/loama/src/components/explorer/ExplorerBreadcrumbs.vue b/loama/src/components/explorer/ExplorerBreadcrumbs.vue deleted file mode 100644 index 20140351..00000000 --- a/loama/src/components/explorer/ExplorerBreadcrumbs.vue +++ /dev/null @@ -1,27 +0,0 @@ - - - - - diff --git a/loama/src/components/explorer/ExplorerEntity.vue b/loama/src/components/explorer/ExplorerEntity.vue deleted file mode 100644 index 9fbc19f0..00000000 --- a/loama/src/components/explorer/ExplorerEntity.vue +++ /dev/null @@ -1,39 +0,0 @@ - - - - - \ No newline at end of file diff --git a/loama/src/components/explorer/ExplorerEntry.vue b/loama/src/components/explorer/ExplorerEntry.vue deleted file mode 100644 index e802dda6..00000000 --- a/loama/src/components/explorer/ExplorerEntry.vue +++ /dev/null @@ -1,55 +0,0 @@ - - - - - diff --git a/loama/src/components/explorer/FallbackExplorer.vue b/loama/src/components/explorer/FallbackExplorer.vue deleted file mode 100644 index f17d73f5..00000000 --- a/loama/src/components/explorer/FallbackExplorer.vue +++ /dev/null @@ -1,48 +0,0 @@ - - - - - \ No newline at end of file diff --git a/loama/src/components/explorer/NewSubject.vue b/loama/src/components/explorer/NewSubject.vue deleted file mode 100644 index df367fa0..00000000 --- a/loama/src/components/explorer/NewSubject.vue +++ /dev/null @@ -1,56 +0,0 @@ - - - - diff --git a/loama/src/components/explorer/ResourceExplorer.vue b/loama/src/components/explorer/ResourceExplorer.vue deleted file mode 100644 index 74737f7f..00000000 --- a/loama/src/components/explorer/ResourceExplorer.vue +++ /dev/null @@ -1,131 +0,0 @@ - - - - - diff --git a/loama/src/components/explorer/SelectedEntry.vue b/loama/src/components/explorer/SelectedEntry.vue deleted file mode 100644 index 5e508a5b..00000000 --- a/loama/src/components/explorer/SelectedEntry.vue +++ /dev/null @@ -1,122 +0,0 @@ - - - - - - diff --git a/loama/src/components/explorer/SubjectPermissionTable.vue b/loama/src/components/explorer/SubjectPermissionTable.vue deleted file mode 100644 index 4f4d5d7e..00000000 --- a/loama/src/components/explorer/SubjectPermissionTable.vue +++ /dev/null @@ -1,243 +0,0 @@ - - - - diff --git a/loama/src/components/layouts/HeaderLayout.vue b/loama/src/components/layouts/HeaderLayout.vue index b6526860..2503587b 100644 --- a/loama/src/components/layouts/HeaderLayout.vue +++ b/loama/src/components/layouts/HeaderLayout.vue @@ -1,7 +1,7 @@