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
425 lines
14 KiB
JavaScript
425 lines
14 KiB
JavaScript
// ── 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,
|
|
}
|