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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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.
|
||||
|
||||
459
server/model/permissions/permissions.db.js
Normal file
459
server/model/permissions/permissions.db.js
Normal 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,
|
||||
}
|
||||
356
server/model/permissions/permissions.model.js
Normal file
356
server/model/permissions/permissions.model.js
Normal 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} | ||||