diff --git a/packages/ctl/ctl.js b/packages/ctl/ctl.js index 6cdff8b5..ac01a274 100755 --- a/packages/ctl/ctl.js +++ b/packages/ctl/ctl.js @@ -927,6 +927,8 @@ class Control extends libLink.Link { this.keepOpen = false; } + async accountUpdateEventHandler() { } + async slaveUpdateEventHandler() { } async instanceUpdateEventHandler() { } diff --git a/packages/lib/link/messages.js b/packages/lib/link/messages.js index 5cc260d2..5a13f6dd 100644 --- a/packages/lib/link/messages.js +++ b/packages/lib/link/messages.js @@ -1085,6 +1085,30 @@ messages.debugWsMessage = new Event({ }, }); +messages.accountUpdate = new Event({ + type: "account_update", + links: ["master-control"], + eventRequired: [], + eventProperties: { + "roles": { + type: "array", + items: { + additionalProperties: false, + type: "object", + required: ["name", "id", "permissions"], + properties: { + "name": { type: "string" }, + "id": { type: "integer" }, + "permissions": { + type: "array", + items: { type: "string" }, + }, + }, + }, + }, + }, +}); + messages.logMessage = new Event({ type: "log_message", links: ["slave-master", "master-control"], diff --git a/packages/lib/schema.js b/packages/lib/schema.js index 6753fd69..da43a843 100644 --- a/packages/lib/schema.js +++ b/packages/lib/schema.js @@ -104,10 +104,27 @@ const serverHandshake = ajv.compile({ "session_timeout": { type: "number" }, "heartbeat_interval": { type: "number" }, "account": { + type: "object", additionalProperties: false, - required: ["name"], + required: ["name", "roles"], properties: { "name": { type: "string" }, + "roles": { + type: "array", + items: { + additionalProperties: false, + type: "object", + required: ["name", "id", "permissions"], + properties: { + "name": { type: "string" }, + "id": { type: "integer" }, + "permissions": { + type: "array", + items: { type: "string" }, + }, + }, + }, + }, }, }, }, diff --git a/packages/master/src/ControlConnection.js b/packages/master/src/ControlConnection.js index 1d2efd49..69e1f5e9 100644 --- a/packages/master/src/ControlConnection.js +++ b/packages/master/src/ControlConnection.js @@ -513,6 +513,7 @@ class ControlConnection extends BaseConnection { role.name = name; role.description = description; role.permissions = new Set(permissions); + this._master.rolePermissionsUpdated(role); } async grantDefaultRolePermissionsRequestHandler(message) { @@ -522,6 +523,7 @@ class ControlConnection extends BaseConnection { } role.grantDefaultPermissions(); + this._master.rolePermissionsUpdated(role); } async deleteRoleRequestHandler(message) { @@ -534,6 +536,7 @@ class ControlConnection extends BaseConnection { this._master.userManager.roles.delete(id); for (let user of this._master.userManager.users.values()) { user.roles.delete(role); + this._master.userPermissionsUpdated(user); } } @@ -573,6 +576,7 @@ class ControlConnection extends BaseConnection { } user.roles = resolvedRoles; + this._master.userPermissionsUpdated(user); } async setUserAdminRequestHandler(message) { diff --git a/packages/master/src/Master.js b/packages/master/src/Master.js index f7590049..52711027 100644 --- a/packages/master/src/Master.js +++ b/packages/master/src/Master.js @@ -10,6 +10,7 @@ const path = require("path"); const libConfig = require("@clusterio/lib/config"); const libErrors = require("@clusterio/lib/errors"); +const libLink = require("@clusterio/lib/link"); const libPlugin = require("@clusterio/lib/plugin"); const libPluginLoader = require("@clusterio/lib/plugin_loader"); const libPrometheus = require("@clusterio/lib/prometheus"); @@ -410,6 +411,44 @@ class Master { } } + /** + * Notify connected control clients under the given user that the + * permissions for this user may have changed. + * @param {module:lib/users.User} user - User permisions updated for. + */ + userPermissionsUpdated(user) { + for (let controlConnection of this.wsServer.controlConnections) { + if (controlConnection.user === user) { + libLink.messages.accountUpdate.send(controlConnection, { + "roles": [...user.roles].map(r => ({ + name: r.name, + id: r.id, + permissions: [...r.permissions], + })), + }); + } + } + } + + /** + * Notify connected control clients with the given role that the + * permissions may have changed. + * @param {module:lib/users.Role} role - Role permisions updated for. + */ + rolePermissionsUpdated(role) { + for (let controlConnection of this.wsServer.controlConnections) { + if (controlConnection.user.roles.has(role)) { + libLink.messages.accountUpdate.send(controlConnection, { + "roles": [...controlConnection.user.roles].map(r => ({ + name: r.name, + id: r.id, + permissions: [...r.permissions], + })), + }); + } + } + } + async loadPlugins() { for (let pluginInfo of this.pluginInfos) { if (!this.config.group(pluginInfo.name).get("load_plugin")) { diff --git a/packages/master/src/WsServer.js b/packages/master/src/WsServer.js index ea0ada5f..9c383976 100644 --- a/packages/master/src/WsServer.js +++ b/packages/master/src/WsServer.js @@ -298,6 +298,11 @@ ${err.stack}` this.controlConnections.push(connection); additionalReadyData["account"] = { name: user.name, + roles: [...user.roles].map(r => ({ + name: r.name, + id: r.id, + permissions: [...r.permissions], + })), }; } diff --git a/packages/web_ui/src/model/account.jsx b/packages/web_ui/src/model/account.jsx index e2b7631b..165a50d1 100644 --- a/packages/web_ui/src/model/account.jsx +++ b/packages/web_ui/src/model/account.jsx @@ -6,30 +6,68 @@ import ControlContext from "../components/ControlContext"; /** * @typedef {Object} Account * @property {?string} name - Name of the currently logged in account. + * @property {?Array} roles - Roles of the corrently logged in account. + * @property {function(string)} hasPermission - + * Check if the currently logged in account has the given permission. * @property {function()} logout - Logs out of the web interface. */ export function useAccount() { let control = useContext(ControlContext); let [name, setName] = useState(control.accountName); + let [roles, setRoles] = useState(control.accountRoles); useEffect(() => { - function onConnect(data) { - setName(data.account.name); + function onAccountUpdate(account) { + setName(account.name); + setRoles(account.roles); } - function onClose() { - setName(null); - } - control.connector.on("connect", onConnect); - control.connector.on("close", onClose); + control.onAccountUpdate(onAccountUpdate); return () => { - control.connector.off("connect", onConnect); - control.connector.off("close", onClose); + control.offAccountUpdate(onAccountUpdate); }; }, [control]); + function hasPermission(permission) { + for (let role of roles) { + if (role.permissions.includes("core.admin") || role.permissions.includes(permission)) { + return true; + } + } + return false; + } + return { name, + roles, + hasPermission(permission) { + if (!roles) { + return null; + } + return hasPermission(permission); + }, + hasAnyPermission(...permissions) { + if (!roles) { + return null; + } + for (let permission of permissions) { + if (hasPermission(permission)) { + return true; + } + } + return false; + }, + hasAllPermission(...permissions) { + if (!roles) { + return null; + } + for (let permission of permissions) { + if (!hasPermission(permission)) { + return false; + } + } + return true; + }, logOut() { control.loggingOut = true; control.shutdown(notifyErrorHandler("Error logging out")); diff --git a/packages/web_ui/src/util/websocket.jsx b/packages/web_ui/src/util/websocket.jsx index aab12ecf..18c54f1c 100644 --- a/packages/web_ui/src/util/websocket.jsx +++ b/packages/web_ui/src/util/websocket.jsx @@ -60,6 +60,13 @@ export class Control extends libLink.Link { */ this.accountName = null; + /** + * Roles of the account this control link is connected as. + * @type {?Array} + */ + this.accountRoles = null; + + this.accountUpdateHandlers = []; this.slaveUpdateHandlers = new Map(); this.instanceUpdateHandlers = new Map(); this.saveListUpdateHandlers = new Map(); @@ -67,6 +74,8 @@ export class Control extends libLink.Link { this.connector.on("connect", data => { this.accountName = data.account.name; + this.accountRoles = data.account.roles; + this.emitAccountUpdate(); this.updateSlaveSubscriptions().catch(err => logger.error( `Unexpected error updating slave subscriptions:\n${err.stack}` )); @@ -83,6 +92,8 @@ export class Control extends libLink.Link { this.connector.on("close", () => { this.accountName = null; + this.accountRoles = null; + this.emitAccountUpdate(); }); for (let event of ["connect", "drop", "resume", "close"]) { @@ -94,6 +105,32 @@ export class Control extends libLink.Link { } } + async accountUpdateEventHandler(message) { + this.accountRoles = message.data.roles; + this.emitAccountUpdate(); + } + + emitAccountUpdate() { + for (let handler of this.accountUpdateHandlers) { + handler({ + name: this.accountName, + roles: this.accountRoles, + }); + } + } + + onAccountUpdate(handler) { + this.accountUpdateHandlers.push(handler); + } + + offAccountUpdate(handler) { + let index = this.accountUpdateHandlers.lastIndexOf(handler); + if (index === -1) { + throw new Error("Given handler is not registered for account update"); + } + this.accountUpdateHandlers.splice(index, 1); + } + async slaveUpdateEventHandler(message) { let handlers = [].concat( this.slaveUpdateHandlers.get(null) || [], diff --git a/test/integration/index.js b/test/integration/index.js index 5dc04010..0bc08be8 100644 --- a/test/integration/index.js +++ b/test/integration/index.js @@ -49,6 +49,8 @@ class TestControl extends libLink.Link { async debugWsMessageEventHandler() { } + async accountUpdateEventHandler() { } + async slaveUpdateEventHandler(message) { this.slaveUpdates.push(message.data); }