-
-
Notifications
You must be signed in to change notification settings - Fork 164
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feature: implement access control lists (#448)
* Fine grained access control lists (see SECURITY.md) * Moved the simple token security code into the server * We now send all of the latest deltas out to ws connections when the connection is made
- Loading branch information
Showing
16 changed files
with
1,826 additions
and
363 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,86 +1,66 @@ | ||
Security | ||
======== | ||
|
||
The server provides a simple mechanism to allow security to be implemented using a separate module. | ||
It is the responsibility of the specific security module to enforce its security policy. | ||
|
||
For an example implementation see https://github.com/SignalK/sk-simple-token-security | ||
|
||
To enable security, add a `security` section to your settings .json file and add any configuration that the specific security implementation requires. | ||
To enable security, add a `security` section to your settings .json file and add any configuration that the specific security implementation requires. This can be done automatically under Security in the admin ui. | ||
|
||
``` | ||
"security": { | ||
"strategy": "sk-simple-token-security", | ||
"jwtSecretKey": "tyPaYnCtpZLZjNXyLRKnspZHQyLGZUgkYvtwE7quwZDaZmAnqpKntRqDjTciVazV", | ||
"strategy": "./tokensecurity", | ||
} | ||
``` | ||
|
||
Implementing a security strategy | ||
================================ | ||
|
||
The stragegy module should export one function which takes the Express application as an argument. | ||
Security configuration is stored in file called `security.json` which will be located in the configuration directory. | ||
|
||
This function should setup security with Express to handle all HTTP and REST security. | ||
ACLs | ||
==== | ||
|
||
A very simple pseudo exmaple (see sk-simple-token-security for a real world example): | ||
Access control lists allow fine grained control of access to specific data in SignalK. The acls are a list which allow specifiying controls for specifc contexts and it goes in the security.json file mentioned above. | ||
|
||
``` | ||
module.exports = function(app) { | ||
app.use('/', function(req, res, next) { | ||
if ( user_is_authenticated(req) ) { | ||
next() | ||
} else { | ||
res.status(401).send("user is not authenticated"); | ||
} | ||
}) | ||
} | ||
``` | ||
The following example defines acls for the self context. It allows anyone to read the paths `"steering.*"`, `"navigation.*"`, `"name"`, `"design.aisShipType"` and allows the admin user permission to write (update) those paths. | ||
|
||
To handle WebSocket security, the exported function should return an object with three methods: `shouldAllowWrite`, `verifyWS` and `authorizeWS`. | ||
The second entry allows the user _john_ to read any data coming from the `actisense.35` $source. | ||
|
||
* `shouldAllowWrite` is called if an attempt is made to post a delta message via the WebSocket. | ||
* `authorizeWS` is called when the WebSocket connection is first established. It should throw an exception if authentication fails | ||
* `verifyWS` is called every time a delta is sent out over the websocket. It should periodically check that the authentication is still valid and throw an execption if it is not. | ||
The last entry covers all other paths, allowing only the admin user to read and no one can write. | ||
|
||
A very simple pseudo exmaple (see sk-simple-token-security for a real world example): | ||
If there is no match is found for a specific path in the acl list, then permission will be denied to that path. | ||
|
||
``` | ||
module.exports = function (app) { | ||
var strategy = {} | ||
app.use('/', function (req, res, next) { | ||
if (user_is_authenticated(req)) { | ||
next() | ||
} else { | ||
res.status(401).send('user is not authenticated') | ||
} | ||
}) | ||
strategy.shouldAllowWrite = function (req) { | ||
if (!user_can_write(req)) { | ||
throw new Error('User does not have write permissions') | ||
} | ||
} | ||
strategy.authorizeWS = function (req) { | ||
if (!user_is_authenticated(req)) { | ||
throw new Error('User is not authenticated') | ||
} | ||
} | ||
strategy.verifyWS = function (req) { | ||
if (!req.lastVerify) { | ||
req.lastTokenVerify = new Date() | ||
return | ||
"acls": [ | ||
{ | ||
"context": "vessels.self", | ||
"resources": [ | ||
{ | ||
"paths": ["steering.*", "navigation.*", "name", "design.aisShipType"], | ||
"permissions": [ | ||
{ | ||
"subject": "any", | ||
"permission": "read" | ||
}, | ||
{ | ||
"subject": "admin", | ||
"permission": "write" | ||
} | ||
] | ||
}, | ||
{ | ||
"sources": [ 'actisense.35' ], | ||
"permissions": [ | ||
{ | ||
"subject": "john", | ||
"permission": "read" | ||
} | ||
] | ||
}, | ||
{ | ||
"paths": ["*"], | ||
"permissions": [ | ||
{ | ||
"subject": "admin", | ||
"permission": "read" | ||
} | ||
] | ||
} | ||
] | ||
} | ||
// check once every minute | ||
var now = new Date() | ||
if (now - req.lastTokenVerify > 60 * 1000) { | ||
req.lastVerify = now | ||
strategy.authorizeWS(req) | ||
} | ||
} | ||
return strategy | ||
} | ||
``` | ||
] | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,173 @@ | ||
/* | ||
* Copyright 2017 Scott Bender <[email protected]> | ||
* | ||
* Licensed under the Apache License, Version 2.0 (the "License"); | ||
* you may not use this file except in compliance with the License. | ||
* You may obtain a copy of the License at | ||
* | ||
* http://www.apache.org/licenses/LICENSE-2.0 | ||
* Unless required by applicable law or agreed to in writing, software | ||
* distributed under the License is distributed on an "AS IS" BASIS, | ||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
* See the License for the specific language governing permissions and | ||
* limitations under the License. | ||
*/ | ||
|
||
const debug = require('debug')('signalk-server:deltacache') | ||
const _ = require('lodash') | ||
const { isUndefined } = _ | ||
const { | ||
getMetadata, | ||
getSourceId, | ||
FullSignalK | ||
} = require('@signalk/signalk-schema') | ||
|
||
const { toDelta } = require('./streambundle') | ||
|
||
function DeltaCache (app, streambundle) { | ||
this.cache = {} | ||
this.lastModifieds = {} | ||
this.app = app | ||
this.defaults = JSON.parse(JSON.stringify(app.config.defaults)) | ||
streambundle.keys.onValue(key => { | ||
streambundle | ||
.getBus(key) | ||
.debounceImmediate(20) | ||
.onValue(this.onValue.bind(this)) | ||
}) | ||
} | ||
|
||
DeltaCache.prototype.onValue = function (msg) { | ||
// debug(`onValue ${JSON.stringify(msg)}`) | ||
const source = ensureHasDollarSource(msg) | ||
|
||
var parts = msg.context.split('.').concat(msg.path.split('.')) | ||
// debug(JSON.stringify(parts)) | ||
const leaf = getLeafObject(this.cache, parts, true) | ||
leaf[source] = msg | ||
this.lastModifieds[msg.context] = new Date().getTime() | ||
} | ||
|
||
DeltaCache.prototype.deleteContext = function (contextKey) { | ||
debug('Deleting context ' + contextKey) | ||
var pathParts = contextKey.split('.') | ||
if (pathParts.length === 2) { | ||
delete this.cache[pathParts[0]][pathParts[1]] | ||
} | ||
} | ||
|
||
DeltaCache.prototype.pruneContexts = function (seconds) { | ||
debug('pruning contexts...') | ||
var threshold = new Date().getTime() - seconds * 1000 | ||
for (let contextKey in this.lastModifieds) { | ||
if (this.lastModifieds[contextKey] < threshold) { | ||
this.deleteContext(contextKey) | ||
delete this.lastModifieds[contextKey] | ||
} | ||
} | ||
} | ||
DeltaCache.prototype.buildFull = function (user, path) { | ||
var signalk = new FullSignalK( | ||
this.app.selfId, | ||
this.app.selfType, | ||
JSON.parse(JSON.stringify(this.defaults)) | ||
) | ||
|
||
const leaf = getLeafObject(this.cache, path, false, true) | ||
if (leaf) { | ||
const deltas = findDeltas(leaf).map(toDelta) | ||
const secFilter = this.app.securityStrategy.shouldFilterDeltas() | ||
? delta => this.app.securityStrategy.filterReadDelta(user, delta) | ||
: delta => true | ||
deltas.filter(secFilter).forEach(signalk.addDelta.bind(signalk)) | ||
} | ||
|
||
return signalk.retrieve() | ||
} | ||
|
||
DeltaCache.prototype.getCachedDeltas = function (user, contextFilter, key) { | ||
var contexts = [] | ||
_.keys(this.cache).forEach(type => { | ||
_.keys(this.cache[type]).forEach(id => { | ||
var context = `${type}.${id}` | ||
if (contextFilter({ context: context })) { | ||
contexts.push(this.cache[type][id]) | ||
} | ||
}) | ||
}) | ||
|
||
var deltas = contexts | ||
.reduce((acc, context) => { | ||
var deltas | ||
|
||
if (key) { | ||
deltas = _.get(context, key) | ||
} else { | ||
deltas = findDeltas(context) | ||
} | ||
if (deltas) { | ||
// acc.push(_.reduce(deltas, ((delta, acc) => !acc ? delta : (new Date(delta.timestamp).getTime() > new Date(acc.timestamp).getTime() ? delta : acc)))) | ||
acc = acc.concat(_.values(deltas)) | ||
} | ||
return acc | ||
}, []) | ||
.map(toDelta) | ||
|
||
deltas.sort((left, right) => { | ||
return new Date(left.timestamp).getTime() - new Date(right).getTime() | ||
}) | ||
|
||
if (this.app.securityStrategy.shouldFilterDeltas()) { | ||
deltas = deltas.filter(delta => { | ||
return this.app.securityStrategy.filterReadDelta(user, delta) | ||
}) | ||
} | ||
return deltas | ||
} | ||
|
||
function pickDeltasFromBranch (acc, obj) { | ||
if (isUndefined(obj.path)) { | ||
// not a delta, so process possible children | ||
_.values(obj).reduce(pickDeltasFromBranch, acc) | ||
} else { | ||
acc.push(obj) | ||
} | ||
return acc | ||
} | ||
|
||
function findDeltas (branchOrLeaf) { | ||
return _.values(branchOrLeaf).reduce(pickDeltasFromBranch, []) | ||
} | ||
|
||
function ensureHasDollarSource (normalizedDelta) { | ||
let dollarSource = normalizedDelta['$source'] | ||
if (!dollarSource) { | ||
dollarSource = getSourceId(normalizedDelta.source) | ||
normalizedDelta['$source'] = dollarSource | ||
} | ||
return dollarSource | ||
} | ||
|
||
function getLeafObject ( | ||
start, | ||
branchesArray, | ||
createIfMissing = false, | ||
returnLast = false | ||
) { | ||
let current = start | ||
for (var i in branchesArray) { | ||
var p = branchesArray[i] | ||
if (isUndefined(current[p])) { | ||
if (createIfMissing) { | ||
current[p] = {} | ||
} else { | ||
return returnLast ? current : null | ||
} | ||
} | ||
current = current[p] | ||
} | ||
return current | ||
} | ||
|
||
module.exports = DeltaCache |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,85 @@ | ||
/* | ||
* Copyright 2017 Scott Bender <[email protected]> | ||
* | ||
* Licensed under the Apache License, Version 2.0 (the "License"); | ||
* you may not use this file except in compliance with the License. | ||
* You may obtain a copy of the License at | ||
* | ||
* http://www.apache.org/licenses/LICENSE-2.0 | ||
* Unless required by applicable law or agreed to in writing, software | ||
* distributed under the License is distributed on an "AS IS" BASIS, | ||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
* See the License for the specific language governing permissions and | ||
* limitations under the License. | ||
*/ | ||
|
||
module.exports = function (app, config) { | ||
return { | ||
getConfiguration: () => { | ||
return {} | ||
}, | ||
|
||
allowRestart: req => { | ||
return false | ||
}, | ||
|
||
allowConfigure: req => { | ||
return false | ||
}, | ||
|
||
getLoginStatus: req => { | ||
return { | ||
status: 'notLoggedIn', | ||
readOnlyAccess: false, | ||
authenticationRequired: false | ||
} | ||
}, | ||
|
||
getConfig: config => { | ||
return config | ||
}, | ||
|
||
setConfig: (config, newConfig) => {}, | ||
|
||
getUsers: config => { | ||
return [] | ||
}, | ||
|
||
updateUser: (config, username, updates, callback) => {}, | ||
|
||
addUser: (config, user, callback) => {}, | ||
|
||
setPassword: (config, username, password, callback) => {}, | ||
|
||
deleteUser: (config, username, callback) => {}, | ||
|
||
shouldAllowWrite: function (req, delta) { | ||
return true | ||
}, | ||
|
||
filterReadDelta: (user, delta) => { | ||
return delta | ||
}, | ||
|
||
verifyWS: spark => {}, | ||
|
||
authorizeWS: req => {}, | ||
|
||
checkACL: (id, context, path, source, operation) => { | ||
return true | ||
}, | ||
|
||
isDummy: () => { | ||
return true | ||
}, | ||
|
||
canAuthorizeWS: () => { | ||
return false | ||
}, | ||
|
||
shouldFilterDeltas: () => { | ||
return false | ||
} | ||
} | ||
} |
Oops, something went wrong.