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

R2, and the first phase where this module WRITES to a game. Groups and grants are
authored on the website and pushed into each server's own permission store, so
every plugin that already calls `UserHasPermission` honours them with no adapter,
and a wipe stops being a data-loss event.

**Seven org-lead decisions (D28-D34).** A grant is keyed to the website USER and
resolved to every Steam id they have linked at push time (D28); every authored row
carries a scope — a server or `*` (D29); groups are mirrored as real groups rather
than flattened (D30); a holder the site did not author is REPORTED, never undone,
with adopt and revoke offered (D31); one verb, with the plugin diffing locally
(D32); a permission no server has registered is reported unresolved and never
self-registered (D33); authoring is people and groups by hand, with rules deferred
(D34).

**Three sets, and every interesting question is a difference between two.**
`desired − pushed` is what to apply; `pushed − desired` is what to RETIRE, because
the site put it there and has since withdrawn it; `present − desired` is drift. The
middle one is why `rust_perm_pushed` exists: a name in the store that is not in the
desired set is either something the site retired or something a human granted, and
those two have opposite correct answers.

**What lands is not what was sent.** A grant naming a permission the server has not
registered did not land — `GrantUserPermission` no-ops silently — and a member the
store has never seen could not be placed. Neither is recorded as pushed, so the
site never believes it gave a privilege it did not.

The loop asks a cheap question every thirty seconds — does the digest of the
desired set still equal what this server last confirmed — and syncs on a change, a
restart, a wipe, a drift hook, a failed attempt past its backoff, or the
fifteen-minute audit that finds drift on a server nobody has touched.

**This module's first admin page**, because a permission model is the first thing
here that has to be composed rather than configured. What is on it is decided by
what an operator can get wrong: four states are invisible from the game and from a
list of grants, and each is a sentence rather than a number.

Walked end to end against a real core at the pinned ref, the real sidecar, and a
stand-in speaking protocol 4 — including a restart that emptied the store and was
fully re-pushed. Four defects the browser found that 133 green tests did not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM
This commit is contained in:
2026-09-21 18:28:32 -05:00
parent a1b6d155a1
commit 43147b796a
27 changed files with 5515 additions and 21 deletions

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