diff --git a/ci/bundle.json b/ci/bundle.json index a38c7af..6ecfb44 100644 --- a/ci/bundle.json +++ b/ci/bundle.json @@ -35,6 +35,7 @@ "ingest.js", "model", "package.json", + "permSync.js", "router", "sidecarClient.js" ], diff --git a/client/src/api.js b/client/src/api.js index 429c8b1..5947fe7 100644 --- a/client/src/api.js +++ b/client/src/api.js @@ -103,6 +103,50 @@ export const admin = { req(`/admin/rust/servers/${encodeURIComponent(id)}/test`, { method: 'POST' }), } +// ── admin · permissions (R2) ────────────────────────────────────────────── +// +// The authoring surface. Every call here writes to the SITE, and none of them +// reaches a game server — the mirror's own loop does that on its own cadence. +// `sync` is the exception and says so in its name: it runs the pass now and +// answers with what each server reported, which is the only call on this screen +// that can be slow or fail because a game host is down. +// +// A write is followed by a re-read rather than a local edit of the model: what +// the screen is showing is partly the game's answer, and the honest way to learn +// the new one is to ask. +export const adminPermissions = { + overview: () => req('/admin/rust/permissions'), + catalogue: () => req('/admin/rust/permissions/catalogue'), + + saveGroup: (name, body) => + req(`/admin/rust/permissions/groups/${encodeURIComponent(name)}`, { method: 'PUT', body }), + deleteGroup: (name) => + req(`/admin/rust/permissions/groups/${encodeURIComponent(name)}`, { method: 'DELETE' }), + + addMember: (name, username) => + req(`/admin/rust/permissions/groups/${encodeURIComponent(name)}/members`, { + method: 'POST', + body: { username }, + }), + removeMember: (name, userId) => + req( + `/admin/rust/permissions/groups/${encodeURIComponent(name)}/members/${encodeURIComponent(userId)}`, + { method: 'DELETE' }, + ), + + grant: (body) => req('/admin/rust/permissions/grants', { method: 'POST', body }), + revoke: (id) => + req(`/admin/rust/permissions/grants/${encodeURIComponent(id)}`, { method: 'DELETE' }), + + adoptDrift: (id) => + req(`/admin/rust/permissions/drift/${encodeURIComponent(id)}/adopt`, { method: 'POST' }), + revokeDrift: (id) => + req(`/admin/rust/permissions/drift/${encodeURIComponent(id)}/revoke`, { method: 'POST' }), + + sync: (serverId = null) => + req('/admin/rust/permissions/sync', { method: 'POST', body: serverId ? { serverId } : {} }), +} + // ── the admin.users.detail extension slot ───────────────────────────────── // // The client half of R13's first slot. Core hands the component a `userId` and @@ -118,8 +162,34 @@ export const adminUserLinks = { }), } +// The same panel's phase 7 half: what this person may do in game. The id in the +// path is the one the slot handed the component, so these send `userId` rather +// than a name — the screen already knows who it is looking at. +export const adminUserPermissions = { + list: (userId) => req(`/admin/users/${encodeURIComponent(userId)}/rust/permissions`), + grant: (userId, body) => + req(`/admin/users/${encodeURIComponent(userId)}/rust/permissions/grants`, { + method: 'POST', + body, + }), + revoke: (userId, grantId) => + req( + `/admin/users/${encodeURIComponent(userId)}/rust/permissions/grants/${encodeURIComponent(grantId)}`, + { method: 'DELETE' }, + ), +} + // Exported for the rare caller that needs the base itself — an ``, a // download link, an EventSource. Reach for `request` first. export { BASE, query } -export default { servers, playerServers, playerLinks, admin, adminUserLinks, BASE } +export default { + servers, + playerServers, + playerLinks, + admin, + adminPermissions, + adminUserLinks, + adminUserPermissions, + BASE, +} diff --git a/client/src/entry.jsx b/client/src/entry.jsx index 7fb8b18..2a1c57e 100644 --- a/client/src/entry.jsx +++ b/client/src/entry.jsx @@ -21,9 +21,10 @@ import { registry, coreApiVersion } from './core.js' import Servers from './routes/public/Servers.jsx' import ServerDetail from './routes/public/ServerDetail.jsx' import Account from './routes/player/Account.jsx' +import Permissions from './routes/admin/Permissions.jsx' import UserRustSections from './routes/admin/UserRustSections.jsx' import FooterStatus from './components/FooterStatus.jsx' -import { IconLink } from './icons.jsx' +import { IconKey, IconLink } from './icons.jsx' // The module id, exactly as `module.json` spells it. Core keys the registry by it // and prefixes every route path with it. @@ -63,12 +64,25 @@ const ID = 'rust' // to do, and a landing page above one page is a page nobody wants. Core applies // its own portal chrome and its own auth gate to the tier, so the component // renders no layout and re-implements no check. +// +// **The admin route arrives in phase 7 and is this module's first.** Everything +// before it was configured through the API — the server rows still are — because +// nothing until now had to be AUTHORED. A permission model is different in kind: +// it is a thing an operator composes and keeps looking at, and there is no +// version of "grant somebody VIP" that belongs in a terminal. +// +// It is registered with an empty path, so it lands at `/admin/rust`, and core +// applies the admin tier's own gate. The routes underneath it are stricter than +// that gate (`requireRole('admin')` on every one), which is a server-side answer +// rather than a client one: a moderator who reached this page would see it fail +// honestly rather than be quietly shown a page that cannot save. registry.registerRoutes(ID, { public: [ { path: '', element: }, { path: 'servers/:id', element: }, ], player: [{ path: '', element: }], + admin: [{ path: '', element: }], }) // ── Nav ─────────────────────────────────────────────────────────────────── @@ -105,6 +119,17 @@ registry.registerNav(ID, { items: [{ label: 'Rust', to: '/player/rust', icon: IconLink }], }) +// The admin sidebar's row. `group` names an existing core group — an unknown name +// appends a new group at the end rather than dropping the row, which is the +// failure mode to avoid here: a row nobody can find is a feature nobody has. +// +// It carries an icon for the same reason the player row does: core draws one on +// every sidebar row, and the one without is the only text in a column of glyphs. +registry.registerNav(ID, { + area: 'admin', + items: [{ label: 'Rust permissions', to: '/admin/rust', icon: IconKey }], +}) + // ── Extension slots ─────────────────────────────────────────────────────── // // Core declares a slot, only core may declare one, and at most one module may diff --git a/client/src/icons.jsx b/client/src/icons.jsx index 941eb3e..4019cd2 100644 --- a/client/src/icons.jsx +++ b/client/src/icons.jsx @@ -46,4 +46,20 @@ export const IconLink = () => ( ) -export default { IconLink } +/** + * A key — the admin sidebar's row for the permission mirror. + * + * Core's admin groups are labelled by subject and drawn with glyphs of the same + * weight, so this is the same 16px frame as the portal's. A key rather than a + * shield: a shield is protection from something, and this row is about handing + * somebody the right to do something. + */ +export const IconKey = () => ( + + + + + +) + +export default { IconLink, IconKey } diff --git a/client/src/routes/admin/Permissions.jsx b/client/src/routes/admin/Permissions.jsx new file mode 100644 index 0000000..c19b09b --- /dev/null +++ b/client/src/routes/admin/Permissions.jsx @@ -0,0 +1,617 @@ +// ── Admin · Rust · Permissions ──────────────────────────────────────────── +// +// R2's authoring surface, and this module's first admin page. +// +// **What is on it is decided by what an operator can get wrong**, rather than by +// what the tables contain. Four states are invisible from the game and from a +// list of grants, and every one of them looks exactly like success: +// +// • a grant against somebody who has linked no Steam account — authored, +// stored, pushed nowhere; +// • a permission no loaded plugin has registered — the grant lands silently +// nowhere, because `GrantUserPermission` no-ops for an unregistered name; +// • a group member who has never connected — the store has no user record to +// put in a group yet, and the membership waits for their first connection; +// • a server whose last sync failed — the site is authoritative and the game +// has not heard it. +// +// So each of those is a sentence on this page rather than a number in a report. +// +// The screen never writes to a game. Every button here writes to the site and +// the mirror's loop reconciles within seconds — except *Sync now*, which runs +// that pass immediately because an operator who has just changed something +// should not have to trust a timer to find out that a host is unreachable. + +import { useCallback, useState } from 'react' + +import { ErrorState, Loading, useAsync } from '../../core.js' +import { ago } from '../../lib/format.js' +import api from '../../api.js' + +const FLEET = '*' + +/** Shared furniture. The kit is nine exports and none of them is a table. */ +function Card({ title, subtitle, children, actions }) { + return ( +
+
+

+ {title} +

+ {subtitle && ( + + {subtitle} + + )} + + {actions} +
+ {children} +
+ ) +} + +function Row({ children, muted = false }) { + return ( +
+ {children} +
+ ) +} + +function Warn({ children }) { + return ( +

+ {children} +

+ ) +} + +function Scope({ value }) { + return ( + + {value === FLEET ? 'every server' : value} + + ) +} + +/** + * One server's mirror state. + * + * `unresolved` and `pending` are rendered as sentences rather than counts + * because each is a different problem with a different fix, and both are + * invisible everywhere else on this page. + */ +function ServerState({ row, onSync, busy }) { + const report = row.report || {} + const unresolved = report.unresolved || [] + const pending = report.pending || [] + + return ( +
+
+ + {row.serverId} + + + {row.inSync ? 'in sync' : row.state === 'failed' ? 'out of sync' : 'pending'} + + + {row.lastOkAt ? `last pushed ${ago(row.lastOkAt)}` : 'never pushed'} + + + +
+ + {row.error && ( +

+ {row.error} +

+ )} + + {unresolved.length > 0 && ( + + {unresolved.join(', ')} — no plugin loaded on this server has registered{' '} + {unresolved.length === 1 ? 'that name' : 'those names'}, so a grant naming{' '} + {unresolved.length === 1 ? 'it' : 'them'} reaches nobody here. It will land by itself when + the plugin is back. + + )} + + {pending.length > 0 && ( + + {pending.length} {pending.length === 1 ? 'membership is' : 'memberships are'} waiting on a + first connection — this server has never seen those players, so it has no account to put + in a group yet. + + )} +
+ ) +} + +/** A hand edit, with the two answers to it. */ +function DriftRow({ row, onAdopt, onRevoke, busy }) { + const subject = row.username ? `${row.username} (${row.subject})` : row.subject + + return ( + + + {row.object}{' '} + + {row.kind === 'group-permission' ? `on group ${row.subject}` : `held by ${subject}`} ·{' '} + {row.serverId} · seen {ago(row.firstSeen)} + + + + + + ) +} + +/** + * The memberships the game could not place yet, as `steamId:group`. + * + * Read out of each server's own report, because it is the only thing that knows: + * a member who has never connected to a server has no user record there to put + * in a group (§12.2 rule 4), and from every other angle they look like a member. + * The server strip says how many; this is what puts it next to the person. + */ +function pendingSet(servers) { + const pending = new Map() + + for (const server of servers) { + for (const entry of (server.report && server.report.pending) || []) { + if (!pending.has(entry)) pending.set(entry, []) + pending.get(entry).push(server.serverId) + } + } + + return pending +} + +function GroupCard({ group, catalogue, servers, pending, onChanged, setError }) { + const [busy, setBusy] = useState(false) + const [member, setMember] = useState('') + const [permission, setPermission] = useState('') + + const act = async (fn) => { + setBusy(true) + setError('') + try { + await fn() + await onChanged() + } catch (err) { + setError(err.message || 'That did not work.') + } finally { + setBusy(false) + } + } + + const save = (permissions) => + act(() => + api.adminPermissions.saveGroup(group.name, { + title: group.title, + rank: group.rank, + scope: group.scope, + permissions, + }), + ) + + return ( + {group.name} · } + actions={ + + } + > +
Permissions
+ {group.permissions.length === 0 && ( +

+ This group carries nothing, so being in it does nothing. +

+ )} + {group.permissions.map((perm) => ( + + {perm} + {!catalogue.some((entry) => entry.permission === perm) && ( + + no server has registered this + + )} + + + ))} + +
{ + event.preventDefault() + if (!permission.trim()) return + save([...group.permissions, permission.trim().toLowerCase()]) + setPermission('') + }} + > + setPermission(event.target.value)} + style={{ flex: 1 }} + /> + +
+ +
+ Members +
+ {group.members.length === 0 && ( +

+ Nobody is in this group. +

+ )} + {group.members.map((m) => { + const waiting = m.accounts + .map((account) => pending.get(`${account.steamId}:${group.name}`)) + .filter(Boolean) + .flat() + + return ( + + + {m.username} + {m.accounts.length > 0 ? ( + + {' '} + · {m.accounts.map((a) => a.name || a.steamId).join(', ')} + + ) : ( + + {' '} + · has linked no Steam account, so this reaches nobody + + )} + {waiting.length > 0 && ( + + {' '} + · waiting on their first connection to {[...new Set(waiting)].join(', ')} + + )} + + + + ) + })} + +
{ + event.preventDefault() + if (!member.trim()) return + act(() => api.adminPermissions.addMember(group.name, member.trim())) + setMember('') + }} + > + setMember(event.target.value)} + style={{ flex: 1 }} + /> + +
+ + {servers.length > 1 && group.scope !== FLEET && ( +

+ This group exists on {group.scope} only. The other servers never receive it. +

+ )} +
+ ) +} + +export default function Permissions() { + const [reloads, setReloads] = useState(0) + const [busy, setBusy] = useState(false) + const [error, setError] = useState('') + const [form, setForm] = useState({ name: '', title: '', scope: FLEET }) + const [grant, setGrant] = useState({ username: '', permission: '', scope: FLEET }) + + const { data, error: loadError } = useAsync(() => api.adminPermissions.overview(), [reloads]) + const reload = useCallback(() => setReloads((n) => n + 1), []) + + const act = async (fn) => { + setBusy(true) + setError('') + try { + await fn() + reload() + } catch (err) { + setError(err.message || 'That did not work.') + } finally { + setBusy(false) + } + } + + if (loadError) return + if (!data) return + + const servers = data.servers || [] + + return ( +
+ {/* No heading of our own: core's admin chrome already draws the route's + title above the page, and a second one is the same words twice. */} +

+ This site is the author of record. Groups and grants written here are pushed into each + server’s own permission store, so every plugin that checks a permission honours them — and a + wipe does not lose them, because they are re-pushed when the server comes back. +

+ + {/* The option source, shared by both forms. A datalist rather than a select: + a name that no server has registered is still authorable — the plugin + may simply not be loaded right now — and the warning beside it is the + honest treatment, where a closed list would be a refusal. */} + + {(data.catalogue || []).map((entry) => ( + + + {error && ( +

+ {error} +

+ )} + + act(() => api.adminPermissions.sync())}> + Sync all + + } + > + {servers.length === 0 && ( +

+ No servers are configured yet, so nothing written here reaches a game. +

+ )} + {servers.map((row) => ( + act(() => api.adminPermissions.sync(id))} + /> + ))} +
+ + {(data.drift || []).length > 0 && ( + +

+ Nothing here is undone automatically. Adopt records it as the site’s + own, so it survives the next wipe; Revoke removes it from the game on + the next sync. +

+ {data.drift.map((row) => ( + act(() => api.adminPermissions.adoptDrift(d.id))} + onRevoke={(d) => act(() => api.adminPermissions.revokeDrift(d.id))} + /> + ))} +
+ )} + + + {(data.grants || []).length === 0 && ( +

+ Nobody holds a permission of their own yet. +

+ )} + {(data.grants || []).map((row) => ( + + + {row.username} · {row.permission}{' '} + + {row.accounts.length === 0 && ( + + {' '} + · has linked no Steam account, so this reaches nobody + + )} + {/* The same warning the group's permission list carries, and it + matters more here: a grant naming a permission nothing has + registered is the failure the plugin's pre-check exists for, + and it is invisible on this row without it. */} + {!(data.catalogue || []).some((entry) => entry.permission === row.permission) && ( + + {' '} + · no server has registered this permission + + )} + {row.source !== 'admin' && ( + · {row.source} + )} + + + + ))} + +
{ + event.preventDefault() + if (!grant.username.trim() || !grant.permission.trim()) return + act(() => + api.adminPermissions.grant({ + username: grant.username.trim(), + permission: grant.permission.trim().toLowerCase(), + scope: grant.scope, + }), + ) + setGrant({ username: '', permission: '', scope: FLEET }) + }} + > + setGrant({ ...grant, username: event.target.value })} + style={{ flex: '1 1 160px' }} + /> + setGrant({ ...grant, permission: event.target.value })} + style={{ flex: '1 1 160px' }} + /> + + +
+
+ + {(data.groups || []).map((group) => ( + + ))} + + +
{ + event.preventDefault() + if (!form.name.trim()) return + act(() => + api.adminPermissions.saveGroup(form.name.trim().toLowerCase(), { + title: form.title.trim() || form.name.trim(), + scope: form.scope, + permissions: [], + }), + ) + setForm({ name: '', title: '', scope: FLEET }) + }} + > + setForm({ ...form, name: event.target.value })} + style={{ flex: '1 1 140px' }} + /> + setForm({ ...form, title: event.target.value })} + style={{ flex: '1 1 140px' }} + /> + + +
+

