Files
Module-Rust/server/router/admin/permissions.router.js
wtclaude e0d13e73db
All checks were successful
PR Checks / client-build (pull_request) Successful in 21s
PR Checks / frozen-manifest (pull_request) Successful in 43s
PR Checks / server-tests (pull_request) Successful in 7m58s
feat(rust): the permission manager — the site owns the whole store (D160-D163, D188-D198)
PLAN_REDESIGNS section 1.

- Every sync reads the store (perm.inventory), reconciles it against the
  site's record and its ledger, and pushes. A change made in the game is
  settled by the server's policy (D161): auto-adopt (default), adopt, or
  revoke. The first read of a server imports everything (D198).
- Groups belong to one server unless an admin shares them (D189), in new
  id-keyed tables; the old ones are copied once at boot and left unread.
  Holders may be a Steam account nobody linked (D188).
- An in-game change affects that server only (D190): a grant that reaches
  further gains an exception, a shared group is split.
- Never judged: a permission the server does not register right now (an
  unloaded plugin is not a revocation), and a pair an event lease holds.
- A new admin API (server view, grant/revoke with everywhere-or-here,
  groups by id, share/split, members, drift answers) and a screen on
  PermissionsManager's flow with a state on every toggle (D162, D163, U-1).
- The announcement voice names a group by id; old name settings still read.

Walked on both rigs against the walk core: import on an existing install,
auto-adopt of a grant and a revoke, a fleet grant's exception, Kits
unloaded without loss, a shared group split, adopt and revoke policies.
Server 420/420, client 58/58, swagger, imports and route manifest current.

Refs #21

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-28 06:58:59 -05:00

