feat(rust): site-owned permissions — the site is the author, the game is the cache #8

Merged
whitlocktech merged 2 commits from feat/phase-7-permissions into edge 2026-09-22 06:53:22 +00:00
27 changed files with 5524 additions and 21 deletions

View File

@@ -35,6 +35,7 @@
"ingest.js",
"model",
"package.json",
"permSync.js",
"router",
"sidecarClient.js"
],

View File

@@ -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 `<img src>`, 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,
}

View File

@@ -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: <Servers /> },
{ path: 'servers/:id', element: <ServerDetail /> },
],
player: [{ path: '', element: <Account /> }],
admin: [{ path: '', element: <Permissions /> }],
})
// ── 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

View File

@@ -46,4 +46,20 @@ export const IconLink = () => (
</Icon>
)
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 = () => (
<Icon>
<circle cx="7.5" cy="15.5" r="4.5" />
<path d="M10.7 12.3L20 3" />
<path d="M17 6l2.5 2.5" />
</Icon>
)
export default { IconLink, IconKey }

View File

@@ -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 (
<section className="panel" style={{ padding: '16px 18px', marginBottom: 18 }}>
<header style={{ display: 'flex', alignItems: 'baseline', gap: 12, marginBottom: 12 }}>
<h2 className="display" style={{ fontSize: '1.05rem', margin: 0, color: 'var(--head)' }}>
{title}
</h2>
{subtitle && (
<span className="sans dim" style={{ fontSize: '0.76rem' }}>
{subtitle}
</span>
)}
<span style={{ flex: 1 }} />
{actions}
</header>
{children}
</section>
)
}
function Row({ children, muted = false }) {
return (
<div
className="sans"
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '8px 0',
borderTop: '1px solid var(--line-soft)',
fontSize: '0.86rem',
color: muted ? 'var(--ink)' : 'var(--head)',
}}
>
{children}
</div>
)
}
function Warn({ children }) {
return (
<p className="sans" style={{ color: '#d08a2a', fontSize: '0.78rem', margin: '6px 0 0' }}>
{children}
</p>
)
}
function Scope({ value }) {
return (
<span className="sans dim" style={{ fontSize: '0.74rem' }}>
{value === FLEET ? 'every server' : value}
</span>
)
}
/**
* 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 (
<div style={{ padding: '10px 0', borderTop: '1px solid var(--line-soft)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span className="sans" style={{ color: 'var(--head)', fontSize: '0.9rem' }}>
{row.serverId}
</span>
<span
className="sans"
style={{ fontSize: '0.76rem', color: row.inSync ? 'var(--ink)' : '#d08a2a' }}
>
{row.inSync ? 'in sync' : row.state === 'failed' ? 'out of sync' : 'pending'}
</span>
<span className="sans dim" style={{ fontSize: '0.74rem' }}>
{row.lastOkAt ? `last pushed ${ago(row.lastOkAt)}` : 'never pushed'}
</span>
<span style={{ flex: 1 }} />
<button type="button" className="btn ghost" onClick={() => onSync(row.serverId)} disabled={busy}>
{busy ? 'Syncing…' : 'Sync now'}
</button>
</div>
{row.error && (
<p className="sans" style={{ color: '#e05a5a', fontSize: '0.78rem', margin: '4px 0 0' }}>
{row.error}
</p>
)}
{unresolved.length > 0 && (
<Warn>
{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.
</Warn>
)}
{pending.length > 0 && (
<Warn>
{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.
</Warn>
)}
</div>
)
}
/** 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>
<span style={{ minWidth: 0, flex: 1 }}>
<strong style={{ fontWeight: 500 }}>{row.object}</strong>{' '}
<span className="dim" style={{ fontSize: '0.78rem' }}>
{row.kind === 'group-permission' ? `on group ${row.subject}` : `held by ${subject}`} ·{' '}
{row.serverId} · seen {ago(row.firstSeen)}
</span>
</span>
<button type="button" className="btn ghost" onClick={() => onAdopt(row)} disabled={busy}>
Adopt
</button>
<button type="button" className="btn ghost" onClick={() => onRevoke(row)} disabled={busy}>
Revoke
</button>
</Row>
)
}
/**
* 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 (
<Card
title={group.title || group.name}
subtitle={<>{group.name} · <Scope value={group.scope} /></>}
actions={
<button
type="button"
className="btn ghost"
disabled={busy}
onClick={() => act(() => api.adminPermissions.deleteGroup(group.name))}
>
Delete
</button>
}
>
<div className="field-label">Permissions</div>
{group.permissions.length === 0 && (
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '4px 0' }}>
This group carries nothing, so being in it does nothing.
</p>
)}
{group.permissions.map((perm) => (
<Row key={perm}>
<span style={{ flex: 1 }}>{perm}</span>
{!catalogue.some((entry) => entry.permission === perm) && (
<span className="sans" style={{ color: '#d08a2a', fontSize: '0.74rem' }}>
no server has registered this
</span>
)}
<button
type="button"
className="btn ghost"
disabled={busy}
onClick={() => save(group.permissions.filter((p) => p !== perm))}
>
Remove
</button>
</Row>
))}
<form
style={{ display: 'flex', gap: 8, marginTop: 10 }}
onSubmit={(event) => {
event.preventDefault()
if (!permission.trim()) return
save([...group.permissions, permission.trim().toLowerCase()])
setPermission('')
}}
>
<input
list="rust-permission-names"
className="input"
placeholder="kits.vip"
value={permission}
onChange={(event) => setPermission(event.target.value)}
style={{ flex: 1 }}
/>
<button type="submit" className="btn" disabled={busy}>
Add permission
</button>
</form>
<div className="field-label" style={{ marginTop: 18 }}>
Members
</div>
{group.members.length === 0 && (
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '4px 0' }}>
Nobody is in this group.
</p>
)}
{group.members.map((m) => {
const waiting = m.accounts
.map((account) => pending.get(`${account.steamId}:${group.name}`))
.filter(Boolean)
.flat()
return (
<Row key={m.userId}>
<span style={{ flex: 1 }}>
{m.username}
{m.accounts.length > 0 ? (
<span className="dim" style={{ fontSize: '0.76rem' }}>
{' '}
· {m.accounts.map((a) => a.name || a.steamId).join(', ')}
</span>
) : (
<span style={{ color: '#d08a2a', fontSize: '0.76rem' }}>
{' '}
· has linked no Steam account, so this reaches nobody
</span>
)}
{waiting.length > 0 && (
<span style={{ color: '#d08a2a', fontSize: '0.76rem' }}>
{' '}
· waiting on their first connection to {[...new Set(waiting)].join(', ')}
</span>
)}
</span>
<button
type="button"
className="btn ghost"
disabled={busy}
onClick={() => act(() => api.adminPermissions.removeMember(group.name, m.userId))}
>
Remove
</button>
</Row>
)
})}
<form
style={{ display: 'flex', gap: 8, marginTop: 10 }}
onSubmit={(event) => {
event.preventDefault()
if (!member.trim()) return
act(() => api.adminPermissions.addMember(group.name, member.trim()))
setMember('')
}}
>
<input
className="input"
placeholder="website username"
value={member}
onChange={(event) => setMember(event.target.value)}
style={{ flex: 1 }}
/>
<button type="submit" className="btn" disabled={busy}>
Add member
</button>
</form>
{servers.length > 1 && group.scope !== FLEET && (
<p className="sans dim" style={{ fontSize: '0.74rem', margin: '10px 0 0' }}>
This group exists on {group.scope} only. The other servers never receive it.
</p>
)}
</Card>
)
}
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 <ErrorState error={loadError} />
if (!data) return <Loading />
const servers = data.servers || []
return (
<div style={{ maxWidth: 900 }}>
{/* 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. */}
<p className="sans dim" style={{ fontSize: '0.82rem', marginTop: 0 }}>
This site is the author of record. Groups and grants written here are pushed into each
servers 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.
</p>
{/* 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. */}
<datalist id="rust-permission-names">
{(data.catalogue || []).map((entry) => (
<option key={entry.permission} value={entry.permission} />
))}
</datalist>
{error && (
<p className="sans" style={{ color: '#e05a5a', fontSize: '0.84rem' }}>
{error}
</p>
)}
<Card
title="Servers"
subtitle={`${servers.length} configured`}
actions={
<button type="button" className="btn ghost" disabled={busy} onClick={() => act(() => api.adminPermissions.sync())}>
Sync all
</button>
}
>
{servers.length === 0 && (
<p className="sans dim" style={{ fontSize: '0.82rem', margin: 0 }}>
No servers are configured yet, so nothing written here reaches a game.
</p>
)}
{servers.map((row) => (
<ServerState
key={row.serverId}
row={row}
busy={busy}
onSync={(id) => act(() => api.adminPermissions.sync(id))}
/>
))}
</Card>
{(data.drift || []).length > 0 && (
<Card
title="Changed in game"
subtitle="granted at a console, not by this site"
>
<p className="sans dim" style={{ fontSize: '0.8rem', marginTop: 0 }}>
Nothing here is undone automatically. <strong>Adopt</strong> records it as the sites
own, so it survives the next wipe; <strong>Revoke</strong> removes it from the game on
the next sync.
</p>
{data.drift.map((row) => (
<DriftRow
key={row.id}
row={row}
busy={busy}
onAdopt={(d) => act(() => api.adminPermissions.adoptDrift(d.id))}
onRevoke={(d) => act(() => api.adminPermissions.revokeDrift(d.id))}
/>
))}
</Card>
)}
<Card title="Direct grants" subtitle="one person, one permission">
{(data.grants || []).length === 0 && (
<p className="sans dim" style={{ fontSize: '0.82rem', margin: 0 }}>
Nobody holds a permission of their own yet.
</p>
)}
{(data.grants || []).map((row) => (
<Row key={row.id}>
<span style={{ flex: 1 }}>
{row.username} · <strong style={{ fontWeight: 500 }}>{row.permission}</strong>{' '}
<Scope value={row.scope} />
{row.accounts.length === 0 && (
<span style={{ color: '#d08a2a', fontSize: '0.76rem' }}>
{' '}
· has linked no Steam account, so this reaches nobody
</span>
)}
{/* 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) && (
<span style={{ color: '#d08a2a', fontSize: '0.76rem' }}>
{' '}
· no server has registered this permission
</span>
)}
{row.source !== 'admin' && (
<span className="dim" style={{ fontSize: '0.74rem' }}> · {row.source}</span>
)}
</span>
<button
type="button"
className="btn ghost"
disabled={busy}
onClick={() => act(() => api.adminPermissions.revoke(row.id))}
>
Remove
</button>
</Row>
))}
<form
style={{ display: 'flex', gap: 8, marginTop: 12, flexWrap: 'wrap' }}
onSubmit={(event) => {
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 })
}}
>
<input
className="input"
placeholder="website username"
value={grant.username}
onChange={(event) => setGrant({ ...grant, username: event.target.value })}
style={{ flex: '1 1 160px' }}
/>
<input
list="rust-permission-names"
className="input"
placeholder="kits.vip"
value={grant.permission}
onChange={(event) => setGrant({ ...grant, permission: event.target.value })}
style={{ flex: '1 1 160px' }}
/>
<select
className="input"
value={grant.scope}
onChange={(event) => setGrant({ ...grant, scope: event.target.value })}
>
<option value={FLEET}>every server</option>
{servers.map((row) => (
<option key={row.serverId} value={row.serverId}>
{row.serverId}
</option>
))}
</select>
<button type="submit" className="btn" disabled={busy}>
Grant
</button>
</form>
</Card>
{(data.groups || []).map((group) => (
<GroupCard
key={group.name}
group={group}
catalogue={data.catalogue || []}
servers={servers}
pending={pendingSet(servers)}
onChanged={reload}
setError={setError}
/>
))}
<Card title="New group">
<form
style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}
onSubmit={(event) => {
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 })
}}
>
<input
className="input"
placeholder="vip"
value={form.name}
onChange={(event) => setForm({ ...form, name: event.target.value })}
style={{ flex: '1 1 140px' }}
/>
<input
className="input"
placeholder="VIP"
value={form.title}
onChange={(event) => setForm({ ...form, title: event.target.value })}
style={{ flex: '1 1 140px' }}
/>
<select
className="input"
value={form.scope}
onChange={(event) => setForm({ ...form, scope: event.target.value })}
>
<option value={FLEET}>every server</option>
{servers.map((row) => (
<option key={row.serverId} value={row.serverId}>
{row.serverId}
</option>
))}
</select>
<button type="submit" className="btn" disabled={busy}>
Create
</button>
</form>
<p className="sans dim" style={{ fontSize: '0.74rem', margin: '10px 0 0' }}>
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.
</p>
</Card>
</div>
)
}

View File

@@ -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 (
<div className="panel" style={{ padding: '14px 16px' }}>
<div className="field-label" style={{ marginBottom: 8 }}>
Permissions
</div>
{nothing && (
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '0 0 8px' }}>
Nothing granted.
</p>
)}
{data.groups.map((group) => (
<div key={group.name} className="sans" style={{ fontSize: '0.84rem', padding: '4px 0' }}>
<span style={{ color: 'var(--head)' }}>{group.title || group.name}</span>{' '}
<span className="dim" style={{ fontSize: '0.76rem' }}>
group · {group.scope === '*' ? 'every server' : group.scope}
{group.permissions.length ? ` · ${group.permissions.join(', ')}` : ' · carries nothing'}
</span>
</div>
))}
{data.grants.map((row) => (
<div
key={row.id}
className="sans"
style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: '0.84rem', padding: '4px 0' }}
>
<span style={{ flex: 1, color: 'var(--head)' }}>
{row.permission}{' '}
<span className="dim" style={{ fontSize: '0.76rem' }}>
{row.scope === '*' ? 'every server' : row.scope}
{row.source !== 'admin' ? ` · ${row.source}` : ''}
</span>
</span>
<button
type="button"
className="btn ghost"
disabled={busy}
onClick={() => act(() => api.adminUserPermissions.revoke(userId, row.id))}
style={{ flex: 'none' }}
>
Remove
</button>
</div>
))}
{!nothing && data.reaches.length === 0 && (
<p className="sans" style={{ color: '#d08a2a', fontSize: '0.78rem', margin: '8px 0 0' }}>
This account has linked no Steam id, so none of it reaches a game yet. It will apply by
itself when they link.
</p>
)}
<form
style={{ display: 'flex', gap: 8, marginTop: 10 }}
onSubmit={(event) => {
event.preventDefault()
if (!permission.trim()) return
act(() =>
api.adminUserPermissions.grant(userId, { permission: permission.trim().toLowerCase() }),
)
setPermission('')
}}
>
<input
className="input"
placeholder="kits.vip"
value={permission}
onChange={(event) => setPermission(event.target.value)}
style={{ flex: 1 }}
/>
<button type="submit" className="btn" disabled={busy}>
Grant
</button>
</form>
{error && (
<p className="sans" style={{ color: '#e05a5a', fontSize: '0.8rem', margin: '8px 0 0' }}>
{error}
</p>
)}
</div>
)
}
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 (
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
@@ -135,13 +263,22 @@ export default function UserRustSections({ userId }) {
{data.links.map((link) => (
<LinkPanel key={link.steamId} userId={userId} link={link} onRemoved={reload} />
))}
</div>
<p className="sans dim" style={{ fontSize: '0.74rem', margin: '12px 0 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.
</p>
{data.links.length > 0 && (
<p className="sans dim" style={{ fontSize: '0.74rem', margin: 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.
</p>
)}
{/* 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. */}
<PermissionsPanel userId={userId} data={permissions} onChanged={reload} />
</div>
</section>
)
}

View File

@@ -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",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

342
server/permSync.js Normal file
View File

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

View File

@@ -0,0 +1,424 @@
// ── Admin · Rust · Permissions ────────────────────────────────────────────
//
// The authoring surface for R2. Everything here writes to the site's own tables
// and marks the affected servers dirty; nothing here talks to a game. The push
// is `permSync.js`'s loop, which is deliberate — a form that wrote to six game
// hosts inside the request would fail differently for each of them and have no
// honest status code to answer with.
//
// **The one exception is "sync now"**, which runs the loop's pass for one server
// and waits for it. It exists because an operator who has just changed something
// wants to see it land, and because waiting thirty seconds to find out that a
// server is unreachable is a bad way to learn it.
//
// Every write logs an activity row. These rows decide who may do what inside
// somebody's game server, which is the one thing on this module's admin tier
// more consequential than the sidecar credential.
const core = require('../../core')
const db = require('../../model/permissions/permissions.db')
const model = require('../../model/permissions/permissions.model')
const permSync = require('../../permSync')
const servers = require('../../model/servers/servers.model')
const log = core.logger('admin:permissions')
/** Everything the screen renders: groups, grants, drift, the catalogue, per-server state. */
async function overview(req, res) {
try {
res.json(await model.overview())
} catch (err) {
log.error('failed to read the permission model', { error: err.message })
res.status(500).json({ message: 'Failed to read the permission model' })
}
}
/**
* Create or update a group.
*
* The permission list is part of the same write, because that is how the form
* edits it: a group and what it carries are one idea on the screen, and two
* requests would leave a group briefly carrying the wrong set.
*/
async function putGroup(req, res) {
const name = model.normaliseName(req.params.name)
const scope = String(req.body.scope || model.FLEET)
try {
if (scope !== model.FLEET && !(await knownServer(scope))) {
return res.status(400).json({ message: 'That scope names no configured server' })
}
const previous = await db.getGroup(name)
await db.upsertGroup({
name,
title: String(req.body.title || name),
rank: Number(req.body.rank) || 0,
scope,
})
const permissions = [...new Set((req.body.permissions || []).map(model.normaliseName))].filter(Boolean)
await db.setGroupPermissions(name, permissions)
// Both scopes: a group that moved from one server to another has to be
// retired from where it was as well as applied where it now is, and only the
// old scope knows the first half.
await db.markDirty(scope)
if (previous && previous.scope !== scope) await db.markDirty(previous.scope)
await core.activity.log({
req,
action: previous ? 'rust.perm.group.update' : 'rust.perm.group.create',
detail: { group: name, scope, permissions: permissions.length },
})
return res.status(204).end()
} catch (err) {
log.error('failed to save a group', { group: name, error: err.message })
return res.status(500).json({ message: 'Failed to save that group' })
}
}
async function deleteGroup(req, res) {
const name = model.normaliseName(req.params.name)
try {
const existing = await db.getGroup(name)
if (!existing) return res.status(404).json({ message: 'No such group' })
await db.deleteGroup(name)
await db.markDirty(existing.scope)
await core.activity.log({ req, action: 'rust.perm.group.delete', detail: { group: name } })
return res.status(204).end()
} catch (err) {
log.error('failed to delete a group', { group: name, error: err.message })
return res.status(500).json({ message: 'Failed to delete that group' })
}
}
async function addMember(req, res) {
const name = model.normaliseName(req.params.name)
try {
const group = await db.getGroup(name)
if (!group) return res.status(404).json({ message: 'No such group' })
const userId = await resolveUser(req.body)
if (!userId) return res.status(404).json({ message: 'No account on this site has that name' })
await db.addGroupMember(name, userId, req.user ? req.user.id : null)
await db.markDirty(group.scope)
await core.activity.log({
req,
action: 'rust.perm.member.add',
detail: { group: name, userId },
})
return res.status(204).end()
} catch (err) {
// A user id that names nobody fails on the foreign key rather than on a
// check of our own: the row is the constraint, and one round trip is
// cheaper than two.
log.error('failed to add a member', { group: name, userId, error: err.message })
return res.status(400).json({ message: 'That account could not be added to the group' })
}
}
async function removeMember(req, res) {
const name = model.normaliseName(req.params.name)
const userId = Number(req.params.userId)
try {
const group = await db.getGroup(name)
if (!group) return res.status(404).json({ message: 'No such group' })
const removed = await db.removeGroupMember(name, userId)
if (!removed) return res.status(404).json({ message: 'That account is not in the group' })
await db.markDirty(group.scope)
await core.activity.log({
req,
action: 'rust.perm.member.remove',
detail: { group: name, userId },
})
return res.status(204).end()
} catch (err) {
log.error('failed to remove a member', { group: name, userId, error: err.message })
return res.status(500).json({ message: 'Failed to remove that account from the group' })
}
}
/**
* Grant one permission to one person.
*
* `source` is fixed at `admin` here and is not accepted from the body: the
* column exists so phase 13's event actions can write their own rows through the
* same table, and a route that let a caller choose would make "who gave this"
* unanswerable the first time somebody passed the wrong string.
*/
async function addGrant(req, res) {
const permission = model.normaliseName(req.body.permission)
const scope = String(req.body.scope || model.FLEET)
let userId = null
try {
if (scope !== model.FLEET && !(await knownServer(scope))) {
return res.status(400).json({ message: 'That scope names no configured server' })
}
userId = await resolveUser(req.body)
if (!userId) return res.status(404).json({ message: 'No account on this site has that name' })
const { inserted } = await db.insertGrant({
userId,
permission,
scope,
source: 'admin',
note: req.body.note ? String(req.body.note).slice(0, 255) : null,
grantedBy: req.user ? req.user.id : null,
})
if (inserted) {
await db.markDirty(scope)
await core.activity.log({
req,
action: 'rust.perm.grant',
detail: { userId, permission, scope },
})
}
return res.status(inserted ? 201 : 200).json({ granted: inserted })
} catch (err) {
log.error('failed to grant', { userId, permission, error: err.message })
return res.status(400).json({ message: 'That permission could not be granted' })
}
}
async function removeGrant(req, res) {
const id = Number(req.params.id)
try {
const grant = await db.getGrant(id)
if (!grant) return res.status(404).json({ message: 'No such grant' })
await db.deleteGrant(id)
await db.markDirty(grant.scope)
await core.activity.log({
req,
action: 'rust.perm.revoke',
detail: { userId: grant.userId, permission: grant.permission, scope: grant.scope },
})
return res.status(204).end()
} catch (err) {
log.error('failed to revoke a grant', { grant: id, error: err.message })
return res.status(500).json({ message: 'Failed to remove that grant' })
}
}
/**
* Adopt a hand edit: the site records it as its own.
*
* It is only possible for a `grant` whose Steam id belongs to a website account,
* and the refusal says so — because the alternative is authoring privilege
* against a game account no person on this site holds, which is precisely the
* thing D28 decided not to do.
*/
async function adoptDrift(req, res) {
const id = Number(req.params.id)
try {
const row = await db.getDrift(id)
if (!row) return res.status(404).json({ message: 'No such drift' })
if (row.kind !== 'grant' && row.kind !== 'member') {
return res.status(400).json({
message: 'Only a grant or a membership can be adopted. A permission on a group is edited on the group itself.',
})
}
const holder = await holderOf(row.subject)
if (!holder) {
return res.status(409).json({
message:
'That Steam account is not linked to any account on this site, so there is nobody to author this against. Revoke it instead, or ask the player to link.',
})
}
if (row.kind === 'grant') {
await db.insertGrant({
userId: holder.userId,
permission: row.object,
scope: row.serverId,
source: 'adopted',
note: 'Adopted from a hand edit',
grantedBy: req.user ? req.user.id : null,
})
} else {
const group = await db.getGroup(row.object)
if (!group) return res.status(409).json({ message: 'That group is not authored on this site' })
await db.addGroupMember(row.object, holder.userId, req.user ? req.user.id : null)
}
// Already in the game, so it is already pushed — recorded as such rather
// than left for the next sync to "apply". Without this the row would be
// desired-but-not-pushed, which is a state the loop would happily write
// again and the game would report as already correct: harmless, and a lie in
// the one table that exists to say what this site put there.
await db.addPushed(row.serverId, [{ kind: row.kind, subject: row.subject, object: row.object }])
await db.deleteDrift(id)
await db.markDirty(row.serverId)
await core.activity.log({
req,
action: 'rust.perm.drift.adopt',
detail: { server: row.serverId, kind: row.kind, subject: row.subject, object: row.object },
})
return res.status(204).end()
} catch (err) {
log.error('failed to adopt drift', { drift: id, error: err.message })
return res.status(500).json({ message: 'Failed to adopt that change' })
}
}
/**
* Revoke a hand edit.
*
* Queued rather than sent: the server may be down, and an instruction that is
* dropped because a game host was restarting is exactly the behaviour a site
* claiming to be the author of record must not have. The next successful sync
* carries it and the queue row goes.
*/
async function revokeDrift(req, res) {
const id = Number(req.params.id)
try {
const row = await db.getDrift(id)
if (!row) return res.status(404).json({ message: 'No such drift' })
await db.queueRevocation({
serverId: row.serverId,
kind: row.kind,
subject: row.subject,
object: row.object,
requestedBy: req.user ? req.user.id : null,
})
await db.deleteDrift(id)
await db.markDirty(row.serverId)
await core.activity.log({
req,
action: 'rust.perm.drift.revoke',
detail: { server: row.serverId, kind: row.kind, subject: row.subject, object: row.object },
})
return res.status(202).json({ queued: true })
} catch (err) {
log.error('failed to queue a revocation', { drift: id, error: err.message })
return res.status(500).json({ message: 'Failed to queue that revocation' })
}
}
/** Run the loop's pass now, for one server or for all of them, and report what happened. */
async function syncNow(req, res) {
const serverId = req.body && req.body.serverId ? String(req.body.serverId) : null
try {
if (serverId && !(await knownServer(serverId))) {
return res.status(404).json({ message: 'No such server' })
}
await db.markDirty(serverId || model.FLEET)
await permSync.tick({ force: serverId })
await core.activity.log({
req,
action: 'rust.perm.sync',
detail: { server: serverId || 'all' },
})
const state = await model.overview()
return res.json({ servers: state.servers, drift: state.drift })
} catch (err) {
log.error('a forced sync failed', { server: serverId, error: err.message })
return res.status(500).json({ message: 'Failed to run the sync' })
}
}
/** Every permission name any configured server has registered, with which ones know it. */
async function catalogue(req, res) {
try {
const rows = await db.listCatalogue()
res.json({ permissions: groupCatalogue(rows) })
} catch (err) {
log.error('failed to read the catalogue', { error: err.message })
res.status(500).json({ message: 'Failed to read the permission catalogue' })
}
}
function groupCatalogue(rows) {
const byPermission = new Map()
for (const row of rows) {
if (!byPermission.has(row.permission)) byPermission.set(row.permission, [])
byPermission.get(row.permission).push(row.serverId)
}
return [...byPermission.entries()]
.map(([permission, serverIds]) => ({ permission, servers: serverIds }))
.sort((a, b) => a.permission.localeCompare(b.permission))
}
/**
* The user id a write is about, from either an id or a username.
*
* The form sends a name, because a form that made an operator type a numeric id
* would be a form nobody could use. The id form stays accepted because the
* client already holds one on the panel inside core's user page, and looking a
* name back up from it would be a round trip to answer a question it has
* already answered.
*/
async function resolveUser(body) {
if (body.userId) return Number(body.userId)
if (!body.username) return null
const user = await db.findUserByUsername(String(body.username).trim())
return user ? user.id : null
}
/** Whether a scope names a server row. A disabled server still counts — it exists. */
async function knownServer(id) {
const rows = await servers.listForAdmin()
return rows.some((row) => row.id === id)
}
/** The website account that holds a Steam id, or null. */
async function holderOf(steamId) {
const links = await db.listLinks()
return links.find((link) => link.steamId === steamId) || null
}
module.exports = {
overview,
putGroup,
deleteGroup,
addMember,
removeMember,
addGrant,
removeGrant,
adoptDrift,
revokeDrift,
syncNow,
catalogue,
}

View File

@@ -0,0 +1,184 @@
// ── Admin · Rust · Permissions ────────────────────────────────────────────
//
// Mounted under the admin tier's `/rust` prefix, so every path here is
// `/api/v1/admin/rust/permissions…`. It is a second router rather than more
// routes on `rust.router.js` because it is a second subject: that one configures
// the bridge, this one authors privilege inside somebody's game.
//
// **Every route is `requireRole('admin')`.** The admin tier's own gate admits
// editors and moderators, and a moderator being able to grant themselves
// `kits.admin` on six servers is the whole of R1's "a weak link is now a
// privilege-escalation path" arriving through the front door instead. The tier
// gate is not re-implemented; this is one gate on top of it, exactly as the
// server-configuration routes do it.
//
// There is no module-declared site permission to gate these more finely with —
// `MODULE_API.md` has no such member at 1.10.0 — so role is the whole of the
// available vocabulary, and `admin` is the honest choice within it.
const core = require('../../core')
const express = core.express
const permissions = require('./permissions.controller')
const { requireRole, validate } = core.middleware
const { body, param } = core.validator
const permissionsRouter = express.Router()
/** A permission or group name, as both mod frameworks store them. */
const NAME = /^[a-z0-9][a-z0-9._-]{0,127}$/i
permissionsRouter.get(
'/',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'The whole permission model'
// #swagger.description = 'Groups with their permissions and members, direct grants, the drift each server reported, the option source of registered permission names, and the sync state of every configured server.'
/* #swagger.responses[200] = { description: 'The authored model and what each game reported', content: { "application/json": { schema: { $ref: "#/components/schemas/RustPermissionModel" } } } } */
requireRole('admin'),
permissions.overview,
)
permissionsRouter.get(
'/catalogue',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Permission names the servers have registered'
// #swagger.description = 'What the loaded plugins on each configured server have registered, cached from the last sync. It is the option source for the authoring form: a permission no server knows cannot be granted, because `GrantUserPermission` silently does nothing for an unregistered name.'
/* #swagger.responses[200] = { description: 'Every registered name, and which servers know it', content: { "application/json": { schema: { $ref: "#/components/schemas/RustPermissionCatalogue" } } } } */
requireRole('admin'),
permissions.catalogue,
)
permissionsRouter.put(
'/groups/:name',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Create or update a permission group'
// #swagger.description = 'Writes the group and the permissions it carries in one request, because they are one idea on the form. `scope` is a server id or `*` for the whole fleet. The group is mirrored into each in-scope game as a real group, so third-party plugins that read group membership see it.'
/* #swagger.responses[204] = { description: 'Saved' } */
/* #swagger.responses[400] = { description: 'Invalid body, or a scope naming no configured server' } */
requireRole('admin'),
param('name').matches(NAME).withMessage('a group name is letters, digits, dots, dashes and underscores'),
body('title').optional().isString().trim().isLength({ max: 120 }),
body('rank').optional().isInt({ min: -1000, max: 1000 }).toInt(),
body('scope').optional().isString().isLength({ min: 1, max: 64 }),
body('permissions').optional().isArray({ max: 500 }),
body('permissions.*').isString().matches(NAME),
validate,
permissions.putGroup,
)
permissionsRouter.delete(
'/groups/:name',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Delete a permission group'
// #swagger.description = 'Removes the group, its permission list and its membership from the site. The next sync retires the group from every server it had been pushed to — a group the site authored and has withdrawn is removed from the game, unlike one somebody created by hand.'
/* #swagger.responses[204] = { description: 'Deleted' } */
/* #swagger.responses[404] = { description: 'No such group' } */
requireRole('admin'),
param('name').isString().isLength({ min: 1, max: 64 }),
validate,
permissions.deleteGroup,
)
permissionsRouter.post(
'/groups/:name/members',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Put an account in a group'
// #swagger.description = 'Membership is authored against a website user and reaches every Steam account they have linked. A member who has never connected to a server cannot be placed in its store yet — the sync reports them as pending and the membership lands on their first connection.'
/* #swagger.responses[204] = { description: 'Added' } */
/* #swagger.responses[404] = { description: 'No such group' } */
requireRole('admin'),
param('name').isString().isLength({ min: 1, max: 64 }),
// Either identifier: the screen sends a name, the panel inside core's own user
// page already holds an id.
body('userId').optional().isInt({ min: 1 }).toInt(),
body('username').optional().isString().trim().isLength({ min: 1, max: 64 }),
validate,
permissions.addMember,
)
permissionsRouter.delete(
'/groups/:name/members/:userId',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Take an account out of a group'
/* #swagger.responses[204] = { description: 'Removed' } */
/* #swagger.responses[404] = { description: 'No such group, or that account is not in it' } */
requireRole('admin'),
param('name').isString().isLength({ min: 1, max: 64 }),
param('userId').isInt({ min: 1 }).toInt(),
validate,
permissions.removeMember,
)
permissionsRouter.post(
'/grants',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Grant one permission to one person'
// #swagger.description = 'A direct grant, authored against a website user and pushed to every Steam account they have linked. Unlike group membership it reaches a player who has never connected to the server, which is what an entitlement earned on the website has to do.'
/* #swagger.responses[201] = { description: 'Granted' } */
/* #swagger.responses[200] = { description: 'They already held it; nothing changed' } */
/* #swagger.responses[400] = { description: 'Invalid body, or a scope naming no configured server' } */
/* #swagger.responses[404] = { description: 'No account on this site has that name' } */
requireRole('admin'),
body('userId').optional().isInt({ min: 1 }).toInt(),
body('username').optional().isString().trim().isLength({ min: 1, max: 64 }),
body('permission').isString().matches(NAME),
body('scope').optional().isString().isLength({ min: 1, max: 64 }),
body('note').optional().isString().isLength({ max: 255 }),
validate,
permissions.addGrant,
)
permissionsRouter.delete(
'/grants/:id',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Remove a grant'
// #swagger.description = 'The next sync revokes it in every in-scope game. A player who has already used what it allowed keeps what they did with it — the grant is the entitlement, not the consumption.'
/* #swagger.responses[204] = { description: 'Removed' } */
/* #swagger.responses[404] = { description: 'No such grant' } */
requireRole('admin'),
param('id').isInt({ min: 1 }).toInt(),
validate,
permissions.removeGrant,
)
permissionsRouter.post(
'/drift/:id/adopt',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Adopt a hand edit'
// #swagger.description = 'Records a grant or membership somebody made in game as one the site authors, so it stops being reported and starts being maintained. It needs a website account holding that Steam id; without one there is nobody to author it against, and the answer is to revoke it or to ask the player to link.'
/* #swagger.responses[204] = { description: 'Adopted' } */
/* #swagger.responses[400] = { description: 'That kind of drift cannot be adopted' } */
/* #swagger.responses[409] = { description: 'That Steam account is linked to nobody on this site' } */
requireRole('admin'),
param('id').isInt({ min: 1 }).toInt(),
validate,
permissions.adoptDrift,
)
permissionsRouter.post(
'/drift/:id/revoke',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Revoke a hand edit'
// #swagger.description = 'Queues the removal rather than performing it: a server that is down keeps the instruction until it comes back. This is the only way the site removes something it did not put there — a sync never does it on its own.'
/* #swagger.responses[202] = { description: 'Queued for the next sync' } */
/* #swagger.responses[404] = { description: 'No such drift' } */
requireRole('admin'),
param('id').isInt({ min: 1 }).toInt(),
validate,
permissions.revokeDrift,
)
permissionsRouter.post(
'/sync',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Push the permission set now'
// #swagger.description = 'Runs the reconciliation loops pass immediately, for one server or for all of them, and answers with what each one reported. The loop does this on its own; the button exists so an operator who has just changed something can see it land, and finds out at once when a server is unreachable.'
/* #swagger.responses[200] = { description: 'The state of every server after the pass', content: { "application/json": { schema: { $ref: "#/components/schemas/RustPermissionSyncResult" } } } } */
/* #swagger.responses[404] = { description: 'No such server' } */
requireRole('admin'),
body('serverId').optional().isString().isLength({ min: 1, max: 64 }),
validate,
permissions.syncNow,
)
module.exports = permissionsRouter

View File

@@ -27,6 +27,11 @@ const { body, param } = core.validator
const adminRustRouter = express.Router()
// R2's authoring surface, under `/rust/permissions`. Its own file because it is
// its own subject — this router configures the bridge, that one decides who may
// do what inside the game the bridge reaches.
adminRustRouter.use('/permissions', require('./permissions.router'))
adminRustRouter.get(
'/servers',
// #swagger.tags = ['Admin · Rust']

View File

@@ -8,6 +8,9 @@
const core = require('../../core')
const links = require('../../model/links/links.model')
const permissionsDb = require('../../model/permissions/permissions.db')
const permissions = require('../../model/permissions/permissions.model')
const servers = require('../../model/servers/servers.model')
const log = core.logger('admin')
@@ -68,4 +71,132 @@ async function removeLink(req, res) {
}
}
module.exports = { listLinks, removeLink }
/**
* GET /admin/users/:id/rust/permissions
*
* What this person may do in game, and — the part that is easy to leave out —
* whether any of it reaches anybody. A grant against an account with no linked
* Steam id is authored, stored, pushed nowhere and looks identical to a working
* one on every screen that does not say so.
*/
async function listPermissions(req, res) {
const userId = Number(req.params.id)
try {
const [groups, groupPermissions, members, grants, allLinks] = await Promise.all([
permissionsDb.listGroups(),
permissionsDb.listGroupPermissions(),
permissionsDb.listGroupMembers(),
permissionsDb.listGrants({ userId }),
permissionsDb.listLinks(),
])
const theirs = new Set(
members.filter((row) => row.userId === userId).map((row) => row.groupName),
)
const carried = new Map()
for (const row of groupPermissions) {
if (!carried.has(row.groupName)) carried.set(row.groupName, [])
carried.get(row.groupName).push(row.permission)
}
res.json({
groups: groups
.filter((group) => theirs.has(group.name))
.map((group) => ({
name: group.name,
title: group.title,
scope: group.scope,
permissions: carried.get(group.name) || [],
})),
grants: permissions.collapseGrants(grants).map((grant) => ({
id: grant.id,
permission: grant.permission,
scope: grant.scope,
source: grant.source,
note: grant.note,
grantedAt: grant.grantedAt,
})),
reaches: allLinks.filter((link) => link.userId === userId).map((link) => link.steamId),
})
} catch (err) {
log.error('failed to read a users Rust permissions', { error: err.message })
res.status(500).json({ message: 'Failed to read this users Rust permissions' })
}
}
/** POST /admin/users/:id/rust/permissions/grants */
async function addGrant(req, res) {
const userId = Number(req.params.id)
const permission = permissions.normaliseName(req.body.permission)
const scope = String(req.body.scope || permissions.FLEET)
try {
if (scope !== permissions.FLEET) {
const known = await servers.listForAdmin()
if (!known.some((row) => row.id === scope)) {
return res.status(400).json({ message: 'That scope names no configured server' })
}
}
const { inserted } = await permissionsDb.insertGrant({
userId,
permission,
scope,
source: 'admin',
note: null,
grantedBy: req.user ? req.user.id : null,
})
if (inserted) {
await permissionsDb.markDirty(scope)
await core.activity.log({
req,
action: 'rust.perm.grant',
detail: { userId, permission, scope },
})
}
return res.status(inserted ? 201 : 200).json({ granted: inserted })
} catch (err) {
log.error('failed to grant a permission', { userId, permission, error: err.message })
return res.status(400).json({ message: 'That permission could not be granted' })
}
}
/**
* DELETE /admin/users/:id/rust/permissions/grants/:grantId
*
* **Scoped by the user as well as by the grant**, like every other write in this
* panel: a grant id belonging to somebody else answers `404` rather than
* removing a privilege from a person whose page nobody was looking at.
*/
async function removeGrant(req, res) {
const userId = Number(req.params.id)
const grantId = Number(req.params.grantId)
try {
const grant = await permissionsDb.getGrant(grantId)
if (!grant || grant.userId !== userId) {
return res.status(404).json({ message: 'That grant does not belong to this user' })
}
await permissionsDb.deleteGrant(grantId)
await permissionsDb.markDirty(grant.scope)
await core.activity.log({
req,
action: 'rust.perm.revoke',
detail: { userId, permission: grant.permission, scope: grant.scope },
})
return res.status(204).end()
} catch (err) {
log.error('failed to remove a grant', { userId, grant: grantId, error: err.message })
return res.status(500).json({ message: 'Failed to remove that permission' })
}
}
module.exports = { listLinks, removeLink, listPermissions, addGrant, removeGrant }

View File

@@ -30,7 +30,7 @@
const core = require('../../core')
const express = core.express
const { param } = core.validator
const { body, param } = core.validator
const usersRust = require('./usersRust.controller')
const { validate } = core.middleware
@@ -70,4 +70,62 @@ usersRustRouter.delete(
usersRust.removeLink,
)
// ── Phase 7: what this person may do in game ─────────────────────────────
//
// The same panel, one section lower. It is here rather than only on the
// permissions screen because the question an operator actually has is about a
// PERSON — "why can this player spawn a kit" is asked on their page, not on a
// list of groups — and because the slot is already the place this module says
// everything else it knows about one user.
//
// Both writes go through the ordinary authored tables and the ordinary loop. A
// grant made here reaches the game when the mirror next reconciles, which is
// seconds, and never inside this request.
usersRustRouter.get(
'/rust/permissions',
// #swagger.tags = ['Admin · Users']
// #swagger.summary = 'A users Rust privileges (admin only)'
// #swagger.description = 'The groups this person is in, the permissions granted to them directly, and the Steam accounts those privileges actually reach. An empty `reaches` means they have linked nothing and hold them on paper only.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
/* #swagger.responses[200] = { description: 'Their groups and grants', content: { "application/json": { schema: { $ref: "#/components/schemas/RustUserPermissions" } } } } */
param('id').isInt(),
validate,
usersRust.listPermissions,
)
usersRustRouter.post(
'/rust/permissions/grants',
// #swagger.tags = ['Admin · Users']
// #swagger.summary = 'Grant a Rust permission to this user (admin only)'
// #swagger.description = 'Authored against the website account, so it reaches every Steam id they have linked — now and later. `scope` is a server id or `*` for the fleet. The push happens on the mirrors next pass.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
/* #swagger.responses[201] = { description: 'Granted' } */
/* #swagger.responses[200] = { description: 'They already held it' } */
/* #swagger.responses[400] = { description: 'Invalid body, or a scope naming no configured server', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt(),
body('permission').isString().matches(/^[a-z0-9][a-z0-9._-]{0,127}$/i),
body('scope').optional().isString().isLength({ min: 1, max: 64 }),
validate,
usersRust.addGrant,
)
usersRustRouter.delete(
'/rust/permissions/grants/:grantId',
// #swagger.tags = ['Admin · Users']
// #swagger.summary = 'Remove a Rust permission from this user (admin only)'
// #swagger.description = 'Scoped to this user as well as to the grant, so a wrong id on the URL removes nothing rather than somebody elses privilege. The revoke reaches the game on the mirrors next pass.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
// #swagger.parameters['grantId'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'The grant to remove.' }
/* #swagger.responses[204] = { description: 'Removed' } */
/* #swagger.responses[404] = { description: 'No such grant for this user', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt(),
param('grantId').isInt({ min: 1 }).toInt(),
validate,
usersRust.removeGrant,
)
module.exports = usersRustRouter

View File

@@ -76,6 +76,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 }]`.
*

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff