Skip to content
228 changes: 100 additions & 128 deletions lib/acl-checker.js
Original file line number Diff line number Diff line change
@@ -1,173 +1,145 @@
'use strict'

const async = require('async')
const path = require('path')
const PermissionSet = require('solid-permissions').PermissionSet
const rdf = require('rdflib')
const url = require('url')
const debug = require('./debug').ACL
const HTTPError = require('./http-error')

const DEFAULT_ACL_SUFFIX = '.acl'

// An ACLChecker exposes the permissions on a specific resource
class ACLChecker {
constructor (options = {}) {
this.debug = options.debug || console.log.bind(console)
constructor (resource, options = {}) {
this.resource = resource
this.host = options.host
this.origin = options.origin
this.fetch = options.fetch
this.strictOrigin = options.strictOrigin
this.suffix = options.suffix || DEFAULT_ACL_SUFFIX
}

can (user, mode, resource, callback, options = {}) {
const debug = this.debug
debug('Can ' + (user || 'an agent') + ' ' + mode + ' ' + resource + '?')
var accessType = 'accessTo'
var possibleACLs = ACLChecker.possibleACLs(resource, this.suffix)
// Returns a fulfilled promise when the user can access the resource
// in the given mode, or rejects with an HTTP error otherwise
can (user, mode) {
debug(`Can ${user || 'an agent'} ${mode} ${this.resource}?`)
// If this is an ACL, Control mode must be present for any operations
if (this.isAcl(resource)) {
if (this.isAcl(this.resource)) {
mode = 'Control'
}
var self = this
async.eachSeries(
possibleACLs,

// Looks for ACL, if found, looks for a rule
function tryAcl (acl, next) {
debug('Check if acl exist: ' + acl)
// Let's see if there is a file..
self.fetch(acl, function (err, graph) {
if (err || !graph || graph.length === 0) {
if (err) debug('Error: ' + err)
accessType = 'defaultForNew'
return next()
}
self.checkAccess(
graph, // The ACL graph
user, // The webId of the user
mode, // Read/Write/Append
resource, // The resource we want to access
accessType, // accessTo or defaultForNew
acl, // The current Acl file!
(err) => { return next(!err || err) },
options
)
})
},
function handleNoAccess (err) {
if (err === false || err === null) {
debug('No ACL resource found - access not allowed')
err = new Error('No Access Control Policy found')
}
if (err === true) {
debug('ACL policy found')
err = null

// Obtain the permission set for the resource
if (!this._permissionSet) {
this._permissionSet = this.getNearestACL()
.then(acl => this.getPermissionSet(acl))
}

// Check the resource's permissions
return this._permissionSet
.then(acls => this.checkAccess(acls, user, mode))
.catch(err => {
debug(`Error: ${err.message}`)
if (!user) {
debug('Authentication required')
throw new HTTPError(401, `Access to ${this.resource} requires authorization`)
} else {
debug(`${mode} access denied for ${user}`)
throw new HTTPError(403, `Access to ${this.resource} denied for ${user}`)
}
if (err) {
debug('Error: ' + err.message)
if (!user || user.length === 0) {
debug('Authentication required')
err.status = 401
err.message = 'Access to ' + resource + ' requires authorization'
})
}

// Gets the ACL that applies to the resource
getNearestACL () {
let isContainer = false
// Create a cascade of reject handlers (one for each possible ACL)
let nearestACL = Promise.reject()
for (const acl of this.getPossibleACLs()) {
nearestACL = nearestACL.catch(() => new Promise((resolve, reject) => {
debug(`Check if ACL exists: ${acl}`)
this.fetch(acl, (err, graph) => {
if (err || !graph || !graph.length) {
if (err) debug(`Error reading ${acl}: ${err}`)
isContainer = true
reject(err)
} else {
debug(mode + ' access denied for: ' + user)
err.status = 403
err.message = 'Access denied for ' + user
resolve({ acl, graph, isContainer })
}
}
return callback(err)
})
})
}))
}
return nearestACL.catch(e => { throw new Error('No ACL resource found') })
}

/**
* Tests whether a graph (parsed .acl resource) allows a given operation
* for a given user. Calls the provided callback with `null` if the user
* has access, otherwise calls it with an error.
* @method checkAccess
* @param graph {Graph} Parsed RDF graph of current .acl resource
* @param user {String} WebID URI of the user accessing the resource
* @param mode {String} Access mode, e.g. 'Read', 'Write', etc.
* @param resource {String} URI of the resource being accessed
* @param accessType {String} One of `accessTo`, or `default`
* @param acl {String} URI of this current .acl resource
* @param callback {Function}
* @param options {Object} Options hashmap
* @param [options.origin] Request's `Origin:` header
* @param [options.host] Request's host URI (with protocol)
*/
checkAccess (graph, user, mode, resource, accessType, acl, callback,
options = {}) {
const debug = this.debug
if (!graph || graph.length === 0) {
debug('ACL ' + acl + ' is empty')
return callback(new Error('No policy found - empty ACL'))
// Gets all possible ACL paths that apply to the resource
getPossibleACLs () {
// Obtain the resource URI and the length of its base
let { resource: uri, suffix } = this
const [ { length: base } ] = uri.match(/^[^:]+:\/*[^/]+/)

// If the URI points to a file, append the file's ACL
const possibleAcls = []
if (!uri.endsWith('/')) {
possibleAcls.push(uri.endsWith(suffix) ? uri : uri + suffix)
}
let isContainer = accessType.startsWith('default')
let aclOptions = {
aclSuffix: this.suffix,
graph: graph,
host: options.host,
origin: options.origin,
rdf: rdf,
strictOrigin: this.strictOrigin,
isAcl: (uri) => { return this.isAcl(uri) },
aclUrlFor: (uri) => { return this.aclUrlFor(uri) }

// Append the ACLs of all parent directories
for (let i = lastSlash(uri); i >= base; i = lastSlash(uri, i - 1)) {
possibleAcls.push(uri.substr(0, i + 1) + suffix)
}
let acls = new PermissionSet(resource, acl, isContainer, aclOptions)
acls.checkAccess(resource, user, mode)
return possibleAcls
}

// Tests whether the permissions allow a given operation
checkAccess (permissionSet, user, mode) {
return permissionSet.checkAccess(this.resource, user, mode)
.then(hasAccess => {
if (hasAccess) {
debug(`${mode} access permitted to ${user}`)
return callback()
return true
} else {
debug(`${mode} access NOT permitted to ${user}` +
aclOptions.strictOrigin ? ` and origin ${options.origin}` : '')
return callback(new Error('ACL file found but no matching policy found'))
debug(`${mode} access NOT permitted to ${user}`)
throw new Error('ACL file found but no matching policy found')
}
})
.catch(err => {
debug(`${mode} access denied to ${user}`)
debug(err)
return callback(err)
throw err
})
}

aclUrlFor (uri) {
if (this.isAcl(uri)) {
return uri
} else {
return uri + this.suffix
// Gets the permission set for the given ACL
getPermissionSet ({ acl, graph, isContainer }) {
if (!graph || graph.length === 0) {
debug('ACL ' + acl + ' is empty')
throw new Error('No policy found - empty ACL')
}
}

isAcl (resource) {
if (typeof resource === 'string') {
return resource.endsWith(this.suffix)
} else {
return false
const aclOptions = {
aclSuffix: this.suffix,
graph: graph,
host: this.host,
origin: this.origin,
rdf: rdf,
strictOrigin: this.strictOrigin,
isAcl: uri => this.isAcl(uri),
aclUrlFor: uri => this.aclUrlFor(uri)
}
return new PermissionSet(this.resource, acl, isContainer, aclOptions)
}

static possibleACLs (uri, suffix) {
var first = uri.endsWith(suffix) ? uri : uri + suffix
var urls = [first]
var parsedUri = url.parse(uri)
var baseUrl = (parsedUri.protocol ? parsedUri.protocol + '//' : '') +
(parsedUri.host || '')
if (baseUrl + '/' === uri) {
return urls
}

var times = parsedUri.pathname.split('/').length
// TODO: improve temporary solution to stop recursive path walking above root
if (parsedUri.pathname.endsWith('/')) {
times--
}
aclUrlFor (uri) {
return this.isAcl(uri) ? uri : uri + this.suffix
}

for (var i = 0; i < times - 1; i++) {
uri = path.dirname(uri)
urls.push(uri + (uri[uri.length - 1] === '/' ? suffix : '/' + suffix))
}
return urls
isAcl (resource) {
return resource.endsWith(this.suffix)
}
}

// Returns the index of the last slash before the given position
function lastSlash (string, pos = string.length) {
return string.lastIndexOf('/', pos)
}

module.exports = ACLChecker
module.exports.DEFAULT_ACL_SUFFIX = DEFAULT_ACL_SUFFIX
37 changes: 19 additions & 18 deletions lib/handlers/allow.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ var ACL = require('../acl-checker')
var $rdf = require('rdflib')
var url = require('url')
var async = require('async')
var debug = require('../debug').ACL
var utils = require('../utils')

function allow (mode) {
Expand All @@ -13,31 +12,33 @@ function allow (mode) {
if (!ldp.webid) {
return next()
}
var baseUri = utils.uriBase(req)

var acl = new ACL({
debug: debug,
fetch: fetchDocument(req.hostname, ldp, baseUri),
suffix: ldp.suffixAcl,
strictOrigin: ldp.strictOrigin
})
req.acl = acl

// Determine the actual path of the request
var reqPath = res && res.locals && res.locals.path
? res.locals.path
: req.path

// Check whether the resource exists
ldp.exists(req.hostname, reqPath, (err, ret) => {
if (ret) {
var stat = ret.stream
}
if (!reqPath.endsWith('/') && !err && stat.isDirectory()) {
// Ensure directories always end in a slash
const stat = err ? null : ret.stream
if (!reqPath.endsWith('/') && stat && stat.isDirectory()) {
reqPath += '/'
}
var options = {

// Obtain and store the ACL of the requested resource
const baseUri = utils.uriBase(req)
req.acl = new ACL(baseUri + reqPath, {
origin: req.get('origin'),
host: req.protocol + '://' + req.get('host')
}
return acl.can(req.session.userId, mode, baseUri + reqPath, next, options)
host: req.protocol + '://' + req.get('host'),
fetch: fetchDocument(req.hostname, ldp, baseUri),
suffix: ldp.suffixAcl,
strictOrigin: ldp.strictOrigin
})

// Ensure the user has the required permission
req.acl.can(req.session.userId, mode)
.then(() => next(), next)
})
}
}
Expand Down
Loading