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
286 lines
10 KiB
JavaScript
286 lines
10 KiB
JavaScript
// ── 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'])
|
||
})
|