feat(rust): site-owned permissions — the site is the author, the game is the cache

R2, and the first phase where this module WRITES to a game. Groups and grants are
authored on the website and pushed into each server's own permission store, so
every plugin that already calls `UserHasPermission` honours them with no adapter,
and a wipe stops being a data-loss event.

**Seven org-lead decisions (D28-D34).** A grant is keyed to the website USER and
resolved to every Steam id they have linked at push time (D28); every authored row
carries a scope — a server or `*` (D29); groups are mirrored as real groups rather
than flattened (D30); a holder the site did not author is REPORTED, never undone,
with adopt and revoke offered (D31); one verb, with the plugin diffing locally
(D32); a permission no server has registered is reported unresolved and never
self-registered (D33); authoring is people and groups by hand, with rules deferred
(D34).

**Three sets, and every interesting question is a difference between two.**
`desired − pushed` is what to apply; `pushed − desired` is what to RETIRE, because
the site put it there and has since withdrawn it; `present − desired` is drift. The
middle one is why `rust_perm_pushed` exists: a name in the store that is not in the
desired set is either something the site retired or something a human granted, and
those two have opposite correct answers.

**What lands is not what was sent.** A grant naming a permission the server has not
registered did not land — `GrantUserPermission` no-ops silently — and a member the
store has never seen could not be placed. Neither is recorded as pushed, so the
site never believes it gave a privilege it did not.

The loop asks a cheap question every thirty seconds — does the digest of the
desired set still equal what this server last confirmed — and syncs on a change, a
restart, a wipe, a drift hook, a failed attempt past its backoff, or the
fifteen-minute audit that finds drift on a server nobody has touched.

**This module's first admin page**, because a permission model is the first thing
here that has to be composed rather than configured. What is on it is decided by
what an operator can get wrong: four states are invisible from the game and from a
list of grants, and each is a sentence rather than a number.

Walked end to end against a real core at the pinned ref, the real sidecar, and a
stand-in speaking protocol 4 — including a restart that emptied the store and was
fully re-pushed. Four defects the browser found that 133 green tests did not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM
This commit is contained in:
2026-09-21 18:28:32 -05:00
parent a1b6d155a1
commit 43147b796a
27 changed files with 5515 additions and 21 deletions

View File

