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

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

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