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:
@@ -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