Files
Module-Rust/server/model/permissions/reconcile.js
wtclaude e0d13e73db
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
feat(rust): the permission manager — the site owns the whole store (D160-D163, D188-D198)
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
2026-09-28 06:58:59 -05:00

278 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── 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 }