@@ -0,0 +1,424 @@
// ── Admin · Rust · Permissions ────────────────────────────────────────────
//
// The authoring surface for R2. Everything here writes to the site's own tables
// and marks the affected servers dirty; nothing here talks to a game. The push
// is `permSync.js`'s loop, which is deliberate — a form that wrote to six game
// hosts inside the request would fail differently for each of them and have no
// honest status code to answer with.
//
// **The one exception is "sync now"**, which runs the loop's pass for one server
// and waits for it. It exists because an operator who has just changed something
// wants to see it land, and because waiting thirty seconds to find out that a
// server is unreachable is a bad way to learn it.
//
// Every write logs an activity row. These rows decide who may do what inside
// somebody's game server, which is the one thing on this module's admin tier
// more consequential than the sidecar credential.
const core = require('../../core')
const db = require('../../model/permissions/permissions.db')
const model = require('../../model/permissions/permissions.model')
const permSync = require('../../permSync')
const servers = require('../../model/servers/servers.model')
const log = core.logger('admin:permissions')
/** Everything the screen renders: groups, grants, drift, the catalogue, per-server state. */
async function overview(req, res) {
try {
res.json(await model.overview())
} catch (err) {
log.error('failed to read the permission model', { error: err.message })
res.status(500).json({ message: 'Failed to read the permission model' })
}
}
/**
* Create or update a group.
*
* The permission list is part of the same write, because that is how the form
* edits it: a group and what it carries are one idea on the screen, and two
* requests would leave a group briefly carrying the wrong set.
*/
async function putGroup(req, res) {
const name = model.normaliseName(req.params.name)
const scope = String(req.body.scope || model.FLEET)
try {
if (scope !== model.FLEET && !(await knownServer(scope))) {
return res.status(400).json({ message: 'That scope names no configured server' })
}
const previous = await db.getGroup(name)
await db.upsertGroup({
name,
title: String(req.body.title || name),
rank: Number(req.body.rank) || 0,
scope,
})
const permissions = [...new Set((req.body.permissions || []).map(model.normaliseName))].filter(Boolean)
await db.setGroupPermissions(name, permissions)
// Both scopes: a group that moved from one server to another has to be
// retired from where it was as well as applied where it now is, and only the
// old scope knows the first half.
await db.markDirty(scope)
if (previous && previous.scope !== scope) await db.markDirty(previous.scope)
await core.activity.log({
req,
action: previous ? 'rust.perm.group.update' : 'rust.perm.group.create',
detail: { group: name, scope, permissions: permissions.length },
})
return res.status(204).end()
} catch (err) {
log.error('failed to save a group', { group: name, error: err.message })
return res.status(500).json({ message: 'Failed to save that group' })
}
}
async function deleteGroup(req, res) {
const name = model.normaliseName(req.params.name)
try {
const existing = await db.getGroup(name)
if (!existing) return res.status(404).json({ message: 'No such group' })
await db.deleteGroup(name)
await db.markDirty(existing.scope)
await core.activity.log({ req, action: 'rust.perm.group.delete', detail: { group: name } })
return res.status(204).end()
} catch (err) {
log.error('failed to delete a group', { group: name, error: err.message })
return res.status(500).json({ message: 'Failed to delete that group' })
}
}
async function addMember(req, res) {
const name = model.normaliseName(req.params.name)
try {
const group = await db.getGroup(name)
if (!group) return res.status(404).json({ message: 'No such group' })
const userId = await resolveUser(req.body)
if (!userId) return res.status(404).json({ message: 'No account on this site has that name' })
await db.addGroupMember(name, userId, req.user ? req.user.id : null)
await db.markDirty(group.scope)
await core.activity.log({
req,
action: 'rust.perm.member.add',
detail: { group: name, userId },
})
return res.status(204).end()
} catch (err) {
// A user id that names nobody fails on the foreign key rather than on a
// check of our own: the row is the constraint, and one round trip is
// cheaper than two.
log.error('failed to add a member', { group: name, userId, error: err.message })
return res.status(400).json({ message: 'That account could not be added to the group' })
}
}
async function removeMember(req, res) {
const name = model.normaliseName(req.params.name)
const userId = Number(req.params.userId)
try {
const group = await db.getGroup(name)
if (!group) return res.status(404).json({ message: 'No such group' })
const removed = await db.removeGroupMember(name, userId)
if (!removed) return res.status(404).json({ message: 'That account is not in the group' })
await db.markDirty(group.scope)
await core.activity.log({
req,
action: 'rust.perm.member.remove',
detail: { group: name, userId },
})
return res.status(204).end()
} catch (err) {
log.error('failed to remove a member', { group: name, userId, error: err.message })
return res.status(500).json({ message: 'Failed to remove that account from the group' })
}
}
/**
* Grant one permission to one person.
*
* `source` is fixed at `admin` here and is not accepted from the body: the
* column exists so phase 13's event actions can write their own rows through the
* same table, and a route that let a caller choose would make "who gave this"
* unanswerable the first time somebody passed the wrong string.
*/
async function addGrant(req, res) {
const permission = model.normaliseName(req.body.permission)
const scope = String(req.body.scope || model.FLEET)
let userId = null
try {
if (scope !== model.FLEET && !(await knownServer(scope))) {
return res.status(400).json({ message: 'That scope names no configured server' })
}
userId = await resolveUser(req.body)
if (!userId) return res.status(404).json({ message: 'No account on this site has that name' })
const { inserted } = await db.insertGrant({
userId,
permission,
scope,
source: 'admin',
note: req.body.note ? String(req.body.note).slice(0, 255) : null,
grantedBy: req.user ? req.user.id : null,
})
if (inserted) {
await db.markDirty(scope)
await core.activity.log({
req,
action: 'rust.perm.grant',
detail: { userId, permission, scope },
})
}
return res.status(inserted ? 201 : 200).json({ granted: inserted })
} catch (err) {
log.error('failed to grant', { userId, permission, error: err.message })
return res.status(400).json({ message: 'That permission could not be granted' })
}
}
async function removeGrant(req, res) {
const id = Number(req.params.id)
try {
const grant = await db.getGrant(id)
if (!grant) return res.status(404).json({ message: 'No such grant' })
await db.deleteGrant(id)
await db.markDirty(grant.scope)
await core.activity.log({
req,
action: 'rust.perm.revoke',
detail: { userId: grant.userId, permission: grant.permission, scope: grant.scope },
})
return res.status(204).end()
} catch (err) {
log.error('failed to revoke a grant', { grant: id, error: err.message })
return res.status(500).json({ message: 'Failed to remove that grant' })
}
}
/**
* Adopt a hand edit: the site records it as its own.
*
* It is only possible for a `grant` whose Steam id belongs to a website account,
* and the refusal says so — because the alternative is authoring privilege
* against a game account no person on this site holds, which is precisely the
* thing D28 decided not to do.
*/
async function adoptDrift(req, res) {
const id = Number(req.params.id)
try {
const row = await db.getDrift(id)
if (!row) return res.status(404).json({ message: 'No such drift' })
if (row.kind !== 'grant' && row.kind !== 'member') {
return res.status(400).json({
message: 'Only a grant or a membership can be adopted. A permission on a group is edited on the group itself.',
})
}
const holder = await holderOf(row.subject)
if (!holder) {
return res.status(409).json({
message:
'That Steam account is not linked to any account on this site, so there is nobody to author this against. Revoke it instead, or ask the player to link.',
})
}
if (row.kind === 'grant') {
await db.insertGrant({
userId: holder.userId,
permission: row.object,
scope: row.serverId,
source: 'adopted',
note: 'Adopted from a hand edit',
grantedBy: req.user ? req.user.id : null,
})
} else {
const group = await db.getGroup(row.object)
if (!group) return res.status(409).json({ message: 'That group is not authored on this site' })
await db.addGroupMember(row.object, holder.userId, req.user ? req.user.id : null)
}
// Already in the game, so it is already pushed — recorded as such rather
// than left for the next sync to "apply". Without this the row would be
// desired-but-not-pushed, which is a state the loop would happily write
// again and the game would report as already correct: harmless, and a lie in
// the one table that exists to say what this site put there.
await db.addPushed(row.serverId, [{ kind: row.kind, subject: row.subject, object: row.object }])
await db.deleteDrift(id)
await db.markDirty(row.serverId)
await core.activity.log({
req,
action: 'rust.perm.drift.adopt',
detail: { server: row.serverId, kind: row.kind, subject: row.subject, object: row.object },
})
return res.status(204).end()
} catch (err) {
log.error('failed to adopt drift', { drift: id, error: err.message })
return res.status(500).json({ message: 'Failed to adopt that change' })
}
}
/**
* Revoke a hand edit.
*
* Queued rather than sent: the server may be down, and an instruction that is
* dropped because a game host was restarting is exactly the behaviour a site
* claiming to be the author of record must not have. The next successful sync
* carries it and the queue row goes.
*/
async function revokeDrift(req, res) {
const id = Number(req.params.id)
try {
const row = await db.getDrift(id)
if (!row) return res.status(404).json({ message: 'No such drift' })
await db.queueRevocation({
serverId: row.serverId,
kind: row.kind,
subject: row.subject,
object: row.object,
requestedBy: req.user ? req.user.id : null,
})
await db.deleteDrift(id)
await db.markDirty(row.serverId)
await core.activity.log({
req,
action: 'rust.perm.drift.revoke',
detail: { server: row.serverId, kind: row.kind, subject: row.subject, object: row.object },
})
return res.status(202).json({ queued: true })
} catch (err) {
log.error('failed to queue a revocation', { drift: id, error: err.message })
return res.status(500).json({ message: 'Failed to queue that revocation' })
}
}
/** Run the loop's pass now, for one server or for all of them, and report what happened. */
async function syncNow(req, res) {
const serverId = req.body && req.body.serverId ? String(req.body.serverId) : null
try {
if (serverId && !(await knownServer(serverId))) {
return res.status(404).json({ message: 'No such server' })
}
await db.markDirty(serverId || model.FLEET)
await permSync.tick({ force: serverId })
await core.activity.log({
req,
action: 'rust.perm.sync',
detail: { server: serverId || 'all' },
})
const state = await model.overview()
return res.json({ servers: state.servers, drift: state.drift })
} catch (err) {
log.error('a forced sync failed', { server: serverId, error: err.message })
return res.status(500).json({ message: 'Failed to run the sync' })
}
}
/** Every permission name any configured server has registered, with which ones know it. */
async function catalogue(req, res) {
try {
const rows = await db.listCatalogue()
res.json({ permissions: groupCatalogue(rows) })
} catch (err) {
log.error('failed to read the catalogue', { error: err.message })
res.status(500).json({ message: 'Failed to read the permission catalogue' })
}
}
function groupCatalogue(rows) {
const byPermission = new Map()
for (const row of rows) {
if (!byPermission.has(row.permission)) byPermission.set(row.permission, [])
byPermission.get(row.permission).push(row.serverId)
}
return [...byPermission.entries()]
.map(([permission, serverIds]) => ({ permission, servers: serverIds }))
.sort((a, b) => a.permission.localeCompare(b.permission))
}
/**
* The user id a write is about, from either an id or a username.
*
* The form sends a name, because a form that made an operator type a numeric id
* would be a form nobody could use. The id form stays accepted because the
* client already holds one on the panel inside core's user page, and looking a
* name back up from it would be a round trip to answer a question it has
* already answered.
*/
async function resolveUser(body) {
if (body.userId) return Number(body.userId)
if (!body.username) return null
const user = await db.findUserByUsername(String(body.username).trim())
return user ? user.id : null
}
/** Whether a scope names a server row. A disabled server still counts — it exists. */
async function knownServer(id) {
const rows = await servers.listForAdmin()
return rows.some((row) => row.id === id)
}
/** The website account that holds a Steam id, or null. */
async function holderOf(steamId) {
const links = await db.listLinks()
return links.find((link) => link.steamId === steamId) || null
}
module.exports = {
overview,
putGroup,
deleteGroup,
addMember,
removeMember,
addGrant,
removeGrant,
adoptDrift,
revokeDrift,
syncNow,
catalogue,
}

