Skip to content
228 changes: 100 additions & 128 deletions lib/acl-checker.js
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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