385 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── Admin · Rust · Permissions ────────────────────────────────────────────
//
// Mounted under the admin tier's `/rust` prefix, so every path here is
// `/api/v1/admin/rust/permissions…`. The permission manager (PLAN_REDESIGNS §1):
// the site owns every permission and group on every server (D160), a server at a
// time on the screen (D162), with each server's policy for a change made in the
// game (D161).
//
// **Every route is `requireRole('admin')`.** The admin tier's own gate admits
// editors and moderators, and a moderator being able to grant themselves
// `kits.admin` on six servers is the whole of R1's "a weak link is now a
// privilege-escalation path" arriving through the front door instead.
const core = require('../../core')
const express = core.express
const permissions = require('./permissions.controller')
const { requireRole, validate } = core.middleware
const { body, param, query } = core.validator
const permissionsRouter = express.Router()
/** A permission or group name, as both mod frameworks store them. */
const NAME = /^[a-z0-9][a-z0-9._-]{0,127}$/i
/** A Steam id: seventeen digits in practice, digits always. */
const STEAM = /^\d{5,20}$/
const serverParam = param('serverId').isString().isLength({ min: 1, max: 64 })
const groupParam = param('id').isInt({ min: 1 }).toInt()
/** A subject: a Steam id, a website account id, or a username. */
const subject = [
body('steamId').optional().isString().matches(STEAM),
body('userId').optional().isInt({ min: 1 }).toInt(),
body('username').optional().isString().trim().isLength({ min: 1, max: 64 }),
]
/** "Here only" on a shared group: split this server's copy off first (D190). */
const hereOnly = [
body('onlyHere').optional().isBoolean().toBoolean(),
body('serverId').optional().isString().isLength({ min: 1, max: 64 }),
]
permissionsRouter.get(
'/',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'The permission manager: servers, policies and what waits for a person'
// #swagger.description = 'Every configured server with its policy for a change made in the game (`auto-adopt`, `adopt`, `revoke`; D161) and its sync state, and every row waiting for a person: each change under `adopt`, an event’s grant removed in the game, a hand-edited style field, and notices of a shared group split off for one server (D190). `chatFields` lists the twelve BetterChat fields for the style editor.'
/* #swagger.responses[200] = { description: 'The overview', content: { "application/json": { schema: { $ref: "#/components/schemas/RustPermissionOverview" } } } } */
requireRole('admin'),
permissions.overview,
)
permissionsRouter.get(
'/catalogue',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Permission names the servers have registered'
// #swagger.description = 'Every permission name the loaded plugins on each server registered, from the last inventory, with the plugin that registered it (`owner`) — null for a name no plugin owns, which on Carbon is its built-in modules’.'
/* #swagger.responses[200] = { description: 'Every registered name, which servers know it, and who registered it', content: { "application/json": { schema: { $ref: "#/components/schemas/RustPermissionCatalogue" } } } } */
requireRole('admin'),
permissions.catalogue,
)
permissionsRouter.get(
'/servers/:serverId',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'One server’s permissions, as the screen shows them'
// #swagger.description = 'Plugins grouped by the plugin that registered each permission, never by prefix; the groups on the server and where else each one is (D189); every player holding anything there, named by linked account and in-game name, or Steam id (D163); what the site wants and why, what has landed, and what the last report said did not — the facts every toggle’s state is read from (U-1).'
/* #swagger.responses[200] = { description: 'The server view', content: { "application/json": { schema: { $ref: "#/components/schemas/RustPermissionServer" } } } } */
/* #swagger.responses[404] = { description: 'No such server' } */
requireRole('admin'),
serverParam,
validate,
permissions.server,
)
permissionsRouter.get(
'/servers/:serverId/players',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Find a player seen on a server'
// #swagger.description = 'By in-game name, Steam id or linked account name, among the players this server has seen — to grant to somebody who holds nothing yet. At most 25, newest first.'
/* #swagger.parameters['q'] = { in: 'query', description: 'Part of a name, Steam id or account name', type: 'string' } */
/* #swagger.responses[200] = { description: 'Matching players' } */
/* #swagger.responses[404] = { description: 'No such server' } */
requireRole('admin'),
serverParam,
query('q').optional().isString().isLength({ max: 64 }),
validate,
permissions.players,
)
permissionsRouter.put(
'/servers/:serverId/policy',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Set what a change made in the game becomes'
// #swagger.description = '`auto-adopt` (the default) makes it the site’s own for that server; `adopt` puts each one to a person; `revoke` undoes it (D161).'
/* #swagger.responses[204] = { description: 'Saved' } */
/* #swagger.responses[400] = { description: 'Not a policy' } */
/* #swagger.responses[404] = { description: 'No such server' } */
requireRole('admin'),
serverParam,
body('policy').isString().isIn(['auto-adopt', 'adopt', 'revoke']),
validate,
permissions.setPolicy,
)
permissionsRouter.post(
'/servers/:serverId/grant',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Grant permissions to one player (a toggle, or Grant all)'
// #swagger.description = 'On this server, or with `everywhere` on every server. A linked Steam id is granted as its website account and reaches every account the person links (D28); an unlinked one as itself (D188). A grant kept off this server by an exception has the exception removed instead.'
/* #swagger.responses[200] = { description: 'How many were granted, and how many exceptions removed' } */
/* #swagger.responses[400] = { description: 'No subject named' } */
/* #swagger.responses[404] = { description: 'No such server' } */
requireRole('admin'),
serverParam,
...subject,
body('permissions').isArray({ min: 1, max: 500 }),
body('permissions.*').isString().matches(NAME),
body('everywhere').optional().isBoolean().toBoolean(),
validate,
permissions.grant,
)
permissionsRouter.post(
'/servers/:serverId/revoke',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Revoke permissions from one player (a toggle, or Revoke all)'
// #swagger.description = 'Every direct grant that puts the permission on this server stops doing so: one scoped to this server is deleted; one that reaches further is deleted with `everywhere`, and otherwise gains an exception for this server (D190). What the player holds through a group or from an event is reported back in `untouched`, not changed.'
/* #swagger.responses[200] = { description: 'How many grants changed, and what could not be' } */
/* #swagger.responses[400] = { description: 'No subject named' } */
/* #swagger.responses[404] = { description: 'No such server' } */
requireRole('admin'),
serverParam,
...subject,
body('permissions').isArray({ min: 1, max: 500 }),
body('permissions.*').isString().matches(NAME),
body('everywhere').optional().isBoolean().toBoolean(),
validate,
permissions.revoke,
)
permissionsRouter.post(
'/servers/:serverId/groups',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Create a group on one server'
// #swagger.description = 'A group belongs to one server unless an admin shares it (D189). A server cannot have two groups of one name.'
/* #swagger.responses[201] = { description: 'Created; the body carries its id' } */
/* #swagger.responses[404] = { description: 'No such server' } */
/* #swagger.responses[409] = { description: 'This server already has a group of that name' } */
requireRole('admin'),
serverParam,
body('name').isString().matches(NAME).withMessage('a group name is letters, digits, dots, dashes and underscores'),
body('title').optional().isString().isLength({ max: 120 }),
body('rank').optional().isInt({ min: -1000, max: 1000 }).toInt(),
body('parent').optional().isString().isLength({ max: 64 }),
validate,
permissions.createGroup,
)
permissionsRouter.patch(
'/groups/:id',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Change a group’s title, rank, parent or chat style'
// #swagger.description = 'The title is kept verbatim. `chat` is the group’s BetterChat style — all twelve fields as text; `null` removes it; absent leaves it. With `onlyHere` and `serverId` on a shared group, that server’s copy is split off first and only it changes (D190).'
/* #swagger.responses[200] = { description: 'Saved; `id` is the group that changed, a new copy if it was split' } */
/* #swagger.responses[400] = { description: 'An invalid style' } */
/* #swagger.responses[404] = { description: 'No such group' } */
requireRole('admin'),
groupParam,
body('title').optional().isString().isLength({ max: 120 }),
body('rank').optional().isInt({ min: -1000, max: 1000 }).toInt(),
body('parent').optional().isString().isLength({ max: 64 }),
body('chat').optional({ values: 'null' }).isObject().withMessage('chat is an object of BetterChat fields, or null'),
...hereOnly,
validate,
permissions.updateGroup,
)
permissionsRouter.delete(
'/groups/:id',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Delete a group'
// #swagger.description = 'From the site and, at the next sync, from every server it is on. A built-in group (`default`, `admin`, Carbon’s `moderator`) is never removed from a game.'
/* #swagger.responses[204] = { description: 'Deleted' } */
/* #swagger.responses[404] = { description: 'No such group' } */
requireRole('admin'),
groupParam,
validate,
permissions.deleteGroup,
)
permissionsRouter.put(
'/groups/:id/permissions',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Replace what a group carries'
// #swagger.description = 'The whole list: the screen’s toggles, Grant all and Revoke all each send it. With `onlyHere` and `serverId` on a shared group, that server’s copy is split off first (D190).'
/* #swagger.responses[200] = { description: 'Saved; `id` is the group that changed' } */
/* #swagger.responses[404] = { description: 'No such group' } */
requireRole('admin'),
groupParam,
body('permissions').isArray({ max: 2000 }),
body('permissions.*').isString().matches(NAME),
...hereOnly,
validate,
permissions.setGroupPermissions,
)
permissionsRouter.put(
'/groups/:id/servers',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Share a group, or stop sharing it'
// #swagger.description = '`allServers` puts it on every server, including servers added later; otherwise `servers` lists them (D189). A chosen server that already has its own group of this name answers 409 with each one’s permissions; repeat with `replace` listing the ids to replace.'
/* #swagger.responses[200] = { description: 'Saved' } */
/* #swagger.responses[404] = { description: 'No such group' } */
/* #swagger.responses[409] = { description: 'A chosen server has its own group of this name' } */
requireRole('admin'),
groupParam,
body('allServers').optional().isBoolean().toBoolean(),
body('servers').optional().isArray({ max: 200 }),
body('servers.*').isString().isLength({ min: 1, max: 64 }),
body('replace').optional().isArray({ max: 200 }),
body('replace.*').isInt({ min: 1 }).toInt(),
validate,
permissions.setGroupServers,
)
permissionsRouter.post(
'/groups/:id/split',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Give one server its own copy of a shared group'
// #swagger.description = 'The copy carries the same permissions, members and style, on that server only, and the shared group stops covering it (D190).'
/* #swagger.responses[200] = { description: 'Split; `id` is the copy' } */
/* #swagger.responses[404] = { description: 'No such group' } */
/* #swagger.responses[409] = { description: 'That group is not on that server' } */
requireRole('admin'),
groupParam,
body('serverId').isString().isLength({ min: 1, max: 64 }),
validate,
permissions.splitGroup,
)
permissionsRouter.post(
'/groups/:id/members',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Put a player in a group'
// #swagger.description = 'A Steam id is a member as itself (D188); a website account reaches every Steam id it links (D28). A member who has never connected to a server is pending there until their first connection.'
/* #swagger.responses[204] = { description: 'Added' } */
/* #swagger.responses[400] = { description: 'No subject named' } */
/* #swagger.responses[404] = { description: 'No such group' } */
requireRole('admin'),
groupParam,
...subject,
...hereOnly,
validate,
permissions.addMember,
)
permissionsRouter.post(
'/groups/:id/members/remove',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Take a player out of a group'
// #swagger.description = 'Both ways they can be in it: as the Steam id, and as the website account it is linked to.'
/* #swagger.responses[204] = { description: 'Removed' } */
/* #swagger.responses[404] = { description: 'No such group' } */
requireRole('admin'),
groupParam,
...subject,
...hereOnly,
validate,
permissions.removeMember,
)
permissionsRouter.post(
'/groups/:id/members/clear',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Remove every member of a group'
/* #swagger.responses[204] = { description: 'Emptied' } */
/* #swagger.responses[404] = { description: 'No such group' } */
requireRole('admin'),
groupParam,
...hereOnly,
validate,
permissions.clearMembers,
)
permissionsRouter.delete(
'/exceptions/:id',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Give a grant back to the one server it was kept off'
/* #swagger.responses[204] = { description: 'Removed' } */
/* #swagger.responses[404] = { description: 'No such exception' } */
requireRole('admin'),
param('id').isInt({ min: 1 }).toInt(),
validate,
permissions.removeException,
)
const driftId = param('id').isInt({ min: 1 }).toInt()
permissionsRouter.post(
'/drift/:id/adopt',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Adopt a change made in the game'
// #swagger.description = 'An addition (or a changed group) becomes the site’s own for that server — the same write auto-adopt makes. For a `chat-field` row, the game’s value becomes the group’s style; 409 when the group has no style or the value is not one the site accepts.'
/* #swagger.responses[204] = { description: 'Adopted' } */
/* #swagger.responses[400] = { description: 'That change was a removal' } */
/* #swagger.responses[404] = { description: 'No such change' } */
requireRole('admin'),
driftId,
validate,
permissions.adoptDrift,
)
permissionsRouter.post(
'/drift/:id/revoke',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Undo an addition made in the game'
// #swagger.description = 'Queued: a server that is down keeps the instruction until it comes back. For a `chat-field` row it puts the site’s value back.'
/* #swagger.responses[202] = { description: 'Queued for the next sync' } */
/* #swagger.responses[400] = { description: 'Only an addition can be revoked' } */
/* #swagger.responses[404] = { description: 'No such change' } */
requireRole('admin'),
driftId,
validate,
permissions.revokeDrift,
)
permissionsRouter.post(
'/drift/:id/accept',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Accept a removal made in the game'
// #swagger.description = 'The site stops giving it on that server: deleted when it was that server’s alone, an exception when it reached further, a split when it was a shared group’s (D190).'
/* #swagger.responses[204] = { description: 'Accepted' } */
/* #swagger.responses[400] = { description: 'Only a removal can be accepted' } */
/* #swagger.responses[404] = { description: 'No such change' } */
requireRole('admin'),
driftId,
validate,
permissions.acceptDrift,
)
permissionsRouter.post(
'/drift/:id/restore',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Put back what the game removed or changed'
// #swagger.description = 'The next sync pushes the site’s version again.'
/* #swagger.responses[202] = { description: 'Queued for the next sync' } */
/* #swagger.responses[400] = { description: 'Only a removal or a changed group can be put back' } */
/* #swagger.responses[404] = { description: 'No such change' } */
requireRole('admin'),
driftId,
validate,
permissions.restoreDrift,
)
permissionsRouter.post(
'/drift/:id/dismiss',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Dismiss a notice'
// #swagger.description = 'A split notice (D190) or an event’s grant that was pushed back. Nothing in any game changes.'
/* #swagger.responses[204] = { description: 'Dismissed' } */
/* #swagger.responses[404] = { description: 'No such notice' } */
requireRole('admin'),
driftId,
validate,
permissions.dismissDrift,
)
permissionsRouter.post(
'/sync',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Read, reconcile and push now'
// #swagger.description = 'Runs the loop’s pass immediately for one server or all of them — read the store, settle what changed in the game by the server’s policy, push — and answers with each server’s state.'
/* #swagger.responses[200] = { description: 'The state of every server after the pass', content: { "application/json": { schema: { $ref: "#/components/schemas/RustPermissionSyncResult" } } } } */
/* #swagger.responses[404] = { description: 'No such server' } */
requireRole('admin'),
body('serverId').optional().isString().isLength({ min: 1, max: 64 }),
validate,
permissions.syncNow,
)
module.exports = permissionsRouter