Refactor User and Role into data classes (#531)

Use the JSON data class pattern for User, Role, and PlayerStats and move
them into lib/src/data.  This means that User is purely data now and
can't reference role objects directly.  To make it possible easily check
permissions attached to roles and store data only visible on the
controller on user objects a ControllerUser type is introduced which is
linked with the UserManager and can therefore reference role objects
directly.

Resolves #525.
This commit is contained in:
Hornwitser authored and GitHub committed 2023-12-02 01:41:43 +01:00
1 parent f802e6ba67
commit e453a80155
37 files changed
+629 -613

No files matched your search

+1 -1
View File
@@ -127,7 +127,7 @@ async function handleBootstrapCommand(
}
let adminRole = lib.ensureDefaultAdminRole(userManager.roles);
admin.roles.add(adminRole);
admin.roleIds.add(adminRole.id);
admin.isAdmin = true;
await userManager.save(path.join(controllerConfig.get("controller.database_directory"), "users.json"));
+2
View File
@@ -1,8 +1,10 @@
import { bootstrap } from "./controller";
export { default as Controller } from "./src/Controller";
export { default as ControllerUser } from "./src/ControllerUser";
export { default as ControlConnection } from "./src/ControlConnection";
export { default as InstanceInfo } from "./src/InstanceInfo";
export { default as BaseControllerPlugin } from "./src/BaseControllerPlugin";
export { default as UserManager } from "./src/UserManager";
if (module === require.main) {
bootstrap();
+1
View File
@@ -20,6 +20,7 @@
},
"dependencies": {
"@clusterio/lib": "^2.0.0-alpha.13",
"@sinclair/typebox": "^0.30.4",
"busboy": "^1.6.0",
"compression": "^1.7.4",
"express": "^4.18.2",
+17 -63
View File
@@ -9,6 +9,7 @@ import * as lib from "@clusterio/lib";
const { logFilter, logger } = lib;
import BaseConnection from "./BaseConnection";
import ControllerUser from "./ControllerUser";
import * as routes from "./routes";
import Controller from "./Controller";
@@ -43,7 +44,7 @@ export default class ControlConnection extends BaseConnection {
registerData: { agent:string, version:string },
connector: WsServerConnector,
controller: Controller,
public user: lib.User, // The user making this connection.
public user: ControllerUser, // The user making this connection.
public id: number
) {
super(connector, controller);
@@ -635,28 +636,11 @@ export default class ControlConnection extends BaseConnection {
}
async handlePermissionListRequest() {
let list = [];
for (let permission of lib.permissions.values()) {
list.push(new lib.RawPermission(
permission.name,
permission.title,
permission.description,
));
}
return list;
return [...lib.permissions.values()];
}
async handleRoleListRequest() {
let list = [];
for (let role of this._controller.userManager.roles.values()) {
list.push(new lib.RawRole(
role.id,
role.name,
role.description,
[...role.permissions],
));
}
return list;
return [...this._controller.userManager.roles.values()];
}
async handleRoleCreateRequest(request: lib.RoleCreateRequest) {
@@ -664,7 +648,7 @@ export default class ControlConnection extends BaseConnection {
// Start at 5 to leave space for future default roles
let id = Math.max(5, lastId+1);
this._controller.userManager.roles.set(id, new lib.Role({ id, ...request }));
this._controller.userManager.roles.set(id, lib.Role.fromJSON({ id, ...request }));
this._controller.userManager.dirty = true;
return id;
}
@@ -694,57 +678,31 @@ export default class ControlConnection extends BaseConnection {
async handleRoleDeleteRequest(request: lib.RoleDeleteRequest) {
let id = request.id;
let role = this._controller.userManager.roles.get(id);
if (!role) {
if (!this._controller.userManager.roles.delete(id)) {
throw new lib.RequestError(`Role with ID ${id} does not exist`);
}
this._controller.userManager.roles.delete(id);
this._controller.userManager.dirty = true;
for (let user of this._controller.userManager.users.values()) {
user.roles.delete(role);
this._controller.userPermissionsUpdated(user);
if (user.roleIds.delete(id)) {
this._controller.userPermissionsUpdated(user);
}
}
}
async handleUserGetRequest(request: lib.UserGetRequest) {
async handleUserGetRequest(request: lib.UserGetRequest): Promise<lib.User> {
let name = request.name;
let user = this._controller.userManager.users.get(name);
if (!user) {
throw new lib.RequestError(`User ${name} does not exist`);
}
return new lib.RawUser(
user.name,
[...user.roles].map(role => role.id),
[...user.instances],
user.isAdmin,
user.isBanned,
user.isWhitelisted,
user.banReason,
undefined,
user.playerStats,
user.instanceStats,
);
return user;
}
async handleUserListRequest() {
let list = [];
for (let user of this._controller.userManager.users.values()) {
list.push(new lib.RawUser(
user.name,
[...user.roles].map(role => role.id),
[...user.instances],
user.isAdmin,
user.isBanned,
user.isWhitelisted,
undefined,
undefined,
user.playerStats,
undefined,
));
}
return list;
async handleUserListRequest(): Promise<lib.User[]> {
return [...this._controller.userManager.users.values()];
}
async handleUserCreateRequest(request: lib.UserCreateRequest) {
@@ -776,17 +734,13 @@ export default class ControlConnection extends BaseConnection {
throw new lib.RequestError(`User '${request.name}' does not exist`);
}
let resolvedRoles: Set<lib.Role> = new Set();
for (let roleId of request.roles) {
let role = this._controller.userManager.roles.get(roleId);
if (!role) {
if (!this._controller.userManager.roles.has(roleId)) {
throw new lib.RequestError(`Role with ID ${roleId} does not exist`);
}
resolvedRoles.add(role);
}
user.roles = resolvedRoles;
user.roleIds = new Set(request.roles);
this._controller.userPermissionsUpdated(user);
this._controller.userUpdated(user);
}
+4 -16
View File
@@ -19,6 +19,7 @@ import HttpCloser from "./HttpCloser";
import InstanceInfo, { InstanceStatus } from "./InstanceInfo";
import * as metrics from "./metrics";
import * as routes from "./routes";
import ControllerUser from "./ControllerUser";
import UserManager from "./UserManager";
import WsServer from "./WsServer";
import HostConnection, { type HostInfo } from "./HostConnection";
@@ -879,22 +880,9 @@ export default class Controller {
lib.invokeHook(this.plugins, "onModUpdated", mod);
}
userUpdated(user: lib.User) {
userUpdated(user: ControllerUser) {
this.userManager.dirty = true;
this.subscriptions.broadcast(new lib.UserUpdateEvent(
new lib.RawUser(
user.name,
[...user.roles].map(role => role.id),
[...user.instances],
user.isAdmin,
user.isBanned,
user.isWhitelisted,
user.banReason,
user.isDeleted,
user.playerStats,
user.instanceStats,
)
));
this.subscriptions.broadcast(new lib.UserUpdateEvent(user));
}
/**
@@ -902,7 +890,7 @@ export default class Controller {
* permissions for this user may have changed.
* @param user - User permisions updated for.
*/
userPermissionsUpdated(user: lib.User) {
userPermissionsUpdated(user: ControllerUser) {
for (let controlConnection of this.wsServer.controlConnections.values()) {
if (controlConnection.user === user) {
controlConnection.send(
+92
View File
@@ -0,0 +1,92 @@
import { Static, Type } from "@sinclair/typebox";
import { IControllerUser, PermissionError, PlayerStats, Role, User, permissions } from "@clusterio/lib";
import type UserManager from "./UserManager";
export default class ControllerUser extends User implements IControllerUser {
constructor(
public userManager: UserManager,
/** Unix time in seconds the user token must be issued after to be valid. */
public tokenValidAfter = 0,
...args: ConstructorParameters<typeof User>
) {
super(...args);
}
static jsonSchema = Type.Object({
...User.jsonSchema.properties,
token_valid_after: Type.Optional(Type.Number()),
});
static fromJSON(json: Static<typeof this.jsonSchema>, userManager: UserManager) {
const roleIds = new Set<number>(json.roles ?? []);
const instanceStats = new Map(
(json.instance_stats ?? []).map(
([id, stats]) => [id, PlayerStats.fromJSON(stats)]
)
);
const playerStats = User._calculatePlayerStats(instanceStats);
return new this(
userManager,
json.token_valid_after,
json.name,
roleIds,
new Set(json.instances),
json.is_admin,
json.is_banned,
json.is_whitelisted,
json.ban_reason,
json.is_deleted,
playerStats,
instanceStats,
);
}
toJSON(controller?: boolean): Static<typeof ControllerUser.jsonSchema> {
if (!controller) {
return super.toJSON();
}
const json: Static<typeof ControllerUser.jsonSchema> = super.toJSON();
if (this.tokenValidAfter) {
json.token_valid_after = this.tokenValidAfter;
}
return json;
}
get roles(): ReadonlySet<Readonly<Role>> {
return new Set([...this.roleIds].map(
id => this.userManager.roles.get(id)
).filter(
(r): r is Role => Boolean(r)
));
}
/**
* Invalidate current tokens for the user
*
* Sets the tokenValidAfter property to the current time, which causes
* all currently issued tokens for the user to become invalid.
*/
invalidateToken() {
this.tokenValidAfter = Math.floor(Date.now() / 1000);
}
checkPermission(permission: string) {
if (!permissions.has(permission)) {
throw new Error(`permission ${permission} does not exist`);
}
for (let roleId of this.roleIds) {
let role = this.userManager.roles.get(roleId);
if (!role) {
continue;
}
if (role.permissions.has("core.admin") || role.permissions.has(permission)) {
return;
}
}
throw new PermissionError("Permission denied");
}
}
+3 -4
View File
@@ -2,7 +2,7 @@ import type Controller from "./Controller";
import type WsServerConnector from "./WsServerConnector";
import * as lib from "@clusterio/lib";
const { logger, PlayerStats } = lib;
const { logger } = lib;
import BaseConnection from "./BaseConnection";
import InstanceInfo, { InstanceStatus } from "./InstanceInfo";
@@ -272,7 +272,6 @@ export default class HostConnection extends BaseConnection {
}
async handleInstancePlayerUpdateEvent(event: lib.InstancePlayerUpdateEvent, src: lib.Address) {
let instanceId = src.id;
let user = this._controller.userManager.users.get(event.name);
if (!user) {
@@ -280,9 +279,9 @@ export default class HostConnection extends BaseConnection {
}
if (event.type === "join") {
user.notifyJoin(instanceId);
this._controller.userManager.notifyJoin(user, instanceId);
} else if (event.type === "leave") {
user.notifyLeave(instanceId);
this._controller.userManager.notifyLeave(user, instanceId);
}
if (event.stats) {
+29 -10
View File
@@ -3,15 +3,22 @@ import jwt from "jsonwebtoken";
import * as lib from "@clusterio/lib";
import ControllerUser from "./ControllerUser";
/**
* Manages users and roles
* @alias module:controller/src/UserManager
*/
export default class UserManager {
roles: Map<number, lib.Role> = new Map();
users: Map<string, lib.User> = new Map();
users: Map<string, ControllerUser> = new Map();
dirty = false;
/**
* Set of users currently online in the cluster.
*/
onlineUsers = new Set<ControllerUser>();
constructor(
private _config: lib.ControllerConfig
) {
@@ -21,12 +28,12 @@ export default class UserManager {
try {
let content = JSON.parse(await fs.readFile(filePath, { encoding: "utf8" }));
for (let serializedRole of content.roles) {
let role = new lib.Role(serializedRole);
let role = lib.Role.fromJSON(serializedRole);
this.roles.set(role.id, role);
}
for (let serializedUser of content.users) {
let user = new lib.User(serializedUser, this.roles);
let user = ControllerUser.fromJSON(serializedUser, this);
this.users.set(user.name, user);
}
@@ -44,12 +51,12 @@ export default class UserManager {
async save(filePath:string): Promise<void> {
let serializedRoles = [];
for (let role of this.roles.values()) {
serializedRoles.push(role.serialize());
serializedRoles.push(role.toJSON());
}
let serializedUsers = [];
for (let user of this.users.values()) {
serializedUsers.push(user.serialize());
serializedUsers.push(user.toJSON(true));
}
let serialized = {
@@ -65,18 +72,18 @@ export default class UserManager {
* @param name - Name of the user to create.
* @returns The created user.
*/
createUser(name:string): lib.User {
createUser(name:string): ControllerUser {
if (this.users.has(name)) {
throw new Error(`User '${name}' already exists`);
}
let roles = [];
let roles = new Set<number>();
let defaultRoleId = this._config.get("controller.default_role_id");
if (defaultRoleId !== null) {
roles.push(defaultRoleId);
if (defaultRoleId !== null && this.roles.has(defaultRoleId)) {
roles.add(defaultRoleId);
}
let user = new lib.User({ name, roles }, this.roles);
let user = new ControllerUser(this, 0, name, roles);
this.users.set(name, user);
this.dirty = true;
return user;
@@ -94,4 +101,16 @@ export default class UserManager {
Buffer.from(this._config.get("controller.auth_secret"), "base64")
);
}
notifyJoin(user: ControllerUser, instance_id: number) {
user.instances.add(instance_id);
this.onlineUsers.add(user);
}
notifyLeave(user: ControllerUser, instance_id: number) {
user.instances.delete(instance_id);
if (!user.instances.size) {
this.onlineUsers.delete(user);
}
}
}
+15 -6
View File
@@ -1438,19 +1438,19 @@ roleCommands.add(new lib.Command({
role.description = args.description;
}
if (args.addPerms) {
role.permissions = role.permissions.concat(args.addPerms);
for (const perm of args.addPerms) {
role.permissions.add(perm);
}
}
if (args.removePerms) {
let perms = new Set(role.permissions);
for (let perm of args.removePerms) {
perms.delete(perm);
role.permissions.delete(perm);
}
role.permissions = [...perms];
}
if (args.setPerms !== undefined) {
role.permissions = args.setPerms;
role.permissions = new Set(args.setPerms);
}
await control.send(new lib.RoleUpdateRequest(role.id, role.name, role.description, role.permissions));
await control.send(new lib.RoleUpdateRequest(role.id, role.name, role.description, [...role.permissions]));
if (args.grantDefault) {
await control.send(new lib.RoleGrantDefaultPermissionsRequest(role.id));
@@ -1480,8 +1480,10 @@ userCommands.add(new lib.Command({
handler: async function(args: { name: string, instanceStats: boolean }, control: Control) {
let user = await control.send(new lib.UserGetRequest(args.name));
Object.assign(user, user.playerStats);
// @ts-expect-error Terrible hack
delete user.playerStats;
let instanceStats = user.instanceStats;
// @ts-expect-error Terrible hack
delete user.instanceStats;
print(asTable(Object.entries(user).map(([property, value]) => ({ property, value }))));
@@ -1516,13 +1518,20 @@ userCommands.add(new lib.Command({
if (args.stats) {
Object.assign(user, user.playerStats);
}
// @ts-expect-error Terrible hack
delete user.playerStats;
// @ts-expect-error Terrible hack
delete user.isDeleted;
// @ts-expect-error Terrible hack
delete user.banReason;
// @ts-expect-error Terrible hack
delete user.instanceStats;
if (!args.attributes) {
// @ts-expect-error Terrible hack
delete user.isAdmin;
// @ts-expect-error Terrible hack
delete user.isWhitelisted;
// @ts-expect-error Terrible hack
delete user.isBanned;
}
}
+1 -2
View File
@@ -7,11 +7,10 @@ export * from "./src/helpers";
export * from "./src/ini";
export * from "./src/link";
export * from "./src/logging";
export * from "./src/permissions";
export * from "./src/plugin";
export * from "./src/schema";
export * from "./src/users";
export * from "./src/subscriptions";
export { default as ExponentialBackoff } from "./src/ExponentialBackoff";
export { default as PlayerStats } from "./src/PlayerStats";
export { default as RateLimiter } from "./src/RateLimiter";
+1 -2
View File
@@ -18,16 +18,15 @@ export * from "./src/link";
export * from "./src/logging";
export * from "./src/logging_utils";
export * from "./src/lua_tools";
export * from "./src/permissions";
export * from "./src/plugin";
export * from "./src/plugin_loader";
export * from "./src/prometheus";
export * from "./src/schema";
export * from "./src/shared_commands";
export * from "./src/stream";
export * from "./src/users";
export * from "./src/zip_ops";
export * from "./src/subscriptions";
export { default as ExponentialBackoff } from "./src/ExponentialBackoff";
export { default as PlayerStats } from "./src/PlayerStats";
export { default as RateLimiter } from "./src/RateLimiter";
+1 -1
View File
@@ -253,7 +253,7 @@ export async function resolveModPack(client: Link, modPackName: string) {
export async function retrieveRole(client: Link, roleName: string) {
let roles = await client.sendTo("controller", new libData.RoleListRequest());
let resolvedRole: libData.RawRole | undefined;
let resolvedRole: libData.Role | undefined;
if (/^-?\d+$/.test(roleName)) {
let roleId = parseInt(roleName, 10);
for (let role of roles) {
+23
View File
@@ -0,0 +1,23 @@
import { Static, Type } from "@sinclair/typebox";
/**
* Represents a permission that can be granted
*/
export default class Permission {
constructor(
public name: string,
public title: string,
public description: string,
public grantByDefault?: boolean,
) { }
static jsonSchema = Type.Object({
name: Type.String(),
title: Type.String(),
description: Type.String(),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.name, json.title, json.description);
}
}
File renamed without changes.
+47
View File
@@ -0,0 +1,47 @@
import { Static, Type } from "@sinclair/typebox";
import { permissions as globalPermissions } from "../permissions";
/**
* Represents a collection of granted permissions
*/
export default class Role {
constructor(
public id: number,
public name: string,
public description: string,
public permissions: Set<string>,
) { }
static jsonSchema = Type.Object({
id: Type.Number(),
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,
new Set(json.permissions),
);
}
toJSON() {
return {
id: this.id,
name: this.name,
description: this.description,
permissions: [...this.permissions],
};
}
grantDefaultPermissions() {
for (let permission of globalPermissions.values()) {
if (permission.grantByDefault) {
this.permissions.add(permission.name);
}
}
}
}
+153
View File
@@ -0,0 +1,153 @@
import { Static, Type } from "@sinclair/typebox";
import PlayerStats from "./PlayerStats";
/**
* Represeents a user in the cluster
*
* Holds data about a Factorio user in the cluster.
*/
export default class User {
constructor(
/** Factorio user name. */
public name: string,
/** Roles this user has */
public roleIds = new Set<number>(),
/** Instances this user is online on. */
public instances = new Set<number>(),
/** True if the user is promoted to admin on the Factorio instances. */
public isAdmin = false,
/** True if the user is banned from Factorio instances. */
public isBanned = false,
/** True if the user is whitelisted on the Factorio instances. */
public isWhitelisted = false,
/** Reason for being banned. Ignored if isBanned is false. */
public banReason = "",
/** True if this user object has been removed from the cluster. */
public isDeleted = false,
/** Combined statistics for the player this user account is tied to. */
public playerStats = new PlayerStats(),
/** Per instance statistics for the player this user account is tied to. */
public instanceStats = new Map<number, PlayerStats>(),
) {
}
static jsonSchema = Type.Object({
name: Type.String(),
roles: Type.Optional(Type.Array(Type.Integer())),
instances: Type.Optional(Type.Array(Type.Integer())),
is_admin: Type.Optional(Type.Boolean()),
is_banned: Type.Optional(Type.Boolean()),
is_whitelisted: Type.Optional(Type.Boolean()),
ban_reason: Type.Optional(Type.String()),
is_deleted: Type.Optional(Type.Boolean()),
instance_stats: Type.Optional(
Type.Array(
Type.Tuple([Type.Integer(), PlayerStats.jsonSchema]),
)
),
});
static fromJSON(json: Static<typeof this.jsonSchema>, _hack?: any) {
const roleIds = new Set<number>(json.roles ?? []);
const instanceStats = new Map(
(json.instance_stats ?? []).map(
([id, stats]) => [id, PlayerStats.fromJSON(stats)]
)
);
const playerStats = User._calculatePlayerStats(instanceStats);
return new this(
json.name,
roleIds,
new Set(json.instances),
json.is_admin,
json.is_banned,
json.is_whitelisted,
json.ban_reason,
json.is_deleted,
playerStats,
instanceStats,
);
}
toJSON() {
let json: Static<typeof User.jsonSchema> = {
name: this.name,
};
if (this.roleIds.size) {
json.roles = [...this.roleIds];
}
if (this.instances.size) {
json.instances = [...this.instances];
}
if (this.isAdmin) {
json.is_admin = true;
}
if (this.isWhitelisted) {
json.is_whitelisted = true;
}
if (this.isBanned) {
json.is_banned = true;
}
if (this.banReason) {
json.ban_reason = this.banReason;
}
if (this.isDeleted) {
json.is_deleted = this.isDeleted;
}
if (this.instanceStats.size) {
json.instance_stats = [...this.instanceStats].map(([id, stats]) => [id, stats.toJSON()]);
}
return json;
}
recalculatePlayerStats() {
this.playerStats = User._calculatePlayerStats(this.instanceStats);
}
static _calculatePlayerStats(instanceStatsMap: Map<number, PlayerStats>) {
let playerStats = new PlayerStats();
for (let instanceStats of instanceStatsMap.values()) {
if (
instanceStats.lastJoinAt
&& (!playerStats.lastJoinAt || instanceStats.lastJoinAt > playerStats.lastJoinAt)
) {
playerStats.lastJoinAt = instanceStats.lastJoinAt;
}
if (
instanceStats.lastLeaveAt
&& (!playerStats.lastLeaveAt || instanceStats.lastLeaveAt > playerStats.lastLeaveAt)
) {
playerStats.lastLeaveAt = instanceStats.lastLeaveAt;
playerStats.lastLeaveReason = instanceStats.lastLeaveReason;
}
playerStats.joinCount += instanceStats.joinCount;
playerStats.onlineTimeMs += instanceStats.onlineTimeMs;
}
return playerStats;
}
}
export interface IControllerUser extends User {
/**
* Check if a given permission is granted
*
* Checks the roles the user is member of for one that grants the given
* permission. If the permission is not granted for the user a
* "Permission denied" error is thrown.
*
* @param permission - The permission to check for.
* @throws {Error} If the given permission does not exist.
* @throws {libErrors.PermissionError} if the user does noh have the given permission.
*/
checkPermission(permission: string): void
}
+4
View File
@@ -7,6 +7,10 @@ export { default as ExportManifest } from "./ExportManifest";
export { default as ModInfo } from "./ModInfo";
export { default as ModPack, ModSetting, ModRecord } from "./ModPack";
export { default as ModuleInfo } from "./ModuleInfo";
export { default as Permission } from "./Permission";
export { default as PlayerStats } from "./PlayerStats";
export { default as Role } from "./Role";
export { default as User, IControllerUser } from "./User";
export * from "./composites";
export * from "./messages_core";
export * from "./messages_controller";
+3 -3
View File
@@ -1,8 +1,8 @@
import { Type, Static } from "@sinclair/typebox";
import PlayerStats from "../PlayerStats";
import PlayerStats from "./PlayerStats";
import { JsonString, StringEnum, jsonArray } from "./composites";
import { RawConfig } from "../config";
import type { User } from "../users";
import type { IControllerUser } from "./User";
import type { MessageRequest } from "./messages_core";
import { CollectorResultSerialized } from "../prometheus";
@@ -433,7 +433,7 @@ export class InstanceTransferSaveRequest {
static type = "request" as const;
static src = ["control", "controller"] as const;
static dst = ["controller", "host"] as const;
static permission(user: User, message: MessageRequest) {
static permission(user: IControllerUser, message: MessageRequest) {
user.checkPermission("core.instance.save.transfer");
if (typeof message.data === "object" && message.data !== null) {
const data = message.data as { copy: boolean, sourceName: string, targetName: string };
+10 -115
View File
@@ -1,52 +1,16 @@
import { Type, Static } from "@sinclair/typebox";
import PlayerStats from "../PlayerStats";
import Permission from "./Permission";
import Role from "./Role";
import User from "./User";
import { JsonNumber, jsonArray } from "./composites";
export class RawPermission { // TODO refactor into lib/user.Permission
constructor(
public name: string,
public title: string,
public description: string,
) { }
static jsonSchema = Type.Object({
name: Type.String(),
title: Type.String(),
description: Type.String(),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.name, json.title, json.description);
}
}
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(RawPermission);
}
export class RawRole { // TODO refactor into lib/user.Role
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);
}
static Response = jsonArray(Permission);
}
export class RoleListRequest {
@@ -55,7 +19,7 @@ export class RoleListRequest {
static src = "control" as const;
static dst = "controller" as const;
static permission = "core.role.list" as const;
static Response = jsonArray(RawRole);
static Response = jsonArray(Role);
}
export class RoleCreateRequest {
@@ -150,75 +114,6 @@ export class RoleDeleteRequest {
}
}
export class RawUser { // TODO refactor into lib/user.User
constructor(
public name: string,
public roles: number[],
public instances: number[],
public isAdmin?: boolean,
public isBanned?: boolean,
public isWhitelisted?: boolean,
public banReason?: string,
public isDeleted?: boolean,
public playerStats?: PlayerStats,
public instanceStats?: Map<number, PlayerStats>,
) { }
static jsonSchema = Type.Object({
name: Type.String(),
roles: Type.Array(Type.Integer()),
instances: Type.Array(Type.Integer()),
isAdmin: Type.Optional(Type.Boolean()),
isBanned: Type.Optional(Type.Boolean()),
isWhitelisted: Type.Optional(Type.Boolean()),
banReason: Type.Optional(Type.String()),
isDeleted: Type.Optional(Type.Boolean()),
playerStats: Type.Optional(PlayerStats.jsonSchema),
instanceStats: Type.Optional(
Type.Array(
Type.Tuple([Type.Integer(), PlayerStats.jsonSchema]),
)
),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
let playerStats: PlayerStats | undefined;
if (json.playerStats) {
playerStats = PlayerStats.fromJSON(json.playerStats);
}
let instanceStats: Map<number, PlayerStats> | undefined;
if (json.instanceStats) {
instanceStats = new Map(
json.instanceStats.map(([id, stats]) => [id, PlayerStats.fromJSON(stats)])
);
}
return new this(
json.name, json.roles, json.instances, json.isAdmin, json.isBanned, json.isWhitelisted, json.banReason,
json.isDeleted, playerStats, instanceStats
);
}
toJSON() {
const json: Static<typeof RawUser.jsonSchema> = {
name: this.name,
roles: this.roles,
instances: this.instances,
isAdmin: this.isAdmin,
isBanned: this.isBanned,
isWhitelisted: this.isWhitelisted,
banReason: this.banReason,
isDeleted: this.isDeleted,
};
if (this.playerStats) {
json.playerStats = this.playerStats.toJSON();
}
if (this.instanceStats) {
json.instanceStats = [...this.instanceStats].map(([k, v]) => [k, v.toJSON()]);
}
return json;
}
}
export class UserGetRequest {
declare ["constructor"]: typeof UserGetRequest;
static type = "request" as const;
@@ -238,7 +133,7 @@ export class UserGetRequest {
return new this(json.name);
}
static Response = RawUser;
static Response = User;
}
export class UserListRequest {
@@ -247,7 +142,7 @@ export class UserListRequest {
static src = "control" as const;
static dst = "controller" as const;
static permission = "core.user.list" as const;
static Response = jsonArray(RawUser);
static Response = jsonArray(User);
}
export class UserCreateRequest {
@@ -414,15 +309,15 @@ export class UserUpdateEvent {
static permission = "core.user.subscribe" as const;
constructor(
public user: RawUser,
public user: User,
) { }
static jsonSchema = Type.Object({
"user": RawUser.jsonSchema,
"user": User.jsonSchema,
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(RawUser.fromJSON(json.user));
return new this(User.fromJSON(json.user));
}
get subscriptionChannel() {
+5 -4
View File
@@ -7,8 +7,9 @@ import { dataClasses } from "./messages";
import { BaseConnector, WebSocketBaseConnector } from "./connectors";
import { strict as assert } from "assert";
import type { PluginNodeEnvInfo, PluginWebpackEnvInfo } from "../plugin";
import type { User } from "../users";
import type { AddressType, JSONDeserialisable, MessageRoutable, MessageRequest, MessageEvent } from "../data";
import type {
AddressType, JSONDeserialisable, MessageRoutable, MessageRequest, MessageEvent, IControllerUser,
} from "../data";
export interface Request<Req, Res> {
constructor: Partial<JSONDeserialisable<Req & Request<Req, Res>>> & {
@@ -17,7 +18,7 @@ export interface Request<Req, Res> {
type: "request";
src: AddressType | readonly AddressType[];
dst: AddressType | readonly AddressType[];
permission?: null | string | ((user: User, message: MessageRequest) => void);
permission?: null | string | ((user: IControllerUser, message: MessageRequest) => void);
plugin?: string;
Response?: JSONDeserialisable<Res>,
}
@@ -31,7 +32,7 @@ export interface Event<T> {
type: "event";
src: AddressType | readonly AddressType[];
dst: AddressType | readonly AddressType[];
permission?: null | string | ((user: User, message: MessageEvent) => void);
permission?: null | string | ((user: IControllerUser, message: MessageEvent) => void);
plugin?: string;
}
}
@@ -6,22 +6,9 @@
* @author Hornwitser
* @module lib/users
*/
import * as libErrors from "./errors";
import PlayerStats from "./PlayerStats";
import { Permission, Role } from "./data";
/**
* Represents a permission that can be granted
*/
export class Permission {
constructor(
public name: string,
public title: string,
public description: string,
public grantByDefault: boolean,
) { }
}
export const permissions = new Map<string, Permission>();
/**
@@ -70,40 +57,6 @@ export function definePermission({
permissions.set(name, new Permission(name, title, description, grantByDefault));
}
/**
* Represents a collection of granted permissions
*/
export class Role {
id: number;
name: string;
description: string;
permissions: Set<string>;
constructor(serializedRole: ReturnType<Role["serialize"]>) {
this.id = serializedRole.id;
this.name = serializedRole.name;
this.description = serializedRole.description;
this.permissions = new Set(serializedRole.permissions);
}
serialize() {
return {
id: this.id,
name: this.name,
description: this.description,
permissions: [...this.permissions],
};
}
grantDefaultPermissions() {
for (let permission of permissions.values()) {
if (permission.grantByDefault) {
this.permissions.add(permission.name);
}
}
}
}
/**
* Ensure the default admin role has full access
@@ -116,7 +69,7 @@ export class Role {
export function ensureDefaultAdminRole(roles: Map<number, Role>) {
let admin = roles.get(0);
if (!admin) {
admin = new Role({
admin = Role.fromJSON({
id: 0,
name: "Cluster Admin",
description: "Cluster wide administrator.",
@@ -140,7 +93,7 @@ export function ensureDefaultAdminRole(roles: Map<number, Role>) {
export function ensureDefaultPlayerRole(roles: Map<number, Role>) {
let player = roles.get(1);
if (!player) {
player = new Role({
player = Role.fromJSON({
id: 1,
name: "Player",
description: "Default player role.",
@@ -152,188 +105,6 @@ export function ensureDefaultPlayerRole(roles: Map<number, Role>) {
}
/**
* Represeents a user in the cluster
*
* Holds data about a Factorio user in the cluster.
*/
export class User {
/** Factorio user name. */
name: string;
/** Instances this user is online on. */
instances: Set<number>;
/** Unix time in seconds the user token must be issued after to be valid. */
tokenValidAfter: number;
/** True if the user is promoted to admin on the Factorio instances. */
isAdmin: boolean;
/** True if the user is whitelisted on the Factorio instances. */
isWhitelisted: boolean;
/** True if the user is banned from Factorio instances. */
isBanned: boolean;
/** Reason for being banned. Ignored if isBanned is false. */
banReason: string;
/** Roles this user has */
roles: Set<Role>;
/** Per instance statistics for the player this user account is tied to. */
instanceStats: Map<number, PlayerStats>;
/** Combined statistics for the player this user account is tied to. */
playerStats: PlayerStats;
/** True if this user object has been removed from the cluster. */
isDeleted: boolean;
constructor(
serializedUser: ReturnType<User["serialize"]>,
loadedRoles: Map<number, Role>
) {
this.name = serializedUser.name;
this.instances = new Set();
this.tokenValidAfter = serializedUser.token_valid_after || 0;
this.isAdmin = Boolean(serializedUser.is_admin);
this.isWhitelisted = Boolean(serializedUser.is_whitelisted);
this.isBanned = Boolean(serializedUser.is_banned);
this.banReason = serializedUser.ban_reason || "";
this.roles = new Set();
if (serializedUser.roles) {
for (let roleId of serializedUser.roles) {
let role = loadedRoles.get(roleId);
if (role) {
this.roles.add(role);
}
}
}
this.instanceStats = new Map(
(serializedUser.instance_stats ? serializedUser.instance_stats : []).map(
([id, stats]) => [id, new PlayerStats(stats)]
)
);
this.playerStats = this._calculatePlayerStats();
this.isDeleted = false;
}
serialize() {
let serialized: {
name: string,
roles?: number[],
token_valid_after?: number,
is_admin?: boolean,
is_whitelisted?: boolean,
is_banned?: boolean,
ban_reason?: string,
instance_stats?: [number, object][],
} = {
name: this.name,
};
if (this.roles.size) {
serialized.roles = [...this.roles].map(role => role.id);
}
if (this.tokenValidAfter) {
serialized.token_valid_after = this.tokenValidAfter;
}
if (this.isAdmin) {
serialized.is_admin = true;
}
if (this.isWhitelisted) {
serialized.is_whitelisted = true;
}
if (this.isBanned) {
serialized.is_banned = true;
}
if (this.banReason) {
serialized.ban_reason = this.banReason;
}
if (this.instanceStats.size) {
serialized.instance_stats = [...this.instanceStats].map(([id, stats]) => [id, stats.toJSON()]);
}
return serialized;
}
/**
* Invalidate current tokens for the user
*
* Sets the tokenValidAfter property to the current time, which causes
* all currently issued tokens for the user to become invalid.
*/
invalidateToken() {
this.tokenValidAfter = Math.floor(Date.now() / 1000);
}
/**
* Check if a given permission is granted
*
* Checks the roles the user is member of for one that grants the given
* permission. If the permission is not granted for the user a
* "Permission denied" error is thrown.
*
* @param permission - The permission to check for.
* @throws {Error} If the given permission does not exist.
* @throws {libErrors.PermissionError} if the user does noh have the given permission.
*/
checkPermission(permission: string) {
if (!permissions.has(permission)) {
throw new Error(`permission ${permission} does not exist`);
}
for (let role of this.roles) {
if (role.permissions.has("core.admin") || role.permissions.has(permission)) {
return;
}
}
throw new libErrors.PermissionError("Permission denied");
}
notifyJoin(instance_id: number) {
this.instances.add(instance_id);
User.onlineUsers.add(this);
}
notifyLeave(instance_id: number) {
this.instances.delete(instance_id);
if (!this.instances.size) {
User.onlineUsers.delete(this);
}
}
recalculatePlayerStats() {
this.playerStats = this._calculatePlayerStats();
}
_calculatePlayerStats() {
let playerStats = new PlayerStats();
for (let instanceStats of this.instanceStats.values()) {
if (
instanceStats.lastJoinAt
&& (!playerStats.lastJoinAt || instanceStats.lastJoinAt > playerStats.lastJoinAt)
) {
playerStats.lastJoinAt = instanceStats.lastJoinAt;
}
if (
instanceStats.lastLeaveAt
&& (!playerStats.lastLeaveAt || instanceStats.lastLeaveAt > playerStats.lastLeaveAt)
) {
playerStats.lastLeaveAt = instanceStats.lastLeaveAt;
playerStats.lastLeaveReason = instanceStats.lastLeaveReason;
}
playerStats.joinCount += instanceStats.joinCount;
playerStats.onlineTimeMs += instanceStats.onlineTimeMs;
}
return playerStats;
}
/**
* Set of users currently online in the cluster.
*/
static onlineUsers = new Set<User>();
}
// Definitions for the built in permissions used in Clusterio.
// description should answer "this permission allows you to ___"
definePermission({
+1 -1
View File
@@ -5,7 +5,7 @@
import * as libHelpers from "./helpers";
import type { Logger } from "./logging";
import type { PluginConfigGroup } from "./config";
import type PlayerStats from "./PlayerStats";
import type { PlayerStats } from "./data";
// TODO Add proper typing for plugins
/* Used to define the export of info.ts from plugins */
+2 -3
View File
@@ -1,7 +1,6 @@
import { Type, Static } from "@sinclair/typebox";
import { Link, Event, EventClass, RequestHandler, WebSocketClientConnector, WebSocketBaseConnector } from "./link";
import { Address, MessageRequest } from "./data";
import { User } from "./users";
import { Address, MessageRequest, IControllerUser } from "./data";
export type SubscriptionRequestHandler<T> = RequestHandler<SubscriptionRequest, Event<T> | null>;
export type EventSubscriberCallback<T> = (value: T) => void;
@@ -69,7 +68,7 @@ export class SubscriptionRequest {
static src = ["control", "instance"] as const;
static dst = "controller" as const;
static Response = SubscriptionResponse;
static permission(user: User, message: MessageRequest) {
static permission(user: IControllerUser, message: MessageRequest) {
if (typeof message.data === "object" && message.data !== null) {
const data = message.data as Static<typeof SubscriptionRequest.jsonSchema>;
const entry = Link._eventsByName.get(data[0]);
@@ -13,7 +13,7 @@ import PluginExtra from "./PluginExtra";
import { notifyErrorHandler } from "../util/notify";
export type RawRoleState = Partial<lib.RawRole> & {
export type RawRoleState = Partial<lib.Role> & {
loading?: boolean;
missing?: boolean;
present?: boolean;
@@ -76,7 +76,7 @@ export default function RoleViewPage() {
description: role["description"],
permissions: {
...Object.fromEntries([...lib.permissions.values()].map(perm => [
perm.name, role.permissions!.includes(perm.name),
perm.name, [...role.permissions!].includes(perm.name),
])),
},
};
+1 -1
View File
@@ -63,7 +63,7 @@ export default function RolesPage() {
let control = useContext(ControlContext);
let navigate = useNavigate();
let [roles, setRoles] = useState<lib.RawRole[]>([]);
let [roles, setRoles] = useState<lib.Role[]>([]);
useEffect(() => {
control.send(new lib.RoleListRequest()).then(newRoles => {
@@ -35,7 +35,7 @@ export default function UserViewPage() {
let control = useContext(ControlContext);
let [instanceList] = useInstanceList();
let [user] = useUser(userName);
let [roles, setRoles] = useState<Map<number, lib.RawRole>>(new Map());
let [roles, setRoles] = useState<Map<number, lib.Role>>(new Map());
let [form] = Form.useForm();
let [rolesDirty, setRolesDirty] = useState<boolean>(false);
let [banReasonDirty, setBanReasonDirty] = useState<boolean>(false);
@@ -78,7 +78,7 @@ export default function UserViewPage() {
let roleSelector = <Form.Item
noStyle
name="roles"
initialValue={user.roles}
initialValue={user.roleIds}
rules={[
{ validator: () => (rolesError ? Promise.reject(rolesError) : Promise.resolve()) },
]}
@@ -181,7 +181,7 @@ export default function UserViewPage() {
<Col flex="auto">
{account.hasPermission("core.user.update_roles")
? roleSelector
: [...user.roles!].map(id => <Tag key={id}>{
: [...user.roleIds!].map(id => <Tag key={id}>{
roles ? (roles.get(id) || { name: id }).name : id
}</Tag>)
}
+5 -5
View File
@@ -82,7 +82,7 @@ export default function UsersPage() {
{
title: "Name",
key: "name",
render: (user: lib.RawUser) => <Space>
render: (user: lib.User) => <Space>
{user.name}
<span>
{user.isAdmin && <Tag color="gold">Admin</Tag>}
@@ -96,14 +96,14 @@ export default function UsersPage() {
{
title: "Roles",
key: "roles",
render: (user: lib.RawUser) => (
user.roles.map(id => <Tag key={id}>{(roles.get(id) || { name: id }).name}</Tag>)
render: (user: lib.User) => (
[...user.roleIds].map(id => <Tag key={id}>{(roles.get(id) || { name: id }).name}</Tag>)
),
},
{
title: "Online time",
key: "onlineTime",
render: (user: lib.RawUser) => (user.playerStats?.onlineTimeMs
render: (user: lib.User) => (user.playerStats?.onlineTimeMs
? formatDuration(user.playerStats.onlineTimeMs) : null),
sorter: (a, b) => (a.playerStats?.onlineTimeMs ?? 0) -
(b.playerStats?.onlineTimeMs ?? 0),
@@ -112,7 +112,7 @@ export default function UsersPage() {
{
title: "Last seen",
key: "lastSeen",
render: (user: lib.RawUser) => formatLastSeen(user),
render: (user: lib.User) => formatLastSeen(user),
sorter: (a, b) => sortLastSeen(a, b),
responsive: ["lg"],
},
+6 -6
View File
@@ -26,7 +26,7 @@ function calculateLastSeen(user: RawUserState, instanceId?: number) {
}
export function formatLastSeen(user: RawUserState, instanceId?: number) {
if (user.instances && user.instances.some(id => instanceId === undefined || id === instanceId)) {
if (user.instances && [...user.instances].some(id => instanceId === undefined || id === instanceId)) {
return <Tag color="green">Online</Tag>;
}
let lastSeen = calculateLastSeen(user, instanceId);
@@ -38,7 +38,7 @@ export function formatLastSeen(user: RawUserState, instanceId?: number) {
export function sortLastSeen(userA:RawUserState, userB:RawUserState, instanceIdA?: number, instanceIdB?: number) {
function epoch(user:RawUserState, instanceId?: number) {
return user.instances && user.instances.some(id => instanceId === undefined || id === instanceId);
return user.instances && [...user.instances].some(id => instanceId === undefined || id === instanceId);
}
let epochA = epoch(userA, instanceIdA);
@@ -52,7 +52,7 @@ export function sortLastSeen(userA:RawUserState, userB:RawUserState, instanceIdA
return lastSeenA - lastSeenB;
}
export type RawUserState = Partial<lib.RawUser> & {
export type RawUserState = Partial<lib.User> & {
loading?: boolean;
present?: boolean;
missing?: boolean;
@@ -78,7 +78,7 @@ export function useUser(name: string): [RawUserState, () => void] {
}
updateUser();
function updateHandler(newUser: lib.RawUser) {
function updateHandler(newUser: lib.User) {
setUser({ ...newUser, present: true });
}
@@ -93,7 +93,7 @@ export function useUser(name: string): [RawUserState, () => void] {
export function useUserList() {
let control = useContext(ControlContext);
let [userList, setUserList] = useState<lib.RawUser[]>([]);
let [userList, setUserList] = useState<lib.User[]>([]);
function updateUserList() {
control.send(new lib.UserListRequest()).then(users => {
@@ -106,7 +106,7 @@ export function useUserList() {
useEffect(() => {
updateUserList();
function updateHandler(newUser: lib.RawUser) {
function updateHandler(newUser: lib.User) {
setUserList(oldList => {
let newList = oldList.concat();
let index = newList.findIndex(u => u.name === newUser.name);
+2 -2
View File
@@ -10,7 +10,7 @@ type instanceHandler = (instanceDetails: lib.InstanceDetails) => void;
type saveListHandler = (saveListEvent: lib.InstanceSaveListUpdateEvent) => void;
type modPackHandler = (modPack: lib.ModPack) => void;
type modInfoHandler = (modInfo: lib.ModInfo) => void;
type userHandler = (rawUser: lib.RawUser) => void;
type userHandler = (rawUser: lib.User) => void;
type logHandler = (info: { level:string, message:string }) => void;
/**
@@ -80,7 +80,7 @@ export class Control extends lib.Link {
lib.ModUpdateEvent, event => event.mod,
);
userUpdate = new lib.EventSubscriber<lib.UserUpdateEvent, lib.RawUser>(
userUpdate = new lib.EventSubscriber<lib.UserUpdateEvent, lib.User>(
lib.UserUpdateEvent, event => event.user,
);
+50
View File
@@ -0,0 +1,50 @@
"use strict";
const assert = require("assert").strict;
const { ControllerUser } = require("@clusterio/controller");
const lib = require("@clusterio/lib");
describe("controller/src/ControllerUser", function() {
describe("class ControllerUser", function() {
class MockUserManager {
roles = new Map([
[1, new lib.Role(1, "a", "a role", new Set(["core.admin"]))],
[2, new lib.Role(2, "b", "b role", new Set(["user-test"]))],
]);
}
const userManager = new MockUserManager();
it("should round trip serialize", function() {
function test_roundtrip(serialized) {
let user = ControllerUser.fromJSON(serialized, userManager);
let user_serialized = user.toJSON(true);
assert.deepEqual(user_serialized, serialized);
let user_deserialized = ControllerUser.fromJSON(user_serialized, userManager);
assert.deepEqual(user_deserialized, user);
}
test_roundtrip({ name: "admin", roles: [1] });
test_roundtrip({ name: "user", roles: [2], token_valid_after: 12345 });
test_roundtrip({ name: "user", is_admin: true, is_whitelisted: true });
test_roundtrip({ name: "user", is_banned: true, ban_reason: "Bad user" });
test_roundtrip({ name: "user", instance_stats: [[1, { join_count: 1 }]]});
});
describe(".checkPermission()", function() {
it("should correctly resolve permissions", function() {
lib.definePermission({ name: "user-test", title: "Test", description: "User Test" });
let a = ControllerUser.fromJSON({ name: "admin", roles: [1] }, userManager);
let b = ControllerUser.fromJSON({ name: "user", roles: [2] }, userManager);
let c = ControllerUser.fromJSON({ name: "null", roles: [] }, userManager);
a.checkPermission("core.control.connect");
assert.throws(() => b.checkPermission("core.control.connect"), new Error("Permission denied"));
assert.throws(() => c.checkPermission("core.control.connect"), new Error("Permission denied"));
a.checkPermission("user-test");
b.checkPermission("user-test");
assert.throws(() => c.checkPermission("user-test"), new Error("Permission denied"));
assert.throws(() => a.checkPermission("invalid"), new Error("permission invalid does not exist"));
});
});
});
});
+35
View File
@@ -0,0 +1,35 @@
"use strict";
const assert = require("assert").strict;
const { ControllerUser, UserManager } = require("@clusterio/controller");
describe("controller/src/UserManager", function() {
describe("class ControllerUser", function() {
const userManager = new UserManager({});
it("should track online users", function() {
let user = ControllerUser.fromJSON({ name: "admin", roles: [1] });
userManager.users.set(user.name, user);
assert(!userManager.onlineUsers.has(user));
assert.deepEqual(user.instances, new Set());
userManager.notifyJoin(user, 12);
assert(userManager.onlineUsers.has(user));
assert.deepEqual(user.instances, new Set([12]));
userManager.notifyJoin(user, 8);
assert(userManager.onlineUsers.has(user));
assert.deepEqual(user.instances, new Set([12, 8]));
userManager.notifyLeave(user, 11);
assert(userManager.onlineUsers.has(user));
assert.deepEqual(user.instances, new Set([12, 8]));
userManager.notifyLeave(user, 12);
assert(userManager.onlineUsers.has(user));
assert.deepEqual(user.instances, new Set([8]));
userManager.notifyLeave(user, 8);
assert(!userManager.onlineUsers.has(user));
assert.deepEqual(user.instances, new Set());
});
});
});
+5 -5
View File
@@ -1034,7 +1034,7 @@ describe("Integration of Clusterio", function() {
let tempRole = roles.find(role => role.name === "temp");
assert.deepEqual(
tempRole,
new lib.RawRole(5, "temp", "A temp role", ["core.control.connect"])
new lib.Role(5, "temp", "A temp role", new Set(["core.control.connect"]))
);
});
});
@@ -1045,21 +1045,21 @@ describe("Integration of Clusterio", function() {
await execCtl(`role edit temp ${args}`);
let roles = await getControl().send(new lib.RoleListRequest());
let newRole = roles.find(role => role.name === "new");
assert.deepEqual(newRole, new lib.RawRole(5, "new", "A new role", []));
assert.deepEqual(newRole, new lib.Role(5, "new", "A new role", new Set()));
});
it("should add permissions with --add-perms", async function() {
let args = "--name new --add-perms core.host.list core.instance.list";
await execCtl(`role edit new ${args}`);
let roles = await getControl().send(new lib.RoleListRequest());
let newRole = roles.find(role => role.name === "new");
assert.deepEqual(newRole.permissions, ["core.host.list", "core.instance.list"]);
assert.deepEqual(newRole.permissions, new Set(["core.host.list", "core.instance.list"]));
});
it("should remove permissions with --remove-perms", async function() {
let args = "--name new --remove-perms core.host.list";
await execCtl(`role edit new ${args}`);
let roles = await getControl().send(new lib.RoleListRequest());
let newRole = roles.find(role => role.name === "new");
assert.deepEqual(newRole.permissions, ["core.instance.list"]);
assert.deepEqual(newRole.permissions, new Set(["core.instance.list"]));
});
it("should grant default permissions with --grant-default", async function() {
await execCtl("role edit new --grant-default");
@@ -1126,7 +1126,7 @@ describe("Integration of Clusterio", function() {
await execCtl('user set-roles temp "Cluster Admin"');
let users = await getControl().send(new lib.UserListRequest());
let tempUser = users.find(user => user.name === "temp");
assert.deepEqual(tempUser.roles, [0]);
assert.deepEqual(tempUser.roleIds, new Set([0]));
assert.equal(getControl().userUpdates.length, 1);
});
+2 -2
View File
@@ -8,7 +8,7 @@ const addr = lib.Address.fromShorthand;
describe("lib/command", function() {
let testRole = lib.RawRole.fromJSON({ id: 28, name: "Test Role", description: "Test", permissions: [] });
let testRole = lib.Role.fromJSON({ id: 28, name: "Test Role", description: "Test", permissions: [] });
let [controlConnector, controllerConnector] = lib.VirtualConnector.makePair(
addr({ controlId: 1}), addr("controller")
@@ -21,7 +21,7 @@ describe("lib/command", function() {
testController.handle(
lib.InstanceDetailsListRequest, () => [new lib.InstanceDetails("Test Instance", 57, 4, undefined, "stopped")]
);
testController.handle(lib.RoleListRequest, () => [testRole]);
testController.handle(lib.RoleListRequest, () => [testRole.toJSON()]);
describe("resolveHost", function() {
it("should pass an integer like string back", async function() {
+28
View File
@@ -0,0 +1,28 @@
"use strict";
const assert = require("assert").strict;
const lib = require("@clusterio/lib");
describe("lib/data/Role", function() {
describe("class Role", function() {
it("should round trip serialize", function() {
let orig = lib.Role.fromJSON({ id: 11, name: "Role", description: "My Role", permissions: ["test"] });
let copy = lib.Role.fromJSON(orig.toJSON());
assert.deepEqual(copy, orig);
});
describe(".grantDefaultRoles()", function() {
it("should only grant permissions with grantByDefault", function() {
let role = lib.Role.fromJSON({ id: 11, name: "Role", description: "My Role" });
role.grantDefaultPermissions();
assert(role.permissions.size > 0, "No permissions were granted");
for (let permission of role.permissions) {
assert(
lib.permissions.get(permission).grantByDefault === true,
"Non-default permission granted"
);
}
});
});
});
});
+67
View File
@@ -0,0 +1,67 @@
"use strict";
const assert = require("assert").strict;
const lib = require("@clusterio/lib");
describe("lib/data/User", function() {
describe("class User", function() {
it("should round trip serialize", function() {
function test_roundtrip(serialized) {
let user = lib.User.fromJSON(serialized);
let user_serialized = user.toJSON();
assert.deepEqual(user_serialized, serialized);
let user_deserialized = lib.User.fromJSON(user_serialized);
assert.deepEqual(user_deserialized, user);
}
test_roundtrip({ name: "admin", roles: [1] });
test_roundtrip({ name: "user", is_admin: true, is_whitelisted: true });
test_roundtrip({ name: "user", is_banned: true, ban_reason: "Bad user" });
test_roundtrip({ name: "user", instance_stats: [[1, { join_count: 1 }]]});
});
it("should keep invalid roles", function() {
let user = lib.User.fromJSON({ name: "test", roles: [1, 4, 55] });
assert.equal(user.roleIds.size, 3, "Unexpected count of roles");
});
it("should calculate playerStats", function() {
let user = lib.User.fromJSON({ name: "test", roles: [1], instance_stats: [
[1, {
join_count: 1,
online_time_ms: 60e3,
last_join_at: new Date("2020-05T12:00Z").getTime(),
last_leave_at: new Date("2020-05T12:01Z").getTime(),
last_leave_reason: "quit",
}],
[2, {
join_count: 1,
online_time_ms: 0,
last_join_at: new Date("2020-05T12:02Z").getTime(),
}],
]});
assert.equal(user.playerStats.onlineTimeMs, 60e3);
assert.equal(user.playerStats.joinCount, 2);
assert.deepEqual(user.playerStats.lastJoinAt, new Date("2020-05T12:02Z"));
assert.deepEqual(user.playerStats.lastLeaveAt, new Date("2020-05T12:01Z"));
assert.equal(user.playerStats.lastLeaveReason, "quit");
user.instanceStats.set(
3,
lib.PlayerStats.fromJSON({
join_count: 2,
online_time_ms: 120e3,
last_join_at: new Date("2020-05T13:00Z").getTime(),
last_leave_at: new Date("2020-05T13:02Z").getTime(),
last_leave_reason: "afk",
}),
);
user.recalculatePlayerStats();
assert.equal(user.playerStats.onlineTimeMs, 180e3);
assert.equal(user.playerStats.joinCount, 4);
assert.deepEqual(user.playerStats.lastJoinAt, new Date("2020-05T13:00Z"));
assert.deepEqual(user.playerStats.lastLeaveAt, new Date("2020-05T13:02Z"));
assert.equal(user.playerStats.lastLeaveReason, "afk");
});
});
});
-120
View File
@@ -44,124 +44,4 @@ describe("lib/users", function() {
);
});
});
describe("class Role", function() {
it("should round trip serialize", function() {
let orig = new lib.Role({ id: 11, name: "Role", description: "My Role", permissions: ["test"] });
let copy = new lib.Role(orig.serialize());
assert.deepEqual(copy, orig);
});
describe(".grantDefaultRoles()", function() {
it("should only grant permissions with grantByDefault", function() {
let role = new lib.Role({ id: 11, name: "Role", description: "My Role" });
role.grantDefaultPermissions();
assert(role.permissions.size > 0, "No permissions were granted");
for (let permission of role.permissions) {
assert(
lib.permissions.get(permission).grantByDefault === true,
"Non-default permission granted"
);
}
});
});
});
describe("class User", function() {
let roles = new Map([
[1, new lib.Role({ id: 1, name: "a", description: "a role", permissions: ["core.admin"] })],
[2, new lib.Role({ id: 2, name: "b", description: "b role", permissions: ["test"] })],
]);
it("should round trip serialize", function() {
function test_roundtrip(serialized) {
let user = new lib.User(serialized, roles);
let user_serialized = user.serialize();
assert.deepEqual(user_serialized, serialized);
let user_deserialized = new lib.User(user_serialized, roles);
assert.deepEqual(user_deserialized, user);
}
test_roundtrip({ name: "admin", roles: [1] });
test_roundtrip({ name: "user", roles: [2], token_valid_after: 12345 });
test_roundtrip({ name: "user", is_admin: true, is_whitelisted: true });
test_roundtrip({ name: "user", is_banned: true, ban_reason: "Bad user" });
test_roundtrip({ name: "user", instance_stats: [[1, { join_count: 1 }]]});
});
it("should ignore invalid roles", function() {
let user = new lib.User({ name: "test", roles: [1, 4, 55] }, roles);
assert.equal(user.roles.size, 1, "Unexpected count of roles");
});
it("should track online users", function() {
let user = new lib.User({ name: "admin", roles: [1] }, roles);
assert(!lib.User.onlineUsers.has(user));
assert.deepEqual(user.instances, new Set());
user.notifyJoin(12);
assert(lib.User.onlineUsers.has(user));
assert.deepEqual(user.instances, new Set([12]));
user.notifyJoin(8);
assert(lib.User.onlineUsers.has(user));
assert.deepEqual(user.instances, new Set([12, 8]));
user.notifyLeave(11);
assert(lib.User.onlineUsers.has(user));
assert.deepEqual(user.instances, new Set([12, 8]));
user.notifyLeave(12);
assert(lib.User.onlineUsers.has(user));
assert.deepEqual(user.instances, new Set([8]));
user.notifyLeave(8);
assert(!lib.User.onlineUsers.has(user));
assert.deepEqual(user.instances, new Set());
});
it("should calculate playerStats", function() {
let user = new lib.User({ name: "test", roles: [1], instance_stats: [
[1, {
join_count: 1,
online_time_ms: 60e3,
last_join_at: new Date("2020-05T12:00Z").getTime(),
last_leave_at: new Date("2020-05T12:01Z").getTime(),
last_leave_reason: "quit",
}],
[2, {
join_count: 1,
online_time_ms: 0,
last_join_at: new Date("2020-05T12:02Z").getTime(),
}],
[3, {
join_count: 2,
online_time_ms: 120e3,
last_join_at: new Date("2020-05T11:00Z").getTime(),
last_leave_at: new Date("2020-05T11:02Z").getTime(),
last_leave_reason: "afk",
}],
]}, roles);
assert.equal(user.playerStats.onlineTimeMs, 180e3);
assert.equal(user.playerStats.joinCount, 4);
assert.deepEqual(user.playerStats.lastJoinAt, new Date("2020-05T12:02Z"));
assert.deepEqual(user.playerStats.lastLeaveAt, new Date("2020-05T12:01Z"));
assert.equal(user.playerStats.lastLeaveReason, "quit");
});
describe(".checkPermission()", function() {
it("should correctly resolve permissions", function() {
let a = new lib.User({ name: "admin", roles: [1] }, roles);
let b = new lib.User({ name: "user", roles: [2] }, roles);
let c = new lib.User({ name: "null", roles: [] }, roles);
a.checkPermission("core.control.connect");
assert.throws(() => b.checkPermission("core.control.connect"), new Error("Permission denied"));
assert.throws(() => c.checkPermission("core.control.connect"), new Error("Permission denied"));
a.checkPermission("test");
b.checkPermission("test");
assert.throws(() => c.checkPermission("test"), new Error("Permission denied"));
assert.throws(() => a.checkPermission("invalid"), new Error("permission invalid does not exist"));
});
});
});
});
+5 -4
View File
@@ -6,6 +6,7 @@ const express = require("express");
const lib = require("@clusterio/lib");
const UserManager = require("@clusterio/controller/dist/src/UserManager").default;
const ControllerUser = require("@clusterio/controller/dist/src/ControllerUser").default;
const addr = lib.Address.fromShorthand;
@@ -167,14 +168,14 @@ class MockController {
this.userManager = new UserManager(this.config);
this.userManager.roles = new Map([
[0, new lib.Role({ id: 0, name: "Admin", description: "admin", permissions: ["core.admin"] })],
[1, new lib.Role({ id: 1, name: "Player", description: "player", permissions: [] })],
[0, lib.Role.fromJSON({ id: 0, name: "Admin", description: "admin", permissions: ["core.admin"] })],
[1, lib.Role.fromJSON({ id: 1, name: "Player", description: "player", permissions: [] })],
]);
this.userManager.roles.get(1).grantDefaultPermissions();
this.userManager.users = new Map([
["test", new lib.User({ name: "test", roles: [0, 1] }, this.userManager.roles)],
["player", new lib.User({ name: "player", roles: [1] }, this.userManager.roles)],
["test", ControllerUser.fromJSON({ name: "test", roles: [0, 1] }, this.userManager)],
["player", ControllerUser.fromJSON({ name: "player", roles: [1] }, this.userManager)],
]);
this.instances = new Map();
this.hosts = new Map();