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