feat(rust): the permission manager — the site owns the whole store (D160-D163, D188-D198)
All checks were successful
PR Checks / client-build (pull_request) Successful in 21s
PR Checks / frozen-manifest (pull_request) Successful in 43s
PR Checks / server-tests (pull_request) Successful in 7m58s

PLAN_REDESIGNS section 1.

- Every sync reads the store (perm.inventory), reconciles it against the
  site's record and its ledger, and pushes. A change made in the game is
  settled by the server's policy (D161): auto-adopt (default), adopt, or
  revoke. The first read of a server imports everything (D198).
- Groups belong to one server unless an admin shares them (D189), in new
  id-keyed tables; the old ones are copied once at boot and left unread.
  Holders may be a Steam account nobody linked (D188).
- An in-game change affects that server only (D190): a grant that reaches
  further gains an exception, a shared group is split.
- Never judged: a permission the server does not register right now (an
  unloaded plugin is not a revocation), and a pair an event lease holds.
- A new admin API (server view, grant/revoke with everywhere-or-here,
  groups by id, share/split, members, drift answers) and a screen on
  PermissionsManager's flow with a state on every toggle (D162, D163, U-1).
- The announcement voice names a group by id; old name settings still read.

Walked on both rigs against the walk core: import on an existing install,
auto-adopt of a grant and a revoke, a fleet grant's exception, Kits
unloaded without loss, a shared group split, adopt and revoke policies.
Server 420/420, client 58/58, swagger, imports and route manifest current.

Refs #21

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-09-28 06:58:59 -05:00
parent 77c90db338
commit e0d13e73db
24 changed files with 5873 additions and 2589 deletions

View File

