From f41b664af205680635f8aa3d7bbdbbda70200e1e Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Tue, 12 May 2026 09:42:09 +0200 Subject: [PATCH] feat: Use a ReadOnlyStore for returning policy data This prevents unnecessarily copying all rules when trying to read data --- packages/uma/src/controller/BaseController.ts | 20 ++++++------- .../authorizers/SimpleOdrlAuthorizer.ts | 6 ++-- packages/uma/src/routes/Collection.ts | 4 +-- .../uma/src/routes/ResourceRegistration.ts | 10 +++---- .../ucp/storage/DirectoryUCRulesStorage.ts | 10 +++---- .../src/ucp/storage/MemoryUCRulesStorage.ts | 12 ++++---- .../uma/src/ucp/storage/UCRulesStorage.ts | 29 ++++++++++++++++--- packages/uma/src/ucp/util/Util.ts | 3 +- packages/uma/src/util/ConvertUtil.ts | 5 ++-- .../src/util/routeSpecific/sanitizeUtil.ts | 4 +-- test/integration/Base.test.ts | 2 -- 11 files changed, 63 insertions(+), 42 deletions(-) diff --git a/packages/uma/src/controller/BaseController.ts b/packages/uma/src/controller/BaseController.ts index 3dcbbf3c..cd02f153 100644 --- a/packages/uma/src/controller/BaseController.ts +++ b/packages/uma/src/controller/BaseController.ts @@ -1,5 +1,5 @@ import { ConflictHttpError } from '@solid/community-server'; -import { UCRulesStorage } from "../ucp/storage/UCRulesStorage"; +import { ReadOnlyStore, UCRulesStorage } from '../ucp/storage/UCRulesStorage'; import { getLoggerFor } from 'global-logger-factory'; import { Parser, Store } from 'n3'; import { writeStore } from "../util/ConvertUtil"; @@ -18,8 +18,8 @@ export abstract class BaseController { protected readonly store: UCRulesStorage, protected sanitizePost: (store: Store, clientID: string) => Promise<{ result: Store, id: string }>, protected sanitizeDelete: (store: Store, entityID: string, clientID: string) => Promise, - protected sanitizeGets: (store: Store, clientID: string) => Promise, - protected sanitizeGet: (store: Store, entityID: string, clientID: string) => Promise, + protected sanitizeGets: (store: ReadOnlyStore, clientID: string) => Promise, + protected sanitizeGet: (store: ReadOnlyStore, entityID: string, clientID: string) => Promise, protected sanitizePatch: (store: Store, entityID: string, clientID: string, patchInformation: string) => Promise ) { } @@ -30,7 +30,7 @@ export abstract class BaseController { * @returns results serialized in Turtle and status code 200, * or an empty body with status 404 if nothing was found */ - private async get(sanitizeGet: () => Promise): Promise<{ message: string, status: number }> { + private async get(sanitizeGet: () => Promise): Promise<{ message: string, status: number }> { const store = await sanitizeGet(); const message = store.size > 0 ? await writeStore(store) : ''; @@ -92,7 +92,7 @@ export abstract class BaseController { * - 204 if deletion was successful */ public async deleteEntity(entityID: string, clientID: string): Promise<{ status: number }> { - const filteredStore = new Store(await this.store.getStore()); + const filteredStore = new Store(await this.store.getStore() as Store); await this.sanitizeDelete(filteredStore, entityID, clientID); const diff = (await this.store.getStore()).difference(filteredStore); await this.store.removeData(diff as Store); @@ -114,12 +114,12 @@ export abstract class BaseController { */ public async patchEntity(entityID: string, patchInformation: string, clientID: string, isolate: boolean = true): Promise> { let response: HttpHandlerResponse = { status: 204, body: '' }; - let filteredStore = new Store(await this.store.getStore()); + let filteredStore = new Store(await this.store.getStore() as Store); let omitStore: Store; if (isolate) { // requires isolating all information about the entity provided, as e.g. the patchinformation has a query to be executed - filteredStore = await this.sanitizeGet(filteredStore, entityID, clientID); - omitStore = new Store(await this.store.getStore()); + filteredStore = await this.sanitizeGet(filteredStore, entityID, clientID) as Store; + omitStore = new Store(await this.store.getStore() as Store); omitStore.removeQuads([ ...filteredStore]); } @@ -130,14 +130,14 @@ export abstract class BaseController { // * bonus: filters out extra quads // ! drawback: PATCH may still be used to DELETE all information about the entity // TODO: check if PATCH is smth we want for all resources, make patchEntity optional otherwise - filteredStore = await this.sanitizeGet(filteredStore, entityID, clientID) || filteredStore; + filteredStore = await this.sanitizeGet(filteredStore, entityID, clientID) as Store || filteredStore; omitStore!.addAll(filteredStore); filteredStore = omitStore!; } const originalStore = await this.store.getStore(); const remove = originalStore.difference(filteredStore); - const add = filteredStore.difference(originalStore); + const add = filteredStore.difference(originalStore as Store); if (remove.size > 0) { await this.store.removeData(remove as Store); diff --git a/packages/uma/src/policies/authorizers/SimpleOdrlAuthorizer.ts b/packages/uma/src/policies/authorizers/SimpleOdrlAuthorizer.ts index f64fdc35..3f90620e 100644 --- a/packages/uma/src/policies/authorizers/SimpleOdrlAuthorizer.ts +++ b/packages/uma/src/policies/authorizers/SimpleOdrlAuthorizer.ts @@ -4,7 +4,7 @@ import { DataFactory as DF, Quad_Subject, Store } from 'n3'; import { ODRL } from 'odrl-evaluator'; import { CLIENTID, WEBID } from '../../credentials/Claims'; import { ClaimSet } from '../../credentials/ClaimSet'; -import { UCRulesStorage } from '../../ucp/storage/UCRulesStorage'; +import { ReadOnlyStore, UCRulesStorage } from '../../ucp/storage/UCRulesStorage'; import { Permission } from '../../views/Permission'; import { Authorizer } from './Authorizer'; @@ -63,7 +63,7 @@ export class SimpleOdrlAuthorizer implements Authorizer { return permissions; } - protected getPermissions(policies: Store, claims: ClaimSet, resource: string, scope: string): + protected getPermissions(policies: ReadOnlyStore, claims: ClaimSet, resource: string, scope: string): Permission[] | undefined { this.logger.info(`Evaluating Request ${scope}, ${resource} with claims ${JSON.stringify(claims)}`); const targets = [ DF.namedNode(resource), ...policies.getObjects(resource, ODRL.terms.partOf, null)]; @@ -153,7 +153,7 @@ export class SimpleOdrlAuthorizer implements Authorizer { * and undefined if any constraint is too complex to evaluate. * Only supports purpose (for client ID) and dateTime constraints. */ - protected validateConstraints(rule: Quad_Subject, policies: Store, claims: ClaimSet): boolean | undefined { + protected validateConstraints(rule: Quad_Subject, policies: ReadOnlyStore, claims: ClaimSet): boolean | undefined { const constraints = policies.getObjects(rule, ODRL.terms.constraint, null).map(constraint => ({ leftOperand: policies.getObjects(constraint, ODRL.terms.leftOperand, null)[0], operator: policies.getObjects(constraint, ODRL.terms.operator, null)[0], diff --git a/packages/uma/src/routes/Collection.ts b/packages/uma/src/routes/Collection.ts index 1226def8..7e94d993 100644 --- a/packages/uma/src/routes/Collection.ts +++ b/packages/uma/src/routes/Collection.ts @@ -16,7 +16,7 @@ import { ODRL } from 'odrl-evaluator'; import { WEBID } from '../credentials/Claims'; import { CredentialParser } from '../credentials/CredentialParser'; import { Verifier } from '../credentials/verify/Verifier'; -import { UCRulesStorage } from '../ucp/storage/UCRulesStorage'; +import { ReadOnlyStore, UCRulesStorage } from '../ucp/storage/UCRulesStorage'; import { DC, ODRL_P, OWL } from '../ucp/util/Vocabularies'; import { writeStore } from '../util/ConvertUtil'; import { @@ -178,7 +178,7 @@ export class CollectionRequestHandler extends HttpHandler { /** * Verifies if the user is allowed to modify the given collection. */ - protected async verifyOwnership(subject: NamedNode, userId: string, store?: Store): Promise { + protected async verifyOwnership(subject: NamedNode, userId: string, store?: ReadOnlyStore): Promise { const userNode = DF.namedNode(userId); store = store ?? await this.policies.getStore(); diff --git a/packages/uma/src/routes/ResourceRegistration.ts b/packages/uma/src/routes/ResourceRegistration.ts index eaf50d49..3d7c4db6 100644 --- a/packages/uma/src/routes/ResourceRegistration.ts +++ b/packages/uma/src/routes/ResourceRegistration.ts @@ -13,7 +13,7 @@ import { import { getLoggerFor } from 'global-logger-factory'; import { DataFactory as DF, NamedNode, Quad, Quad_Subject, Store } from 'n3'; import { randomUUID } from 'node:crypto'; -import { UCRulesStorage } from '../ucp/storage/UCRulesStorage'; +import { ReadOnlyStore, UCRulesStorage } from '../ucp/storage/UCRulesStorage'; import { DC, ODRL, ODRL_P, OWL } from '../ucp/util/Vocabularies'; import { HttpHandler, @@ -248,7 +248,7 @@ export class ResourceRegistrationRequestHandler extends HttpHandler { * @param previous - The previous {@link ResourceDescription}, in case this is an update. */ protected async updateCollections( - policyStore: Store, + policyStore: ReadOnlyStore, id: string, owner: string, description: ResourceDescription, @@ -307,7 +307,7 @@ export class ResourceRegistrationRequestHandler extends HttpHandler { * @param previous - The previous {@link ResourceDescription}, in case this is an update. */ protected async updateRelations( - policyStore: Store, + policyStore: ReadOnlyStore, id: string, description: ResourceDescription, previous?: ResourceDescription @@ -417,7 +417,7 @@ export class ResourceRegistrationRequestHandler extends HttpHandler { * @param entries - {@link CollectionMetadata} objects to parse. * @param policyStore - {@link Store} with the relevant triples to update. */ - protected generatePartOfTriples(part: NamedNode, entries: CollectionMetadata[], policyStore: Store): Quad[] { + protected generatePartOfTriples(part: NamedNode, entries: CollectionMetadata[], policyStore: ReadOnlyStore): Quad[] { const quads: Quad[] = []; for (const entry of entries) { const collectionIds = this.findCollectionIds(entry, policyStore); @@ -439,7 +439,7 @@ export class ResourceRegistrationRequestHandler extends HttpHandler { * @param entry - Relevant {@link CollectionMetadata}. * @param data - {@link Store} in which to find the matching triples. */ - protected findCollectionIds(entry: CollectionMetadata, data: Store): Quad_Subject[] { + protected findCollectionIds(entry: CollectionMetadata, data: ReadOnlyStore): Quad_Subject[] { const sourceMatches = data.getSubjects(ODRL.terms.source, entry.source, null); if (entry.reverse) { const blankQuads = sourceMatches.flatMap((subject): Quad[] => diff --git a/packages/uma/src/ucp/storage/DirectoryUCRulesStorage.ts b/packages/uma/src/ucp/storage/DirectoryUCRulesStorage.ts index b175bdc1..ec6eeb19 100644 --- a/packages/uma/src/ucp/storage/DirectoryUCRulesStorage.ts +++ b/packages/uma/src/ucp/storage/DirectoryUCRulesStorage.ts @@ -1,5 +1,5 @@ import { extractQuadsRecursive } from '../util/Util'; -import { UCRulesStorage } from "./UCRulesStorage"; +import { ReadOnlyStore, UCRulesStorage } from './UCRulesStorage'; import * as path from 'path' import * as fs from 'fs' import { Parser, Store, Writer } from 'n3'; @@ -28,9 +28,9 @@ export class DirectoryUCRulesStorage implements UCRulesStorage { this.baseIRI = baseIRI; } - public async getStore(): Promise { + public async getStore(): Promise { if (this.filesRead) { - return new Store(this.store); + return this.store; } const parser = new Parser({ baseIRI: this.baseIRI }); @@ -51,13 +51,13 @@ export class DirectoryUCRulesStorage implements UCRulesStorage { } - public async removeData(data: Store): Promise { + public async removeData(data: ReadOnlyStore): Promise { // Make sure the files have been read into memory await this.getStore(); this.store.removeQuads(data.getQuads(null, null, null, null)); } - public async getRule(identifier: string): Promise { + public async getRule(identifier: string): Promise { const allRules = await this.getStore() return extractQuadsRecursive(allRules, identifier); } diff --git a/packages/uma/src/ucp/storage/MemoryUCRulesStorage.ts b/packages/uma/src/ucp/storage/MemoryUCRulesStorage.ts index 06c9ef44..0c7abfc0 100644 --- a/packages/uma/src/ucp/storage/MemoryUCRulesStorage.ts +++ b/packages/uma/src/ucp/storage/MemoryUCRulesStorage.ts @@ -1,6 +1,6 @@ import { DataFactory, Store } from 'n3'; import { extractQuadsRecursive } from '../util/Util'; -import { UCRulesStorage } from './UCRulesStorage'; +import { ReadOnlyStore, UCRulesStorage } from './UCRulesStorage'; const { namedNode } = DataFactory; @@ -11,16 +11,16 @@ export class MemoryUCRulesStorage implements UCRulesStorage { this.store = new Store(); } - public async getStore(): Promise { - return new Store(this.store); + public async getStore(): Promise { + return this.store; } - public async addRule(rule: Store): Promise { + public async addRule(rule: ReadOnlyStore): Promise { this.store.addQuads(rule.getQuads(null, null, null, null)) } - public async getRule(identifier: string): Promise { + public async getRule(identifier: string): Promise { // currently doesn't check whether it is actually an odrl:rule return extractQuadsRecursive(this.store, identifier) } @@ -36,7 +36,7 @@ export class MemoryUCRulesStorage implements UCRulesStorage { this.deleteRule(ruleID); } - public async removeData(data: Store): Promise { + public async removeData(data: ReadOnlyStore): Promise { this.store.removeQuads(data.getQuads(null, null, null, null)); } } diff --git a/packages/uma/src/ucp/storage/UCRulesStorage.ts b/packages/uma/src/ucp/storage/UCRulesStorage.ts index 22d7c4dc..c4a69b19 100644 --- a/packages/uma/src/ucp/storage/UCRulesStorage.ts +++ b/packages/uma/src/ucp/storage/UCRulesStorage.ts @@ -1,19 +1,40 @@ import { Store } from "n3"; +/** + * A read-only view of an N3 Store. + * All write/mutation methods are excluded. + */ +export type ReadOnlyStore = Omit< + Store, + | 'addAll' + | 'deleteMatches' + | 'add' + | 'addQuad' + | 'addQuads' + | 'delete' + | 'import' + | 'removeQuad' + | 'removeQuads' + | 'remove' + | 'removeMatches' + | 'deleteGraph' + | 'clear' +>; + export interface UCRulesStorage { - getStore: () => Promise; + getStore: () => Promise; /** * Add a single Usage Control Rule to the storage * @param rule * @returns */ - addRule: (rule: Store) => Promise; + addRule: (rule: ReadOnlyStore) => Promise; /** * Get a Usage Control Rule from the storage * @param identifier * @returns */ - getRule: (identifier: string) => Promise; + getRule: (identifier: string) => Promise; /** * Delete a Usage Control Rule from the storage * @param identifier @@ -30,5 +51,5 @@ export interface UCRulesStorage { * Removes specific triples from the storage. * @param data */ - removeData: (data: Store) => Promise; + removeData: (data: ReadOnlyStore) => Promise; } diff --git a/packages/uma/src/ucp/util/Util.ts b/packages/uma/src/ucp/util/Util.ts index e4f82f9a..a5e701f5 100644 --- a/packages/uma/src/ucp/util/Util.ts +++ b/packages/uma/src/ucp/util/Util.ts @@ -1,4 +1,5 @@ import { Store } from "n3"; +import { ReadOnlyStore } from '../storage/UCRulesStorage'; /** * A recursive search algorithm that gives all quads that a subject can reach (working with circles) @@ -7,7 +8,7 @@ import { Store } from "n3"; * @param subjectIRI * @param existing IRIs that already have done the recursive search (IRIs in there must not be searched for again) */ -export function extractQuadsRecursive(store: Store, subjectIRI: string, existing?: string[]): Store { +export function extractQuadsRecursive(store: ReadOnlyStore, subjectIRI: string, existing?: string[]): Store { const tempStore = new Store(); const subjectIRIQuads = store.getQuads(subjectIRI, null, null, null); diff --git a/packages/uma/src/util/ConvertUtil.ts b/packages/uma/src/util/ConvertUtil.ts index ea4c2b68..00679ec9 100644 --- a/packages/uma/src/util/ConvertUtil.ts +++ b/packages/uma/src/util/ConvertUtil.ts @@ -1,6 +1,7 @@ -import { Prefixes, Store, Writer } from 'n3'; +import { Prefixes, Writer } from 'n3'; import { parse, stringify } from 'node:querystring'; import { NamedNode } from '@rdfjs/types'; +import { ReadOnlyStore } from '../ucp/storage/UCRulesStorage'; /** * Converts a x-www-form-urlencoded string to a JSON object. @@ -49,7 +50,7 @@ export function isIri(input: string): boolean { /** * Write an N3 store to a string (in turtle format) */ -export async function writeStore(store: Store, prefixes: Prefixes = {}): Promise { +export async function writeStore(store: ReadOnlyStore, prefixes: Prefixes = {}): Promise { const writer = new Writer({ format: 'text/turtle', prefixes }); writer.addQuads(store.getQuads(null, null, null, null)); diff --git a/packages/uma/src/util/routeSpecific/sanitizeUtil.ts b/packages/uma/src/util/routeSpecific/sanitizeUtil.ts index 7a1425a7..645b8ae0 100644 --- a/packages/uma/src/util/routeSpecific/sanitizeUtil.ts +++ b/packages/uma/src/util/routeSpecific/sanitizeUtil.ts @@ -1,4 +1,4 @@ -import { Store } from "n3"; +import { ReadOnlyStore } from '../../ucp/storage/UCRulesStorage'; /** * Check whether all subjects in the new store @@ -11,7 +11,7 @@ import { Store } from "n3"; * @param newStore the store containing new data * @returns true if no subjects are already defined, false otherwise */ -export const noAlreadyDefinedSubjects = (store: Store, newStore: Store): boolean => +export const noAlreadyDefinedSubjects = (store: ReadOnlyStore, newStore: ReadOnlyStore): boolean => newStore.getSubjects(null, null, null) .every((subject) => store.countQuads(subject, null, null, null) === 0); export class ConflictError extends Error { diff --git a/test/integration/Base.test.ts b/test/integration/Base.test.ts index dcf25d9c..7474b55c 100644 --- a/test/integration/Base.test.ts +++ b/test/integration/Base.test.ts @@ -3,7 +3,6 @@ import { setGlobalLoggerFactory, WinstonLoggerFactory } from 'global-logger-fact import { Parser, Writer } from 'n3'; import { readFile } from 'node:fs/promises'; import * as path from 'node:path'; -import { promises } from 'node:timers'; import { getDefaultCssVariables, getPorts, instantiateFromConfig } from '../util/ServerUtil'; import { generateCredentials } from '../util/UmaUtil'; @@ -92,7 +91,6 @@ describe('A server setup', (): void => { describe('using public namespace authorization', (): void => { it('RS: provides immediate read access.', async(): Promise => { - await promises.setTimeout(1000); const publicResource = `http://localhost:${cssPort}/alice/profile/card`; const publicResponse = await fetch(publicResource);