+ A group is created in each in-scope game as a real group, so plugins that read group + membership see it. A member who has never connected to a server joins it there on their + first connection — a direct grant reaches them straight away, which is the difference + worth knowing when somebody is waiting. +

+
+
+ ) +} diff --git a/client/src/routes/admin/UserRustSections.jsx b/client/src/routes/admin/UserRustSections.jsx index 3313965..1d167d6 100644 --- a/client/src/routes/admin/UserRustSections.jsx +++ b/client/src/routes/admin/UserRustSections.jsx @@ -113,11 +113,131 @@ function LinkPanel({ userId, link, onRemoved }) { ) } +/** + * Phase 7's half of the panel: what this person may do in game. + * + * It renders whenever they hold anything, INCLUDING when they have linked no + * Steam account — which is the one case worth going out of the way for. A grant + * against an unlinked person is authored, stored, pushed nowhere, and identical + * to a working one everywhere except here. + */ +function PermissionsPanel({ userId, data, onChanged }) { + const [busy, setBusy] = useState(false) + const [error, setError] = useState('') + const [permission, setPermission] = useState('') + + const act = async (fn) => { + setBusy(true) + setError('') + try { + await fn() + await onChanged() + } catch (err) { + setError(err.message || 'That did not work.') + } finally { + setBusy(false) + } + } + + if (!data) return null + + const nothing = data.groups.length === 0 && data.grants.length === 0 + + return ( +
+
+ Permissions +
+ + {nothing && ( +

+ Nothing granted. +

+ )} + + {data.groups.map((group) => ( +
+ {group.title || group.name}{' '} + + group · {group.scope === '*' ? 'every server' : group.scope} + {group.permissions.length ? ` · ${group.permissions.join(', ')}` : ' · carries nothing'} + +
+ ))} + + {data.grants.map((row) => ( +
+ + {row.permission}{' '} + + {row.scope === '*' ? 'every server' : row.scope} + {row.source !== 'admin' ? ` · ${row.source}` : ''} + + + +
+ ))} + + {!nothing && data.reaches.length === 0 && ( +

+ This account has linked no Steam id, so none of it reaches a game yet. It will apply by + itself when they link. +

+ )} + +
{ + event.preventDefault() + if (!permission.trim()) return + act(() => + api.adminUserPermissions.grant(userId, { permission: permission.trim().toLowerCase() }), + ) + setPermission('') + }} + > + setPermission(event.target.value)} + style={{ flex: 1 }} + /> + +
+ + {error && ( +

+ {error} +

+ )} +
+ ) +} + export default function UserRustSections({ userId }) { // Core's `useAsync` has no refresh, so a counter in the deps is how this // re-reads after its own write (the same shape the player page uses). const [reloads, setReloads] = useState(0) const { data } = useAsync(() => api.adminUserLinks.list(userId), [userId, reloads]) + const { data: permissions } = useAsync( + () => api.adminUserPermissions.list(userId), + [userId, reloads], + ) const reload = useCallback(() => setReloads((n) => n + 1), []) // No `Loading` and no `ErrorState`, deliberately. This is a section inside @@ -125,7 +245,15 @@ export default function UserRustSections({ userId }) { // have nothing to do with is worse than a section that appears when it has // something, and a failure here must not replace core's own user detail with an // error card. - if (!data || data.links.length === 0) return null + // **Both reads decide whether this section exists**, and the second one is the + // reason. A browser walk found it: a person can hold permissions and have + // linked no Steam account — which is exactly the state an operator most needs + // to see, because it is the one that reaches nobody — and a section gated on + // links alone hides it completely. + const holdsSomething = + permissions && (permissions.groups.length > 0 || permissions.grants.length > 0) + + if (!data || (data.links.length === 0 && !holdsSomething)) return null return (
@@ -135,13 +263,22 @@ export default function UserRustSections({ userId }) { {data.links.map((link) => ( ))} - -

- A link is fleet-wide and totals are all-time, summed across every wipe. Unlinking here is - recorded in the activity log — it is the way back for a player who linked the wrong account - and cannot reach it in game. -

+ {data.links.length > 0 && ( +

+ A link is fleet-wide and totals are all-time, summed across every wipe. Unlinking here is + recorded in the activity log — it is the way back for a player who linked the wrong + account and cannot reach it in game. +

+ )} + + {/* Inside the same section rather than beside it: "who is this in game" + and "what may they do there" are one question asked twice, and an + operator reading a support ticket has both in front of them. The note + above belongs to the links, so it sits with them rather than under + the panel it would otherwise appear to describe. */} + +
) } diff --git a/routes.manifest.json b/routes.manifest.json index 18657bd..e1814e3 100644 --- a/routes.manifest.json +++ b/routes.manifest.json @@ -1,6 +1,21 @@ { "$comment": "Generated inventory of the URLs module-rust serves - the module half of the freeze core keeps in server/routes.manifest.json. DERIVED as the difference between a core without this module and the same core with it, both at the pinned ref in ci/core-ref.json. Regenerate with the frozen-manifest job in .gitea/workflows/pr-checks.yml; see server/scripts/frozenManifest.js.", "routes": [ + { + "method": "DELETE", + "path": "/api/v1/admin/rust/permissions/grants/:id", + "tier": "public" + }, + { + "method": "DELETE", + "path": "/api/v1/admin/rust/permissions/groups/:name", + "tier": "public" + }, + { + "method": "DELETE", + "path": "/api/v1/admin/rust/permissions/groups/:name/members/:userId", + "tier": "public" + }, { "method": "DELETE", "path": "/api/v1/admin/rust/servers/:id", @@ -11,11 +26,26 @@ "path": "/api/v1/admin/users/:id/rust/links/:steamId", "tier": "public" }, + { + "method": "DELETE", + "path": "/api/v1/admin/users/:id/rust/permissions/grants/:grantId", + "tier": "public" + }, { "method": "DELETE", "path": "/api/v1/player/rust/links/:steamId", "tier": "public" }, + { + "method": "GET", + "path": "/api/v1/admin/rust/permissions", + "tier": "public" + }, + { + "method": "GET", + "path": "/api/v1/admin/rust/permissions/catalogue", + "tier": "public" + }, { "method": "GET", "path": "/api/v1/admin/rust/servers", @@ -26,6 +56,11 @@ "path": "/api/v1/admin/users/:id/rust/links", "tier": "public" }, + { + "method": "GET", + "path": "/api/v1/admin/users/:id/rust/permissions", + "tier": "public" + }, { "method": "GET", "path": "/api/v1/player/rust/links", @@ -66,16 +101,51 @@ "path": "/api/v1/public/rust/servers/:id/wipes", "tier": "public" }, + { + "method": "POST", + "path": "/api/v1/admin/rust/permissions/drift/:id/adopt", + "tier": "public" + }, + { + "method": "POST", + "path": "/api/v1/admin/rust/permissions/drift/:id/revoke", + "tier": "public" + }, + { + "method": "POST", + "path": "/api/v1/admin/rust/permissions/grants", + "tier": "public" + }, + { + "method": "POST", + "path": "/api/v1/admin/rust/permissions/groups/:name/members", + "tier": "public" + }, + { + "method": "POST", + "path": "/api/v1/admin/rust/permissions/sync", + "tier": "public" + }, { "method": "POST", "path": "/api/v1/admin/rust/servers/:id/test", "tier": "public" }, + { + "method": "POST", + "path": "/api/v1/admin/users/:id/rust/permissions/grants", + "tier": "public" + }, { "method": "POST", "path": "/api/v1/player/rust/link", "tier": "public" }, + { + "method": "PUT", + "path": "/api/v1/admin/rust/permissions/groups/:name", + "tier": "public" + }, { "method": "PUT", "path": "/api/v1/admin/rust/servers/:id", diff --git a/server/boot.js b/server/boot.js index 945cf5f..b4eb58d 100644 --- a/server/boot.js +++ b/server/boot.js @@ -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) } diff --git a/server/catalogue.js b/server/catalogue.js index cf255bf..de3c6ba 100644 --- a/server/catalogue.js +++ b/server/catalogue.js @@ -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. */ diff --git a/server/db/purge.sql b/server/db/purge.sql index bb0e96d..f48674b 100644 --- a/server/db/purge.sql +++ b/server/db/purge.sql @@ -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; diff --git a/server/db/schema.sql b/server/db/schema.sql index 3248d5f..da2b04b 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -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` diff --git a/server/ingest.js b/server/ingest.js index 7e878d5..8256293 100644 --- a/server/ingest.js +++ b/server/ingest.js @@ -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. diff --git a/server/model/permissions/permissions.db.js b/server/model/permissions/permissions.db.js new file mode 100644 index 0000000..de97b05 --- /dev/null +++ b/server/model/permissions/permissions.db.js @@ -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, +} diff --git a/server/model/permissions/permissions.model.js b/server/model/permissions/permissions.model.js new file mode 100644 index 0000000..5412fd1 --- /dev/null +++ b/server/model/permissions/permissions.model.js @@ -0,0 +1,356 @@ +// ── The authored set, and what it means for one server ──────────────────── +// +// This file turns "what an operator wrote on the website" into "what one game +// server's store should contain", which is where four of phase 7's decisions +// actually live: +// +// D28 a grant is authored against a WEBSITE USER and resolved to every Steam +// id they have linked, here, at the moment of the push. +// D29 every authored row carries a scope — one server, or `*` for the fleet — +// and a server sees only what names it. +// D30 groups travel as groups. Membership is a separate wire fact from the +// permissions the group carries, because the game stores them separately +// and one of the two can fail on its own (§12.2 rule 4). +// D31 the difference between the desired set and what this site has already +// pushed is what gets retired. Anything else in the store is drift, and +// drift is reported rather than undone. +// +// Nothing here talks to a sidecar — `permSync.js` does that. The split is the +// usual one and earns its keep twice over here: the whole of the interesting +// logic is a pure function of four tables, so it is tested without a game, a +// sidecar, or a database. + +const crypto = require('node:crypto') + +const db = require('./permissions.db') + +/** A scope that means every server. Stored, rather than null, so the column never needs a coalesce. */ +const FLEET = '*' + +/** + * Permission and group names, as both frameworks store them. + * + * Lowercased on the way in, because the store lowers them and a site that did + * not would author `Kits.VIP`, push it, read back `kits.vip`, and report its own + * grant as drift for ever. + */ +function normaliseName(value) { + return String(value || '').trim().toLowerCase() +} + +/** Whether a scope reaches a server. */ +function inScope(scope, serverId) { + return scope === FLEET || scope === serverId +} + +/** + * Everything the authoring screen renders, in one read. + * + * Assembled here rather than in SQL because the shape is a tree — a group with + * its permissions and its members — and the alternative is either four round + * trips per group or one join that repeats every group row once per member. + */ +async function overview() { + const [groups, groupPermissions, members, grants, sync, drift, catalogue] = await Promise.all([ + db.listGroups(), + db.listGroupPermissions(), + db.listGroupMembers(), + db.listGrants(), + db.listSync(), + db.listDrift(), + db.listCatalogue(), + ]) + + const byGroup = new Map(groups.map((group) => [group.name, { ...group, permissions: [], members: [] }])) + + for (const row of groupPermissions) { + const group = byGroup.get(row.groupName) + if (group) group.permissions.push(row.permission) + } + + // A member with two linked Steam accounts arrives as two rows from the join, + // and is one person on the screen — holding BOTH accounts, not the first one + // the join happened to return. The screen needs all of them: a membership is + // pushed per account, and it can be waiting on one while it landed on another. + const memberByKey = new Map() + + for (const row of members) { + const group = byGroup.get(row.groupName) + if (!group) continue + + const key = `${row.groupName}:${row.userId}` + let member = memberByKey.get(key) + + if (!member) { + member = { + userId: row.userId, + username: row.username, + accounts: [], + addedAt: row.addedAt, + } + memberByKey.set(key, member) + group.members.push(member) + } + + if (row.steamId) member.accounts.push({ steamId: row.steamId, name: row.playerName || null }) + } + + return { + groups: [...byGroup.values()], + grants: collapseGrants(grants), + servers: sync.map(shapeSync), + drift, + catalogue: catalogueByPermission(catalogue), + } +} + +/** + * One row per grant, not one per linked account. + * + * The join in `listGrants` multiplies a grant by the holder's accounts, which is + * what the push wants and the opposite of what a screen wants. + */ +function collapseGrants(rows) { + const byId = new Map() + + for (const row of rows) { + const existing = byId.get(row.id) + + if (!existing) { + byId.set(row.id, { + id: row.id, + userId: row.userId, + username: row.username, + permission: row.permission, + scope: row.scope, + source: row.source, + note: row.note, + grantedAt: row.grantedAt, + accounts: row.steamId ? [{ steamId: row.steamId, name: row.playerName || null }] : [], + }) + + continue + } + + if (row.steamId) existing.accounts.push({ steamId: row.steamId, name: row.playerName || null }) + } + + return [...byId.values()] +} + +/** + * The sync row as a client reads it. + * + * `report` is stored as the JSON the game sent and parsed here rather than on the + * way in, so a report this build cannot read is a rendering problem on one + * screen instead of a write that failed. + */ +function shapeSync(row) { + let report = null + + if (row.report) { + try { + report = JSON.parse(row.report) + } catch { + report = null + } + } + + return { + serverId: row.serverId, + state: row.state, + dirty: Boolean(row.dirty), + inSync: Boolean(row.desiredHash) && row.desiredHash === row.syncedHash && row.state === 'ok', + lastAttemptAt: row.lastAttemptAt, + lastOkAt: row.lastOkAt, + error: row.error || null, + report, + } +} + +/** Which servers know each permission name — the form's option source, and its warning label. */ +function catalogueByPermission(rows) { + const byPermission = new Map() + + for (const row of rows) { + if (!byPermission.has(row.permission)) byPermission.set(row.permission, []) + byPermission.get(row.permission).push(row.serverId) + } + + return [...byPermission.entries()] + .map(([permission, servers]) => ({ permission, servers })) + .sort((a, b) => a.permission.localeCompare(b.permission)) +} + +/** + * The whole authored set, read once, in the shape the per-server build wants. + * + * Read once per sync tick rather than once per server: six servers is six + * different answers derived from one set of tables, and re-reading them per + * server is six times the queries for the same rows. + */ +async function readAuthored() { + const [groups, groupPermissions, members, grants, links] = await Promise.all([ + db.listGroups(), + db.listGroupPermissions(), + db.listGroupMembers(), + db.listGrants(), + db.listLinks(), + ]) + + const steamIdsByUser = new Map() + + for (const link of links) { + if (!steamIdsByUser.has(link.userId)) steamIdsByUser.set(link.userId, []) + steamIdsByUser.get(link.userId).push(link.steamId) + } + + return { groups, groupPermissions, members, grants, steamIdsByUser } +} + +/** + * What one server's store should contain, and the rows that say so. + * + * Returns three things the caller needs together and must not compute twice: + * + * `payload` what goes on the wire + * `rows` the same set in `rust_perm_pushed`'s shape, for the diff + * `hash` a stable digest of `rows`, which is how the loop knows nothing + * has changed without asking a game server + * + * **A user with no linked Steam account contributes nothing and is not an + * error.** They are authored against perfectly well and reach nobody until they + * link — which the admin screen says out loud, because a grant that reaches + * nothing looks exactly like one that worked. + */ +function buildDesired(serverId, authored) { + const { groups, groupPermissions, members, grants, steamIdsByUser } = authored + + const scopedGroups = groups.filter((group) => inScope(group.scope, serverId)) + const groupNames = new Set(scopedGroups.map((group) => group.name)) + + const permissionsByGroup = new Map(scopedGroups.map((group) => [group.name, []])) + const membersByGroup = new Map(scopedGroups.map((group) => [group.name, []])) + const managed = new Set() + const rows = [] + + for (const group of scopedGroups) + rows.push({ kind: 'group', subject: group.name, object: '' }) + + for (const row of groupPermissions) { + if (!groupNames.has(row.groupName)) continue + + const permission = normaliseName(row.permission) + permissionsByGroup.get(row.groupName).push(permission) + managed.add(permission) + rows.push({ kind: 'group-permission', subject: row.groupName, object: permission }) + } + + const seenMember = new Set() + + for (const row of members) { + if (!groupNames.has(row.groupName)) continue + + for (const steamId of steamIdsByUser.get(row.userId) || []) { + const key = `${row.groupName}:${steamId}` + if (seenMember.has(key)) continue + seenMember.add(key) + + membersByGroup.get(row.groupName).push(steamId) + rows.push({ kind: 'member', subject: steamId, object: row.groupName }) + } + } + + const permissionsBySteamId = new Map() + const seenGrant = new Set() + + for (const row of grants) { + if (!inScope(row.scope, serverId)) continue + + const permission = normaliseName(row.permission) + + // Managed whether or not it reaches anybody: the namespace is what makes a + // hand grant of this permission to somebody else show up as drift, and a + // grant whose holder has linked nothing would otherwise silently narrow it. + managed.add(permission) + + // **Resolved from the link map, not from the row.** `listGrants` joins the + // links and therefore repeats a grant once per linked account, which would + // give the right answer here by accident — until somebody changes that query + // and one of a person's two accounts quietly stops being granted. The map is + // the same source the members above use, and it says what it means. + for (const steamId of steamIdsByUser.get(row.userId) || []) { + const key = `${steamId}:${permission}` + if (seenGrant.has(key)) continue + seenGrant.add(key) + + if (!permissionsBySteamId.has(steamId)) permissionsBySteamId.set(steamId, []) + permissionsBySteamId.get(steamId).push(permission) + rows.push({ kind: 'grant', subject: steamId, object: permission }) + } + } + + const payload = { + groups: scopedGroups.map((group) => ({ + name: group.name, + title: group.title || group.name, + rank: group.rank, + permissions: permissionsByGroup.get(group.name), + members: membersByGroup.get(group.name), + })), + grants: [...permissionsBySteamId.entries()].map(([steamId, permissions]) => ({ + steamId, + permissions, + })), + managed: [...managed].sort(), + } + + return { payload, rows, hash: hashRows(rows) } +} + +/** + * A digest of the desired set. + * + * Sorted before hashing, because the rows come out of several queries in an + * order nothing guarantees — an unsorted digest would differ between two reads + * of an unchanged set and push to every game server on every tick. + */ +function hashRows(rows) { + const canonical = rows + .map((row) => `${row.kind}${row.subject}${row.object}`) + .sort() + .join('\n') + + return crypto.createHash('sha256').update(canonical).digest('hex') +} + +/** A row's identity, for set arithmetic against what was pushed. */ +const rowKey = (row) => `${row.kind}${row.subject}${row.object}` + +/** + * What this site put in a server and has since withdrawn. + * + * `pushed − desired`, and it is the one calculation that cannot be replaced by + * asking the game: a name in the store that is not in the desired set is either + * something the site retired or something a human granted, and those have + * opposite correct answers (D31). Only the pushed ledger tells them apart. + */ +function retirements(pushed, desiredRows) { + const desired = new Set(desiredRows.map(rowKey)) + + return pushed.filter((row) => !desired.has(rowKey(row))) +} + +module.exports = { + FLEET, + normaliseName, + inScope, + overview, + readAuthored, + buildDesired, + retirements, + hashRows, + rowKey, + collapseGrants, + shapeSync, +} diff --git a/server/permSync.js b/server/permSync.js new file mode 100644 index 0000000..00881e1 --- /dev/null +++ b/server/permSync.js @@ -0,0 +1,342 @@ +// ── Keeping a game's permission store equal to what the site authored ───── +// +// R2's whole mechanism, and it is chapter 4's board pointed the other way: the +// site is the single producer of a set, it re-sends the whole thing rather than +// a stream of edits, and the receiver reconciles. What is new is the direction — +// the module telling the game what the site knows, where every earlier phase +// asked the game what it knew. +// +// ── One verb (D32) ──────────────────────────────────────────────────────── +// +// A sync sends the whole desired set and the plugin diffs it against the live +// store. The website never holds a copy of the game's permissions, which is the +// point: a second source of truth is stale the moment it lands, and the store is +// the bigger of the two sets. +// +// The delta the site DOES compute is the one the game cannot: what this site put +// there and has since withdrawn (`retirements`). A name in the store that is not +// in the desired set is either that, or a hand edit — and only the pushed ledger +// can tell them apart (D31). +// +// ── When it runs ────────────────────────────────────────────────────────── +// +// Every tick asks a cheap question — does the digest of the desired set still +// equal what this server last confirmed — and does nothing when the answer is +// yes. A sync therefore happens when: +// +// • an operator changed something (the dirty flag, and the digest behind it) +// • the game restarted or wiped (a new boot id or wipe id: the store may have +// been emptied, and R2's promise is that a wipe is not a data-loss event) +// • a permission hook fired in the game that we did not cause (`ingest.js` +// marks the server dirty; the authoritative answer is this sync's report) +// • the audit interval elapsed — the backstop that finds drift on a quiet +// server nobody has touched +// • the last attempt failed, after a backoff +// +// ── What it never does ──────────────────────────────────────────────────── +// +// It does not remove a grant it did not make (D31), it does not invent a +// permission the server has not registered (D33), and it does not treat a +// silent sidecar as a reason to forget anything. A server that is unreachable +// keeps its retirements and its revocations until it comes back. + +const core = require('./core') + +const db = require('./model/permissions/permissions.db') +const model = require('./model/permissions/permissions.model') +const servers = require('./model/servers/servers.model') +const serversDb = require('./model/servers/servers.db') +const sidecar = require('./sidecarClient') + +const log = core.logger('permissions') + +/** How often the loop asks whether anything needs pushing. */ +const TICK_MS = 30 * 1000 + +/** + * How long a server may go without a full reconciliation, however quiet it is. + * + * The digest comparison is what keeps the loop cheap, and on its own it would + * also mean a server whose store somebody edited by hand is never asked about + * again. This is the interval at which the question gets asked anyway. + */ +const AUDIT_MS = 15 * 60 * 1000 + +/** How long to leave a failing server alone before trying again. */ +const FAIL_BACKOFF_MS = 2 * 60 * 1000 + +/** + * The most rows one sync may carry. + * + * Below the sidecar's line cap and below the plugin's operation ceiling, so the + * refusal happens here — where it can name the server and reach an operator — + * rather than as a `413` or a `too-large` from two processes away. + */ +const MAX_ROWS = 15000 + +let timer = null + +function start() { + if (timer) return + + timer = setInterval(() => { + tick().catch((err) => log.error('permission sync tick failed', { error: err.message })) + }, TICK_MS) + + if (timer.unref) timer.unref() +} + +function stop() { + if (!timer) return + + clearInterval(timer) + timer = null +} + +/** + * One pass over every enabled server. + * + * The authored set is read ONCE and handed to each server's build: six servers + * are six different answers derived from the same four tables, and re-reading + * them per server is six times the queries for identical rows. + */ +async function tick({ force = null } = {}) { + await db.ensureSyncRows() + + const [rows, state, sync, authored] = await Promise.all([ + servers.listForPolling(), + serversDb.listState(), + db.listSync(), + model.readAuthored(), + ]) + + const syncById = new Map(sync.map((row) => [row.serverId, row])) + const stateById = new Map(state.map((row) => [row.serverId, row])) + + // `allSettled`, for the same reason the board poll uses it: one unreachable + // host must not stop the other five being reconciled. + await Promise.allSettled( + rows + .filter((server) => force === null || force === server.id) + .map((server) => + syncOne(server, { + authored, + sync: syncById.get(server.id) || null, + state: stateById.get(server.id) || null, + force: force !== null, + }), + ), + ) +} + +/** + * Whether this server needs a push right now. + * + * Returns a reason rather than a boolean, because the reason is worth logging: + * "why did the website just write to my game server" is a question an operator + * asks, and `wipe` and `drift` are very different answers. + */ +function reasonToSync({ desiredHash, sync, state, force }) { + if (force) return 'requested' + if (!sync) return 'first' + if (sync.state !== 'ok' && sync.lastAttemptAt && age(sync.lastAttemptAt) < FAIL_BACKOFF_MS && !sync.dirty) { + return null + } + if (sync.state !== 'ok') return 'retry' + if (desiredHash !== sync.syncedHash) return 'changed' + if (sync.dirty) return 'dirty' + + const bootId = state && state.bootId ? state.bootId : null + const wipeId = state && state.wipeId ? state.wipeId : null + + // A restart or a wipe is the case R2 exists for: the game may have forgotten + // everything, and the site has not. + if (bootId && bootId !== sync.bootId) return 'restart' + if (wipeId && wipeId !== sync.wipeId) return 'wipe' + + if (!sync.lastAttemptAt || age(sync.lastAttemptAt) >= AUDIT_MS) return 'audit' + + return null +} + +function age(value) { + const at = value instanceof Date ? value.getTime() : new Date(value).getTime() + return Number.isFinite(at) ? Date.now() - at : Number.MAX_SAFE_INTEGER +} + +async function syncOne(server, { authored, sync, state, force }) { + const desired = model.buildDesired(server.id, authored) + const reason = reasonToSync({ desiredHash: desired.hash, sync, state, force }) + + if (!reason) return null + + const [pushed, revocations] = await Promise.all([ + db.listPushed(server.id), + db.listRevocations(server.id), + ]) + + const retirements = model.retirements(pushed, desired.rows) + const retire = [ + ...retirements.map((row) => ({ kind: row.kind, subject: row.subject, object: row.object })), + ...revocations.map((row) => ({ kind: row.kind, subject: row.subject, object: row.object })), + ] + + const bootId = state && state.bootId ? state.bootId : null + const wipeId = state && state.wipeId ? state.wipeId : null + + if (desired.rows.length + retire.length > MAX_ROWS) { + // Refused here rather than sent: the sidecar would answer `413` and the + // plugin would answer `too-large`, and neither of those messages reaches the + // person who has to make the set smaller. + const error = `the permission set is too large to push (${desired.rows.length + retire.length} rows, limit ${MAX_ROWS})` + log.error('permission sync refused', { server: server.id, rows: desired.rows.length }) + await db.putSyncResult(server.id, { + state: 'failed', + desiredHash: desired.hash, + syncedHash: sync ? sync.syncedHash : null, + bootId, + wipeId, + report: null, + error, + }) + + return 'too-large' + } + + log.info('syncing permissions', { + server: server.id, + reason, + rows: desired.rows.length, + retire: retire.length, + }) + + const result = await sidecar.permSync(server, { + setId: desired.hash, + groups: desired.payload.groups, + grants: desired.payload.grants, + managed: desired.payload.managed, + retire, + }) + + if (!result.ok) { + await db.putSyncResult(server.id, { + state: 'failed', + desiredHash: desired.hash, + syncedHash: sync ? sync.syncedHash : null, + bootId, + wipeId, + report: null, + error: result.status, + }) + + return result.status + } + + const report = result.data || {} + + // The plugin refuses a whole sync with `perm.error` — `busy` while an earlier + // one is still draining, `too-large` past its own ceiling. Both are answers + // rather than transport failures, exactly like a refused link code, so they + // arrive as a 200 and are told apart by `kind`. + if (report.kind === 'perm.error') { + await db.putSyncResult(server.id, { + state: 'failed', + desiredHash: desired.hash, + syncedHash: sync ? sync.syncedHash : null, + bootId, + wipeId, + report: null, + error: `the game refused the sync: ${report.reason || 'unknown'}`, + }) + + return report.reason || 'refused' + } + + await applyReport(server, { desired, retire, report, bootId, wipeId }) + + return 'ok' +} + +/** + * Record what the game said it did. + * + * Three writes, and the order matters only in that all three are safe to repeat: + * a sync that crashes here is re-run next tick and reaches the same place, which + * is the property that lets this loop be the only writer. + */ +async function applyReport(server, { desired, retire, report, bootId, wipeId }) { + const unresolved = new Set((report.unresolved || []).map(model.normaliseName)) + const pending = new Set(report.pending || []) + + // A grant naming a permission this server has not registered did NOT land — + // `GrantUserPermission` no-ops silently for an unregistered name, which is + // why the plugin pre-checks and says so. Recording it as pushed would make the + // site believe it had given a privilege it had not. + // + // The same for a member the store could not place: the membership is waiting + // on their first connection, and it is not in the game yet. + const landed = desired.rows.filter((row) => { + if (row.kind === 'grant' || row.kind === 'group-permission') return !unresolved.has(row.object) + if (row.kind === 'member') return !pending.has(`${row.subject}:${row.object}`) + return true + }) + + await db.addPushed(server.id, landed) + + // Everything retired is gone from the game whether the plugin removed it or + // found it already absent, so it stops being something this site put there. + await db.removePushed(server.id, retire) + + const revocations = await db.listRevocations(server.id) + await db.deleteRevocations(revocations.map((row) => row.id)) + + await db.replaceDrift(server.id, (report.foreign || []).map((row) => ({ + kind: String(row.kind || ''), + subject: String(row.subject || ''), + object: String(row.object || ''), + }))) + + await db.putSyncResult(server.id, { + state: 'ok', + desiredHash: desired.hash, + syncedHash: desired.hash, + bootId, + wipeId, + report: JSON.stringify(report), + error: null, + }) + + // The option source, refreshed from the same server that just answered. It is + // a second round trip and it is worth it: the form must not offer a name that + // stopped being registered when somebody uninstalled a plugin, because a grant + // against one is a privilege nobody ever gets and nothing ever reports. + const catalogue = await sidecar.permCatalogue(server) + + if (catalogue.ok && catalogue.data && Array.isArray(catalogue.data.permissions)) { + await db.putCatalogue( + server.id, + catalogue.data.permissions.map(model.normaliseName).filter(Boolean), + ) + } + + log.info('permissions synced', { + server: server.id, + applied: report.applied, + unresolved: (report.unresolved || []).length, + foreign: (report.foreign || []).length, + pending: (report.pending || []).length, + }) +} + +module.exports = { + TICK_MS, + AUDIT_MS, + FAIL_BACKOFF_MS, + MAX_ROWS, + start, + stop, + tick, + syncOne, + reasonToSync, + applyReport, +} diff --git a/server/router/admin/permissions.controller.js b/server/router/admin/permissions.controller.js new file mode 100644 index 0000000..e2649c7 --- /dev/null +++ b/server/router/admin/permissions.controller.js @@ -0,0 +1,424 @@ +// ── Admin · Rust · Permissions ──────────────────────────────────────────── +// +// The authoring surface for R2. Everything here writes to the site's own tables +// and marks the affected servers dirty; nothing here talks to a game. The push +// is `permSync.js`'s loop, which is deliberate — a form that wrote to six game +// hosts inside the request would fail differently for each of them and have no +// honest status code to answer with. +// +// **The one exception is "sync now"**, which runs the loop's pass for one server +// and waits for it. It exists because an operator who has just changed something +// wants to see it land, and because waiting thirty seconds to find out that a +// server is unreachable is a bad way to learn it. +// +// Every write logs an activity row. These rows decide who may do what inside +// somebody's game server, which is the one thing on this module's admin tier +// more consequential than the sidecar credential. + +const core = require('../../core') + +const db = require('../../model/permissions/permissions.db') +const model = require('../../model/permissions/permissions.model') +const permSync = require('../../permSync') +const servers = require('../../model/servers/servers.model') + +const log = core.logger('admin:permissions') + +/** Everything the screen renders: groups, grants, drift, the catalogue, per-server state. */ +async function overview(req, res) { + try { + res.json(await model.overview()) + } catch (err) { + log.error('failed to read the permission model', { error: err.message }) + res.status(500).json({ message: 'Failed to read the permission model' }) + } +} + +/** + * Create or update a group. + * + * The permission list is part of the same write, because that is how the form + * edits it: a group and what it carries are one idea on the screen, and two + * requests would leave a group briefly carrying the wrong set. + */ +async function putGroup(req, res) { + const name = model.normaliseName(req.params.name) + const scope = String(req.body.scope || model.FLEET) + + try { + if (scope !== model.FLEET && !(await knownServer(scope))) { + return res.status(400).json({ message: 'That scope names no configured server' }) + } + + const previous = await db.getGroup(name) + + await db.upsertGroup({ + name, + title: String(req.body.title || name), + rank: Number(req.body.rank) || 0, + scope, + }) + + const permissions = [...new Set((req.body.permissions || []).map(model.normaliseName))].filter(Boolean) + await db.setGroupPermissions(name, permissions) + + // Both scopes: a group that moved from one server to another has to be + // retired from where it was as well as applied where it now is, and only the + // old scope knows the first half. + await db.markDirty(scope) + if (previous && previous.scope !== scope) await db.markDirty(previous.scope) + + await core.activity.log({ + req, + action: previous ? 'rust.perm.group.update' : 'rust.perm.group.create', + detail: { group: name, scope, permissions: permissions.length }, + }) + + return res.status(204).end() + } catch (err) { + log.error('failed to save a group', { group: name, error: err.message }) + return res.status(500).json({ message: 'Failed to save that group' }) + } +} + +async function deleteGroup(req, res) { + const name = model.normaliseName(req.params.name) + + try { + const existing = await db.getGroup(name) + if (!existing) return res.status(404).json({ message: 'No such group' }) + + await db.deleteGroup(name) + await db.markDirty(existing.scope) + + await core.activity.log({ req, action: 'rust.perm.group.delete', detail: { group: name } }) + + return res.status(204).end() + } catch (err) { + log.error('failed to delete a group', { group: name, error: err.message }) + return res.status(500).json({ message: 'Failed to delete that group' }) + } +} + +async function addMember(req, res) { + const name = model.normaliseName(req.params.name) + + try { + const group = await db.getGroup(name) + if (!group) return res.status(404).json({ message: 'No such group' }) + + const userId = await resolveUser(req.body) + if (!userId) return res.status(404).json({ message: 'No account on this site has that name' }) + + await db.addGroupMember(name, userId, req.user ? req.user.id : null) + await db.markDirty(group.scope) + + await core.activity.log({ + req, + action: 'rust.perm.member.add', + detail: { group: name, userId }, + }) + + return res.status(204).end() + } catch (err) { + // A user id that names nobody fails on the foreign key rather than on a + // check of our own: the row is the constraint, and one round trip is + // cheaper than two. + log.error('failed to add a member', { group: name, userId, error: err.message }) + return res.status(400).json({ message: 'That account could not be added to the group' }) + } +} + +async function removeMember(req, res) { + const name = model.normaliseName(req.params.name) + const userId = Number(req.params.userId) + + try { + const group = await db.getGroup(name) + if (!group) return res.status(404).json({ message: 'No such group' }) + + const removed = await db.removeGroupMember(name, userId) + if (!removed) return res.status(404).json({ message: 'That account is not in the group' }) + + await db.markDirty(group.scope) + await core.activity.log({ + req, + action: 'rust.perm.member.remove', + detail: { group: name, userId }, + }) + + return res.status(204).end() + } catch (err) { + log.error('failed to remove a member', { group: name, userId, error: err.message }) + return res.status(500).json({ message: 'Failed to remove that account from the group' }) + } +} + +/** + * Grant one permission to one person. + * + * `source` is fixed at `admin` here and is not accepted from the body: the + * column exists so phase 13's event actions can write their own rows through the + * same table, and a route that let a caller choose would make "who gave this" + * unanswerable the first time somebody passed the wrong string. + */ +async function addGrant(req, res) { + const permission = model.normaliseName(req.body.permission) + const scope = String(req.body.scope || model.FLEET) + let userId = null + + try { + if (scope !== model.FLEET && !(await knownServer(scope))) { + return res.status(400).json({ message: 'That scope names no configured server' }) + } + + userId = await resolveUser(req.body) + if (!userId) return res.status(404).json({ message: 'No account on this site has that name' }) + + const { inserted } = await db.insertGrant({ + userId, + permission, + scope, + source: 'admin', + note: req.body.note ? String(req.body.note).slice(0, 255) : null, + grantedBy: req.user ? req.user.id : null, + }) + + if (inserted) { + await db.markDirty(scope) + await core.activity.log({ + req, + action: 'rust.perm.grant', + detail: { userId, permission, scope }, + }) + } + + return res.status(inserted ? 201 : 200).json({ granted: inserted }) + } catch (err) { + log.error('failed to grant', { userId, permission, error: err.message }) + return res.status(400).json({ message: 'That permission could not be granted' }) + } +} + +async function removeGrant(req, res) { + const id = Number(req.params.id) + + try { + const grant = await db.getGrant(id) + if (!grant) return res.status(404).json({ message: 'No such grant' }) + + await db.deleteGrant(id) + await db.markDirty(grant.scope) + + await core.activity.log({ + req, + action: 'rust.perm.revoke', + detail: { userId: grant.userId, permission: grant.permission, scope: grant.scope }, + }) + + return res.status(204).end() + } catch (err) { + log.error('failed to revoke a grant', { grant: id, error: err.message }) + return res.status(500).json({ message: 'Failed to remove that grant' }) + } +} + +/** + * Adopt a hand edit: the site records it as its own. + * + * It is only possible for a `grant` whose Steam id belongs to a website account, + * and the refusal says so — because the alternative is authoring privilege + * against a game account no person on this site holds, which is precisely the + * thing D28 decided not to do. + */ +async function adoptDrift(req, res) { + const id = Number(req.params.id) + + try { + const row = await db.getDrift(id) + if (!row) return res.status(404).json({ message: 'No such drift' }) + + if (row.kind !== 'grant' && row.kind !== 'member') { + return res.status(400).json({ + message: 'Only a grant or a membership can be adopted. A permission on a group is edited on the group itself.', + }) + } + + const holder = await holderOf(row.subject) + + if (!holder) { + return res.status(409).json({ + message: + 'That Steam account is not linked to any account on this site, so there is nobody to author this against. Revoke it instead, or ask the player to link.', + }) + } + + if (row.kind === 'grant') { + await db.insertGrant({ + userId: holder.userId, + permission: row.object, + scope: row.serverId, + source: 'adopted', + note: 'Adopted from a hand edit', + grantedBy: req.user ? req.user.id : null, + }) + } else { + const group = await db.getGroup(row.object) + if (!group) return res.status(409).json({ message: 'That group is not authored on this site' }) + + await db.addGroupMember(row.object, holder.userId, req.user ? req.user.id : null) + } + + // Already in the game, so it is already pushed — recorded as such rather + // than left for the next sync to "apply". Without this the row would be + // desired-but-not-pushed, which is a state the loop would happily write + // again and the game would report as already correct: harmless, and a lie in + // the one table that exists to say what this site put there. + await db.addPushed(row.serverId, [{ kind: row.kind, subject: row.subject, object: row.object }]) + await db.deleteDrift(id) + await db.markDirty(row.serverId) + + await core.activity.log({ + req, + action: 'rust.perm.drift.adopt', + detail: { server: row.serverId, kind: row.kind, subject: row.subject, object: row.object }, + }) + + return res.status(204).end() + } catch (err) { + log.error('failed to adopt drift', { drift: id, error: err.message }) + return res.status(500).json({ message: 'Failed to adopt that change' }) + } +} + +/** + * Revoke a hand edit. + * + * Queued rather than sent: the server may be down, and an instruction that is + * dropped because a game host was restarting is exactly the behaviour a site + * claiming to be the author of record must not have. The next successful sync + * carries it and the queue row goes. + */ +async function revokeDrift(req, res) { + const id = Number(req.params.id) + + try { + const row = await db.getDrift(id) + if (!row) return res.status(404).json({ message: 'No such drift' }) + + await db.queueRevocation({ + serverId: row.serverId, + kind: row.kind, + subject: row.subject, + object: row.object, + requestedBy: req.user ? req.user.id : null, + }) + + await db.deleteDrift(id) + await db.markDirty(row.serverId) + + await core.activity.log({ + req, + action: 'rust.perm.drift.revoke', + detail: { server: row.serverId, kind: row.kind, subject: row.subject, object: row.object }, + }) + + return res.status(202).json({ queued: true }) + } catch (err) { + log.error('failed to queue a revocation', { drift: id, error: err.message }) + return res.status(500).json({ message: 'Failed to queue that revocation' }) + } +} + +/** Run the loop's pass now, for one server or for all of them, and report what happened. */ +async function syncNow(req, res) { + const serverId = req.body && req.body.serverId ? String(req.body.serverId) : null + + try { + if (serverId && !(await knownServer(serverId))) { + return res.status(404).json({ message: 'No such server' }) + } + + await db.markDirty(serverId || model.FLEET) + await permSync.tick({ force: serverId }) + + await core.activity.log({ + req, + action: 'rust.perm.sync', + detail: { server: serverId || 'all' }, + }) + + const state = await model.overview() + return res.json({ servers: state.servers, drift: state.drift }) + } catch (err) { + log.error('a forced sync failed', { server: serverId, error: err.message }) + return res.status(500).json({ message: 'Failed to run the sync' }) + } +} + +/** Every permission name any configured server has registered, with which ones know it. */ +async function catalogue(req, res) { + try { + const rows = await db.listCatalogue() + res.json({ permissions: groupCatalogue(rows) }) + } catch (err) { + log.error('failed to read the catalogue', { error: err.message }) + res.status(500).json({ message: 'Failed to read the permission catalogue' }) + } +} + +function groupCatalogue(rows) { + const byPermission = new Map() + + for (const row of rows) { + if (!byPermission.has(row.permission)) byPermission.set(row.permission, []) + byPermission.get(row.permission).push(row.serverId) + } + + return [...byPermission.entries()] + .map(([permission, serverIds]) => ({ permission, servers: serverIds })) + .sort((a, b) => a.permission.localeCompare(b.permission)) +} + +/** + * The user id a write is about, from either an id or a username. + * + * The form sends a name, because a form that made an operator type a numeric id + * would be a form nobody could use. The id form stays accepted because the + * client already holds one on the panel inside core's user page, and looking a + * name back up from it would be a round trip to answer a question it has + * already answered. + */ +async function resolveUser(body) { + if (body.userId) return Number(body.userId) + if (!body.username) return null + + const user = await db.findUserByUsername(String(body.username).trim()) + return user ? user.id : null +} + +/** Whether a scope names a server row. A disabled server still counts — it exists. */ +async function knownServer(id) { + const rows = await servers.listForAdmin() + return rows.some((row) => row.id === id) +} + +/** The website account that holds a Steam id, or null. */ +async function holderOf(steamId) { + const links = await db.listLinks() + return links.find((link) => link.steamId === steamId) || null +} + +module.exports = { + overview, + putGroup, + deleteGroup, + addMember, + removeMember, + addGrant, + removeGrant, + adoptDrift, + revokeDrift, + syncNow, + catalogue, +} diff --git a/server/router/admin/permissions.router.js b/server/router/admin/permissions.router.js new file mode 100644 index 0000000..69a3f3f --- /dev/null +++ b/server/router/admin/permissions.router.js @@ -0,0 +1,184 @@ +// ── Admin · Rust · Permissions ──────────────────────────────────────────── +// +// Mounted under the admin tier's `/rust` prefix, so every path here is +// `/api/v1/admin/rust/permissions…`. It is a second router rather than more +// routes on `rust.router.js` because it is a second subject: that one configures +// the bridge, this one authors privilege inside somebody's game. +// +// **Every route is `requireRole('admin')`.** The admin tier's own gate admits +// editors and moderators, and a moderator being able to grant themselves +// `kits.admin` on six servers is the whole of R1's "a weak link is now a +// privilege-escalation path" arriving through the front door instead. The tier +// gate is not re-implemented; this is one gate on top of it, exactly as the +// server-configuration routes do it. +// +// There is no module-declared site permission to gate these more finely with — +// `MODULE_API.md` has no such member at 1.10.0 — so role is the whole of the +// available vocabulary, and `admin` is the honest choice within it. + +const core = require('../../core') + +const express = core.express +const permissions = require('./permissions.controller') +const { requireRole, validate } = core.middleware +const { body, param } = core.validator + +const permissionsRouter = express.Router() + +/** A permission or group name, as both mod frameworks store them. */ +const NAME = /^[a-z0-9][a-z0-9._-]{0,127}$/i + +permissionsRouter.get( + '/', + // #swagger.tags = ['Admin · Rust'] + // #swagger.summary = 'The whole permission model' + // #swagger.description = 'Groups with their permissions and members, direct grants, the drift each server reported, the option source of registered permission names, and the sync state of every configured server.' + /* #swagger.responses[200] = { description: 'The authored model and what each game reported', content: { "application/json": { schema: { $ref: "#/components/schemas/RustPermissionModel" } } } } */ + requireRole('admin'), + permissions.overview, +) + +permissionsRouter.get( + '/catalogue', + // #swagger.tags = ['Admin · Rust'] + // #swagger.summary = 'Permission names the servers have registered' + // #swagger.description = 'What the loaded plugins on each configured server have registered, cached from the last sync. It is the option source for the authoring form: a permission no server knows cannot be granted, because `GrantUserPermission` silently does nothing for an unregistered name.' + /* #swagger.responses[200] = { description: 'Every registered name, and which servers know it', content: { "application/json": { schema: { $ref: "#/components/schemas/RustPermissionCatalogue" } } } } */ + requireRole('admin'), + permissions.catalogue, +) + +permissionsRouter.put( + '/groups/:name', + // #swagger.tags = ['Admin · Rust'] + // #swagger.summary = 'Create or update a permission group' + // #swagger.description = 'Writes the group and the permissions it carries in one request, because they are one idea on the form. `scope` is a server id or `*` for the whole fleet. The group is mirrored into each in-scope game as a real group, so third-party plugins that read group membership see it.' + /* #swagger.responses[204] = { description: 'Saved' } */ + /* #swagger.responses[400] = { description: 'Invalid body, or a scope naming no configured server' } */ + requireRole('admin'), + param('name').matches(NAME).withMessage('a group name is letters, digits, dots, dashes and underscores'), + body('title').optional().isString().trim().isLength({ max: 120 }), + body('rank').optional().isInt({ min: -1000, max: 1000 }).toInt(), + body('scope').optional().isString().isLength({ min: 1, max: 64 }), + body('permissions').optional().isArray({ max: 500 }), + body('permissions.*').isString().matches(NAME), + validate, + permissions.putGroup, +) + +permissionsRouter.delete( + '/groups/:name', + // #swagger.tags = ['Admin · Rust'] + // #swagger.summary = 'Delete a permission group' + // #swagger.description = 'Removes the group, its permission list and its membership from the site. The next sync retires the group from every server it had been pushed to — a group the site authored and has withdrawn is removed from the game, unlike one somebody created by hand.' + /* #swagger.responses[204] = { description: 'Deleted' } */ + /* #swagger.responses[404] = { description: 'No such group' } */ + requireRole('admin'), + param('name').isString().isLength({ min: 1, max: 64 }), + validate, + permissions.deleteGroup, +) + +permissionsRouter.post( + '/groups/:name/members', + // #swagger.tags = ['Admin · Rust'] + // #swagger.summary = 'Put an account in a group' + // #swagger.description = 'Membership is authored against a website user and reaches every Steam account they have linked. A member who has never connected to a server cannot be placed in its store yet — the sync reports them as pending and the membership lands on their first connection.' + /* #swagger.responses[204] = { description: 'Added' } */ + /* #swagger.responses[404] = { description: 'No such group' } */ + requireRole('admin'), + param('name').isString().isLength({ min: 1, max: 64 }), + // Either identifier: the screen sends a name, the panel inside core's own user + // page already holds an id. + body('userId').optional().isInt({ min: 1 }).toInt(), + body('username').optional().isString().trim().isLength({ min: 1, max: 64 }), + validate, + permissions.addMember, +) + +permissionsRouter.delete( + '/groups/:name/members/:userId', + // #swagger.tags = ['Admin · Rust'] + // #swagger.summary = 'Take an account out of a group' + /* #swagger.responses[204] = { description: 'Removed' } */ + /* #swagger.responses[404] = { description: 'No such group, or that account is not in it' } */ + requireRole('admin'), + param('name').isString().isLength({ min: 1, max: 64 }), + param('userId').isInt({ min: 1 }).toInt(), + validate, + permissions.removeMember, +) + +permissionsRouter.post( + '/grants', + // #swagger.tags = ['Admin · Rust'] + // #swagger.summary = 'Grant one permission to one person' + // #swagger.description = 'A direct grant, authored against a website user and pushed to every Steam account they have linked. Unlike group membership it reaches a player who has never connected to the server, which is what an entitlement earned on the website has to do.' + /* #swagger.responses[201] = { description: 'Granted' } */ + /* #swagger.responses[200] = { description: 'They already held it; nothing changed' } */ + /* #swagger.responses[400] = { description: 'Invalid body, or a scope naming no configured server' } */ + /* #swagger.responses[404] = { description: 'No account on this site has that name' } */ + requireRole('admin'), + body('userId').optional().isInt({ min: 1 }).toInt(), + body('username').optional().isString().trim().isLength({ min: 1, max: 64 }), + body('permission').isString().matches(NAME), + body('scope').optional().isString().isLength({ min: 1, max: 64 }), + body('note').optional().isString().isLength({ max: 255 }), + validate, + permissions.addGrant, +) + +permissionsRouter.delete( + '/grants/:id', + // #swagger.tags = ['Admin · Rust'] + // #swagger.summary = 'Remove a grant' + // #swagger.description = 'The next sync revokes it in every in-scope game. A player who has already used what it allowed keeps what they did with it — the grant is the entitlement, not the consumption.' + /* #swagger.responses[204] = { description: 'Removed' } */ + /* #swagger.responses[404] = { description: 'No such grant' } */ + requireRole('admin'), + param('id').isInt({ min: 1 }).toInt(), + validate, + permissions.removeGrant, +) + +permissionsRouter.post( + '/drift/:id/adopt', + // #swagger.tags = ['Admin · Rust'] + // #swagger.summary = 'Adopt a hand edit' + // #swagger.description = 'Records a grant or membership somebody made in game as one the site authors, so it stops being reported and starts being maintained. It needs a website account holding that Steam id; without one there is nobody to author it against, and the answer is to revoke it or to ask the player to link.' + /* #swagger.responses[204] = { description: 'Adopted' } */ + /* #swagger.responses[400] = { description: 'That kind of drift cannot be adopted' } */ + /* #swagger.responses[409] = { description: 'That Steam account is linked to nobody on this site' } */ + requireRole('admin'), + param('id').isInt({ min: 1 }).toInt(), + validate, + permissions.adoptDrift, +) + +permissionsRouter.post( + '/drift/:id/revoke', + // #swagger.tags = ['Admin · Rust'] + // #swagger.summary = 'Revoke a hand edit' + // #swagger.description = 'Queues the removal rather than performing it: a server that is down keeps the instruction until it comes back. This is the only way the site removes something it did not put there — a sync never does it on its own.' + /* #swagger.responses[202] = { description: 'Queued for the next sync' } */ + /* #swagger.responses[404] = { description: 'No such drift' } */ + requireRole('admin'), + param('id').isInt({ min: 1 }).toInt(), + validate, + permissions.revokeDrift, +) + +permissionsRouter.post( + '/sync', + // #swagger.tags = ['Admin · Rust'] + // #swagger.summary = 'Push the permission set now' + // #swagger.description = 'Runs the reconciliation loop’s pass immediately, for one server or for all of them, and answers with what each one reported. The loop does this on its own; the button exists so an operator who has just changed something can see it land, and finds out at once when a server is unreachable.' + /* #swagger.responses[200] = { description: 'The state of every server after the pass', content: { "application/json": { schema: { $ref: "#/components/schemas/RustPermissionSyncResult" } } } } */ + /* #swagger.responses[404] = { description: 'No such server' } */ + requireRole('admin'), + body('serverId').optional().isString().isLength({ min: 1, max: 64 }), + validate, + permissions.syncNow, +) + +module.exports = permissionsRouter diff --git a/server/router/admin/rust.router.js b/server/router/admin/rust.router.js index c91d993..c0f7019 100644 --- a/server/router/admin/rust.router.js +++ b/server/router/admin/rust.router.js @@ -27,6 +27,11 @@ const { body, param } = core.validator const adminRustRouter = express.Router() +// R2's authoring surface, under `/rust/permissions`. Its own file because it is +// its own subject — this router configures the bridge, that one decides who may +// do what inside the game the bridge reaches. +adminRustRouter.use('/permissions', require('./permissions.router')) + adminRustRouter.get( '/servers', // #swagger.tags = ['Admin · Rust'] diff --git a/server/router/admin/usersRust.controller.js b/server/router/admin/usersRust.controller.js index 6fb2592..b45cd54 100644 --- a/server/router/admin/usersRust.controller.js +++ b/server/router/admin/usersRust.controller.js @@ -8,6 +8,9 @@ const core = require('../../core') const links = require('../../model/links/links.model') +const permissionsDb = require('../../model/permissions/permissions.db') +const permissions = require('../../model/permissions/permissions.model') +const servers = require('../../model/servers/servers.model') const log = core.logger('admin') @@ -68,4 +71,132 @@ async function removeLink(req, res) { } } -module.exports = { listLinks, removeLink } +/** + * GET /admin/users/:id/rust/permissions + * + * What this person may do in game, and — the part that is easy to leave out — + * whether any of it reaches anybody. A grant against an account with no linked + * Steam id is authored, stored, pushed nowhere and looks identical to a working + * one on every screen that does not say so. + */ +async function listPermissions(req, res) { + const userId = Number(req.params.id) + + try { + const [groups, groupPermissions, members, grants, allLinks] = await Promise.all([ + permissionsDb.listGroups(), + permissionsDb.listGroupPermissions(), + permissionsDb.listGroupMembers(), + permissionsDb.listGrants({ userId }), + permissionsDb.listLinks(), + ]) + + const theirs = new Set( + members.filter((row) => row.userId === userId).map((row) => row.groupName), + ) + + const carried = new Map() + for (const row of groupPermissions) { + if (!carried.has(row.groupName)) carried.set(row.groupName, []) + carried.get(row.groupName).push(row.permission) + } + + res.json({ + groups: groups + .filter((group) => theirs.has(group.name)) + .map((group) => ({ + name: group.name, + title: group.title, + scope: group.scope, + permissions: carried.get(group.name) || [], + })), + grants: permissions.collapseGrants(grants).map((grant) => ({ + id: grant.id, + permission: grant.permission, + scope: grant.scope, + source: grant.source, + note: grant.note, + grantedAt: grant.grantedAt, + })), + reaches: allLinks.filter((link) => link.userId === userId).map((link) => link.steamId), + }) + } catch (err) { + log.error('failed to read a user’s Rust permissions', { error: err.message }) + res.status(500).json({ message: 'Failed to read this user’s Rust permissions' }) + } +} + +/** POST /admin/users/:id/rust/permissions/grants */ +async function addGrant(req, res) { + const userId = Number(req.params.id) + const permission = permissions.normaliseName(req.body.permission) + const scope = String(req.body.scope || permissions.FLEET) + + try { + if (scope !== permissions.FLEET) { + const known = await servers.listForAdmin() + if (!known.some((row) => row.id === scope)) { + return res.status(400).json({ message: 'That scope names no configured server' }) + } + } + + const { inserted } = await permissionsDb.insertGrant({ + userId, + permission, + scope, + source: 'admin', + note: null, + grantedBy: req.user ? req.user.id : null, + }) + + if (inserted) { + await permissionsDb.markDirty(scope) + await core.activity.log({ + req, + action: 'rust.perm.grant', + detail: { userId, permission, scope }, + }) + } + + return res.status(inserted ? 201 : 200).json({ granted: inserted }) + } catch (err) { + log.error('failed to grant a permission', { userId, permission, error: err.message }) + return res.status(400).json({ message: 'That permission could not be granted' }) + } +} + +/** + * DELETE /admin/users/:id/rust/permissions/grants/:grantId + * + * **Scoped by the user as well as by the grant**, like every other write in this + * panel: a grant id belonging to somebody else answers `404` rather than + * removing a privilege from a person whose page nobody was looking at. + */ +async function removeGrant(req, res) { + const userId = Number(req.params.id) + const grantId = Number(req.params.grantId) + + try { + const grant = await permissionsDb.getGrant(grantId) + + if (!grant || grant.userId !== userId) { + return res.status(404).json({ message: 'That grant does not belong to this user' }) + } + + await permissionsDb.deleteGrant(grantId) + await permissionsDb.markDirty(grant.scope) + + await core.activity.log({ + req, + action: 'rust.perm.revoke', + detail: { userId, permission: grant.permission, scope: grant.scope }, + }) + + return res.status(204).end() + } catch (err) { + log.error('failed to remove a grant', { userId, grant: grantId, error: err.message }) + return res.status(500).json({ message: 'Failed to remove that permission' }) + } +} + +module.exports = { listLinks, removeLink, listPermissions, addGrant, removeGrant } diff --git a/server/router/admin/usersRust.router.js b/server/router/admin/usersRust.router.js index feed707..24c830c 100644 --- a/server/router/admin/usersRust.router.js +++ b/server/router/admin/usersRust.router.js @@ -30,7 +30,7 @@ const core = require('../../core') const express = core.express -const { param } = core.validator +const { body, param } = core.validator const usersRust = require('./usersRust.controller') const { validate } = core.middleware @@ -70,4 +70,62 @@ usersRustRouter.delete( usersRust.removeLink, ) +// ── Phase 7: what this person may do in game ───────────────────────────── +// +// The same panel, one section lower. It is here rather than only on the +// permissions screen because the question an operator actually has is about a +// PERSON — "why can this player spawn a kit" is asked on their page, not on a +// list of groups — and because the slot is already the place this module says +// everything else it knows about one user. +// +// Both writes go through the ordinary authored tables and the ordinary loop. A +// grant made here reaches the game when the mirror next reconciles, which is +// seconds, and never inside this request. + +usersRustRouter.get( + '/rust/permissions', + // #swagger.tags = ['Admin · Users'] + // #swagger.summary = 'A user’s Rust privileges (admin only)' + // #swagger.description = 'The groups this person is in, the permissions granted to them directly, and the Steam accounts those privileges actually reach. An empty `reaches` means they have linked nothing and hold them on paper only.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' } + /* #swagger.responses[200] = { description: 'Their groups and grants', content: { "application/json": { schema: { $ref: "#/components/schemas/RustUserPermissions" } } } } */ + param('id').isInt(), + validate, + usersRust.listPermissions, +) + +usersRustRouter.post( + '/rust/permissions/grants', + // #swagger.tags = ['Admin · Users'] + // #swagger.summary = 'Grant a Rust permission to this user (admin only)' + // #swagger.description = 'Authored against the website account, so it reaches every Steam id they have linked — now and later. `scope` is a server id or `*` for the fleet. The push happens on the mirror’s next pass.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' } + /* #swagger.responses[201] = { description: 'Granted' } */ + /* #swagger.responses[200] = { description: 'They already held it' } */ + /* #swagger.responses[400] = { description: 'Invalid body, or a scope naming no configured server', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('id').isInt(), + body('permission').isString().matches(/^[a-z0-9][a-z0-9._-]{0,127}$/i), + body('scope').optional().isString().isLength({ min: 1, max: 64 }), + validate, + usersRust.addGrant, +) + +usersRustRouter.delete( + '/rust/permissions/grants/:grantId', + // #swagger.tags = ['Admin · Users'] + // #swagger.summary = 'Remove a Rust permission from this user (admin only)' + // #swagger.description = 'Scoped to this user as well as to the grant, so a wrong id on the URL removes nothing rather than somebody else’s privilege. The revoke reaches the game on the mirror’s next pass.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' } + // #swagger.parameters['grantId'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'The grant to remove.' } + /* #swagger.responses[204] = { description: 'Removed' } */ + /* #swagger.responses[404] = { description: 'No such grant for this user', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('id').isInt(), + param('grantId').isInt({ min: 1 }).toInt(), + validate, + usersRust.removeGrant, +) + module.exports = usersRustRouter diff --git a/server/scripts/swaggerFragment.js b/server/scripts/swaggerFragment.js index 09a6c97..2b2e0c0 100644 --- a/server/scripts/swaggerFragment.js +++ b/server/scripts/swaggerFragment.js @@ -76,6 +76,16 @@ const SLOT_MOUNT = { 'admin.users.detail': '/api/v1/admin/users/:id', } +// A router mounted INSIDE a registered one with `use()` needs nothing here, and +// that was worth finding out: swagger-autogen reads a FILE and follows its +// `require`s, so `/rust/permissions` is generated with the right prefix from +// `rust.router.js` alone. It is the opposite of the hole phase 6 found with the +// slot — the registration walk cannot see a nested router, and the generator can. +// +// A nested router exists at all because a mount prefix is ONE path segment +// (core's `PREFIX` is `/^\/[a-z0-9][a-z0-9-]*$/`), so `/rust/permissions` cannot +// be declared in `module.json` and has to be a `use()` under `/rust`. + /** * Run `register()` with a recording api and return `[{ file, prefix, what }]`. * diff --git a/server/sidecarClient.js b/server/sidecarClient.js index 8683704..a13b1fe 100644 --- a/server/sidecarClient.js +++ b/server/sidecarClient.js @@ -52,9 +52,11 @@ const TIMEOUT_MS = 12000 * here, `PROTOCOL_VERSION` in the sidecar, `ProtocolVersion` in the bridge * plugin, and `protocol` in its `overlay.toml`. * - * **3 — identity.** Protocol 2 was the read path; 3 adds the first message the - * WEBSITE originates (`link.confirm`) and the two account frames the plugin - * emits beside it. The bump lands here in the same change as the emitters, + * **4 — the permission mirror.** Protocol 2 was the read path, 3 the first + * message the WEBSITE originates (`link.confirm`); 4 is the first that WRITES + * to the game — the whole permission set the site authors for one server, and + * the report the plugin sends back. The bump lands here in the same change as + * the emitters, * because the sidecar refuses a client declaring a different version with a * `409`: a module left on 2 would stop being able to read the server board it * has been reading all along. A constant that lags the deployment is not a safe @@ -64,7 +66,7 @@ const TIMEOUT_MS = 12000 * deployment into a `409` naming both numbers instead of a parse failure three * layers further in. */ -const PROTOCOL_VERSION = 3 +const PROTOCOL_VERSION = 4 /** What a caller gets back. Shaped once so every call site reads the same. */ function reply(ok, status, data = null) { @@ -210,6 +212,33 @@ const feedTail = (server) => request(server, '/feed') const confirmLink = (server, code) => request(server, '/link/confirm', { method: 'POST', body: { code } }) +/** + * What one server's loaded plugins have registered, and the groups its store + * holds (protocol 4). + * + * The option source behind the authoring form (D33). It is a live read through + * to the game rather than anything cached at the sidecar, because the answer + * changes when an operator loads a plugin — and the whole reason to ask is to + * offer names that will actually resolve. It therefore fails when the game is + * down, like `/status` and unlike every store-backed read. + */ +const permCatalogue = (server) => request(server, '/permissions/catalogue') + +/** + * Push the whole permission set this site authors for one server (protocol 4). + * + * **The second call in this file that is not a GET, and the first that changes + * the game.** The body is the desired set plus what the site has withdrawn; the + * plugin diffs it against the live store, applies the difference and answers + * with a report — counts, the names it could not resolve, the memberships that + * are waiting on a first connection, and every holder the site did not author. + * + * **A refusal comes back `{ ok: true }`**, like a refused link code: `perm.error` + * and `perm.report` are both answers, and the sidecar keeps its own status codes + * for the transport. The caller discriminates on `data.kind`. + */ +const permSync = (server, set) => request(server, '/permissions/sync', { method: 'POST', body: set }) + module.exports = { TIMEOUT_MS, PROTOCOL_VERSION, @@ -221,5 +250,7 @@ module.exports = { feed, feedTail, confirmLink, + permCatalogue, + permSync, joinUrl, } diff --git a/server/swagger/doc.js b/server/swagger/doc.js index ca16863..27fa245 100644 --- a/server/swagger/doc.js +++ b/server/swagger/doc.js @@ -195,6 +195,237 @@ module.exports = { }, }, }, + RustPermissionModel: { + type: 'object', + description: + 'The whole permission model (GET /admin/rust/permissions): what the site authors, what each game reported back, and the names a grant may use.', + properties: { + groups: { + type: 'array', + description: 'Groups the site authors, mirrored into each in-scope game as a real group.', + items: { + type: 'object', + properties: { + name: { type: 'string', example: 'vip' }, + title: { type: 'string', example: 'VIP' }, + rank: { type: 'integer', example: 10 }, + scope: { + type: 'string', + description: 'A server id, or `*` for every server.', + example: '*', + }, + permissions: { type: 'array', items: { type: 'string', example: 'kits.vip' } }, + members: { + type: 'array', + items: { + type: 'object', + properties: { + userId: { type: 'integer', example: 42 }, + username: { type: 'string', example: 'wanderer' }, + steamId: { + type: 'string', + nullable: true, + description: 'Null when this account has linked no Steam id, in which case the membership reaches nobody yet.', + example: '76561198000000000', + }, + playerName: { type: 'string', nullable: true, example: 'Wanderer' }, + }, + }, + }, + }, + }, + }, + grants: { + type: 'array', + description: 'Permissions held by one person without a group. Unlike membership, a direct grant reaches a player who has never connected.', + items: { + type: 'object', + properties: { + id: { type: 'integer', example: 7 }, + userId: { type: 'integer', example: 42 }, + username: { type: 'string', example: 'wanderer' }, + permission: { type: 'string', example: 'kits.gold' }, + scope: { type: 'string', example: 'main' }, + source: { + type: 'string', + description: 'What authored it — `admin`, `adopted`, or a later phase’s own writer.', + example: 'admin', + }, + note: { type: 'string', nullable: true, example: null }, + grantedAt: { type: 'string', format: 'date-time' }, + accounts: { + type: 'array', + description: 'The Steam accounts this grant reaches. Empty means it reaches nobody yet.', + items: { + type: 'object', + properties: { + steamId: { type: 'string', example: '76561198000000000' }, + name: { type: 'string', nullable: true, example: 'Wanderer' }, + }, + }, + }, + }, + }, + }, + servers: { + type: 'array', + description: 'The state of the mirror, per configured server.', + items: { $ref: '#/components/schemas/RustPermissionSyncState' }, + }, + drift: { + type: 'array', + description: 'What a game holds that the site did not author. Reported, never undone.', + items: { + type: 'object', + properties: { + id: { type: 'integer', example: 3 }, + serverId: { type: 'string', example: 'main' }, + kind: { + type: 'string', + description: 'One of `grant`, `member`, `group-permission`.', + example: 'grant', + }, + subject: { + type: 'string', + description: 'A Steam id, or a group name.', + example: '76561198000000000', + }, + object: { + type: 'string', + description: 'A permission name, or a group name.', + example: 'kits.admin', + }, + username: { + type: 'string', + nullable: true, + description: 'The website account holding that Steam id, when there is one. Without it the drift cannot be adopted, only revoked.', + example: 'wanderer', + }, + firstSeen: { type: 'string', format: 'date-time' }, + }, + }, + }, + catalogue: { + type: 'array', + items: { $ref: '#/components/schemas/RustPermissionCatalogueEntry' }, + }, + }, + }, + RustPermissionSyncState: { + type: 'object', + description: 'Whether one server’s store matches what the site authors, and what its last report said.', + properties: { + serverId: { type: 'string', example: 'main' }, + state: { + type: 'string', + description: 'One of `pending`, `ok`, `failed`.', + example: 'ok', + }, + inSync: { + type: 'boolean', + description: 'True when the last successful push carried the set the site currently authors.', + example: true, + }, + dirty: { type: 'boolean', example: false }, + lastAttemptAt: { type: 'string', format: 'date-time', nullable: true }, + lastOkAt: { type: 'string', format: 'date-time', nullable: true }, + error: { + type: 'string', + nullable: true, + description: 'Why the last attempt failed — a transport word (`timeout`, `no-token`, `protocol-mismatch`) or the game’s own refusal.', + example: null, + }, + report: { + type: 'object', + nullable: true, + description: 'The plugin’s report from the last successful sync.', + properties: { + applied: { + type: 'object', + properties: { + grants: { type: 'integer', example: 2 }, + revokes: { type: 'integer', example: 0 }, + groupsCreated: { type: 'integer', example: 1 }, + members: { type: 'integer', example: 3 }, + }, + }, + alreadyCorrect: { type: 'integer', example: 14 }, + unresolved: { + type: 'array', + description: 'Permission names no loaded plugin on that server has registered. A grant naming one lands nowhere and is not recorded as pushed.', + items: { type: 'string', example: 'kits.gold' }, + }, + pending: { + type: 'array', + description: 'Memberships waiting on a first connection: the store has no user record to put in a group yet.', + items: { type: 'string', example: '76561198000000000:vip' }, + }, + }, + }, + }, + }, + RustPermissionCatalogue: { + type: 'object', + description: 'Every permission name the configured servers have registered (GET /admin/rust/permissions/catalogue).', + properties: { + permissions: { + type: 'array', + items: { $ref: '#/components/schemas/RustPermissionCatalogueEntry' }, + }, + }, + }, + RustPermissionCatalogueEntry: { + type: 'object', + description: 'One registered permission name, and which servers know it.', + properties: { + permission: { type: 'string', example: 'kits.vip' }, + servers: { type: 'array', items: { type: 'string', example: 'main' } }, + }, + }, + RustPermissionSyncResult: { + type: 'object', + description: 'What a forced sync produced (POST /admin/rust/permissions/sync).', + properties: { + servers: { type: 'array', items: { $ref: '#/components/schemas/RustPermissionSyncState' } }, + drift: { type: 'array', items: { type: 'object' } }, + }, + }, + RustUserPermissions: { + type: 'object', + description: 'One person’s Rust privileges, for the admin.users.detail panel (GET /admin/users/{id}/rust/permissions).', + properties: { + groups: { + type: 'array', + items: { + type: 'object', + properties: { + name: { type: 'string', example: 'vip' }, + title: { type: 'string', example: 'VIP' }, + scope: { type: 'string', example: '*' }, + permissions: { type: 'array', items: { type: 'string', example: 'kits.vip' } }, + }, + }, + }, + grants: { + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'integer', example: 7 }, + permission: { type: 'string', example: 'kits.gold' }, + scope: { type: 'string', example: 'main' }, + source: { type: 'string', example: 'admin' }, + grantedAt: { type: 'string', format: 'date-time' }, + }, + }, + }, + reaches: { + type: 'array', + description: 'The Steam accounts these privileges reach. Empty means this person has linked nothing and holds them on paper only.', + items: { type: 'string', example: '76561198000000000' }, + }, + }, + }, RustSidecarProbe: { type: 'object', description: 'What a sidecar said when probed (POST /admin/rust/servers/{id}/test).', diff --git a/server/test/catalogue.test.js b/server/test/catalogue.test.js index b718ec7..d97836d 100644 --- a/server/test/catalogue.test.js +++ b/server/test/catalogue.test.js @@ -87,13 +87,13 @@ test('every kind is classified exactly once', () => { assert.equal(seen.size, catalogue.PUBLIC_KINDS.length + catalogue.STAFF_KINDS.length) }) -test('the classification covers exactly the kinds protocol 3 defines', () => { +test('the classification covers exactly the kinds protocol 4 defines', () => { // The spec lives in another repository, so the list is restated here rather // than parsed — and restating it is the point: adding a kind to the protocol // without deciding who may see it has to fail somewhere, and this is where. // // Sourced from docs/rust-link/PROTOCOL.md §8.4. - const PROTOCOL_3 = [ + const PROTOCOL_4 = [ 'player.connected', 'player.disconnected', 'player.respawned', @@ -111,7 +111,8 @@ test('the classification covers exactly the kinds protocol 3 defines', () => { 'server.shutdown', 'account.link.requested', 'account.unlinked', + 'perm.drift', ] - assert.deepEqual([...catalogue.ALL_KINDS].sort(), [...PROTOCOL_3].sort()) + assert.deepEqual([...catalogue.ALL_KINDS].sort(), [...PROTOCOL_4].sort()) }) diff --git a/server/test/identityRoutes.test.js b/server/test/identityRoutes.test.js index ee0a18b..12a2851 100644 --- a/server/test/identityRoutes.test.js +++ b/server/test/identityRoutes.test.js @@ -74,7 +74,14 @@ test('the admin.users.detail router merges the parent’s params and keeps its o assert.equal(slot.router.mergeParams, true) const paths = routesOf(slot.router).map((r) => `${r.method} ${r.path}`).sort() - assert.deepEqual(paths, ['DELETE /rust/links/:steamId', 'GET /rust/links']) + assert.deepEqual(paths, [ + 'DELETE /rust/links/:steamId', + // Phase 7 filled the same panel with what this person may do in game. + 'DELETE /rust/permissions/grants/:grantId', + 'GET /rust/links', + 'GET /rust/permissions', + 'POST /rust/permissions/grants', + ]) for (const route of routesOf(slot.router)) { assert.ok(route.path.startsWith('/rust/'), `${route.path} must live under this module's own segment`) diff --git a/server/test/permissions.test.js b/server/test/permissions.test.js new file mode 100644 index 0000000..bf24bb8 --- /dev/null +++ b/server/test/permissions.test.js @@ -0,0 +1,285 @@ +// ── The permission mirror ───────────────────────────────────────────────── +// +// The whole of R2's correctness is three set operations and one rule about what +// counts as landed, and every test here is one of those: +// +// desired − pushed apply +// pushed − desired RETIRE, because the site put it there and withdrew it +// present − desired drift, which is reported and never undone +// +// and: a grant naming a permission the server has not registered did NOT land, +// however much the push looked like it worked. +// +// The last one is the one with teeth. `GrantUserPermission` returns void, throws +// nothing and logs nothing for an unregistered name (PLAN.md §12.2 rule 1), so a +// module that recorded it as pushed would believe it had given a privilege it had +// not — and would then RETIRE it from a server that never had it, which is a +// no-op that reads as a success in every log. + +const test = require('node:test') +const assert = require('node:assert') + +const { fakeCtx } = require('./_fakes') + +function withCore(overrides = {}) { + const queries = [] + + require('../core')._reset() + require('../core').init( + fakeCtx({ + db: { + query: (sql, params) => { + queries.push({ sql: sql.trim().replace(/\s+/g, ' '), params }) + const verb = sql.trim().split(/\s+/)[0].toUpperCase() + if (verb === 'SELECT') return Promise.resolve([]) + return Promise.resolve({ affectedRows: 1 }) + }, + pool: {}, + }, + ...overrides, + }), + ) + + return queries +} + +/** One authored set: a fleet group, a server-scoped group, and two grants. */ +function authored() { + return { + groups: [ + { name: 'vip', title: 'VIP', rank: 10, scope: '*' }, + { name: 'builder', title: 'Builder', rank: 0, scope: 'creative' }, + ], + groupPermissions: [ + { groupName: 'vip', permission: 'kits.vip' }, + { groupName: 'builder', permission: 'buildtools.use' }, + ], + members: [ + { groupName: 'vip', userId: 1 }, + { groupName: 'builder', userId: 2 }, + ], + grants: [ + { id: 1, userId: 1, permission: 'kits.gold', scope: '*', steamId: '7656001' }, + { id: 2, userId: 3, permission: 'kits.gold', scope: '*', steamId: null }, + { id: 3, userId: 2, permission: 'zonemanager.admin', scope: 'creative', steamId: '7656002' }, + ], + // One person with TWO Steam accounts, one with one, one with none. + steamIdsByUser: new Map([ + [1, ['7656001', '7656099']], + [2, ['7656002']], + ]), + } +} + +test('a grant reaches every Steam account its holder has linked (D28)', () => { + withCore() + const model = require('../model/permissions/permissions.model') + + const { payload } = model.buildDesired('main', authored()) + const holders = payload.grants.map((row) => row.steamId).sort() + + // `kits.gold` is authored once, against user 1, who holds two accounts. + assert.deepEqual(holders, ['7656001', '7656099']) + for (const row of payload.grants) assert.deepEqual(row.permissions, ['kits.gold']) +}) + +test('a holder who has linked nothing contributes to the namespace but reaches nobody', () => { + withCore() + const model = require('../model/permissions/permissions.model') + + const { payload, rows } = model.buildDesired('main', authored()) + + // User 3 holds `kits.gold` and has no account. Nothing is pushed for them… + assert.ok(!rows.some((row) => row.kind === 'grant' && row.subject === null)) + // …and the permission is still MANAGED, which is what makes a hand grant of it + // to somebody else show up as drift rather than as nothing at all. + assert.ok(payload.managed.includes('kits.gold')) +}) + +test('scope decides what a server is sent at all (D29)', () => { + withCore() + const model = require('../model/permissions/permissions.model') + + const main = model.buildDesired('main', authored()) + const creative = model.buildDesired('creative', authored()) + + assert.deepEqual(main.payload.groups.map((g) => g.name), ['vip']) + assert.deepEqual(creative.payload.groups.map((g) => g.name).sort(), ['builder', 'vip']) + + // The server-scoped grant is on `creative` and nowhere else. + assert.ok(!main.payload.managed.includes('zonemanager.admin')) + assert.ok(creative.payload.managed.includes('zonemanager.admin')) +}) + +test('a group travels as a group: its members and its permissions are separate facts (D30)', () => { + withCore() + const model = require('../model/permissions/permissions.model') + + const { payload, rows } = model.buildDesired('main', authored()) + const vip = payload.groups.find((group) => group.name === 'vip') + + assert.deepEqual(vip.permissions, ['kits.vip']) + assert.deepEqual(vip.members.sort(), ['7656001', '7656099']) + + // Three distinct row kinds, because the game can fail at each independently: a + // group can exist while a membership does not, which is exactly what happens + // for a player the store has never seen. + assert.ok(rows.some((r) => r.kind === 'group' && r.subject === 'vip')) + assert.ok(rows.some((r) => r.kind === 'group-permission' && r.object === 'kits.vip')) + assert.ok(rows.some((r) => r.kind === 'member' && r.object === 'vip')) +}) + +test('the digest does not depend on the order rows came out of the database', () => { + withCore() + const model = require('../model/permissions/permissions.model') + + const rows = model.buildDesired('main', authored()).rows + const shuffled = [...rows].reverse() + + // An unsorted digest would differ between two reads of an unchanged set, and + // the loop would push to every game server on every tick for ever. + assert.equal(model.hashRows(rows), model.hashRows(shuffled)) + assert.notEqual(model.hashRows(rows), model.hashRows(rows.slice(1))) +}) + +test('what this site put there and has withdrawn is the only thing retired (D31)', () => { + withCore() + const model = require('../model/permissions/permissions.model') + + const desired = [ + { kind: 'grant', subject: '7656001', object: 'kits.gold' }, + { kind: 'member', subject: '7656001', object: 'vip' }, + ] + + const pushed = [ + { kind: 'grant', subject: '7656001', object: 'kits.gold' }, // still wanted + { kind: 'grant', subject: '7656001', object: 'kits.silver' }, // withdrawn + ] + + assert.deepEqual(model.retirements(pushed, desired), [ + { kind: 'grant', subject: '7656001', object: 'kits.silver' }, + ]) + + // A hand grant is in NEITHER set, so it is never retired by this calculation — + // it reaches the operator as drift instead. That difference is the reason the + // pushed ledger exists at all. + assert.deepEqual(model.retirements([], desired), []) +}) + +test('a permission the server could not resolve is not recorded as pushed', async () => { + const queries = withCore() + const permSync = require('../permSync') + + const desired = { + hash: 'h1', + rows: [ + { kind: 'grant', subject: '7656001', object: 'kits.gold' }, + { kind: 'grant', subject: '7656001', object: 'kits.vip' }, + { kind: 'member', subject: '7656002', object: 'vip' }, + { kind: 'member', subject: '7656003', object: 'vip' }, + ], + } + + const report = { + kind: 'perm.report', + applied: { grants: 1 }, + unresolved: ['kits.vip'], + pending: ['7656003:vip'], + foreign: [], + } + + // The catalogue refresh is a second call to the game; stubbed so the report + // path is what this test is about. + const sidecar = require('../sidecarClient') + sidecar.permCatalogue = async () => ({ ok: false, status: 'no-token', data: null }) + + await permSync.applyReport({ id: 'main' }, { desired, retire: [], report, bootId: null, wipeId: null }) + + const insert = queries.find((q) => q.sql.startsWith('INSERT IGNORE INTO rust_perm_pushed')) + assert.ok(insert, 'the rows that landed must be recorded') + + const recorded = insert.params.join(' ') + assert.ok(recorded.includes('kits.gold'), 'a grant that landed is pushed') + assert.ok(!recorded.includes('kits.vip'), 'an unresolved permission never reached the store') + assert.ok(recorded.includes('7656002'), 'a membership that took is pushed') + assert.ok(!recorded.includes('7656003'), 'a pending membership is not in the game yet') +}) + +test('a restart, a wipe and a hand edit each provoke a sync; a quiet server does not', () => { + withCore() + const permSync = require('../permSync') + + const base = { + state: 'ok', + dirty: false, + syncedHash: 'h1', + bootId: 'boot-1', + wipeId: 'w-1', + lastAttemptAt: new Date(), + } + + const at = (sync, state = {}) => + permSync.reasonToSync({ + desiredHash: 'h1', + sync, + state: { bootId: 'boot-1', wipeId: 'w-1', ...state }, + force: false, + }) + + assert.equal(at(base), null, 'nothing changed: no push') + assert.equal(at({ ...base, dirty: true }), 'dirty') + assert.equal(permSync.reasonToSync({ desiredHash: 'h2', sync: base, state: {}, force: false }), 'changed') + assert.equal(at(base, { bootId: 'boot-2' }), 'restart') + assert.equal(at(base, { wipeId: 'w-2' }), 'wipe') + assert.equal(at(null), 'first') + + // The audit is the backstop that finds drift on a server nobody has touched. + const old = new Date(Date.now() - permSync.AUDIT_MS - 1000) + assert.equal(at({ ...base, lastAttemptAt: old }), 'audit') +}) + +test('a failing server is left alone for a backoff, unless something changed', () => { + withCore() + const permSync = require('../permSync') + + const failing = { + state: 'failed', + dirty: false, + syncedHash: 'h1', + lastAttemptAt: new Date(), + } + + assert.equal( + permSync.reasonToSync({ desiredHash: 'h1', sync: failing, state: {}, force: false }), + null, + 'a server that just failed is not hammered every thirty seconds', + ) + + assert.equal( + permSync.reasonToSync({ desiredHash: 'h1', sync: { ...failing, dirty: true }, state: {}, force: false }), + 'retry', + 'an operator changing something is a reason to try again at once', + ) + + const older = new Date(Date.now() - permSync.FAIL_BACKOFF_MS - 1000) + assert.equal( + permSync.reasonToSync({ desiredHash: 'h1', sync: { ...failing, lastAttemptAt: older }, state: {}, force: false }), + 'retry', + ) +}) + +test('names are lowered, because the store lowers them', () => { + withCore() + const model = require('../model/permissions/permissions.model') + + const set = { + ...authored(), + grants: [{ id: 9, userId: 1, permission: 'Kits.GOLD', scope: '*', steamId: '7656001' }], + } + + const { payload } = model.buildDesired('main', set) + + // Pushed as `kits.gold`, read back as `kits.gold`. Unlowered, the site would + // push one name, find another, and report its own grant as drift for ever. + assert.deepEqual(payload.grants[0].permissions, ['kits.gold']) +}) diff --git a/swagger-fragment.json b/swagger-fragment.json index 8b797bb..38e51ad 100644 --- a/swagger-fragment.json +++ b/swagger-fragment.json @@ -1,5 +1,382 @@ { "paths": { + "/api/v1/admin/rust/permissions": { + "get": { + "tags": [ + "Admin · Rust" + ], + "summary": "The whole permission model", + "description": "Groups with their permissions and members, direct grants, the drift each server reported, the option source of registered permission names, and the sync state of every configured server.", + "responses": { + "200": { + "description": "The authored model and what each game reported", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RustPermissionModel" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/v1/admin/rust/permissions/catalogue": { + "get": { + "tags": [ + "Admin · Rust" + ], + "summary": "Permission names the servers have registered", + "description": "What the loaded plugins on each configured server have registered, cached from the last sync. It is the option source for the authoring form: a permission no server knows cannot be granted, because `GrantUserPermission` silently does nothing for an unregistered name.", + "responses": { + "200": { + "description": "Every registered name, and which servers know it", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RustPermissionCatalogue" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/v1/admin/rust/permissions/drift/{id}/adopt": { + "post": { + "tags": [ + "Admin · Rust" + ], + "summary": "Adopt a hand edit", + "description": "Records a grant or membership somebody made in game as one the site authors, so it stops being reported and starts being maintained. It needs a website account holding that Steam id; without one there is nobody to author it against, and the answer is to revoke it or to ask the player to link.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Adopted" + }, + "400": { + "description": "That kind of drift cannot be adopted" + }, + "404": { + "description": "Not Found" + }, + "409": { + "description": "That Steam account is linked to nobody on this site" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/v1/admin/rust/permissions/drift/{id}/revoke": { + "post": { + "tags": [ + "Admin · Rust" + ], + "summary": "Revoke a hand edit", + "description": "Queues the removal rather than performing it: a server that is down keeps the instruction until it comes back. This is the only way the site removes something it did not put there — a sync never does it on its own.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "202": { + "description": "Queued for the next sync" + }, + "404": { + "description": "No such drift" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/v1/admin/rust/permissions/grants": { + "post": { + "tags": [ + "Admin · Rust" + ], + "summary": "Grant one permission to one person", + "description": "A direct grant, authored against a website user and pushed to every Steam account they have linked. Unlike group membership it reaches a player who has never connected to the server, which is what an entitlement earned on the website has to do.", + "responses": { + "200": { + "description": "They already held it; nothing changed" + }, + "201": { + "description": "Granted" + }, + "400": { + "description": "Invalid body, or a scope naming no configured server" + }, + "404": { + "description": "No account on this site has that name" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "permission": { + "example": "any" + }, + "scope": { + "example": "any" + }, + "note": { + "example": "any" + } + } + } + } + } + } + } + }, + "/api/v1/admin/rust/permissions/grants/{id}": { + "delete": { + "tags": [ + "Admin · Rust" + ], + "summary": "Remove a grant", + "description": "The next sync revokes it in every in-scope game. A player who has already used what it allowed keeps what they did with it — the grant is the entitlement, not the consumption.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Removed" + }, + "404": { + "description": "No such grant" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/v1/admin/rust/permissions/groups/{name}": { + "put": { + "tags": [ + "Admin · Rust" + ], + "summary": "Create or update a permission group", + "description": "Writes the group and the permissions it carries in one request, because they are one idea on the form. `scope` is a server id or `*` for the whole fleet. The group is mirrored into each in-scope game as a real group, so third-party plugins that read group membership see it.", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Saved" + }, + "400": { + "description": "Invalid body, or a scope naming no configured server" + }, + "500": { + "description": "Internal Server Error" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "scope": { + "example": "any" + }, + "title": { + "example": "any" + }, + "rank": { + "example": "any" + }, + "permissions": { + "example": "any" + } + } + } + } + } + } + }, + "delete": { + "tags": [ + "Admin · Rust" + ], + "summary": "Delete a permission group", + "description": "Removes the group, its permission list and its membership from the site. The next sync retires the group from every server it had been pushed to — a group the site authored and has withdrawn is removed from the game, unlike one somebody created by hand.", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Deleted" + }, + "404": { + "description": "No such group" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/v1/admin/rust/permissions/groups/{name}/members": { + "post": { + "tags": [ + "Admin · Rust" + ], + "summary": "Put an account in a group", + "description": "Membership is authored against a website user and reaches every Steam account they have linked. A member who has never connected to a server cannot be placed in its store yet — the sync reports them as pending and the membership lands on their first connection.", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Added" + }, + "400": { + "description": "Bad Request" + }, + "404": { + "description": "No such group" + } + } + } + }, + "/api/v1/admin/rust/permissions/groups/{name}/members/{userId}": { + "delete": { + "tags": [ + "Admin · Rust" + ], + "summary": "Take an account out of a group", + "description": "", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "userId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Removed" + }, + "404": { + "description": "No such group, or that account is not in it" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/v1/admin/rust/permissions/sync": { + "post": { + "tags": [ + "Admin · Rust" + ], + "summary": "Push the permission set now", + "description": "Runs the reconciliation loop’s pass immediately, for one server or for all of them, and answers with what each one reported. The loop does this on its own; the button exists so an operator who has just changed something can see it land, and finds out at once when a server is unreachable.", + "responses": { + "200": { + "description": "The state of every server after the pass", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RustPermissionSyncResult" + } + } + } + }, + "404": { + "description": "No such server" + }, + "500": { + "description": "Internal Server Error" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "req": { + "example": "any" + } + } + } + } + } + } + } + }, "/api/v1/admin/rust/servers": { "get": { "tags": [ @@ -259,6 +636,167 @@ ] } }, + "/api/v1/admin/users/{id}/rust/permissions": { + "get": { + "tags": [ + "Admin · Users" + ], + "summary": "A user’s Rust privileges (admin only)", + "description": "The groups this person is in, the permissions granted to them directly, and the Steam accounts those privileges actually reach. An empty `reaches` means they have linked nothing and hold them on paper only.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "User id." + } + ], + "responses": { + "200": { + "description": "Their groups and grants", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RustUserPermissions" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/users/{id}/rust/permissions/grants": { + "post": { + "tags": [ + "Admin · Users" + ], + "summary": "Grant a Rust permission to this user (admin only)", + "description": "Authored against the website account, so it reaches every Steam id they have linked — now and later. `scope` is a server id or `*` for the fleet. The push happens on the mirror’s next pass.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "User id." + } + ], + "responses": { + "200": { + "description": "They already held it" + }, + "201": { + "description": "Granted" + }, + "400": { + "description": "Invalid body, or a scope naming no configured server", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "permission": { + "example": "any" + }, + "scope": { + "example": "any" + } + } + } + } + } + } + } + }, + "/api/v1/admin/users/{id}/rust/permissions/grants/{grantId}": { + "delete": { + "tags": [ + "Admin · Users" + ], + "summary": "Remove a Rust permission from this user (admin only)", + "description": "Scoped to this user as well as to the grant, so a wrong id on the URL removes nothing rather than somebody else’s privilege. The revoke reaches the game on the mirror’s next pass.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "User id." + }, + { + "name": "grantId", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "The grant to remove." + } + ], + "responses": { + "204": { + "description": "Removed" + }, + "404": { + "description": "No such grant for this user", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, "/api/v1/player/rust/link": { "post": { "tags": [ @@ -1649,6 +2187,1205 @@ } } }, + "RustPermissionModel": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "The whole permission model (GET /admin/rust/permissions): what the site authors, what each game reported back, and the names a grant may use." + }, + "properties": { + "type": "object", + "properties": { + "groups": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "description": { + "type": "string", + "example": "Groups the site authors, mirrored into each in-scope game as a real group." + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "name": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "vip" + } + } + }, + "title": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "VIP" + } + } + }, + "rank": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 10 + } + } + }, + "scope": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "description": { + "type": "string", + "example": "A server id, or `*` for every server." + }, + "example": { + "type": "string", + "example": "*" + } + } + }, + "permissions": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "kits.vip" + } + } + } + } + }, + "members": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "userId": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 42 + } + } + }, + "username": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "wanderer" + } + } + }, + "steamId": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "Null when this account has linked no Steam id, in which case the membership reaches nobody yet." + }, + "example": { + "type": "string", + "example": "76561198000000000" + } + } + }, + "playerName": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "string", + "example": "Wanderer" + } + } + } + } + } + } + } + } + } + } + } + } + } + } + }, + "grants": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "description": { + "type": "string", + "example": "Permissions held by one person without a group. Unlike membership, a direct grant reaches a player who has never connected." + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 7 + } + } + }, + "userId": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 42 + } + } + }, + "username": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "wanderer" + } + } + }, + "permission": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "kits.gold" + } + } + }, + "scope": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "main" + } + } + }, + "source": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "description": { + "type": "string", + "example": "What authored it — `admin`, `adopted`, or a later phase’s own writer." + }, + "example": { + "type": "string", + "example": "admin" + } + } + }, + "note": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": {} + } + }, + "grantedAt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + } + } + }, + "accounts": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "description": { + "type": "string", + "example": "The Steam accounts this grant reaches. Empty means it reaches nobody yet." + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "steamId": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "76561198000000000" + } + } + }, + "name": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "string", + "example": "Wanderer" + } + } + } + } + } + } + } + } + } + } + } + } + } + } + }, + "servers": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "description": { + "type": "string", + "example": "The state of the mirror, per configured server." + }, + "items": { + "$ref": "#/components/schemas/RustPermissionSyncState" + } + } + }, + "drift": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "description": { + "type": "string", + "example": "What a game holds that the site did not author. Reported, never undone." + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 3 + } + } + }, + "serverId": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "main" + } + } + }, + "kind": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "description": { + "type": "string", + "example": "One of `grant`, `member`, `group-permission`." + }, + "example": { + "type": "string", + "example": "grant" + } + } + }, + "subject": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "description": { + "type": "string", + "example": "A Steam id, or a group name." + }, + "example": { + "type": "string", + "example": "76561198000000000" + } + } + }, + "object": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "description": { + "type": "string", + "example": "A permission name, or a group name." + }, + "example": { + "type": "string", + "example": "kits.admin" + } + } + }, + "username": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "The website account holding that Steam id, when there is one. Without it the drift cannot be adopted, only revoked." + }, + "example": { + "type": "string", + "example": "wanderer" + } + } + }, + "firstSeen": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + } + } + } + } + } + } + } + } + }, + "catalogue": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "$ref": "#/components/schemas/RustPermissionCatalogueEntry" + } + } + } + } + } + } + }, + "RustPermissionSyncState": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "Whether one server’s store matches what the site authors, and what its last report said." + }, + "properties": { + "type": "object", + "properties": { + "serverId": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "main" + } + } + }, + "state": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "description": { + "type": "string", + "example": "One of `pending`, `ok`, `failed`." + }, + "example": { + "type": "string", + "example": "ok" + } + } + }, + "inSync": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "description": { + "type": "string", + "example": "True when the last successful push carried the set the site currently authors." + }, + "example": { + "type": "boolean", + "example": true + } + } + }, + "dirty": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "example": { + "type": "boolean", + "example": false + } + } + }, + "lastAttemptAt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "lastOkAt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "error": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "Why the last attempt failed — a transport word (`timeout`, `no-token`, `protocol-mismatch`) or the game’s own refusal." + }, + "example": {} + } + }, + "report": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "The plugin’s report from the last successful sync." + }, + "properties": { + "type": "object", + "properties": { + "applied": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "grants": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 2 + } + } + }, + "revokes": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 0 + } + } + }, + "groupsCreated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 1 + } + } + }, + "members": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 3 + } + } + } + } + } + } + }, + "alreadyCorrect": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 14 + } + } + }, + "unresolved": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "description": { + "type": "string", + "example": "Permission names no loaded plugin on that server has registered. A grant naming one lands nowhere and is not recorded as pushed." + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "kits.gold" + } + } + } + } + }, + "pending": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "description": { + "type": "string", + "example": "Memberships waiting on a first connection: the store has no user record to put in a group yet." + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "76561198000000000:vip" + } + } + } + } + } + } + } + } + } + } + } + } + }, + "RustPermissionCatalogue": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "Every permission name the configured servers have registered (GET /admin/rust/permissions/catalogue)." + }, + "properties": { + "type": "object", + "properties": { + "permissions": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "$ref": "#/components/schemas/RustPermissionCatalogueEntry" + } + } + } + } + } + } + }, + "RustPermissionCatalogueEntry": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "One registered permission name, and which servers know it." + }, + "properties": { + "type": "object", + "properties": { + "permission": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "kits.vip" + } + } + }, + "servers": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "main" + } + } + } + } + } + } + } + } + }, + "RustPermissionSyncResult": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "What a forced sync produced (POST /admin/rust/permissions/sync)." + }, + "properties": { + "type": "object", + "properties": { + "servers": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "$ref": "#/components/schemas/RustPermissionSyncState" + } + } + }, + "drift": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + } + } + } + } + } + } + } + } + }, + "RustUserPermissions": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "One person’s Rust privileges, for the admin.users.detail panel (GET /admin/users/{id}/rust/permissions)." + }, + "properties": { + "type": "object", + "properties": { + "groups": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "name": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "vip" + } + } + }, + "title": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "VIP" + } + } + }, + "scope": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "*" + } + } + }, + "permissions": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "kits.vip" + } + } + } + } + } + } + } + } + } + } + }, + "grants": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 7 + } + } + }, + "permission": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "kits.gold" + } + } + }, + "scope": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "main" + } + } + }, + "source": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "admin" + } + } + }, + "grantedAt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + } + } + } + } + } + } + } + } + }, + "reaches": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "description": { + "type": "string", + "example": "The Steam accounts these privileges reach. Empty means this person has linked nothing and holds them on paper only." + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "76561198000000000" + } + } + } + } + } + } + } + } + }, "RustSidecarProbe": { "type": "object", "properties": {