Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions src/acl/aclLogic.ts
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -78,11 +79,13 @@ export function createAclLogic(store): AclLogic {

async function planGrant(resourceURL: string | NamedNode, subject: AccessSubject, modes: AccessMode[]): Promise<PatchPlan> {
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<PatchPlan> {
const context = await findEffectiveAcl(resourceURL)
if (subject.type === 'origin') return planOriginRevoke(context, subject.iri)
return planAuthorizationRevoke(context, subject)
}

Expand Down
140 changes: 140 additions & 0 deletions src/acl/originPlanner.ts
Original file line number Diff line number Diff line change
@@ -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<string>, 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))
}
6 changes: 4 additions & 2 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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<string>,
Expand Down
78 changes: 78 additions & 0 deletions test/originPlanner.test.ts
Original file line number Diff line number Diff line change
@@ -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: <http://www.w3.org/ns/auth/acl#>. '

async function context(body = ''): Promise<ACLContext> {
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])
})
})
1 change: 1 addition & 0 deletions test/publicAclApi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,5 +28,6 @@ describe('public ACL API', () => {
(resource: string | import('rdflib').NamedNode, subject: AccessSubject, modes: AccessMode[]) => Promise<PatchPlan>
>()
expectTypeOf<AccessControlSubjectKind>().toEqualTypeOf<'agent' | 'agentGroup' | 'agentClass' | 'origin'>()
expectTypeOf<{ type: 'origin', iri: string }>().toExtend<AccessSubject>()
})
})
Loading