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