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
460 lines
15 KiB
JavaScript
460 lines
15 KiB
JavaScript
// ── 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,
|
|
}
|