Skip to content

Commit e8d5a0c

Browse files
authored
Merge pull request #970 from solid/integrate-acl-check
Integrate acl-check
2 parents 4a21521 + 530f740 commit e8d5a0c

File tree

30 files changed

+941
-658
lines changed

30 files changed

+941
-658
lines changed

bin/solid.js

Lines changed: 0 additions & 1 deletion
This file was deleted.

bin/solid.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
#!/usr/bin/env node
2+
const startCli = require('./lib/cli')
3+
startCli()

config/defaults.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ module.exports = {
1212
'serverUri': 'https://localhost:8443',
1313
'webid': true,
1414
'strictOrigin': true,
15-
'originsAllowed': ['https://apps.solid.invalid'],
15+
'trustedOrigins': ['https://apps.solid.invalid'],
1616
'dataBrowserPath': 'default'
1717

1818
// For use in Enterprises to configure a HTTP proxy for all outbound HTTP requests from the SOLID server (we use

lib/acl-checker.js

Lines changed: 90 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -1,78 +1,130 @@
11
'use strict'
22

3-
const PermissionSet = require('solid-permissions').PermissionSet
43
const rdf = require('rdflib')
54
const debug = require('./debug').ACL
65
const HTTPError = require('./http-error')
6+
const aclCheck = require('@solid/acl-check')
7+
const { URL } = require('url')
78

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

1012
// An ACLChecker exposes the permissions on a specific resource
1113
class ACLChecker {
1214
constructor (resource, options = {}) {
1315
this.resource = resource
14-
this.host = options.host
15-
this.origin = options.origin
16+
this.resourceUrl = new URL(resource)
17+
this.agentOrigin = options.agentOrigin
1618
this.fetch = options.fetch
1719
this.fetchGraph = options.fetchGraph
1820
this.strictOrigin = options.strictOrigin
19-
this.originsAllowed = options.originsAllowed
21+
this.trustedOrigins = options.trustedOrigins
2022
this.suffix = options.suffix || DEFAULT_ACL_SUFFIX
23+
this.aclCached = {}
24+
this.messagesCached = {}
25+
this.requests = {}
2126
}
2227

2328
// Returns a fulfilled promise when the user can access the resource
2429
// in the given mode, or rejects with an HTTP error otherwise
25-
can (user, mode) {
30+
async can (user, mode) {
31+
const cacheKey = `${mode}-${user}`
32+
if (this.aclCached[cacheKey]) {
33+
return this.aclCached[cacheKey]
34+
}
35+
this.messagesCached[cacheKey] = this.messagesCached[cacheKey] || []
36+
37+
const acl = await this.getNearestACL().catch(err => {
38+
this.messagesCached[cacheKey].push(new HTTPError(err.status || 500, err.message || err))
39+
})
40+
if (!acl) {
41+
this.aclCached[cacheKey] = Promise.resolve(false)
42+
return this.aclCached[cacheKey]
43+
}
44+
let resource = rdf.sym(this.resource)
45+
if (this.resource.endsWith('/' + this.suffix)) {
46+
resource = rdf.sym(ACLChecker.getDirectory(this.resource))
47+
}
2648
// If this is an ACL, Control mode must be present for any operations
2749
if (this.isAcl(this.resource)) {
2850
mode = 'Control'
51+
resource = rdf.sym(this.resource.substring(0, this.resource.length - this.suffix.length))
2952
}
30-
31-
// Obtain the permission set for the resource
32-
if (!this._permissionSet) {
33-
this._permissionSet = this.getNearestACL()
34-
.then(acl => this.getPermissionSet(acl))
53+
const directory = acl.isContainer ? rdf.sym(ACLChecker.getDirectory(acl.acl)) : null
54+
const aclFile = rdf.sym(acl.acl)
55+
const agent = user ? rdf.sym(user) : null
56+
const modes = [ACL(mode)]
57+
const agentOrigin = this.agentOrigin ? rdf.sym(this.agentOrigin) : null
58+
const trustedOrigins = this.trustedOrigins ? this.trustedOrigins.map(trustedOrigin => rdf.sym(trustedOrigin)) : null
59+
const accessDenied = aclCheck.accessDenied(acl.graph, resource, directory, aclFile, agent, modes, agentOrigin, trustedOrigins)
60+
if (accessDenied && this.agentOrigin && this.resourceUrl.origin !== this.agentOrigin) {
61+
this.messagesCached[cacheKey].push(new HTTPError(403, accessDenied))
62+
} else if (accessDenied && user) {
63+
this.messagesCached[cacheKey].push(new HTTPError(403, accessDenied))
64+
} else if (accessDenied) {
65+
this.messagesCached[cacheKey].push(new HTTPError(401, accessDenied))
3566
}
67+
this.aclCached[cacheKey] = Promise.resolve(!accessDenied)
68+
return this.aclCached[cacheKey]
69+
}
3670

37-
// Check the resource's permissions
38-
return this._permissionSet
39-
.then(acls => this.checkAccess(acls, user, mode))
40-
.catch(() => {
41-
if (!user) {
42-
throw new HTTPError(401, `Access to ${this.resource} requires authorization`)
43-
} else {
44-
throw new HTTPError(403, `Access to ${this.resource} denied for ${user}`)
45-
}
46-
})
71+
async getError (user, mode) {
72+
const cacheKey = `${mode}-${user}`
73+
this.aclCached[cacheKey] = this.aclCached[cacheKey] || this.can(user, mode)
74+
const isAllowed = await this.aclCached[cacheKey]
75+
return isAllowed ? null : this.messagesCached[cacheKey].reduce((prevMsg, msg) => msg.status > prevMsg.status ? msg : prevMsg, { status: 0 })
76+
}
77+
78+
static getDirectory (aclFile) {
79+
const parts = aclFile.split('/')
80+
parts.pop()
81+
return `${parts.join('/')}/`
4782
}
4883

4984
// Gets the ACL that applies to the resource
50-
getNearestACL () {
85+
async getNearestACL () {
5186
const { resource } = this
5287
let isContainer = false
53-
// Create a cascade of reject handlers (one for each possible ACL)
54-
const nearestACL = this.getPossibleACLs().reduce((prevACL, acl) => {
55-
return prevACL.catch(() => new Promise((resolve, reject) => {
56-
this.fetch(acl, (err, graph) => {
57-
if (err || !graph || !graph.length) {
58-
isContainer = true
59-
reject(err)
60-
} else {
61-
const relative = resource.replace(acl.replace(/[^/]+$/, ''), './')
62-
debug(`Using ACL ${acl} for ${relative}`)
63-
resolve({ acl, graph, isContainer })
64-
}
65-
})
66-
}))
67-
}, Promise.reject())
68-
return nearestACL.catch(e => { throw new Error('No ACL resource found') })
88+
const possibleACLs = this.getPossibleACLs()
89+
const acls = [...possibleACLs]
90+
let returnAcl = null
91+
while (possibleACLs.length > 0 && !returnAcl) {
92+
const acl = possibleACLs.shift()
93+
let graph
94+
try {
95+
this.requests[acl] = this.requests[acl] || this.fetch(acl)
96+
graph = await this.requests[acl]
97+
} catch (err) {
98+
if (err && (err.code === 'ENOENT' || err.status === 404)) {
99+
isContainer = true
100+
continue
101+
}
102+
debug(err)
103+
throw err
104+
}
105+
const relative = resource.replace(acl.replace(/[^/]+$/, ''), './')
106+
debug(`Using ACL ${acl} for ${relative}`)
107+
returnAcl = { acl, graph, isContainer }
108+
}
109+
if (!returnAcl) {
110+
throw new HTTPError(500, `No ACL found for ${resource}, searched in \n- ${acls.join('\n- ')}`)
111+
}
112+
const groupUrls = returnAcl.graph
113+
.statementsMatching(null, ACL('agentGroup'), null)
114+
.map(node => node.object.value.split('#')[0])
115+
await Promise.all(groupUrls.map(groupUrl => {
116+
this.requests[groupUrl] = this.requests[groupUrl] || this.fetch(groupUrl, returnAcl.graph)
117+
return this.requests[groupUrl]
118+
}))
119+
120+
return returnAcl
69121
}
70122

71123
// Gets all possible ACL paths that apply to the resource
72124
getPossibleACLs () {
73125
// Obtain the resource URI and the length of its base
74126
let { resource: uri, suffix } = this
75-
const [ { length: base } ] = uri.match(/^[^:]+:\/*[^/]+/)
127+
const [{ length: base }] = uri.match(/^[^:]+:\/*[^/]+/)
76128

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

90-
// Tests whether the permissions allow a given operation
91-
checkAccess (permissionSet, user, mode) {
92-
const options = { fetchGraph: this.fetchGraph }
93-
return permissionSet.checkAccess(this.resource, user, mode, options)
94-
.then(hasAccess => {
95-
if (hasAccess) {
96-
return true
97-
} else {
98-
throw new Error('ACL file found but no matching policy found')
99-
}
100-
})
101-
}
102-
103-
// Gets the permission set for the given ACL
104-
getPermissionSet ({ acl, graph, isContainer }) {
105-
if (!graph || graph.length === 0) {
106-
debug('ACL ' + acl + ' is empty')
107-
throw new Error('No policy found - empty ACL')
108-
}
109-
const aclOptions = {
110-
aclSuffix: this.suffix,
111-
graph: graph,
112-
host: this.host,
113-
origin: this.origin,
114-
rdf: rdf,
115-
strictOrigin: this.strictOrigin,
116-
originsAllowed: this.originsAllowed,
117-
isAcl: uri => this.isAcl(uri),
118-
aclUrlFor: uri => this.aclUrlFor(uri)
119-
}
120-
return new PermissionSet(this.resource, acl, isContainer, aclOptions)
121-
}
122-
123-
aclUrlFor (uri) {
124-
return this.isAcl(uri) ? uri : uri + this.suffix
125-
}
126-
127142
isAcl (resource) {
128143
return resource.endsWith(this.suffix)
129144
}

lib/api/authn/webid-oidc.js

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -87,9 +87,7 @@ function middleware (oidc) {
8787
// Static assets related to authentication
8888
const authAssets = [
8989
['/.well-known/solid/login/', '../static/popup-redirect.html', false],
90-
['/common/', 'solid-auth-client/dist-popup/popup.html'],
91-
['/common/js/', 'solid-auth-client/dist-lib/solid-auth-client.bundle.js'],
92-
['/common/js/', 'solid-auth-client/dist-lib/solid-auth-client.bundle.js.map']
90+
['/common/', 'solid-auth-client/dist-popup/popup.html']
9391
]
9492
authAssets.map(args => routeResolvedFile(router, ...args))
9593

lib/create-app.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@ function createApp (argv = {}) {
6464
app.use('/common', express.static(path.join(__dirname, '../common')))
6565
routeResolvedFile(app, '/common/js/', 'mashlib/dist/mashlib.min.js')
6666
routeResolvedFile(app, '/common/js/', 'mashlib/dist/mashlib.min.js.map')
67+
routeResolvedFile(app, '/common/js/', 'solid-auth-client/dist-lib/solid-auth-client.bundle.js.map')
68+
routeResolvedFile(app, '/common/js/', 'solid-auth-client/dist-lib/solid-auth-client.bundle.js.map')
6769
app.use('/.well-known', express.static(path.join(__dirname, '../common/well-known')))
6870

6971
// Serve bootstrap from it's node_module directory
@@ -198,14 +200,15 @@ function initWebId (argv, app, ldp) {
198200
// any third-party application could perform authenticated requests
199201
// without permission by including the credentials set by the Solid server.
200202
app.use((req, res, next) => {
201-
const origin = req.headers.origin
203+
const origin = req.get('origin')
204+
const trustedOrigins = argv.trustedOrigins
202205
const userId = req.session.userId
203206
// Exception: allow logout requests from all third-party apps
204207
// such that OIDC client can log out via cookie auth
205208
// TODO: remove this exception when OIDC clients
206209
// use Bearer token to authenticate instead of cookie
207210
// (https://github.com/solid/node-solid-server/pull/835#issuecomment-426429003)
208-
if (!argv.host.allowsSessionFor(userId, origin) && !isLogoutRequest(req)) {
211+
if (!argv.host.allowsSessionFor(userId, origin, trustedOrigins) && !isLogoutRequest(req)) {
209212
debug(`Rejecting session for ${userId} from ${origin}`)
210213
// Destroy session data
211214
delete req.session.userId

lib/handlers/allow.js

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
module.exports = allow
22

3+
const $rdf = require('rdflib')
34
const ACL = require('../acl-checker')
45
const debug = require('../debug.js').ACL
6+
const fs = require('fs')
7+
const { promisify } = require('util')
8+
const HTTPError = require('../http-error')
59

610
function allow (mode) {
711
return async function allowHandler (req, res, next) {
@@ -35,26 +39,29 @@ function allow (mode) {
3539

3640
// Obtain and store the ACL of the requested resource
3741
req.acl = new ACL(rootUrl + reqPath, {
38-
origin: req.get('origin'),
39-
host: req.protocol + '://' + req.get('host'),
40-
fetch: fetchFromLdp(ldp.resourceMapper, ldp),
42+
agentOrigin: req.get('origin'),
43+
// host: req.get('host'),
44+
fetch: fetchFromLdp(ldp.resourceMapper),
4145
fetchGraph: (uri, options) => {
4246
// first try loading from local fs
4347
return ldp.getGraph(uri, options.contentType)
4448
// failing that, fetch remote graph
4549
.catch(() => ldp.fetchGraph(uri, options))
4650
},
4751
suffix: ldp.suffixAcl,
48-
strictOrigin: ldp.strictOrigin
52+
strictOrigin: ldp.strictOrigin,
53+
trustedOrigins: ldp.trustedOrigins
4954
})
5055

5156
// Ensure the user has the required permission
5257
const userId = req.session.userId
53-
req.acl.can(userId, mode)
54-
.then(() => next(), err => {
55-
debug(`${mode} access denied to ${userId || '(none)'}`)
56-
next(err)
57-
})
58+
const isAllowed = await req.acl.can(userId, mode)
59+
if (isAllowed) {
60+
return next()
61+
}
62+
const error = await req.acl.getError(userId, mode)
63+
debug(`${mode} access denied to ${userId || '(none)'}: ${error.status} - ${error.message}`)
64+
next(error)
5865
}
5966
}
6067

@@ -66,8 +73,19 @@ function allow (mode) {
6673
* - `callback(null, graph)` with the parsed RDF graph of the fetched resource
6774
* @return {Function} Returns a `fetch(uri, callback)` handler
6875
*/
69-
function fetchFromLdp (mapper, ldp) {
70-
return function fetch (url, callback) {
71-
ldp.getGraph(url).then(g => callback(null, g), callback)
76+
function fetchFromLdp (mapper) {
77+
return async function fetch (url, graph = $rdf.graph()) {
78+
// Convert the URL into a filename
79+
let path, contentType
80+
try {
81+
({ path, contentType } = await mapper.mapUrlToFile({ url }))
82+
} catch (err) {
83+
throw new HTTPError(404, err)
84+
}
85+
// Read the file from disk
86+
const body = await promisify(fs.readFile)(path, {'encoding': 'utf8'})
87+
// Parse the file as Turtle
88+
$rdf.parse(body, graph, url, contentType)
89+
return graph
7290
}
7391
}

0 commit comments

Comments
 (0)