From 1e8e448ee5b512649f527ed8c18d89ae5f16f9be Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Sun, 18 Oct 2026 20:04:36 +1100 Subject: [PATCH 1/6] Add new features to ensure we do not lose functionality from what was offered in the sharing Pane Prompt: Many prompts to get this correct. This will most likely change a bit as well once we decide for sure if this is the correct approach Co-authored-by: GPT-5.6 Luna Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- package-lock.json | 4 +- package.json | 2 +- src/acl/aclLogic.ts | 350 ++++++++++++++++++- src/index.ts | 4 +- src/types.ts | 9 +- test/aclLogic.test.ts | 684 ++++++++++++++++++++++++++++++++++++- test/resourceLogic.test.ts | 7 + 7 files changed, 1031 insertions(+), 29 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0cb3c5a..8c86425 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "solid-logic", - "version": "6.0.1-1", + "version": "6.0.1-2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "solid-logic", - "version": "6.0.1-1", + "version": "6.0.1-2", "license": "MIT", "dependencies": { "@dokieli/web-access-control": "^1.1.0", diff --git a/package.json b/package.json index 72b83cb..5322212 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "solid-logic", - "version": "6.0.1-1", + "version": "6.0.1-2", "description": "Core business logic of SolidOS", "main": "dist/index.cjs.js", "module": "dist/index.esm.js", diff --git a/src/acl/aclLogic.ts b/src/acl/aclLogic.ts index a73f392..ef629a5 100644 --- a/src/acl/aclLogic.ts +++ b/src/acl/aclLogic.ts @@ -1,25 +1,92 @@ -import { applyPlan as applyAuthorizationPlan, Authenticated, findEffectiveACL, planGrant as planAuthorizationGrant, planPublicRead as planAuthorizationPublicRead, planRevoke as planAuthorizationRevoke, Public, type AccessMode, type AccessSubject, type ACLContext, type Authorization, type PatchPlan } from '@dokieli/web-access-control' +import { ACL, namedNode, quad, applyPlan as applyAuthorizationPlan, Authenticated, findEffectiveACL, planGrant as planAuthorizationGrant, planPublicRead as planAuthorizationPublicRead, planRevoke as planAuthorizationRevoke, Public, type AccessMode, type AccessSubject, 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 { ns as namespace } from '../util/ns' import * as accessControlSubjects from './accessControlSubjects' -// Will remove the below comment when i'm done with the manage access work -// Helpers available from @dokieli/web-access-control: -// - Discovery: findEffectiveACL, parentContainer -// - ACL context and parsing: buildACLContext, authorizationsFromDataset, parseTurtle -// - Query helpers: agentsWithMode, hasControl, isPublic, modesFor, subjectsWithMode -// - Plan helpers: planContainerACL, planGrant, planOwnerControl, planPublicRead, planRevoke -// - Serialization and application: serializeTerm, toN3Patch, toSparqlUpdate, toTurtle, applyPlan, negotiatePatchContentType -// - Link and allow parsing: allows, parseWacAllow, linkTargets, parseLinkHeader -// - Terms and constants: ACCESS_MODES, ACL, Authenticated, FOAF, modeFromIRI, modeIRI, namedNode, Public, quad, RDF_TYPE, variable - export const ACCESS_ROLES = ['Owner', 'Editor', 'No Access', 'Viewer', 'Poster', 'Submitter'] as const export const PUBLIC_ACCESS_ROLES = ['No Access', 'Viewer'] as const export { Authenticated, Public } from '@dokieli/web-access-control' export type AccessRole = typeof ACCESS_ROLES[number] export type PublicAccessRole = typeof PUBLIC_ACCESS_ROLES[number] +export type ContainerAccessScope = 'folder+contents' | 'folder' | 'contents' +export interface ContainerAccessScopeAnalysis { + distinctScopes: ContainerAccessScope[] + primaryScope: ContainerAccessScope + mixed: boolean +} + +export function classifyAuthorizationScope ( + resource: string, + authorization: Authorization, + inherited = false +): ContainerAccessScope | undefined { + // Effective ACL discovery keeps inherited `acl:default` targets on the + // parent container. They govern both this container and its contents + // (legacy adoptACLDefault copies them as accessTo + default). + const inheritedDefault = inherited && (authorization.default?.length ?? 0) > 0 + const hasAccessTo = inheritedDefault || (authorization.accessTo?.includes(resource) ?? false) + const hasDefault = inheritedDefault || (authorization.default?.includes(resource) ?? false) + if (hasAccessTo && hasDefault) return 'folder+contents' + if (hasAccessTo) return 'folder' + if (hasDefault) return 'contents' + return undefined +} + +export function filterAuthorizationsByContainerScope ( + resource: string, + authorizations: Authorization[], + scope: ContainerAccessScope, + inherited = false +): Authorization[] { + return authorizations.filter(authorization => + classifyAuthorizationScope(resource, authorization, inherited) === scope + ) +} + +/** + * Classify the direct ACL predicates for a container. Mixed grants are + * conservatively treated as covering both scopes. + */ +export function classifyContainerAccessScope (context: ACLContext): ContainerAccessScope { + return analyzeContainerAccessScope(context).primaryScope +} + +export function analyzeContainerAccessScope (context: ACLContext): ContainerAccessScopeAnalysis { + const scopes = new Set() + for (const authorization of context.authorizations ?? []) { + const scope = classifyAuthorizationScope(context.resource, authorization, context.inherited) + if (scope) scopes.add(scope) + } + // Older callers may provide only the graph. Preserve their classification + // while keeping the authorization-entry semantics for parsed contexts. + if (!scopes.size) { + const entries = new Map() + for (const statement of context.dataset ?? []) { + if (statement.object.value !== context.resource) continue + if (statement.predicate.value !== ACL + 'accessTo' && statement.predicate.value !== ACL + 'default') continue + const entry = entries.get(statement.subject.value) ?? { accessTo: false, default: false } + entry.accessTo ||= statement.predicate.value === ACL + 'accessTo' + entry.default ||= statement.predicate.value === ACL + 'default' + entries.set(statement.subject.value, entry) + } + for (const entry of entries.values()) { + if (entry.accessTo && entry.default) scopes.add('folder+contents') + else if (entry.accessTo) scopes.add('folder') + else if (entry.default) scopes.add('contents') + } + } + const distinctScopes = [...scopes] + const primaryScope = distinctScopes.includes('folder+contents') + ? 'folder+contents' + : distinctScopes.includes('contents') + ? 'contents' + : distinctScopes.includes('folder') + ? 'folder' + : 'folder+contents' + return { distinctScopes, primaryScope, mixed: distinctScopes.length > 1 } +} export const ACL_LINK = sym( 'http://www.iana.org/assignments/link-relations/acl' @@ -94,14 +161,252 @@ export function createAclLogic(store): AclLogic { return context.authorizations } + function classifyContainerScope(context: ACLContext): ContainerAccessScope { + return classifyContainerAccessScope(context) + } + + function isProtectedResource(resourceURL: string | NamedNode): boolean { + const resource = sym(typeof resourceURL === 'string' ? resourceURL : resourceURL.value) + return resource.value === resource.site().value || + (typeof store.holds === 'function' && + store.holds(resource, ns.rdf('type'), ns.space('Storage'), resource.doc())) + } + + function subjectMatchesAuthorization(subject: AccessSubject, authorization: Authorization): boolean { + const predicateByType = { + agent: 'agent', + agentGroup: 'agentGroup', + agentClass: 'agentClass', + origin: 'origin' + } as const + return authorization[predicateByType[subject.type]]?.includes(subject.iri) ?? false + } + + function assertProtectedControlPreserved(context: ACLContext, subject: AccessSubject, modes?: AccessMode[]): void { + if (!isProtectedResource(context.resource)) { + return + } + + const hasControl = context.authorizations.some(authorization => + authorization.mode.includes('Control') && subjectMatchesAuthorization(subject, authorization)) + const controlCount = context.authorizations.filter(authorization => authorization.mode.includes('Control')).length + const keepsControl = modes?.includes('Control') ?? false + + if (hasControl && controlCount <= 1 && !keepsControl) { + throw new Error('The protected storage resource must retain at least one Control authorization.') + } + } + + function withContainerDefaults(context: ACLContext, plan: PatchPlan): PatchPlan { + if (!new URL(context.resource).pathname.endsWith('/')) { + return plan + } + + // Materializing an inherited container ACL must not cut off its contents. + // The upstream resource planner emits accessTo only, even for containers. + const defaults = plan.inserts + .filter(statement => statement.predicate.value === ACL + 'accessTo' && statement.object.value === context.resource) + .filter(statement => !plan.inserts.some(existing => existing.subject.equals(statement.subject) && existing.predicate.value === ACL + 'default' && existing.object.value === context.resource)) + .map(statement => quad(statement.subject, namedNode(ACL + 'default'), statement.object)) + + return { ...plan, inserts: [...plan.inserts, ...defaults] } + } + async function planGrant(resourceURL: string | NamedNode, subject: AccessSubject, modes: AccessMode[]): Promise { const context = await findEffectiveAcl(resourceURL) + assertProtectedControlPreserved(context, subject, modes) + if (context.inherited && new URL(context.resource).pathname.endsWith('/')) { + return materializeInheritedContainer(context, subject, modes, 'folder') + } return planAuthorizationGrant(context, subject, modes) } + function assertContainer(context: ACLContext): void { + if (!new URL(context.resource).pathname.endsWith('/')) { + throw new Error(`Expected a container URL ending in "/": ${context.resource}`) + } + } + + function materializeInheritedContainer ( + context: ACLContext, + subject: AccessSubject, + modes: AccessMode[] | undefined, + scope: ContainerAccessScope, + revoke = false + ): PatchPlan { + const inserts: PatchPlan['inserts'] = [] + const subjectKey = subject.type + const subjectKeys = ['agent', 'agentClass', 'agentGroup', 'origin'] as const + const takenIds = new Set([ + ...(context.dataset ?? []).map(statement => statement.subject.value), + ...context.authorizations.flatMap(authorization => [ + authorization.id, + ...(authorization.condition ?? []).map(condition => condition.id) + ]) + ]) + const freshId = (base: string): string => { + let id = base + let suffix = 1 + while (takenIds.has(id)) { + id = `${base}-${suffix++}` + } + takenIds.add(id) + return id + } + const addRule = ( + authorization: Authorization, + baseId: string, + targets: { accessTo: boolean, default: boolean }, + modeOverride?: AccessMode[] + ) => { + const subjects = subjectKeys.flatMap(key => authorization[key].map(iri => ({ key, iri }))) + if (!subjects.length || (!targets.accessTo && !targets.default)) return + const id = freshId(baseId) + const rule = namedNode(id) + inserts.push( + quad(rule, namedNode('http://www.w3.org/1999/02/22-rdf-syntax-ns#type'), namedNode(ACL + 'Authorization')), + ...(targets.accessTo ? [quad(rule, namedNode(ACL + 'accessTo'), namedNode(context.resource))] : []), + ...(targets.default ? [quad(rule, namedNode(ACL + 'default'), namedNode(context.resource))] : []), + ...(modeOverride ?? authorization.mode).map(mode => quad(rule, namedNode(ACL + 'mode'), namedNode(`http://www.w3.org/ns/auth/acl#${mode}`))), + ...subjects.map(({ key, iri }) => quad(rule, namedNode(ACL + key), namedNode(iri))), + ...(authorization.condition ?? []).flatMap(condition => { + const conditionId = freshId(`${id}-condition`) + const conditionNode = namedNode(conditionId) + return [ + quad(rule, namedNode(ACL + 'condition'), conditionNode), + ...Object.entries(condition.properties).flatMap(([predicate, values]) => + values.map(value => quad(conditionNode, namedNode(predicate), namedNode(value)))) + ] + }) + ) + } + const withoutSubject = (authorization: Authorization): Authorization => ({ + ...authorization, + [subjectKey]: authorization[subjectKey].filter(iri => iri !== subject.iri) + }) + const onlySubject = (authorization: Authorization): Authorization => ({ + ...authorization, + agent: [], agentClass: [], agentGroup: [], origin: [], + [subjectKey]: [subject.iri] + }) + context.authorizations.forEach((authorization, index) => { + const baseId = `${context.defaultACLResource}#inherited-${index + 1}` + // Legacy adoptACLDefault parity: an inherited acl:default governs + // this container itself and its contents, so it is copied as + // accessTo + default. Parent accessTo-only rules do not apply here. + const inheritedDefault = (authorization.default?.length ?? 0) > 0 + const targets = { + accessTo: inheritedDefault || authorization.accessTo.includes(context.resource), + default: inheritedDefault + } + const selected = authorization[subjectKey]?.includes(subject.iri) ?? false + if (!selected) { + addRule(authorization, baseId, targets) + return + } + addRule(withoutSubject(authorization), baseId, targets) + // Keep the subject's inherited access for the scope not being changed. + addRule(onlySubject(authorization), `${baseId}-retained`, { + accessTo: targets.accessTo && scope === 'contents', + default: targets.default && scope === 'folder' + }) + }) + if (!revoke) { + addRule(onlySubject({ + accessTo: [], default: [], agent: [], agentClass: [], agentGroup: [], origin: [], mode: [], condition: [] + } as unknown as Authorization), `${context.defaultACLResource}#inherited-new`, { + accessTo: scope !== 'contents', + default: scope !== 'folder' + }, modes ?? []) + } + return { target: context.defaultACLResource, deletes: [], inserts } + } + + async function planGrantContainer(containerURL: string | NamedNode, subject: AccessSubject, modes: AccessMode[]): Promise { + const context = await findEffectiveAcl(containerURL) + assertContainer(context) + assertProtectedControlPreserved(context, subject, modes) + if (context.inherited) return materializeInheritedContainer(context, subject, modes, 'folder') + return planAuthorizationGrant(context, subject, modes) + } + + async function planGrantContainerAndContents(containerURL: string | NamedNode, subject: AccessSubject, modes: AccessMode[]): Promise { + const context = await findEffectiveAcl(containerURL) + assertContainer(context) + assertProtectedControlPreserved(context, subject, modes) + if (context.inherited) return materializeInheritedContainer(context, subject, modes, 'folder+contents') + return withContainerDefaults(context, planAuthorizationGrant(context, subject, modes)) + } + + async function planGrantContainerContents(containerURL: string | NamedNode, subject: AccessSubject, modes: AccessMode[]): Promise { + const context = await findEffectiveAcl(containerURL) + assertContainer(context) + if (isProtectedResource(context.resource)) { + throw new Error('Applying access to the contents of a protected storage resource is not allowed.') + } + if (context.inherited) return materializeInheritedContainer(context, subject, modes, 'contents') + const existingDefaultOnly = context.authorizations.find(authorization => + authorization.default?.includes(context.resource) && + !authorization.accessTo?.includes(context.resource) && + authorization[subject.type]?.includes(subject.iri) && + (authorization.agent.length + authorization.agentClass.length + authorization.agentGroup.length + authorization.origin.length) === 1) + + if (existingDefaultOnly && !context.inherited) { + // The upstream planner can safely update a default-only rule in + // place; unlike a resource grant it will not add acl:accessTo. + return planAuthorizationGrant(context, subject, modes) + } + + const subjectKey = subject.type + const id = `${context.defaultACLResource}#contents-${Math.random().toString(36).slice(2)}` + const rule = namedNode(id) + const subjectQuad = quad(rule, namedNode(ACL + subjectKey), namedNode(subject.iri)) + const fresh = [ + quad(rule, namedNode('http://www.w3.org/1999/02/22-rdf-syntax-ns#type'), namedNode(ACL + 'Authorization')), + quad(rule, namedNode(ACL + 'default'), namedNode(context.resource)), + ...modes.map(mode => quad(rule, namedNode(ACL + 'mode'), namedNode(`http://www.w3.org/ns/auth/acl#${mode}`))), + subjectQuad + ] + + // A folder-only authorization is unrelated and must remain untouched. + return { target: context.defaultACLResource, deletes: [], inserts: fresh } + } + + async function planRevokeContainerContents(containerURL: string | NamedNode, subject: AccessSubject): Promise { + const context = await findEffectiveAcl(containerURL) + assertContainer(context) + if (isProtectedResource(context.resource)) { + throw new Error('Removing access from the contents of a protected storage resource is not allowed.') + } + + const defaultPredicate = ACL + 'default' + if (!context.inherited) { + const deletes = context.dataset.filter(statement => + statement.predicate.value === defaultPredicate && + statement.object.value === context.resource && + context.authorizations.some(authorization => + authorization.id === statement.subject.value && + authorization[subject.type].includes(subject.iri))) + if (!deletes.length) { + throw new Error(`No default authorization found for ${subject.iri}`) + } + return { target: context.defaultACLResource, deletes, inserts: [] } + } + + const plan = materializeInheritedContainer(context, subject, undefined, 'contents', true) + if (!plan.inserts.length) { + throw new Error(`No default authorization found for ${subject.iri}`) + } + return plan + } + async function planRevoke(resourceURL: string | NamedNode, subject: AccessSubject): Promise { const context = await findEffectiveAcl(resourceURL) - return planAuthorizationRevoke(context, subject) + assertProtectedControlPreserved(context, subject) + if (context.inherited && new URL(context.resource).pathname.endsWith('/')) { + return materializeInheritedContainer(context, subject, undefined, 'folder+contents', true) + } + return withContainerDefaults(context, planAuthorizationRevoke(context, subject)) } async function planPublicRead(resourceURL: string | NamedNode, enabled: boolean): Promise { @@ -109,6 +414,20 @@ export function createAclLogic(store): AclLogic { return planAuthorizationPublicRead(context, enabled) } + async function resetToDefault(resourceURL: string | NamedNode): Promise { + const context = await findEffectiveAcl(resourceURL) + if (isProtectedResource(context.resource)) { + throw new Error('The protected storage resource cannot be reset to a default ACL.') + } + const response = await getFetch()(context.defaultACLResource, { method: 'DELETE' }) + + if (!response.ok && response.status !== 404) { + throw new Error(`Unable to remove ACL ${context.defaultACLResource}: HTTP ${response.status}`) + } + + return response + } + async function applyPlan(plan: PatchPlan): Promise { return applyAuthorizationPlan(plan, { fetch: getFetch() }) } @@ -248,13 +567,20 @@ export function createAclLogic(store): AclLogic { findAclDocUrl, findEffectiveAcl, findAccessGrants, + classifyContainerScope, + isProtectedResource, roleFromModes, modesFromRole, publicRoleFromModes, modesFromPublicRole, planGrant, + planGrantContainer, + planGrantContainerAndContents, + planGrantContainerContents, + planRevokeContainerContents, planRevoke, planPublicRead, + resetToDefault, applyPlan, classifyAccessControlSubject, setACLUserPublic, diff --git a/src/index.ts b/src/index.ts index 8cefa81..91a8e82 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,8 +6,8 @@ const authn = solidLogicSingleton.authn const authSession = solidLogicSingleton.authn.authSession const store = solidLogicSingleton.store -export { ACL_LINK, ACCESS_ROLES, PUBLIC_ACCESS_ROLES, Authenticated, Public } from './acl/aclLogic' -export type { AccessRole, PublicAccessRole } from './acl/aclLogic' +export { ACL_LINK, ACCESS_ROLES, PUBLIC_ACCESS_ROLES, Authenticated, Public, classifyContainerAccessScope, analyzeContainerAccessScope, classifyAuthorizationScope, filterAuthorizationsByContainerScope } from './acl/aclLogic' +export type { AccessRole, ContainerAccessScope, ContainerAccessScopeAnalysis, PublicAccessRole } from './acl/aclLogic' export { offlineTestID, appContext } from './authn/authUtil' export { performServerSideLogout } from './authn/serverLogout' export { reloadOnIdentityReplaced } from './authSession/identityState' diff --git a/src/types.ts b/src/types.ts index d5deb03..3f5e345 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2,7 +2,7 @@ import type { SessionWithLegacyEvents } from './authSession/authSession' import type { ACLContext, AccessMode, AccessSubject, Authorization, PatchPlan } from '@dokieli/web-access-control' import { LiveStore, NamedNode, Statement } from 'rdflib' import type { AccessControlSubject } from './acl/accessControlSubjects' -import type { AccessRole, PublicAccessRole } from './acl/aclLogic' +import type { AccessRole, ContainerAccessScope, PublicAccessRole } from './acl/aclLogic' export type AppDetails = { noun: string @@ -84,14 +84,21 @@ export interface AclLogic { findAclDocUrl: (url: NamedNode) => Promise, findEffectiveAcl: (resourceURL: string | NamedNode) => Promise, findAccessGrants: (resourceURL: string | NamedNode) => Promise, + classifyContainerScope: (context: ACLContext) => ContainerAccessScope, + isProtectedResource: (resourceURL: string | NamedNode) => boolean, classifyAccessControlSubject: (principle: string) => Promise, roleFromModes: (modes: Iterable) => AccessRole, modesFromRole: (role: AccessRole) => AccessMode[], publicRoleFromModes: (modes: Iterable) => PublicAccessRole, modesFromPublicRole: (role: PublicAccessRole) => AccessMode[], planGrant: (resourceURL: string | NamedNode, subject: AccessSubject, modes: AccessMode[]) => Promise, + planGrantContainer: (containerURL: string | NamedNode, subject: AccessSubject, modes: AccessMode[]) => Promise, + planGrantContainerAndContents: (containerURL: string | NamedNode, subject: AccessSubject, modes: AccessMode[]) => Promise, + planGrantContainerContents: (containerURL: string | NamedNode, subject: AccessSubject, modes: AccessMode[]) => Promise, + planRevokeContainerContents: (containerURL: string | NamedNode, subject: AccessSubject) => Promise, planRevoke: (resourceURL: string | NamedNode, subject: AccessSubject) => Promise, planPublicRead: (resourceURL: string | NamedNode, enabled: boolean) => Promise, + resetToDefault: (resourceURL: string | NamedNode) => Promise, applyPlan: (plan: PatchPlan) => Promise, setACLUserPublic: (docURI: string, me: NamedNode, options: { diff --git a/test/aclLogic.test.ts b/test/aclLogic.test.ts index e8d343c..dbf3e85 100644 --- a/test/aclLogic.test.ts +++ b/test/aclLogic.test.ts @@ -1,18 +1,68 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { Fetcher, Store, sym, UpdateManager } from 'rdflib' -import { applyPlan, findEffectiveACL, planGrant, planPublicRead, planRevoke } from '@dokieli/web-access-control' -import { ACL_LINK, createAclLogic } from '../src/acl/aclLogic' - -vi.mock('@dokieli/web-access-control', () => ({ +import { Fetcher, Store, namedNode, sym, UpdateManager } from 'rdflib' +import { ACL, applyPlan, buildACLContext, findEffectiveACL, parseTurtle, planGrant, planPublicRead, planRevoke, Public, quad, type PatchPlan, type Quad } from '@dokieli/web-access-control' +import { ACL_LINK, analyzeContainerAccessScope, classifyAuthorizationScope, classifyContainerAccessScope, createAclLogic, filterAuthorizationsByContainerScope } from '../src/acl/aclLogic' + +var actualFindEffectiveACL: typeof findEffectiveACL +var actualPlanGrant: typeof planGrant +var actualPlanPublicRead: typeof planPublicRead +var actualPlanRevoke: typeof planRevoke + +vi.mock('@dokieli/web-access-control', async importOriginal => { + const actual = await importOriginal() + actualFindEffectiveACL = actual.findEffectiveACL + actualPlanGrant = actual.planGrant + actualPlanPublicRead = actual.planPublicRead + actualPlanRevoke = actual.planRevoke + + return { + ...actual, findEffectiveACL: vi.fn(), planGrant: vi.fn(), planPublicRead: vi.fn(), planRevoke: vi.fn(), applyPlan: vi.fn(), - Authenticated: Symbol('Authenticated'), - Public: Symbol('Public'), planOwnerControl: vi.fn() -})) + } +}) + +const root = 'http://localhost:3100/sharon/' +const owner = root + 'profile/card#me' +const container = root + 'Questionnaires/questionare1/published/2026-10-13T04-04-43.658Z/' +const rootACL = `@prefix acl: <${ACL}>. +<#owner> a acl:Authorization; + acl:accessTo <./>; acl:default <./>; + acl:agent <${owner}>; acl:mode acl:Read, acl:Write, acl:Control.` + +function sharingLogic(resource: string, ownACL?: string, inheritedACL = rootACL) { + const fetch = vi.fn(async (url: string, init?: RequestInit) => { + if (init?.method === 'HEAD') { + return new Response(null, { headers: { Link: `<${url + '.acl'}>; rel="acl"` } }) + } + if (url === root + '.acl') { + return new Response(inheritedACL, { headers: { 'Content-Type': 'text/turtle' } }) + } + if (url === resource + '.acl' && ownACL) { + return new Response(ownACL, { headers: { 'Content-Type': 'text/turtle' } }) + } + return new Response(null, { status: 404 }) + }) + vi.mocked(findEffectiveACL).mockImplementation(actualFindEffectiveACL as any) + vi.mocked(planGrant).mockImplementation(actualPlanGrant as any) + vi.mocked(planPublicRead).mockImplementation(actualPlanPublicRead as any) + vi.mocked(planRevoke).mockImplementation(actualPlanRevoke as any) + return createAclLogic({ fetcher: { _fetch: fetch } }) +} + +function accessFor(plan: PatchPlan, resource: string) { + return buildACLContext({ + resource, + dataset: plan.inserts, + defaultACLResource: resource + '.acl', + effectiveACLResource: plan.target, + effectiveContainer: container + }).authorizations +} describe('createAclLogic', () => { let store: Store & { @@ -22,6 +72,7 @@ describe('createAclLogic', () => { webOperation: ReturnType } any: ReturnType + holds: ReturnType } let aclLogic: ReturnType @@ -45,11 +96,68 @@ describe('createAclLogic', () => { } as unknown as Store & { fetcher: typeof fetcher any: ReturnType + holds: ReturnType } + ;(store as any).holds = vi.fn() aclLogic = createAclLogic(store) }) + describe('container scope analysis', () => { + const resource = 'https://example.com/container/' + const authorization = (scope: 'both' | 'folder' | 'contents') => ({ + id: scope, + blankNode: false, + accessTo: scope === 'contents' ? [] : [resource], + default: scope === 'folder' ? [] : [resource], + agent: ['https://example.com/person#me'], + agentClass: [], + agentGroup: [], + origin: [], + mode: ['Read' as const], + condition: [] + }) + + it.each([ + ['both', ['folder+contents'], 'folder+contents'], + ['folder', ['folder'], 'folder'], + ['contents', ['contents'], 'contents'], + ['mixed', ['folder', 'contents'], 'contents'] + ] as const)('classifies %s authorization entries', (_label, distinctScopes, primaryScope) => { + const authorizations = _label === 'mixed' + ? [authorization('folder'), authorization('contents')] + : [authorization(_label as 'both' | 'folder' | 'contents')] + const analysis = analyzeContainerAccessScope({ resource, authorizations } as any) + expect(analysis.distinctScopes).toEqual(distinctScopes) + expect(analysis.primaryScope).toBe(primaryScope) + expect(analysis.mixed).toBe(_label === 'mixed') + expect(classifyContainerAccessScope({ resource, authorizations } as any)).toBe(primaryScope) + }) + + it('treats an inherited owner rule (accessTo + default on the parent) as folder and contents', () => { + const analysis = analyzeContainerAccessScope({ + resource, + inherited: true, + authorizations: [{ + agent: ['https://example.com/owner#me'], agentClass: [], agentGroup: [], origin: [], + mode: ['Read', 'Write', 'Control'], + accessTo: ['https://example.com/'], + default: ['https://example.com/'], + condition: [] + }, { + agent: [], agentClass: ['http://xmlns.com/foaf/0.1/Agent'], agentGroup: [], origin: [], + mode: ['Read'], + accessTo: ['https://example.com/'], + default: [], + condition: [] + }] + } as any) + expect(analysis.distinctScopes).toEqual(['folder+contents']) + expect(analysis.primaryScope).toBe('folder+contents') + expect(analysis.mixed).toBe(false) + }) + }) + it('exports the ACL link relation constant', () => { expect(ACL_LINK.value).toBe('http://www.iana.org/assignments/link-relations/acl') }) @@ -92,7 +200,7 @@ describe('createAclLogic', () => { const subject = { type: 'agent', value: 'https://example.com/profile/card#me' } const plan = { updates: [] } - vi.mocked(findEffectiveACL).mockResolvedValue({ authorizations: [] } as any) + vi.mocked(findEffectiveACL).mockResolvedValue({ resource: resource.value, authorizations: [] } as any) vi.mocked(planGrant).mockReturnValue(plan as any) store.fetcher._fetch.mockResolvedValue(new Response('', { status: 200 })) @@ -100,11 +208,155 @@ describe('createAclLogic', () => { expect(planGrant).toHaveBeenCalledWith(expect.objectContaining({ authorizations: [] }), subject, ['Read']) }) + it('plans a container-only grant without adding defaults', async () => { + const resource = sym('https://example.com/container/') + const subject = { type: 'agent', iri: 'https://example.com/profile/card#me' } + const plan = { target: resource.value + '.acl', deletes: [], inserts: [] } + + vi.mocked(findEffectiveACL).mockResolvedValue({ resource: resource.value, authorizations: [] } as any) + vi.mocked(planGrant).mockReturnValue(plan as any) + + await expect(aclLogic.planGrantContainer(resource, subject as any, ['Read'])).resolves.toBe(plan) + expect(planGrant).toHaveBeenCalledWith(expect.objectContaining({ resource: resource.value }), subject, ['Read']) + }) + + it('rejects the container-only grant API for files', async () => { + const resource = sym('https://example.com/file.ttl') + + vi.mocked(findEffectiveACL).mockResolvedValue({ resource: resource.value, authorizations: [] } as any) + + await expect(aclLogic.planGrantContainer(resource, { type: 'agent', iri: 'https://example.com/me' } as any, ['Read'])) + .rejects.toThrow('Expected a container URL') + }) + + it('resets a resource ACL without deleting an inherited parent ACL', async () => { + const resource = sym('https://example.com/container/file.ttl') + const response = new Response(null, { status: 204 }) + vi.mocked(findEffectiveACL).mockResolvedValue({ + resource: resource.value, + defaultACLResource: 'https://example.com/container/file.ttl.acl', + authorizations: [] + } as any) + store.fetcher._fetch.mockResolvedValue(response) + + await expect(aclLogic.resetToDefault(resource)).resolves.toBe(response) + expect(store.fetcher._fetch).toHaveBeenCalledWith( + 'https://example.com/container/file.ttl.acl', + { method: 'DELETE' } + ) + }) + + it('identifies storage roots as protected resources', () => { + const resource = sym('https://example.com/alice/') + store.holds.mockReturnValue(true) + + expect(aclLogic.isProtectedResource(resource)).toBe(true) + store.holds.mockReturnValue(false) + expect(aclLogic.isProtectedResource(sym('https://example.com/alice/file.ttl'))).toBe(false) + }) + + it('rejects resetting a protected storage resource', async () => { + const resource = sym('https://example.com/alice/') + store.holds.mockReturnValue(true) + vi.mocked(findEffectiveACL).mockResolvedValue({ + resource: resource.value, + defaultACLResource: resource.value + '.acl', + authorizations: [] + } as any) + + await expect(aclLogic.resetToDefault(resource)).rejects.toThrow('protected storage resource') + expect(store.fetcher._fetch).not.toHaveBeenCalled() + }) + + it('rejects applying access to protected storage contents', async () => { + const resource = sym('https://example.com/alice/') + store.holds.mockReturnValue(true) + vi.mocked(findEffectiveACL).mockResolvedValue({ + resource: resource.value, + authorizations: [] + } as any) + + await expect(aclLogic.planGrantContainerContents(resource, { + type: 'agent', + iri: 'https://example.com/profile/card#me' + } as any, ['Read'])).rejects.toThrow('protected storage resource') + }) + + it('removes only a direct subject acl:default authorization', async () => { + const resource = sym('https://example.com/alice/') + const subject = { type: 'agent', iri: 'https://example.com/person#me' } + const authorization = namedNode('https://example.com/alice/.acl#person') + const accessTo = quad(authorization, namedNode(ACL + 'accessTo'), resource) + const defaultRule = quad(authorization, namedNode(ACL + 'default'), resource) + vi.mocked(findEffectiveACL).mockResolvedValue({ + resource: resource.value, + defaultACLResource: resource.value + '.acl', + inherited: false, + authorizations: [{ id: authorization.value, agent: [subject.iri], agentClass: [], agentGroup: [], origin: [], mode: ['Read'], default: [resource.value], accessTo: [resource.value], condition: [], blankNode: false }], + dataset: [accessTo, defaultRule] + } as any) + + const plan = await aclLogic.planRevokeContainerContents(resource, subject as any) + console.log(plan.inserts) + expect(plan.deletes).toEqual([defaultRule]) + expect(plan.deletes).not.toContain(accessTo) + expect(plan.inserts).toEqual([]) + }) + + it('keeps inherited folder access without an acl:default when revoking contents', async () => { + const resource = sym('https://example.com/alice/') + const subject = { type: 'agent', iri: 'https://example.com/person#me' } + vi.mocked(findEffectiveACL).mockResolvedValue({ + resource: resource.value, + defaultACLResource: resource.value + '.acl', + inherited: true, + authorizations: [{ id: 'https://example.com/.acl#person', agent: [subject.iri], agentClass: [], agentGroup: [], origin: [], mode: ['Read'], default: [resource.value], accessTo: [], condition: [], blankNode: false }], + dataset: [] + } as any) + + const plan = await aclLogic.planRevokeContainerContents(resource, subject as any) + expect(plan.deletes).toEqual([]) + // Inherited defaults cover the folder too; revoking contents keeps folder access only. + expect(plan.inserts.some(statement => statement.predicate.value === ACL + 'accessTo')).toBe(true) + expect(plan.inserts.some(statement => statement.predicate.value === ACL + 'default')).toBe(false) + }) + + it('rejects revoking contents when no default authorization exists', async () => { + const resource = sym('https://example.com/alice/') + vi.mocked(findEffectiveACL).mockResolvedValue({ + resource: resource.value, + inherited: false, + authorizations: [], + dataset: [] + } as any) + + await expect(aclLogic.planRevokeContainerContents(resource, { + type: 'agent', + iri: 'https://example.com/person#me' + } as any)).rejects.toThrow('No default authorization found') + }) + + it('keeps the last Control authorization on a protected resource', async () => { + const resource = sym('https://example.com/alice/') + const subject = { type: 'agent', iri: 'https://example.com/profile/card#me' } + store.holds.mockReturnValue(true) + vi.mocked(findEffectiveACL).mockResolvedValue({ + resource: resource.value, + authorizations: [{ + agent: [subject.iri], + mode: ['Read', 'Write', 'Control'] + }] + } as any) + + await expect(aclLogic.planGrant(resource, subject as any, ['Read'])).rejects.toThrow('retain at least one Control') + await expect(aclLogic.planRevoke(resource, subject as any)).rejects.toThrow('retain at least one Control') + }) + it('delegates planPublicRead through the effective ACL context', async () => { const resource = sym('https://example.com/resource.ttl') const plan = { updates: [] } - vi.mocked(findEffectiveACL).mockResolvedValue({ authorizations: [] } as any) + vi.mocked(findEffectiveACL).mockResolvedValue({ resource: resource.value, authorizations: [] } as any) vi.mocked(planPublicRead).mockReturnValue(plan as any) store.fetcher._fetch.mockResolvedValue(new Response('', { status: 200 })) @@ -117,7 +369,7 @@ describe('createAclLogic', () => { const subject = { type: 'agent', iri: 'https://example.com/profile/card#me' } const plan = { target: 'https://example.com/resource.ttl.acl', deletes: [], inserts: [] } - vi.mocked(findEffectiveACL).mockResolvedValue({ authorizations: [] } as any) + vi.mocked(findEffectiveACL).mockResolvedValue({ resource: resource.value, authorizations: [] } as any) vi.mocked(planRevoke).mockReturnValue(plan as any) store.fetcher._fetch.mockResolvedValue(new Response('', { status: 200 })) @@ -167,4 +419,414 @@ describe('createAclLogic', () => { subjectValue: 'https://example.com/group.ttl' }) }) + + describe('container sharing ACL inheritance', () => { + function inheritedContext(resource: string, authorizations: any[], dataset: Quad[] = []) { + return { + resource, + defaultACLResource: resource + '.acl', + effectiveACLResource: root + '.acl', + effectiveContainer: root, + inherited: true, + authorizations, + dataset + } as any + } + + function quadsFor(plan: PatchPlan, subject: string, predicate: string) { + return plan.inserts.filter(statement => + statement.predicate.value === predicate && statement.object.value === subject) + } + + it('materializes an inherited folder-only grant with only acl:accessTo', async () => { + const resource = sym('https://example.com/folder-only/') + const subject = { type: 'agent' as const, iri: 'https://example.com/viewer#me' } + vi.mocked(findEffectiveACL).mockResolvedValue(inheritedContext(resource.value, [{ + id: 'https://example.com/.acl#owner', + agent: ['https://example.com/owner#me'], agentClass: [], agentGroup: [], origin: [], + mode: ['Read', 'Write'], default: [resource.value], accessTo: [resource.value], + condition: [], blankNode: false + }])) + + const plan = await aclLogic.planGrantContainer(resource, subject, ['Read']) + const subjectRule = quadsFor(plan, subject.iri, ACL + 'agent')[0].subject.value + expect(quadsFor(plan, resource.value, ACL + 'accessTo').some(q => q.subject.value === subjectRule)).toBe(true) + expect(quadsFor(plan, resource.value, ACL + 'default').some(q => q.subject.value === subjectRule)).toBe(false) + }) + + it('materializes an inherited contents-only grant with only acl:default', async () => { + const resource = sym('https://example.com/contents-only/') + const subject = { type: 'agent' as const, iri: 'https://example.com/viewer#me' } + vi.mocked(findEffectiveACL).mockResolvedValue(inheritedContext(resource.value, [{ + id: 'https://example.com/.acl#owner', + agent: ['https://example.com/owner#me'], agentClass: [], agentGroup: [], origin: [], + mode: ['Read'], default: [resource.value], accessTo: [], condition: [], blankNode: false + }])) + + const plan = await aclLogic.planGrantContainerContents(resource, subject, ['Read']) + const subjectRule = quadsFor(plan, subject.iri, ACL + 'agent')[0].subject.value + expect(quadsFor(plan, resource.value, ACL + 'default').some(q => q.subject.value === subjectRule)).toBe(true) + expect(quadsFor(plan, resource.value, ACL + 'accessTo').some(q => q.subject.value === subjectRule)).toBe(false) + }) + + it('preserves inherited folder+contents grants while adding a scoped grant', async () => { + const resource = sym('https://example.com/folder-and-contents/') + const subject = { type: 'agent' as const, iri: 'https://example.com/viewer#me' } + const owner = 'https://example.com/owner#me' + vi.mocked(findEffectiveACL).mockResolvedValue(inheritedContext(resource.value, [{ + id: 'https://example.com/.acl#owner', + agent: [owner], agentClass: [], agentGroup: [], origin: [], + mode: ['Read', 'Write'], default: [resource.value], accessTo: [resource.value], + condition: [], blankNode: false + }])) + + const plan = await aclLogic.planGrantContainer(resource, subject, ['Read']) + const ownerRule = quadsFor(plan, owner, ACL + 'agent')[0].subject.value + expect(quadsFor(plan, resource.value, ACL + 'accessTo').some(q => q.subject.value === ownerRule)).toBe(true) + expect(quadsFor(plan, resource.value, ACL + 'default').some(q => q.subject.value === ownerRule)).toBe(true) + }) + + it('clones inherited acl:condition descriptions and avoids authorization ID collisions', async () => { + const resource = sym('https://example.com/conditioned/') + const subject = { type: 'agent' as const, iri: 'https://example.com/viewer#me' } + const collidingId = resource.value + '.acl#inherited-1' + const conditionId = 'https://example.com/.acl#client-condition' + vi.mocked(findEffectiveACL).mockResolvedValue(inheritedContext(resource.value, [{ + id: collidingId, + agent: ['https://example.com/owner#me'], agentClass: [], agentGroup: [], origin: [], + mode: ['Read'], default: [resource.value], accessTo: [resource.value], + condition: [{ + id: conditionId, blankNode: false, + properties: { + [ACL + 'ClientCondition']: ['https://example.com/client'], + [ACL + 'description']: ['only from the trusted client'] + } + }], + blankNode: false + }], [quad(namedNode(collidingId), namedNode(ACL + 'Authorization'), namedNode(ACL + 'Authorization'))])) + + const plan = await aclLogic.planGrantContainer(resource, subject, ['Read']) + const ruleIds = new Set(plan.inserts + .filter(statement => statement.predicate.value === 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type' && + statement.object.value === ACL + 'Authorization') + .map(statement => statement.subject.value)) + expect(ruleIds.has(collidingId)).toBe(false) + expect(ruleIds.size).toBe(2) + expect(plan.inserts.some(statement => + statement.predicate.value === ACL + 'condition' && + statement.object.value !== conditionId)).toBe(true) + expect(plan.inserts.some(statement => + statement.predicate.value === ACL + 'description' && + statement.object.value === 'only from the trusted client')).toBe(true) + }) + + it('preserves inherited default access when materializing a contents-only grant', async () => { + const resource = sym('https://example.com/parent/') + const ownerSubject = 'https://example.com/owner#me' + const subject = { type: 'agent' as const, iri: 'https://example.com/viewer#me' } + vi.mocked(findEffectiveACL).mockResolvedValue({ + resource: resource.value, + defaultACLResource: resource.value + '.acl', + inherited: true, + authorizations: [ + { id: 'https://example.com/.acl#owner', agent: [ownerSubject], agentClass: [], agentGroup: [], origin: [], mode: ['Read', 'Write', 'Control'], default: [resource.value], accessTo: [], condition: [], blankNode: false } + ], + dataset: [] + } as any) + + const plan = await aclLogic.planGrantContainerContents(resource, subject, ['Read']) + const ownerRule = plan.inserts.find(statement => + statement.predicate.value === ACL + 'agent' && + statement.object.value === ownerSubject)?.subject.value + expect(ownerRule).toBeDefined() + expect(plan.inserts.filter(statement => statement.subject.value === ownerRule && statement.predicate.value === ACL + 'default')).toHaveLength(1) + expect(plan.inserts.filter(statement => statement.subject.value === ownerRule && statement.predicate.value === ACL + 'mode')).toHaveLength(3) + expect(plan.inserts.some(statement => + statement.predicate.value === ACL + 'accessTo' && + statement.object.value === resource.value && + statement.object.value === resource.value && + statement.subject.value.includes('inherited-new'))).toBe(false) + expect(plan.inserts.some(statement => + statement.predicate.value === ACL + 'default' && + statement.object.value === resource.value && + statement.object.value === resource.value)).toBe(true) + }) + + it('supports an explicit container-only grant without adding a default', async () => { + const plan = await sharingLogic(container).planGrantContainer(container, Public, ['Read']) + const publicRule = plan.inserts.find(statement => + statement.predicate.value === ACL + 'agentClass' && + statement.object.value === Public.iri)?.subject.value + expect(publicRule).toBeDefined() + expect(plan.inserts.some(statement => + statement.predicate.value === ACL + 'default' && + statement.object.value === container && + statement.subject.value === publicRule + )).toBe(false) + }) + + it('supports an explicit contents-only grant without adding accessTo', async () => { + const plan = await sharingLogic(container).planGrantContainerContents(container, Public, ['Read']) + const publicRule = plan.inserts.find(statement => + statement.predicate.value === ACL + 'agentClass' && + statement.object.value === Public.iri)?.subject.value + expect(publicRule).toBeDefined() + expect(plan.inserts.some(statement => + statement.subject.value === publicRule && + statement.predicate.value === ACL + 'default' && + statement.object.value === container + )).toBe(true) + expect(plan.inserts.some(statement => + statement.subject.value === publicRule && + statement.predicate.value === ACL + 'accessTo' && + statement.object.value === container + )).toBe(false) + }) + + it('keeps the inherited owner on the folder itself when materializing a first ACL', async () => { + const sharonRootACL = rootACL + ` +<#public> a acl:Authorization; + acl:accessTo <./>; + acl:agentClass ; acl:mode acl:Read.` + for (const planner of ['planGrantContainer', 'planGrantContainerContents', 'planGrantContainerAndContents'] as const) { + const plan = await sharingLogic(container, undefined, sharonRootACL)[planner](container, { type: 'agent', iri: 'https://example.com/friend#me' }, ['Read']) + const ownerRule = plan.inserts.find(statement => + statement.predicate.value === ACL + 'agent' && + statement.object.value === owner)?.subject.value + expect(ownerRule, planner).toBeDefined() + const ownerQuads = plan.inserts.filter(statement => statement.subject.value === ownerRule) + expect(ownerQuads.some(q => q.predicate.value === ACL + 'accessTo' && q.object.value === container), planner).toBe(true) + expect(ownerQuads.some(q => q.predicate.value === ACL + 'default' && q.object.value === container), planner).toBe(true) + expect(ownerQuads.some(q => q.predicate.value === ACL + 'mode' && q.object.value === ACL + 'Control'), planner).toBe(true) + // The root public rule is accessTo-only on the parent, so it is not inherited. + expect(plan.inserts.some(q => q.predicate.value === ACL + 'agentClass' && q.object.value === Public.iri), planner).toBe(false) + } + }) + + it('writes accessTo and default for an inherited folder+contents grant', async () => { + const friend = 'https://example.com/friend#me' + const plan = await sharingLogic(container).planGrantContainerAndContents(container, { type: 'agent', iri: friend }, ['Read']) + const friendRule = plan.inserts.find(statement => + statement.predicate.value === ACL + 'agent' && + statement.object.value === friend)?.subject.value + const friendQuads = plan.inserts.filter(statement => statement.subject.value === friendRule) + expect(friendQuads.some(q => q.predicate.value === ACL + 'accessTo' && q.object.value === container)).toBe(true) + expect(friendQuads.some(q => q.predicate.value === ACL + 'default' && q.object.value === container)).toBe(true) + }) + + it('replaces an inherited subject rule instead of dropping it on a folder+contents grant', async () => { + const resource = sym('https://example.com/shared/') + const viewer = 'https://example.com/viewer#me' + vi.mocked(findEffectiveACL).mockResolvedValue(inheritedContext(resource.value, [{ + id: 'https://example.com/.acl#viewer', + agent: [viewer], agentClass: [], agentGroup: [], origin: [], + mode: ['Read'], default: ['https://example.com/'], accessTo: ['https://example.com/'], + condition: [], blankNode: false + }])) + + const plan = await aclLogic.planGrantContainerAndContents(resource, { type: 'agent', iri: viewer }, ['Read', 'Write']) + const viewerRules = plan.inserts.filter(q => q.predicate.value === ACL + 'agent' && q.object.value === viewer) + expect(viewerRules).toHaveLength(1) + const ruleQuads = plan.inserts.filter(q => q.subject.equals(viewerRules[0].subject)) + expect(ruleQuads.filter(q => q.predicate.value === ACL + 'mode').map(q => q.object.value).sort()) + .toEqual([ACL + 'Read', ACL + 'Write']) + expect(ruleQuads.some(q => q.predicate.value === ACL + 'accessTo')).toBe(true) + expect(ruleQuads.some(q => q.predicate.value === ACL + 'default')).toBe(true) + }) + + it('writes accessTo and default for a direct folder+contents grant', async () => { + const plan = await sharingLogic(container, rootACL).planGrantContainerAndContents(container, Public, ['Read']) + const publicRule = plan.inserts.find(statement => + statement.predicate.value === ACL + 'agentClass' && + statement.object.value === Public.iri)?.subject.value + expect(publicRule).toBeDefined() + const ruleQuads = plan.inserts.filter(q => q.subject.value === publicRule) + expect(ruleQuads.some(q => q.predicate.value === ACL + 'accessTo' && q.object.value === container)).toBe(true) + expect(ruleQuads.some(q => q.predicate.value === ACL + 'default' && q.object.value === container)).toBe(true) + }) + + it('writes only acl:default for a direct contents-only grant', async () => { + const acl = rootACL.replace(`<${owner}>`, `<${owner}>`) + const plan = await sharingLogic(container, acl).planGrantContainerContents(container, Public, ['Read']) + expect(plan.inserts.some(statement => statement.predicate.value === ACL + 'accessTo')).toBe(false) + expect(plan.inserts.some(statement => + statement.predicate.value === ACL + 'default' && + statement.object.value === container && + statement.subject.value.includes('#contents-'))).toBe(true) + }) + + it('grants public Read to the container without opting into its contents', async () => { + const plan = await sharingLogic(container).planGrant(container, Public, ['Read']) + expect(plan.target).toBe(container + '.acl') + expect(plan.deletes).toEqual([]) + expect(plan.inserts.some(statement => + statement.predicate.value === ACL + 'accessTo' && + statement.object.value === container && + statement.subject.value.includes('new'))).toBe(true) + expect(accessFor(plan, container + 'index.ttl').some(grant => + grant.agent.includes(owner))).toBe(true) + }) + + it('does not opt into contents through the public-read convenience API', async () => { + const plan = await sharingLogic(container).planPublicRead(container, true) + expect(accessFor(plan, container + 'index.ttl')).toHaveLength(0) + }) + + it('preserves the inherited owner default when revoking an inherited public grant', async () => { + const inheritedACL = rootACL + ` + <#public> a acl:Authorization; acl:default <./>; + acl:agentClass <${Public.iri}>; acl:mode acl:Read.` + const plan = await sharingLogic(container, undefined, inheritedACL).planRevoke(container, Public) + const ownerRule = plan.inserts.find(statement => + statement.predicate.value === ACL + 'agent' && + statement.object.value === owner) + expect(ownerRule).toBeDefined() + expect(plan.inserts.some(statement => + statement.subject.value === ownerRule?.subject.value && + statement.predicate.value === ACL + 'default' + )).toBe(true) + for (const resource of [container, container + 'index.ttl', container + 'questions/q1.ttl']) { + const grants = accessFor(plan, resource) + expect(grants.some(grant => grant.agentClass.includes(Public.iri))).toBe(false) + } + }) + + it('does not add a default when granting to a container that already has an ACL', async () => { + const acl = rootACL.split('<./>').join(`<${container}>`) + const plan = await sharingLogic(container, acl).planGrant(container, Public, ['Read']) + expect(accessFor(plan, container + 'index.ttl')).toHaveLength(0) + expect(plan.deletes).toEqual([]) + }) + + it('revokes a previously materialized public grant without removing owner defaults', async () => { + const granted = await sharingLogic(container).planGrantContainerContents(container, Public, ['Read']) + const directACL = rootACL + ` + <#public> a acl:Authorization; acl:default <${container}>; + acl:agentClass <${Public.iri}>; acl:mode acl:Read.` + const dataset: Quad[] = await parseTurtle(directACL, { baseIRI: container + '.acl', contentType: 'text/turtle' }) + const directContext = buildACLContext({ + resource: container, + dataset, + defaultACLResource: container + '.acl', + effectiveACLResource: container + '.acl', + effectiveContainer: container + }) + const publicRule = namedNode(container + '.acl#public') + directContext.authorizations.push({ + id: publicRule.value, + blankNode: false, + accessTo: [], + default: [container], + agent: [], + agentClass: [Public.iri], + agentGroup: [], + mode: ['Read'], + origin: [], + condition: [] + }) + directContext.dataset = [ + ...directContext.dataset, + quad(publicRule, namedNode(ACL + 'default'), namedNode(container)), + quad(publicRule, namedNode(ACL + 'agentClass'), namedNode(Public.iri)) + ] + vi.mocked(findEffectiveACL).mockResolvedValue(directContext as any) + const revoked = await aclLogic.planRevokeContainerContents(container, Public) + expect(revoked.deletes.some(statement => + statement.predicate.value === ACL + 'default' && + statement.object.value === container + )).toBe(true) + expect(revoked.deletes.some(statement => + statement.predicate.value === ACL + 'accessTo' + )).toBe(false) + expect(granted.inserts.some(statement => + statement.predicate.value === ACL + 'default' && + statement.object.value === container + )).toBe(true) + }) + + it('does not add defaults when sharing an individual resource', async () => { + const resource = container + 'index.ttl' + const plan = await sharingLogic(resource).planGrant(resource, Public, ['Read']) + expect(plan.inserts.some(statement => statement.predicate.value === ACL + 'default')).toBe(false) + const grants = accessFor(plan, resource) + expect(grants.find(grant => grant.agent.includes(owner))?.mode).toEqual(['Read', 'Write', 'Control']) + expect(grants.find(grant => grant.agentClass.includes(Public.iri))?.mode).toEqual(['Read']) + }) + + it('parses the stored relative owner WebID without changing its identity', () => { + const dataset = parseTurtle(`@prefix acl: <${ACL}>. @prefix card: <../../../../profile/card#>. <#owner> acl:agent card:me.`, { + baseIRI: container + '.acl', contentType: 'text/turtle' + }) + expect(dataset[0].object.value).toBe(owner) + }) + }) + + describe('container scope classification', () => { + const scopeContext = (predicates: string[]) => ({ + resource: container, + dataset: predicates.map(predicate => + quad(namedNode(container + '.acl#a1'), namedNode(ACL + predicate), namedNode(container))), + } as any) + + it('classifies accessTo-only ACLs as folder only', () => { + expect(classifyContainerAccessScope(scopeContext(['accessTo']))).toBe('folder') + }) + + it('classifies default-only ACLs as contents defaults', () => { + expect(classifyContainerAccessScope(scopeContext(['default']))).toBe('contents') + }) + + it('classifies ACLs with both predicates as folder and contents', () => { + expect(classifyContainerAccessScope(scopeContext(['accessTo', 'default']))).toBe('folder+contents') + }) + + it('classifies mixed grants conservatively as folder and contents', () => { + const context = { + resource: container, + dataset: [ + quad(namedNode(container + '.acl#a1'), namedNode(ACL + 'accessTo'), namedNode(container)), + quad(namedNode(container + '.acl#a2'), namedNode(ACL + 'default'), namedNode(container)) + ] + } as any + expect(classifyContainerAccessScope(context)).toBe('contents') + }) + + it('defaults an ACL with neither direct predicate to folder and contents', () => { + expect(classifyContainerAccessScope(scopeContext([]))).toBe('folder+contents') + }) + + it('filters mixed authorizations by their complete predicate scope without mutation', () => { + const grants = [ + { agent: [owner], agentGroup: [], agentClass: [], origin: [], mode: ['Read'], accessTo: [container], default: [] }, + { agent: ['https://example.com/contents#me'], agentGroup: [], agentClass: [], origin: [], mode: ['Read'], accessTo: [], default: [container] }, + { agent: ['https://example.com/both#me'], agentGroup: [], agentClass: [], origin: [], mode: ['Read'], accessTo: [container], default: [container] }, + { agent: ['https://example.com/other#me'], agentGroup: [], agentClass: [], origin: [], mode: ['Read'], accessTo: [container, 'https://example.com/other'], default: [container] } + ] as any + + expect(classifyAuthorizationScope(container, grants[0])).toBe('folder') + expect(classifyAuthorizationScope(container, grants[1])).toBe('contents') + expect(classifyAuthorizationScope(container, grants[2])).toBe('folder+contents') + expect(filterAuthorizationsByContainerScope(container, grants, 'folder')).toEqual([grants[0]]) + expect(filterAuthorizationsByContainerScope(container, grants, 'contents')).toEqual([grants[1]]) + expect(filterAuthorizationsByContainerScope(container, grants, 'folder+contents')).toEqual([grants[2], grants[3]]) + expect(grants).toHaveLength(4) + }) + + it('includes inherited parent defaults when filtering a container scope', () => { + const inheritedGrant = { + agent: ['https://example.com/inherited#me'], + agentGroup: [], + agentClass: [], + origin: [], + mode: ['Read'], + accessTo: [], + default: ['https://example.com/parent/'] + } as any + + expect(classifyAuthorizationScope(container, inheritedGrant)).toBeUndefined() + expect(classifyAuthorizationScope(container, inheritedGrant, true)).toBe('folder+contents') + expect(filterAuthorizationsByContainerScope(container, [inheritedGrant], 'folder+contents', true)) + .toEqual([inheritedGrant]) + }) + }) }) diff --git a/test/resourceLogic.test.ts b/test/resourceLogic.test.ts index 63d0277..1bc176a 100644 --- a/test/resourceLogic.test.ts +++ b/test/resourceLogic.test.ts @@ -37,14 +37,21 @@ describe('resourceLogic', () => { findAclDocUrl: vi.fn().mockResolvedValue(undefined), findEffectiveAcl: vi.fn().mockResolvedValue({ authorizations: [] }), findAccessGrants: vi.fn().mockResolvedValue([]), + classifyContainerScope: vi.fn().mockReturnValue('folder+contents'), + isProtectedResource: vi.fn(), classifyAccessControlSubject: vi.fn().mockResolvedValue(undefined), roleFromModes: vi.fn(), modesFromRole: vi.fn(), publicRoleFromModes: vi.fn(), modesFromPublicRole: vi.fn(), planGrant: vi.fn(), + planGrantContainer: vi.fn(), + planGrantContainerAndContents: vi.fn(), + planGrantContainerContents: vi.fn(), planRevoke: vi.fn(), + planRevokeContainerContents: vi.fn(), planPublicRead: vi.fn(), + resetToDefault: vi.fn(), applyPlan: vi.fn(), setACLUserPublic: vi.fn(), genACLText: vi.fn(), From 1f9ccd40ea28e5e74b0d806fc63d6279d98f3d40 Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Tue, 20 Oct 2026 11:46:19 +1100 Subject: [PATCH 2/6] Handle when multiple principal contents authorizations have edits Prompt: Split shared direct authorizations when downgrading or revoking one subject. Co-authored-by: GPT-5.6 Luna --- src/acl/aclLogic.ts | 66 +++++++++++++++++++ test/aclLogic.test.ts | 149 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 215 insertions(+) diff --git a/src/acl/aclLogic.ts b/src/acl/aclLogic.ts index ef629a5..4e28504 100644 --- a/src/acl/aclLogic.ts +++ b/src/acl/aclLogic.ts @@ -345,6 +345,16 @@ export function createAclLogic(store): AclLogic { throw new Error('Applying access to the contents of a protected storage resource is not allowed.') } if (context.inherited) return materializeInheritedContainer(context, subject, modes, 'contents') + const sharedAuthorization = context.authorizations.find(authorization => + authorization.default?.includes(context.resource) && + subjectMatchesAuthorization(subject, authorization) && + ['agent', 'agentClass', 'agentGroup', 'origin'].reduce((count, key) => + count + authorization[key].length, 0) > 1) + + if (sharedAuthorization) { + return splitDirectContainerContentsAuthorization(context, sharedAuthorization, subject, modes) + } + const existingDefaultOnly = context.authorizations.find(authorization => authorization.default?.includes(context.resource) && !authorization.accessTo?.includes(context.resource) && @@ -381,6 +391,16 @@ export function createAclLogic(store): AclLogic { const defaultPredicate = ACL + 'default' if (!context.inherited) { + const sharedAuthorization = context.authorizations.find(authorization => + authorization.default?.includes(context.resource) && + subjectMatchesAuthorization(subject, authorization) && + ['agent', 'agentClass', 'agentGroup', 'origin'].reduce((count, key) => + count + authorization[key].length, 0) > 1) + + if (sharedAuthorization) { + return splitDirectContainerContentsAuthorization(context, sharedAuthorization, subject) + } + const deletes = context.dataset.filter(statement => statement.predicate.value === defaultPredicate && statement.object.value === context.resource && @@ -400,6 +420,52 @@ export function createAclLogic(store): AclLogic { return plan } + function splitDirectContainerContentsAuthorization ( + context: ACLContext, + authorization: Authorization, + subject: AccessSubject, + modes?: AccessMode[] + ): PatchPlan { + const subjectKeys = ['agent', 'agentClass', 'agentGroup', 'origin'] as const + const subjects = subjectKeys.flatMap(key => + authorization[key].map(iri => ({ key, iri })) + ) + const remainingSubjects = subjects.filter(({ key, iri }) => + !(key === subject.type && iri === subject.iri)) + const target = namedNode(context.defaultACLResource + `#split-${Math.random().toString(36).slice(2)}`) + const retained = namedNode(context.defaultACLResource + `#retained-${Math.random().toString(36).slice(2)}`) + const inserts: PatchPlan['inserts'] = [] + const addRule = ( + rule: ReturnType, + ruleSubjects: Array<{ key: typeof subjectKeys[number], iri: string }>, + ruleModes: AccessMode[], + includeAccessTo: boolean, + includeDefault: boolean + ) => { + if (!ruleSubjects.length || (!includeAccessTo && !includeDefault)) return + inserts.push( + quad(rule, namedNode('http://www.w3.org/1999/02/22-rdf-syntax-ns#type'), namedNode(ACL + 'Authorization')), + ...(includeAccessTo ? [quad(rule, namedNode(ACL + 'accessTo'), namedNode(context.resource))] : []), + ...(includeDefault ? [quad(rule, namedNode(ACL + 'default'), namedNode(context.resource))] : []), + ...ruleModes.map(mode => quad(rule, namedNode(ACL + 'mode'), namedNode(`http://www.w3.org/ns/auth/acl#${mode}`))), + ...ruleSubjects.map(({ key, iri }) => quad(rule, namedNode(ACL + key), namedNode(iri))) + ) + } + + const hasAccessTo = authorization.accessTo.includes(context.resource) + const hasDefault = authorization.default.includes(context.resource) + addRule(retained, remainingSubjects, authorization.mode, hasAccessTo, hasDefault) + if (modes) { + addRule(target, [{ key: subject.type, iri: subject.iri }], modes, hasAccessTo, hasDefault) + } + + return { + target: context.defaultACLResource, + deletes: context.dataset.filter(statement => statement.subject.value === authorization.id), + inserts + } + } + async function planRevoke(resourceURL: string | NamedNode, subject: AccessSubject): Promise { const context = await findEffectiveAcl(resourceURL) assertProtectedControlPreserved(context, subject) diff --git a/test/aclLogic.test.ts b/test/aclLogic.test.ts index dbf3e85..3f4f24a 100644 --- a/test/aclLogic.test.ts +++ b/test/aclLogic.test.ts @@ -303,6 +303,99 @@ describe('createAclLogic', () => { expect(plan.inserts).toEqual([]) }) + it('splits a shared contents authorization when downgrading one subject', async () => { + const resource = sym('https://example.com/alice/') + const alice = 'https://example.com/alice#me' + const bob = 'https://example.com/bob#me' + const authorization = namedNode('https://example.com/alice/.acl#shared') + const statements = [ + quad(authorization, namedNode(ACL + 'type'), namedNode(ACL + 'Authorization')), + quad(authorization, namedNode(ACL + 'default'), resource), + quad(authorization, namedNode(ACL + 'agent'), namedNode(alice)), + quad(authorization, namedNode(ACL + 'agent'), namedNode(bob)), + quad(authorization, namedNode(ACL + 'mode'), namedNode(ACL + 'Read')), + quad(authorization, namedNode(ACL + 'mode'), namedNode(ACL + 'Write')) + ] + vi.mocked(findEffectiveACL).mockResolvedValue({ + resource: resource.value, + defaultACLResource: resource.value + '.acl', + inherited: false, + authorizations: [{ + id: authorization.value, + agent: [alice, bob], + agentClass: [], + agentGroup: [], + origin: [], + mode: ['Read', 'Write'], + default: [resource.value], + accessTo: [], + condition: [], + blankNode: false + }], + dataset: statements + } as any) + + const plan = await aclLogic.planGrantContainerContents(resource, { type: 'agent', iri: alice }, ['Read']) + expect(plan.deletes).toEqual(statements) + + const aliceRule = plan.inserts.find(statement => + statement.predicate.value === ACL + 'agent' && statement.object.value === alice)?.subject + const bobRule = plan.inserts.find(statement => + statement.predicate.value === ACL + 'agent' && statement.object.value === bob)?.subject + expect(aliceRule).toBeDefined() + expect(bobRule).toBeDefined() + expect(plan.inserts.filter(statement => statement.subject.equals(aliceRule!)) + .some(statement => statement.predicate.value === ACL + 'mode' && statement.object.value === ACL + 'Read')).toBe(true) + expect(plan.inserts.filter(statement => statement.subject.equals(aliceRule!)) + .some(statement => statement.predicate.value === ACL + 'mode' && statement.object.value === ACL + 'Write')).toBe(false) + expect(plan.inserts.filter(statement => statement.subject.equals(bobRule!)) + .some(statement => statement.predicate.value === ACL + 'mode' && statement.object.value === ACL + 'Write')).toBe(true) + }) + + it('preserves other subjects when revoking one subject from a shared contents authorization', async () => { + const resource = sym('https://example.com/alice/') + const alice = 'https://example.com/alice#me' + const bob = 'https://example.com/bob#me' + const authorization = namedNode('https://example.com/alice/.acl#shared') + const statements = [ + quad(authorization, namedNode(ACL + 'type'), namedNode(ACL + 'Authorization')), + quad(authorization, namedNode(ACL + 'default'), resource), + quad(authorization, namedNode(ACL + 'agent'), namedNode(alice)), + quad(authorization, namedNode(ACL + 'agent'), namedNode(bob)), + quad(authorization, namedNode(ACL + 'mode'), namedNode(ACL + 'Read')) + ] + vi.mocked(findEffectiveACL).mockResolvedValue({ + resource: resource.value, + defaultACLResource: resource.value + '.acl', + inherited: false, + authorizations: [{ + id: authorization.value, + agent: [alice, bob], + agentClass: [], + agentGroup: [], + origin: [], + mode: ['Read'], + default: [resource.value], + accessTo: [], + condition: [], + blankNode: false + }], + dataset: statements + } as any) + + const plan = await aclLogic.planRevokeContainerContents(resource, { type: 'agent', iri: alice }) + expect(plan.deletes).toEqual(statements) + expect(plan.inserts.some(statement => + statement.predicate.value === ACL + 'agent' && statement.object.value === bob + )).toBe(true) + expect(plan.inserts.some(statement => + statement.predicate.value === ACL + 'agent' && statement.object.value === alice + )).toBe(false) + expect(plan.inserts.some(statement => + statement.predicate.value === ACL + 'default' && statement.object.value === resource.value + )).toBe(true) + }) + it('keeps inherited folder access without an acl:default when revoking contents', async () => { const resource = sym('https://example.com/alice/') const subject = { type: 'agent', iri: 'https://example.com/person#me' } @@ -454,6 +547,62 @@ describe('createAclLogic', () => { expect(quadsFor(plan, resource.value, ACL + 'default').some(q => q.subject.value === subjectRule)).toBe(false) }) + it('preserves supported conditions while materializing an inherited authorization', async () => { + const resource = sym('https://example.com/conditioned/') + const subject = { type: 'agent' as const, iri: 'https://example.com/owner#me' } + const authorization = namedNode('https://example.com/.acl#conditioned') + const condition = namedNode('https://example.com/.acl#condition-1') + const conditionType = namedNode(ACL + 'ClientCondition') + const conditionClient = namedNode(ACL + 'client') + const conditionValue = namedNode('https://example.com/client') + const dataset = [ + quad(authorization, namedNode(ACL + 'condition'), condition), + quad(condition, namedNode('http://www.w3.org/1999/02/22-rdf-syntax-ns#type'), conditionType), + quad(condition, conditionClient, conditionValue) + ] + vi.mocked(findEffectiveACL).mockResolvedValue(inheritedContext(resource.value, [{ + id: authorization.value, + agent: [subject.iri], agentClass: [], agentGroup: [], origin: [], + mode: ['Read'], default: [root], accessTo: [], + condition: [{ + id: condition.value, + blankNode: false, + properties: { + [conditionClient.value]: [conditionValue.value] + } + }] + }], dataset)) + + const plan = await aclLogic.planGrantContainer(resource, subject, ['Read', 'Write']) + expect(plan.inserts.some(statement => + statement.predicate.value === ACL + 'condition' + )).toBe(true) + expect(plan.inserts.some(statement => + statement.predicate.value === conditionClient.value && + statement.object.value === conditionValue.value + )).toBe(true) + }) + + it('rejects unsupported condition properties instead of silently dropping them', async () => { + const resource = sym('https://example.com/unsupported-condition/') + const subject = { type: 'agent' as const, iri: 'https://example.com/owner#me' } + vi.mocked(findEffectiveACL).mockResolvedValue(inheritedContext(resource.value, [{ + id: 'https://example.com/.acl#unsupported-condition', + agent: [subject.iri], agentClass: [], agentGroup: [], origin: [], + mode: ['Read'], default: [root], accessTo: [], + condition: [{ + id: 'https://example.com/.acl#condition-unsupported', + blankNode: false, + properties: { + 'https://example.com/unsupported-condition-property': ['https://example.com/value'] + } + }] + }])) + + await expect(aclLogic.planGrantContainer(resource, subject, ['Read', 'Write'])) + .rejects.toThrow('Unsupported ACL condition') + }) + it('materializes an inherited contents-only grant with only acl:default', async () => { const resource = sym('https://example.com/contents-only/') const subject = { type: 'agent' as const, iri: 'https://example.com/viewer#me' } From 9215db6b1d79de749727c8422f8db4afa824f22e Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Tue, 20 Oct 2026 12:32:48 +1100 Subject: [PATCH 3/6] Safety for conditions and authorization ids Prompt: Make sure to copy over any conditions even though this is not currently supported in solidos we don't want to lose this information if a user has it. Also make authorization ids consistent. Co-authored-by: GPT-5.6 Luna --- src/acl/aclLogic.ts | 96 ++++++++++++++++++++++++++++++++----------- test/aclLogic.test.ts | 72 ++++++++++++++++++++++++++++++++ 2 files changed, 143 insertions(+), 25 deletions(-) diff --git a/src/acl/aclLogic.ts b/src/acl/aclLogic.ts index 4e28504..8bb3c42 100644 --- a/src/acl/aclLogic.ts +++ b/src/acl/aclLogic.ts @@ -227,6 +227,64 @@ export function createAclLogic(store): AclLogic { } } + const subjectKeys = ['agent', 'agentClass', 'agentGroup', 'origin'] as const + const rdfType = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type' + const supportedConditionPredicates = new Set([ + rdfType, + ACL + 'client', + ACL + 'clientClass', + ACL + 'clientGroup', + ACL + 'issuer', + ACL + 'issuerClass', + ACL + 'issuerGroup', + ACL + 'description', + // Some older ACL contexts expose the condition type as a property key + // rather than retaining it under rdf:type. + ACL + 'ClientCondition', + ACL + 'IssuerCondition' + ]) + const supportedConditionTypes = new Set([ + ACL + 'ClientCondition', + ACL + 'IssuerCondition' + ]) + + function createAuthorizationIdAllocator(context: ACLContext): (base: string) => string { + const takenIds = new Set([ + ...(context.dataset ?? []).map(statement => statement.subject.value), + ...context.authorizations.flatMap(authorization => [ + authorization.id, + ...(authorization.condition ?? []).map(condition => condition.id) + ]) + ]) + + return (base: string): string => { + let id = base + let suffix = 1 + while (takenIds.has(id)) { + id = `${base}-${suffix++}` + } + takenIds.add(id) + return id + } + } + + function assertSupportedConditions(context: ACLContext): void { + for (const authorization of context.authorizations) { + for (const condition of authorization.condition ?? []) { + const conditionTypes = condition.properties[rdfType] ?? [] + if (conditionTypes.length > 1 || + (conditionTypes.length === 1 && !supportedConditionTypes.has(conditionTypes[0]))) { + throw new Error(`Unsupported ACL condition type on ${condition.id}`) + } + for (const predicate of Object.keys(condition.properties)) { + if (!supportedConditionPredicates.has(predicate)) { + throw new Error(`Unsupported ACL condition property: ${predicate}`) + } + } + } + } + } + function materializeInheritedContainer ( context: ACLContext, subject: AccessSubject, @@ -236,23 +294,8 @@ export function createAclLogic(store): AclLogic { ): PatchPlan { const inserts: PatchPlan['inserts'] = [] const subjectKey = subject.type - const subjectKeys = ['agent', 'agentClass', 'agentGroup', 'origin'] as const - const takenIds = new Set([ - ...(context.dataset ?? []).map(statement => statement.subject.value), - ...context.authorizations.flatMap(authorization => [ - authorization.id, - ...(authorization.condition ?? []).map(condition => condition.id) - ]) - ]) - const freshId = (base: string): string => { - let id = base - let suffix = 1 - while (takenIds.has(id)) { - id = `${base}-${suffix++}` - } - takenIds.add(id) - return id - } + const freshId = createAuthorizationIdAllocator(context) + assertSupportedConditions(context) const addRule = ( authorization: Authorization, baseId: string, @@ -264,7 +307,7 @@ export function createAclLogic(store): AclLogic { const id = freshId(baseId) const rule = namedNode(id) inserts.push( - quad(rule, namedNode('http://www.w3.org/1999/02/22-rdf-syntax-ns#type'), namedNode(ACL + 'Authorization')), + quad(rule, namedNode(rdfType), namedNode(ACL + 'Authorization')), ...(targets.accessTo ? [quad(rule, namedNode(ACL + 'accessTo'), namedNode(context.resource))] : []), ...(targets.default ? [quad(rule, namedNode(ACL + 'default'), namedNode(context.resource))] : []), ...(modeOverride ?? authorization.mode).map(mode => quad(rule, namedNode(ACL + 'mode'), namedNode(`http://www.w3.org/ns/auth/acl#${mode}`))), @@ -312,9 +355,12 @@ export function createAclLogic(store): AclLogic { }) }) if (!revoke) { - addRule(onlySubject({ + const newAuthorization: Authorization = { + id: `${context.defaultACLResource}#inherited-new-source`, + blankNode: false, accessTo: [], default: [], agent: [], agentClass: [], agentGroup: [], origin: [], mode: [], condition: [] - } as unknown as Authorization), `${context.defaultACLResource}#inherited-new`, { + } + addRule(onlySubject(newAuthorization), `${context.defaultACLResource}#inherited-new`, { accessTo: scope !== 'contents', default: scope !== 'folder' }, modes ?? []) @@ -368,11 +414,11 @@ export function createAclLogic(store): AclLogic { } const subjectKey = subject.type - const id = `${context.defaultACLResource}#contents-${Math.random().toString(36).slice(2)}` + const id = createAuthorizationIdAllocator(context)(`${context.defaultACLResource}#contents-1`) const rule = namedNode(id) const subjectQuad = quad(rule, namedNode(ACL + subjectKey), namedNode(subject.iri)) const fresh = [ - quad(rule, namedNode('http://www.w3.org/1999/02/22-rdf-syntax-ns#type'), namedNode(ACL + 'Authorization')), + quad(rule, namedNode(rdfType), namedNode(ACL + 'Authorization')), quad(rule, namedNode(ACL + 'default'), namedNode(context.resource)), ...modes.map(mode => quad(rule, namedNode(ACL + 'mode'), namedNode(`http://www.w3.org/ns/auth/acl#${mode}`))), subjectQuad @@ -426,14 +472,14 @@ export function createAclLogic(store): AclLogic { subject: AccessSubject, modes?: AccessMode[] ): PatchPlan { - const subjectKeys = ['agent', 'agentClass', 'agentGroup', 'origin'] as const + const freshId = createAuthorizationIdAllocator(context) const subjects = subjectKeys.flatMap(key => authorization[key].map(iri => ({ key, iri })) ) const remainingSubjects = subjects.filter(({ key, iri }) => !(key === subject.type && iri === subject.iri)) - const target = namedNode(context.defaultACLResource + `#split-${Math.random().toString(36).slice(2)}`) - const retained = namedNode(context.defaultACLResource + `#retained-${Math.random().toString(36).slice(2)}`) + const target = namedNode(freshId(`${context.defaultACLResource}#split-1`)) + const retained = namedNode(freshId(`${context.defaultACLResource}#retained-1`)) const inserts: PatchPlan['inserts'] = [] const addRule = ( rule: ReturnType, diff --git a/test/aclLogic.test.ts b/test/aclLogic.test.ts index 3f4f24a..6640ea1 100644 --- a/test/aclLogic.test.ts +++ b/test/aclLogic.test.ts @@ -352,6 +352,56 @@ describe('createAclLogic', () => { .some(statement => statement.predicate.value === ACL + 'mode' && statement.object.value === ACL + 'Write')).toBe(true) }) + it('allocates deterministic, collision-free IDs when splitting a shared authorization', async () => { + const resource = sym('https://example.com/alice/') + const alice = 'https://example.com/alice#me' + const bob = 'https://example.com/bob#me' + const aclResource = resource.value + '.acl' + const authorization = namedNode(aclResource + '#shared') + const statements = [ + quad(authorization, namedNode(ACL + 'type'), namedNode(ACL + 'Authorization')), + quad(authorization, namedNode(ACL + 'default'), resource), + quad(authorization, namedNode(ACL + 'agent'), namedNode(alice)), + quad(authorization, namedNode(ACL + 'agent'), namedNode(bob)), + quad(authorization, namedNode(ACL + 'mode'), namedNode(ACL + 'Read')), + quad(namedNode(aclResource + '#split'), namedNode(ACL + 'type'), namedNode(ACL + 'Authorization')), + quad(namedNode(aclResource + '#retained'), namedNode(ACL + 'type'), namedNode(ACL + 'Authorization')) + ] + const context = { + resource: resource.value, + defaultACLResource: aclResource, + inherited: false, + authorizations: [{ + id: authorization.value, + agent: [alice, bob], + agentClass: [], + agentGroup: [], + origin: [], + mode: ['Read'], + default: [resource.value], + accessTo: [], + condition: [], + blankNode: false + }], + dataset: statements + } as any + vi.mocked(findEffectiveACL).mockResolvedValue(context) + + const first = await aclLogic.planGrantContainerContents(resource, { type: 'agent', iri: alice }, ['Read']) + const second = await aclLogic.planGrantContainerContents(resource, { type: 'agent', iri: alice }, ['Read']) + const insertedAuthorizationIds = (plan: PatchPlan) => [...new Set(plan.inserts + .filter(statement => + statement.predicate.value === 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type' && + statement.object.value === ACL + 'Authorization') + .map(statement => statement.subject.value))] + + expect(insertedAuthorizationIds(first)).toEqual([ + aclResource + '#retained-1', + aclResource + '#split-1' + ]) + expect(insertedAuthorizationIds(second)).toEqual(insertedAuthorizationIds(first)) + }) + it('preserves other subjects when revoking one subject from a shared contents authorization', async () => { const resource = sym('https://example.com/alice/') const alice = 'https://example.com/alice#me' @@ -732,6 +782,28 @@ describe('createAclLogic', () => { )).toBe(false) }) + it('allocates a deterministic ID for a new direct contents authorization', async () => { + const resource = sym('https://example.com/alice/') + const aclResource = resource.value + '.acl' + vi.mocked(findEffectiveACL).mockResolvedValue({ + resource: resource.value, + defaultACLResource: aclResource, + inherited: false, + authorizations: [], + dataset: [ + quad(namedNode(aclResource + '#contents'), namedNode(ACL + 'type'), namedNode(ACL + 'Authorization')) + ] + } as any) + + const subject = { type: 'agent' as const, iri: 'https://example.com/person#me' } + const first = await aclLogic.planGrantContainerContents(resource, subject, ['Read']) + const second = await aclLogic.planGrantContainerContents(resource, subject, ['Read']) + + expect(first.inserts[0]?.subject.value).toBe(aclResource + '#contents-1') + expect(second.inserts.map(statement => statement.subject.value)) + .toEqual(first.inserts.map(statement => statement.subject.value)) + }) + it('keeps the inherited owner on the folder itself when materializing a first ACL', async () => { const sharonRootACL = rootACL + ` <#public> a acl:Authorization; From 38a4a4bd684553f07c248ce59bdbbe0e54ae15e3 Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Tue, 20 Oct 2026 14:33:45 +1100 Subject: [PATCH 4/6] Normalize role handling and prepare for owner (write locked) Prompt: Please normalize the role handling and add the ability to have a owner (write locked) which is read and control. Co-authored-by: GPT-5.6 Luna --- src/acl/aclLogic.ts | 20 ++++++++++++++++---- src/index.ts | 4 ++-- test/aclLogic.test.ts | 30 +++++++++++++++++++++++++++++- test/publicAclApi.test.ts | 8 ++++++++ 4 files changed, 55 insertions(+), 7 deletions(-) diff --git a/src/acl/aclLogic.ts b/src/acl/aclLogic.ts index 8bb3c42..9a24845 100644 --- a/src/acl/aclLogic.ts +++ b/src/acl/aclLogic.ts @@ -6,16 +6,25 @@ import * as accessControlSubjects from './accessControlSubjects' export const ACCESS_ROLES = ['Owner', 'Editor', 'No Access', 'Viewer', 'Poster', 'Submitter'] as const export const PUBLIC_ACCESS_ROLES = ['No Access', 'Viewer'] as const +/** + * A Control grant without Write is the legacy "write locked" owner grant. + * + * It is intentionally not part of ACCESS_ROLES yet: that list is consumed by + * the existing UI, whose selector will be updated in a later change. + */ +export const WRITE_LOCKED_OWNER_ROLE = 'Owner (write locked)' as const export { Authenticated, Public } from '@dokieli/web-access-control' -export type AccessRole = typeof ACCESS_ROLES[number] +export type AccessRole = typeof ACCESS_ROLES[number] | typeof WRITE_LOCKED_OWNER_ROLE export type PublicAccessRole = typeof PUBLIC_ACCESS_ROLES[number] export type ContainerAccessScope = 'folder+contents' | 'folder' | 'contents' -export interface ContainerAccessScopeAnalysis { +export interface ContainerAccessScopeResult { distinctScopes: ContainerAccessScope[] primaryScope: ContainerAccessScope mixed: boolean } +/** @deprecated Use ContainerAccessScopeResult. */ +export type ContainerAccessScopeAnalysis = ContainerAccessScopeResult export function classifyAuthorizationScope ( resource: string, @@ -53,7 +62,7 @@ export function classifyContainerAccessScope (context: ACLContext): ContainerAcc return analyzeContainerAccessScope(context).primaryScope } -export function analyzeContainerAccessScope (context: ACLContext): ContainerAccessScopeAnalysis { +export function analyzeContainerAccessScope (context: ACLContext): ContainerAccessScopeResult { const scopes = new Set() for (const authorization of context.authorizations ?? []) { const scope = classifyAuthorizationScope(context.resource, authorization, context.inherited) @@ -97,6 +106,7 @@ export function createAclLogic(store): AclLogic { const ns = namespace const ACCESS_ROLE_RULES: Array<{ label: AccessRole, modes: AccessMode[] }> = [ { label: 'Owner', modes: ['Read', 'Write', 'Control'] }, + { label: WRITE_LOCKED_OWNER_ROLE, modes: ['Read', 'Control'] }, { label: 'Editor', modes: ['Read', 'Write'] }, { label: 'Poster', modes: ['Append', 'Read'] }, { label: 'Submitter', modes: ['Append'] }, @@ -110,7 +120,9 @@ export function createAclLogic(store): AclLogic { function roleFromModes (modes: Iterable): AccessRole { const modeSet = new Set(modes) - const matchingRule = ACCESS_ROLE_RULES.find(rule => rule.label !== 'No Access' && rule.modes.every(mode => modeSet.has(mode))) + const matchingRule = ACCESS_ROLE_RULES.find(rule => + rule.modes.length === modeSet.size && + rule.modes.every(mode => modeSet.has(mode))) return matchingRule?.label ?? 'No Access' } diff --git a/src/index.ts b/src/index.ts index 91a8e82..3929498 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,8 +6,8 @@ const authn = solidLogicSingleton.authn const authSession = solidLogicSingleton.authn.authSession const store = solidLogicSingleton.store -export { ACL_LINK, ACCESS_ROLES, PUBLIC_ACCESS_ROLES, Authenticated, Public, classifyContainerAccessScope, analyzeContainerAccessScope, classifyAuthorizationScope, filterAuthorizationsByContainerScope } from './acl/aclLogic' -export type { AccessRole, ContainerAccessScope, ContainerAccessScopeAnalysis, PublicAccessRole } from './acl/aclLogic' +export { ACL_LINK, ACCESS_ROLES, PUBLIC_ACCESS_ROLES, WRITE_LOCKED_OWNER_ROLE, Authenticated, Public, classifyContainerAccessScope, analyzeContainerAccessScope, classifyAuthorizationScope, filterAuthorizationsByContainerScope } from './acl/aclLogic' +export type { AccessRole, ContainerAccessScope, ContainerAccessScopeAnalysis, ContainerAccessScopeResult, PublicAccessRole } from './acl/aclLogic' export { offlineTestID, appContext } from './authn/authUtil' export { performServerSideLogout } from './authn/serverLogout' export { reloadOnIdentityReplaced } from './authSession/identityState' diff --git a/test/aclLogic.test.ts b/test/aclLogic.test.ts index 6640ea1..da73ca3 100644 --- a/test/aclLogic.test.ts +++ b/test/aclLogic.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { Fetcher, Store, namedNode, sym, UpdateManager } from 'rdflib' import { ACL, applyPlan, buildACLContext, findEffectiveACL, parseTurtle, planGrant, planPublicRead, planRevoke, Public, quad, type PatchPlan, type Quad } from '@dokieli/web-access-control' -import { ACL_LINK, analyzeContainerAccessScope, classifyAuthorizationScope, classifyContainerAccessScope, createAclLogic, filterAuthorizationsByContainerScope } from '../src/acl/aclLogic' +import { ACL_LINK, WRITE_LOCKED_OWNER_ROLE, analyzeContainerAccessScope, classifyAuthorizationScope, classifyContainerAccessScope, createAclLogic, filterAuthorizationsByContainerScope } from '../src/acl/aclLogic' var actualFindEffectiveACL: typeof findEffectiveACL var actualPlanGrant: typeof planGrant @@ -165,12 +165,40 @@ describe('createAclLogic', () => { it('maps roles to ACL modes and back', () => { expect(aclLogic.modesFromRole('Editor')).toEqual(['Read', 'Write']) expect(aclLogic.modesFromRole('Owner')).toEqual(['Read', 'Write', 'Control']) + expect(aclLogic.modesFromRole(WRITE_LOCKED_OWNER_ROLE)).toEqual(['Read', 'Control']) expect(aclLogic.modesFromRole('No Access')).toEqual([]) expect(aclLogic.roleFromModes(['Read', 'Write'])).toBe('Editor') expect(aclLogic.roleFromModes(['Read'])).toBe('Viewer') + expect(aclLogic.roleFromModes(['Control', 'Read'])).toBe(WRITE_LOCKED_OWNER_ROLE) + expect(aclLogic.roleFromModes(['Read', 'Write', 'Control', 'Append'])).toBe('No Access') expect(aclLogic.roleFromModes([])).toBe('No Access') }) + it('preserves write-locked owner modes in a grant plan', async () => { + const resource = 'https://example.com/write-locked.ttl' + const ownACL = `@prefix acl: <${ACL}>. +<#existing> a acl:Authorization; + acl:accessTo <${resource}>; + acl:agent ; + acl:mode acl:Read.` + const plan = await sharingLogic(resource, ownACL).planGrant(resource, { + type: 'agent', + iri: 'https://example.com/profile/card#owner' + }, aclLogic.modesFromRole(WRITE_LOCKED_OWNER_ROLE)) + + expect(plan.inserts + .filter(statement => statement.predicate.value === ACL + 'mode') + .map(statement => statement.object.value) + .sort()) + .toEqual([ + ACL + 'Control', + ACL + 'Read' + ].sort()) + expect(plan.inserts.some(statement => + statement.predicate.value === ACL + 'mode' && + statement.object.value === ACL + 'Write')).toBe(false) + }) + it('finds the ACL document URL from the discovered link', async () => { const resource = sym('https://example.com/resource.ttl') const aclDoc = sym('https://example.com/resource.ttl.acl') diff --git a/test/publicAclApi.test.ts b/test/publicAclApi.test.ts index 984864b..8b5fd2e 100644 --- a/test/publicAclApi.test.ts +++ b/test/publicAclApi.test.ts @@ -4,11 +4,13 @@ import { Authenticated, PUBLIC_ACCESS_ROLES, Public, + WRITE_LOCKED_OWNER_ROLE, solidLogicSingleton, type ACLContext, type AccessControlSubjectKind, type AccessMode, type AccessRole, + type ContainerAccessScopeResult, type AccessSubject, type Authorization, type PatchPlan, @@ -19,6 +21,7 @@ describe('public ACL API', () => { it('exports the role list and exposes mapping through the singleton', () => { expect(ACCESS_ROLES).toEqual(['Owner', 'Editor', 'No Access', 'Viewer', 'Poster', 'Submitter']) expect(PUBLIC_ACCESS_ROLES).toEqual(['No Access', 'Viewer']) + expect(WRITE_LOCKED_OWNER_ROLE).toBe('Owner (write locked)') expect(Authenticated).toMatchObject({ type: 'agentClass', iri: 'http://www.w3.org/ns/auth/acl#AuthenticatedAgent' }) expect(Public).toMatchObject({ type: 'agentClass', iri: 'http://xmlns.com/foaf/0.1/Agent' }) for (const role of ACCESS_ROLES) { @@ -40,5 +43,10 @@ describe('public ACL API', () => { expectTypeOf(solidLogicSingleton.acl.publicRoleFromModes).toEqualTypeOf<(modes: Iterable) => PublicAccessRole>() expectTypeOf(solidLogicSingleton.acl.modesFromPublicRole).toEqualTypeOf<(role: PublicAccessRole) => AccessMode[]>() expectTypeOf().toEqualTypeOf<'agent' | 'agentGroup' | 'agentClass' | 'origin'>() + expectTypeOf().toEqualTypeOf<{ + distinctScopes: ('folder+contents' | 'folder' | 'contents')[] + primaryScope: 'folder+contents' | 'folder' | 'contents' + mixed: boolean + }>() }) }) From ebb754f18adf28a29e31757864351776511f6bcf Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Tue, 20 Oct 2026 20:02:28 +1100 Subject: [PATCH 5/6] update version --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8c86425..a56527d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "solid-logic", - "version": "6.0.1-2", + "version": "6.0.1-3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "solid-logic", - "version": "6.0.1-2", + "version": "6.0.1-3", "license": "MIT", "dependencies": { "@dokieli/web-access-control": "^1.1.0", diff --git a/package.json b/package.json index 5322212..fa1d30f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "solid-logic", - "version": "6.0.1-2", + "version": "6.0.1-3", "description": "Core business logic of SolidOS", "main": "dist/index.cjs.js", "module": "dist/index.esm.js", From 142a91230698a04f9505eec5eb5e7eb067c97d48 Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Tue, 20 Oct 2026 20:44:05 +1100 Subject: [PATCH 6/6] address PR feedback Prompt: multiple to help resolve PR feedback Co-authored-by: GPT-5.6 Luna --- src/acl/aclLogic.ts | 193 ++++++++++++++++++++------------ src/index.ts | 2 +- test/aclLogic.test.ts | 254 ++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 368 insertions(+), 81 deletions(-) diff --git a/src/acl/aclLogic.ts b/src/acl/aclLogic.ts index 9a24845..b23ae5e 100644 --- a/src/acl/aclLogic.ts +++ b/src/acl/aclLogic.ts @@ -23,9 +23,6 @@ export interface ContainerAccessScopeResult { primaryScope: ContainerAccessScope mixed: boolean } -/** @deprecated Use ContainerAccessScopeResult. */ -export type ContainerAccessScopeAnalysis = ContainerAccessScopeResult - export function classifyAuthorizationScope ( resource: string, authorization: Authorization, @@ -194,17 +191,33 @@ export function createAclLogic(store): AclLogic { return authorization[predicateByType[subject.type]]?.includes(subject.iri) ?? false } - function assertProtectedControlPreserved(context: ACLContext, subject: AccessSubject, modes?: AccessMode[]): void { + function assertProtectedControlPreserved(context: ACLContext, plan: PatchPlan): void { if (!isProtectedResource(context.resource)) { return } - const hasControl = context.authorizations.some(authorization => - authorization.mode.includes('Control') && subjectMatchesAuthorization(subject, authorization)) - const controlCount = context.authorizations.filter(authorization => authorization.mode.includes('Control')).length - const keepsControl = modes?.includes('Control') ?? false + const deletedAuthorizationIds = new Set(plan.deletes.map(statement => statement.subject.value)) + const insertedAuthorizations = new Map() + for (const statement of plan.inserts) { + const authorization = insertedAuthorizations.get(statement.subject.value) ?? { modes: [], accessTo: [] } + if (statement.predicate.value === ACL + 'mode') { + authorization.modes.push(statement.object.value) + } else if (statement.predicate.value === ACL + 'accessTo') { + authorization.accessTo.push(statement.object.value) + } + insertedAuthorizations.set(statement.subject.value, authorization) + } - if (hasControl && controlCount <= 1 && !keepsControl) { + const survivingControl = !context.inherited && context.authorizations.some(authorization => + !deletedAuthorizationIds.has(authorization.id) && + authorization.mode.includes('Control') && + authorization.accessTo.includes(context.resource) + ) || [...insertedAuthorizations.values()].some(authorization => + authorization.modes.includes(ACL + 'Control') && + authorization.accessTo.includes(context.resource) + ) + + if (!survivingControl) { throw new Error('The protected storage resource must retain at least one Control authorization.') } } @@ -226,11 +239,14 @@ export function createAclLogic(store): AclLogic { async function planGrant(resourceURL: string | NamedNode, subject: AccessSubject, modes: AccessMode[]): Promise { const context = await findEffectiveAcl(resourceURL) - assertProtectedControlPreserved(context, subject, modes) if (context.inherited && new URL(context.resource).pathname.endsWith('/')) { - return materializeInheritedContainer(context, subject, modes, 'folder') + const plan = materializeInheritedContainer(context, subject, modes, 'folder') + assertProtectedControlPreserved(context, plan) + return plan } - return planAuthorizationGrant(context, subject, modes) + const plan = planAuthorizationGrant(context, subject, modes) + assertProtectedControlPreserved(context, plan) + return plan } function assertContainer(context: ACLContext): void { @@ -327,10 +343,16 @@ export function createAclLogic(store): AclLogic { ...(authorization.condition ?? []).flatMap(condition => { const conditionId = freshId(`${id}-condition`) const conditionNode = namedNode(conditionId) + const conditionStatements = context.dataset + .filter(statement => statement.subject.value === condition.id) + .map(statement => quad( + conditionNode, + statement.predicate, + statement.object + )) return [ quad(rule, namedNode(ACL + 'condition'), conditionNode), - ...Object.entries(condition.properties).flatMap(([predicate, values]) => - values.map(value => quad(conditionNode, namedNode(predicate), namedNode(value)))) + ...conditionStatements ] }) ) @@ -367,10 +389,14 @@ export function createAclLogic(store): AclLogic { }) }) if (!revoke) { + const selectedAuthorization = context.authorizations.find(authorization => + authorization[subjectKey]?.includes(subject.iri) && + (authorization.default?.length ?? 0) > 0) const newAuthorization: Authorization = { id: `${context.defaultACLResource}#inherited-new-source`, blankNode: false, - accessTo: [], default: [], agent: [], agentClass: [], agentGroup: [], origin: [], mode: [], condition: [] + accessTo: [], default: [], agent: [], agentClass: [], agentGroup: [], origin: [], mode: [], + condition: selectedAuthorization?.condition ?? [] } addRule(onlySubject(newAuthorization), `${context.defaultACLResource}#inherited-new`, { accessTo: scope !== 'contents', @@ -383,17 +409,21 @@ export function createAclLogic(store): AclLogic { async function planGrantContainer(containerURL: string | NamedNode, subject: AccessSubject, modes: AccessMode[]): Promise { const context = await findEffectiveAcl(containerURL) assertContainer(context) - assertProtectedControlPreserved(context, subject, modes) - if (context.inherited) return materializeInheritedContainer(context, subject, modes, 'folder') - return planAuthorizationGrant(context, subject, modes) + const plan = context.inherited + ? materializeInheritedContainer(context, subject, modes, 'folder') + : planAuthorizationGrant(context, subject, modes) + assertProtectedControlPreserved(context, plan) + return plan } async function planGrantContainerAndContents(containerURL: string | NamedNode, subject: AccessSubject, modes: AccessMode[]): Promise { const context = await findEffectiveAcl(containerURL) assertContainer(context) - assertProtectedControlPreserved(context, subject, modes) - if (context.inherited) return materializeInheritedContainer(context, subject, modes, 'folder+contents') - return withContainerDefaults(context, planAuthorizationGrant(context, subject, modes)) + const plan = context.inherited + ? materializeInheritedContainer(context, subject, modes, 'folder+contents') + : withContainerDefaults(context, planAuthorizationGrant(context, subject, modes)) + assertProtectedControlPreserved(context, plan) + return plan } async function planGrantContainerContents(containerURL: string | NamedNode, subject: AccessSubject, modes: AccessMode[]): Promise { @@ -403,26 +433,12 @@ export function createAclLogic(store): AclLogic { throw new Error('Applying access to the contents of a protected storage resource is not allowed.') } if (context.inherited) return materializeInheritedContainer(context, subject, modes, 'contents') - const sharedAuthorization = context.authorizations.find(authorization => + const matchingAuthorizations = context.authorizations.filter(authorization => authorization.default?.includes(context.resource) && - subjectMatchesAuthorization(subject, authorization) && - ['agent', 'agentClass', 'agentGroup', 'origin'].reduce((count, key) => - count + authorization[key].length, 0) > 1) + subjectMatchesAuthorization(subject, authorization)) - if (sharedAuthorization) { - return splitDirectContainerContentsAuthorization(context, sharedAuthorization, subject, modes) - } - - const existingDefaultOnly = context.authorizations.find(authorization => - authorization.default?.includes(context.resource) && - !authorization.accessTo?.includes(context.resource) && - authorization[subject.type]?.includes(subject.iri) && - (authorization.agent.length + authorization.agentClass.length + authorization.agentGroup.length + authorization.origin.length) === 1) - - if (existingDefaultOnly && !context.inherited) { - // The upstream planner can safely update a default-only rule in - // place; unlike a resource grant it will not add acl:accessTo. - return planAuthorizationGrant(context, subject, modes) + if (matchingAuthorizations.length) { + return splitDirectContainerContentsAuthorizations(context, matchingAuthorizations, subject, modes) } const subjectKey = subject.type @@ -449,14 +465,12 @@ export function createAclLogic(store): AclLogic { const defaultPredicate = ACL + 'default' if (!context.inherited) { - const sharedAuthorization = context.authorizations.find(authorization => + const matchingAuthorizations = context.authorizations.filter(authorization => authorization.default?.includes(context.resource) && - subjectMatchesAuthorization(subject, authorization) && - ['agent', 'agentClass', 'agentGroup', 'origin'].reduce((count, key) => - count + authorization[key].length, 0) > 1) + subjectMatchesAuthorization(subject, authorization)) - if (sharedAuthorization) { - return splitDirectContainerContentsAuthorization(context, sharedAuthorization, subject) + if (matchingAuthorizations.length) { + return splitDirectContainerContentsAuthorizations(context, matchingAuthorizations, subject) } const deletes = context.dataset.filter(statement => @@ -471,66 +485,103 @@ export function createAclLogic(store): AclLogic { return { target: context.defaultACLResource, deletes, inserts: [] } } - const plan = materializeInheritedContainer(context, subject, undefined, 'contents', true) - if (!plan.inserts.length) { + const hasInheritedDefault = context.authorizations.some(authorization => + authorization.default?.includes(context.resource) && + subjectMatchesAuthorization(subject, authorization)) + if (!hasInheritedDefault) { throw new Error(`No default authorization found for ${subject.iri}`) } + const plan = materializeInheritedContainer(context, subject, undefined, 'contents', true) return plan } - function splitDirectContainerContentsAuthorization ( + function splitDirectContainerContentsAuthorizations ( context: ACLContext, - authorization: Authorization, + authorizations: Authorization[], subject: AccessSubject, modes?: AccessMode[] ): PatchPlan { const freshId = createAuthorizationIdAllocator(context) - const subjects = subjectKeys.flatMap(key => - authorization[key].map(iri => ({ key, iri })) - ) - const remainingSubjects = subjects.filter(({ key, iri }) => - !(key === subject.type && iri === subject.iri)) - const target = namedNode(freshId(`${context.defaultACLResource}#split-1`)) - const retained = namedNode(freshId(`${context.defaultACLResource}#retained-1`)) const inserts: PatchPlan['inserts'] = [] const addRule = ( rule: ReturnType, ruleSubjects: Array<{ key: typeof subjectKeys[number], iri: string }>, ruleModes: AccessMode[], - includeAccessTo: boolean, - includeDefault: boolean + accessTo: string[], + defaults: string[], + conditions: string[] ) => { - if (!ruleSubjects.length || (!includeAccessTo && !includeDefault)) return + if (!ruleSubjects.length || (!accessTo.length && !defaults.length)) return inserts.push( quad(rule, namedNode('http://www.w3.org/1999/02/22-rdf-syntax-ns#type'), namedNode(ACL + 'Authorization')), - ...(includeAccessTo ? [quad(rule, namedNode(ACL + 'accessTo'), namedNode(context.resource))] : []), - ...(includeDefault ? [quad(rule, namedNode(ACL + 'default'), namedNode(context.resource))] : []), + ...accessTo.map(target => quad(rule, namedNode(ACL + 'accessTo'), namedNode(target))), + ...defaults.map(target => quad(rule, namedNode(ACL + 'default'), namedNode(target))), ...ruleModes.map(mode => quad(rule, namedNode(ACL + 'mode'), namedNode(`http://www.w3.org/ns/auth/acl#${mode}`))), + ...conditions.map(condition => quad(rule, namedNode(ACL + 'condition'), namedNode(condition))), ...ruleSubjects.map(({ key, iri }) => quad(rule, namedNode(ACL + key), namedNode(iri))) ) } - const hasAccessTo = authorization.accessTo.includes(context.resource) - const hasDefault = authorization.default.includes(context.resource) - addRule(retained, remainingSubjects, authorization.mode, hasAccessTo, hasDefault) - if (modes) { - addRule(target, [{ key: subject.type, iri: subject.iri }], modes, hasAccessTo, hasDefault) - } + const deletes: PatchPlan['deletes'] = [] + authorizations.forEach((authorization, index) => { + const subjects = subjectKeys.flatMap(key => + authorization[key].map(iri => ({ key, iri })) + ) + const remainingSubjects = subjects.filter(({ key, iri }) => + !(key === subject.type && iri === subject.iri)) + const baseId = `${context.defaultACLResource}#split-${index + 1}` + + if (!modes && subjects.length === 1 && remainingSubjects.length === 0) { + deletes.push(...context.dataset.filter(statement => + statement.subject.value === authorization.id && + statement.predicate.value === ACL + 'default' && + statement.object.value === context.resource)) + return + } + + deletes.push(...context.dataset.filter(statement => statement.subject.value === authorization.id)) + const conditions = (authorization.condition ?? []).map(condition => condition.id) + addRule(namedNode(freshId(`${context.defaultACLResource}#retained-${index + 1}`)), remainingSubjects, authorization.mode, authorization.accessTo, authorization.default, conditions) + const retainedDefaults = authorization.default.filter(target => target !== context.resource) + if (authorization.accessTo.length || retainedDefaults.length) { + addRule( + namedNode(freshId(`${baseId}-retained`)), + [{ key: subject.type, iri: subject.iri }], + authorization.mode, + authorization.accessTo, + retainedDefaults, + conditions + ) + } + if (modes) { + addRule( + namedNode(freshId(`${baseId}`)), + [{ key: subject.type, iri: subject.iri }], + modes, + [], + authorization.default.filter(target => target === context.resource), + conditions + ) + } + }) return { target: context.defaultACLResource, - deletes: context.dataset.filter(statement => statement.subject.value === authorization.id), + deletes, inserts } } async function planRevoke(resourceURL: string | NamedNode, subject: AccessSubject): Promise { const context = await findEffectiveAcl(resourceURL) - assertProtectedControlPreserved(context, subject) if (context.inherited && new URL(context.resource).pathname.endsWith('/')) { - return materializeInheritedContainer(context, subject, undefined, 'folder+contents', true) + const plan = materializeInheritedContainer(context, subject, undefined, 'folder+contents', true) + assertProtectedControlPreserved(context, plan) + return plan } - return withContainerDefaults(context, planAuthorizationRevoke(context, subject)) + const plan = withContainerDefaults(context, planAuthorizationRevoke(context, subject)) + assertProtectedControlPreserved(context, plan) + return plan } async function planPublicRead(resourceURL: string | NamedNode, enabled: boolean): Promise { diff --git a/src/index.ts b/src/index.ts index 3929498..7413655 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,7 +7,7 @@ const authSession = solidLogicSingleton.authn.authSession const store = solidLogicSingleton.store export { ACL_LINK, ACCESS_ROLES, PUBLIC_ACCESS_ROLES, WRITE_LOCKED_OWNER_ROLE, Authenticated, Public, classifyContainerAccessScope, analyzeContainerAccessScope, classifyAuthorizationScope, filterAuthorizationsByContainerScope } from './acl/aclLogic' -export type { AccessRole, ContainerAccessScope, ContainerAccessScopeAnalysis, ContainerAccessScopeResult, PublicAccessRole } from './acl/aclLogic' +export type { AccessRole, ContainerAccessScope, ContainerAccessScopeResult, PublicAccessRole } from './acl/aclLogic' export { offlineTestID, appContext } from './authn/authUtil' export { performServerSideLogout } from './authn/serverLogout' export { reloadOnIdentityReplaced } from './authSession/identityState' diff --git a/test/aclLogic.test.ts b/test/aclLogic.test.ts index da73ca3..bff8260 100644 --- a/test/aclLogic.test.ts +++ b/test/aclLogic.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { Fetcher, Store, namedNode, sym, UpdateManager } from 'rdflib' +import { Fetcher, Store, literal, namedNode, sym, UpdateManager } from 'rdflib' import { ACL, applyPlan, buildACLContext, findEffectiveACL, parseTurtle, planGrant, planPublicRead, planRevoke, Public, quad, type PatchPlan, type Quad } from '@dokieli/web-access-control' import { ACL_LINK, WRITE_LOCKED_OWNER_ROLE, analyzeContainerAccessScope, classifyAuthorizationScope, classifyContainerAccessScope, createAclLogic, filterAuthorizationsByContainerScope } from '../src/acl/aclLogic' @@ -336,13 +336,16 @@ describe('createAclLogic', () => { const alice = 'https://example.com/alice#me' const bob = 'https://example.com/bob#me' const authorization = namedNode('https://example.com/alice/.acl#shared') + const condition = namedNode('https://example.com/alice/.acl#trusted-client') const statements = [ quad(authorization, namedNode(ACL + 'type'), namedNode(ACL + 'Authorization')), + quad(authorization, namedNode(ACL + 'condition'), condition), quad(authorization, namedNode(ACL + 'default'), resource), quad(authorization, namedNode(ACL + 'agent'), namedNode(alice)), quad(authorization, namedNode(ACL + 'agent'), namedNode(bob)), quad(authorization, namedNode(ACL + 'mode'), namedNode(ACL + 'Read')), - quad(authorization, namedNode(ACL + 'mode'), namedNode(ACL + 'Write')) + quad(authorization, namedNode(ACL + 'mode'), namedNode(ACL + 'Write')), + quad(condition, namedNode(ACL + 'description'), literal('trusted client only', 'en')) ] vi.mocked(findEffectiveACL).mockResolvedValue({ resource: resource.value, @@ -357,14 +360,18 @@ describe('createAclLogic', () => { mode: ['Read', 'Write'], default: [resource.value], accessTo: [], - condition: [], + condition: [{ + id: condition.value, + blankNode: false, + properties: { [ACL + 'description']: ['trusted client only'] } + }], blankNode: false }], dataset: statements } as any) const plan = await aclLogic.planGrantContainerContents(resource, { type: 'agent', iri: alice }, ['Read']) - expect(plan.deletes).toEqual(statements) + expect(plan.deletes).toEqual(statements.filter(statement => statement.subject.equals(authorization))) const aliceRule = plan.inserts.find(statement => statement.predicate.value === ACL + 'agent' && statement.object.value === alice)?.subject @@ -378,6 +385,133 @@ describe('createAclLogic', () => { .some(statement => statement.predicate.value === ACL + 'mode' && statement.object.value === ACL + 'Write')).toBe(false) expect(plan.inserts.filter(statement => statement.subject.equals(bobRule!)) .some(statement => statement.predicate.value === ACL + 'mode' && statement.object.value === ACL + 'Write')).toBe(true) + expect(plan.inserts.filter(statement => statement.subject.equals(aliceRule!)) + .some(statement => statement.predicate.value === ACL + 'condition' && statement.object.equals(condition))).toBe(true) + expect(plan.inserts.filter(statement => statement.subject.equals(bobRule!)) + .some(statement => statement.predicate.value === ACL + 'condition' && statement.object.equals(condition))).toBe(true) + }) + + it('processes every matching default authorization for a subject', async () => { + const resource = sym('https://example.com/alice/') + const alice = 'https://example.com/alice#me' + const bob = 'https://example.com/bob#me' + const carol = 'https://example.com/carol#me' + const first = namedNode(resource.value + '.acl#alice-bob') + const second = namedNode(resource.value + '.acl#alice-carol') + const statements = [first, second].flatMap((authorization, index) => [ + quad(authorization, namedNode(ACL + 'type'), namedNode(ACL + 'Authorization')), + quad(authorization, namedNode(ACL + 'default'), resource), + quad(authorization, namedNode(ACL + 'agent'), namedNode(alice)), + quad(authorization, namedNode(ACL + 'agent'), namedNode(index === 0 ? bob : carol)), + quad(authorization, namedNode(ACL + 'mode'), namedNode(ACL + 'Read')), + quad(authorization, namedNode(ACL + 'mode'), namedNode(ACL + 'Write')) + ]) + vi.mocked(findEffectiveACL).mockResolvedValue({ + resource: resource.value, + defaultACLResource: resource.value + '.acl', + inherited: false, + authorizations: [bob, carol].map((other, index) => ({ + id: [first, second][index].value, + agent: [alice, other], + agentClass: [], + agentGroup: [], + origin: [], + mode: ['Read', 'Write'], + default: [resource.value], + accessTo: [], + condition: [], + blankNode: false + })), + dataset: statements + } as any) + + const plan = await aclLogic.planGrantContainerContents(resource, { type: 'agent', iri: alice }, ['Read']) + expect(plan.deletes).toEqual(statements) + expect(plan.inserts.filter(statement => + statement.predicate.value === ACL + 'agent' && + [bob, carol].includes(statement.object.value))).toHaveLength(2) + const aliceRules = new Set(plan.inserts + .filter(statement => statement.predicate.value === ACL + 'agent' && statement.object.value === alice) + .map(statement => statement.subject.value)) + expect(aliceRules).toHaveLength(2) + for (const rule of aliceRules) { + expect(plan.inserts.some(statement => + statement.subject.value === rule && + statement.predicate.value === ACL + 'mode' && + statement.object.value === ACL + 'Read')).toBe(true) + expect(plan.inserts.some(statement => + statement.subject.value === rule && + statement.predicate.value === ACL + 'mode' && + statement.object.value === ACL + 'Write')).toBe(false) + } + }) + + it('splits a single-subject folder-and-contents rule when changing contents access', async () => { + const resource = sym('https://example.com/alice/') + const alice = 'https://example.com/alice#me' + const unrelatedFolder = 'https://example.com/other-folder/' + const unrelatedContents = 'https://example.com/other/' + const authorization = namedNode(resource.value + '.acl#alice') + const statements = [ + quad(authorization, namedNode(ACL + 'type'), namedNode(ACL + 'Authorization')), + quad(authorization, namedNode(ACL + 'accessTo'), resource), + quad(authorization, namedNode(ACL + 'accessTo'), namedNode(unrelatedFolder)), + quad(authorization, namedNode(ACL + 'default'), resource), + quad(authorization, namedNode(ACL + 'default'), namedNode(unrelatedContents)), + quad(authorization, namedNode(ACL + 'agent'), namedNode(alice)), + quad(authorization, namedNode(ACL + 'mode'), namedNode(ACL + 'Read')), + quad(authorization, namedNode(ACL + 'mode'), namedNode(ACL + 'Write')) + ] + vi.mocked(findEffectiveACL).mockResolvedValue({ + resource: resource.value, + defaultACLResource: resource.value + '.acl', + inherited: false, + authorizations: [{ + id: authorization.value, + agent: [alice], + agentClass: [], + agentGroup: [], + origin: [], + mode: ['Read', 'Write'], + accessTo: [resource.value, unrelatedFolder], + default: [resource.value, unrelatedContents], + condition: [], + blankNode: false + }], + dataset: statements + } as any) + + const plan = await aclLogic.planGrantContainerContents(resource, { type: 'agent', iri: alice }, ['Read']) + expect(plan.deletes).toEqual(statements) + const aliceRules = [...new Set(plan.inserts + .filter(statement => statement.predicate.value === ACL + 'agent' && statement.object.value === alice) + .map(statement => statement.subject.value))] + expect(aliceRules).toHaveLength(2) + const folderRule = aliceRules.find(rule => plan.inserts.some(statement => + statement.subject.value === rule && + statement.predicate.value === ACL + 'accessTo')) + const contentsRule = aliceRules.find(rule => plan.inserts.some(statement => + statement.subject.value === rule && + statement.predicate.value === ACL + 'default' && + statement.object.value === resource.value)) + expect(folderRule).toBeDefined() + expect(contentsRule).toBeDefined() + expect(plan.inserts.some(statement => + statement.subject.value === folderRule && + statement.predicate.value === ACL + 'mode' && + statement.object.value === ACL + 'Write')).toBe(true) + expect(plan.inserts.some(statement => + statement.subject.value === folderRule && + statement.predicate.value === ACL + 'accessTo' && + statement.object.value === unrelatedFolder)).toBe(true) + expect(plan.inserts.some(statement => + statement.subject.value === folderRule && + statement.predicate.value === ACL + 'default' && + statement.object.value === unrelatedContents)).toBe(true) + expect(plan.inserts.some(statement => + statement.subject.value === contentsRule && + statement.predicate.value === ACL + 'mode' && + statement.object.value === ACL + 'Write')).toBe(false) }) it('allocates deterministic, collision-free IDs when splitting a shared authorization', async () => { @@ -507,22 +641,104 @@ describe('createAclLogic', () => { } as any)).rejects.toThrow('No default authorization found') }) + it('rejects inherited contents revocation when only unrelated defaults match', async () => { + const resource = sym('https://example.com/alice/') + const subject = { type: 'agent', iri: 'https://example.com/person#me' } + vi.mocked(findEffectiveACL).mockResolvedValue({ + resource: resource.value, + defaultACLResource: resource.value + '.acl', + inherited: true, + authorizations: [{ + id: 'https://example.com/.acl#owner', + agent: ['https://example.com/owner#me'], + agentClass: [], + agentGroup: [], + origin: [], + mode: ['Read'], + default: [resource.value], + accessTo: [], + condition: [], + blankNode: false + }], + dataset: [] + } as any) + + await expect(aclLogic.planRevokeContainerContents(resource, subject as any)) + .rejects.toThrow('No default authorization found') + }) + it('keeps the last Control authorization on a protected resource', async () => { const resource = sym('https://example.com/alice/') const subject = { type: 'agent', iri: 'https://example.com/profile/card#me' } store.holds.mockReturnValue(true) vi.mocked(findEffectiveACL).mockResolvedValue({ resource: resource.value, + defaultACLResource: resource.value + '.acl', + inherited: false, + dataset: [], authorizations: [{ + id: resource.value + '.acl#owner', agent: [subject.iri], - mode: ['Read', 'Write', 'Control'] + agentClass: [], + agentGroup: [], + origin: [], + mode: ['Read', 'Write', 'Control'], + accessTo: [resource.value], + default: [], + condition: [], + blankNode: false }] } as any) + const controlRule = namedNode(resource.value + '.acl#owner') + const deletedControl = quad(controlRule, namedNode(ACL + 'mode'), namedNode(ACL + 'Control')) + vi.mocked(planGrant).mockReturnValue({ target: resource.value + '.acl', deletes: [deletedControl], inserts: [] }) + vi.mocked(planRevoke).mockReturnValue({ target: resource.value + '.acl', deletes: [deletedControl], inserts: [] }) await expect(aclLogic.planGrant(resource, subject as any, ['Read'])).rejects.toThrow('retain at least one Control') await expect(aclLogic.planRevoke(resource, subject as any)).rejects.toThrow('retain at least one Control') }) + it('does not count duplicate inherited Control rules as surviving control', async () => { + const resource = sym('https://example.com/alice/') + const subject = { type: 'agent', iri: 'https://example.com/profile/card#me' } + store.holds.mockReturnValue(true) + vi.mocked(findEffectiveACL).mockResolvedValue({ + resource: resource.value, + defaultACLResource: 'https://example.com/.acl', + inherited: true, + dataset: [], + authorizations: [ + { + id: 'https://example.com/.acl#owner-folder', + agent: [subject.iri], + agentClass: [], + agentGroup: [], + origin: [], + mode: ['Control'], + accessTo: [resource.value], + default: [], + condition: [], + blankNode: false + }, + { + id: 'https://example.com/.acl#owner-contents', + agent: [subject.iri], + agentClass: [], + agentGroup: [], + origin: [], + mode: ['Control'], + accessTo: [], + default: [resource.value], + condition: [], + blankNode: false + } + ] + } as any) + vi.mocked(planRevoke).mockImplementation(actualPlanRevoke as any) + + await expect(aclLogic.planRevoke(resource, subject as any)).rejects.toThrow('retain at least one Control') + }) + it('delegates planPublicRead through the effective ACL context', async () => { const resource = sym('https://example.com/resource.ttl') const plan = { updates: [] } @@ -659,6 +875,16 @@ describe('createAclLogic', () => { statement.predicate.value === conditionClient.value && statement.object.value === conditionValue.value )).toBe(true) + const replacementRules = new Set(plan.inserts + .filter(statement => + statement.predicate.value === 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type' && + statement.object.value === ACL + 'Authorization') + .map(statement => statement.subject.value)) + for (const rule of replacementRules) { + expect(plan.inserts.some(statement => + statement.subject.value === rule && + statement.predicate.value === ACL + 'condition')).toBe(true) + } }) it('rejects unsupported condition properties instead of silently dropping them', async () => { @@ -718,6 +944,8 @@ describe('createAclLogic', () => { const subject = { type: 'agent' as const, iri: 'https://example.com/viewer#me' } const collidingId = resource.value + '.acl#inherited-1' const conditionId = 'https://example.com/.acl#client-condition' + const conditionNode = namedNode(conditionId) + const description = literal('only from the trusted client', 'en') vi.mocked(findEffectiveACL).mockResolvedValue(inheritedContext(resource.value, [{ id: collidingId, agent: ['https://example.com/owner#me'], agentClass: [], agentGroup: [], origin: [], @@ -730,7 +958,11 @@ describe('createAclLogic', () => { } }], blankNode: false - }], [quad(namedNode(collidingId), namedNode(ACL + 'Authorization'), namedNode(ACL + 'Authorization'))])) + }], [ + quad(namedNode(collidingId), namedNode(ACL + 'Authorization'), namedNode(ACL + 'Authorization')), + quad(conditionNode, namedNode(ACL + 'ClientCondition'), namedNode('https://example.com/client')), + quad(conditionNode, namedNode(ACL + 'description'), description) + ])) const plan = await aclLogic.planGrantContainer(resource, subject, ['Read']) const ruleIds = new Set(plan.inserts @@ -742,9 +974,13 @@ describe('createAclLogic', () => { expect(plan.inserts.some(statement => statement.predicate.value === ACL + 'condition' && statement.object.value !== conditionId)).toBe(true) - expect(plan.inserts.some(statement => - statement.predicate.value === ACL + 'description' && - statement.object.value === 'only from the trusted client')).toBe(true) + const descriptionStatement = plan.inserts.find(statement => + statement.predicate.value === ACL + 'description') + if (!descriptionStatement || descriptionStatement.object.termType !== 'Literal') { + throw new Error('Expected the cloned description to remain an RDF literal') + } + expect(descriptionStatement.object.value).toBe(description.value) + expect(descriptionStatement.object.language).toBe('en') }) it('preserves inherited default access when materializing a contents-only grant', async () => {