|
| 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