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() }) })