Files
Module-Rust/server/model/permissions/permissions.apply.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

211 lines
8.1 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.

// ── Carrying out what the reconciler decided ──────────────────────────────
//
// `reconcile.plan` says WHAT a change made in the game becomes; this file writes
// it into the site's own record. Every write here is about ONE server (D190): a
// change in one game affects that server and nothing else, even when the site's
// row reaches further.
//
// • A grant that reaches only this server is deleted or written outright.
// • A grant that reaches more (a fleet grant, or a user's grant scoped `*`)
// gains an EXCEPTION for this server, and keeps reaching every other one.
// • A group shared with other servers is SPLIT: this server gets its own copy,
// the change is made to the copy, and the shared group stops covering it. A
// notice says so, in case the change was meant for every server.
//
// Ops are applied one at a time and each re-reads what it needs, because an
// earlier op in the same plan may have split the group a later one writes to.
// `permSync` runs a plan under one lock for the whole fleet, so two servers'
// plans never split the same shared group at once.
const db = require('./permissions.db')
const model = require('./permissions.model')
/** The site's group of this name on this server, or null (D189). */
async function groupOn(name, serverId) {
const [groups, groupServers] = await Promise.all([db.listGroups(), db.listGroupServers()])
return model.groupsOn(serverId, { groups, groupServers }).find((group) => group.name === name) || null
}
/**
* The group of this name that belongs to THIS server alone, splitting a shared
* one if that is what covers it (D190). Null when the site has no such group.
*/
async function ownGroup(name, serverId) {
const group = await groupOn(name, serverId)
if (!group) return null
const groupServers = await db.listGroupServers()
if (!model.isShared(group, model.serversByGroup(groupServers))) return group
const copy = await db.copyGroup(group.id, 'split')
await db.setGroupServers(copy, { allServers: false, servers: [serverId] })
await db.removeGroupFromServer(group.id, serverId)
await db.noteSplit(serverId, {
group: name,
detail: `changed in the game on ${serverId}; that server now has its own copy of "${name}"`,
})
return db.getGroup(copy)
}
/** The Steam ids and user linked to one Steam id, for finding a user's grant. */
async function userOf(steamId) {
const links = await db.listLinks()
const link = links.find((row) => row.steamId === steamId)
return link ? link.userId : null
}
/**
* A grant the game holds and the site does not. If a grant that reaches this
* server was only kept off it by an EXCEPTION, the exception is what the game
* just undid, so the exception goes. Otherwise a Steam-account grant for this
* server alone is written (D188, D190).
*/
async function adoptGrant(serverId, { steamId, permission, source }) {
const exceptions = (await db.listExceptions()).filter((e) => e.serverId === serverId)
if (exceptions.length) {
const userId = await userOf(steamId)
const [userGrants, steamGrants] = await Promise.all([
userId === null ? [] : db.listGrants({ userId }),
db.listSteamGrants({ steamId }),
])
const candidates = [
...userGrants.map((g) => ({ holder: 'user', id: g.id, permission: g.permission, scope: g.scope })),
...steamGrants.map((g) => ({ holder: 'steam', id: g.id, permission: g.permission, scope: g.scope })),
].filter((g) => model.normaliseName(g.permission) === permission && model.inScope(g.scope, serverId))
for (const grant of candidates) {
const exception = exceptions.find((e) => e.holder === grant.holder && Number(e.grantId) === Number(grant.id))
if (exception) {
await db.deleteException(exception.id)
return
}
}
}
await db.insertSteamGrant({ steamId, permission, scope: serverId, source })
}
/**
* A grant the site holds and the game no longer does. Each source that put it
* on this server stops doing so: one scoped to this server alone is deleted; one
* that reaches further gains an exception here. An event's grant is left to the
* event (the reconciler never sends one here).
*/
async function dropGrant(serverId, { sources = [] }) {
for (const source of sources) {
if (source.type !== 'userGrant' && source.type !== 'steamGrant') continue
const holder = source.type === 'userGrant' ? 'user' : 'steam'
if (source.scope === serverId) {
if (holder === 'user') await db.deleteGrant(source.id)
else await db.deleteSteamGrant(source.id)
} else {
await db.addException({ holder, grantId: source.id, serverId })
}
}
}
/** Run one op. Returns a short line for the log. */
async function applyOp(serverId, op) {
switch (op.op) {
case 'adoptGroup': {
// A group of this name may already exist on another server, or be shared
// with every server but this one: either way this server gets its own.
const existing = await groupOn(op.name, serverId)
if (existing) return `group ${op.name}: already the site's`
const id = await db.insertGroup({ name: op.name, title: op.title, rank: op.rank, parent: op.parent, source: op.source })
await db.setGroupServers(id, { allServers: false, servers: [serverId] })
return `group ${op.name}: adopted`
}
case 'setGroupAttrs': {
const group = await ownGroup(op.group, serverId)
if (!group) return `group ${op.group}: not the site's`
await db.updateGroup(group.id, { title: op.title, rank: op.rank, parent: op.parent })
return `group ${op.group}: title, rank and parent from the game`
}
case 'adoptGroupPermission': {
const group = await ownGroup(op.group, serverId)
if (!group) return `group ${op.group}: not the site's`
await db.addGroupPermission(group.id, op.permission)
return `group ${op.group} + ${op.permission}`
}
case 'dropGroupPermission': {
const group = await ownGroup(op.group, serverId)
if (!group) return `group ${op.group}: not the site's`
await db.removeGroupPermission(group.id, op.permission)
return `group ${op.group} − ${op.permission}`
}
case 'adoptMember': {
const group = await ownGroup(op.group, serverId)
if (!group) return `group ${op.group}: not the site's`
await db.addGroupSteamMember(group.id, op.steamId, { source: op.source })
return `${op.steamId} in ${op.group}`
}
case 'dropMember': {
const group = await ownGroup(op.group, serverId)
if (!group) return `group ${op.group}: not the site's`
// Whichever way the site had them in it: as a Steam account, and as the
// website account that account is linked to.
await db.removeGroupSteamMember(group.id, op.steamId)
const userId = await userOf(op.steamId)
if (userId !== null) await db.removeGroupMember(group.id, userId)
return `${op.steamId} out of ${op.group}`
}
case 'adoptGrant':
await adoptGrant(serverId, op)
return `${op.steamId} + ${op.permission}`
case 'dropGrant':
await dropGrant(serverId, op)
return `${op.steamId} − ${op.permission}`
case 'dropGroup': {
const group = await groupOn(op.group, serverId)
if (!group) return `group ${op.group}: not the site's`
const groupServers = await db.listGroupServers()
if (model.isShared(group, model.serversByGroup(groupServers))) {
await db.removeGroupFromServer(group.id, serverId)
return `group ${op.group}: no longer on ${serverId}`
}
await db.deleteGroup(group.id)
return `group ${op.group}: deleted`
}
default:
return `unknown op ${op.op}`
}
}
/** Run a plan's ops in order. One op failing does not stop the rest. */
async function applyOps(serverId, ops, log = null) {
const done = []
for (const op of ops) {
try {
// eslint-disable-next-line no-await-in-loop
done.push(await applyOp(serverId, op))
} catch (err) {
done.push(`${op.op} failed: ${err.message}`)
if (log) log.warn('permission op failed', { server: serverId, op: op.op, error: err.message })
}
}
return done
}
module.exports = { groupOn, ownGroup, applyOp, applyOps }