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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2,101 changes: 574 additions & 1,527 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
"solidos-toolkit": "dev"
},
"dependencies": {
"@dokieli/web-access-control": "^1.0.0",
"@uvdsl/solid-oidc-client-browser": "^0.2.3",
"solid-namespace": "^0.5.4",
"vite": "^8.0.16",
Expand Down
59 changes: 58 additions & 1 deletion src/acl/aclLogic.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,17 @@
import { applyPlan as applyAuthorizationPlan, findEffectiveACL, planGrant as planAuthorizationGrant, planPublicRead as planAuthorizationPublicRead, planRevoke as planAuthorizationRevoke, type AccessMode, type AccessSubject, type ACLContext, type Authorization, type PatchPlan } from '@dokieli/web-access-control'
import { graph, NamedNode, Namespace, serialize, sym } from 'rdflib'
import { AclLogic } from '../types'
import type { AclLogic } from '../types'
import { ns as namespace } from '../util/ns'

// 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 ACL_LINK = sym(
'http://www.iana.org/assignments/link-relations/acl'
Expand All @@ -10,6 +20,18 @@ export const ACL_LINK = sym(
export function createAclLogic(store): AclLogic {

const ns = namespace

function getFetch() {
const fetcher = store.fetcher as {
_fetch?: (url: string, init?: RequestInit) => Promise<Response>
fetch?: (url: string, init?: RequestInit) => Promise<Response>
} | undefined
const fetch = fetcher?._fetch ?? fetcher?.fetch
if (!fetch) {
throw new Error('Cannot find effective ACL, store has no fetcher')
}
return fetch.bind(fetcher)
}

async function findAclDocUrl(url: NamedNode) {
await store.fetcher.load(url)
Expand All @@ -19,6 +41,35 @@ export function createAclLogic(store): AclLogic {
}
return docNode.value
}

async function findEffectiveAcl(resourceURL: string | NamedNode): Promise<ACLContext> {
const url = typeof resourceURL === 'string' ? resourceURL : resourceURL.value
return findEffectiveACL(url, { fetch: getFetch() })
}

async function findAccessGrants(resourceURL: string | NamedNode): Promise<Authorization[]> {
const context = await findEffectiveAcl(resourceURL)
return context.authorizations
}

async function planGrant(resourceURL: string | NamedNode, subject: AccessSubject, modes: AccessMode[]): Promise<PatchPlan> {
const context = await findEffectiveAcl(resourceURL)
return planAuthorizationGrant(context, subject, modes)
}

async function planRevoke(resourceURL: string | NamedNode, subject: AccessSubject): Promise<PatchPlan> {
const context = await findEffectiveAcl(resourceURL)
return planAuthorizationRevoke(context, subject)
}

async function planPublicRead(resourceURL: string | NamedNode, enabled: boolean): Promise<PatchPlan> {
const context = await findEffectiveAcl(resourceURL)
return planAuthorizationPublicRead(context, enabled)
}

async function applyPlan(plan: PatchPlan): Promise<Response> {
return applyAuthorizationPlan(plan, { fetch: getFetch() })
}
/**
* Simple Access Control
*
Expand Down Expand Up @@ -149,6 +200,12 @@ export function createAclLogic(store): AclLogic {
}
return {
findAclDocUrl,
findEffectiveAcl,
findAccessGrants,
planGrant,
planRevoke,
planPublicRead,
applyPlan,
setACLUserPublic,
genACLText
}
Expand Down
17 changes: 15 additions & 2 deletions src/resource/resourceLogic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import { ACL_LINK } from '../acl/aclLogic'
import { ns } from '../util/ns'
import { assertSuccessfulHttpResponse, isMissingError } from './resourceHttp'
import { readWacAccessInfo } from './resourceMetadata'
import { type AclLogic, type ResourceAccess, type ResourceAccessWithDelete, type ResourceDeleteOptions, type ResourceLogic, type ResourceMetadata, type ResourceMetadataWithDelete, type TypeIndexLogic } from '../types'
import { type ResourceAccess, type ResourceAccessWithDelete, type ResourceDeleteOptions, type ResourceLogic, type ResourceMetadata, type ResourceMetadataWithDelete, type TypeIndexLogic } from '../types'
import type { AclLogic } from '../types'

export function createResourceLogic(store, aclLogic: AclLogic, containerLogic, typeIndexLogic: TypeIndexLogic): ResourceLogic {
function createContainer(url: string) {
Expand All @@ -18,6 +19,17 @@ export function createResourceLogic(store, aclLogic: AclLogic, containerLogic, t
return containerLogic.getContainerMemberCount(resourceNode)
}

function isWebId(resource: string | NamedNode) {
const uri = typeof resource === 'string' ? resource : resource.value

try {
const parsed = new URL(uri)
return parsed.hash === '#me' && /\/profile\/card\.ttl$/.test(parsed.pathname)
} catch (_error) {
return false
}
}

function readMetadata(subject: NamedNode, response: Response): ResourceMetadata {
let contentType: string | undefined
let canEdit = false
Expand Down Expand Up @@ -175,6 +187,7 @@ export function createResourceLogic(store, aclLogic: AclLogic, containerLogic, t
fetchMetadataWithDelete,
createContainer,
isContainer,
getContainerMemberCount
getContainerMemberCount,
isWebId
}
}
12 changes: 11 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { SessionWithLegacyEvents } from './authSession/authSession'
import type { ACLContext, AccessMode, AccessSubject, Authorization, PatchPlan } from '@dokieli/web-access-control'
import { LiveStore, NamedNode, Statement } from 'rdflib'

export type AppDetails = {
Expand Down Expand Up @@ -79,6 +80,12 @@ export interface ProfileLogic {

export interface AclLogic {
findAclDocUrl: (url: NamedNode) => Promise<string | undefined>,
findEffectiveAcl: (resourceURL: string | NamedNode) => Promise<ACLContext>,
findAccessGrants: (resourceURL: string | NamedNode) => Promise<Authorization[]>,
planGrant: (resourceURL: string | NamedNode, subject: AccessSubject, modes: AccessMode[]) => Promise<PatchPlan>,
planRevoke: (resourceURL: string | NamedNode, subject: AccessSubject) => Promise<PatchPlan>,
planPublicRead: (resourceURL: string | NamedNode, enabled: boolean) => Promise<PatchPlan>,
Comment thread
SharonStrats marked this conversation as resolved.
applyPlan: (plan: PatchPlan) => Promise<Response>,
setACLUserPublic: (docURI: string, me: NamedNode,
options: {
defaultForNew?: boolean,
Expand All @@ -93,6 +100,8 @@ export interface AclLogic {
) => string | undefined
}

export type { ACLContext, AccessMode, AccessSubject, Authorization, PatchPlan }

export interface InboxLogic {
createInboxFor: (peerWebId: string, nick: string) => Promise<string>,
getNewMessages: (user?: NamedNode) => Promise<NamedNode[]>,
Expand Down Expand Up @@ -132,7 +141,8 @@ export interface ResourceLogic {
fetchMetadataWithDelete: (subject: NamedNode) => Promise<ResourceMetadataWithDelete>,
createContainer: (url: string) => Promise<void>,
isContainer: (resource: NamedNode) => boolean,
getContainerMemberCount: (resource: NamedNode) => number
getContainerMemberCount: (resource: NamedNode) => number,
isWebId: (resource: string | NamedNode) => boolean
}

export interface TypeIndexLogic {
Expand Down
159 changes: 136 additions & 23 deletions test/aclLogic.test.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,138 @@
import { beforeAll, describe, expect, it } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { Fetcher, Store, sym, UpdateManager } from 'rdflib'
import { createAclLogic } from '../src/acl/aclLogic'

describe('setACLUserPublic', () => {
let aclLogic: any
let store: Store
beforeAll(() => {
const options = { fetch: fetch }
store = new Store()
store.fetcher = new Fetcher(store, options)
store.updater = new UpdateManager(store)
aclLogic = createAclLogic(store)
})
it('exists', () => {
expect(aclLogic.setACLUserPublic).toBeInstanceOf(Function)
})
it.skip('runs', async () => {
expect(await aclLogic.setACLUserPublic(
'https://test.test#',
sym('https://test.test#'),
{}
)).toEqual({})
})
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', () => ({
findEffectiveACL: vi.fn(),
planGrant: vi.fn(),
planPublicRead: vi.fn(),
planRevoke: vi.fn(),
applyPlan: vi.fn(),
Authenticated: Symbol('Authenticated'),
Public: Symbol('Public'),
planOwnerControl: vi.fn()
}))

describe('createAclLogic', () => {
let store: Store & {
fetcher: Fetcher & {
_fetch: ReturnType<typeof vi.fn>
load: ReturnType<typeof vi.fn>
webOperation: ReturnType<typeof vi.fn>
}
any: ReturnType<typeof vi.fn>
}
let aclLogic: ReturnType<typeof createAclLogic>

beforeEach(() => {
const fetcher = {
_fetch: vi.fn(),
load: vi.fn(),
webOperation: vi.fn(),
unload: vi.fn()
} as unknown as Fetcher & {
_fetch: ReturnType<typeof vi.fn>
load: ReturnType<typeof vi.fn>
webOperation: ReturnType<typeof vi.fn>
unload: ReturnType<typeof vi.fn>
}

store = {
fetcher,
updater: new UpdateManager(new Store()),
any: vi.fn()
} as unknown as Store & {
fetcher: typeof fetcher
any: ReturnType<typeof vi.fn>
}

aclLogic = createAclLogic(store)
})

it('exports the ACL link relation constant', () => {
expect(ACL_LINK.value).toBe('http://www.iana.org/assignments/link-relations/acl')
})

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')
const loadResult = { ok: true }

store.fetcher.load.mockResolvedValue(loadResult)
store.any.mockReturnValue(aclDoc)

await expect(aclLogic.findAclDocUrl(resource)).resolves.toBe(aclDoc.value)
expect(store.fetcher.load).toHaveBeenCalledWith(resource)
expect(store.any).toHaveBeenCalledWith(resource, ACL_LINK)
})

it('returns the authorizations from the effective ACL context', async () => {
const resource = sym('https://example.com/resource.ttl')
const context = { authorizations: [{ id: 'a1' }] }

vi.mocked(findEffectiveACL).mockResolvedValue(context as any)
store.fetcher._fetch.mockResolvedValue(new Response('', { status: 200 }))

await expect(aclLogic.findAccessGrants(resource)).resolves.toEqual(context.authorizations)
expect(findEffectiveACL).toHaveBeenCalledWith(resource.value, expect.objectContaining({ fetch: expect.any(Function) }))
})

it('delegates planGrant through the effective ACL context', async () => {
const resource = sym('https://example.com/resource.ttl')
const subject = { type: 'agent', value: 'https://example.com/profile/card#me' }
const plan = { updates: [] }

vi.mocked(findEffectiveACL).mockResolvedValue({ authorizations: [] } as any)
vi.mocked(planGrant).mockReturnValue(plan as any)
store.fetcher._fetch.mockResolvedValue(new Response('', { status: 200 }))

await expect(aclLogic.planGrant(resource, subject as any, ['Read'])).resolves.toBe(plan)
expect(planGrant).toHaveBeenCalledWith(expect.objectContaining({ authorizations: [] }), subject, ['Read'])
})

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(planPublicRead).mockReturnValue(plan as any)
store.fetcher._fetch.mockResolvedValue(new Response('', { status: 200 }))

await expect(aclLogic.planPublicRead(resource, true)).resolves.toBe(plan)
expect(planPublicRead).toHaveBeenCalledWith(expect.objectContaining({ authorizations: [] }), true)
})

it('delegates planRevoke through the effective ACL context', async () => {
const resource = sym('https://example.com/resource.ttl')
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(planRevoke).mockReturnValue(plan as any)
store.fetcher._fetch.mockResolvedValue(new Response('', { status: 200 }))

await expect(aclLogic.planRevoke(resource, subject as any)).resolves.toBe(plan)
expect(planRevoke).toHaveBeenCalledWith(expect.objectContaining({ authorizations: [] }), subject)
})

it('applies a patch plan with the store fetcher', async () => {
const plan = { target: 'https://example.com/resource.ttl.acl', deletes: [], inserts: [] }
const response = new Response('ok', { status: 200 })

store.fetcher._fetch.mockResolvedValue(new Response('', { status: 200 }))
vi.mocked(applyPlan).mockResolvedValue(response)

await expect(aclLogic.applyPlan(plan as any)).resolves.toBe(response)
expect(applyPlan).toHaveBeenCalledWith(plan, expect.objectContaining({ fetch: expect.any(Function) }))
})

it('throws when the store cannot supply a fetch function', async () => {
const resource = sym('https://example.com/resource.ttl')
store.fetcher = { _fetch: vi.fn(), load: vi.fn(), webOperation: vi.fn(), unload: vi.fn() } as any
delete (store.fetcher as any).fetch
delete (store.fetcher as any)._fetch

await expect(aclLogic.findEffectiveAcl(resource)).rejects.toThrow('Cannot find effective ACL, store has no fetcher')
})
})
14 changes: 14 additions & 0 deletions test/resourceLogic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ describe('resourceLogic', () => {
store = makeStore()
aclLogic = {
findAclDocUrl: vi.fn().mockResolvedValue(undefined),
findEffectiveAcl: vi.fn().mockResolvedValue({ authorizations: [] }),
findAccessGrants: vi.fn().mockResolvedValue([]),
planGrant: vi.fn(),
planRevoke: vi.fn(),
planPublicRead: vi.fn(),
applyPlan: vi.fn(),
setACLUserPublic: vi.fn(),
genACLText: vi.fn()
}
Expand Down Expand Up @@ -103,4 +109,12 @@ describe('resourceLogic', () => {
await expect(resourceLogic.recursiveDelete(resource)).resolves.toBeUndefined()
expect(store.removeDocument).toHaveBeenCalledWith(resource)
})

it('recognizes only profile-card.ttl WebIDs', () => {
const resourceLogic = createResourceLogic(store, aclLogic, containerLogic, typeIndexLogic)

expect(resourceLogic.isWebId(sym('https://alice.example.com/profile/card.ttl#me'))).toBe(true)
expect(resourceLogic.isWebId(sym('https://alice.example.com/profile/card#me'))).toBe(false)
expect(resourceLogic.isWebId(sym('https://alice.example.com/profile/card.jsonld#me'))).toBe(false)
})
})
Loading