@@ -0,0 +1,277 @@
// ── Three sets, and what a change made in the game becomes ────────────────
//
// `rust_perm_pushed`'s own comment has always named three sets — what is in the
// game, what this site put there, and what the site wants there. Until protocol
// 13 the plugin could only compute the first for the names the site claimed.
// The inventory (PLAN_REDESIGNS §1.2) gives the site all three, so the whole of
// "what happened, and what do we do about it" is decided here:
//
// in the game pushed desired means
// yes no no ADDED in the game
// no yes yes REMOVED in the game
// yes yes yes a group whose title, rank or parent the
// game holds differently from what was
// pushed: CHANGED in the game
// yes no yes landed by some other hand: recorded
//
// (desired − pushed is the ordinary push, and pushed − desired the ordinary
// retirement; neither is this file's business.)
//
// Then the server's policy (D161) says what each change becomes: the site's own
// (`auto-adopt`, the default), a question for a person (`adopt`), or undone
// (`revoke`). The first inventory of a server imports what it finds whatever the
// policy (D198).
//
// **Two things are never judged, and both are how a site would otherwise throw
// away its own grants.** A permission the server has not REGISTERED right now —
// a plugin unloaded for a minute — is missing from the inventory because the
// plugin that owns it is, not because anybody revoked it. And a group permission
// an event lease holds is the lease's until it ends.
//
// Pure: rows in, a plan out. `permissions.apply.js` carries the plan out.
const { rowKey, groupValue, normaliseName, BUILTIN_GROUPS } = require('./permissions.model')
const JUDGED = new Set(['group', 'group-permission', 'member', 'grant'])
/** The inventory in the pushed ledger's shape. */
function presentRows(inventory) {
const rows = []
for (const group of (inventory && inventory.groups) || []) {
const name = normaliseName(group.name)
if (!name) continue
rows.push({ kind: 'group', subject: name, object: '', value: groupValue(group.title, group.rank, group.parent) })
for (const permission of group.permissions || []) {
rows.push({ kind: 'group-permission', subject: name, object: normaliseName(permission) })
}
}
for (const user of (inventory && inventory.users) || []) {
for (const permission of user.permissions || []) {
rows.push({ kind: 'grant', subject: String(user.steamId), object: normaliseName(permission) })
}
for (const group of user.groups || []) {
rows.push({ kind: 'member', subject: String(user.steamId), object: normaliseName(group) })
}
}
return rows
}
/** The group attributes a `group` row's value carries. */
function parseGroupValue(value) {
try {
const [title, rank, parent] = JSON.parse(value)
return { title: String(title == null ? '' : title), rank: Number(rank) || 0, parent: normaliseName(parent) }
} catch {
return null
}
}
/**
* Sort every row into added, removed, changed or landed.
*
* `registered` is the set of names the server registers right now; `leased` the
* lease-held pairs, as `group-permission` rows.
*/
function classify({ present, pushed, desired, registered, leased = [] }) {
const leasedKeys = new Set(leased.map((row) => rowKey({ kind: 'group-permission', subject: normaliseName(row.subject), object: normaliseName(row.object) })))
const judged = (row) => {
if (!JUDGED.has(row.kind)) return false
if ((row.kind === 'grant' || row.kind === 'group-permission') && !registered.has(row.object)) return false
// `default` holds every connected player by the framework's rule (§1.2).
if (row.kind === 'member' && row.object === 'default') return false
if (leasedKeys.has(rowKey(row))) return false
return true
}
const index = (rows) => new Map(rows.filter(judged).map((row) => [rowKey(row), row]))
const P = index(present)
const U = index(pushed)
const D = index(desired)
const added = []
const removed = []
const changed = []
const landed = []
for (const [key, row] of P) {
if (!U.has(key) && !D.has(key)) added.push(row)
else if (!U.has(key) && D.has(key)) landed.push({ ...D.get(key) })
else if (row.kind === 'group' && U.has(key) && D.has(key)) {
const pushedValue = U.get(key).value
// A value the site pushed, that the site still wants, and that the game no
// longer holds: somebody changed the group in the game. A ledger row with
// no value (before protocol 13) cannot say, and the site's value is pushed.
if (pushedValue && row.value !== pushedValue && D.get(key).value === pushedValue) changed.push(row)
}
}
for (const [key, row] of U) {
if (!P.has(key) && D.has(key)) removed.push(row)
}
// A group removed in the game takes its permissions and members with it; they
// are the group's removal, not changes of their own.
const goneGroups = new Set(removed.filter((row) => row.kind === 'group').map((row) => row.subject))
const keep = (row) =>
row.kind === 'group' || !goneGroups.has(row.kind === 'member' ? row.object : row.subject)
return { added, removed: removed.filter(keep), changed, landed }
}
/**
* What the changes become under one server's policy.
*
* Returns:
* `ops` for `permissions.apply.js`, in the order they must run —
* groups before what goes in them
* `drift` "needs a person" rows (D161's `adopt`, and what no policy can
* settle alone)
* `revocations` what the `revoke` policy undoes at this sync
* `hold` row keys this sync must neither push back nor retire nor
* record, because a person has not answered yet
*/
function plan({ classes, policy, importing, sources }) {
const ops = []
const drift = []
const revocations = []
const hold = new Set()
const source = importing ? 'imported' : 'adopted'
const adoptOp = (row) => {
if (row.kind === 'group') {
const attrs = parseGroupValue(row.value) || { title: '', rank: 0, parent: '' }
return { op: 'adoptGroup', name: row.subject, ...attrs, source }
}
if (row.kind === 'group-permission') return { op: 'adoptGroupPermission', group: row.subject, permission: row.object, source }
if (row.kind === 'member') return { op: 'adoptMember', group: row.object, steamId: row.subject, source }
return { op: 'adoptGrant', steamId: row.subject, permission: row.object, source }
}
const dropOp = (row) => {
if (row.kind === 'group') return { op: 'dropGroup', group: row.subject }
if (row.kind === 'group-permission') return { op: 'dropGroupPermission', group: row.subject, permission: row.object }
if (row.kind === 'member') {
return { op: 'dropMember', group: row.object, steamId: row.subject, sources: sources.get(rowKey(row)) || [] }
}
return { op: 'dropGrant', steamId: row.subject, permission: row.object, sources: sources.get(rowKey(row)) || [] }
}
const order = { adoptGroup: 0, setGroupAttrs: 1, adoptGroupPermission: 2, adoptMember: 2, adoptGrant: 2, dropGroupPermission: 3, dropMember: 3, dropGrant: 3, dropGroup: 4 }
// ── The first inventory: everything present becomes the site's (D160, D198) ──
//
// Additions and changed attributes are imported whatever the policy. A removal
// at import is something this site pushed that the game has since lost — it is
// pushed back, as it always was, rather than deleted on the strength of a
// snapshot taken the moment the site first looked.
if (importing) {
for (const row of classes.added) ops.push(adoptOp(row))
for (const row of classes.changed) ops.push({ op: 'setGroupAttrs', group: row.subject, ...parseGroupValue(row.value) })
return { ops: ops.sort((a, b) => order[a.op] - order[b.op]), drift, revocations, hold }
}
if (policy === 'revoke') {
// The site's set wins. An addition is removed at this sync; a removal or a
// changed group is simply pushed back by the desired set.
for (const row of classes.added) {
// A built-in group cannot be removed; one made in the game under `revoke`
// is — but `default` and `admin` are never "added", the import took them.
if (row.kind === 'group' && BUILTIN_GROUPS.has(row.subject)) continue
revocations.push({ kind: row.kind, subject: row.subject, object: row.object })
}
return { ops, drift, revocations, hold }
}
if (policy === 'adopt') {
// Every change waits for a person, and until then the game is left as it is.
// A group made in the game carries its title, rank and parent, so adopting it
// later keeps them.
for (const row of classes.added) {
drift.push({ kind: row.kind, subject: row.subject, object: row.object, direction: 'added', ...(row.kind === 'group' ? { detail: row.value } : {}) })
}
for (const row of classes.removed) {
drift.push({ kind: row.kind, subject: row.subject, object: row.object, direction: 'removed' })
hold.add(rowKey(row))
}
for (const row of classes.changed) {
drift.push({ kind: 'group', subject: row.subject, object: '', direction: 'changed', detail: row.value })
hold.add(rowKey(row))
}
return { ops, drift, revocations, hold }
}
// ── auto-adopt, the default (D161, D190) ──
for (const row of classes.added) ops.push(adoptOp(row))
for (const row of classes.removed) {
const op = dropOp(row)
// A grant only an event gave cannot be adopted away: the event owns it, and
// its revert will withdraw it. It is pushed back, and a person is told.
if (op.op === 'dropGrant' && op.sources.length && op.sources.every((s) => s.type === 'runGrant')) {
drift.push({ kind: row.kind, subject: row.subject, object: row.object, direction: 'removed', detail: 'event' })
continue
}
ops.push(op)
}
for (const row of classes.changed) ops.push({ op: 'setGroupAttrs', group: row.subject, ...parseGroupValue(row.value) })
return { ops: ops.sort((a, b) => order[a.op] - order[b.op]), drift, revocations, hold }
}
/**
* The desired set with the held rows taken out: not in the payload, so the
* plugin does not put them back, and not in `rows`, so the report does not
* record them. A held group keeps its place — only its title, rank and parent
* are left off, which the plugin reads as "leave them".
*/
function withHold(desired, hold) {
if (!hold || !hold.size) return desired
const heldGroups = new Set()
const payload = { ...desired.payload }
payload.groups = desired.payload.groups.map((group) => {
const key = rowKey({ kind: 'group', subject: group.name, object: '' })
const out = { ...group }
if (hold.has(key)) {
heldGroups.add(group.name)
delete out.title
delete out.rank
delete out.parent
}
out.permissions = (group.permissions || []).filter((p) => !hold.has(rowKey({ kind: 'group-permission', subject: group.name, object: p })))
out.members = (group.members || []).filter((s) => !hold.has(rowKey({ kind: 'member', subject: s, object: group.name })))
return out
})
payload.grants = desired.payload.grants
.map((grant) => ({
...grant,
permissions: grant.permissions.filter((p) => !hold.has(rowKey({ kind: 'grant', subject: grant.steamId, object: p }))),
}))
.filter((grant) => grant.permissions.length)
return {
...desired,
payload,
rows: desired.rows.filter((row) => !hold.has(rowKey(row))),
heldGroups,
}
}
module.exports = { presentRows, parseGroupValue, classify, plan, withHold }