From cca3153afa126012c56181027e66e6a3f6610176 Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Wed, 29 Apr 2026 11:44:11 +0200 Subject: [PATCH] add PATCH Append --- src/auth/middleware.js | 40 +++++++++++- test/auth.test.js | 142 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 181 insertions(+), 1 deletion(-) diff --git a/src/auth/middleware.js b/src/auth/middleware.js index 31dee4d..399f44a 100644 --- a/src/auth/middleware.js +++ b/src/auth/middleware.js @@ -7,6 +7,8 @@ import { getWebIdFromRequestAsync } from './token.js'; import { checkAccess, getRequiredMode } from '../wac/checker.js'; import { AccessMode } from '../wac/parser.js'; +import { parseN3Patch } from '../patch/n3-patch.js'; +import { parseSparqlUpdate } from '../patch/sparql-update.js'; import * as storage from '../storage/filesystem.js'; import { getEffectiveUrlPath } from '../utils/url.js'; import { generateDatabrowserHtml, generateModuleDatabrowserHtml } from '../mashlib/index.js'; @@ -91,7 +93,13 @@ export async function authorize(request, reply, options = {}) { const resourceUrl = buildResourceUrl(request, urlPath); // Get required access mode - use override if provided, otherwise derive from method - const requiredMode = options.requiredMode || getRequiredMode(method); + let requiredMode = options.requiredMode || getRequiredMode(method); + + // PATCH can be authorized as Append when it is insert-only. + // Any delete operation (or parse ambiguity) stays Write. + if (!options.requiredMode && method === 'PATCH') { + requiredMode = getPatchRequiredMode(request, resourceUrl); + } // For write operations on non-existent resources, check parent container let checkPath = storagePath; @@ -120,6 +128,36 @@ export async function authorize(request, reply, options = {}) { return { authorized: allowed, webId, wacAllow, authError, paymentRequired, paid, balance, currency }; } +/** + * Determine PATCH required mode from patch payload semantics. + * Insert-only patches require Append; delete-capable patches require Write. + */ +function getPatchRequiredMode(request, baseUri) { + const contentType = (request.headers['content-type'] || '').toLowerCase(); + const rawBody = Buffer.isBuffer(request.body) ? request.body.toString() : request.body; + + if (typeof rawBody !== 'string') { + return AccessMode.WRITE; + } + + try { + if (contentType.includes('application/sparql-update')) { + const update = parseSparqlUpdate(rawBody, baseUri); + return update.deletes.length === 0 ? AccessMode.APPEND : AccessMode.WRITE; + } + + if (contentType.includes('text/n3') || contentType.includes('application/n3')) { + const patch = parseN3Patch(rawBody, baseUri); + return patch.deletes.length === 0 ? AccessMode.APPEND : AccessMode.WRITE; + } + } catch { + // Fail closed to Write when patch parsing is invalid/ambiguous. + return AccessMode.WRITE; + } + + return AccessMode.WRITE; +} + /** * Get parent container path */ diff --git a/test/auth.test.js b/test/auth.test.js index d34e760..cadd2c4 100644 --- a/test/auth.test.js +++ b/test/auth.test.js @@ -142,6 +142,148 @@ describe('Authentication', () => { assertStatus(res, 201); }); + it('should allow append-only PATCH for insert-only patch', async () => { + await createTestPod('appendpatch1'); + await createTestPod('appendwriter1'); + + const baseUrl = getBaseUrl(); + + // Create target resource and container first. + await request('/appendpatch1/public/item.json', { + method: 'PUT', + headers: { 'Content-Type': 'application/ld+json' }, + body: JSON.stringify({ + '@context': { ex: 'http://example.org/' }, + '@id': '#it', + 'ex:name': 'initial' + }), + auth: 'appendpatch1' + }); + + // Set container ACL: owner full control + authenticated append only. + const acl = { + '@context': { acl: 'http://www.w3.org/ns/auth/acl#' }, + '@graph': [ + { + '@id': '#owner', + '@type': 'acl:Authorization', + 'acl:agent': { '@id': `${baseUrl}/appendpatch1/profile/card.jsonld#me` }, + 'acl:accessTo': { '@id': `${baseUrl}/appendpatch1/public/` }, + 'acl:default': { '@id': `${baseUrl}/appendpatch1/public/` }, + 'acl:mode': [ + { '@id': 'acl:Read' }, + { '@id': 'acl:Write' }, + { '@id': 'acl:Control' } + ] + }, + { + '@id': '#authenticated-append', + '@type': 'acl:Authorization', + 'acl:agentClass': { '@id': 'acl:AuthenticatedAgent' }, + 'acl:accessTo': { '@id': `${baseUrl}/appendpatch1/public/` }, + 'acl:default': { '@id': `${baseUrl}/appendpatch1/public/` }, + 'acl:mode': [ + { '@id': 'acl:Read' }, + { '@id': 'acl:Append' } + ] + } + ] + }; + + await request('/appendpatch1/public/.acl', { + method: 'PUT', + headers: { 'Content-Type': 'application/ld+json' }, + body: JSON.stringify(acl), + auth: 'appendpatch1' + }); + + const insertOnlyPatch = ` + @prefix solid: . + @prefix ex: . + _:patch a solid:InsertDeletePatch; + solid:inserts { <#it> ex:added "yes" }. + `; + + const res = await request('/appendpatch1/public/item.json', { + method: 'PATCH', + headers: { 'Content-Type': 'text/n3' }, + body: insertOnlyPatch, + auth: 'appendwriter1' + }); + + assertStatus(res, 204); + }); + + it('should deny append-only PATCH when patch includes deletes', async () => { + await createTestPod('appendpatch2'); + await createTestPod('appendwriter2'); + + const baseUrl = getBaseUrl(); + + await request('/appendpatch2/public/item.json', { + method: 'PUT', + headers: { 'Content-Type': 'application/ld+json' }, + body: JSON.stringify({ + '@context': { ex: 'http://example.org/' }, + '@id': '#it', + 'ex:name': 'initial' + }), + auth: 'appendpatch2' + }); + + const acl = { + '@context': { acl: 'http://www.w3.org/ns/auth/acl#' }, + '@graph': [ + { + '@id': '#owner', + '@type': 'acl:Authorization', + 'acl:agent': { '@id': `${baseUrl}/appendpatch2/profile/card.jsonld#me` }, + 'acl:accessTo': { '@id': `${baseUrl}/appendpatch2/public/` }, + 'acl:default': { '@id': `${baseUrl}/appendpatch2/public/` }, + 'acl:mode': [ + { '@id': 'acl:Read' }, + { '@id': 'acl:Write' }, + { '@id': 'acl:Control' } + ] + }, + { + '@id': '#authenticated-append', + '@type': 'acl:Authorization', + 'acl:agentClass': { '@id': 'acl:AuthenticatedAgent' }, + 'acl:accessTo': { '@id': `${baseUrl}/appendpatch2/public/` }, + 'acl:default': { '@id': `${baseUrl}/appendpatch2/public/` }, + 'acl:mode': [ + { '@id': 'acl:Read' }, + { '@id': 'acl:Append' } + ] + } + ] + }; + + await request('/appendpatch2/public/.acl', { + method: 'PUT', + headers: { 'Content-Type': 'application/ld+json' }, + body: JSON.stringify(acl), + auth: 'appendpatch2' + }); + + const deletePatch = ` + @prefix solid: . + @prefix ex: . + _:patch a solid:InsertDeletePatch; + solid:deletes { <#it> ex:name "initial" }. + `; + + const res = await request('/appendpatch2/public/item.json', { + method: 'PATCH', + headers: { 'Content-Type': 'text/n3' }, + body: deletePatch, + auth: 'appendwriter2' + }); + + assertStatus(res, 403); + }); + it('should deny public read on inbox', async () => { await createTestPod('inboxread');