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:
@@ -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())
|
||||
})
|
||||
|
||||
@@ -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`)
|
||||
|
||||
285
server/test/permissions.test.js
Normal file
285
server/test/permissions.test.js
Normal 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'])
|
||||
})
|
||||
Reference in New Issue
Block a user