Define permissions on PluginDeclaration (#988)

Adds a permissions array to PluginDeclaration, a type-level Permissions
registry that plugins extend with module augmentation, and
registerPluginPermissions() called from all entrypoints. checkPermission
and the web UI hasPermission helpers now take PermissionName so typos are
compile errors. Core definitions move into a corePermissions array that
the interface derives its keys from.

Closes #987

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-09-11 23:28:30 +01:00
1 parent 2aee5379b5
commit 8c03ac15ab
23 files changed
+758 -575

No files matched your search

+44
View File
@@ -10,6 +10,7 @@ The plugin classes have pre-defined hooks that are called during various stages
- [Defining the plugin class](#defining-the-plugin-class)
- [Logging Messages](#logging-messages)
- [Plugin Configuration](#plugin-configuration)
- [Plugin Permissions](#plugin-permissions)
- [Communicating with Factorio](#communicating-with-factorio)
- [Defining Link Messages](#defining-link-messages)
- [Sending Link Messages](#sending-link-messages)
@@ -101,6 +102,10 @@ The following properties are recognized:
Object with link messages definitions for this plugin.
See guide for [defining link messages](#defining-link-messages) below.
**permissions**:
Array of permission definitions for this plugin.
See [Plugin Permissions](#plugin-permissions)
The optional module folder contains a Clusterio module that will be patched into the save when the plugin is loaded.
See the section on [Clusterio Modules](developing-for-clusterio.md) in the Developing for Clusterio document.
The only restriction imposed on modules embedded into plugins is that they must be named the same as the plugin.
@@ -249,6 +254,45 @@ async init() {
```
## Plugin Permissions
Permissions are granted to roles and checked on users, both on the controller and in the web UI.
A plugin declares the permissions it uses under `permissions` in the `plugin` export.
Names must start with the plugin name followed by a dot.
For example in index.ts:
```ts
import type * as lib from "@clusterio/lib";
declare module "@clusterio/lib" {
// Extend the interface of known permission names so that
// user.checkPermission(...) accepts our permission.
export interface Permissions {
"foo_frobber.frobnicate": never;
}
}
export default {
...
permissions: [
{
name: "foo_frobber.frobnicate",
title: "Frobnicate",
description: "Run frobnication on instances.",
grantByDefault: false, // Whether the generated Player role is granted this permission
},
],
} satisfies lib.PluginDeclaration;
```
The `Permissions` interface only exists at the type level, its values are always `never` and only the keys are used.
Adding your permission names to it gives you autocomplete and a compile error on typos when calling `user.checkPermission`, `account.hasPermission` or setting the `permission` of a web UI page.
In JavaScript the `declare module` block is not needed.
Permissions declared this way are registered when the plugin is loaded.
The older `lib.definePermission()` function still works, but a plugin should not use both for the same permission name.
## Communicating with Factorio
For pushing data into Factorio there's RCON, which lets you send arbitrary Lua commands to invoke whatever code you want in the game.
+1
View File
@@ -402,6 +402,7 @@ async function initialize(): Promise<InitializeParameters> {
logger.info("Loading Plugin info");
const pluginInfos = await lib.loadPluginInfos(pluginList);
lib.registerPluginMessages(pluginInfos);
lib.registerPluginPermissions(pluginInfos);
lib.addPluginConfigFields(pluginInfos);
const controllerConfigPath = args.config;
+1 -1
View File
@@ -169,7 +169,7 @@ export default class ControlConnection extends BaseConnection {
}
if (typeof permission === "string") {
this.user.checkPermission(permission);
this.user.checkPermission(permission as lib.PermissionName);
return;
}
+2 -2
View File
@@ -1,7 +1,7 @@
import {
IUser, Role, SubscribableDatastore,
PermissionError, permissions,
PermissionError, PermissionName, permissions,
} from "@clusterio/lib";
import UserRecord from "./UserRecord.js";
@@ -126,7 +126,7 @@ export default class User extends UserRecord implements IUser {
this.saveRecord();
}
checkPermission(permission: string) {
checkPermission(permission: PermissionName) {
if (!permissions.has(permission)) {
throw new Error(`permission ${permission} does not exist`);
}
+37 -32
View File
@@ -1,38 +1,6 @@
import * as lib from "@clusterio/lib";
//%if multi_context
import Messages from "./messages.js";
lib.definePermission({
name: "__plugin_name__.example.permission.event",
title: "Example permission event",
description: "Example Description. Event. Change me in index.js",
grantByDefault: true,
});
lib.definePermission({
name: "__plugin_name__.example.permission.request",
title: "Example permission request",
description: "Example Description. Request. Change me in index.js",
grantByDefault: true,
});
//%endif
//%if controller & web // Subscribing requires web content and the controller
lib.definePermission({
name: "__plugin_name__.example.permission.subscribe",
title: "Example permission subscribe",
description: "Example Description. Subscribe. Change me in index.js",
grantByDefault: true,
});
//%endif
//%if web
lib.definePermission({
name: "__plugin_name__.page.view",
title: "Example page view permission",
description: "Example Description. View. Change me in index.js",
grantByDefault: true,
});
//%endif
export const plugin = {
@@ -115,6 +83,43 @@ export const plugin = {
//%if multi_context // Subscribing requires multi context
],
//%endif
//%if multi_context | web
permissions: [
//%endif
//%if multi_context
{
name: "__plugin_name__.example.permission.event",
title: "Example permission event",
description: "Example Description. Event. Change me in index.js",
grantByDefault: true,
},
{
name: "__plugin_name__.example.permission.request",
title: "Example permission request",
description: "Example Description. Request. Change me in index.js",
grantByDefault: true,
},
//%endif
//%if controller & web // Subscribing requires web content and the controller
{
name: "__plugin_name__.example.permission.subscribe",
title: "Example permission subscribe",
description: "Example Description. Subscribe. Change me in index.js",
grantByDefault: true,
},
//%endif
//%if web
{
name: "__plugin_name__.page.view",
title: "Example page view permission",
description: "Example Description. View. Change me in index.js",
grantByDefault: true,
},
//%endif
//%if multi_context | web
],
//%endif
//%if web // Web content template has an example route which is the plugin name
webEntrypoint: "./web",
@@ -57,7 +57,7 @@ export class WebPlugin extends BaseWebPlugin {
permission: "__plugin_name__.example.permission.subscribe",
//%endif
//%if !controller
permission: "__plugin_name__.example.permission.view",
permission: "__plugin_name__.page.view",
//%endif
content: <MyTemplatePage/>,
},
+55 -34
View File
@@ -1,40 +1,8 @@
import * as lib from "@clusterio/lib";
//%if multi_context
import * as Messages from "./messages.js";
lib.definePermission({
name: "__plugin_name__.example.permission.event",
title: "Example permission event",
description: "Example Description. Event. Change me in index.ts",
grantByDefault: true,
});
lib.definePermission({
name: "__plugin_name__.example.permission.request",
title: "Example permission request",
description: "Example Description. Request. Change me in index.ts",
grantByDefault: true,
});
//%endif
//%if controller & web // Subscribing requires web content and the controller
lib.definePermission({
name: "__plugin_name__.example.permission.subscribe",
title: "Example permission subscribe",
description: "Example Description. Subscribe. Change me in index.ts",
grantByDefault: true,
});
//%endif
//%if web
lib.definePermission({
name: "__plugin_name__.page.view",
title: "Example page view permission",
description: "Example Description. View. Change me in index.ts",
grantByDefault: true,
});
//%endif
//%if config
//%if config | multi_context | web
declare module "@clusterio/lib" {
//%endif
@@ -58,7 +26,23 @@ declare module "@clusterio/lib" {
"__plugin_name__.myControlField": string;
}
//%endif
//%if config
//%if multi_context | web
export interface Permissions {
//%endif
//%if multi_context
"__plugin_name__.example.permission.event": never;
"__plugin_name__.example.permission.request": never;
//%endif
//%if controller & web // Subscribing requires web content and the controller
"__plugin_name__.example.permission.subscribe": never;
//%endif
//%if web
"__plugin_name__.page.view": never;
//%endif
//%if multi_context | web
}
//%endif
//%if config | multi_context | web
}
//%endif
@@ -143,6 +127,43 @@ export const plugin: lib.PluginDeclaration = {
//%if multi_context // Subscribing requires multi context
],
//%endif
//%if multi_context | web
permissions: [
//%endif
//%if multi_context
{
name: "__plugin_name__.example.permission.event",
title: "Example permission event",
description: "Example Description. Event. Change me in index.ts",
grantByDefault: true,
},
{
name: "__plugin_name__.example.permission.request",
title: "Example permission request",
description: "Example Description. Request. Change me in index.ts",
grantByDefault: true,
},
//%endif
//%if controller & web // Subscribing requires web content and the controller
{
name: "__plugin_name__.example.permission.subscribe",
title: "Example permission subscribe",
description: "Example Description. Subscribe. Change me in index.ts",
grantByDefault: true,
},
//%endif
//%if web
{
name: "__plugin_name__.page.view",
title: "Example page view permission",
description: "Example Description. View. Change me in index.ts",
grantByDefault: true,
},
//%endif
//%if multi_context | web
],
//%endif
//%if web // Web content template has an example route which is the plugin name
webEntrypoint: "./web",
@@ -9,5 +9,5 @@
"noEmitOnError": true,
"outDir": "${configDir}/dist/browser",
},
"include": [ "web/**/*.tsx", "web/**/*.ts", "messages.ts", "package.json" ],
"include": [ "web/**/*.tsx", "web/**/*.ts", "index.ts", "messages.ts", "package.json" ],
}
@@ -57,7 +57,7 @@ export class WebPlugin extends BaseWebPlugin {
permission: "__plugin_name__.example.permission.subscribe",
//%endif
//%if !controller
permission: "__plugin_name__.example.permission.view",
permission: "__plugin_name__.page.view",
//%endif
content: <MyTemplatePage/>,
},
+1
View File
@@ -108,6 +108,7 @@ export class Control extends lib.Link {
async function loadPlugins(pluginList: Map<string, string>) {
let pluginInfos = await lib.loadPluginInfos(pluginList);
lib.registerPluginMessages(pluginInfos);
lib.registerPluginPermissions(pluginInfos);
lib.addPluginConfigFields(pluginInfos);
let ctlPlugins = new Map<string, BaseCtlPlugin>();
+1
View File
@@ -163,6 +163,7 @@ async function startHost() {
logger.info("Loading Plugin info");
let pluginInfos = await lib.loadPluginInfos(pluginList);
lib.registerPluginMessages(pluginInfos);
lib.registerPluginPermissions(pluginInfos);
lib.addPluginConfigFields(pluginInfos);
const hostConfigPath = args.config;
+2 -2
View File
@@ -1,5 +1,5 @@
import { Static, Type } from "@sinclair/typebox";
import type { PermissionName } from "../permissions.js";
import PlayerStats from "./PlayerStats.js";
/** Represents a user in the cluster */
@@ -164,5 +164,5 @@ export interface IUser extends UserDetails {
* @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
checkPermission(permission: PermissionName): void
}
File diff suppressed because it is too large. Load diff
+2 -1
View File
@@ -5,9 +5,9 @@
import * as libHelpers from "./helpers.js";
import type { Logger } from "./logging.js";
import type { FieldDefinition } from "./config/index.js";
import type { PermissionDefinition } from "./permissions.js";
import type { PlayerStats } from "./data/index.js";
export const PluginFeatureFlags = [
/** The plugin requires module code to be patched into the save */
"SavePatching",
@@ -37,6 +37,7 @@ export type PluginDeclaration = {
features?: (typeof PluginFeatureFlags)[number][];
messages?: any[];
permissions?: PermissionDefinition[];
routes?: string[];
}
+2 -1
View File
@@ -2,6 +2,7 @@ import { Type, Static } from "@sinclair/typebox";
import { Link, Event, EventClass, RequestHandler, WebSocketBaseConnector } from "./link/index.js";
import { Address, MessageRequest, IUser, JsonBoolean, StringEnum } from "./data/index.js";
import isDeepStrictEqual from "#is_deep_strict_equal";
import type { PermissionName } from "./permissions.js";
import { RequestError } from "./errors.js";
import { logger } from "./logging.js";
@@ -155,7 +156,7 @@ export class SubscriptionRequest {
const entry = Link._eventsByName.get(data[0]);
if (entry && entry.Event.permission) {
if (typeof entry.Event.permission === "string") {
user.checkPermission(entry.Event.permission);
user.checkPermission(entry.Event.permission as PermissionName);
} else {
entry.Event.permission(user, message);
}
+5 -5
View File
@@ -1,5 +1,5 @@
import type React from "react";
import type { AccountRole, FieldDefinition, Logger, PluginWebpackEnvInfo } from "@clusterio/lib";
import type { AccountRole, FieldDefinition, Logger, PermissionName, PluginWebpackEnvInfo } from "@clusterio/lib";
import type { Control } from "./util/websocket";
/**
@@ -37,11 +37,11 @@ export type UserAccount = {
/** Roles of the corrently logged in account. */
roles: AccountRole[];
/** Check if the currently logged in account has the given permission. */
hasPermission: (permission: string) => boolean | null;
hasPermission: (permission: PermissionName) => boolean | null;
/** Check if the currently logged in account has any of the given permissions. */
hasAnyPermission: (...permissions: string[]) => boolean | null;
hasAnyPermission: (...permissions: PermissionName[]) => boolean | null;
/** Check if the currently logged in account has all of given permissions. */
hasAllPermission: (...permissions: string[]) => boolean | null;
hasAllPermission: (...permissions: PermissionName[]) => boolean | null;
/** Logs out of the web interface. */
logOut: () => void;
};
@@ -75,7 +75,7 @@ export interface PluginPage {
/**
* Permission to access page. function are expected to throw an error if access is deny.
*/
permission?: string | ((account: UserAccount) => (boolean|null));
permission?: PermissionName | ((account: UserAccount) => (boolean|null));
};
/**
+1
View File
@@ -136,6 +136,7 @@ export default async function bootstrap() {
}));
let [pluginInfos, pluginSetKey] = await loadPluginInfos();
lib.registerPluginMessages(pluginInfos);
lib.registerPluginPermissions(pluginInfos);
lib.addPluginConfigFields(pluginInfos);
let wsUrl = new URL(webRoot, document.location.href);
+5 -5
View File
@@ -22,13 +22,13 @@ export function useAccount(): UserAccount {
};
}, [control]);
function checkPermissionExists(permission: string) {
function checkPermissionExists(permission: lib.PermissionName) {
if (!lib.permissions.has(permission)) {
throw new Error(`permission ${permission} does not exist`);
}
}
function hasPermission(permission: string): boolean {
function hasPermission(permission: lib.PermissionName): boolean {
for (let role of roles) {
if (role.permissions.includes("core.admin") || role.permissions.includes(permission)) {
return true;
@@ -40,14 +40,14 @@ export function useAccount(): UserAccount {
return {
name,
roles,
hasPermission(permission: string): boolean | null {
hasPermission(permission: lib.PermissionName): boolean | null {
if (!roles) {
return null;
}
checkPermissionExists(permission);
return hasPermission(permission);
},
hasAnyPermission(...permissions: string[]): boolean | null {
hasAnyPermission(...permissions: lib.PermissionName[]): boolean | null {
if (!roles) {
return null;
}
@@ -61,7 +61,7 @@ export function useAccount(): UserAccount {
}
return false;
},
hasAllPermission(...permissions: string[]): boolean | null {
hasAllPermission(...permissions: lib.PermissionName[]): boolean | null {
if (!roles) {
return null;
}
+11 -7
View File
@@ -8,15 +8,11 @@ declare module "@clusterio/lib" {
export interface ControllerConfigFields {
"inventory_sync.player_lock_timeout": number;
}
export interface Permissions {
"inventory_sync.inventory.view": never;
}
}
lib.definePermission({
name: "inventory_sync.inventory.view",
title: "View player inventories",
description: "View player inventories",
grantByDefault: true,
});
export const plugin: lib.PluginDeclaration = {
name: "inventory_sync",
title: "Inventory sync",
@@ -57,6 +53,14 @@ export const plugin: lib.PluginDeclaration = {
messages.DownloadRequest,
messages.DatabaseStatsRequest,
],
permissions: [
{
name: "inventory_sync.inventory.view",
title: "View player inventories",
description: "View player inventories",
grantByDefault: true,
},
],
webEntrypoint: "./web",
routes: ["/inventory"],
};
+1 -1
View File
@@ -4,5 +4,5 @@
{ "path": "../../packages/lib/tsconfig.browser.json" },
{ "path": "../../packages/web_ui/tsconfig.browser.json" },
],
"include": [ "web/**/*.tsx", "web/**/*.ts", "messages.ts", "package.json" ],
"include": [ "web/**/*.tsx", "web/**/*.ts", "index.ts", "messages.ts", "package.json" ],
}
+11 -7
View File
@@ -9,15 +9,11 @@ declare module "@clusterio/lib" {
export interface InstanceConfigFields {
"subspace_storage.log_item_transfers": boolean;
}
export interface Permissions {
"subspace_storage.storage.view": never;
}
}
lib.definePermission({
name: "subspace_storage.storage.view",
title: "View Subspace Storage",
description: "View the items and fluids stored in the shared subspace.",
grantByDefault: true,
});
export const plugin: lib.PluginDeclaration = {
name: "subspace_storage",
title: "Subspace Storage",
@@ -60,6 +56,14 @@ export const plugin: lib.PluginDeclaration = {
messages.UpdateStorageEvent,
messages.SetStorageSubscriptionRequest,
],
permissions: [
{
name: "subspace_storage.storage.view",
title: "View Subspace Storage",
description: "View the items and fluids stored in the shared subspace.",
grantByDefault: true,
},
],
webEntrypoint: "./web",
routes: ["/storage"],
};
@@ -4,5 +4,5 @@
{ "path": "../../packages/lib/tsconfig.browser.json" },
{ "path": "../../packages/web_ui/tsconfig.browser.json" },
],
"include": [ "web/**/*.tsx", "web/**/*.ts", "messages.ts", "package.json" ],
"include": [ "web/**/*.tsx", "web/**/*.ts", "index.ts", "messages.ts", "package.json" ],
}
+41
View File
@@ -43,4 +43,45 @@ describe("lib/users", function() {
);
});
});
describe("registerPluginPermissions()", function() {
it("should define the permissions declared by plugins", function() {
lib.registerPluginPermissions([
{ name: "foo", permissions: [
{ name: "foo.first", title: "First", description: "The first" },
{ name: "foo.second", title: "Second", description: "The second", grantByDefault: true },
]},
{ name: "bar" },
{ name: "baz", permissions: [] },
]);
assert(lib.permissions.has("foo.first"), "foo.first was not defined");
assert.equal(lib.permissions.get("foo.first").grantByDefault, false);
assert(lib.permissions.has("foo.second"), "foo.second was not defined");
assert.equal(lib.permissions.get("foo.second").grantByDefault, true);
});
it("should throw if the name does not start with the plugin name", function() {
assert.throws(
() => lib.registerPluginPermissions([
{ name: "foo", permissions: [{ name: "bar.spam", title: "Spam", description: "Spam" }] },
]),
new Error("Expected name of permission 'bar.spam' for foo to start with 'foo.'")
);
assert.throws(
() => lib.registerPluginPermissions([
{ name: "foo", permissions: [{ title: "Spam", description: "Spam" }] },
]),
new Error("Expected name of permission 'undefined' for foo to start with 'foo.'")
);
});
it("should throw on already defined permission", function() {
assert.throws(
() => lib.registerPluginPermissions([
{ name: "foo", permissions: [{ name: "foo.first", title: "First", description: "The first" }] },
]),
new Error("Permission 'foo.first' is already defined")
);
});
});
});