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:
424
server/router/admin/permissions.controller.js
Normal file
424
server/router/admin/permissions.controller.js
Normal 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,
|
||||
}
|
||||
184
server/router/admin/permissions.router.js
Normal file
184
server/router/admin/permissions.router.js
Normal 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
|
||||
@@ -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']
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user