View File

@@ -0,0 +1,184 @@
// ── 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.
//
// **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.
const core = require('../../core')
const express = core.express
const permissions = require('./permissions.controller')
const { requireRole, validate } = core.middleware
const { body, param } = 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
permissionsRouter.get(
'/',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'The whole permission model'
// #swagger.description = 'Groups with their permissions and members, direct grants, the drift each server reported, the option source of registered permission names, and the sync state of every configured server.'
/* #swagger.responses[200] = { description: 'The authored model and what each game reported', content: { "application/json": { schema: { $ref: "#/components/schemas/RustPermissionModel" } } } } */
requireRole('admin'),
permissions.overview,
)
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" } } } } */
requireRole('admin'),
permissions.catalogue,
)
permissionsRouter.put(
'/groups/:name',
// #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.'
/* #swagger.responses[204] = { description: 'Saved' } */
/* #swagger.responses[400] = { description: 'Invalid body, or a scope naming no configured 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),
validate,
permissions.putGroup,
)
permissionsRouter.delete(
'/groups/:name',
// #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.responses[204] = { description: 'Deleted' } */
/* #swagger.responses[404] = { description: 'No such group' } */
requireRole('admin'),
param('name').isString().isLength({ min: 1, max: 64 }),
validate,
permissions.deleteGroup,
)
permissionsRouter.post(
'/groups/:name/members',
// #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.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 }),
validate,
permissions.addMember,
)
permissionsRouter.delete(
'/groups/:name/members/:userId',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Take an account out of a group'
/* #swagger.responses[204] = { description: 'Removed' } */
/* #swagger.responses[404] = { description: 'No such group, or that account is not in it' } */
requireRole('admin'),
param('name').isString().isLength({ min: 1, max: 64 }),
param('userId').isInt({ min: 1 }).toInt(),
validate,
permissions.removeMember,
)
permissionsRouter.post(
'/grants',
// #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' } */
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 }),
validate,
permissions.addGrant,
)
permissionsRouter.delete(
'/grants/: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.responses[204] = { description: 'Removed' } */
/* #swagger.responses[404] = { description: 'No such grant' } */
requireRole('admin'),
param('id').isInt({ min: 1 }).toInt(),
validate,
permissions.removeGrant,
)
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.'
/* #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' } */
requireRole('admin'),
param('id').isInt({ min: 1 }).toInt(),
validate,
permissions.adoptDrift,
)
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.'
/* #swagger.responses[202] = { description: 'Queued for the next sync' } */
/* #swagger.responses[404] = { description: 'No such drift' } */
requireRole('admin'),
param('id').isInt({ min: 1 }).toInt(),
validate,
permissions.revokeDrift,
)
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.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

