diff --git a/controller/src/classes/OdrlController.ts b/controller/src/classes/OdrlController.ts index b603a54..7c911e8 100644 --- a/controller/src/classes/OdrlController.ts +++ b/controller/src/classes/OdrlController.ts @@ -53,6 +53,11 @@ export class ODRLController { + return new ODRLPolicyService(this.authorizationServerURL).fetchResources(); + } + + /** * Updates existing policies according to present rule changes * @param updates All requested changes @@ -164,6 +169,6 @@ export class ODRLController { - return new ODRLAccessRequestService(this.authorizationServerURL).retrieveAccessRequests(); + return new ODRLAccessRequestService(this.authorizationServerURL).retrieveAccessRequests(await this.getResources()); } } diff --git a/controller/src/classes/utils/OdrlAccessRequestService.ts b/controller/src/classes/utils/OdrlAccessRequestService.ts index 5c1408b..9325cb1 100644 --- a/controller/src/classes/utils/OdrlAccessRequestService.ts +++ b/controller/src/classes/utils/OdrlAccessRequestService.ts @@ -85,14 +85,8 @@ export class ODRLAccessRequestService { /** * Retrieve all access requests related to the given resource owner or requesting party */ - public retrieveAccessRequests = async (): Promise<{ asRequestingParty: AccessRequest[], asResourceOwner: AccessRequest[] }> => { - const [ requestsResponse, policiesResponse ] = await Promise.all( - ['/requests', '/policies'].map((endpoint) => authenticatedFetch( - `${this.authorizationServerURL}${endpoint}`, { - method: 'GET', - } - )) - ); + public retrieveAccessRequests = async (owned: string[]): Promise<{ asRequestingParty: AccessRequest[], asResourceOwner: AccessRequest[] }> => { + const requestsResponse = await authenticatedFetch(`${this.authorizationServerURL}/requests`); if (requestsResponse.status === 404) return { asRequestingParty: [], @@ -100,10 +94,8 @@ export class ODRLAccessRequestService { } const requestsText = await requestsResponse.text() || ''; - const policiesText = await policiesResponse.text() || ''; const requestsStore = new Store(this.parser.parse(requestsText)); - const policiesStore = new Store(this.parser.parse(policiesText)); const id = getLoggedInIdentifier(); const requestingPartyBindings = await this.queryEngine.queryBindings( @@ -111,7 +103,7 @@ export class ODRLAccessRequestService { ); const resourceOwnerBindings = await this.queryEngine.queryBindings( - this.accessRequestForResourceOwner(id), { sources: [requestsStore, policiesStore] } + this.accessRequestForResourceOwner(owned), { sources: [requestsStore] } ); return { @@ -207,10 +199,12 @@ export class ODRLAccessRequestService { (GROUP_CONCAT(DISTINCT ?action; separator=",") AS ?actions) ?constraintUri ?leftOperand ?operator ?rightOperand WHERE { + VALUES ?requestingParty { <${requestingPartyID}> } + ?uid a sotw:EvaluationRequest ; sotw:requestedTarget ?target ; sotw:requestedAction ?action ; - sotw:requestingParty <${requestingPartyID}> ; + sotw:requestingParty ?requestingParty ; sotw:requestStatus ?status . OPTIONAL { @@ -228,14 +222,9 @@ export class ODRLAccessRequestService { * Fetches all access requests controlled by a given WebId * Returns a SPARQL query string * - * An ID being the resource owner is determined by there being a policy owned by this ID targeting this resource. - * If there is no policy yet for this resource, - * this function will not be able to determine that the given ID is the owner. - * - * @param resourceOwnerID * @returns */ - private readonly accessRequestForResourceOwner = (resourceOwnerID: string): string => ` + private readonly accessRequestForResourceOwner = (owned: string[]): string => ` PREFIX ex: PREFIX sotw: PREFIX odrl: @@ -244,9 +233,8 @@ export class ODRLAccessRequestService { (GROUP_CONCAT(DISTINCT ?action; separator=",") AS ?actions) ?constraintUri ?leftOperand ?operator ?rightOperand WHERE { - ?policy odrl:target ?target ; - odrl:assigner <${resourceOwnerID}> . - + VALUES ?target { ${owned.map(o => `<${o}>`).join(' ')} } + ?uid a sotw:EvaluationRequest ; sotw:requestedTarget ?target ; sotw:requestedAction ?action ; diff --git a/controller/src/classes/utils/OdrlPolicyService.ts b/controller/src/classes/utils/OdrlPolicyService.ts index 986be95..8461e0a 100644 --- a/controller/src/classes/utils/OdrlPolicyService.ts +++ b/controller/src/classes/utils/OdrlPolicyService.ts @@ -24,6 +24,19 @@ export class ODRLPolicyService { return result; } + public async fetchResources(): Promise { + // TODO: should use well-known URL + const response = await authenticatedFetch(`${this.authorizationServerURL}/resources/`); + if (!response.ok) { + throw new Error(response.statusText); + } + const data = await response.json(); + if (!Array.isArray(data)) { + throw new Error('Expected an array of resources'); + } + return data; + } + public async fetchPolicies() { // Get all our policies const response = await authenticatedFetch(UMA_URL(this.authorizationServerURL), { diff --git a/controller/src/types/modules.ts b/controller/src/types/modules.ts index a3c5bea..7e4006e 100644 --- a/controller/src/types/modules.ts +++ b/controller/src/types/modules.ts @@ -15,6 +15,11 @@ export interface IController>(subject: T[K]): string; getOrCreateIndex(): Promise; + /** + * Returns all resources registered for the authorized user. + */ + getResources(): Promise; + updatePolicy(updates: RuleUpdate[]): Promise; getResourcePolicies(resourceUrl: string): Promise; diff --git a/loama/src/components/policy-view/RuleForm.vue b/loama/src/components/policy-view/RuleForm.vue index ece20f3..a41ec26 100644 --- a/loama/src/components/policy-view/RuleForm.vue +++ b/loama/src/components/policy-view/RuleForm.vue @@ -7,8 +7,9 @@ placeholder="webId of the person or app" /> - + + Access level @@ -58,7 +59,7 @@ import { computed, onMounted, reactive, ref, watch } from 'vue'; import type { Rule, Constraint, RuleUpdate } from 'loama-controller'; import { levelForAction } from '@/lib/Accesslevel'; import { loadPurposes, PURPOSES } from '@/lib/Purposes'; -import { useTomSelectMultiple } from '@/lib/Usetomselect' +import { useTomSelectMultiple, useTomSelectSingle } from '@/lib/Usetomselect' import { usePodStore } from '@/lib/state'; import { useControllerStore } from '@/stores/useControllerStore'; import 'tom-select/dist/css/tom-select.css'; @@ -96,6 +97,7 @@ const form = reactive({ const errors = ref({ resourceIdentifier: false, action: false }); const confirmingDelete = ref(false); +const resourcesLoaded = ref(false); const purposesLoaded = ref(false); watch(() => form.action.length, (newLength) => { @@ -114,12 +116,27 @@ const subjectEditable = computed(() => { return editable.value && selectedPolicy.value?.type !== 'Agreement'; }); +const resourceSelectEl = ref(null); +const resourceModel = computed({ + get: () => form.resourceIdentifier, + set: (value) => { form.resourceIdentifier = value; }, +}); + const purposeSelectEl = ref(null); const purposesModel = computed({ get: () => form.purposes, set: (value) => { form.purposes = value; }, }); +useTomSelectSingle(resourceSelectEl, resourceModel, editable, () => ({ + options: podStore.resources.map((r) => ({ value: r, text: r })), + valueField: 'value', + labelField: 'text', + searchField: ['text'], + placeholder: 'Search resources…', + create: false, +})); + useTomSelectMultiple(purposeSelectEl, purposesModel, editable, { options: PURPOSES.options, optgroups: PURPOSES.groups, @@ -200,6 +217,8 @@ watch(() => [props.rule, props.mode], resetForm, { immediate: true }); onMounted(async () => { await loadPurposes(); purposesLoaded.value = true; + await podStore.loadResources(controllerStore.current); + resourcesLoaded.value = true; resetForm(); }); diff --git a/loama/src/lib/Usetomselect.ts b/loama/src/lib/Usetomselect.ts index 4fe3902..ca0ace7 100644 --- a/loama/src/lib/Usetomselect.ts +++ b/loama/src/lib/Usetomselect.ts @@ -12,10 +12,10 @@ import TomSelect from 'tom-select'; * @param settings TomSelect settings (options, optgroups, render, etc.) */ export function useTomSelectMultiple( - elRef: Ref, - modelValue: Ref, - editable: Ref, - settings: Record = {}, + elRef: Ref, + modelValue: Ref, + editable: Ref, + settings: Record = {}, ) { let instance: TomSelect | null = null; @@ -24,8 +24,8 @@ export function useTomSelectMultiple( const current = instance.getValue(); const currentArray = Array.isArray(current) ? current : [current]; const same = - currentArray.length === value.length && - currentArray.every((v, i) => v === value[i]); + currentArray.length === value.length && + currentArray.every((v, i) => v === value[i]); if (!same) instance.setValue(value, true); // true = silent, avoids feedback loop }; @@ -59,16 +59,89 @@ export function useTomSelectMultiple( // that starts false), so react to the ref itself rather than only // creating the instance once in onMounted. watch( - elRef, - (el) => { - destroy(); - if (el) create(el); - }, - { immediate: true }, + elRef, + (el) => { + destroy(); + if (el) create(el); + }, + { immediate: true }, ); watch(modelValue, applyValue, { deep: true }); watch(editable, applyEditable); onBeforeUnmount(destroy); -} \ No newline at end of file +} + +/** + * Same idea as useTomSelectMultiple, but binds to a plain element + * @param modelValue reactive single selected value, kept in sync both ways + * @param editable reactive boolean, toggles TomSelect enable/disable + * @param settings TomSelect settings (options, optgroups, render, etc.) + */ +export function useTomSelectSingle( + elRef: Ref, + modelValue: Ref, + editable: Ref, + settings: Record | (() => Record) = {}, +) { + let instance: TomSelect | null = null; + + // Support a factory so option lists reflect data that's still loading + // (e.g. async store data) at the time useTomSelectSingle() is called, + // rather than whatever snapshot existed during