Add role subscriptions and data model

This commit is contained in:
Cooldude2606 committed 2025-06-14 00:28:03 +01:00
1 parent a5bf8d92d9
commit df1edec623
15 files changed
+244 -203

No files matched your search

+26 -5
View File
@@ -141,6 +141,7 @@ export default class Controller {
const roles = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
path.join(databaseDirectory, "roles.json"),
lib.Role.fromJSON.bind(lib.Role),
this.migrateRoles,
).bootstrap());
const userManager = new UserManager(config, roles);
@@ -227,6 +228,7 @@ export default class Controller {
this.subscriptions.handle(lib.ModPackUpdatesEvent, this.handleModPackSubscription.bind(this));
this.subscriptions.handle(lib.ModUpdatesEvent, this.handleModSubscription.bind(this));
this.subscriptions.handle(lib.UserUpdatesEvent, this.handleUserSubscription.bind(this));
this.subscriptions.handle(lib.RoleUpdatesEvent, this.handleRoleSubscription.bind(this));
// Handle updates for datastores
this.systems.on("update", this.systemsUpdated.bind(this));
@@ -672,6 +674,20 @@ export default class Controller {
return instance;
}
static migrateRoles(rawJson: unknown[]): Static<typeof lib.Role.jsonSchema>[] {
const serialized = rawJson as Static<typeof lib.Role.jsonSchema>[];
return serialized.map(json => {
json.permissions = json.permissions.map(permission => {
// migrate: core.instance.save.list.subscribe was renamed in alpha 17
if (permission === "core.instance.save.list.subscribe") {
return "core.instance.save.subscribe";
}
return permission;
});
return json;
});
}
static async loadJsonObject(filePath: string, throwOnMissing: boolean = false): Promise<any> {
let manifest = {};
try {
@@ -1075,12 +1091,10 @@ export default class Controller {
}
}
/**
* Notify connected control clients with the given role that the
* permissions may have changed.
* @param roles - Roles updated.
*/
rolesUpdated(roles: lib.Role[]) {
this.subscriptions.broadcast(new lib.RoleUpdatesEvent(roles));
// lib.invokeHook(this.plugins, "onRolesUpdated", roles); // This doesn't exist at the moment
// Notify connected control clients with the given role that the permissions may have changed.
for (const role of roles) {
for (let controlConnection of this.wsServer.controlConnections.values()) {
if (controlConnection.user.roles.has(role)) {
@@ -1098,6 +1112,13 @@ export default class Controller {
}
}
async handleRoleSubscription(request: lib.SubscriptionRequest) {
const roles = [...this.roles.values()].filter(
role => role.updatedAtMs > request.lastRequestTimeMs,
);
return roles.length ? new lib.RoleUpdatesEvent(roles) : null;
}
async loadPlugins() {
for (let pluginInfo of this.pluginInfos) {
try {
+1 -9
View File
@@ -23,20 +23,12 @@ export default class Role {
is_deleted: Type.Optional(Type.Boolean()),
});
static remapPermission(permission: string) {
// migrate: core.instance.save.list.subscribe was renamed in alpha 17
if (permission === "core.instance.save.list.subscribe") {
return "core.instance.save.subscribe";
}
return permission;
}
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(
json.id,
json.name,
json.description,
new Set(json.permissions.map(this.remapPermission)),
new Set(json.permissions),
json.updated_at_ms,
json.is_deleted,
);
+1
View File
@@ -21,5 +21,6 @@ export * from "./messages_host";
export * from "./messages_instance";
export * from "./messages_mod";
export * from "./messages_user";
export * from "./messages_role";
export * from "./messages_plugin";
export * from "./version";
+134
View File
@@ -0,0 +1,134 @@
import { Type, Static } from "@sinclair/typebox";
import { JsonNumber, jsonArray } from "./composites";
import Permission from "./Permission";
import Role from "./Role";
export class PermissionListRequest {
declare ["constructor"]: typeof PermissionListRequest;
static type = "request" as const;
static src = "control" as const;
static dst = "controller" as const;
static permission = "core.permission.list" as const;
static Response = jsonArray(Permission);
}
export class RoleListRequest {
declare ["constructor"]: typeof RoleListRequest;
static type = "request" as const;
static src = "control" as const;
static dst = "controller" as const;
static permission = "core.role.list" as const;
static Response = jsonArray(Role);
}
export class RoleUpdatesEvent {
declare ["constructor"]: typeof RoleUpdatesEvent;
static type = "event" as const;
static src = "controller" as const;
static dst = "control" as const;
static permission = "core.role.subscribe" as const;
constructor(
public updates: Role[],
) { }
static jsonSchema = Type.Object({
"updates": Type.Array(Role.jsonSchema),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.updates.map(update => Role.fromJSON(update)));
}
}
export class RoleCreateRequest {
declare ["constructor"]: typeof RoleCreateRequest;
static type = "request" as const;
static src = "control" as const;
static dst = "controller" as const;
static permission = "core.role.create" as const;
constructor(
public name: string,
public description: string,
public permissions: string[],
) { }
static jsonSchema = Type.Object({
name: Type.String(),
description: Type.String(),
permissions: Type.Array(Type.String()),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.name, json.description, json.permissions);
}
static Response = JsonNumber;
}
export class RoleUpdateRequest {
declare ["constructor"]: typeof RoleUpdateRequest;
static type = "request" as const;
static src = "control" as const;
static dst = "controller" as const;
static permission = "core.role.update" as const;
constructor(
public id: number,
public name: string,
public description: string,
public permissions: string[],
) { }
static jsonSchema = Type.Object({
id: Type.Integer(),
name: Type.String(),
description: Type.String(),
permissions: Type.Array(Type.String()),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.id, json.name, json.description, json.permissions);
}
}
export class RoleGrantDefaultPermissionsRequest {
declare ["constructor"]: typeof RoleGrantDefaultPermissionsRequest;
static type = "request" as const;
static src = "control" as const;
static dst = "controller" as const;
static permission = "core.role.update" as const;
constructor(
public id: number,
) { }
static jsonSchema = Type.Object({
id: Type.Integer(),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.id);
}
}
export class RoleDeleteRequest {
declare ["constructor"]: typeof RoleDeleteRequest;
static type = "request" as const;
static src = "control" as const;
static dst = "controller" as const;
static permission = "core.role.delete" as const;
constructor(
public id: number,
) { }
static jsonSchema = Type.Object({
id: Type.Integer(),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.id);
}
}
+1 -113
View File
@@ -1,120 +1,8 @@
import { Type, Static } from "@sinclair/typebox";
import Permission from "./Permission";
import Role from "./Role";
import User, { IControllerUser } from "./User";
import { JsonNumber, StringEnum, jsonArray, plainJson } from "./composites";
import { StringEnum, jsonArray, plainJson } from "./composites";
import { MessageRequest } from "./messages_core";
export class PermissionListRequest {
declare ["constructor"]: typeof PermissionListRequest;
static type = "request" as const;
static src = "control" as const;
static dst = "controller" as const;
static permission = "core.permission.list" as const;
static Response = jsonArray(Permission);
}
export class RoleListRequest {
declare ["constructor"]: typeof RoleListRequest;
static type = "request" as const;
static src = "control" as const;
static dst = "controller" as const;
static permission = "core.role.list" as const;
static Response = jsonArray(Role);
}
export class RoleCreateRequest {
declare ["constructor"]: typeof RoleCreateRequest;
static type = "request" as const;
static src = "control" as const;
static dst = "controller" as const;
static permission = "core.role.create" as const;
constructor(
public name: string,
public description: string,
public permissions: string[],
) { }
static jsonSchema = Type.Object({
name: Type.String(),
description: Type.String(),
permissions: Type.Array(Type.String()),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.name, json.description, json.permissions);
}
static Response = JsonNumber;
}
export class RoleUpdateRequest {
declare ["constructor"]: typeof RoleUpdateRequest;
static type = "request" as const;
static src = "control" as const;
static dst = "controller" as const;
static permission = "core.role.update" as const;
constructor(
public id: number,
public name: string,
public description: string,
public permissions: string[],
) { }
static jsonSchema = Type.Object({
id: Type.Integer(),
name: Type.String(),
description: Type.String(),
permissions: Type.Array(Type.String()),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.id, json.name, json.description, json.permissions);
}
}
export class RoleGrantDefaultPermissionsRequest {
declare ["constructor"]: typeof RoleGrantDefaultPermissionsRequest;
static type = "request" as const;
static src = "control" as const;
static dst = "controller" as const;
static permission = "core.role.update" as const;
constructor(
public id: number,
) { }
static jsonSchema = Type.Object({
id: Type.Integer(),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.id);
}
}
export class RoleDeleteRequest {
declare ["constructor"]: typeof RoleDeleteRequest;
static type = "request" as const;
static src = "control" as const;
static dst = "controller" as const;
static permission = "core.role.delete" as const;
constructor(
public id: number,
) { }
static jsonSchema = Type.Object({
id: Type.Integer(),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.id);
}
}
export class UserGetRequest {
declare ["constructor"]: typeof UserGetRequest;
static type = "request" as const;
+9 -6
View File
@@ -4,6 +4,7 @@ import * as host from "../data/messages_host";
import * as instance from "../data/messages_instance";
import * as mod from "../data/messages_mod";
import * as user from "../data/messages_user";
import * as role from "../data/messages_role";
import * as plugin from "../data/messages_plugin";
import * as subscriptions from "../subscriptions";
import type { RequestClass, EventClass } from "./link";
@@ -107,12 +108,14 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
mod.ModUpdatesEvent,
mod.ModPortalDownloadRequest,
user.PermissionListRequest,
user.RoleListRequest,
user.RoleCreateRequest,
user.RoleUpdateRequest,
user.RoleGrantDefaultPermissionsRequest,
user.RoleDeleteRequest,
role.PermissionListRequest,
role.RoleListRequest,
role.RoleUpdatesEvent,
role.RoleCreateRequest,
role.RoleUpdateRequest,
role.RoleGrantDefaultPermissionsRequest,
role.RoleDeleteRequest,
user.UserGetRequest,
user.UserListRequest,
user.UserCreateRequest,
+6
View File
@@ -432,6 +432,12 @@ definePermission({
description: "Get the full list of roles and their permissions in the cluster.",
grantByDefault: true,
});
definePermission({
name: "core.role.subscribe",
title: "Subscribe to role updates",
description: "Subscribe to be notified on updates on the details of roles.",
grantByDefault: true,
});
definePermission({
name: "core.role.create",
title: "Create role",
+2 -13
View File
@@ -1,21 +1,10 @@
import React, { useContext, useEffect, useState } from "react";
import { Select } from "antd";
import * as lib from "@clusterio/lib";
import ControlContext from "./ControlContext";
import { notifyErrorHandler } from "../util/notify";
import { InputComponentProps } from "../BaseWebPlugin";
import { useRoles } from "../model/roles";
export default function InputRole(props: InputComponentProps) {
let control = useContext(ControlContext);
let [roles, setRoles] = useState<lib.Role[]>([]);
useEffect(() => {
control.send(new lib.RoleListRequest()).then(newRoles => {
setRoles(newRoles);
}).catch(notifyErrorHandler("Error fetching role list"));
}, []);
const [roles] = useRoles();
return <Select
showSearch
@@ -5,6 +5,7 @@ import DeleteOutlined from "@ant-design/icons/DeleteOutlined";
import * as lib from "@clusterio/lib";
import { useRole } from "../model/roles";
import { useAccount } from "../model/account";
import ControlContext from "./ControlContext";
import PageHeader from "./PageHeader";
@@ -12,30 +13,6 @@ import PageLayout from "./PageLayout";
import PluginExtra from "./PluginExtra";
import { notifyErrorHandler } from "../util/notify";
function useRole(id: number): [Readonly<lib.Role> | undefined, boolean, () => void] {
let control = useContext(ControlContext);
let [role, setRole] = useState<lib.Role>();
let [synced, setSynced] = useState(false);
function updateRole() {
// XXX optimize by requesting only the role in question
control.send(new lib.RoleListRequest()).then(roles => {
let match = roles.find(u => u.id === id);
if (match) {
setRole(match);
}
setSynced(true);
});
}
useEffect(() => {
updateRole();
}, [id]);
return [role, synced, updateRole];
}
export default function RoleViewPage() {
let params = useParams();
let roleId = Number(params.id);
@@ -44,7 +21,7 @@ export default function RoleViewPage() {
let account = useAccount();
let control = useContext(ControlContext);
const [role, synced, updateRole] = useRole(roleId);
const [role, synced] = useRole(roleId);
let [edited, setEdited] = useState(false);
@@ -63,6 +40,7 @@ export default function RoleViewPage() {
</PageLayout>;
}
// TODO: Update displayed perms when role perms change and have not been edited locally
let initialValues = {
name: role["name"],
description: role["description"],
@@ -88,7 +66,6 @@ export default function RoleViewPage() {
new lib.RoleUpdateRequest(roleId, values.name || "", values.description || "", newPermissions)
).then(() => {
setEdited(false);
updateRole();
}).catch(notifyErrorHandler("Error applying changes"));
}}
>
+3 -10
View File
@@ -4,6 +4,7 @@ import { Button, Form, Input, Modal, Table } from "antd";
import * as lib from "@clusterio/lib";
import { useRoles } from "../model/roles";
import { useAccount } from "../model/account";
import ControlContext from "./ControlContext";
import { notifyErrorHandler } from "../util/notify";
@@ -60,16 +61,8 @@ function CreateRoleButton() {
export default function RolesPage() {
let account = useAccount();
let control = useContext(ControlContext);
let navigate = useNavigate();
let [roles, setRoles] = useState<lib.Role[]>([]);
useEffect(() => {
control.send(new lib.RoleListRequest()).then(newRoles => {
setRoles(newRoles);
}).catch(notifyErrorHandler("Error fetching role list"));
}, []);
const [roles] = useRoles();
return <PageLayout nav={[{ name: "Roles" }]}>
<PageHeader
@@ -88,7 +81,7 @@ export default function RolesPage() {
dataIndex: "description",
},
]}
dataSource={roles}
dataSource={[...roles.values()]}
pagination={false}
rowKey={role => role.id}
onRow={(role, rowIndex) => ({
@@ -8,6 +8,7 @@ import DeleteOutlined from "@ant-design/icons/DeleteOutlined";
import * as lib from "@clusterio/lib";
import { useRoles } from "../model/roles";
import { useAccount } from "../model/account";
import { useInstances } from "../model/instance";
import ControlContext from "./ControlContext";
@@ -35,21 +36,13 @@ export default function UserViewPage() {
let control = useContext(ControlContext);
let [instances] = useInstances();
const [user, synced] = useUser(userName);
let [roles, setRoles] = useState<Map<number, lib.Role>>(new Map());
let [roles] = useRoles();
let [form] = Form.useForm();
let [rolesDirty, setRolesDirty] = useState<boolean>(false);
let [banReasonDirty, setBanReasonDirty] = useState<boolean>(false);
let [applyingRoles, setApplyingRoles] = useState<boolean>(false);
let [rolesError, setRolesError] = useState<string|undefined>();
useEffect(() => {
control.send(new lib.RoleListRequest()).then(newRoles => {
setRoles(new Map(newRoles.map(role => [role.id, role])));
}).catch(() => {
setRoles(new Map());
});
}, []);
useEffect(() => {
if (
account.hasPermission("core.user.set_banned")
@@ -76,6 +69,7 @@ export default function UserViewPage() {
</PageLayout>;
}
// TODO: Update displayed roles when user roles change and have not been edited locally
let roleSelector = <Form.Item
noStyle
name="roles"
+2 -11
View File
@@ -13,6 +13,7 @@ import {
import * as lib from "@clusterio/lib";
import { useRoles } from "../model/roles";
import { useAccount } from "../model/account";
import ControlContext from "./ControlContext";
import { notifyErrorHandler } from "../util/notify";
@@ -373,21 +374,11 @@ function BulkUserActionButton() {
export default function UsersPage() {
let account = useAccount();
let control = useContext(ControlContext);
let navigate = useNavigate();
let [users] = useUsers();
const [roles, setRoles] = useState(new Map());
const [roles] = useRoles();
const searchInput = useRef<InputRef>(null);
useEffect(() => {
control.send(new lib.RoleListRequest()).then(newRoles => {
setRoles(new Map(newRoles.map(role => [role.id, role])));
}).catch(() => {
// ignore
});
}, []);
return <PageLayout nav={[{ name: "Users" }]}>
<PageHeader
title="Users"
+23
View File
@@ -0,0 +1,23 @@
import { useCallback, useContext, useSyncExternalStore } from "react";
import ControlContext from "../components/ControlContext";
import * as lib from "@clusterio/lib";
export function useRolesOfUser(user?: lib.User) {
const [roles, synced] = useRoles();
return [
user !== undefined ? new Map([...user.roleIds].map(roleId => [roleId, roles.get(roleId)])) : new Map(),
synced,
] as const;
}
export function useRole(id?: number) {
const [roles, synced] = useRoles();
return [id !== undefined ? roles.get(id) : undefined, synced] as const;
}
export function useRoles() {
const control = useContext(ControlContext);
const subscribe = useCallback((callback: () => void) => control.roles.subscribe(callback), [control]);
return useSyncExternalStore(subscribe, () => control.roles.getSnapshot());
}
+1
View File
@@ -67,6 +67,7 @@ export class Control extends lib.Link {
modPacks = new lib.EventSubscriber(lib.ModPackUpdatesEvent, this);
mods = new lib.EventSubscriber(lib.ModUpdatesEvent, this);
users = new lib.EventSubscriber(lib.UserUpdatesEvent, this);
roles = new lib.EventSubscriber(lib.RoleUpdatesEvent, this);
declare connector: ControlConnector;
+29 -1
View File
@@ -1,7 +1,7 @@
"use strict";
const assert = require("assert").strict;
const { Controller, HostInfo, InstanceInfo } = require("@clusterio/controller");
const { ControllerConfig, Address, RequestError, InstanceConfig, HostConfig, SystemInfo } = require("@clusterio/lib");
const { ControllerConfig, Address, RequestError, InstanceConfig, SystemInfo, Role } = require("@clusterio/lib");
const { EventEmitter } = require("stream");
class MockEvent {}
@@ -314,5 +314,33 @@ describe("controller/src/Controller", function() {
]);
});
});
describe("Role", function() {
it("migrates renamed permissions", function() { // Alpha 17
const result = Controller.migrateRoles([
{ permissions: ["foo", "bar", "core.instance.save.list.subscribe"] },
{ permissions: ["bar", "core.instance.save.list.subscribe"] },
{ permissions: ["core.instance.save.subscribe"] },
]);
assert.deepEqual(result, [
{ permissions: ["foo", "bar", "core.instance.save.subscribe"] },
{ permissions: ["bar", "core.instance.save.subscribe"] },
{ permissions: ["core.instance.save.subscribe"] },
]);
});
it("does nothing for upto date data", function() {
const role = new Role(
0, "n", "d", new Set("p1", "p2"), 0, false
);
const jsonString = JSON.stringify(role);
const roleJson = JSON.parse(jsonString);
const roleJsonCopy = JSON.parse(jsonString);
const result = Controller.migrateRoles([
roleJson, roleJson, roleJson,
]);
assert.deepEqual(result, [
roleJsonCopy, roleJsonCopy, roleJsonCopy,
]);
});
});
});
});