View File

@@ -27,6 +27,11 @@ const { body, param } = core.validator
const adminRustRouter = express.Router()
// R2's authoring surface, under `/rust/permissions`. Its own file because it is
// its own subject — this router configures the bridge, that one decides who may
// do what inside the game the bridge reaches.
adminRustRouter.use('/permissions', require('./permissions.router'))
adminRustRouter.get(
'/servers',
// #swagger.tags = ['Admin · Rust']

View File

@@ -8,6 +8,9 @@
const core = require('../../core')
const links = require('../../model/links/links.model')
const permissionsDb = require('../../model/permissions/permissions.db')
const permissions = require('../../model/permissions/permissions.model')
const servers = require('../../model/servers/servers.model')
const log = core.logger('admin')
@@ -68,4 +71,132 @@ async function removeLink(req, res) {
}
}
module.exports = { listLinks, removeLink }
/**
* GET /admin/users/:id/rust/permissions
*
* What this person may do in game, and — the part that is easy to leave out —
* whether any of it reaches anybody. A grant against an account with no linked
* Steam id is authored, stored, pushed nowhere and looks identical to a working
* one on every screen that does not say so.
*/
async function listPermissions(req, res) {
const userId = Number(req.params.id)
try {
const [groups, groupPermissions, members, grants, allLinks] = await Promise.all([
permissionsDb.listGroups(),
permissionsDb.listGroupPermissions(),
permissionsDb.listGroupMembers(),
permissionsDb.listGrants({ userId }),
permissionsDb.listLinks(),
])
const theirs = new Set(
members.filter((row) => row.userId === userId).map((row) => row.groupName),
)
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)
}
res.json({
groups: groups
.filter((group) => theirs.has(group.name))
.map((group) => ({
name: group.name,
title: group.title,
scope: group.scope,
permissions: carried.get(group.name) || [],
})),
grants: permissions.collapseGrants(grants).map((grant) => ({
id: grant.id,
permission: grant.permission,
scope: grant.scope,
source: grant.source,
note: grant.note,
grantedAt: grant.grantedAt,
})),
reaches: allLinks.filter((link) => link.userId === userId).map((link) => link.steamId),
})
} catch (err) {
log.error('failed to read a user’s Rust permissions', { error: err.message })
res.status(500).json({ message: 'Failed to read this user’s Rust permissions' })
}
}
/** POST /admin/users/:id/rust/permissions/grants */
async function addGrant(req, res) {
const userId = Number(req.params.id)
const permission = permissions.normaliseName(req.body.permission)
const scope = String(req.body.scope || permissions.FLEET)
try {
if (scope !== permissions.FLEET) {
const known = await servers.listForAdmin()
if (!known.some((row) => row.id === scope)) {
return res.status(400).json({ message: 'That scope names no configured server' })
}
}
const { inserted } = await permissionsDb.insertGrant({
userId,
permission,
scope,
source: 'admin',
note: null,
grantedBy: req.user ? req.user.id : null,
})
if (inserted) {
await permissionsDb.markDirty(scope)
await core.activity.log({
req,
action: 'rust.perm.grant',
detail: { userId, permission, scope },
})
}
return res.status(inserted ? 201 : 200).json({ granted: inserted })
} catch (err) {
log.error('failed to grant a permission', { userId, permission, error: err.message })
return res.status(400).json({ message: 'That permission could not be granted' })
}
}
/**
* DELETE /admin/users/:id/rust/permissions/grants/:grantId
*
* **Scoped by the user as well as by the grant**, like every other write in this
* panel: a grant id belonging to somebody else answers `404` rather than
* removing a privilege from a person whose page nobody was looking at.
*/
async function removeGrant(req, res) {
const userId = Number(req.params.id)
const grantId = Number(req.params.grantId)
try {
const grant = await permissionsDb.getGrant(grantId)
if (!grant || grant.userId !== userId) {
return res.status(404).json({ message: 'That grant does not belong to this user' })
}
await permissionsDb.deleteGrant(grantId)
await permissionsDb.markDirty(grant.scope)
await core.activity.log({
req,
action: 'rust.perm.revoke',
detail: { userId, permission: grant.permission, scope: grant.scope },
})
return res.status(204).end()
} catch (err) {
log.error('failed to remove a grant', { userId, grant: grantId, error: err.message })
return res.status(500).json({ message: 'Failed to remove that permission' })
}
}
module.exports = { listLinks, removeLink, listPermissions, addGrant, removeGrant }

