// ── 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, }