Skip to content

Commit

Permalink
Merge pull request #970 from solid/integrate-acl-check
Browse files Browse the repository at this point in the history
Integrate acl-check
  • Loading branch information
kjetilk authored Nov 27, 2018
2 parents 4a21521 + 530f740 commit e8d5a0c
Show file tree
Hide file tree
Showing 30 changed files with 941 additions and 658 deletions.
1 change: 0 additions & 1 deletion bin/solid.js

This file was deleted.

3 changes: 3 additions & 0 deletions bin/solid.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
#!/usr/bin/env node
const startCli = require('./lib/cli')
startCli()
2 changes: 1 addition & 1 deletion config/defaults.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ module.exports = {
'serverUri': 'https://localhost:8443',
'webid': true,
'strictOrigin': true,
'originsAllowed': ['https://apps.solid.invalid'],
'trustedOrigins': ['https://apps.solid.invalid'],
'dataBrowserPath': 'default'

// For use in Enterprises to configure a HTTP proxy for all outbound HTTP requests from the SOLID server (we use
Expand Down
File renamed without changes.
165 changes: 90 additions & 75 deletions lib/acl-checker.js
Original file line number Diff line number Diff line change
@@ -1,78 +1,130 @@
'use strict'

const PermissionSet = require('solid-permissions').PermissionSet
const rdf = require('rdflib')
const debug = require('./debug').ACL
const HTTPError = require('./http-error')
const aclCheck = require('@solid/acl-check')
const { URL } = require('url')

const DEFAULT_ACL_SUFFIX = '.acl'
const ACL = rdf.Namespace('http://www.w3.org/ns/auth/acl#')

// An ACLChecker exposes the permissions on a specific resource
class ACLChecker {
constructor (resource, options = {}) {
this.resource = resource
this.host = options.host
this.origin = options.origin
this.resourceUrl = new URL(resource)
this.agentOrigin = options.agentOrigin
this.fetch = options.fetch
this.fetchGraph = options.fetchGraph
this.strictOrigin = options.strictOrigin
this.originsAllowed = options.originsAllowed
this.trustedOrigins = options.trustedOrigins
this.suffix = options.suffix || DEFAULT_ACL_SUFFIX
this.aclCached = {}
this.messagesCached = {}
this.requests = {}
}

// 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) {
async can (user, mode) {
const cacheKey = `${mode}-${user}`
if (this.aclCached[cacheKey]) {
return this.aclCached[cacheKey]
}
this.messagesCached[cacheKey] = this.messagesCached[cacheKey] || []

const acl = await this.getNearestACL().catch(err => {
this.messagesCached[cacheKey].push(new HTTPError(err.status || 500, err.message || err))
})
if (!acl) {
this.aclCached[cacheKey] = Promise.resolve(false)
return this.aclCached[cacheKey]
}
let resource = rdf.sym(this.resource)
if (this.resource.endsWith('/' + this.suffix)) {
resource = rdf.sym(ACLChecker.getDirectory(this.resource))
}
// If this is an ACL, Control mode must be present for any operations
if (this.isAcl(this.resource)) {
mode = 'Control'
resource = rdf.sym(this.resource.substring(0, this.resource.length - this.suffix.length))
}

// Obtain the permission set for the resource
if (!this._permissionSet) {
this._permissionSet = this.getNearestACL()
.then(acl => this.getPermissionSet(acl))
const directory = acl.isContainer ? rdf.sym(ACLChecker.getDirectory(acl.acl)) : null
const aclFile = rdf.sym(acl.acl)
const agent = user ? rdf.sym(user) : null
const modes = [ACL(mode)]
const agentOrigin = this.agentOrigin ? rdf.sym(this.agentOrigin) : null
const trustedOrigins = this.trustedOrigins ? this.trustedOrigins.map(trustedOrigin => rdf.sym(trustedOrigin)) : null
const accessDenied = aclCheck.accessDenied(acl.graph, resource, directory, aclFile, agent, modes, agentOrigin, trustedOrigins)
if (accessDenied && this.agentOrigin && this.resourceUrl.origin !== this.agentOrigin) {
this.messagesCached[cacheKey].push(new HTTPError(403, accessDenied))
} else if (accessDenied && user) {
this.messagesCached[cacheKey].push(new HTTPError(403, accessDenied))
} else if (accessDenied) {
this.messagesCached[cacheKey].push(new HTTPError(401, accessDenied))
}
this.aclCached[cacheKey] = Promise.resolve(!accessDenied)
return this.aclCached[cacheKey]
}

// Check the resource's permissions
return this._permissionSet
.then(acls => this.checkAccess(acls, user, mode))
.catch(() => {
if (!user) {
throw new HTTPError(401, `Access to ${this.resource} requires authorization`)
} else {
throw new HTTPError(403, `Access to ${this.resource} denied for ${user}`)
}
})
async getError (user, mode) {
const cacheKey = `${mode}-${user}`
this.aclCached[cacheKey] = this.aclCached[cacheKey] || this.can(user, mode)
const isAllowed = await this.aclCached[cacheKey]
return isAllowed ? null : this.messagesCached[cacheKey].reduce((prevMsg, msg) => msg.status > prevMsg.status ? msg : prevMsg, { status: 0 })
}

static getDirectory (aclFile) {
const parts = aclFile.split('/')
parts.pop()
return `${parts.join('/')}/`
}

// Gets the ACL that applies to the resource
getNearestACL () {
async getNearestACL () {
const { resource } = this
let isContainer = false
// Create a cascade of reject handlers (one for each possible ACL)
const nearestACL = this.getPossibleACLs().reduce((prevACL, acl) => {
return prevACL.catch(() => new Promise((resolve, reject) => {
this.fetch(acl, (err, graph) => {
if (err || !graph || !graph.length) {
isContainer = true
reject(err)
} else {
const relative = resource.replace(acl.replace(/[^/]+$/, ''), './')
debug(`Using ACL ${acl} for ${relative}`)
resolve({ acl, graph, isContainer })
}
})
}))
}, Promise.reject())
return nearestACL.catch(e => { throw new Error('No ACL resource found') })
const possibleACLs = this.getPossibleACLs()
const acls = [...possibleACLs]
let returnAcl = null
while (possibleACLs.length > 0 && !returnAcl) {
const acl = possibleACLs.shift()
let graph
try {
this.requests[acl] = this.requests[acl] || this.fetch(acl)
graph = await this.requests[acl]
} catch (err) {
if (err && (err.code === 'ENOENT' || err.status === 404)) {
isContainer = true
continue
}
debug(err)
throw err
}
const relative = resource.replace(acl.replace(/[^/]+$/, ''), './')
debug(`Using ACL ${acl} for ${relative}`)
returnAcl = { acl, graph, isContainer }
}
if (!returnAcl) {
throw new HTTPError(500, `No ACL found for ${resource}, searched in \n- ${acls.join('\n- ')}`)
}
const groupUrls = returnAcl.graph
.statementsMatching(null, ACL('agentGroup'), null)
.map(node => node.object.value.split('#')[0])
await Promise.all(groupUrls.map(groupUrl => {
this.requests[groupUrl] = this.requests[groupUrl] || this.fetch(groupUrl, returnAcl.graph)
return this.requests[groupUrl]
}))

return returnAcl
}

// 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(/^[^:]+:\/*[^/]+/)
const [{ length: base }] = uri.match(/^[^:]+:\/*[^/]+/)

// If the URI points to a file, append the file's ACL
const possibleAcls = []
Expand All @@ -87,43 +139,6 @@ class ACLChecker {
return possibleAcls
}

// Tests whether the permissions allow a given operation
checkAccess (permissionSet, user, mode) {
const options = { fetchGraph: this.fetchGraph }
return permissionSet.checkAccess(this.resource, user, mode, options)
.then(hasAccess => {
if (hasAccess) {
return true
} else {
throw new Error('ACL file found but no matching policy found')
}
})
}

// 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')
}
const aclOptions = {
aclSuffix: this.suffix,
graph: graph,
host: this.host,
origin: this.origin,
rdf: rdf,
strictOrigin: this.strictOrigin,
originsAllowed: this.originsAllowed,
isAcl: uri => this.isAcl(uri),
aclUrlFor: uri => this.aclUrlFor(uri)
}
return new PermissionSet(this.resource, acl, isContainer, aclOptions)
}

aclUrlFor (uri) {
return this.isAcl(uri) ? uri : uri + this.suffix
}

isAcl (resource) {
return resource.endsWith(this.suffix)
}
Expand Down
4 changes: 1 addition & 3 deletions lib/api/authn/webid-oidc.js
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,7 @@ function middleware (oidc) {
// Static assets related to authentication
const authAssets = [
['/.well-known/solid/login/', '../static/popup-redirect.html', false],
['/common/', 'solid-auth-client/dist-popup/popup.html'],
['/common/js/', 'solid-auth-client/dist-lib/solid-auth-client.bundle.js'],
['/common/js/', 'solid-auth-client/dist-lib/solid-auth-client.bundle.js.map']
['/common/', 'solid-auth-client/dist-popup/popup.html']
]
authAssets.map(args => routeResolvedFile(router, ...args))

Expand Down
7 changes: 5 additions & 2 deletions lib/create-app.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ function createApp (argv = {}) {
app.use('/common', express.static(path.join(__dirname, '../common')))
routeResolvedFile(app, '/common/js/', 'mashlib/dist/mashlib.min.js')
routeResolvedFile(app, '/common/js/', 'mashlib/dist/mashlib.min.js.map')
routeResolvedFile(app, '/common/js/', 'solid-auth-client/dist-lib/solid-auth-client.bundle.js.map')
routeResolvedFile(app, '/common/js/', 'solid-auth-client/dist-lib/solid-auth-client.bundle.js.map')
app.use('/.well-known', express.static(path.join(__dirname, '../common/well-known')))

// Serve bootstrap from it's node_module directory
Expand Down Expand Up @@ -198,14 +200,15 @@ function initWebId (argv, app, ldp) {
// any third-party application could perform authenticated requests
// without permission by including the credentials set by the Solid server.
app.use((req, res, next) => {
const origin = req.headers.origin
const origin = req.get('origin')
const trustedOrigins = argv.trustedOrigins
const userId = req.session.userId
// Exception: allow logout requests from all third-party apps
// such that OIDC client can log out via cookie auth
// TODO: remove this exception when OIDC clients
// use Bearer token to authenticate instead of cookie
// (https://github.com/solid/node-solid-server/pull/835#issuecomment-426429003)
if (!argv.host.allowsSessionFor(userId, origin) && !isLogoutRequest(req)) {
if (!argv.host.allowsSessionFor(userId, origin, trustedOrigins) && !isLogoutRequest(req)) {
debug(`Rejecting session for ${userId} from ${origin}`)
// Destroy session data
delete req.session.userId
Expand Down
42 changes: 30 additions & 12 deletions lib/handlers/allow.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
module.exports = allow

const $rdf = require('rdflib')
const ACL = require('../acl-checker')
const debug = require('../debug.js').ACL
const fs = require('fs')
const { promisify } = require('util')
const HTTPError = require('../http-error')

function allow (mode) {
return async function allowHandler (req, res, next) {
Expand Down Expand Up @@ -35,26 +39,29 @@ function allow (mode) {

// Obtain and store the ACL of the requested resource
req.acl = new ACL(rootUrl + reqPath, {
origin: req.get('origin'),
host: req.protocol + '://' + req.get('host'),
fetch: fetchFromLdp(ldp.resourceMapper, ldp),
agentOrigin: req.get('origin'),
// host: req.get('host'),
fetch: fetchFromLdp(ldp.resourceMapper),
fetchGraph: (uri, options) => {
// first try loading from local fs
return ldp.getGraph(uri, options.contentType)
// failing that, fetch remote graph
.catch(() => ldp.fetchGraph(uri, options))
},
suffix: ldp.suffixAcl,
strictOrigin: ldp.strictOrigin
strictOrigin: ldp.strictOrigin,
trustedOrigins: ldp.trustedOrigins
})

// Ensure the user has the required permission
const userId = req.session.userId
req.acl.can(userId, mode)
.then(() => next(), err => {
debug(`${mode} access denied to ${userId || '(none)'}`)
next(err)
})
const isAllowed = await req.acl.can(userId, mode)
if (isAllowed) {
return next()
}
const error = await req.acl.getError(userId, mode)
debug(`${mode} access denied to ${userId || '(none)'}: ${error.status} - ${error.message}`)
next(error)
}
}

Expand All @@ -66,8 +73,19 @@ function allow (mode) {
* - `callback(null, graph)` with the parsed RDF graph of the fetched resource
* @return {Function} Returns a `fetch(uri, callback)` handler
*/
function fetchFromLdp (mapper, ldp) {
return function fetch (url, callback) {
ldp.getGraph(url).then(g => callback(null, g), callback)
function fetchFromLdp (mapper) {
return async function fetch (url, graph = $rdf.graph()) {
// Convert the URL into a filename
let path, contentType
try {
({ path, contentType } = await mapper.mapUrlToFile({ url }))
} catch (err) {
throw new HTTPError(404, err)
}
// Read the file from disk
const body = await promisify(fs.readFile)(path, {'encoding': 'utf8'})
// Parse the file as Turtle
$rdf.parse(body, graph, url, contentType)
return graph
}
}
Loading

0 comments on commit e8d5a0c

Please sign in to comment.