Skip to content

Commit 6e0b966

Browse files
committed
more mixed for app
1 parent 6f6e708 commit 6e0b966

20 files changed

+2745
-0
lines changed

lib/acl-checker.mjs

Lines changed: 353 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,353 @@
1+
'use strict'
2+
/* eslint-disable node/no-deprecated-api */
3+
4+
import { dirname } from 'path'
5+
import rdf from 'rdflib'
6+
import { ACL as debug } from './debug.mjs'
7+
// import { cache as debugCache } from './debug.mjs'
8+
import HTTPError from './http-error.mjs'
9+
import aclCheck from '@solid/acl-check'
10+
import { URL } from 'url'
11+
import { promisify } from 'util'
12+
import fs from 'fs'
13+
import Url from 'url'
14+
import httpFetch from 'node-fetch'
15+
16+
export const DEFAULT_ACL_SUFFIX = '.acl'
17+
const ACL = rdf.Namespace('http://www.w3.org/ns/auth/acl#')
18+
19+
// TODO: expunge-on-write so that we can increase the caching time
20+
// For now this cache is a big performance gain but very simple
21+
// FIXME: set this through the config system instead of directly
22+
// through an env var:
23+
const EXPIRY_MS = parseInt(process.env.ACL_CACHE_TIME) || 10000 // 10 seconds
24+
let temporaryCache = {}
25+
26+
// An ACLChecker exposes the permissions on a specific resource
27+
class ACLChecker {
28+
constructor (resource, options = {}) {
29+
this.resource = resource
30+
this.resourceUrl = new URL(resource)
31+
this.agentOrigin = null
32+
try {
33+
if (options.strictOrigin && options.agentOrigin) {
34+
this.agentOrigin = rdf.sym(options.agentOrigin)
35+
}
36+
} catch (e) {
37+
// noop
38+
}
39+
this.fetch = options.fetch
40+
this.fetchGraph = options.fetchGraph
41+
this.trustedOrigins = options.strictOrigin && options.trustedOrigins ? options.trustedOrigins.map(trustedOrigin => rdf.sym(trustedOrigin)) : null
42+
this.suffix = options.suffix || DEFAULT_ACL_SUFFIX
43+
this.aclCached = {}
44+
this.messagesCached = {}
45+
this.requests = {}
46+
this.slug = options.slug
47+
}
48+
49+
// Returns a fulfilled promise when the user can access the resource
50+
// in the given mode; otherwise, rejects with an HTTP error
51+
async can (user, mode, method = 'GET', resourceExists = true) {
52+
const cacheKey = `${mode}-${user}`
53+
if (this.aclCached[cacheKey]) {
54+
return this.aclCached[cacheKey]
55+
}
56+
this.messagesCached[cacheKey] = this.messagesCached[cacheKey] || []
57+
58+
// for method DELETE nearestACL and ACL from parent resource
59+
const acl = await this.getNearestACL(method).catch(err => {
60+
this.messagesCached[cacheKey].push(new HTTPError(err.status || 500, err.message || err))
61+
})
62+
if (!acl) {
63+
this.aclCached[cacheKey] = Promise.resolve(false)
64+
return this.aclCached[cacheKey]
65+
}
66+
let resource = rdf.sym(this.resource)
67+
let parentResource = resource
68+
if (!this.resource.endsWith('/')) { parentResource = rdf.sym(ACLChecker.getDirectory(this.resource)) }
69+
if (this.resource.endsWith('/' + this.suffix)) {
70+
resource = rdf.sym(ACLChecker.getDirectory(this.resource))
71+
parentResource = resource
72+
}
73+
// If this is an ACL, Control mode must be present for any operations
74+
if (this.isAcl(this.resource)) {
75+
mode = 'Control'
76+
const thisResource = this.resource.substring(0, this.resource.length - this.suffix.length)
77+
resource = rdf.sym(thisResource)
78+
parentResource = resource
79+
if (!thisResource.endsWith('/')) parentResource = rdf.sym(ACLChecker.getDirectory(thisResource))
80+
}
81+
const directory = acl.isContainer ? rdf.sym(ACLChecker.getDirectory(acl.docAcl)) : null
82+
const aclFile = rdf.sym(acl.docAcl)
83+
const aclGraph = acl.docGraph
84+
const agent = user ? rdf.sym(user) : null
85+
const modes = [ACL(mode)]
86+
const agentOrigin = this.agentOrigin
87+
const trustedOrigins = this.trustedOrigins
88+
let originTrustedModes = []
89+
try {
90+
this.fetch(aclFile.doc().value)
91+
originTrustedModes = await aclCheck.getTrustedModesForOrigin(aclGraph, resource, directory, aclFile, agentOrigin, (uriNode) => {
92+
return this.fetch(uriNode.doc().value, aclGraph)
93+
})
94+
} catch (e) {
95+
// FIXME: https://github.com/solid/acl-check/issues/23
96+
// console.error(e.message)
97+
}
98+
99+
function resourceAccessDenied (modes) {
100+
return aclCheck.accessDenied(aclGraph, resource, directory, aclFile, agent, modes, agentOrigin, trustedOrigins, originTrustedModes)
101+
}
102+
function accessDeniedForAccessTo (modes) {
103+
const accessDeniedAccessTo = aclCheck.accessDenied(aclGraph, directory, null, aclFile, agent, modes, agentOrigin, trustedOrigins, originTrustedModes)
104+
const accessResult = !accessDenied && !accessDeniedAccessTo
105+
return accessResult ? false : accessDenied || accessDeniedAccessTo
106+
}
107+
async function accessdeniedFromParent (modes) {
108+
const parentAclDirectory = ACLChecker.getDirectory(acl.parentAcl)
109+
const parentDirectory = parentResource === parentAclDirectory ? null : rdf.sym(parentAclDirectory)
110+
const accessDeniedParent = aclCheck.accessDenied(acl.parentGraph, parentResource, parentDirectory, rdf.sym(acl.parentAcl), agent, modes, agentOrigin, trustedOrigins, originTrustedModes)
111+
const accessResult = !accessDenied && !accessDeniedParent
112+
return accessResult ? false : accessDenied || accessDeniedParent
113+
}
114+
115+
let accessDenied = resourceAccessDenied(modes)
116+
// debugCache('accessDenied resource ' + accessDenied)
117+
118+
// For create and update HTTP methods
119+
if ((method === 'PUT' || method === 'PATCH' || method === 'COPY')) {
120+
// if resource and acl have same parent container,
121+
// and resource does not exist, then accessTo Append from parent is required
122+
if (directory && directory.value === dirname(aclFile.value) + '/' && !resourceExists) {
123+
accessDenied = accessDeniedForAccessTo([ACL('Append')])
124+
}
125+
// debugCache('accessDenied PUT/PATCH ' + accessDenied)
126+
}
127+
128+
// For delete HTTP method
129+
if ((method === 'DELETE')) {
130+
if (resourceExists) {
131+
// deleting a Container
132+
// without Read, the response code will reveal whether a Container is empty or not
133+
if (directory && this.resource.endsWith('/')) accessDenied = resourceAccessDenied([ACL('Read'), ACL('Write')])
134+
// if resource and acl have same parent container,
135+
// then both Read and Write on parent is required
136+
else if (!directory && aclFile.value.endsWith(`/${this.suffix}`)) accessDenied = await accessdeniedFromParent([ACL('Read'), ACL('Write')])
137+
138+
// deleting a Document
139+
else if (directory && directory.value === dirname(aclFile.value) + '/') {
140+
accessDenied = accessDeniedForAccessTo([ACL('Write')])
141+
} else {
142+
accessDenied = await accessdeniedFromParent([ACL('Write')])
143+
}
144+
145+
// https://github.com/solid/specification/issues/14#issuecomment-1712773516
146+
} else { accessDenied = true }
147+
// debugCache('accessDenied DELETE ' + accessDenied)
148+
}
149+
150+
if (accessDenied && user) {
151+
this.messagesCached[cacheKey].push(HTTPError(403, accessDenied))
152+
} else if (accessDenied) {
153+
this.messagesCached[cacheKey].push(HTTPError(401, 'Unauthenticated'))
154+
}
155+
this.aclCached[cacheKey] = Promise.resolve(!accessDenied)
156+
return this.aclCached[cacheKey]
157+
}
158+
159+
async getError (user, mode) {
160+
const cacheKey = `${mode}-${user}`
161+
// TODO ?? add to can: req.method and resourceExists. Actually all tests pass
162+
this.aclCached[cacheKey] = this.aclCached[cacheKey] || this.can(user, mode)
163+
const isAllowed = await this.aclCached[cacheKey]
164+
return isAllowed ? null : this.messagesCached[cacheKey].reduce((prevMsg, msg) => msg.status > prevMsg.status ? msg : prevMsg, { status: 0 })
165+
}
166+
167+
static getDirectory (aclFile) {
168+
const parts = aclFile.split('/')
169+
parts.pop()
170+
return `${parts.join('/')}/`
171+
}
172+
173+
// Gets any ACLs that apply to the resource
174+
// DELETE uses docAcl when docAcl is parent to the resource
175+
// or docAcl and parentAcl when docAcl is the ACL of the Resource
176+
async getNearestACL (method) {
177+
const { resource } = this
178+
let isContainer = false
179+
const possibleACLs = this.getPossibleACLs()
180+
const acls = [...possibleACLs]
181+
let returnAcl = null
182+
let returnParentAcl = null
183+
let parentAcl = null
184+
let parentGraph = null
185+
let docAcl = null
186+
let docGraph = null
187+
while (possibleACLs.length > 0 && !returnParentAcl) {
188+
const acl = possibleACLs.shift()
189+
let graph
190+
try {
191+
this.requests[acl] = this.requests[acl] || this.fetch(acl)
192+
graph = await this.requests[acl]
193+
} catch (err) {
194+
if (err && (err.code === 'ENOENT' || err.status === 404)) {
195+
// only set isContainer before docAcl
196+
if (!docAcl) isContainer = true
197+
continue
198+
}
199+
debug(err)
200+
throw err
201+
}
202+
// const relative = resource.replace(acl.replace(/[^/]+$/, ''), './')
203+
// debug(`Using ACL ${acl} for ${relative}`)
204+
if (!docAcl) {
205+
docAcl = acl
206+
docGraph = graph
207+
// parentAcl is only needed for DELETE
208+
if (method !== 'DELETE') returnParentAcl = true
209+
} else {
210+
parentAcl = acl
211+
parentGraph = graph
212+
returnParentAcl = true
213+
}
214+
215+
returnAcl = { docAcl, docGraph, isContainer, parentAcl, parentGraph }
216+
}
217+
if (!returnAcl) {
218+
throw new HTTPError(500, `No ACL found for ${resource}, searched in \n- ${acls.join('\n- ')}`)
219+
}
220+
// fetch group
221+
let groupNodes = returnAcl.docGraph.statementsMatching(null, ACL('agentGroup'), null)
222+
let groupUrls = groupNodes.map(node => node.object.value.split('#')[0])
223+
await Promise.all(groupUrls.map(async groupUrl => {
224+
try {
225+
const docGraph = await this.fetch(groupUrl, returnAcl.docGraph)
226+
this.requests[groupUrl] = this.requests[groupUrl] || docGraph
227+
} catch (e) {} // failed to fetch groupUrl
228+
}))
229+
if (parentAcl) {
230+
groupNodes = returnAcl.parentGraph.statementsMatching(null, ACL('agentGroup'), null)
231+
groupUrls = groupNodes.map(node => node.object.value.split('#')[0])
232+
await Promise.all(groupUrls.map(async groupUrl => {
233+
try {
234+
const docGraph = await this.fetch(groupUrl, returnAcl.parentGraph)
235+
this.requests[groupUrl] = this.requests[groupUrl] || docGraph
236+
} catch (e) {} // failed to fetch groupUrl
237+
}))
238+
}
239+
240+
// debugAccounts('ALAIN returnACl ' + '\ndocAcl ' + returnAcl.docAcl + '\nparentAcl ' + returnAcl.parentAcl)
241+
return returnAcl
242+
}
243+
244+
// Gets all possible ACL paths that apply to the resource
245+
getPossibleACLs () {
246+
// Obtain the resource URI and the length of its base
247+
const { resource: uri, suffix } = this
248+
const [{ length: base }] = uri.match(/^[^:]+:\/*[^/]+/)
249+
250+
// If the URI points to a file, append the file's ACL
251+
const possibleAcls = []
252+
if (!uri.endsWith('/')) {
253+
possibleAcls.push(uri.endsWith(suffix) ? uri : uri + suffix)
254+
}
255+
256+
// Append the ACLs of all parent directories
257+
for (let i = lastSlash(uri); i >= base; i = lastSlash(uri, i - 1)) {
258+
possibleAcls.push(uri.substr(0, i + 1) + suffix)
259+
}
260+
return possibleAcls
261+
}
262+
263+
isAcl (resource) {
264+
return resource.endsWith(this.suffix)
265+
}
266+
267+
static createFromLDPAndRequest (resource, ldp, req) {
268+
const trustedOrigins = ldp.getTrustedOrigins(req)
269+
return new ACLChecker(resource, {
270+
agentOrigin: req.get('origin'),
271+
// host: req.get('host'),
272+
fetch: fetchLocalOrRemote(ldp.resourceMapper, ldp.serverUri),
273+
fetchGraph: (uri, options) => {
274+
// first try loading from local fs
275+
return ldp.getGraph(uri, options.contentType)
276+
// failing that, fetch remote graph
277+
.catch(() => ldp.fetchGraph(uri, options))
278+
},
279+
suffix: ldp.suffixAcl,
280+
strictOrigin: ldp.strictOrigin,
281+
trustedOrigins,
282+
slug: decodeURIComponent(req.headers.slug)
283+
})
284+
}
285+
}
286+
287+
/**
288+
* Returns a fetch document handler used by the ACLChecker to fetch .acl
289+
* resources up the inheritance chain.
290+
* The `fetch(uri, callback)` results in the callback, with either:
291+
* - `callback(err, graph)` if any error is encountered, or
292+
* - `callback(null, graph)` with the parsed RDF graph of the fetched resource
293+
* @return {Function} Returns a `fetch(uri, callback)` handler
294+
*/
295+
function fetchLocalOrRemote (mapper, serverUri) {
296+
async function doFetch (url) {
297+
// Convert the URL into a filename
298+
let body, path, contentType
299+
300+
if (Url.parse(url).host.includes(Url.parse(serverUri).host)) {
301+
// Fetch the acl from local
302+
try {
303+
({ path, contentType } = await mapper.mapUrlToFile({ url }))
304+
} catch (err) {
305+
// delete from cache
306+
delete temporaryCache[url]
307+
throw new HTTPError(404, err)
308+
}
309+
// Read the file from disk
310+
body = await promisify(fs.readFile)(path, { encoding: 'utf8' })
311+
} else {
312+
// Fetch the acl from the internet
313+
const response = await httpFetch(url)
314+
body = await response.text()
315+
contentType = response.headers.get('content-type')
316+
}
317+
return { body, contentType }
318+
}
319+
return async function fetch (url, graph = rdf.graph()) {
320+
graph.initPropertyActions(['sameAs']) // activate sameAs
321+
if (!temporaryCache[url]) {
322+
// debugCache('Populating cache', url)
323+
temporaryCache[url] = {
324+
timer: setTimeout(() => {
325+
// debugCache('Expunging from cache', url)
326+
delete temporaryCache[url]
327+
if (Object.keys(temporaryCache).length === 0) {
328+
// debugCache('Cache is empty again')
329+
}
330+
}, EXPIRY_MS),
331+
promise: doFetch(url)
332+
}
333+
}
334+
// debugCache('Cache hit', url)
335+
const { body, contentType } = await temporaryCache[url].promise
336+
// Parse the file as Turtle
337+
rdf.parse(body, graph, url, contentType)
338+
return graph
339+
}
340+
}
341+
342+
// Returns the index of the last slash before the given position
343+
function lastSlash (string, pos = string.length) {
344+
return string.lastIndexOf('/', pos)
345+
}
346+
347+
export default ACLChecker
348+
349+
// Used in ldp and the unit tests:
350+
export function clearAclCache (url) {
351+
if (url) delete temporaryCache[url]
352+
else temporaryCache = {}
353+
}

0 commit comments

Comments
 (0)