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:
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user