From c85de8f7b0795bc59e66ab5f0b5490d2d3dd60be Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Wed, 7 Oct 2026 12:18:35 +1100 Subject: [PATCH] handle the origin type for solidos Prompt: dokeili's plans do not include origin, check for origin and create an origin plan following our existing logic in the acl directory for origins. we do not need originClass at this time Co-authored-by: GPT-5.4 Mini --- src/acl/aclLogic.ts | 7 +- src/acl/originPlanner.ts | 140 +++++++++++++++++++++++++++++++++++++ src/types.ts | 6 +- test/originPlanner.test.ts | 78 +++++++++++++++++++++ test/publicAclApi.test.ts | 1 + 5 files changed, 228 insertions(+), 4 deletions(-) create mode 100644 src/acl/originPlanner.ts create mode 100644 test/originPlanner.test.ts diff --git a/src/acl/aclLogic.ts b/src/acl/aclLogic.ts index e07307b..5f0ea67 100644 --- a/src/acl/aclLogic.ts +++ b/src/acl/aclLogic.ts @@ -1,8 +1,9 @@ -import { applyPlan as applyAuthorizationPlan, findEffectiveACL, planGrant as planAuthorizationGrant, planPublicRead as planAuthorizationPublicRead, planRevoke as planAuthorizationRevoke, type AccessMode, type AccessSubject, type ACLContext, type Authorization, type PatchPlan } from '@dokieli/web-access-control' +import { applyPlan as applyAuthorizationPlan, findEffectiveACL, planGrant as planAuthorizationGrant, planPublicRead as planAuthorizationPublicRead, planRevoke as planAuthorizationRevoke, type AccessMode, type ACLContext, type Authorization, type PatchPlan } from '@dokieli/web-access-control' import { graph, NamedNode, Namespace, serialize, sym } from 'rdflib' -import type { AclLogic } from '../types' +import type { AccessSubject, AclLogic } from '../types' import { ns as namespace } from '../util/ns' import * as accessControlSubjects from './accessControlSubjects' +import { planOriginGrant, planOriginRevoke } from './originPlanner' // Will remove the below comment when i'm done with the manage access work // Helpers available from @dokieli/web-access-control: @@ -78,11 +79,13 @@ export function createAclLogic(store): AclLogic { async function planGrant(resourceURL: string | NamedNode, subject: AccessSubject, modes: AccessMode[]): Promise { const context = await findEffectiveAcl(resourceURL) + if (subject.type === 'origin') return planOriginGrant(context, subject.iri, modes) return planAuthorizationGrant(context, subject, modes) } async function planRevoke(resourceURL: string | NamedNode, subject: AccessSubject): Promise { const context = await findEffectiveAcl(resourceURL) + if (subject.type === 'origin') return planOriginRevoke(context, subject.iri) return planAuthorizationRevoke(context, subject) } diff --git a/src/acl/originPlanner.ts b/src/acl/originPlanner.ts new file mode 100644 index 0000000..481a962 --- /dev/null +++ b/src/acl/originPlanner.ts @@ -0,0 +1,140 @@ +import { ACL, RDF_TYPE, modeIRI, namedNode, quad, type ACLContext, type AccessMode, type Authorization, type PatchPlan, type Quad } from '@dokieli/web-access-control' + +function normalizeOrigin(origin: string): string { + const parsed = new URL(origin) + if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password || parsed.pathname !== '/' || parsed.search || parsed.hash) { + throw new Error(`Invalid ACL origin: ${origin}. Enter an HTTP(S) origin without a path, credentials, query or fragment.`) + } + return parsed.origin +} + +function authQuads(context: ACLContext, authorization: Authorization): Quad[] { + return context.dataset.filter(item => item.subject.termType === 'NamedNode' && item.subject.value === authorization.id) +} + +function hasOnlyOriginRecipient(authorization: Authorization, origin: string): boolean { + return authorization.origin.length === 1 && + authorization.origin[0] === origin && + authorization.agent.length === 0 && + authorization.agentClass.length === 0 && + authorization.agentGroup.length === 0 +} + +function freshAuthorizationId(context: ACLContext, existing: Set, suffix: string): string { + let attempt = 0 + let id = `${context.defaultACLResource}#${suffix}` + while (existing.has(id)) { + attempt += 1 + id = `${context.defaultACLResource}#${suffix}-${attempt}` + } + existing.add(id) + return id +} + +function conditionDescriptions(context: ACLContext, authorization: Authorization): Quad[] { + const deletes: Quad[] = [] + for (const condition of authorization.condition) { + if (condition.blankNode) { + continue + } + const shared = context.authorizations.some(other => + other.id !== authorization.id && + other.condition.some(existing => existing.id === condition.id)) + if (!shared) { + deletes.push(...context.dataset.filter(item => + item.subject.termType === 'NamedNode' && + item.subject.value === condition.id)) + } + } + return deletes +} + +function buildOriginGrant(context: ACLContext, origin: string, modes: AccessMode[]): PatchPlan { + if (!modes.length) { + throw new Error('An origin grant requires at least one mode; use planRevoke to remove access.') + } + + const target = context.defaultACLResource + const taken = new Set(context.dataset + .filter(item => item.subject.termType === 'NamedNode') + .map(item => item.subject.value)) + const deletes: Quad[] = [] + const inserts: Quad[] = [] + + const matching = context.authorizations.filter(authorization => authorization.origin.includes(origin)) + if (!matching.length) { + const id = freshAuthorizationId(context, taken, 'origin') + inserts.push( + quad(namedNode(id), namedNode(RDF_TYPE), namedNode(ACL + 'Authorization')), + quad(namedNode(id), namedNode(ACL + 'accessTo'), namedNode(context.resource)), + quad(namedNode(id), namedNode(ACL + 'origin'), namedNode(origin)) + ) + for (const mode of modes) { + inserts.push(quad(namedNode(id), namedNode(ACL + 'mode'), namedNode(modeIRI(mode)))) + } + return { target, deletes, inserts } + } + + for (const authorization of matching) { + if (hasOnlyOriginRecipient(authorization, origin)) { + deletes.push(...authQuads(context, authorization).filter(item => item.predicate.value === ACL + 'mode')) + for (const mode of modes) { + inserts.push(quad(namedNode(authorization.id), namedNode(ACL + 'mode'), namedNode(modeIRI(mode)))) + } + continue + } + + const newId = freshAuthorizationId(context, taken, 'origin') + deletes.push(quad(namedNode(authorization.id), namedNode(ACL + 'origin'), namedNode(origin))) + if (authorization.condition.some(condition => condition.blankNode)) { + throw new Error(`Cannot safely split origin authorization ${authorization.id}: blank-node conditions require a full ACL update.`) + } + inserts.push( + quad(namedNode(newId), namedNode(RDF_TYPE), namedNode(ACL + 'Authorization')), + ...authQuads(context, authorization) + .filter(item => + item.predicate.value !== ACL + 'origin' && + item.predicate.value !== ACL + 'agent' && + item.predicate.value !== ACL + 'agentClass' && + item.predicate.value !== ACL + 'agentGroup' && + item.predicate.value !== ACL + 'mode') + .map(item => quad(namedNode(newId), item.predicate, item.object)), + quad(namedNode(newId), namedNode(ACL + 'origin'), namedNode(origin)) + ) + for (const mode of modes) { + inserts.push(quad(namedNode(newId), namedNode(ACL + 'mode'), namedNode(modeIRI(mode)))) + } + } + + return { target, deletes, inserts } +} + +function buildOriginRevoke(context: ACLContext, origin: string): PatchPlan { + const target = context.defaultACLResource + const deletes: Quad[] = [] + const inserts: Quad[] = [] + + const matching = context.authorizations.filter(authorization => authorization.origin.includes(origin)) + if (!matching.length) { + throw new Error(`No authorization found granting access to origin ${origin}.`) + } + + for (const authorization of matching) { + if (hasOnlyOriginRecipient(authorization, origin)) { + deletes.push(...authQuads(context, authorization)) + deletes.push(...conditionDescriptions(context, authorization)) + } else { + deletes.push(quad(namedNode(authorization.id), namedNode(ACL + 'origin'), namedNode(origin))) + } + } + + return { target, deletes, inserts } +} + +export function planOriginGrant(context: ACLContext, originIri: string, modes: AccessMode[]): PatchPlan { + return buildOriginGrant(context, normalizeOrigin(originIri), modes) +} + +export function planOriginRevoke(context: ACLContext, originIri: string): PatchPlan { + return buildOriginRevoke(context, normalizeOrigin(originIri)) +} diff --git a/src/types.ts b/src/types.ts index eea8426..db3ec9e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,5 @@ import type { SessionWithLegacyEvents } from './authSession/authSession' -import type { ACLContext, AccessMode, AccessSubject, Authorization, PatchPlan } from '@dokieli/web-access-control' +import type { ACLContext, AccessMode, AccessSubject as DokieliAccessSubject, Authorization, PatchPlan } from '@dokieli/web-access-control' import { LiveStore, NamedNode, Statement } from 'rdflib' import type { AccessControlSubject } from './acl/accessControlSubjects' import type { AccessRole } from './acl/aclLogic' @@ -105,7 +105,9 @@ export interface AclLogic { ) => string | undefined } -export type { ACLContext, AccessMode, AccessSubject, Authorization, PatchPlan } +export type AccessSubject = DokieliAccessSubject | { type: 'origin', iri: string } + +export type { ACLContext, AccessMode, Authorization, PatchPlan } export interface InboxLogic { createInboxFor: (peerWebId: string, nick: string) => Promise, diff --git a/test/originPlanner.test.ts b/test/originPlanner.test.ts new file mode 100644 index 0000000..35bfaf2 --- /dev/null +++ b/test/originPlanner.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it, vi } from 'vitest' +import { authorizationsFromDataset, buildACLContext, parseTurtle, type ACLContext, type Quad } from '@dokieli/web-access-control' +import { planOriginGrant, planOriginRevoke } from '../src/acl/originPlanner' +import { createAclLogic } from '../src/acl/aclLogic' + +const resource = 'https://pod.example/folder/data.ttl' +const acl = resource + '.acl' +const origin = 'https://app.example' +const alice = 'https://alice.example/#me' +const prefix = '@prefix acl: . ' + +async function context(body = ''): Promise { + const dataset = await parseTurtle(prefix + body, { baseIRI: acl, contentType: 'text/turtle' }) as Quad[] + return buildACLContext({ + resource, + defaultACLResource: acl, + effectiveACLResource: acl, + dataset + }) +} + +function apply(plan: { deletes: Quad[], inserts: Quad[] }, ctx: ACLContext): Quad[] { + return ctx.dataset.filter(item => !plan.deletes.some(deleted => item.equals(deleted))).concat(plan.inserts) +} + +describe('origin planner', () => { + it('creates a standalone origin authorization', async () => { + const ctx = await context() + const plan = planOriginGrant(ctx, origin + '/', ['Read', 'Write']) + const auths = authorizationsFromDataset(plan.inserts, { accessTo: resource }) + expect(auths).toHaveLength(1) + expect(auths[0]).toEqual(expect.objectContaining({ + origin: [origin], + agent: [], + agentClass: [], + agentGroup: [], + mode: ['Read', 'Write'] + })) + }) + + it('splits a shared authorization so origin changes do not affect other recipients', async () => { + const ctx = await context(`<#shared> a acl:Authorization; acl:accessTo <${resource}>; acl:agent <${alice}>; acl:origin <${origin}>; acl:mode acl:Read.`) + const plan = planOriginGrant(ctx, origin, ['Write']) + const auths = authorizationsFromDataset(apply(plan, ctx), { accessTo: resource }) + expect(auths).toEqual(expect.arrayContaining([ + expect.objectContaining({ agent: [alice], origin: [], mode: ['Read'] }), + expect.objectContaining({ agent: [], origin: [origin], mode: ['Write'] }) + ])) + }) + + it('revokes only the origin part of a shared authorization', async () => { + const ctx = await context(`<#shared> a acl:Authorization; acl:accessTo <${resource}>; acl:agent <${alice}>; acl:origin <${origin}>; acl:mode acl:Read.`) + const plan = planOriginRevoke(ctx, origin) + const auths = authorizationsFromDataset(apply(plan, ctx), { accessTo: resource }) + expect(auths).toEqual([expect.objectContaining({ agent: [alice], origin: [], mode: ['Read'] })]) + }) + + it('removes a standalone origin authorization entirely on revoke', async () => { + const ctx = await context(`<#origin> a acl:Authorization; acl:accessTo <${resource}>; acl:origin <${origin}>; acl:mode acl:Read.`) + const plan = planOriginRevoke(ctx, origin) + const auths = authorizationsFromDataset(apply(plan, ctx), { accessTo: resource }) + expect(auths).toEqual([]) + }) + + it('dispatches origin through solid-logic and keeps other subject kinds on dokieli', async () => { + const fetch = vi.fn(async (url: string, init?: RequestInit) => { + if (url === resource) return new Response(null, { headers: { Link: `<${acl}>; rel="acl"` } }) + if (url === acl) return new Response(init?.method === 'HEAD' ? null : '', { headers: { 'Content-Type': 'text/turtle' } }) + throw new Error(`Unexpected fetch ${url}`) + }) + const logic = createAclLogic({ fetcher: { _fetch: fetch } }) + const originPlan = await logic.planGrant(resource, { type: 'origin', iri: origin }, ['Read']) + const agentPlan = await logic.planGrant(resource, { type: 'agent', iri: alice }, ['Read']) + + expect(authorizationsFromDataset(originPlan.inserts, { accessTo: resource })[0].origin).toEqual([origin]) + expect(authorizationsFromDataset(agentPlan.inserts, { accessTo: resource })[0].agent).toEqual([alice]) + }) +}) diff --git a/test/publicAclApi.test.ts b/test/publicAclApi.test.ts index a2f9926..8b3a0db 100644 --- a/test/publicAclApi.test.ts +++ b/test/publicAclApi.test.ts @@ -28,5 +28,6 @@ describe('public ACL API', () => { (resource: string | import('rdflib').NamedNode, subject: AccessSubject, modes: AccessMode[]) => Promise >() expectTypeOf().toEqualTypeOf<'agent' | 'agentGroup' | 'agentClass' | 'origin'>() + expectTypeOf<{ type: 'origin', iri: string }>().toExtend() }) })