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:
2026-09-21 18:28:32 -05:00
parent a1b6d155a1
commit 43147b796a
27 changed files with 5515 additions and 21 deletions

View File

@@ -44,6 +44,7 @@ const core = require('./core')
const db = require('./model/servers/servers.db')
const eventsDb = require('./model/events/events.db')
const ingest = require('./ingest')
const permSync = require('./permSync')
const servers = require('./model/servers/servers.model')
const sidecar = require('./sidecarClient')
@@ -190,6 +191,11 @@ async function prune() {
async function onBoot() {
await refresh()
// The permission mirror owns its own loop and its own cadence (see
// `permSync.js`). It is started rather than run here: a first pass would write
// to every configured game server before the website had finished booting, and
// nothing about R2 is urgent enough to delay a listener for.
permSync.start()
refreshTimer = setInterval(refresh, REFRESH_MS)
ingestTimer = setInterval(ingestAll, INGEST_MS)
pruneTimer = setInterval(prune, PRUNE_MS)
@@ -200,7 +206,7 @@ async function onBoot() {
if (timer && typeof timer.unref === 'function') timer.unref()
}
log.info('booted', { refreshMs: REFRESH_MS, ingestMs: INGEST_MS })
log.info('booted', { refreshMs: REFRESH_MS, ingestMs: INGEST_MS, permSyncMs: permSync.TICK_MS })
}
/**
@@ -212,6 +218,8 @@ async function onBoot() {
* rather than cancelled, since nothing can stop a promise that is still running.
*/
async function onShutdown() {
permSync.stop()
for (const timer of [refreshTimer, ingestTimer, pruneTimer]) {
if (timer) clearInterval(timer)
}

View File

@@ -76,6 +76,10 @@ const STAFF_KINDS = Object.freeze([
// about somebody's identity, not about what happened on the server.
'account.link.requested',
'account.unlinked',
// Protocol 4. Who holds which privilege in game, and the fact that somebody
// changed it by hand — a question about a person's standing and about an
// operator's own console, neither of which is a public page's business.
'perm.drift',
])
/** Every kind protocol 3 defines. */

View File

@@ -19,6 +19,17 @@
-- it knows this module registered, because it is the side that knows which
-- registrant owned what.
-- Phase 7. Children before parents: every one of these carries a foreign key
-- into `rust_servers`, `users` or `rust_perm_groups`.
DROP TABLE IF EXISTS rust_perm_catalogue;
DROP TABLE IF EXISTS rust_perm_sync;
DROP TABLE IF EXISTS rust_perm_revocations;
DROP TABLE IF EXISTS rust_perm_drift;
DROP TABLE IF EXISTS rust_perm_pushed;
DROP TABLE IF EXISTS rust_perm_grants;
DROP TABLE IF EXISTS rust_perm_group_members;
DROP TABLE IF EXISTS rust_perm_group_permissions;
DROP TABLE IF EXISTS rust_perm_groups;
DROP TABLE IF EXISTS rust_account_links;
DROP TABLE IF EXISTS rust_ingest_cursor;
DROP TABLE IF EXISTS rust_presence;

View File

@@ -335,6 +335,271 @@ CREATE TABLE IF NOT EXISTS rust_account_links (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ── Site-owned permissions (phase 7, R2) ──────────────────────────────────
--
-- The website is the author of record for who may do what in game, and the
-- framework's own permission store is an ENFORCEMENT CACHE. That is one
-- sentence with three consequences, and the tables below are shaped by them:
--
-- • Every third-party plugin honours a site grant with no adapter, because
-- they all already call `UserHasPermission`. Nothing here is read by the
-- game directly; it is pushed into the store the game already consults.
-- • A wipe stops being a data-loss event. The game forgets and the site does
-- not, so the next sync puts it all back.
-- • A hand edit is REPORTED, never silently overwritten (D31). Which means
-- the site has to be able to tell a grant it made from one somebody typed
-- at a console — and that is a fact only the site can hold, because the
-- store records who granted a permission nowhere.
--
-- ── A grant is against a WEBSITE USER (D28) ───────────────────────────────
--
-- Not against a Steam id, though a Steam id is what reaches the game. The site
-- authors privilege for a PERSON: phase 13's earned entitlements follow whoever
-- earned them, and an account unlinked from a person takes their privileges
-- with it. The Steam ids are resolved from `rust_account_links` at push time,
-- so a player who links a second account gets what they hold on both — which is
-- the honest reading of "this person may do this".
--
-- A user with no linked account is authored against perfectly well and simply
-- reaches nobody until they link. That is visible on the admin screen rather
-- than silent, because a grant that reaches nothing looks identical to a grant
-- that worked from every other angle.
--
-- ── Scope (D29) ───────────────────────────────────────────────────────────
--
-- Every authored row carries one: a server id, or `*` for the whole fleet. The
-- game stores permissions per server (each has its own store), an operator
-- running a modded server and a vanilla one will not want one set on both, and
-- a single-server community never has to think about it.
-- ── Groups ────────────────────────────────────────────────────────────────
--
-- Mirrored into the game as REAL groups (D30) rather than flattened into
-- per-player grants. Third-party plugins read group membership, BetterChat's
-- group API (R15, phase 17) has something to hang on, and an operator reading
-- `oxide.show groups` sees what the website shows.
--
-- The cost of that fidelity is written down in PLAN.md §12.2 rule 4 and does
-- not go away: **a player the store has never seen cannot be put in a group**,
-- while a direct grant to the same id works immediately. The sync reports those
-- members as pending and the membership lands on their first connection.
--
-- The name is the primary key, fleet-wide, even though the row carries a scope:
-- one `vip` on the site is one `vip` in the game, pushed to the servers its
-- scope names. Two groups of the same name with different scopes would be two
-- definitions of one name in every store that received both.
CREATE TABLE IF NOT EXISTS rust_perm_groups (
name VARCHAR(64) NOT NULL PRIMARY KEY,
title VARCHAR(120) NOT NULL DEFAULT '',
rank INT NOT NULL DEFAULT 0,
scope VARCHAR(64) NOT NULL DEFAULT '*',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- What each group carries. A row per permission rather than a list on the group
-- for the ordinary reason: "which groups grant kits.vip" is the question an
-- operator asks when they are about to remove a plugin, and that is a WHERE
-- clause here and a scan of every row in the other shape.
CREATE TABLE IF NOT EXISTS rust_perm_group_permissions (
group_name VARCHAR(64) NOT NULL,
permission VARCHAR(128) NOT NULL,
PRIMARY KEY (group_name, permission),
CONSTRAINT fk_rust_perm_group_permissions_group
FOREIGN KEY (group_name) REFERENCES rust_perm_groups (name) ON DELETE CASCADE
);
-- Who is in each group — by website user, like every other authored row.
--
-- `added_by` is an admin's user id and deliberately carries NO foreign key: a
-- staff member's account being deleted must not delete the record of what they
-- did, and `ON DELETE SET NULL` would quietly rewrite history to "nobody".
-- The activity log is the audit trail; this column is a convenience beside it.
CREATE TABLE IF NOT EXISTS rust_perm_group_members (
group_name VARCHAR(64) NOT NULL,
user_id INT NOT NULL,
added_by INT NULL,
added_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (group_name, user_id),
KEY idx_rust_perm_members_user (user_id),
CONSTRAINT fk_rust_perm_members_group
FOREIGN KEY (group_name) REFERENCES rust_perm_groups (name) ON DELETE CASCADE,
CONSTRAINT fk_rust_perm_members_user
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
-- ── Direct grants ─────────────────────────────────────────────────────────
--
-- A permission held by one person, without a group. It is not a lesser version
-- of membership: it is the shape that reaches a player who has never connected
-- to that server, which is exactly what an entitlement earned on the website at
-- three in the morning has to do (R16).
--
-- `source` is why this table does not need changing in phase 13. Every later
-- author — an event action granting the right to redeem a kit, a lease handing
-- out a weekend group — writes a row here with its own source rather than a
-- store of its own, so there is one answer to "why does this player have this"
-- and one place the push reads.
CREATE TABLE IF NOT EXISTS rust_perm_grants (
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
permission VARCHAR(128) NOT NULL,
scope VARCHAR(64) NOT NULL DEFAULT '*',
source VARCHAR(32) NOT NULL DEFAULT 'admin',
note VARCHAR(255) NULL,
granted_by INT NULL,
granted_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_rust_perm_grant (user_id, permission, scope),
KEY idx_rust_perm_grant_user (user_id),
CONSTRAINT fk_rust_perm_grants_user
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
-- ── What this site has actually put in each game ──────────────────────────
--
-- The site's memory of its own authorship, one row per thing it has confirmed
-- into one server's store. It is the table that makes D31 possible at all.
--
-- Three sets, and every interesting question is the difference between two of
-- them:
--
-- desired pushed what to apply
-- pushed desired what to RETIRE, because the site put it there and has
-- since withdrawn it
-- present desired drift: somebody else put it there
--
-- Without the middle row a withdrawn grant is indistinguishable from a hand
-- edit, and those two have opposite correct answers. Inferring it from absence
-- is the mistake this table exists to prevent.
--
-- It is keyed by Steam id rather than by user, because it records what is in the
-- GAME, and the game has never heard of a website account. Unlinking an account
-- therefore leaves its row here until the next sync retires it — which is the
-- correct behaviour and would be impossible to express keyed the other way.
CREATE TABLE IF NOT EXISTS rust_perm_pushed (
server_id VARCHAR(64) NOT NULL,
-- `grant` | `member` | `group-permission` | `group`
kind VARCHAR(24) NOT NULL,
-- a Steam id, or a group name
subject VARCHAR(64) NOT NULL,
-- a permission, a group name, or '' for the existence of a group
object VARCHAR(128) NOT NULL,
pushed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (server_id, kind, subject, object),
CONSTRAINT fk_rust_perm_pushed_server
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE
);
-- ── Drift ─────────────────────────────────────────────────────────────────
--
-- What a sync found in a server's store that the site did not author, within
-- the namespace the site claims. Rows appear and disappear with the report:
-- this is the CURRENT difference, not a history of differences, and a hand edit
-- that somebody has since removed should stop being on the screen.
--
-- Nothing here is ever removed from the game by the sync itself. An operator
-- typing `oxide.grant` during an incident is drift, not an error, and the two
-- answers offered to them — adopt it, or revoke it — are both a person's
-- decision.
CREATE TABLE IF NOT EXISTS rust_perm_drift (
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
server_id VARCHAR(64) NOT NULL,
kind VARCHAR(24) NOT NULL,
subject VARCHAR(64) NOT NULL,
object VARCHAR(128) NOT NULL,
first_seen DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_seen DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_rust_perm_drift (server_id, kind, subject, object),
CONSTRAINT fk_rust_perm_drift_server
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE
);
-- ── Removing something the site never put there ───────────────────────────
--
-- Revoking a drift row cannot go through `rust_perm_pushed`, because the whole
-- point of a drift row is that it was never pushed. It cannot go through the
-- authored tables either: a foreign grant often names a Steam id that belongs
-- to no website account at all, and there is no user to author it against.
--
-- So a revoke is its own instruction with its own lifetime: queued by a person,
-- carried in the next sync's retire list, and deleted once a report says the
-- game no longer has it. A server that is offline keeps the instruction until
-- it comes back, which is the behaviour an operator expects from a website that
-- claims to be the author of record.
CREATE TABLE IF NOT EXISTS rust_perm_revocations (
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
server_id VARCHAR(64) NOT NULL,
kind VARCHAR(24) NOT NULL,
subject VARCHAR(64) NOT NULL,
object VARCHAR(128) NOT NULL,
requested_by INT NULL,
requested_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_rust_perm_revocation (server_id, kind, subject, object),
CONSTRAINT fk_rust_perm_revocations_server
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE
);
-- ── The state of the mirror, per server ───────────────────────────────────
--
-- One row per configured server: whether its store currently matches what the
-- site authors, when that was last true, and what the last report said.
--
-- `dirty` is how everything that should provoke a sync says so without knowing
-- anything about syncing: an admin writing a grant, a drift hook firing in the
-- game, a server reporting a new boot id or a new wipe. The loop owns WHEN, and
-- every other part of the module owns WHETHER.
--
-- `desired_hash` and `synced_hash` are the cheap half of that question. A loop
-- that pushed the whole set every tick would work and would also write to six
-- game servers every thirty seconds for ever; comparing a hash costs one query
-- and skips the round trip when nothing has changed. The periodic audit below
-- is what keeps that from being a way to never notice drift.
CREATE TABLE IF NOT EXISTS rust_perm_sync (
server_id VARCHAR(64) NOT NULL PRIMARY KEY,
-- `pending` | `ok` | `failed`
state VARCHAR(24) NOT NULL DEFAULT 'pending',
dirty TINYINT(1) NOT NULL DEFAULT 1,
desired_hash VARCHAR(64) NULL,
synced_hash VARCHAR(64) NULL,
boot_id VARCHAR(64) NULL,
wipe_id VARCHAR(48) NULL,
last_attempt_at DATETIME NULL,
last_ok_at DATETIME NULL,
report LONGTEXT NULL,
error VARCHAR(191) NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_rust_perm_sync_server
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE
);
-- ── What each server's plugins have registered ────────────────────────────
--
-- The option source the authoring form offers (D33), cached from the live read
-- so that opening the form is not six round trips to six game hosts.
--
-- It is a cache of a fact that changes when an operator loads a plugin, and it
-- is refreshed on every sync — which is also why a name that has stopped being
-- registered disappears from the form rather than lingering as a choice that
-- silently does nothing.
CREATE TABLE IF NOT EXISTS rust_perm_catalogue (
server_id VARCHAR(64) NOT NULL,
permission VARCHAR(128) NOT NULL,
seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (server_id, permission),
CONSTRAINT fk_rust_perm_catalogue_server
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE
);
-- ── Changes to tables that already shipped ────────────────────────────────
--
-- An ALTER below the CREATE, never an edit to it: `CREATE TABLE IF NOT EXISTS`

View File

@@ -35,6 +35,7 @@ const core = require('./core')
const db = require('./model/events/events.db')
const links = require('./model/links/links.model')
const permissionsDb = require('./model/permissions/permissions.db')
const sidecar = require('./sidecarClient')
const log = core.logger('ingest')
@@ -173,6 +174,23 @@ async function apply(serverId, item) {
await db.touchPlayer(frame.steamId, frame.name || null)
break
// ── Protocol 4: somebody changed the permission store, and it was not us ──
//
// The plugin raises this only for writes it did not make itself — its own
// sync suppresses the hooks while it applies (PROTOCOL.md §10.4). What
// arrives here is therefore a hand edit, a console command, or another
// plugin granting something.
//
// **It is a reason to reconcile, not the reconciliation.** This frame cannot
// say whether the change is foreign: only the desired set can, and that
// comparison happens in the sync. So the server is marked dirty and the next
// tick produces the authoritative answer — which means a hook that stops
// firing on a framework upgrade costs latency and nothing else. The audit
// interval finds the same drift within fifteen minutes either way.
case 'perm.drift':
await permissionsDb.markDirty(serverId)
break
default:
// Stored, not counted. Moderation frames, the server lifecycle, and
// anything a newer protocol sends that this build does not understand.

View File

@@ -0,0 +1,459 @@
// ── SQL for the permission mirror, and nothing else ───────────────────────
//
// The tables this file reads are described at length in `db/schema.sql`; what
// matters here is which of them is authoritative for what, because four of the
// eight look similar and answer completely different questions:
//
// AUTHORED `rust_perm_groups`, `..._group_permissions`, `..._group_members`,
// `rust_perm_grants` — what an operator (and later an event) says
// should be true. Keyed by WEBSITE USER (D28).
// PUSHED `rust_perm_pushed` — what this site has confirmed into one game's
// store. Keyed by STEAM ID, because it records what is in the game
// and the game has never heard of a website account.
// FOUND `rust_perm_drift` — what a sync found that the site did not
// author. Replaced whole by each report: it is the current
// difference, not a history of differences.
// INSTRUCTED `rust_perm_revocations` — remove this, even though we never put
// it there. The only way to act on drift, since a foreign grant
// often names a Steam id no website account holds.
//
// Raw parameterised SQL through `core.query`, no ORM, like every other `.db.js`
// here. Bulk writes are batched into one statement with a generated placeholder
// list rather than looped, because a fleet-wide sync writes hundreds of rows and
// a round trip each is how a boot tick becomes a second long.
const core = require('../../core')
const GROUPS = 'rust_perm_groups'
const GROUP_PERMISSIONS = 'rust_perm_group_permissions'
const GROUP_MEMBERS = 'rust_perm_group_members'
const GRANTS = 'rust_perm_grants'
const PUSHED = 'rust_perm_pushed'
const DRIFT = 'rust_perm_drift'
const REVOCATIONS = 'rust_perm_revocations'
const SYNC = 'rust_perm_sync'
const CATALOGUE = 'rust_perm_catalogue'
const LINKS = 'rust_account_links'
const SERVERS = 'rust_servers'
/** `(?,?,?),(?,?,?)` for `rows.length` rows of `width` columns. */
function placeholders(rows, width) {
return rows.map(() => `(${new Array(width).fill('?').join(',')})`).join(',')
}
// ---- the authored set ----
async function listGroups() {
return core.query(
`SELECT name, title, \`rank\`, scope, created_at AS createdAt, updated_at AS updatedAt
FROM ${GROUPS}
ORDER BY \`rank\` DESC, name ASC`,
)
}
async function getGroup(name) {
const rows = await core.query(
`SELECT name, title, \`rank\`, scope FROM ${GROUPS} WHERE name = ?`,
[name],
)
return rows[0] || null
}
/**
* Create or update one group.
*
* `ON DUPLICATE KEY UPDATE` rather than a check-then-write: two admins on the
* same screen is not a race worth losing a title over, and the row's identity is
* its name either way.
*/
async function upsertGroup({ name, title, rank, scope }) {
await core.query(
`INSERT INTO ${GROUPS} (name, title, \`rank\`, scope)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE title = VALUES(title), \`rank\` = VALUES(\`rank\`),
scope = VALUES(scope), updated_at = CURRENT_TIMESTAMP`,
[name, title, rank, scope],
)
}
async function deleteGroup(name) {
const result = await core.query(`DELETE FROM ${GROUPS} WHERE name = ?`, [name])
return Number(result.affectedRows || 0) > 0
}
async function listGroupPermissions() {
return core.query(
`SELECT group_name AS groupName, permission FROM ${GROUP_PERMISSIONS} ORDER BY permission ASC`,
)
}
/** Replace a group's permission list whole. The form edits a list, so the write is a list. */
async function setGroupPermissions(name, permissions) {
await core.query(`DELETE FROM ${GROUP_PERMISSIONS} WHERE group_name = ?`, [name])
if (!permissions.length) return
await core.query(
`INSERT INTO ${GROUP_PERMISSIONS} (group_name, permission)
VALUES ${placeholders(permissions, 2)}`,
permissions.flatMap((permission) => [name, permission]),
)
}
/**
* Every membership, with the member's Steam accounts joined on.
*
* One query rather than a membership read plus a link read per member: the admin
* screen renders both together and the push needs both together, and a fleet's
* worth of members is one round trip either way.
*/
async function listGroupMembers() {
return core.query(
`SELECT m.group_name AS groupName, m.user_id AS userId, m.added_at AS addedAt,
u.username, l.steam_id AS steamId, p.name AS playerName
FROM ${GROUP_MEMBERS} m
JOIN users u ON u.id = m.user_id
LEFT JOIN ${LINKS} l ON l.user_id = m.user_id
LEFT JOIN rust_players p ON p.steam_id = l.steam_id
ORDER BY m.group_name ASC, u.username ASC`,
)
}
async function addGroupMember(groupName, userId, addedBy) {
await core.query(
`INSERT IGNORE INTO ${GROUP_MEMBERS} (group_name, user_id, added_by) VALUES (?, ?, ?)`,
[groupName, userId, addedBy],
)
}
async function removeGroupMember(groupName, userId) {
const result = await core.query(
`DELETE FROM ${GROUP_MEMBERS} WHERE group_name = ? AND user_id = ?`,
[groupName, userId],
)
return Number(result.affectedRows || 0) > 0
}
/**
* Every direct grant, with the holder's accounts joined on.
*
* `username` is on the row because a grant with no linked Steam account still
* has to be listable and nameable — that state is the one the admin screen most
* needs to show, since it looks exactly like a working grant from every other
* angle and reaches nobody.
*/
async function listGrants({ userId = null } = {}) {
return core.query(
`SELECT g.id, g.user_id AS userId, g.permission, g.scope, g.source, g.note,
g.granted_at AS grantedAt, u.username,
l.steam_id AS steamId, p.name AS playerName
FROM ${GRANTS} g
JOIN users u ON u.id = g.user_id
LEFT JOIN ${LINKS} l ON l.user_id = g.user_id
LEFT JOIN rust_players p ON p.steam_id = l.steam_id
${userId === null ? '' : 'WHERE g.user_id = ?'}
ORDER BY u.username ASC, g.permission ASC`,
userId === null ? [] : [userId],
)
}
async function getGrant(id) {
const rows = await core.query(
`SELECT id, user_id AS userId, permission, scope, source FROM ${GRANTS} WHERE id = ?`,
[id],
)
return rows[0] || null
}
/**
* Add a grant, or leave the one that is already there alone.
*
* `INSERT IGNORE` against the unique key, and the return says which happened —
* the controller needs to tell "granted" from "they already had it" to write an
* honest activity row.
*/
async function insertGrant({ userId, permission, scope, source, note, grantedBy }) {
const result = await core.query(
`INSERT IGNORE INTO ${GRANTS} (user_id, permission, scope, source, note, granted_by)
VALUES (?, ?, ?, ?, ?, ?)`,
[userId, permission, scope, source, note, grantedBy],
)
return { inserted: Number(result.affectedRows || 0) > 0, id: result.insertId }
}
async function deleteGrant(id) {
const result = await core.query(`DELETE FROM ${GRANTS} WHERE id = ?`, [id])
return Number(result.affectedRows || 0) > 0
}
/**
* One website account by name, for the authoring form.
*
* A form that made an operator type a numeric user id would be a form nobody
* could use, and the alternative — calling core's own admin user search from the
* client — would bind this module to the shape of a response the contract does
* not cover. Reading the `users` table is already what every join in this file
* does.
*
* Case-insensitive because the column's collation is: core stores usernames in a
* `_ci` collation and an exact-case lookup would refuse a name the site itself
* considers the same one.
*/
async function findUserByUsername(username) {
const rows = await core.query(`SELECT id, username FROM users WHERE username = ? LIMIT 1`, [username])
return rows[0] || null
}
/** Which website user holds which Steam account. The join that turns an authored row into a push. */
async function listLinks() {
return core.query(`SELECT user_id AS userId, steam_id AS steamId FROM ${LINKS}`)
}
// ---- what is actually out there ----
async function listPushed(serverId) {
return core.query(
`SELECT kind, subject, object FROM ${PUSHED} WHERE server_id = ?`,
[serverId],
)
}
async function addPushed(serverId, rows) {
if (!rows.length) return
await core.query(
`INSERT IGNORE INTO ${PUSHED} (server_id, kind, subject, object)
VALUES ${placeholders(rows, 4)}`,
rows.flatMap((row) => [serverId, row.kind, row.subject, row.object]),
)
}
async function removePushed(serverId, rows) {
for (const row of rows) {
// eslint-disable-next-line no-await-in-loop
await core.query(
`DELETE FROM ${PUSHED} WHERE server_id = ? AND kind = ? AND subject = ? AND object = ?`,
[serverId, row.kind, row.subject, row.object],
)
}
}
/**
* Replace one server's drift list with what the latest report found.
*
* Whole, rather than merged, and `first_seen` survives through the
* `ON DUPLICATE KEY UPDATE` — so "this has been here since Tuesday" is still
* answerable while "somebody has since undone it" removes the row.
*/
async function replaceDrift(serverId, rows) {
if (!rows.length) {
await core.query(`DELETE FROM ${DRIFT} WHERE server_id = ?`, [serverId])
return
}
await core.query(
`INSERT INTO ${DRIFT} (server_id, kind, subject, object)
VALUES ${placeholders(rows, 4)}
ON DUPLICATE KEY UPDATE last_seen = CURRENT_TIMESTAMP`,
rows.flatMap((row) => [serverId, row.kind, row.subject, row.object]),
)
// Anything this report did NOT name is gone from the game, so it goes from
// here. Named explicitly rather than swept by timestamp: two syncs a second
// apart would make a timestamp window either delete live rows or keep dead
// ones, depending on the clock.
await core.query(
`DELETE FROM ${DRIFT}
WHERE server_id = ?
AND (kind, subject, object) NOT IN (${placeholders(rows, 3)})`,
[serverId, ...rows.flatMap((row) => [row.kind, row.subject, row.object])],
)
}
async function listDrift() {
return core.query(
`SELECT d.id, d.server_id AS serverId, d.kind, d.subject, d.object,
d.first_seen AS firstSeen, d.last_seen AS lastSeen,
l.user_id AS userId, u.username, p.name AS playerName
FROM ${DRIFT} d
LEFT JOIN ${LINKS} l ON l.steam_id = d.subject
LEFT JOIN users u ON u.id = l.user_id
LEFT JOIN rust_players p ON p.steam_id = d.subject
ORDER BY d.server_id ASC, d.kind ASC, d.subject ASC`,
)
}
async function getDrift(id) {
const rows = await core.query(
`SELECT id, server_id AS serverId, kind, subject, object FROM ${DRIFT} WHERE id = ?`,
[id],
)
return rows[0] || null
}
async function deleteDrift(id) {
await core.query(`DELETE FROM ${DRIFT} WHERE id = ?`, [id])
}
async function queueRevocation({ serverId, kind, subject, object, requestedBy }) {
await core.query(
`INSERT IGNORE INTO ${REVOCATIONS} (server_id, kind, subject, object, requested_by)
VALUES (?, ?, ?, ?, ?)`,
[serverId, kind, subject, object, requestedBy],
)
}
async function listRevocations(serverId) {
return core.query(
`SELECT id, kind, subject, object FROM ${REVOCATIONS} WHERE server_id = ?`,
[serverId],
)
}
async function deleteRevocations(ids) {
if (!ids.length) return
await core.query(
`DELETE FROM ${REVOCATIONS} WHERE id IN (${ids.map(() => '?').join(',')})`,
ids,
)
}
// ---- the state of the mirror ----
/**
* One sync row per configured server, created on demand.
*
* A server added today has no row and must not therefore be skipped for ever, so
* the read inserts what is missing rather than the writer remembering to.
*/
async function ensureSyncRows() {
await core.query(
`INSERT IGNORE INTO ${SYNC} (server_id) SELECT id FROM ${SERVERS}`,
)
}
async function listSync() {
return core.query(
`SELECT s.server_id AS serverId, s.state, s.dirty, s.desired_hash AS desiredHash,
s.synced_hash AS syncedHash, s.boot_id AS bootId, s.wipe_id AS wipeId,
s.last_attempt_at AS lastAttemptAt, s.last_ok_at AS lastOkAt,
s.report, s.error
FROM ${SYNC} s
ORDER BY s.server_id ASC`,
)
}
/**
* Mark servers as needing a sync.
*
* `scope` is a server id or `*`; a fleet-wide change dirties every row, which is
* right: the set each server should hold has changed even if only one of them
* will notice a difference.
*/
async function markDirty(scope) {
if (!scope || scope === '*') {
await core.query(`UPDATE ${SYNC} SET dirty = 1, updated_at = CURRENT_TIMESTAMP`)
return
}
await core.query(
`UPDATE ${SYNC} SET dirty = 1, updated_at = CURRENT_TIMESTAMP WHERE server_id = ?`,
[scope],
)
}
/**
* Record the outcome of one attempt.
*
* **`dirty` is cleared unconditionally, and that is safe because it is an
* optimisation rather than the truth.** Something may well have changed the
* authored set while this sync was in flight, and clearing the flag would then
* lose that change — except that the loop's real condition is
* `desired_hash != synced_hash`, recomputed from the tables on every tick. The
* flag only saves a hash comparison; the hash is what cannot be wrong.
*
* `last_ok_at` moves only on success, and it is passed rather than composed into
* the SQL so the statement is the same string every time.
*/
async function putSyncResult(serverId, { state, syncedHash, desiredHash, bootId, wipeId, report, error }) {
const okAt = state === 'ok' ? new Date() : null
await core.query(
`INSERT INTO ${SYNC} (server_id, state, dirty, desired_hash, synced_hash, boot_id, wipe_id,
last_attempt_at, last_ok_at, report, error, updated_at)
VALUES (?, ?, 0, ?, ?, ?, ?, NOW(), ?, ?, ?, NOW())
ON DUPLICATE KEY UPDATE state = VALUES(state), dirty = 0,
desired_hash = VALUES(desired_hash),
synced_hash = VALUES(synced_hash),
boot_id = VALUES(boot_id), wipe_id = VALUES(wipe_id),
last_attempt_at = NOW(),
last_ok_at = COALESCE(VALUES(last_ok_at), last_ok_at),
report = VALUES(report), error = VALUES(error),
updated_at = NOW()`,
[serverId, state, desiredHash, syncedHash, bootId, wipeId, okAt, report, error],
)
}
// ---- the option source ----
async function putCatalogue(serverId, permissions) {
await core.query(`DELETE FROM ${CATALOGUE} WHERE server_id = ?`, [serverId])
if (!permissions.length) return
await core.query(
`INSERT IGNORE INTO ${CATALOGUE} (server_id, permission)
VALUES ${placeholders(permissions, 2)}`,
permissions.flatMap((permission) => [serverId, permission]),
)
}
async function listCatalogue() {
return core.query(
`SELECT server_id AS serverId, permission FROM ${CATALOGUE} ORDER BY permission ASC`,
)
}
module.exports = {
GROUPS,
GRANTS,
PUSHED,
DRIFT,
listGroups,
getGroup,
upsertGroup,
deleteGroup,
listGroupPermissions,
setGroupPermissions,
listGroupMembers,
addGroupMember,
removeGroupMember,
listGrants,
getGrant,
insertGrant,
deleteGrant,
findUserByUsername,
listLinks,
listPushed,
addPushed,
removePushed,
replaceDrift,
listDrift,
getDrift,
deleteDrift,
queueRevocation,
listRevocations,
deleteRevocations,
ensureSyncRows,
listSync,
markDirty,
putSyncResult,
putCatalogue,
listCatalogue,
}

View File

@@ -0,0 +1,356 @@
// ── The authored set, and what it means for one server ────────────────────
//
// This file turns "what an operator wrote on the website" into "what one game
// server's store should contain", which is where four of phase 7's decisions
// actually live:
//
// D28 a grant is authored against a WEBSITE USER and resolved to every Steam
// id they have linked, here, at the moment of the push.
// D29 every authored row carries a scope — one server, or `*` for the fleet —
// and a server sees only what names it.
// D30 groups travel as groups. Membership is a separate wire fact from the
// permissions the group carries, because the game stores them separately
// and one of the two can fail on its own (§12.2 rule 4).
// D31 the difference between the desired set and what this site has already
// pushed is what gets retired. Anything else in the store is drift, and
// drift is reported rather than undone.
//
// Nothing here talks to a sidecar — `permSync.js` does that. The split is the
// usual one and earns its keep twice over here: the whole of the interesting
// logic is a pure function of four tables, so it is tested without a game, a
// sidecar, or a database.
const crypto = require('node:crypto')
const db = require('./permissions.db')
/** A scope that means every server. Stored, rather than null, so the column never needs a coalesce. */
const FLEET = '*'
/**
* Permission and group names, as both frameworks store them.
*
* Lowercased on the way in, because the store lowers them and a site that did
* not would author `Kits.VIP`, push it, read back `kits.vip`, and report its own
* grant as drift for ever.
*/
function normaliseName(value) {
return String(value || '').trim().toLowerCase()
}
/** Whether a scope reaches a server. */
function inScope(scope, serverId) {
return scope === FLEET || scope === serverId
}
/**
* Everything the authoring screen renders, in one read.
*
* Assembled here rather than in SQL because the shape is a tree — a group with
* its permissions and its members — and the alternative is either four round
* trips per group or one join that repeats every group row once per member.
*/
async function overview() {
const [groups, groupPermissions, members, grants, sync, drift, catalogue] = await Promise.all([
db.listGroups(),
db.listGroupPermissions(),
db.listGroupMembers(),
db.listGrants(),
db.listSync(),
db.listDrift(),
db.listCatalogue(),
])
const byGroup = new Map(groups.map((group) => [group.name, { ...group, permissions: [], members: [] }]))
for (const row of groupPermissions) {
const group = byGroup.get(row.groupName)
if (group) group.permissions.push(row.permission)
}
// A member with two linked Steam accounts arrives as two rows from the join,
// and is one person on the screen — holding BOTH accounts, not the first one
// the join happened to return. The screen needs all of them: a membership is
// pushed per account, and it can be waiting on one while it landed on another.
const memberByKey = new Map()
for (const row of members) {
const group = byGroup.get(row.groupName)
if (!group) continue
const key = `${row.groupName}:${row.userId}`
let member = memberByKey.get(key)
if (!member) {
member = {
userId: row.userId,
username: row.username,
accounts: [],
addedAt: row.addedAt,
}
memberByKey.set(key, member)
group.members.push(member)
}
if (row.steamId) member.accounts.push({ steamId: row.steamId, name: row.playerName || null })
}
return {
groups: [...byGroup.values()],
grants: collapseGrants(grants),
servers: sync.map(shapeSync),
drift,
catalogue: catalogueByPermission(catalogue),
}
}
/**
* One row per grant, not one per linked account.
*
* The join in `listGrants` multiplies a grant by the holder's accounts, which is
* what the push wants and the opposite of what a screen wants.
*/
function collapseGrants(rows) {
const byId = new Map()
for (const row of rows) {
const existing = byId.get(row.id)
if (!existing) {
byId.set(row.id, {
id: row.id,
userId: row.userId,
username: row.username,
permission: row.permission,
scope: row.scope,
source: row.source,
note: row.note,
grantedAt: row.grantedAt,
accounts: row.steamId ? [{ steamId: row.steamId, name: row.playerName || null }] : [],
})
continue
}
if (row.steamId) existing.accounts.push({ steamId: row.steamId, name: row.playerName || null })
}
return [...byId.values()]
}
/**
* The sync row as a client reads it.
*
* `report` is stored as the JSON the game sent and parsed here rather than on the
* way in, so a report this build cannot read is a rendering problem on one
* screen instead of a write that failed.
*/
function shapeSync(row) {
let report = null
if (row.report) {
try {
report = JSON.parse(row.report)
} catch {
report = null
}
}
return {
serverId: row.serverId,
state: row.state,
dirty: Boolean(row.dirty),
inSync: Boolean(row.desiredHash) && row.desiredHash === row.syncedHash && row.state === 'ok',
lastAttemptAt: row.lastAttemptAt,
lastOkAt: row.lastOkAt,
error: row.error || null,
report,
}
}
/** Which servers know each permission name — the form's option source, and its warning label. */
function catalogueByPermission(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, servers]) => ({ permission, servers }))
.sort((a, b) => a.permission.localeCompare(b.permission))
}
/**
* The whole authored set, read once, in the shape the per-server build wants.
*
* Read once per sync tick rather than once per server: six servers is six
* different answers derived from one set of tables, and re-reading them per
* server is six times the queries for the same rows.
*/
async function readAuthored() {
const [groups, groupPermissions, members, grants, links] = await Promise.all([
db.listGroups(),
db.listGroupPermissions(),
db.listGroupMembers(),
db.listGrants(),
db.listLinks(),
])
const steamIdsByUser = new Map()
for (const link of links) {
if (!steamIdsByUser.has(link.userId)) steamIdsByUser.set(link.userId, [])
steamIdsByUser.get(link.userId).push(link.steamId)
}
return { groups, groupPermissions, members, grants, steamIdsByUser }
}
/**
* What one server's store should contain, and the rows that say so.
*
* Returns three things the caller needs together and must not compute twice:
*
* `payload` what goes on the wire
* `rows` the same set in `rust_perm_pushed`'s shape, for the diff
* `hash` a stable digest of `rows`, which is how the loop knows nothing
* has changed without asking a game server
*
* **A user with no linked Steam account contributes nothing and is not an
* error.** They are authored against perfectly well and reach nobody until they
* link — which the admin screen says out loud, because a grant that reaches
* nothing looks exactly like one that worked.
*/
function buildDesired(serverId, authored) {
const { groups, groupPermissions, members, grants, steamIdsByUser } = authored
const scopedGroups = groups.filter((group) => inScope(group.scope, serverId))
const groupNames = new Set(scopedGroups.map((group) => group.name))
const permissionsByGroup = new Map(scopedGroups.map((group) => [group.name, []]))
const membersByGroup = new Map(scopedGroups.map((group) => [group.name, []]))
const managed = new Set()
const rows = []
for (const group of scopedGroups)
rows.push({ kind: 'group', subject: group.name, object: '' })
for (const row of groupPermissions) {
if (!groupNames.has(row.groupName)) continue
const permission = normaliseName(row.permission)
permissionsByGroup.get(row.groupName).push(permission)
managed.add(permission)
rows.push({ kind: 'group-permission', subject: row.groupName, object: permission })
}
const seenMember = new Set()
for (const row of members) {
if (!groupNames.has(row.groupName)) continue
for (const steamId of steamIdsByUser.get(row.userId) || []) {
const key = `${row.groupName}:${steamId}`
if (seenMember.has(key)) continue
seenMember.add(key)
membersByGroup.get(row.groupName).push(steamId)
rows.push({ kind: 'member', subject: steamId, object: row.groupName })
}
}
const permissionsBySteamId = new Map()
const seenGrant = new Set()
for (const row of grants) {
if (!inScope(row.scope, serverId)) continue
const permission = normaliseName(row.permission)
// Managed whether or not it reaches anybody: the namespace is what makes a
// hand grant of this permission to somebody else show up as drift, and a
// grant whose holder has linked nothing would otherwise silently narrow it.
managed.add(permission)
// **Resolved from the link map, not from the row.** `listGrants` joins the
// links and therefore repeats a grant once per linked account, which would
// give the right answer here by accident — until somebody changes that query
// and one of a person's two accounts quietly stops being granted. The map is
// the same source the members above use, and it says what it means.
for (const steamId of steamIdsByUser.get(row.userId) || []) {
const key = `${steamId}:${permission}`
if (seenGrant.has(key)) continue
seenGrant.add(key)
if (!permissionsBySteamId.has(steamId)) permissionsBySteamId.set(steamId, [])
permissionsBySteamId.get(steamId).push(permission)
rows.push({ kind: 'grant', subject: steamId, object: permission })
}
}
const payload = {
groups: scopedGroups.map((group) => ({
name: group.name,
title: group.title || group.name,
rank: group.rank,
permissions: permissionsByGroup.get(group.name),
members: membersByGroup.get(group.name),
})),
grants: [...permissionsBySteamId.entries()].map(([steamId, permissions]) => ({
steamId,
permissions,
})),
managed: [...managed].sort(),
}
return { payload, rows, hash: hashRows(rows) }
}
/**
* A digest of the desired set.
*
* Sorted before hashing, because the rows come out of several queries in an
* order nothing guarantees — an unsorted digest would differ between two reads
* of an unchanged set and push to every game server on every tick.
*/
function hashRows(rows) {
const canonical = rows
.map((row) => `${row.kind}${row.subject}${row.object}`)
.sort()
.join('\n')
return crypto.createHash('sha256').update(canonical).digest('hex')
}
/** A row's identity, for set arithmetic against what was pushed. */
const rowKey = (row) => `${row.kind}${row.subject}${row.object}`
/**
* What this site put in a server and has since withdrawn.
*
* `pushed desired`, and it is the one calculation that cannot be replaced by
* asking the game: 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 have
* opposite correct answers (D31). Only the pushed ledger tells them apart.
*/
function retirements(pushed, desiredRows) {
const desired = new Set(desiredRows.map(rowKey))
return pushed.filter((row) => !desired.has(rowKey(row)))
}
module.exports = {
FLEET,
normaliseName,
inScope,
overview,
readAuthored,
buildDesired,
retirements,
hashRows,
rowKey,
collapseGrants,
shapeSync,
}

342
server/permSync.js Normal file
View File

@@ -0,0 +1,342 @@
// ── Keeping a game's permission store equal to what the site authored ─────
//
// R2's whole mechanism, and it is chapter 4's board pointed the other way: the
// site is the single producer of a set, it re-sends the whole thing rather than
// a stream of edits, and the receiver reconciles. What is new is the direction —
// the module telling the game what the site knows, where every earlier phase
// asked the game what it knew.
//
// ── One verb (D32) ────────────────────────────────────────────────────────
//
// A sync sends the whole desired set and the plugin diffs it against the live
// store. The website never holds a copy of the game's permissions, which is the
// point: a second source of truth is stale the moment it lands, and the store is
// the bigger of the two sets.
//
// The delta the site DOES compute is the one the game cannot: what this site put
// there and has since withdrawn (`retirements`). A name in the store that is not
// in the desired set is either that, or a hand edit — and only the pushed ledger
// can tell them apart (D31).
//
// ── When it runs ──────────────────────────────────────────────────────────
//
// Every tick asks a cheap question — does the digest of the desired set still
// equal what this server last confirmed — and does nothing when the answer is
// yes. A sync therefore happens when:
//
// • an operator changed something (the dirty flag, and the digest behind it)
// • the game restarted or wiped (a new boot id or wipe id: the store may have
// been emptied, and R2's promise is that a wipe is not a data-loss event)
// • a permission hook fired in the game that we did not cause (`ingest.js`
// marks the server dirty; the authoritative answer is this sync's report)
// • the audit interval elapsed — the backstop that finds drift on a quiet
// server nobody has touched
// • the last attempt failed, after a backoff
//
// ── What it never does ────────────────────────────────────────────────────
//
// It does not remove a grant it did not make (D31), it does not invent a
// permission the server has not registered (D33), and it does not treat a
// silent sidecar as a reason to forget anything. A server that is unreachable
// keeps its retirements and its revocations until it comes back.
const core = require('./core')
const db = require('./model/permissions/permissions.db')
const model = require('./model/permissions/permissions.model')
const servers = require('./model/servers/servers.model')
const serversDb = require('./model/servers/servers.db')
const sidecar = require('./sidecarClient')
const log = core.logger('permissions')
/** How often the loop asks whether anything needs pushing. */
const TICK_MS = 30 * 1000
/**
* How long a server may go without a full reconciliation, however quiet it is.
*
* The digest comparison is what keeps the loop cheap, and on its own it would
* also mean a server whose store somebody edited by hand is never asked about
* again. This is the interval at which the question gets asked anyway.
*/
const AUDIT_MS = 15 * 60 * 1000
/** How long to leave a failing server alone before trying again. */
const FAIL_BACKOFF_MS = 2 * 60 * 1000
/**
* The most rows one sync may carry.
*
* Below the sidecar's line cap and below the plugin's operation ceiling, so the
* refusal happens here — where it can name the server and reach an operator —
* rather than as a `413` or a `too-large` from two processes away.
*/
const MAX_ROWS = 15000
let timer = null
function start() {
if (timer) return
timer = setInterval(() => {
tick().catch((err) => log.error('permission sync tick failed', { error: err.message }))
}, TICK_MS)
if (timer.unref) timer.unref()
}
function stop() {
if (!timer) return
clearInterval(timer)
timer = null
}
/**
* One pass over every enabled server.
*
* The authored set is read ONCE and handed to each server's build: six servers
* are six different answers derived from the same four tables, and re-reading
* them per server is six times the queries for identical rows.
*/
async function tick({ force = null } = {}) {
await db.ensureSyncRows()
const [rows, state, sync, authored] = await Promise.all([
servers.listForPolling(),
serversDb.listState(),
db.listSync(),
model.readAuthored(),
])
const syncById = new Map(sync.map((row) => [row.serverId, row]))
const stateById = new Map(state.map((row) => [row.serverId, row]))
// `allSettled`, for the same reason the board poll uses it: one unreachable
// host must not stop the other five being reconciled.
await Promise.allSettled(
rows
.filter((server) => force === null || force === server.id)
.map((server) =>
syncOne(server, {
authored,
sync: syncById.get(server.id) || null,
state: stateById.get(server.id) || null,
force: force !== null,
}),
),
)
}
/**
* Whether this server needs a push right now.
*
* Returns a reason rather than a boolean, because the reason is worth logging:
* "why did the website just write to my game server" is a question an operator
* asks, and `wipe` and `drift` are very different answers.
*/
function reasonToSync({ desiredHash, sync, state, force }) {
if (force) return 'requested'
if (!sync) return 'first'
if (sync.state !== 'ok' && sync.lastAttemptAt && age(sync.lastAttemptAt) < FAIL_BACKOFF_MS && !sync.dirty) {
return null
}
if (sync.state !== 'ok') return 'retry'
if (desiredHash !== sync.syncedHash) return 'changed'
if (sync.dirty) return 'dirty'
const bootId = state && state.bootId ? state.bootId : null
const wipeId = state && state.wipeId ? state.wipeId : null
// A restart or a wipe is the case R2 exists for: the game may have forgotten
// everything, and the site has not.
if (bootId && bootId !== sync.bootId) return 'restart'
if (wipeId && wipeId !== sync.wipeId) return 'wipe'
if (!sync.lastAttemptAt || age(sync.lastAttemptAt) >= AUDIT_MS) return 'audit'
return null
}
function age(value) {
const at = value instanceof Date ? value.getTime() : new Date(value).getTime()
return Number.isFinite(at) ? Date.now() - at : Number.MAX_SAFE_INTEGER
}
async function syncOne(server, { authored, sync, state, force }) {
const desired = model.buildDesired(server.id, authored)
const reason = reasonToSync({ desiredHash: desired.hash, sync, state, force })
if (!reason) return null
const [pushed, revocations] = await Promise.all([
db.listPushed(server.id),
db.listRevocations(server.id),
])
const retirements = model.retirements(pushed, desired.rows)
const retire = [
...retirements.map((row) => ({ kind: row.kind, subject: row.subject, object: row.object })),
...revocations.map((row) => ({ kind: row.kind, subject: row.subject, object: row.object })),
]
const bootId = state && state.bootId ? state.bootId : null
const wipeId = state && state.wipeId ? state.wipeId : null
if (desired.rows.length + retire.length > MAX_ROWS) {
// Refused here rather than sent: the sidecar would answer `413` and the
// plugin would answer `too-large`, and neither of those messages reaches the
// person who has to make the set smaller.
const error = `the permission set is too large to push (${desired.rows.length + retire.length} rows, limit ${MAX_ROWS})`
log.error('permission sync refused', { server: server.id, rows: desired.rows.length })
await db.putSyncResult(server.id, {
state: 'failed',
desiredHash: desired.hash,
syncedHash: sync ? sync.syncedHash : null,
bootId,
wipeId,
report: null,
error,
})
return 'too-large'
}
log.info('syncing permissions', {
server: server.id,
reason,
rows: desired.rows.length,
retire: retire.length,
})
const result = await sidecar.permSync(server, {
setId: desired.hash,
groups: desired.payload.groups,
grants: desired.payload.grants,
managed: desired.payload.managed,
retire,
})
if (!result.ok) {
await db.putSyncResult(server.id, {
state: 'failed',
desiredHash: desired.hash,
syncedHash: sync ? sync.syncedHash : null,
bootId,
wipeId,
report: null,
error: result.status,
})
return result.status
}
const report = result.data || {}
// The plugin refuses a whole sync with `perm.error` — `busy` while an earlier
// one is still draining, `too-large` past its own ceiling. Both are answers
// rather than transport failures, exactly like a refused link code, so they
// arrive as a 200 and are told apart by `kind`.
if (report.kind === 'perm.error') {
await db.putSyncResult(server.id, {
state: 'failed',
desiredHash: desired.hash,
syncedHash: sync ? sync.syncedHash : null,
bootId,
wipeId,
report: null,
error: `the game refused the sync: ${report.reason || 'unknown'}`,
})
return report.reason || 'refused'
}
await applyReport(server, { desired, retire, report, bootId, wipeId })
return 'ok'
}
/**
* Record what the game said it did.
*
* Three writes, and the order matters only in that all three are safe to repeat:
* a sync that crashes here is re-run next tick and reaches the same place, which
* is the property that lets this loop be the only writer.
*/
async function applyReport(server, { desired, retire, report, bootId, wipeId }) {
const unresolved = new Set((report.unresolved || []).map(model.normaliseName))
const pending = new Set(report.pending || [])
// A grant naming a permission this server has not registered did NOT land —
// `GrantUserPermission` no-ops silently for an unregistered name, which is
// why the plugin pre-checks and says so. Recording it as pushed would make the
// site believe it had given a privilege it had not.
//
// The same for a member the store could not place: the membership is waiting
// on their first connection, and it is not in the game yet.
const landed = desired.rows.filter((row) => {
if (row.kind === 'grant' || row.kind === 'group-permission') return !unresolved.has(row.object)
if (row.kind === 'member') return !pending.has(`${row.subject}:${row.object}`)
return true
})
await db.addPushed(server.id, landed)
// Everything retired is gone from the game whether the plugin removed it or
// found it already absent, so it stops being something this site put there.
await db.removePushed(server.id, retire)
const revocations = await db.listRevocations(server.id)
await db.deleteRevocations(revocations.map((row) => row.id))
await db.replaceDrift(server.id, (report.foreign || []).map((row) => ({
kind: String(row.kind || ''),
subject: String(row.subject || ''),
object: String(row.object || ''),
})))
await db.putSyncResult(server.id, {
state: 'ok',
desiredHash: desired.hash,
syncedHash: desired.hash,
bootId,
wipeId,
report: JSON.stringify(report),
error: null,
})
// The option source, refreshed from the same server that just answered. It is
// a second round trip and it is worth it: the form must not offer a name that
// stopped being registered when somebody uninstalled a plugin, because a grant
// against one is a privilege nobody ever gets and nothing ever reports.
const catalogue = await sidecar.permCatalogue(server)
if (catalogue.ok && catalogue.data && Array.isArray(catalogue.data.permissions)) {
await db.putCatalogue(
server.id,
catalogue.data.permissions.map(model.normaliseName).filter(Boolean),
)
}
log.info('permissions synced', {
server: server.id,
applied: report.applied,
unresolved: (report.unresolved || []).length,
foreign: (report.foreign || []).length,
pending: (report.pending || []).length,
})
}
module.exports = {
TICK_MS,
AUDIT_MS,
FAIL_BACKOFF_MS,
MAX_ROWS,
start,
stop,
tick,
syncOne,
reasonToSync,
applyReport,
}

View 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,
}

View 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 loops 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

View File

@@ -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']

View File

@@ -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 users Rust permissions', { error: err.message })
res.status(500).json({ message: 'Failed to read this users 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 }

View File

@@ -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 users 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 mirrors 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 elses privilege. The revoke reaches the game on the mirrors 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

View File

@@ -76,6 +76,7 @@ const SLOT_MOUNT = {
'admin.users.detail': '/api/v1/admin/users/:id',
}
/**
* Run `register()` with a recording api and return `[{ file, prefix, what }]`.
*

View File

@@ -52,9 +52,11 @@ const TIMEOUT_MS = 12000
* here, `PROTOCOL_VERSION` in the sidecar, `ProtocolVersion` in the bridge
* plugin, and `protocol` in its `overlay.toml`.
*
* **3identity.** Protocol 2 was the read path; 3 adds the first message the
* WEBSITE originates (`link.confirm`) and the two account frames the plugin
* emits beside it. The bump lands here in the same change as the emitters,
* **4the permission mirror.** Protocol 2 was the read path, 3 the first
* message the WEBSITE originates (`link.confirm`); 4 is the first that WRITES
* to the game — the whole permission set the site authors for one server, and
* the report the plugin sends back. The bump lands here in the same change as
* the emitters,
* because the sidecar refuses a client declaring a different version with a
* `409`: a module left on 2 would stop being able to read the server board it
* has been reading all along. A constant that lags the deployment is not a safe
@@ -64,7 +66,7 @@ const TIMEOUT_MS = 12000
* deployment into a `409` naming both numbers instead of a parse failure three
* layers further in.
*/
const PROTOCOL_VERSION = 3
const PROTOCOL_VERSION = 4
/** What a caller gets back. Shaped once so every call site reads the same. */
function reply(ok, status, data = null) {
@@ -210,6 +212,33 @@ const feedTail = (server) => request(server, '/feed')
const confirmLink = (server, code) =>
request(server, '/link/confirm', { method: 'POST', body: { code } })
/**
* What one server's loaded plugins have registered, and the groups its store
* holds (protocol 4).
*
* The option source behind the authoring form (D33). It is a live read through
* to the game rather than anything cached at the sidecar, because the answer
* changes when an operator loads a plugin — and the whole reason to ask is to
* offer names that will actually resolve. It therefore fails when the game is
* down, like `/status` and unlike every store-backed read.
*/
const permCatalogue = (server) => request(server, '/permissions/catalogue')
/**
* Push the whole permission set this site authors for one server (protocol 4).
*
* **The second call in this file that is not a GET, and the first that changes
* the game.** The body is the desired set plus what the site has withdrawn; the
* plugin diffs it against the live store, applies the difference and answers
* with a report — counts, the names it could not resolve, the memberships that
* are waiting on a first connection, and every holder the site did not author.
*
* **A refusal comes back `{ ok: true }`**, like a refused link code: `perm.error`
* and `perm.report` are both answers, and the sidecar keeps its own status codes
* for the transport. The caller discriminates on `data.kind`.
*/
const permSync = (server, set) => request(server, '/permissions/sync', { method: 'POST', body: set })
module.exports = {
TIMEOUT_MS,
PROTOCOL_VERSION,
@@ -221,5 +250,7 @@ module.exports = {
feed,
feedTail,
confirmLink,
permCatalogue,
permSync,
joinUrl,
}

View File

@@ -195,6 +195,237 @@ module.exports = {
},
},
},
RustPermissionModel: {
type: 'object',
description:
'The whole permission model (GET /admin/rust/permissions): what the site authors, what each game reported back, and the names a grant may use.',
properties: {
groups: {
type: 'array',
description: 'Groups the site authors, mirrored into each in-scope game as a real group.',
items: {
type: 'object',
properties: {
name: { type: 'string', example: 'vip' },
title: { type: 'string', example: 'VIP' },
rank: { type: 'integer', example: 10 },
scope: {
type: 'string',
description: 'A server id, or `*` for every server.',
example: '*',
},
permissions: { type: 'array', items: { type: 'string', example: 'kits.vip' } },
members: {
type: 'array',
items: {
type: 'object',
properties: {
userId: { type: 'integer', example: 42 },
username: { type: 'string', example: 'wanderer' },
steamId: {
type: 'string',
nullable: true,
description: 'Null when this account has linked no Steam id, in which case the membership reaches nobody yet.',
example: '76561198000000000',
},
playerName: { type: 'string', nullable: true, example: 'Wanderer' },
},
},
},
},
},
},
grants: {
type: 'array',
description: 'Permissions held by one person without a group. Unlike membership, a direct grant reaches a player who has never connected.',
items: {
type: 'object',
properties: {
id: { type: 'integer', example: 7 },
userId: { type: 'integer', example: 42 },
username: { type: 'string', example: 'wanderer' },
permission: { type: 'string', example: 'kits.gold' },
scope: { type: 'string', example: 'main' },
source: {
type: 'string',
description: 'What authored it — `admin`, `adopted`, or a later phases own writer.',
example: 'admin',
},
note: { type: 'string', nullable: true, example: null },
grantedAt: { type: 'string', format: 'date-time' },
accounts: {
type: 'array',
description: 'The Steam accounts this grant reaches. Empty means it reaches nobody yet.',
items: {
type: 'object',
properties: {
steamId: { type: 'string', example: '76561198000000000' },
name: { type: 'string', nullable: true, example: 'Wanderer' },
},
},
},
},
},
},
servers: {
type: 'array',
description: 'The state of the mirror, per configured server.',
items: { $ref: '#/components/schemas/RustPermissionSyncState' },
},
drift: {
type: 'array',
description: 'What a game holds that the site did not author. Reported, never undone.',
items: {
type: 'object',
properties: {
id: { type: 'integer', example: 3 },
serverId: { type: 'string', example: 'main' },
kind: {
type: 'string',
description: 'One of `grant`, `member`, `group-permission`.',
example: 'grant',
},
subject: {
type: 'string',
description: 'A Steam id, or a group name.',
example: '76561198000000000',
},
object: {
type: 'string',
description: 'A permission name, or a group name.',
example: 'kits.admin',
},
username: {
type: 'string',
nullable: true,
description: 'The website account holding that Steam id, when there is one. Without it the drift cannot be adopted, only revoked.',
example: 'wanderer',
},
firstSeen: { type: 'string', format: 'date-time' },
},
},
},
catalogue: {
type: 'array',
items: { $ref: '#/components/schemas/RustPermissionCatalogueEntry' },
},
},
},
RustPermissionSyncState: {
type: 'object',
description: 'Whether one servers store matches what the site authors, and what its last report said.',
properties: {
serverId: { type: 'string', example: 'main' },
state: {
type: 'string',
description: 'One of `pending`, `ok`, `failed`.',
example: 'ok',
},
inSync: {
type: 'boolean',
description: 'True when the last successful push carried the set the site currently authors.',
example: true,
},
dirty: { type: 'boolean', example: false },
lastAttemptAt: { type: 'string', format: 'date-time', nullable: true },
lastOkAt: { type: 'string', format: 'date-time', nullable: true },
error: {
type: 'string',
nullable: true,
description: 'Why the last attempt failed — a transport word (`timeout`, `no-token`, `protocol-mismatch`) or the games own refusal.',
example: null,
},
report: {
type: 'object',
nullable: true,
description: 'The plugins report from the last successful sync.',
properties: {
applied: {
type: 'object',
properties: {
grants: { type: 'integer', example: 2 },
revokes: { type: 'integer', example: 0 },
groupsCreated: { type: 'integer', example: 1 },
members: { type: 'integer', example: 3 },
},
},
alreadyCorrect: { type: 'integer', example: 14 },
unresolved: {
type: 'array',
description: 'Permission names no loaded plugin on that server has registered. A grant naming one lands nowhere and is not recorded as pushed.',
items: { type: 'string', example: 'kits.gold' },
},
pending: {
type: 'array',
description: 'Memberships waiting on a first connection: the store has no user record to put in a group yet.',
items: { type: 'string', example: '76561198000000000:vip' },
},
},
},
},
},
RustPermissionCatalogue: {
type: 'object',
description: 'Every permission name the configured servers have registered (GET /admin/rust/permissions/catalogue).',
properties: {
permissions: {
type: 'array',
items: { $ref: '#/components/schemas/RustPermissionCatalogueEntry' },
},
},
},
RustPermissionCatalogueEntry: {
type: 'object',
description: 'One registered permission name, and which servers know it.',
properties: {
permission: { type: 'string', example: 'kits.vip' },
servers: { type: 'array', items: { type: 'string', example: 'main' } },
},
},
RustPermissionSyncResult: {
type: 'object',
description: 'What a forced sync produced (POST /admin/rust/permissions/sync).',
properties: {
servers: { type: 'array', items: { $ref: '#/components/schemas/RustPermissionSyncState' } },
drift: { type: 'array', items: { type: 'object' } },
},
},
RustUserPermissions: {
type: 'object',
description: 'One persons Rust privileges, for the admin.users.detail panel (GET /admin/users/{id}/rust/permissions).',
properties: {
groups: {
type: 'array',
items: {
type: 'object',
properties: {
name: { type: 'string', example: 'vip' },
title: { type: 'string', example: 'VIP' },
scope: { type: 'string', example: '*' },
permissions: { type: 'array', items: { type: 'string', example: 'kits.vip' } },
},
},
},
grants: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'integer', example: 7 },
permission: { type: 'string', example: 'kits.gold' },
scope: { type: 'string', example: 'main' },
source: { type: 'string', example: 'admin' },
grantedAt: { type: 'string', format: 'date-time' },
},
},
},
reaches: {
type: 'array',
description: 'The Steam accounts these privileges reach. Empty means this person has linked nothing and holds them on paper only.',
items: { type: 'string', example: '76561198000000000' },
},
},
},
RustSidecarProbe: {
type: 'object',
description: 'What a sidecar said when probed (POST /admin/rust/servers/{id}/test).',

View File

@@ -87,13 +87,13 @@ test('every kind is classified exactly once', () => {
assert.equal(seen.size, catalogue.PUBLIC_KINDS.length + catalogue.STAFF_KINDS.length)
})
test('the classification covers exactly the kinds protocol 3 defines', () => {
test('the classification covers exactly the kinds protocol 4 defines', () => {
// The spec lives in another repository, so the list is restated here rather
// than parsed — and restating it is the point: adding a kind to the protocol
// without deciding who may see it has to fail somewhere, and this is where.
//
// Sourced from docs/rust-link/PROTOCOL.md §8.4.
const PROTOCOL_3 = [
const PROTOCOL_4 = [
'player.connected',
'player.disconnected',
'player.respawned',
@@ -111,7 +111,8 @@ test('the classification covers exactly the kinds protocol 3 defines', () => {
'server.shutdown',
'account.link.requested',
'account.unlinked',
'perm.drift',
]
assert.deepEqual([...catalogue.ALL_KINDS].sort(), [...PROTOCOL_3].sort())
assert.deepEqual([...catalogue.ALL_KINDS].sort(), [...PROTOCOL_4].sort())
})

View File

@@ -74,7 +74,14 @@ test('the admin.users.detail router merges the parents params and keeps its o
assert.equal(slot.router.mergeParams, true)
const paths = routesOf(slot.router).map((r) => `${r.method} ${r.path}`).sort()
assert.deepEqual(paths, ['DELETE /rust/links/:steamId', 'GET /rust/links'])
assert.deepEqual(paths, [
'DELETE /rust/links/:steamId',
// Phase 7 filled the same panel with what this person may do in game.
'DELETE /rust/permissions/grants/:grantId',
'GET /rust/links',
'GET /rust/permissions',
'POST /rust/permissions/grants',
])
for (const route of routesOf(slot.router)) {
assert.ok(route.path.startsWith('/rust/'), `${route.path} must live under this module's own segment`)

View File

@@ -0,0 +1,285 @@
// ── The permission mirror ─────────────────────────────────────────────────
//
// The whole of R2's correctness is three set operations and one rule about what
// counts as landed, and every test here is one of those:
//
// desired pushed apply
// pushed desired RETIRE, because the site put it there and withdrew it
// present desired drift, which is reported and never undone
//
// and: a grant naming a permission the server has not registered did NOT land,
// however much the push looked like it worked.
//
// The last one is the one with teeth. `GrantUserPermission` returns void, throws
// nothing and logs nothing for an unregistered name (PLAN.md §12.2 rule 1), so a
// module that recorded it as pushed would believe it had given a privilege it had
// not — and would then RETIRE it from a server that never had it, which is a
// no-op that reads as a success in every log.
const test = require('node:test')
const assert = require('node:assert')
const { fakeCtx } = require('./_fakes')
function withCore(overrides = {}) {
const queries = []
require('../core')._reset()
require('../core').init(
fakeCtx({
db: {
query: (sql, params) => {
queries.push({ sql: sql.trim().replace(/\s+/g, ' '), params })
const verb = sql.trim().split(/\s+/)[0].toUpperCase()
if (verb === 'SELECT') return Promise.resolve([])
return Promise.resolve({ affectedRows: 1 })
},
pool: {},
},
...overrides,
}),
)
return queries
}
/** One authored set: a fleet group, a server-scoped group, and two grants. */
function authored() {
return {
groups: [
{ name: 'vip', title: 'VIP', rank: 10, scope: '*' },
{ name: 'builder', title: 'Builder', rank: 0, scope: 'creative' },
],
groupPermissions: [
{ groupName: 'vip', permission: 'kits.vip' },
{ groupName: 'builder', permission: 'buildtools.use' },
],
members: [
{ groupName: 'vip', userId: 1 },
{ groupName: 'builder', userId: 2 },
],
grants: [
{ id: 1, userId: 1, permission: 'kits.gold', scope: '*', steamId: '7656001' },
{ id: 2, userId: 3, permission: 'kits.gold', scope: '*', steamId: null },
{ id: 3, userId: 2, permission: 'zonemanager.admin', scope: 'creative', steamId: '7656002' },
],
// One person with TWO Steam accounts, one with one, one with none.
steamIdsByUser: new Map([
[1, ['7656001', '7656099']],
[2, ['7656002']],
]),
}
}
test('a grant reaches every Steam account its holder has linked (D28)', () => {
withCore()
const model = require('../model/permissions/permissions.model')
const { payload } = model.buildDesired('main', authored())
const holders = payload.grants.map((row) => row.steamId).sort()
// `kits.gold` is authored once, against user 1, who holds two accounts.
assert.deepEqual(holders, ['7656001', '7656099'])
for (const row of payload.grants) assert.deepEqual(row.permissions, ['kits.gold'])
})
test('a holder who has linked nothing contributes to the namespace but reaches nobody', () => {
withCore()
const model = require('../model/permissions/permissions.model')
const { payload, rows } = model.buildDesired('main', authored())
// User 3 holds `kits.gold` and has no account. Nothing is pushed for them…
assert.ok(!rows.some((row) => row.kind === 'grant' && row.subject === null))
// …and the permission is still MANAGED, which is what makes a hand grant of it
// to somebody else show up as drift rather than as nothing at all.
assert.ok(payload.managed.includes('kits.gold'))
})
test('scope decides what a server is sent at all (D29)', () => {
withCore()
const model = require('../model/permissions/permissions.model')
const main = model.buildDesired('main', authored())
const creative = model.buildDesired('creative', authored())
assert.deepEqual(main.payload.groups.map((g) => g.name), ['vip'])
assert.deepEqual(creative.payload.groups.map((g) => g.name).sort(), ['builder', 'vip'])
// The server-scoped grant is on `creative` and nowhere else.
assert.ok(!main.payload.managed.includes('zonemanager.admin'))
assert.ok(creative.payload.managed.includes('zonemanager.admin'))
})
test('a group travels as a group: its members and its permissions are separate facts (D30)', () => {
withCore()
const model = require('../model/permissions/permissions.model')
const { payload, rows } = model.buildDesired('main', authored())
const vip = payload.groups.find((group) => group.name === 'vip')
assert.deepEqual(vip.permissions, ['kits.vip'])
assert.deepEqual(vip.members.sort(), ['7656001', '7656099'])
// Three distinct row kinds, because the game can fail at each independently: a
// group can exist while a membership does not, which is exactly what happens
// for a player the store has never seen.
assert.ok(rows.some((r) => r.kind === 'group' && r.subject === 'vip'))
assert.ok(rows.some((r) => r.kind === 'group-permission' && r.object === 'kits.vip'))
assert.ok(rows.some((r) => r.kind === 'member' && r.object === 'vip'))
})
test('the digest does not depend on the order rows came out of the database', () => {
withCore()
const model = require('../model/permissions/permissions.model')
const rows = model.buildDesired('main', authored()).rows
const shuffled = [...rows].reverse()
// An unsorted digest would differ between two reads of an unchanged set, and
// the loop would push to every game server on every tick for ever.
assert.equal(model.hashRows(rows), model.hashRows(shuffled))
assert.notEqual(model.hashRows(rows), model.hashRows(rows.slice(1)))
})
test('what this site put there and has withdrawn is the only thing retired (D31)', () => {
withCore()
const model = require('../model/permissions/permissions.model')
const desired = [
{ kind: 'grant', subject: '7656001', object: 'kits.gold' },
{ kind: 'member', subject: '7656001', object: 'vip' },
]
const pushed = [
{ kind: 'grant', subject: '7656001', object: 'kits.gold' }, // still wanted
{ kind: 'grant', subject: '7656001', object: 'kits.silver' }, // withdrawn
]
assert.deepEqual(model.retirements(pushed, desired), [
{ kind: 'grant', subject: '7656001', object: 'kits.silver' },
])
// A hand grant is in NEITHER set, so it is never retired by this calculation —
// it reaches the operator as drift instead. That difference is the reason the
// pushed ledger exists at all.
assert.deepEqual(model.retirements([], desired), [])
})
test('a permission the server could not resolve is not recorded as pushed', async () => {
const queries = withCore()
const permSync = require('../permSync')
const desired = {
hash: 'h1',
rows: [
{ kind: 'grant', subject: '7656001', object: 'kits.gold' },
{ kind: 'grant', subject: '7656001', object: 'kits.vip' },
{ kind: 'member', subject: '7656002', object: 'vip' },
{ kind: 'member', subject: '7656003', object: 'vip' },
],
}
const report = {
kind: 'perm.report',
applied: { grants: 1 },
unresolved: ['kits.vip'],
pending: ['7656003:vip'],
foreign: [],
}
// The catalogue refresh is a second call to the game; stubbed so the report
// path is what this test is about.
const sidecar = require('../sidecarClient')
sidecar.permCatalogue = async () => ({ ok: false, status: 'no-token', data: null })
await permSync.applyReport({ id: 'main' }, { desired, retire: [], report, bootId: null, wipeId: null })
const insert = queries.find((q) => q.sql.startsWith('INSERT IGNORE INTO rust_perm_pushed'))
assert.ok(insert, 'the rows that landed must be recorded')
const recorded = insert.params.join(' ')
assert.ok(recorded.includes('kits.gold'), 'a grant that landed is pushed')
assert.ok(!recorded.includes('kits.vip'), 'an unresolved permission never reached the store')
assert.ok(recorded.includes('7656002'), 'a membership that took is pushed')
assert.ok(!recorded.includes('7656003'), 'a pending membership is not in the game yet')
})
test('a restart, a wipe and a hand edit each provoke a sync; a quiet server does not', () => {
withCore()
const permSync = require('../permSync')
const base = {
state: 'ok',
dirty: false,
syncedHash: 'h1',
bootId: 'boot-1',
wipeId: 'w-1',
lastAttemptAt: new Date(),
}
const at = (sync, state = {}) =>
permSync.reasonToSync({
desiredHash: 'h1',
sync,
state: { bootId: 'boot-1', wipeId: 'w-1', ...state },
force: false,
})
assert.equal(at(base), null, 'nothing changed: no push')
assert.equal(at({ ...base, dirty: true }), 'dirty')
assert.equal(permSync.reasonToSync({ desiredHash: 'h2', sync: base, state: {}, force: false }), 'changed')
assert.equal(at(base, { bootId: 'boot-2' }), 'restart')
assert.equal(at(base, { wipeId: 'w-2' }), 'wipe')
assert.equal(at(null), 'first')
// The audit is the backstop that finds drift on a server nobody has touched.
const old = new Date(Date.now() - permSync.AUDIT_MS - 1000)
assert.equal(at({ ...base, lastAttemptAt: old }), 'audit')
})
test('a failing server is left alone for a backoff, unless something changed', () => {
withCore()
const permSync = require('../permSync')
const failing = {
state: 'failed',
dirty: false,
syncedHash: 'h1',
lastAttemptAt: new Date(),
}
assert.equal(
permSync.reasonToSync({ desiredHash: 'h1', sync: failing, state: {}, force: false }),
null,
'a server that just failed is not hammered every thirty seconds',
)
assert.equal(
permSync.reasonToSync({ desiredHash: 'h1', sync: { ...failing, dirty: true }, state: {}, force: false }),
'retry',
'an operator changing something is a reason to try again at once',
)
const older = new Date(Date.now() - permSync.FAIL_BACKOFF_MS - 1000)
assert.equal(
permSync.reasonToSync({ desiredHash: 'h1', sync: { ...failing, lastAttemptAt: older }, state: {}, force: false }),
'retry',
)
})
test('names are lowered, because the store lowers them', () => {
withCore()
const model = require('../model/permissions/permissions.model')
const set = {
...authored(),
grants: [{ id: 9, userId: 1, permission: 'Kits.GOLD', scope: '*', steamId: '7656001' }],
}
const { payload } = model.buildDesired('main', set)
// Pushed as `kits.gold`, read back as `kits.gold`. Unlowered, the site would
// push one name, find another, and report its own grant as drift for ever.
assert.deepEqual(payload.grants[0].permissions, ['kits.gold'])
})