View File

@@ -30,7 +30,7 @@
const core = require('../../core')
const express = core.express
const { param } = core.validator
const { body, param } = core.validator
const usersRust = require('./usersRust.controller')
const { validate } = core.middleware
@@ -70,4 +70,62 @@ usersRustRouter.delete(
usersRust.removeLink,
)
// ── Phase 7: what this person may do in game ─────────────────────────────
//
// The same panel, one section lower. It is here rather than only on the
// permissions screen because the question an operator actually has is about a
// PERSON — "why can this player spawn a kit" is asked on their page, not on a
// list of groups — and because the slot is already the place this module says
// everything else it knows about one user.
//
// Both writes go through the ordinary authored tables and the ordinary loop. A
// grant made here reaches the game when the mirror next reconciles, which is
// seconds, and never inside this request.
usersRustRouter.get(
'/rust/permissions',
// #swagger.tags = ['Admin · Users']
// #swagger.summary = 'A user’s Rust privileges (admin only)'
// #swagger.description = 'The groups this person is in, the permissions granted to them directly, and the Steam accounts those privileges actually reach. An empty `reaches` means they have linked nothing and hold them on paper only.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
/* #swagger.responses[200] = { description: 'Their groups and grants', content: { "application/json": { schema: { $ref: "#/components/schemas/RustUserPermissions" } } } } */
param('id').isInt(),
validate,
usersRust.listPermissions,
)
usersRustRouter.post(
'/rust/permissions/grants',
// #swagger.tags = ['Admin · Users']
// #swagger.summary = 'Grant a Rust permission to this user (admin only)'
// #swagger.description = 'Authored against the website account, so it reaches every Steam id they have linked — now and later. `scope` is a server id or `*` for the fleet. The push happens on the mirror’s next pass.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
/* #swagger.responses[201] = { description: 'Granted' } */
/* #swagger.responses[200] = { description: 'They already held it' } */
/* #swagger.responses[400] = { description: 'Invalid body, or a scope naming no configured server', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt(),
body('permission').isString().matches(/^[a-z0-9][a-z0-9._-]{0,127}$/i),
body('scope').optional().isString().isLength({ min: 1, max: 64 }),
validate,
usersRust.addGrant,
)
usersRustRouter.delete(
'/rust/permissions/grants/:grantId',
// #swagger.tags = ['Admin · Users']
// #swagger.summary = 'Remove a Rust permission from this user (admin only)'
// #swagger.description = 'Scoped to this user as well as to the grant, so a wrong id on the URL removes nothing rather than somebody else’s privilege. The revoke reaches the game on the mirror’s next pass.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
// #swagger.parameters['grantId'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'The grant to remove.' }
/* #swagger.responses[204] = { description: 'Removed' } */
/* #swagger.responses[404] = { description: 'No such grant for this user', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt(),
param('grantId').isInt({ min: 1 }).toInt(),
validate,
usersRust.removeGrant,
)
module.exports = usersRustRouter