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