feat(rust): the permission manager — the site owns the whole store (D160-D163, D188-D198)
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

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
This commit is contained in:
2026-09-28 06:58:59 -05:00
parent 77c90db338
commit e0d13e73db
24 changed files with 5873 additions and 2589 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1,39 +1,53 @@
// ── Admin · Rust · Permissions ────────────────────────────────────────────
//
// Mounted under the admin tier's `/rust` prefix, so every path here is
// `/api/v1/admin/rust/permissions…`. It is a second router rather than more
// routes on `rust.router.js` because it is a second subject: that one configures
// the bridge, this one authors privilege inside somebody's game.
// `/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. The tier
// gate is not re-implemented; this is one gate on top of it, exactly as the
// server-configuration routes do it.
//
// There is no module-declared site permission to gate these more finely with —
// `MODULE_API.md` has no such member at 1.10.0 — so role is the whole of the
// available vocabulary, and `admin` is the honest choice within it.
// 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 } = core.validator
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 whole permission model'
// #swagger.description = 'Groups with their permissions, members and BetterChat style (`chat`, or null), direct grants, the drift each server reported, the option source of registered permission names, and the sync state of every configured server. A drift row of kind `chat-field` is a style field somebody changed in game: `subject` is the group, `object` the field and `detail` what the game holds. `chatFields` lists the twelve BetterChat fields with their types and defaults, for the style editor.'
/* #swagger.responses[200] = { description: 'The authored model and what each game reported', content: { "application/json": { schema: { $ref: "#/components/schemas/RustPermissionModel" } } } } */
// #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,
)
@@ -42,118 +56,259 @@ permissionsRouter.get(
'/catalogue',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Permission names the servers have registered'
// #swagger.description = 'What the loaded plugins on each configured server have registered, cached from the last sync. It is the option source for the authoring form: a permission no server knows cannot be granted, because `GrantUserPermission` silently does nothing for an unregistered name.'
/* #swagger.responses[200] = { description: 'Every registered name, and which servers know it', content: { "application/json": { schema: { $ref: "#/components/schemas/RustPermissionCatalogue" } } } } */
// #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.put(
'/groups/:name',
permissionsRouter.get(
'/servers/:serverId',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Create or update a permission group'
// #swagger.description = 'Writes the group and the permissions it carries in one request, because they are one idea on the form. `scope` is a server id or `*` for the whole fleet. The group is mirrored into each in-scope game as a real group, so third-party plugins that read group membership see it. `chat` is the group’s BetterChat style: all twelve fields (`Priority`, `Title`, `TitleColor`, `TitleSize`, `TitleHidden`, `TitleHiddenIfNotPrimary`, `UsernameColor`, `UsernameSize`, `MessageColor`, `MessageSize`, `ChatFormat`, `ConsoleFormat`), each as text; `null` removes the style, which removes the group from BetterChat on the next sync; absent leaves it alone. A format must hold `{Message}` exactly once. A 400 carries one sentence per problem in `errors`.'
/* #swagger.responses[204] = { description: 'Saved' } */
/* #swagger.responses[400] = { description: 'Invalid body, or a scope naming no configured server' } */
// #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'),
param('name').matches(NAME).withMessage('a group name is letters, digits, dots, dashes and underscores'),
body('title').optional().isString().trim().isLength({ max: 120 }),
body('rank').optional().isInt({ min: -1000, max: 1000 }).toInt(),
body('scope').optional().isString().isLength({ min: 1, max: 64 }),
body('permissions').optional().isArray({ max: 500 }),
body('permissions.*').isString().matches(NAME),
// Shape only; the twelve fields and their rules are `chatStyle.validateStyle`'s,
// in the controller, so the form gets one sentence per problem.
body('chat').optional({ values: 'null' }).isObject().withMessage('chat is an object of BetterChat fields, or null'),
serverParam,
validate,
permissions.putGroup,
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/:name',
'/groups/:id',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Delete a permission group'
// #swagger.description = 'Removes the group, its permission list and its membership from the site. The next sync retires the group from every server it had been pushed to — a group the site authored and has withdrawn is removed from the game, unlike one somebody created by hand.'
// #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'),
param('name').isString().isLength({ min: 1, max: 64 }),
groupParam,
validate,
permissions.deleteGroup,
)
permissionsRouter.post(
'/groups/:name/members',
permissionsRouter.put(
'/groups/:id/permissions',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Put an account in a group'
// #swagger.description = 'Membership is authored against a website user and reaches every Steam account they have linked. A member who has never connected to a server cannot be placed in its store yet — the sync reports them as pending and the membership lands on their first connection.'
/* #swagger.responses[204] = { description: 'Added' } */
// #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'),
param('name').isString().isLength({ min: 1, max: 64 }),
// Either identifier: the screen sends a name, the panel inside core's own user
// page already holds an id.
body('userId').optional().isInt({ min: 1 }).toInt(),
body('username').optional().isString().trim().isLength({ min: 1, max: 64 }),
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.delete(
'/groups/:name/members/:userId',
permissionsRouter.post(
'/groups/:id/members/remove',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Take an account out of a group'
// #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, or that account is not in it' } */
/* #swagger.responses[404] = { description: 'No such group' } */
requireRole('admin'),
param('name').isString().isLength({ min: 1, max: 64 }),
param('userId').isInt({ min: 1 }).toInt(),
groupParam,
...subject,
...hereOnly,
validate,
permissions.removeMember,
)
permissionsRouter.post(
'/grants',
'/groups/:id/members/clear',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Grant one permission to one person'
// #swagger.description = 'A direct grant, authored against a website user and pushed to every Steam account they have linked. Unlike group membership it reaches a player who has never connected to the server, which is what an entitlement earned on the website has to do.'
/* #swagger.responses[201] = { description: 'Granted' } */
/* #swagger.responses[200] = { description: 'They already held it; nothing changed' } */
/* #swagger.responses[400] = { description: 'Invalid body, or a scope naming no configured server' } */
/* #swagger.responses[404] = { description: 'No account on this site has that name' } */
// #swagger.summary = 'Remove every member of a group'
/* #swagger.responses[204] = { description: 'Emptied' } */
/* #swagger.responses[404] = { description: 'No such group' } */
requireRole('admin'),
body('userId').optional().isInt({ min: 1 }).toInt(),
body('username').optional().isString().trim().isLength({ min: 1, max: 64 }),
body('permission').isString().matches(NAME),
body('scope').optional().isString().isLength({ min: 1, max: 64 }),
body('note').optional().isString().isLength({ max: 255 }),
groupParam,
...hereOnly,
validate,
permissions.addGrant,
permissions.clearMembers,
)
permissionsRouter.delete(
'/grants/:id',
'/exceptions/:id',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Remove a grant'
// #swagger.description = 'The next sync revokes it in every in-scope game. A player who has already used what it allowed keeps what they did with it — the grant is the entitlement, not the consumption.'
// #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 grant' } */
/* #swagger.responses[404] = { description: 'No such exception' } */
requireRole('admin'),
param('id').isInt({ min: 1 }).toInt(),
validate,
permissions.removeGrant,
permissions.removeException,
)
const driftId = param('id').isInt({ min: 1 }).toInt()
permissionsRouter.post(
'/drift/:id/adopt',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Adopt a hand edit'
// #swagger.description = 'Records a grant or membership somebody made in game as one the site authors, so it stops being reported and starts being maintained. It needs a website account holding that Steam id; without one there is nobody to author it against, and the answer is to revoke it or to ask the player to link. For a `chat-field` row it copies the game’s value into the group’s style — which every server in the group’s scope is then pushed — and answers 409 when the group has no style or the value is not one the site accepts.'
// #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 kind of drift cannot be adopted' } */
/* #swagger.responses[409] = { description: 'That Steam account is linked to nobody on this site' } */
/* #swagger.responses[400] = { description: 'That change was a removal' } */
/* #swagger.responses[404] = { description: 'No such change' } */
requireRole('admin'),
param('id').isInt({ min: 1 }).toInt(),
driftId,
validate,
permissions.adoptDrift,
)
@@ -161,21 +316,63 @@ permissionsRouter.post(
permissionsRouter.post(
'/drift/:id/revoke',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Revoke a hand edit'
// #swagger.description = 'Queues the removal rather than performing it: a server that is down keeps the instruction until it comes back. This is the only way the site removes something it did not put there — a sync never does it on its own. For a `chat-field` row it puts the site’s value back over the hand edit on the next sync.'
// #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[404] = { description: 'No such drift' } */
/* #swagger.responses[400] = { description: 'Only an addition can be revoked' } */
/* #swagger.responses[404] = { description: 'No such change' } */
requireRole('admin'),
param('id').isInt({ min: 1 }).toInt(),
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 = 'Push the permission set now'
// #swagger.description = 'Runs the reconciliation loop’s pass immediately, for one server or for all of them, and answers with what each one reported. The loop does this on its own; the button exists so an operator who has just changed something can see it land, and finds out at once when a server is unreachable.'
// #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'),

View File

@@ -83,32 +83,41 @@ async function listPermissions(req, res) {
const userId = Number(req.params.id)
try {
const [groups, groupPermissions, members, grants, allLinks] = await Promise.all([
const [groups, groupServers, groupPermissions, members, grants, allLinks] = await Promise.all([
permissionsDb.listGroups(),
permissionsDb.listGroupServers(),
permissionsDb.listGroupPermissions(),
permissionsDb.listGroupMembers(),
permissionsDb.listGrants({ userId }),
permissionsDb.listLinks(),
])
const theirs = new Set(
members.filter((row) => row.userId === userId).map((row) => row.groupName),
)
const theirs = new Set(members.filter((row) => row.userId === userId).map((row) => row.groupId))
const carried = new Map()
for (const row of groupPermissions) {
if (!carried.has(row.groupName)) carried.set(row.groupName, [])
carried.get(row.groupName).push(row.permission)
if (!carried.has(row.groupId)) carried.set(row.groupId, [])
carried.get(row.groupId).push(row.permission)
}
// A group is on one server unless it is shared (D189): `scope` says `*`
// for every server, or lists the servers it is on.
const on = new Map()
for (const row of groupServers) {
if (!row.included) continue
if (!on.has(row.groupId)) on.set(row.groupId, [])
on.get(row.groupId).push(row.serverId)
}
res.json({
groups: groups
.filter((group) => theirs.has(group.name))
.filter((group) => theirs.has(group.id))
.map((group) => ({
id: group.id,
name: group.name,
title: group.title,
scope: group.scope,
permissions: carried.get(group.name) || [],
scope: group.allServers ? permissions.FLEET : (on.get(group.id) || []).join(','),
permissions: carried.get(group.id) || [],
})),
grants: permissions.collapseGrants(grants).map((grant) => ({
id: grant.id,