feat(rust): site-owned permissions — the site is the author, the game is the cache
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
This commit is contained in:
@@ -335,6 +335,271 @@ CREATE TABLE IF NOT EXISTS rust_account_links (
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
|
||||
-- ── Site-owned permissions (phase 7, R2) ──────────────────────────────────
|
||||
--
|
||||
-- The website is the author of record for who may do what in game, and the
|
||||
-- framework's own permission store is an ENFORCEMENT CACHE. That is one
|
||||
-- sentence with three consequences, and the tables below are shaped by them:
|
||||
--
|
||||
-- • Every third-party plugin honours a site grant with no adapter, because
|
||||
-- they all already call `UserHasPermission`. Nothing here is read by the
|
||||
-- game directly; it is pushed into the store the game already consults.
|
||||
-- • A wipe stops being a data-loss event. The game forgets and the site does
|
||||
-- not, so the next sync puts it all back.
|
||||
-- • A hand edit is REPORTED, never silently overwritten (D31). Which means
|
||||
-- the site has to be able to tell a grant it made from one somebody typed
|
||||
-- at a console — and that is a fact only the site can hold, because the
|
||||
-- store records who granted a permission nowhere.
|
||||
--
|
||||
-- ── A grant is against a WEBSITE USER (D28) ───────────────────────────────
|
||||
--
|
||||
-- Not against a Steam id, though a Steam id is what reaches the game. The site
|
||||
-- authors privilege for a PERSON: phase 13's earned entitlements follow whoever
|
||||
-- earned them, and an account unlinked from a person takes their privileges
|
||||
-- with it. The Steam ids are resolved from `rust_account_links` at push time,
|
||||
-- so a player who links a second account gets what they hold on both — which is
|
||||
-- the honest reading of "this person may do this".
|
||||
--
|
||||
-- A user with no linked account is authored against perfectly well and simply
|
||||
-- reaches nobody until they link. That is visible on the admin screen rather
|
||||
-- than silent, because a grant that reaches nothing looks identical to a grant
|
||||
-- that worked from every other angle.
|
||||
--
|
||||
-- ── Scope (D29) ───────────────────────────────────────────────────────────
|
||||
--
|
||||
-- Every authored row carries one: a server id, or `*` for the whole fleet. The
|
||||
-- game stores permissions per server (each has its own store), an operator
|
||||
-- running a modded server and a vanilla one will not want one set on both, and
|
||||
-- a single-server community never has to think about it.
|
||||
|
||||
|
||||
-- ── Groups ────────────────────────────────────────────────────────────────
|
||||
--
|
||||
-- Mirrored into the game as REAL groups (D30) rather than flattened into
|
||||
-- per-player grants. Third-party plugins read group membership, BetterChat's
|
||||
-- group API (R15, phase 17) has something to hang on, and an operator reading
|
||||
-- `oxide.show groups` sees what the website shows.
|
||||
--
|
||||
-- The cost of that fidelity is written down in PLAN.md §12.2 rule 4 and does
|
||||
-- not go away: **a player the store has never seen cannot be put in a group**,
|
||||
-- while a direct grant to the same id works immediately. The sync reports those
|
||||
-- members as pending and the membership lands on their first connection.
|
||||
--
|
||||
-- The name is the primary key, fleet-wide, even though the row carries a scope:
|
||||
-- one `vip` on the site is one `vip` in the game, pushed to the servers its
|
||||
-- scope names. Two groups of the same name with different scopes would be two
|
||||
-- definitions of one name in every store that received both.
|
||||
CREATE TABLE IF NOT EXISTS rust_perm_groups (
|
||||
name VARCHAR(64) NOT NULL PRIMARY KEY,
|
||||
title VARCHAR(120) NOT NULL DEFAULT '',
|
||||
rank INT NOT NULL DEFAULT 0,
|
||||
scope VARCHAR(64) NOT NULL DEFAULT '*',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
|
||||
-- What each group carries. A row per permission rather than a list on the group
|
||||
-- for the ordinary reason: "which groups grant kits.vip" is the question an
|
||||
-- operator asks when they are about to remove a plugin, and that is a WHERE
|
||||
-- clause here and a scan of every row in the other shape.
|
||||
CREATE TABLE IF NOT EXISTS rust_perm_group_permissions (
|
||||
group_name VARCHAR(64) NOT NULL,
|
||||
permission VARCHAR(128) NOT NULL,
|
||||
PRIMARY KEY (group_name, permission),
|
||||
CONSTRAINT fk_rust_perm_group_permissions_group
|
||||
FOREIGN KEY (group_name) REFERENCES rust_perm_groups (name) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
|
||||
-- Who is in each group — by website user, like every other authored row.
|
||||
--
|
||||
-- `added_by` is an admin's user id and deliberately carries NO foreign key: a
|
||||
-- staff member's account being deleted must not delete the record of what they
|
||||
-- did, and `ON DELETE SET NULL` would quietly rewrite history to "nobody".
|
||||
-- The activity log is the audit trail; this column is a convenience beside it.
|
||||
CREATE TABLE IF NOT EXISTS rust_perm_group_members (
|
||||
group_name VARCHAR(64) NOT NULL,
|
||||
user_id INT NOT NULL,
|
||||
added_by INT NULL,
|
||||
added_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (group_name, user_id),
|
||||
KEY idx_rust_perm_members_user (user_id),
|
||||
CONSTRAINT fk_rust_perm_members_group
|
||||
FOREIGN KEY (group_name) REFERENCES rust_perm_groups (name) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_rust_perm_members_user
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
|
||||
-- ── Direct grants ─────────────────────────────────────────────────────────
|
||||
--
|
||||
-- A permission held by one person, without a group. It is not a lesser version
|
||||
-- of membership: it is the shape that reaches a player who has never connected
|
||||
-- to that server, which is exactly what an entitlement earned on the website at
|
||||
-- three in the morning has to do (R16).
|
||||
--
|
||||
-- `source` is why this table does not need changing in phase 13. Every later
|
||||
-- author — an event action granting the right to redeem a kit, a lease handing
|
||||
-- out a weekend group — writes a row here with its own source rather than a
|
||||
-- store of its own, so there is one answer to "why does this player have this"
|
||||
-- and one place the push reads.
|
||||
CREATE TABLE IF NOT EXISTS rust_perm_grants (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
permission VARCHAR(128) NOT NULL,
|
||||
scope VARCHAR(64) NOT NULL DEFAULT '*',
|
||||
source VARCHAR(32) NOT NULL DEFAULT 'admin',
|
||||
note VARCHAR(255) NULL,
|
||||
granted_by INT NULL,
|
||||
granted_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uq_rust_perm_grant (user_id, permission, scope),
|
||||
KEY idx_rust_perm_grant_user (user_id),
|
||||
CONSTRAINT fk_rust_perm_grants_user
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
|
||||
-- ── What this site has actually put in each game ──────────────────────────
|
||||
--
|
||||
-- The site's memory of its own authorship, one row per thing it has confirmed
|
||||
-- into one server's store. It is the table that makes D31 possible at all.
|
||||
--
|
||||
-- Three sets, and every interesting question is the difference between two of
|
||||
-- them:
|
||||
--
|
||||
-- desired − pushed what to apply
|
||||
-- pushed − desired what to RETIRE, because the site put it there and has
|
||||
-- since withdrawn it
|
||||
-- present − desired drift: somebody else put it there
|
||||
--
|
||||
-- Without the middle row a withdrawn grant is indistinguishable from a hand
|
||||
-- edit, and those two have opposite correct answers. Inferring it from absence
|
||||
-- is the mistake this table exists to prevent.
|
||||
--
|
||||
-- It is keyed by Steam id rather than by user, because it records what is in the
|
||||
-- GAME, and the game has never heard of a website account. Unlinking an account
|
||||
-- therefore leaves its row here until the next sync retires it — which is the
|
||||
-- correct behaviour and would be impossible to express keyed the other way.
|
||||
CREATE TABLE IF NOT EXISTS rust_perm_pushed (
|
||||
server_id VARCHAR(64) NOT NULL,
|
||||
-- `grant` | `member` | `group-permission` | `group`
|
||||
kind VARCHAR(24) NOT NULL,
|
||||
-- a Steam id, or a group name
|
||||
subject VARCHAR(64) NOT NULL,
|
||||
-- a permission, a group name, or '' for the existence of a group
|
||||
object VARCHAR(128) NOT NULL,
|
||||
pushed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (server_id, kind, subject, object),
|
||||
CONSTRAINT fk_rust_perm_pushed_server
|
||||
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
|
||||
-- ── Drift ─────────────────────────────────────────────────────────────────
|
||||
--
|
||||
-- What a sync found in a server's store that the site did not author, within
|
||||
-- the namespace the site claims. Rows appear and disappear with the report:
|
||||
-- this is the CURRENT difference, not a history of differences, and a hand edit
|
||||
-- that somebody has since removed should stop being on the screen.
|
||||
--
|
||||
-- Nothing here is ever removed from the game by the sync itself. An operator
|
||||
-- typing `oxide.grant` during an incident is drift, not an error, and the two
|
||||
-- answers offered to them — adopt it, or revoke it — are both a person's
|
||||
-- decision.
|
||||
CREATE TABLE IF NOT EXISTS rust_perm_drift (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
server_id VARCHAR(64) NOT NULL,
|
||||
kind VARCHAR(24) NOT NULL,
|
||||
subject VARCHAR(64) NOT NULL,
|
||||
object VARCHAR(128) NOT NULL,
|
||||
first_seen DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_seen DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uq_rust_perm_drift (server_id, kind, subject, object),
|
||||
CONSTRAINT fk_rust_perm_drift_server
|
||||
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
|
||||
-- ── Removing something the site never put there ───────────────────────────
|
||||
--
|
||||
-- Revoking a drift row cannot go through `rust_perm_pushed`, because the whole
|
||||
-- point of a drift row is that it was never pushed. It cannot go through the
|
||||
-- authored tables either: a foreign grant often names a Steam id that belongs
|
||||
-- to no website account at all, and there is no user to author it against.
|
||||
--
|
||||
-- So a revoke is its own instruction with its own lifetime: queued by a person,
|
||||
-- carried in the next sync's retire list, and deleted once a report says the
|
||||
-- game no longer has it. A server that is offline keeps the instruction until
|
||||
-- it comes back, which is the behaviour an operator expects from a website that
|
||||
-- claims to be the author of record.
|
||||
CREATE TABLE IF NOT EXISTS rust_perm_revocations (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
server_id VARCHAR(64) NOT NULL,
|
||||
kind VARCHAR(24) NOT NULL,
|
||||
subject VARCHAR(64) NOT NULL,
|
||||
object VARCHAR(128) NOT NULL,
|
||||
requested_by INT NULL,
|
||||
requested_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uq_rust_perm_revocation (server_id, kind, subject, object),
|
||||
CONSTRAINT fk_rust_perm_revocations_server
|
||||
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
|
||||
-- ── The state of the mirror, per server ───────────────────────────────────
|
||||
--
|
||||
-- One row per configured server: whether its store currently matches what the
|
||||
-- site authors, when that was last true, and what the last report said.
|
||||
--
|
||||
-- `dirty` is how everything that should provoke a sync says so without knowing
|
||||
-- anything about syncing: an admin writing a grant, a drift hook firing in the
|
||||
-- game, a server reporting a new boot id or a new wipe. The loop owns WHEN, and
|
||||
-- every other part of the module owns WHETHER.
|
||||
--
|
||||
-- `desired_hash` and `synced_hash` are the cheap half of that question. A loop
|
||||
-- that pushed the whole set every tick would work and would also write to six
|
||||
-- game servers every thirty seconds for ever; comparing a hash costs one query
|
||||
-- and skips the round trip when nothing has changed. The periodic audit below
|
||||
-- is what keeps that from being a way to never notice drift.
|
||||
CREATE TABLE IF NOT EXISTS rust_perm_sync (
|
||||
server_id VARCHAR(64) NOT NULL PRIMARY KEY,
|
||||
-- `pending` | `ok` | `failed`
|
||||
state VARCHAR(24) NOT NULL DEFAULT 'pending',
|
||||
dirty TINYINT(1) NOT NULL DEFAULT 1,
|
||||
desired_hash VARCHAR(64) NULL,
|
||||
synced_hash VARCHAR(64) NULL,
|
||||
boot_id VARCHAR(64) NULL,
|
||||
wipe_id VARCHAR(48) NULL,
|
||||
last_attempt_at DATETIME NULL,
|
||||
last_ok_at DATETIME NULL,
|
||||
report LONGTEXT NULL,
|
||||
error VARCHAR(191) NULL,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_rust_perm_sync_server
|
||||
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
|
||||
-- ── What each server's plugins have registered ────────────────────────────
|
||||
--
|
||||
-- The option source the authoring form offers (D33), cached from the live read
|
||||
-- so that opening the form is not six round trips to six game hosts.
|
||||
--
|
||||
-- It is a cache of a fact that changes when an operator loads a plugin, and it
|
||||
-- is refreshed on every sync — which is also why a name that has stopped being
|
||||
-- registered disappears from the form rather than lingering as a choice that
|
||||
-- silently does nothing.
|
||||
CREATE TABLE IF NOT EXISTS rust_perm_catalogue (
|
||||
server_id VARCHAR(64) NOT NULL,
|
||||
permission VARCHAR(128) NOT NULL,
|
||||
seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (server_id, permission),
|
||||
CONSTRAINT fk_rust_perm_catalogue_server
|
||||
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
|
||||
-- ── Changes to tables that already shipped ────────────────────────────────
|
||||
--
|
||||
-- An ALTER below the CREATE, never an edit to it: `CREATE TABLE IF NOT EXISTS`
|
||||
|
||||
Reference in New Issue
Block a user