From 0cc8a42e6cb1976c6be30b9e815da5ff63a7b853 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Wed, 7 Oct 2026 13:39:15 +0000 Subject: [PATCH 1/6] LuckPerms in the staff panel: mirror, change outbox and rights The TFMCWeb bridge publishes LuckPerms snapshots to the site and applies changes staff queue there. Mods view; admins change the groups and permissions policy.yaml allows on players; root changes everything, including group definitions and tracks. Roots can also make other accounts root from the panel. Co-Authored-By: Claude Opus 5.5 (1M context) --- backend/src/api/admin_routes.py | 7 +- backend/src/api/luckperms_routes.py | 122 ++++++ backend/src/auth/admin_cli.py | 5 +- backend/src/auth/roles.py | 14 +- backend/src/auth/test_admin.py | 13 +- backend/src/luckperms/README.md | 123 ++++++ backend/src/luckperms/__init__.py | 0 backend/src/luckperms/changes.py | 528 ++++++++++++++++++++++++ backend/src/luckperms/conftest.py | 18 + backend/src/luckperms/mirror.py | 366 ++++++++++++++++ backend/src/luckperms/nodes.py | 152 +++++++ backend/src/luckperms/policy.py | 75 ++++ backend/src/luckperms/policy.yaml | 41 ++ backend/src/luckperms/test_luckperms.py | 407 ++++++++++++++++++ backend/src/luckperms/views.py | 229 ++++++++++ backend/src/skins/schema.sql | 82 ++++ 16 files changed, 2171 insertions(+), 11 deletions(-) create mode 100644 backend/src/api/luckperms_routes.py create mode 100644 backend/src/luckperms/README.md create mode 100644 backend/src/luckperms/__init__.py create mode 100644 backend/src/luckperms/changes.py create mode 100644 backend/src/luckperms/conftest.py create mode 100644 backend/src/luckperms/mirror.py create mode 100644 backend/src/luckperms/nodes.py create mode 100644 backend/src/luckperms/policy.py create mode 100644 backend/src/luckperms/policy.yaml create mode 100644 backend/src/luckperms/test_luckperms.py create mode 100644 backend/src/luckperms/views.py diff --git a/backend/src/api/admin_routes.py b/backend/src/api/admin_routes.py index 0c743d23..0287732a 100644 --- a/backend/src/api/admin_routes.py +++ b/backend/src/api/admin_routes.py @@ -185,6 +185,7 @@ def post_revoke_sessions(user_id: int, body: ReasonBody, request: Request, respo _WRITE_ROUTES = ( (re.compile(r"^/admin/accounts/([^/]+)/role$"), "account.role.change"), (re.compile(r"^/admin/accounts/([^/]+)/sessions/revoke$"), "account.sessions.revoke"), + (re.compile(r"^/admin/luckperms/changes$"), "luckperms.change"), ) @@ -203,7 +204,7 @@ def _audit_invalid_write(request: Request, exc: RequestValidationError) -> None: return if actor is None: return - raw_id = match.group(1) + raw_id = match.group(1) if match.groups() else "" fields = sorted({".".join(str(part) for part in error.get("loc", ())[1:]) for error in exc.errors()}) audit.record_refusal( actor=actor, action=action, outcome="invalid", @@ -219,5 +220,9 @@ async def _validation_handler(request: Request, exc: RequestValidationError): def install(app: FastAPI) -> None: """Mount the staff routes and audit their malformed writes.""" + from src.api.luckperms_routes import plugin_router, staff_router + app.include_router(admin_router) + app.include_router(staff_router) + app.include_router(plugin_router) app.add_exception_handler(RequestValidationError, _validation_handler) diff --git a/backend/src/api/luckperms_routes.py b/backend/src/api/luckperms_routes.py new file mode 100644 index 00000000..a46267ed --- /dev/null +++ b/backend/src/api/luckperms_routes.py @@ -0,0 +1,122 @@ +"""LuckPerms in the staff panel, and the TFMCWeb bridge that feeds and applies it. + +Plugin routes need the primary plugin key: LuckPerms storage is shared, so +only one server applies changes. Staff routes follow admin_routes: reads need +a staff role, writes also need the site's own origin, and refusals by a +signed-in caller are audited. +""" +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response +from pydantic import BaseModel, Field +from starlette.concurrency import run_in_threadpool + +from src.api.admin_routes import _capable, _write +from src.api.auth_routes import _no_store +from src.api.patreon_routes import require_writer +from src.api.request_body import read_json_body +from src.auth import admin +from src.luckperms import changes, mirror, views + +SNAPSHOT_MAX_BYTES = 16 * 1024 * 1024 +RESULTS_MAX_BYTES = 4 * 1024 * 1024 + +plugin_router = APIRouter(prefix="/luckperms/plugin", tags=["luckperms"], dependencies=[Depends(require_writer)]) +staff_router = APIRouter(prefix="/admin/luckperms", tags=["admin"]) + + +@plugin_router.put("/snapshot") +async def put_snapshot(request: Request): + payload = await read_json_body(request, SNAPSHOT_MAX_BYTES) + try: + return await run_in_threadpool(mirror.replace_snapshot, payload) + except mirror.SnapshotError as exc: + raise HTTPException(400, detail=exc.code) from None + + +class HashBody(BaseModel): + hash: str = Field(..., max_length=128) + revision: int = Field(..., gt=0) + + +@plugin_router.post("/snapshot/unchanged") +def post_unchanged(body: HashBody): + if mirror.mark_unchanged(body.hash, body.revision): + return {"ok": True} + return {"ok": False, "need_full": True} + + +@plugin_router.get("/changes") +def get_changes(response: Response): + _no_store(response) + return {"changes": changes.fetch_for_bridge()} + + +@plugin_router.post("/changes/results") +async def post_results(request: Request): + payload = await read_json_body(request, RESULTS_MAX_BYTES) + if not isinstance(payload, dict): + raise HTTPException(400, detail="bad_results") + try: + return await run_in_threadpool(changes.record_results, payload.get("results")) + except admin.AdminError as exc: + raise HTTPException(exc.status, detail=exc.code) from None + + +# -------------------- +# Staff +# -------------------- + + +def _read(request: Request, response: Response, run): + viewer = _capable(request, "view_luckperms") + _no_store(response) + try: + return run(viewer) + except admin.AdminError as exc: + raise HTTPException(exc.status, detail=exc.code) from None + + +@staff_router.get("") +def get_overview(request: Request, response: Response): + return _read(request, response, views.overview) + + +@staff_router.get("/groups/{name}") +def get_group(name: str, request: Request, response: Response): + return _read(request, response, lambda viewer: views.group_detail(viewer, name)) + + +@staff_router.get("/players") +def get_players(request: Request, response: Response, q: str = "", group: str | None = None, + page: int = Query(1, ge=1, le=10_000)): + return _read(request, response, lambda viewer: views.users(viewer, q, group, page)) + + +@staff_router.get("/players/{player_uuid}") +def get_player(player_uuid: str, request: Request, response: Response): + return _read(request, response, lambda viewer: views.user_detail(viewer, player_uuid)) + + +@staff_router.get("/changes") +def get_recent_changes(request: Request, response: Response): + return _read(request, response, lambda _viewer: {"changes": changes.list_changes()}) + + +@staff_router.get("/changes/{change_id}") +def get_change(change_id: int, request: Request, response: Response): + return _read(request, response, lambda _viewer: changes.get_change(change_id)) + + +class ChangeBody(BaseModel): + target_type: str = Field(..., max_length=8) + target: str = Field(..., max_length=64) + ops: list[dict] = Field(..., max_length=changes.OPS_MAX) + reason: str = Field(..., max_length=admin.REASON_MAX * 2) + + +@staff_router.post("/changes") +def post_change(body: ChangeBody, request: Request, response: Response): + _no_store(response) + return _write(request, "luckperms.change", None, + lambda token: changes.queue_change(token, body.target_type, body.target, body.ops, body.reason)) diff --git a/backend/src/auth/admin_cli.py b/backend/src/auth/admin_cli.py index 084c75e0..ef52fcbc 100644 --- a/backend/src/auth/admin_cli.py +++ b/backend/src/auth/admin_cli.py @@ -1,4 +1,4 @@ -"""Operator commands for root accounts. The website never grants or removes root. +"""Operator commands for root accounts, for the first owner or when no root can sign in. Run inside the backend container, for example: python -m src.auth.admin_cli grant-root --discord-id 123 --reason "First owner" @@ -6,7 +6,8 @@ python -m src.auth.admin_cli list-staff The account must have signed in to the website once. Each change is audited -as an operator action and signs the account out everywhere. +as an operator action and signs the account out everywhere. Once one root +exists, roots can also give and take root in the staff panel. """ from __future__ import annotations diff --git a/backend/src/auth/roles.py b/backend/src/auth/roles.py index 33aee780..f96f03b5 100644 --- a/backend/src/auth/roles.py +++ b/backend/src/auth/roles.py @@ -14,6 +14,10 @@ "view_player_movement": "admin", "revoke_sessions": "mod", "change_role": "admin", + # In-game LuckPerms ranks: everyone on staff sees them; what an admin may + # change, beyond viewing, is decided per node by src/luckperms/policy.py. + "view_luckperms": "mod", + "change_luckperms": "admin", } @@ -34,12 +38,16 @@ def capabilities(role: str | None) -> list[str]: def outranks(actor_role: str | None, target_role: str | None) -> bool: - """Staff may only act on accounts strictly below their own role.""" + """Staff may only act on accounts below their own role; root may also act on other roots.""" + if actor_role == "root" and target_role == "root": + return True return rank(actor_role) > rank(target_role) >= 0 def assignable_roles(actor_role: str | None) -> list[str]: - """Roles below the actor's own. Root is granted only by an operator.""" + """Roles below the actor's own; root may give any role, root included.""" if not can(actor_role, "change_role"): return [] - return [role for role in ROLES if role != "root" and rank(role) < rank(actor_role)] + if actor_role == "root": + return list(ROLES) + return [role for role in ROLES if rank(role) < rank(actor_role)] diff --git a/backend/src/auth/test_admin.py b/backend/src/auth/test_admin.py index 0d50630b..36463b30 100644 --- a/backend/src/auth/test_admin.py +++ b/backend/src/auth/test_admin.py @@ -75,14 +75,16 @@ def sessions_of(db, user_id): # -------------------- def test_role_rules(): - assert roles.assignable_roles("root") == ["player", "mod", "admin"] + assert roles.assignable_roles("root") == ["player", "mod", "admin", "root"] assert roles.assignable_roles("admin") == ["player", "mod"] assert roles.assignable_roles("mod") == [] assert roles.assignable_roles("player") == [] assert roles.outranks("admin", "mod") and not roles.outranks("admin", "admin") assert not roles.outranks("root", "unknown") and not roles.outranks("unknown", "player") + # Roots may act on other roots; cannot_act_on_self still covers their own account. + assert roles.outranks("root", "root") and not roles.outranks("admin", "root") assert not roles.is_staff("player") and not roles.is_staff("wizard") and roles.is_staff("mod") - assert roles.capabilities("mod") == ["view_admin", "view_players", "revoke_sessions"] + assert roles.capabilities("mod") == ["view_admin", "view_players", "revoke_sessions", "view_luckperms"] assert roles.capabilities("bogus") == [] @@ -159,7 +161,7 @@ def test_panel_reads_need_staff(app, env): _, mod = account(env, "mona", "mod") me = client(app, mod).get("/admin/me") assert me.status_code == 200 and me.headers["cache-control"] == "no-store" - assert me.json()["capabilities"] == ["view_admin", "view_players", "revoke_sessions"] + assert me.json()["capabilities"] == ["view_admin", "view_players", "revoke_sessions", "view_luckperms"] assert me.json()["assignable_roles"] == [] @@ -215,8 +217,9 @@ def test_root_can_make_admins_and_target_is_signed_out(app, env): ("admin", "admin", "mod", 403, "target_outranks_you"), ("admin", "root", "player", 403, "target_outranks_you"), ("root", "admin", "player", 200, None), - ("root", "player", "root", 403, "role_not_assignable"), - ("root", "root", "admin", 403, "target_outranks_you"), + ("root", "player", "root", 200, None), + ("root", "root", "admin", 200, None), + ("admin", "player", "root", 403, "role_not_assignable"), ("mod", "player", "mod", 403, "forbidden"), ("player", "player", "mod", 403, "forbidden"), ("admin", "player", "player", 409, "role_unchanged"), diff --git a/backend/src/luckperms/README.md b/backend/src/luckperms/README.md new file mode 100644 index 00000000..0af784df --- /dev/null +++ b/backend/src/luckperms/README.md @@ -0,0 +1,123 @@ +# LuckPerms in the staff panel + +The website shows and edits LuckPerms without touching its database. One +server's TFMCWeb (Main's) is the bridge: it publishes a snapshot of LuckPerms +to the site and applies changes that staff queue on the site, through the +LuckPerms API. Dev and Main share one LuckPerms MariaDB, so exactly one server +may apply changes. Another server may publish to its own site (Dev publishes to +dev.tfminecraft.net) without applying, which makes that site read-only. + +## Rights + +| Who | May | +| --- | --- | +| mod | view everything: groups, tracks, every player's groups and permissions, change history | +| admin | add or remove the groups in `policy.yaml` `admin_groups` on players, promote/demote players along tracks between those groups, set or unset player permissions matching `admin_permissions` | +| root | everything, including staff groups, other permissions, meta (prefix/suffix/weight) on players, group definitions and tracks | + +A listed group counts as an admin group only while everything it inherits, in +any context, is listed too, so a root edit to a group definition cannot hand +admins a staff group by inheritance. Unknown permissions, wildcards and regex +nodes are root-only. + +Admins may not change anything on their own Minecraft account, on a player +whose linked website account is admin or root, or on a player who holds or +inherits a root-only group (in-game staff, linked or not). Rights are checked +when a change is queued and again when the bridge collects it, so a demotion +in between cancels it. Every change carries a +reason and is written to `admin_audit`, and LuckPerms logs it (`/lp log`) with +the source `web:`. + +## Plugin protocol + +All routes need the primary `X-Plugin-Key`; secondary keys are refused. + +### Snapshot + +`PUT /luckperms/plugin/snapshot` (JSON, at most 16 MiB): + +```json +{ + "server": "main", + "generated_at": 1791321779, + "revision": 1791321779123, + "hash": "", + "groups": [{"name": "staff", "display_name": null, "weight": 200, "nodes": [NODE]}], + "tracks": [{"name": "staff", "groups": ["staff_player", "staff_inactive", "staff"]}], + "users": [{"uuid": "…", "name": "drefvelin", "nodes": [NODE]}] +} +``` + +`NODE` is `{"key": "group.staff", "value": true, "contexts": {"server": ["main"]}, "expiry": 0}`; +`expiry` is unix seconds, 0 for permanent; `contexts` maps each key to its values (empty object for global). +`users` holds every user with at least one stored node (`UserManager.searchAll` with an empty key prefix). +The site replaces its whole mirror in one transaction and answers `{"ok": true}`. + +`revision` orders everything the bridge reports: it strictly increases across snapshots and +results, also across restarts (the bridge uses `max(last + 1, current time in ms)`). The site +ignores a snapshot or result state whose revision is not above the newest it has stored +(answering `{"ok": true, "stale": true}`), so a request delayed past a newer one cannot undo it. + +`POST /luckperms/plugin/snapshot/unchanged` `{"hash": "…", "revision": …}` answers `{"ok": true}` and +marks the mirror current when the hash matches the stored one, else `{"ok": false, "need_full": true}`. + +The bridge builds a snapshot every `snapshot-seconds` (default 30) and right after applying changes, +on the same single worker thread that applies changes, so a snapshot never predates an applied change. + +### Changes + +`GET /luckperms/plugin/changes` returns up to 20 changes in id order and records the poll time +(the site shows the bridge as connected while polls are recent): + +```json +{"changes": [{ + "id": 12, + "target_type": "user", // user | group | track + "target": "5b0c…-uuid", // user UUID, group name or track name + "target_name": "drefvelin", + "actor_name": "web:w.o.n", // LuckPerms action source name + "actor_uuid": null, // the actor's linked Minecraft UUID, if any + "description": "parent add staff", // LuckPerms action log text + "ops": [OP] +}]} +``` + +Ops, applied in order to one holder and saved once: + +| target | op | fields | precondition | +| --- | --- | --- | --- | +| user, group | `add_node` | `node` | the exact node (key, value, contexts, expiry) is absent | +| user, group | `remove_node` | `node` | the exact node is present | +| group | `create_group` | | the group does not exist | +| group | `delete_group` | | the group exists | +| track | `create_track` | | the track does not exist | +| track | `delete_track` | | the track exists | +| track | `set_groups` | `groups` (ordered) | the track exists and every group exists | + +The bridge checks every precondition in op order against a staged copy of the live LuckPerms data +(so create-then-add and remove-then-add work) before mutating anything; if one fails, nothing is +mutated or saved and the result is `{"ok": false, "error": ""}` with codes +`node_exists`, `node_missing`, `group_exists`, `group_missing`, `track_exists`, `track_missing`, +`bad_op`, `bad_target`, `save_failed`. A `group.` node whose group does not exist is `group_missing`. +If saving fails after mutating a loaded holder, the bridge reloads that holder from storage so the +server does not keep an unsaved change in memory. After saving a user it pushes a user update; after +a group or track change it pushes a full update. It submits one LuckPerms action per change +(source UUID: the actor's linked Minecraft UUID, else the nil UUID). + +`POST /luckperms/plugin/changes/results`: + +```json +{"results": [{"id": 12, "ok": true, "error": null, "revision": 1791321780456, "state": STATE}]} +``` + +`STATE` (optional) is the target after the change: a user `{"uuid", "name", "nodes"}`, a group +`{"name", "display_name", "weight", "nodes"}` or `null` (deleted, or tracks). The site updates its +mirror for that target at once. The bridge keeps results it could not post and posts them again +on later ticks until the site answers `ok`. + +Site side, a change is `pending` until fetched, then `sent`, and the site never offers it again: a +change is applied at most once. A sent change with no result after 300 s becomes `unknown` (staff +are told to check the player); a late result still settles it. A change not fetched within +10 minutes `expired`. New changes are refused with `bridge_offline` unless the bridge polled in the +last 60 s, and always on a site with `LUCKPERMS_READ_ONLY=1` (dev.tfminecraft.net, whose server +shares LuckPerms with Main). diff --git a/backend/src/luckperms/__init__.py b/backend/src/luckperms/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/src/luckperms/changes.py b/backend/src/luckperms/changes.py new file mode 100644 index 00000000..56eda219 --- /dev/null +++ b/backend/src/luckperms/changes.py @@ -0,0 +1,528 @@ +"""LuckPerms changes staff queue for the TFMCWeb bridge. + +A change is checked against the mirror and the actor's rights inside one +immediate transaction, which also reloads the actor's session, so a demotion +that lands first always wins. The change row and its audit row commit +together; a refused attempt rolls back and is then logged on its own. + +Only one unfinished change per target is allowed, so every change is checked +against the state it will actually meet. The bridge checks the same +preconditions again against live LuckPerms before it saves anything. +""" +from __future__ import annotations + +import json +import os +import time +from datetime import datetime, timezone + +from src.auth import audit, roles +from src.auth.admin import AdminError, clean_reason +from src.auth.players import canonical_uuid +from src.auth.users import session_user_in +from src.skins.db import connect + +from . import mirror, nodes, policy + +OPS_MAX = 20 +TRACK_GROUPS_MAX = 32 +DESCRIPTION_MAX = 300 +FETCH_LIMIT = 20 +# Unfetched changes are dropped after this; staff queued them against an old view. +PENDING_TTL_SECONDS = 600 +# A sent change is never offered again; with no result by then its outcome is unknown. +RESULT_WAIT_SECONDS = 300 +HISTORY_LIMIT = 50 + +USER_OPS = {"add_node", "remove_node", "promote", "demote"} +GROUP_OPS = {"add_node", "remove_node", "create_group", "delete_group"} +TRACK_OPS = {"create_track", "delete_track", "set_groups"} +UNFINISHED = ("pending", "sent") + + +def read_only() -> bool: + """dev.tfminecraft.net shows LuckPerms but must not change it: Dev shares Main's storage.""" + return os.environ.get("LUCKPERMS_READ_ONLY", "").strip().lower() in {"1", "true", "yes"} + + +def _now() -> int: + return int(time.time()) + + +def _iso(ts: float | None = None) -> str: + return datetime.fromtimestamp(ts if ts is not None else time.time(), timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def _parse(stamp: str) -> float: + return datetime.strptime(stamp, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc).timestamp() + + +def _bad(code: str) -> AdminError: + return AdminError(400, code, "invalid") + + +def _conflict(code: str) -> AdminError: + return AdminError(409, code, "conflict") + + +def _forbidden(code: str = "forbidden") -> AdminError: + return AdminError(403, code, "denied") + + +def _node(raw, *, new: bool) -> dict: + try: + return nodes.from_request(raw, new=new) + except nodes.NodeError as exc: + raise _bad(exc.code) from None + + +def _find(holder: list[dict], node: dict) -> int | None: + for index, held in enumerate(holder): + if nodes.same(held, node): + return index + return None + + +def _inherits(groups: dict[str, dict], start: str, wanted: str, now: int) -> bool: + return wanted in mirror.inherited(groups, [start], now) + + +# -------------------- +# Building ops +# -------------------- + + +def _node_ops(holder: list[dict], raw_op: dict, groups: dict[str, dict], now: int, *, self_group: str | None = None): + """Check one add/remove against the holder's simulated nodes and apply it there.""" + op = raw_op["op"] + node = _node(raw_op.get("node"), new=op == "add_node") + group = nodes.group_of(node["key"]) + if op == "add_node": + if _find(holder, node) is not None: + raise _conflict("node_exists") + if group is not None: + if group not in groups: + raise _conflict("group_missing") + if self_group is not None and node["value"] and ( + group == self_group or _inherits(groups, group, self_group, now)): + raise _conflict("inheritance_cycle") + holder.append(node) + return {"op": "add_node", "node": node}, nodes.describe(node) + index = _find(holder, node) + if index is None: + raise _conflict("node_missing") + holder.pop(index) + return {"op": "remove_node", "node": node}, nodes.describe_removal(node) + + +def _track_step(holder: list[dict], track: dict, direction: int): + """Promote (+1) or demote (-1) along a track in the global context, as explicit node ops.""" + positions = [ + (index, held) for held in holder + if held["value"] and not held["contexts"] and nodes.group_of(held["key"]) in track["groups"] + for index in [track["groups"].index(nodes.group_of(held["key"]))] + ] + if len(positions) > 1: + raise _conflict("ambiguous_track") + steps = [] + if not positions: + if direction < 0: + raise _conflict("not_on_track") + if not track["groups"]: + raise _conflict("track_empty") + target = track["groups"][0] + else: + index, held = positions[0] + holder.remove(held) + steps.append({"op": "remove_node", "node": held}) + target_index = index + direction + if target_index >= len(track["groups"]): + raise _conflict("track_end") + target = track["groups"][target_index] if target_index >= 0 else None + if target is not None: + added = {"key": f"group.{target}", "value": True, "contexts": {}, "expiry": 0} + if _find(holder, added) is None: + holder.append(added) + steps.append({"op": "add_node", "node": added}) + return steps + + +def build_user_ops(conn, uuid: str, raw_ops: list, now: int) -> tuple[list[dict], list[str]]: + groups = mirror.load_groups(conn) + tracks = {t["name"]: t for t in mirror.load_tracks(conn)} + holder = mirror.user_nodes(conn, uuid) + ops, words = [], [] + for raw in raw_ops: + if raw["op"] in ("promote", "demote"): + track = tracks.get(str(raw.get("track") or "").lower()) + if track is None: + raise _conflict("track_missing") + steps = _track_step(holder, track, 1 if raw["op"] == "promote" else -1) + ops.extend(steps) + words.append(f"{raw['op']} {track['name']}") + else: + op, word = _node_ops(holder, raw, groups, now) + ops.append(op) + words.append(word) + return ops, words + + +def build_group_ops(conn, name: str, raw_ops: list, now: int) -> tuple[list[dict], list[str]]: + groups = mirror.load_groups(conn) + exists = name in groups + holder = list(groups[name]["nodes"]) if exists else [] + ops, words = [], [] + for raw in raw_ops: + op = raw["op"] + if op == "create_group": + if exists: + raise _conflict("group_exists") + exists = True + groups[name] = {"name": name, "display_name": None, "weight": None, "nodes": holder} + ops.append({"op": "create_group"}) + words.append("create") + continue + if not exists: + raise _conflict("group_missing") + if op == "delete_group": + if name == "default": + raise _forbidden("cannot_delete_default") + exists = False + ops.append({"op": "delete_group"}) + words.append("delete") + continue + op_json, word = _node_ops(holder, raw, groups, now, self_group=name) + groups[name]["nodes"] = holder + ops.append(op_json) + words.append(word) + return ops, words + + +def build_track_ops(conn, name: str, raw_ops: list) -> tuple[list[dict], list[str]]: + groups = mirror.load_groups(conn) + exists = any(t["name"] == name for t in mirror.load_tracks(conn)) + ops, words = [], [] + for raw in raw_ops: + op = raw["op"] + if op == "create_track": + if exists: + raise _conflict("track_exists") + exists = True + ops.append({"op": "create_track"}) + words.append("create") + continue + if not exists: + raise _conflict("track_missing") + if op == "delete_track": + exists = False + ops.append({"op": "delete_track"}) + words.append("delete") + continue + listed = raw.get("groups") + if not isinstance(listed, list) or len(listed) > TRACK_GROUPS_MAX: + raise _bad("bad_track_groups") + clean = [str(g).strip().lower() for g in listed if isinstance(g, str)] + if len(clean) != len(listed) or len(set(clean)) != len(clean): + raise _bad("bad_track_groups") + if any(g not in groups for g in clean): + raise _conflict("group_missing") + ops.append({"op": "set_groups", "groups": clean}) + words.append("set " + " ".join(clean) if clean else "clear") + return ops, words + + +# -------------------- +# Rights +# -------------------- + + +def _linked_uuid(conn, discord_user_id: str | None) -> str | None: + if not discord_user_id: + return None + row = conn.execute("SELECT player_uuid FROM discord_links WHERE discord_user_id = ?", (discord_user_id,)).fetchone() + return canonical_uuid(row["player_uuid"]) if row else None + + +def _target_account_role(conn, uuid: str) -> str | None: + row = conn.execute( + "SELECT u.role FROM discord_links dl JOIN users u ON u.discord_user_id = dl.discord_user_id " + "WHERE lower(dl.player_uuid) = ?", + (uuid,), + ).fetchone() + return row["role"] if row else None + + +def _holds_root_only_group(conn, uuid: str, groups: dict[str, dict]) -> bool: + """Whether the player holds, in any context, a group only root may manage: in-game staff.""" + rules = policy.load() + for node in mirror.user_nodes(conn, uuid): + group = nodes.group_of(node["key"]) + if group and node["value"] and policy.group_role(rules, groups, group) == "root": + return True + return False + + +def check_user_target(conn, actor: dict, uuid: str) -> None: + """Admins may not change their own account, fellow admins and roots, or in-game staff.""" + if actor["role"] == "root": + return + if _linked_uuid(conn, actor["discord_user_id"]) == uuid: + raise _forbidden("cannot_act_on_self") + target_role = _target_account_role(conn, uuid) + if target_role is not None and roles.rank(target_role) >= roles.rank(actor["role"]): + raise _forbidden("target_outranks_you") + if _holds_root_only_group(conn, uuid, mirror.load_groups(conn)): + raise _forbidden("target_is_staff") + + +def check_ops_allowed(conn, actor_role: str, target_type: str, ops: list[dict]) -> None: + if target_type != "user": + if not policy.may_edit_definitions(actor_role): + raise _forbidden("root_only") + return + rules = policy.load() + groups = mirror.load_groups(conn) + for op in ops: + if not policy.may_edit_node(rules, groups, actor_role, op["node"]): + raise _forbidden("root_only") + + +# -------------------- +# Queueing +# -------------------- + + +def _shape(target_type, target, raw_ops) -> tuple[str, str, list[dict]]: + if target_type not in ("user", "group", "track"): + raise _bad("bad_target") + if not isinstance(target, str) or not target.strip(): + raise _bad("bad_target") + if target_type == "user": + target = canonical_uuid(target) + if target is None: + raise _bad("bad_uuid") + else: + target = target.strip().lower() + if not nodes.GROUP_NAME.match(target): + raise _bad("bad_group_name" if target_type == "group" else "bad_track_name") + if not isinstance(raw_ops, list) or not raw_ops or len(raw_ops) > OPS_MAX: + raise _bad("bad_ops") + allowed = {"user": USER_OPS, "group": GROUP_OPS, "track": TRACK_OPS}[target_type] + for raw in raw_ops: + if not isinstance(raw, dict) or raw.get("op") not in allowed: + raise _bad("bad_ops") + return target_type, target, raw_ops + + +def queue_change(token: str, target_type, target, raw_ops, reason: str | None) -> dict: + action = "luckperms.change" + actor = None + clean = None + detail_target = {"target_type": target_type if isinstance(target_type, str) else None, + "target": target[:64] if isinstance(target, str) else None} + try: + with connect() as conn: + conn.execute("BEGIN IMMEDIATE") + try: + actor = session_user_in(conn, token) + if actor is None: + raise AdminError(401, "not_signed_in", "denied") + if not roles.can(actor["role"], "change_luckperms"): + raise _forbidden() + clean = clean_reason(reason) + target_type, target, raw_ops = _shape(target_type, target, raw_ops) + detail_target = {"target_type": target_type, "target": target} + state = mirror.status(conn) + if read_only() or not state["applying"]: + raise AdminError(503, "bridge_offline", "unavailable") + if conn.execute( + "SELECT 1 FROM lp_changes WHERE target_type = ? AND target = ? AND status IN ('pending', 'sent')", + (target_type, target)).fetchone(): + raise _conflict("change_pending") + now = _now() + target_name = target + if target_type == "user": + row = mirror.user_row(conn, target) + if row is None: + raise AdminError(404, "player_not_found", "not_found") + target_name = row["name"] or target + check_user_target(conn, actor, target) + ops, words = build_user_ops(conn, target, raw_ops, now) + elif target_type == "group": + ops, words = build_group_ops(conn, target, raw_ops, now) + else: + ops, words = build_track_ops(conn, target, raw_ops) + check_ops_allowed(conn, actor["role"], target_type, ops) + if not ops: + raise _conflict("nothing_to_change") + description = "; ".join(words)[:DESCRIPTION_MAX] + change_id = conn.execute( + """ + INSERT INTO lp_changes ( + created_at, actor_user_id, actor_name, actor_role, actor_minecraft_uuid, + target_type, target, target_name, description, ops_json, reason + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + (_iso(), actor["user_id"], audit.display_name(actor), actor["role"], + _linked_uuid(conn, actor["discord_user_id"]), target_type, target, target_name, + description, json.dumps(ops, sort_keys=True), clean), + ).lastrowid + audit.record(conn, actor=actor, action=action, outcome="ok", reason=clean, + detail={**detail_target, "target_name": target_name, "change_id": change_id, + "description": description}) + conn.commit() + except BaseException: + conn.rollback() + raise + except AdminError as exc: + if actor is not None: + audit.record_refusal(actor=actor, action=action, outcome=exc.outcome, reason=clean, + detail={**detail_target, "error": exc.code}) + raise + return get_change(change_id) + + +# -------------------- +# Reading changes +# -------------------- + + +def _change_json(row) -> dict: + return { + "id": row["id"], + "created_at": row["created_at"], + "actor_name": row["actor_name"], + "actor_role": row["actor_role"], + "target_type": row["target_type"], + "target": row["target"], + "target_name": row["target_name"], + "description": row["description"], + "reason": row["reason"], + "status": row["status"], + "finished_at": row["finished_at"], + "error": row["error"], + } + + +def get_change(change_id: int) -> dict: + with connect() as conn: + row = conn.execute("SELECT * FROM lp_changes WHERE id = ?", (change_id,)).fetchone() + if row is None: + raise AdminError(404, "change_not_found", "not_found") + return _change_json(row) + + +def list_changes(target_type: str | None = None, target: str | None = None, limit: int = HISTORY_LIMIT) -> list[dict]: + where, args = [], [] + if target_type and target: + where.append("target_type = ? AND target = ?") + args += [target_type, target] + clause = f"WHERE {' AND '.join(where)}" if where else "" + with connect() as conn: + rows = conn.execute(f"SELECT * FROM lp_changes {clause} ORDER BY id DESC LIMIT ?", + [*args, max(1, min(limit, HISTORY_LIMIT))]).fetchall() + return [_change_json(row) for row in rows] + + +# -------------------- +# Bridge side +# -------------------- + + +def _actor_name(row) -> str: + return f"web:{row['actor_name'] or row['actor_user_id']}"[:100] + + +def _still_allowed(conn, row) -> bool: + """The actor's rights again at collection time: a demotion since queueing cancels the change.""" + actor = conn.execute("SELECT id AS user_id, discord_user_id, role FROM users WHERE id = ?", + (row["actor_user_id"],)).fetchone() + if actor is None or not roles.can(actor["role"], "change_luckperms"): + return False + actor = dict(actor) + try: + if row["target_type"] == "user": + check_user_target(conn, actor, row["target"]) + check_ops_allowed(conn, actor["role"], row["target_type"], json.loads(row["ops_json"])) + except AdminError: + return False + return True + + +def fetch_for_bridge() -> list[dict]: + """Changes for the bridge, oldest first. Each is handed out once; records the poll.""" + now = time.time() + with connect() as conn: + conn.execute("BEGIN IMMEDIATE") + try: + mirror.record_poll(conn) + out = [] + rows = conn.execute( + "SELECT * FROM lp_changes WHERE status IN ('pending', 'sent') ORDER BY id").fetchall() + for row in rows: + if row["status"] == "sent": + if now - _parse(row["sent_at"]) > RESULT_WAIT_SECONDS: + conn.execute("UPDATE lp_changes SET status = 'unknown', finished_at = ?, error = 'no_result' " + "WHERE id = ?", (_iso(now), row["id"])) + continue + if now - _parse(row["created_at"]) > PENDING_TTL_SECONDS: + conn.execute("UPDATE lp_changes SET status = 'expired', finished_at = ?, error = 'expired' " + "WHERE id = ?", (_iso(now), row["id"])) + continue + if read_only(): + continue + if not _still_allowed(conn, row): + conn.execute("UPDATE lp_changes SET status = 'failed', finished_at = ?, error = 'actor_changed' " + "WHERE id = ?", (_iso(now), row["id"])) + continue + if len(out) >= FETCH_LIMIT: + continue + conn.execute("UPDATE lp_changes SET status = 'sent', sent_at = ?, attempts = attempts + 1 " + "WHERE id = ?", (_iso(now), row["id"])) + out.append({ + "id": row["id"], + "target_type": row["target_type"], + "target": row["target"], + "target_name": row["target_name"], + "actor_name": _actor_name(row), + "actor_uuid": row["actor_minecraft_uuid"], + "description": row["description"], + "ops": json.loads(row["ops_json"]), + }) + conn.commit() + except BaseException: + conn.rollback() + raise + return out + + +def record_results(results) -> dict: + if not isinstance(results, list): + raise _bad("bad_results") + recorded = 0 + with connect() as conn: + conn.execute("BEGIN IMMEDIATE") + try: + for result in results: + if not isinstance(result, dict) or not isinstance(result.get("id"), int): + continue + row = conn.execute("SELECT * FROM lp_changes WHERE id = ?", (result["id"],)).fetchone() + # A late result still settles a change whose outcome was unknown. + if row is None or row["status"] not in ("sent", "unknown"): + continue + ok = result.get("ok") is True + error = None if ok else str(result.get("error") or "failed")[:64] + conn.execute("UPDATE lp_changes SET status = ?, finished_at = ?, error = ? WHERE id = ?", + ("applied" if ok else "failed", _iso(), error, row["id"])) + if ok and "state" in result: + try: + mirror.apply_state(conn, row["target_type"], row["target"], result["state"], + result.get("revision")) + except mirror.SnapshotError: + conn.execute("UPDATE lp_state SET hash = NULL WHERE id = 1") + recorded += 1 + conn.commit() + except BaseException: + conn.rollback() + raise + return {"ok": True, "recorded": recorded} diff --git a/backend/src/luckperms/conftest.py b/backend/src/luckperms/conftest.py new file mode 100644 index 00000000..ec0ffa99 --- /dev/null +++ b/backend/src/luckperms/conftest.py @@ -0,0 +1,18 @@ +import sys +from pathlib import Path +import pytest + +BACKEND = Path(__file__).resolve().parents[2] +if str(BACKEND) not in sys.path: + sys.path.insert(0, str(BACKEND)) + + +@pytest.fixture +def database(tmp_path, monkeypatch): + from src.skins import db + monkeypatch.setattr(db, "DB_PATH", tmp_path / "province.db") + monkeypatch.setattr(db, "DATA_DIR", tmp_path) + for name in ("SKINS_DIR", "WARDROBE_DIR", "DRINKS_DIR"): + monkeypatch.setattr(db, name, tmp_path / name.lower()) + db.migrate() + return db diff --git a/backend/src/luckperms/mirror.py b/backend/src/luckperms/mirror.py new file mode 100644 index 00000000..3f3dbc74 --- /dev/null +++ b/backend/src/luckperms/mirror.py @@ -0,0 +1,366 @@ +"""The site's copy of LuckPerms, as the TFMCWeb bridge last published it. + +The bridge's snapshot replaces every table in one transaction. A change +result carries its target's state, which replaces just that target, so the +panel shows an applied change before the next snapshot arrives. +""" +from __future__ import annotations + +import json +import time +from datetime import datetime, timezone + +from src.auth.players import canonical_uuid +from src.skins.db import connect + +from . import nodes + +USERS_MAX = 50_000 +NODES_MAX = 2_000_000 +NAME_MAX = 36 +# The bridge polls every few seconds; a minute of silence means it is gone. +CONNECTED_SECONDS = 60 +PAGE_SIZE = 50 +QUERY_MAX = 64 + + +class SnapshotError(Exception): + def __init__(self, code: str): + super().__init__(code) + self.code = code + + +def _iso(ts: float | None = None) -> str: + return datetime.fromtimestamp(ts if ts is not None else time.time(), timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def _age(stamp: str | None, now: float) -> float | None: + if not stamp: + return None + then = datetime.strptime(stamp, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc).timestamp() + return max(0.0, now - then) + + +def _node_rows(raw_nodes) -> list[dict]: + if not isinstance(raw_nodes, list): + raise SnapshotError("bad_snapshot") + try: + return [nodes.from_snapshot(node) for node in raw_nodes] + except nodes.NodeError: + raise SnapshotError("bad_snapshot") from None + + +def _group(raw) -> tuple[dict, list[dict]]: + if not isinstance(raw, dict) or not isinstance(raw.get("name"), str) or not raw["name"]: + raise SnapshotError("bad_snapshot") + weight = raw.get("weight") + display = raw.get("display_name") + return ( + { + "name": raw["name"].lower()[:NAME_MAX * 2], + "display_name": display if isinstance(display, str) else None, + "weight": weight if isinstance(weight, int) and not isinstance(weight, bool) else None, + }, + _node_rows(raw.get("nodes", [])), + ) + + +def _user(raw) -> tuple[dict, list[dict]]: + if not isinstance(raw, dict) or not isinstance(raw.get("uuid"), str): + raise SnapshotError("bad_snapshot") + uuid = canonical_uuid(raw["uuid"]) + if uuid is None: + raise SnapshotError("bad_snapshot") + name = raw.get("name") + return {"uuid": uuid, "name": name[:NAME_MAX] if isinstance(name, str) and name else None}, _node_rows( + raw.get("nodes", [])) + + +def _track(raw) -> dict: + if (not isinstance(raw, dict) or not isinstance(raw.get("name"), str) or not raw["name"] + or not isinstance(raw.get("groups"), list) + or not all(isinstance(g, str) for g in raw["groups"])): + raise SnapshotError("bad_snapshot") + return {"name": raw["name"].lower(), "groups": [g.lower() for g in raw["groups"]]} + + +def _insert_group(conn, group: dict, group_nodes: list[dict]) -> None: + conn.execute("INSERT OR REPLACE INTO lp_groups (name, display_name, weight) VALUES (?, ?, ?)", + (group["name"], group["display_name"], group["weight"])) + conn.executemany( + "INSERT INTO lp_group_nodes (group_name, key, value, contexts, expiry) VALUES (?, ?, ?, ?, ?)", + [(group["name"], n["key"], int(n["value"]), nodes.contexts_json(n["contexts"]), n["expiry"]) + for n in group_nodes], + ) + + +def _insert_user(conn, user: dict, user_nodes: list[dict]) -> None: + conn.execute("INSERT OR REPLACE INTO lp_users (uuid, name) VALUES (?, ?)", (user["uuid"], user["name"])) + conn.executemany( + "INSERT INTO lp_user_nodes (uuid, key, value, contexts, expiry) VALUES (?, ?, ?, ?, ?)", + [(user["uuid"], n["key"], int(n["value"]), nodes.contexts_json(n["contexts"]), n["expiry"]) + for n in user_nodes], + ) + + +def replace_snapshot(payload) -> dict: + if not isinstance(payload, dict): + raise SnapshotError("bad_snapshot") + for field in ("groups", "tracks", "users"): + if not isinstance(payload.get(field), list): + raise SnapshotError("bad_snapshot") + if len(payload["users"]) > USERS_MAX: + raise SnapshotError("snapshot_too_large") + snapshot_hash = payload.get("hash") + server = payload.get("server") + generated_at = payload.get("generated_at") + revision = _revision(payload.get("revision")) + groups = [_group(raw) for raw in payload["groups"]] + tracks = [_track(raw) for raw in payload["tracks"]] + users = [_user(raw) for raw in payload["users"]] + if sum(len(n) for _, n in users) + sum(len(n) for _, n in groups) > NODES_MAX: + raise SnapshotError("snapshot_too_large") + stamp = _iso() + with connect() as conn: + conn.execute("BEGIN IMMEDIATE") + try: + if not _newer(conn, revision): + conn.rollback() + return {"ok": True, "stale": True} + for table in ("lp_group_nodes", "lp_groups", "lp_tracks", "lp_user_nodes", "lp_users"): + conn.execute(f"DELETE FROM {table}") + for group, group_nodes in groups: + _insert_group(conn, group, group_nodes) + conn.executemany("INSERT OR REPLACE INTO lp_tracks (name, groups_json) VALUES (?, ?)", + [(t["name"], json.dumps(t["groups"])) for t in tracks]) + for user, user_nodes in users: + _insert_user(conn, user, user_nodes) + conn.execute( + """ + INSERT INTO lp_state (id, server, hash, generated_at, revision, received_at, checked_at) + VALUES (1, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET server = excluded.server, hash = excluded.hash, + generated_at = excluded.generated_at, revision = excluded.revision, + received_at = excluded.received_at, checked_at = excluded.checked_at + """, + (str(server)[:32] if server else None, str(snapshot_hash)[:128] if snapshot_hash else None, + generated_at if isinstance(generated_at, int) else None, revision, stamp, stamp), + ) + conn.commit() + except BaseException: + conn.rollback() + raise + return {"ok": True, "groups": len(groups), "tracks": len(tracks), "users": len(users)} + + +def _revision(raw) -> int: + if not isinstance(raw, int) or isinstance(raw, bool) or raw <= 0: + raise SnapshotError("bad_revision") + return raw + + +def _newer(conn, revision: int) -> bool: + row = conn.execute("SELECT revision FROM lp_state WHERE id = 1").fetchone() + return row is None or row["revision"] is None or revision > row["revision"] + + +def mark_unchanged(snapshot_hash, revision) -> bool: + if not isinstance(snapshot_hash, str) or not snapshot_hash: + return False + revision = _revision(revision) + with connect() as conn: + updated = conn.execute( + "UPDATE lp_state SET checked_at = ?, revision = ? WHERE id = 1 AND hash = ? AND revision < ?", + (_iso(), revision, snapshot_hash, revision)).rowcount + conn.commit() + return updated == 1 + + +def apply_state(conn, target_type: str, target: str, state, revision) -> None: + """Replace one target with the state a change result reported, on the caller's transaction. + + Ignored unless newer than everything stored. The stored hash no longer + describes the mirror, so the next unchanged check asks for a full snapshot. + """ + if target_type not in ("user", "group"): + return + revision = _revision(revision) + if not _newer(conn, revision): + return + if target_type == "user": + conn.execute("DELETE FROM lp_user_nodes WHERE uuid = ?", (target,)) + conn.execute("DELETE FROM lp_users WHERE uuid = ?", (target,)) + if isinstance(state, dict): + user, user_nodes = _user(state) + if user["uuid"] == target: + _insert_user(conn, user, user_nodes) + elif target_type == "group": + conn.execute("DELETE FROM lp_group_nodes WHERE group_name = ?", (target,)) + conn.execute("DELETE FROM lp_groups WHERE name = ?", (target,)) + if isinstance(state, dict): + group, group_nodes = _group(state) + if group["name"] == target: + _insert_group(conn, group, group_nodes) + conn.execute("UPDATE lp_state SET hash = NULL, revision = ? WHERE id = 1", (revision,)) + + +def record_poll(conn) -> None: + conn.execute( + "INSERT INTO lp_state (id, polled_at) VALUES (1, ?) ON CONFLICT(id) DO UPDATE SET polled_at = excluded.polled_at", + (_iso(),), + ) + + +def status(conn=None) -> dict: + def read(c): + return c.execute("SELECT server, received_at, checked_at, polled_at FROM lp_state WHERE id = 1").fetchone() + + if conn is None: + with connect() as own: + row = read(own) + else: + row = read(conn) + now = time.time() + polled = _age(row["polled_at"], now) if row else None + return { + "server": row["server"] if row else None, + "snapshot_at": row["received_at"] if row else None, + "checked_at": row["checked_at"] if row else None, + "has_snapshot": bool(row and row["received_at"]), + "applying": polled is not None and polled <= CONNECTED_SECONDS, + "polled_at": row["polled_at"] if row else None, + } + + +# -------------------- +# Reads +# -------------------- + + +def _node(row) -> dict: + return {"key": row["key"], "value": bool(row["value"]), "contexts": json.loads(row["contexts"]), + "expiry": row["expiry"]} + + +def _sort_key(node: dict): + return (node["key"].lower(), json.dumps(node["contexts"], sort_keys=True), node["expiry"], not node["value"]) + + +def load_groups(conn) -> dict[str, dict]: + groups = {row["name"]: {"name": row["name"], "display_name": row["display_name"], "weight": row["weight"], + "nodes": []} + for row in conn.execute("SELECT name, display_name, weight FROM lp_groups")} + for row in conn.execute("SELECT group_name, key, value, contexts, expiry FROM lp_group_nodes"): + if row["group_name"] in groups: + groups[row["group_name"]]["nodes"].append(_node(row)) + for group in groups.values(): + group["nodes"].sort(key=_sort_key) + return groups + + +def load_tracks(conn) -> list[dict]: + return [{"name": row["name"], "groups": json.loads(row["groups_json"])} + for row in conn.execute("SELECT name, groups_json FROM lp_tracks ORDER BY name")] + + +def user_nodes(conn, uuid: str) -> list[dict]: + rows = conn.execute("SELECT key, value, contexts, expiry FROM lp_user_nodes WHERE uuid = ?", (uuid,)) + return sorted((_node(row) for row in rows), key=_sort_key) + + +def user_row(conn, uuid: str) -> dict | None: + row = conn.execute("SELECT uuid, name FROM lp_users WHERE uuid = ?", (uuid,)).fetchone() + return dict(row) if row else None + + +def parents(group: dict, now: int, *, global_only: bool = True) -> list[str]: + """Groups this group inherits directly.""" + out = [] + for node in group["nodes"]: + name = nodes.group_of(node["key"]) + if name and node["value"] and nodes.active(node, now) and (not global_only or not node["contexts"]): + out.append(name) + return out + + +def meta_value(group: dict, prefix: str, now: int) -> tuple[int, str] | None: + """The highest-priority prefix or suffix a group sets itself (global, active).""" + best = None + for node in group["nodes"]: + key = node["key"] + if not key.lower().startswith(prefix + ".") or not node["value"] or node["contexts"]: + continue + if not nodes.active(node, now): + continue + parts = key.split(".", 2) + if len(parts) != 3: + continue + try: + priority = int(parts[1]) + except ValueError: + continue + if best is None or priority > best[0]: + best = (priority, parts[2]) + return best + + +def inherited(groups: dict[str, dict], direct: list[str], now: int) -> list[str]: + """Every group reached from these, in breadth-first order, each once.""" + seen: list[str] = [] + queue = list(direct) + while queue: + name = queue.pop(0) + if name in seen or name not in groups: + continue + seen.append(name) + queue.extend(parents(groups[name], now)) + return seen + + +def rank_group(groups: dict[str, dict], user_node_list: list[dict], now: int) -> str | None: + """The heaviest group a player inherits globally: what LuckPerms shows as their rank.""" + direct = [nodes.group_of(n["key"]) for n in user_node_list + if nodes.group_of(n["key"]) and n["value"] and not n["contexts"] and nodes.active(n, now)] + chain = inherited(groups, [d for d in direct if d], now) or (["default"] if "default" in groups else []) + if not chain: + return None + return max(chain, key=lambda name: (groups[name]["weight"] or 0, name)) + + +def search_users(conn, q: str, group: str | None, page: int) -> dict: + """Players by name or UUID prefix, optionally only those holding a group directly.""" + text = (q or "").strip().lower() + if len(text) > QUERY_MAX: + raise SnapshotError("query_too_long") + where, args = [], [] + if text: + where.append("(lower(u.name) LIKE ? ESCAPE '\\' OR u.uuid LIKE ? ESCAPE '\\')") + escaped = text.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + args += [f"%{escaped}%", f"{escaped}%"] + if group: + where.append("u.uuid IN (SELECT uuid FROM lp_user_nodes WHERE key = ?)") + args.append(f"group.{group.lower()}") + clause = f"WHERE {' AND '.join(where)}" if where else "" + total = conn.execute(f"SELECT COUNT(*) FROM lp_users u {clause}", args).fetchone()[0] + rows = conn.execute( + f""" + SELECT u.uuid, u.name FROM lp_users u {clause} + ORDER BY CASE WHEN lower(u.name) = ? THEN 0 WHEN lower(u.name) LIKE ? ESCAPE '\\' THEN 1 ELSE 2 END, + u.name IS NULL, lower(u.name), u.uuid + LIMIT ? OFFSET ? + """, + [*args, text, (text.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%"), + PAGE_SIZE, (page - 1) * PAGE_SIZE], + ).fetchall() + return {"total": total, "page": page, "page_size": PAGE_SIZE, "rows": [dict(r) for r in rows]} + + +def group_counts(conn) -> dict[str, int]: + """Players holding each group directly, in any context.""" + out: dict[str, int] = {} + for row in conn.execute( + "SELECT key, COUNT(DISTINCT uuid) AS n FROM lp_user_nodes WHERE key LIKE 'group.%' GROUP BY key"): + name = nodes.group_of(row["key"]) + if name: + out[name] = out.get(name, 0) + row["n"] + return out diff --git a/backend/src/luckperms/nodes.py b/backend/src/luckperms/nodes.py new file mode 100644 index 00000000..96474848 --- /dev/null +++ b/backend/src/luckperms/nodes.py @@ -0,0 +1,152 @@ +"""LuckPerms nodes as the site stores and checks them. + +A node is {"key", "value", "contexts", "expiry"}: contexts map each context +key to a sorted list of values ({} when global) and expiry is unix seconds, +0 when permanent. Two nodes are the same node only when all four match, as in +LuckPerms' exact equality. +""" +from __future__ import annotations + +import json +import re +import time + +KEY_MAX = 200 +CONTEXT_KEYS_MAX = 8 +CONTEXT_VALUE_MAX = 64 +# Ten years: anything later is a mistake rather than a temporary grant. +EXPIRY_MAX_SECONDS = 10 * 365 * 24 * 3600 +GROUP_NAME = re.compile(r"^[a-z0-9_+\-]{1,36}$") +_CONTEXT_KEY = re.compile(r"^[a-z0-9_.:\-]{1,64}$") +META_PREFIXES = ("prefix.", "suffix.", "meta.", "weight.", "displayname.") + + +class NodeError(Exception): + def __init__(self, code: str): + super().__init__(code) + self.code = code + + +def _printable(text: str) -> bool: + return all(ord(ch) >= 0x20 and ord(ch) != 0x7F for ch in text) + + +def kind(key: str) -> str: + """group, meta or permission.""" + lowered = key.lower() + if lowered.startswith("group."): + return "group" + if lowered.startswith(META_PREFIXES): + return "meta" + return "permission" + + +def group_of(key: str) -> str | None: + return key[len("group."):].lower() if kind(key) == "group" else None + + +def clean_contexts(raw) -> dict[str, list[str]]: + if raw is None: + return {} + if not isinstance(raw, dict) or len(raw) > CONTEXT_KEYS_MAX: + raise NodeError("bad_contexts") + out: dict[str, list[str]] = {} + for key, values in raw.items(): + if not isinstance(key, str) or not _CONTEXT_KEY.match(key.lower()): + raise NodeError("bad_contexts") + if isinstance(values, str): + values = [values] + if not isinstance(values, list) or not values or len(values) > CONTEXT_KEYS_MAX: + raise NodeError("bad_contexts") + clean = set() + for value in values: + if (not isinstance(value, str) or not value.strip() or len(value) > CONTEXT_VALUE_MAX + or not _printable(value)): + raise NodeError("bad_contexts") + clean.add(value.strip().lower()) + out[key.lower()] = sorted(clean) + return dict(sorted(out.items())) + + +def contexts_json(contexts: dict[str, list[str]]) -> str: + return json.dumps(contexts, sort_keys=True, separators=(",", ":")) + + +def from_snapshot(raw) -> dict: + """A node as the bridge reported it. Trusted, but still shaped.""" + if not isinstance(raw, dict) or not isinstance(raw.get("key"), str) or not raw["key"]: + raise NodeError("bad_node") + expiry = raw.get("expiry") or 0 + if not isinstance(expiry, int) or expiry < 0: + raise NodeError("bad_node") + return { + "key": raw["key"][:KEY_MAX * 2], + "value": bool(raw.get("value", True)), + "contexts": clean_contexts(raw.get("contexts")), + "expiry": expiry, + } + + +def from_request(raw, *, now: int | None = None, new: bool) -> dict: + """A node staff sent. New nodes are normalised the way LuckPerms would + store them; nodes being removed must name an existing node exactly.""" + if not isinstance(raw, dict): + raise NodeError("bad_node") + key = raw.get("key") + if not isinstance(key, str): + raise NodeError("bad_node") + key = key.strip() + if not key or len(key) > KEY_MAX or not _printable(key): + raise NodeError("bad_node") + value = raw.get("value", True) + if not isinstance(value, bool): + raise NodeError("bad_node") + expiry = raw.get("expiry") or 0 + if not isinstance(expiry, int) or isinstance(expiry, bool) or expiry < 0: + raise NodeError("bad_expiry") + if new: + node_kind = kind(key) + if node_kind == "group": + group = group_of(key) + if not GROUP_NAME.match(group or ""): + raise NodeError("bad_group_name") + key = f"group.{group}" + elif node_kind == "permission": + if " " in key: + raise NodeError("bad_node") + key = key.lower() + if expiry: + current = int(time.time()) if now is None else now + if expiry <= current or expiry > current + EXPIRY_MAX_SECONDS: + raise NodeError("bad_expiry") + return {"key": key, "value": value, "contexts": clean_contexts(raw.get("contexts")), "expiry": expiry} + + +def same(a: dict, b: dict) -> bool: + return (a["key"] == b["key"] and a["value"] == b["value"] and a["contexts"] == b["contexts"] + and a["expiry"] == b["expiry"]) + + +def active(node: dict, now: int) -> bool: + return not node["expiry"] or node["expiry"] > now + + +def describe(node: dict) -> str: + """LuckPerms command wording for the action log: "parent add staff server=main".""" + tail = "".join(f" {key}={value}" for key, values in node["contexts"].items() for value in values) + temp = node["expiry"] > 0 + group = group_of(node["key"]) + if group is not None and node["value"]: + verb = "parent addtemp" if temp else "parent add" + return f"{verb} {group}{tail}" + verb = "permission settemp" if temp else "permission set" + return f"{verb} {node['key']} {str(node['value']).lower()}{tail}" + + +def describe_removal(node: dict) -> str: + tail = "".join(f" {key}={value}" for key, values in node["contexts"].items() for value in values) + temp = node["expiry"] > 0 + group = group_of(node["key"]) + if group is not None and node["value"]: + return f"{'parent removetemp' if temp else 'parent remove'} {group}{tail}" + return f"{'permission unsettemp' if temp else 'permission unset'} {node['key']}{tail}" diff --git a/backend/src/luckperms/policy.py b/backend/src/luckperms/policy.py new file mode 100644 index 00000000..2022433a --- /dev/null +++ b/backend/src/luckperms/policy.py @@ -0,0 +1,75 @@ +"""Who may change which LuckPerms nodes. The rules are in policy.yaml.""" +from __future__ import annotations + +import fnmatch +from dataclasses import dataclass +from functools import lru_cache +from pathlib import Path + +import yaml + +from src.auth import roles + +from . import nodes + +POLICY_PATH = Path(__file__).with_name("policy.yaml") + + +@dataclass(frozen=True) +class Policy: + admin_groups: frozenset[str] + patreon_groups: frozenset[str] + admin_permissions: tuple[str, ...] + + +@lru_cache(maxsize=1) +def load() -> Policy: + raw = yaml.safe_load(POLICY_PATH.read_text(encoding="utf-8")) or {} + return Policy( + admin_groups=frozenset(str(g).lower() for g in raw.get("admin_groups") or ()), + patreon_groups=frozenset(str(g).lower() for g in raw.get("patreon_groups") or ()), + admin_permissions=tuple(str(p).lower() for p in raw.get("admin_permissions") or ()), + ) + + +def _closure(groups: dict[str, dict], name: str) -> set[str]: + """The group and everything it inherits, in any context, expired or not.""" + seen: set[str] = set() + queue = [name] + while queue: + current = queue.pop() + if current in seen: + continue + seen.add(current) + for node in groups.get(current, {}).get("nodes", ()): + parent = nodes.group_of(node["key"]) + if parent and node["value"]: + queue.append(parent) + return seen + + +def group_role(policy: Policy, groups: dict[str, dict], group: str) -> str: + """admin when the group and everything it inherits are admin groups; else root.""" + return "admin" if _closure(groups, group.lower()) <= policy.admin_groups else "root" + + +def node_role(policy: Policy, groups: dict[str, dict], node: dict) -> str: + """The lowest site role that may add or remove this node on a player, whatever its value.""" + key = node["key"].lower() + node_kind = nodes.kind(key) + if node_kind == "group": + return group_role(policy, groups, nodes.group_of(key) or "") + if node_kind == "meta" or "*" in key or key.startswith("r="): + return "root" + if any(fnmatch.fnmatchcase(key, pattern) for pattern in policy.admin_permissions): + return "admin" + return "root" + + +def may_edit_node(policy: Policy, groups: dict[str, dict], role: str | None, node: dict) -> bool: + return roles.rank(role) >= roles.rank(node_role(policy, groups, node)) + + +def may_edit_definitions(role: str | None) -> bool: + """Group definitions and tracks change what everyone in them can do.""" + return role == "root" diff --git a/backend/src/luckperms/policy.yaml b/backend/src/luckperms/policy.yaml new file mode 100644 index 00000000..5d260b60 --- /dev/null +++ b/backend/src/luckperms/policy.yaml @@ -0,0 +1,41 @@ +# Who may change what in LuckPerms from the staff panel. Mods only view. +# Group definitions and tracks are root-only, and so is every group not listed here. + +# Groups admins may add to or remove from players, and move players between on tracks. +# A listed group only counts while every group it inherits is listed too. +admin_groups: + - default + - commoner + - legacy + - noble + - gilded + - ascended + - helper_player + - helper_inactive + - helper + +# Granted by the Patreon sync (TFMCWeb on Main). Its reconcile resets permanent, +# global grants of these groups for linked patrons, so the panel warns about that. +patreon_groups: + - noble + - gilded + - ascended + +# Player permissions admins may set or unset. Shell-style patterns, any case. +# Every other permission, and any key containing "*" or written as a regex +# ("r=..."), is root-only. These are the progress and preference nodes in use. +admin_permissions: + - "professions.*" + - "rulequiz.completed" + - "tfmc.has_spawned" + - "tfmcresourcepack.enable" + - "tips.off" + - "pvp.non_lethal" + - "chatbubble.see" + - "armourshop.*" + - "mural.*" + - "mcpet.*" + - "deepsea.*" + - "bloodmagic.complete" + - "necromancy.complete" + - "ia.user.craft.*" diff --git a/backend/src/luckperms/test_luckperms.py b/backend/src/luckperms/test_luckperms.py new file mode 100644 index 00000000..0d85fc7f --- /dev/null +++ b/backend/src/luckperms/test_luckperms.py @@ -0,0 +1,407 @@ +import json +import time + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from src.api import admin_routes, auth_routes +from src.auth import users +from src.luckperms import changes, mirror, nodes, policy + +SITE = "https://www.tfminecraft.net" +ORIGIN = {"Origin": SITE} +SESSION = "__Host-tfmc_session" +PLUGIN = {"X-Plugin-Key": "primary-key"} +SECONDARY = {"X-Plugin-Key": "tutorial-key"} + +ALICE = "11111111-1111-1111-1111-111111111111" +BOB = "22222222-2222-2222-2222-222222222222" +ADMIN_MC = "33333333-3333-3333-3333-333333333333" +ROOT_MC = "44444444-4444-4444-4444-444444444444" + + +def node(key, value=True, contexts=None, expiry=0): + return {"key": key, "value": value, "contexts": contexts or {}, "expiry": expiry} + + +def group(name, weight, *keys, prefix=None): + group_nodes = [node(k) for k in keys] + if weight is not None: + group_nodes.append(node(f"weight.{weight}")) + if prefix: + group_nodes.append(node(f"prefix.{weight}.{prefix}")) + return {"name": name, "display_name": None, "weight": weight, "nodes": group_nodes} + + +def snapshot(**overrides): + payload = { + "server": "main", + "generated_at": 1791321779, + "revision": 1000, + "hash": "h1", + "groups": [ + group("default", None), + group("commoner", 10, "group.default", prefix="&7Commoner"), + group("noble", 20, "group.commoner", prefix="&6Noble"), + group("helper_player", 120, "group.commoner"), + group("helper_inactive", 130, "group.helper_player"), + group("helper", 140, "group.helper_inactive", "essentials.kick", prefix="&dHelper"), + group("staff_player", 180, "group.commoner"), + group("staff_inactive", 190, "group.staff_player"), + group("staff", 200, "group.staff_inactive", "*", prefix="&cStaff"), + ], + "tracks": [ + {"name": "helper", "groups": ["helper_player", "helper_inactive", "helper"]}, + {"name": "staff", "groups": ["staff_player", "staff_inactive", "staff"]}, + ], + "users": [ + {"uuid": ALICE, "name": "Alice", "nodes": [node("group.commoner"), node("professions.chef_1"), + node("tips.off", contexts={"server": ["main"]})]}, + {"uuid": BOB, "name": "Bob", "nodes": [node("group.helper_player")]}, + {"uuid": ADMIN_MC, "name": "AdminMC", "nodes": [node("group.staff")]}, + {"uuid": ROOT_MC, "name": "RootMC", "nodes": [node("group.staff")]}, + ], + } + payload.update(overrides) + return payload + + +@pytest.fixture +def env(database, monkeypatch): + monkeypatch.setenv("DISCORD_AUTH_ENABLED", "1") + monkeypatch.setenv("DISCORD_CLIENT_ID", "client-id") + monkeypatch.setenv("DISCORD_CLIENT_SECRET", "client-secret") + monkeypatch.setenv("DISCORD_GUILD_ID", "999999999999999999") + monkeypatch.setenv("SITE_PUBLIC_URL", SITE) + monkeypatch.setenv("PLUGIN_KEY", "primary-key") + monkeypatch.setenv("PLUGIN_KEYS_SECONDARY", "tutorial-key") + monkeypatch.delenv("DISCORD_REDIRECT_URI", raising=False) + monkeypatch.delenv("PS_PRODUCTION", raising=False) + monkeypatch.delenv("LUCKPERMS_READ_ONLY", raising=False) + return database + + +@pytest.fixture +def app(env): + app = FastAPI() + app.include_router(auth_routes.auth_router) + admin_routes.install(app) + return app + + +def account(db, name, role="player", minecraft=None): + discord_id = str(100000000000000000 + abs(hash(name)) % 10**15) + token = users.sign_in( + {"discord_user_id": discord_id, "discord_username": name, "discord_global_name": name.title(), + "discord_avatar": None}, + guild_member=True, + ) + with db.connect() as conn: + conn.execute("UPDATE users SET role = ? WHERE discord_user_id = ?", (role, discord_id)) + if minecraft: + conn.execute( + "INSERT INTO discord_links (player_uuid, discord_user_id, discord_username, minecraft_name, linked_at) " + "VALUES (?, ?, ?, ?, ?)", (minecraft, discord_id, name, name, "2026-10-01T00:00:00Z")) + conn.commit() + return token + + +def client(app, token=None): + c = TestClient(app, base_url="https://testserver") + if token: + c.cookies.set(SESSION, token, domain="testserver.local") + return c + + +@pytest.fixture +def bridge(app): + plugin = client(app) + assert plugin.put("/luckperms/plugin/snapshot", headers=PLUGIN, json=snapshot()).json()["users"] == 4 + assert plugin.get("/luckperms/plugin/changes", headers=PLUGIN).json() == {"changes": []} + return plugin + + +@pytest.fixture +def staff(env): + return { + "mod": account(env, "mona", "mod"), + "admin": account(env, "adam", "admin", ADMIN_MC), + "root": account(env, "rory", "root", ROOT_MC), + } + + +def submit(app, token, target_type, target, ops, reason="Event reward"): + return client(app, token).post("/admin/luckperms/changes", headers=ORIGIN, + json={"target_type": target_type, "target": target, "ops": ops, "reason": reason}) + + +def audit_rows(db): + with db.connect() as conn: + return [dict(r) for r in conn.execute("SELECT * FROM admin_audit ORDER BY id")] + + +# -------------------- +# Nodes and policy +# -------------------- + + +def test_new_nodes_are_normalised_and_checked(): + assert nodes.from_request({"key": " Essentials.Fly ", "contexts": {"Server": "MAIN"}}, new=True) == node( + "essentials.fly", contexts={"server": ["main"]}) + assert nodes.from_request({"key": "group.Helper"}, new=True)["key"] == "group.helper" + for bad in ({"key": ""}, {"key": "a b"}, {"key": "x", "value": "yes"}, {"key": "group.no spaces"}, + {"key": "x", "contexts": {"server": []}}, {"key": "x\x07y"}): + with pytest.raises(nodes.NodeError): + nodes.from_request(bad, new=True) + now = int(time.time()) + with pytest.raises(nodes.NodeError, match="bad_expiry"): + nodes.from_request({"key": "x", "expiry": now - 5}, new=True, now=now) + assert nodes.from_request({"key": "x", "expiry": now + 60}, new=True, now=now)["expiry"] == now + 60 + assert nodes.describe(node("group.staff", contexts={"server": ["main"]})) == "parent add staff server=main" + assert nodes.describe(node("tips.off", value=False, expiry=5)) == "permission settemp tips.off false" + assert nodes.describe_removal(node("group.staff")) == "parent remove staff" + + +def test_policy_puts_staff_power_out_of_admin_reach(): + rules = policy.load() + groups = {g["name"]: g for g in snapshot()["groups"]} + assert policy.node_role(rules, groups, node("group.helper")) == "admin" + assert policy.node_role(rules, groups, node("group.helper+")) == "root" + assert policy.node_role(rules, groups, node("group.staff")) == "root" + assert policy.node_role(rules, groups, node("professions.chef_1")) == "admin" + assert policy.node_role(rules, groups, node("professions.chef_1", value=False)) == "admin" + for key in ("*", "professions.*", "essentials.kick", "tfmc.staff", "luckperms.user.parent.add", + "minecraft.command.op", "r=professions.*", "prefix.300.&cOwner", "weight.999", "meta.x.y"): + assert policy.node_role(rules, groups, node(key)) == "root", key + # A listed group that inherits a staff group, in any context, is no longer an admin group. + groups["commoner"]["nodes"].append(node("group.staff_player", contexts={"server": ["dev"]})) + assert policy.node_role(rules, groups, node("group.noble")) == "root" + + +# -------------------- +# Plugin routes +# -------------------- + + +def test_plugin_routes_need_the_primary_key(app): + plugin = client(app) + assert plugin.put("/luckperms/plugin/snapshot", json=snapshot()).status_code == 401 + assert plugin.put("/luckperms/plugin/snapshot", headers=SECONDARY, json=snapshot()).status_code == 403 + assert plugin.get("/luckperms/plugin/changes", headers=SECONDARY).status_code == 403 + + +def test_snapshot_replaces_mirror_and_unchanged_checks_hash(bridge, env): + assert bridge.put("/luckperms/plugin/snapshot", headers=PLUGIN, json={"groups": "x"}).status_code == 400 + assert bridge.put("/luckperms/plugin/snapshot", headers=PLUGIN, + json=snapshot(revision=None)).json()["detail"] == "bad_revision" + assert bridge.post("/luckperms/plugin/snapshot/unchanged", headers=PLUGIN, + json={"hash": "other", "revision": 1001}).json() == {"ok": False, "need_full": True} + assert bridge.post("/luckperms/plugin/snapshot/unchanged", headers=PLUGIN, + json={"hash": "h1", "revision": 1001}).json() == {"ok": True} + smaller = snapshot(hash="h2", revision=1002, users=[{"uuid": BOB.upper(), "name": "Bob", "nodes": []}]) + assert bridge.put("/luckperms/plugin/snapshot", headers=PLUGIN, json=smaller).json()["users"] == 1 + # A delayed older snapshot cannot undo a newer one. + late = bridge.put("/luckperms/plugin/snapshot", headers=PLUGIN, json=snapshot(hash="old", revision=1001)) + assert late.json() == {"ok": True, "stale": True} + with env.connect() as conn: + assert [r["uuid"] for r in conn.execute("SELECT uuid FROM lp_users")] == [BOB] + assert mirror.status(conn)["applying"] is True + + +# -------------------- +# Staff reads +# -------------------- + + +def test_mods_see_everything_but_change_nothing(app, bridge, staff, env): + mod = client(app, staff["mod"]) + overview = mod.get("/admin/luckperms").json() + assert [g["name"] for g in overview["groups"]][:3] == ["staff", "staff_inactive", "staff_player"] + staff_group = overview["groups"][0] + assert staff_group["prefix"] == "&cStaff" and staff_group["members"] == 2 and staff_group["min_role"] == "root" + assert overview["rights"] == {"read_only": False, "change_players": False, "edit_definitions": False} + player = mod.get(f"/admin/luckperms/players/{ALICE}").json()["player"] + assert player["rank"] == "commoner" and player["rank_prefix"] == "&7Commoner" + assert player["inherits"] == ["commoner", "default"] + assert not any(n["editable"] for n in player["nodes"]) + listing = mod.get("/admin/luckperms/players", params={"q": "ali"}).json() + assert [r["name"] for r in listing["rows"]] == ["Alice"] and listing["rows"][0]["rank"] == "commoner" + members = mod.get("/admin/luckperms/players", params={"group": "staff"}).json() + assert {r["name"] for r in members["rows"]} == {"AdminMC", "RootMC"} + detail = mod.get("/admin/luckperms/groups/helper").json()["group"] + assert detail["inherits"] == ["helper_inactive", "helper_player", "commoner", "default"] + assert detail["tracks"] == ["helper"] and detail["children"] == [] + assert submit(app, staff["mod"], "user", ALICE, [{"op": "add_node", "node": node("tips.off")}]).status_code == 403 + + +def test_views_mark_what_an_admin_may_change(app, bridge, staff): + admin = client(app, staff["admin"]) + bob = admin.get(f"/admin/luckperms/players/{BOB}").json() + helper = next(t for t in bob["player"]["tracks"] if t["name"] == "helper") + assert helper["position"] == 0 and helper["can_promote"] and helper["can_demote"] + assert not next(t for t in bob["player"]["tracks"] if t["name"] == "staff")["can_promote"] + addable = {g["name"] for g in bob["groups"] if g["addable"]} + assert "helper" in addable and "staff" not in addable + own = admin.get(f"/admin/luckperms/players/{ADMIN_MC}").json() + assert own["rights"]["change_this_player"] is False + assert admin.get(f"/admin/luckperms/players/{ROOT_MC}").json()["rights"]["change_this_player"] is False + + +# -------------------- +# Changes +# -------------------- + + +def test_admin_change_round_trip(app, bridge, staff, env): + response = submit(app, staff["admin"], "user", ALICE, [ + {"op": "add_node", "node": {"key": "group.Noble"}}, + {"op": "remove_node", "node": node("professions.chef_1")}, + ]) + assert response.status_code == 200, response.text + change = response.json() + assert change["status"] == "pending" + assert change["description"] == "parent add noble; permission unset professions.chef_1" + again = submit(app, staff["admin"], "user", ALICE, [{"op": "add_node", "node": node("tips.on")}]) + assert again.json()["detail"] == "change_pending" + + sent = bridge.get("/luckperms/plugin/changes", headers=PLUGIN).json()["changes"] + assert len(sent) == 1 and sent[0]["actor_name"] == "web:adam" and sent[0]["actor_uuid"] == ADMIN_MC + assert sent[0]["ops"] == [{"op": "add_node", "node": node("group.noble")}, + {"op": "remove_node", "node": node("professions.chef_1")}] + assert bridge.get("/luckperms/plugin/changes", headers=PLUGIN).json() == {"changes": []} + + state = {"uuid": ALICE, "name": "Alice", "nodes": [node("group.commoner"), node("group.noble")]} + result = bridge.post("/luckperms/plugin/changes/results", headers=PLUGIN, + json={"results": [{"id": change["id"], "ok": True, "revision": 2000, "state": state}]}) + assert result.json() == {"ok": True, "recorded": 1} + admin = client(app, staff["admin"]) + assert admin.get(f"/admin/luckperms/changes/{change['id']}").json()["status"] == "applied" + player = admin.get(f"/admin/luckperms/players/{ALICE}").json()["player"] + assert player["rank"] == "noble" and [n["key"] for n in player["nodes"]] == ["group.commoner", "group.noble"] + # The mirror changed under the stored hash, so the bridge is asked for a full snapshot. + assert bridge.post("/luckperms/plugin/snapshot/unchanged", headers=PLUGIN, + json={"hash": "h1", "revision": 2001}).json()["ok"] is False + # And a snapshot built before the change cannot hide it again. + assert bridge.put("/luckperms/plugin/snapshot", headers=PLUGIN, json=snapshot(revision=1500)).json()["stale"] + rows = [r for r in audit_rows(env) if r["action"] == "luckperms.change"] + assert [(r["outcome"], json.loads(r["detail_json"]).get("error")) for r in rows] == [("ok", None), ("conflict", "change_pending")] + + +@pytest.mark.parametrize("role,target,ops,status,code", [ + ("admin", ALICE, [{"op": "add_node", "node": {"key": "group.staff"}}], 403, "root_only"), + ("admin", ALICE, [{"op": "add_node", "node": {"key": "essentials.*"}}], 403, "root_only"), + ("admin", ALICE, [{"op": "add_node", "node": {"key": "essentials.fly"}}], 403, "root_only"), + ("admin", ALICE, [{"op": "add_node", "node": {"key": "professions.chef_2", "value": False}}], 200, None), + ("admin", ALICE, [{"op": "add_node", "node": {"key": "prefix.500.&cKing"}}], 403, "root_only"), + ("admin", ADMIN_MC, [{"op": "add_node", "node": {"key": "tips.off"}}], 403, "cannot_act_on_self"), + ("admin", ROOT_MC, [{"op": "add_node", "node": {"key": "tips.off"}}], 403, "target_outranks_you"), + ("admin", ALICE, [{"op": "add_node", "node": node("group.commoner")}], 409, "node_exists"), + ("admin", ALICE, [{"op": "remove_node", "node": node("tips.off")}], 409, "node_missing"), + ("admin", ALICE, [{"op": "add_node", "node": {"key": "group.ghost"}}], 409, "group_missing"), + ("admin", ALICE, [{"op": "demote", "track": "helper"}], 409, "not_on_track"), + ("admin", BOB, [{"op": "promote", "track": "staff"}], 403, "root_only"), + ("admin", "nope", [{"op": "add_node", "node": {"key": "x"}}], 400, "bad_uuid"), + ("admin", ALICE, [{"op": "create_group"}], 400, "bad_ops"), + ("admin", "staff", [{"op": "add_node", "node": {"key": "x"}}], 400, "bad_uuid"), + ("root", ALICE, [{"op": "add_node", "node": {"key": "group.staff"}}], 200, None), + ("root", ROOT_MC, [{"op": "add_node", "node": {"key": "essentials.*"}}], 200, None), +]) +def test_user_change_rules(app, bridge, staff, role, target, ops, status, code): + response = submit(app, staff[role], "user", target, ops) + assert response.status_code == status, response.text + if code: + assert response.json()["detail"] == code + + +def test_promote_and_demote_become_explicit_ops(app, bridge, staff): + change = submit(app, staff["admin"], "user", BOB, [{"op": "promote", "track": "helper"}]).json() + assert change["description"] == "promote helper" + ops = bridge.get("/luckperms/plugin/changes", headers=PLUGIN).json()["changes"][0]["ops"] + assert ops == [{"op": "remove_node", "node": node("group.helper_player")}, + {"op": "add_node", "node": node("group.helper_inactive")}] + bridge.post("/luckperms/plugin/changes/results", headers=PLUGIN, + json={"results": [{"id": change["id"], "ok": False, "error": "node_missing"}]}) + assert client(app, staff["admin"]).get(f"/admin/luckperms/changes/{change['id']}").json()["error"] == "node_missing" + demote = submit(app, staff["admin"], "user", BOB, [{"op": "demote", "track": "helper"}]) + assert demote.status_code == 200 + ops = bridge.get("/luckperms/plugin/changes", headers=PLUGIN).json()["changes"][0]["ops"] + assert ops == [{"op": "remove_node", "node": node("group.helper_player")}] + + +def test_group_and_track_definitions_are_root_only(app, bridge, staff): + ops = [{"op": "create_group"}, {"op": "add_node", "node": {"key": "group.commoner"}}, + {"op": "add_node", "node": {"key": "weight.60"}}] + assert submit(app, staff["admin"], "group", "knight", ops).json()["detail"] == "root_only" + created = submit(app, staff["root"], "group", "knight", ops) + assert created.status_code == 200 and created.json()["description"] == ( + "create; parent add commoner; permission set weight.60 true") + cycle = submit(app, staff["root"], "group", "commoner", [{"op": "add_node", "node": {"key": "group.staff"}}]) + assert cycle.json()["detail"] == "inheritance_cycle" + assert submit(app, staff["root"], "group", "default", [{"op": "delete_group"}]).json()["detail"] == "cannot_delete_default" + track = submit(app, staff["root"], "track", "helper", [{"op": "set_groups", "groups": ["helper_player", "helper"]}]) + assert track.status_code == 200 + assert submit(app, staff["root"], "track", "staff", [{"op": "set_groups", "groups": ["ghost"]}]).json()[ + "detail"] == "group_missing" + + +def test_changes_need_a_connected_bridge_and_expire(app, staff, env, monkeypatch): + monkeypatch.delenv("LUCKPERMS_READ_ONLY", raising=False) + plugin = client(app) + plugin.put("/luckperms/plugin/snapshot", headers=PLUGIN, json=snapshot()) + response = submit(app, staff["admin"], "user", ALICE, [{"op": "add_node", "node": {"key": "tips.off"}}]) + assert response.status_code == 503 and response.json()["detail"] == "bridge_offline" + plugin.get("/luckperms/plugin/changes", headers=PLUGIN) + change = submit(app, staff["admin"], "user", ALICE, [{"op": "add_node", "node": {"key": "tips.off"}}]).json() + later = time.time() + changes.PENDING_TTL_SECONDS + 5 + monkeypatch.setattr(changes.time, "time", lambda: later) + assert changes.fetch_for_bridge() == [] + assert changes.get_change(change["id"])["status"] == "expired" + + +def test_changes_are_sent_once_and_unanswered_ones_become_unknown(app, bridge, staff, monkeypatch): + change = submit(app, staff["admin"], "user", ALICE, [{"op": "add_node", "node": {"key": "tips.off"}}]).json() + clock = [time.time()] + monkeypatch.setattr(changes.time, "time", lambda: clock[0]) + assert len(changes.fetch_for_bridge()) == 1 + clock[0] += changes.RESULT_WAIT_SECONDS + 1 + assert changes.fetch_for_bridge() == [] + assert changes.get_change(change["id"])["status"] == "unknown" + # A late result still settles it. + changes.record_results([{"id": change["id"], "ok": True, "revision": 5000, "state": None}]) + assert changes.get_change(change["id"])["status"] == "applied" + + +def test_admins_cannot_touch_in_game_staff_even_unlinked(app, bridge, staff): + plugin = client(app) + users_now = snapshot()["users"] + [{"uuid": "55555555-5555-5555-5555-555555555555", "name": "Mod", + "nodes": [node("group.staff_player", contexts={"server": ["main"]})]}] + plugin.put("/luckperms/plugin/snapshot", headers=PLUGIN, json=snapshot(revision=1100, users=users_now)) + response = submit(app, staff["admin"], "user", "55555555-5555-5555-5555-555555555555", + [{"op": "add_node", "node": {"key": "tips.off"}}]) + assert response.json()["detail"] == "target_is_staff" + + +def test_demotion_after_queueing_cancels_the_change(app, bridge, staff, env): + change = submit(app, staff["admin"], "user", ALICE, [{"op": "add_node", "node": {"key": "tips.off"}}]).json() + with env.connect() as conn: + conn.execute("UPDATE users SET role = 'mod' WHERE discord_username = 'adam'") + conn.commit() + assert changes.fetch_for_bridge() == [] + assert changes.get_change(change["id"])["error"] == "actor_changed" + + +def test_read_only_site_shows_but_never_changes(app, bridge, staff, monkeypatch): + monkeypatch.setenv("LUCKPERMS_READ_ONLY", "1") + root = client(app, staff["root"]) + assert root.get("/admin/luckperms").json()["rights"] == { + "read_only": True, "change_players": False, "edit_definitions": False} + response = submit(app, staff["root"], "user", ALICE, [{"op": "add_node", "node": {"key": "tips.off"}}]) + assert response.json()["detail"] == "bridge_offline" + + +def test_malformed_change_is_audited(app, bridge, staff, env): + response = client(app, staff["admin"]).post("/admin/luckperms/changes", headers=ORIGIN, json={"target": ALICE}) + assert response.status_code == 422 + row = audit_rows(env)[-1] + assert (row["action"], row["outcome"]) == ("luckperms.change", "invalid") diff --git a/backend/src/luckperms/views.py b/backend/src/luckperms/views.py new file mode 100644 index 00000000..61c8dc15 --- /dev/null +++ b/backend/src/luckperms/views.py @@ -0,0 +1,229 @@ +"""What the staff panel shows of LuckPerms, with what the viewer may change marked on it. + +The marks only decide which controls appear; changes.queue_change checks +everything again when a change is made. +""" +from __future__ import annotations + +import time + +from src.auth import roles +from src.auth.admin import AdminError +from src.auth.players import canonical_uuid +from src.skins.db import connect + +from . import changes, mirror, nodes, policy + + +def _can_change(viewer: dict) -> bool: + return roles.can(viewer["role"], "change_luckperms") + + +def _group_summary(group: dict, groups: dict[str, dict], counts: dict[str, int], rules: policy.Policy, + now: int) -> dict: + prefix = mirror.meta_value(group, "prefix", now) + suffix = mirror.meta_value(group, "suffix", now) + return { + "name": group["name"], + "display_name": group["display_name"], + "weight": group["weight"], + "prefix": prefix[1] if prefix else None, + "suffix": suffix[1] if suffix else None, + "parents": mirror.parents(group, now), + "members": counts.get(group["name"], 0), + "permissions": sum(1 for n in group["nodes"] if nodes.kind(n["key"]) == "permission"), + "min_role": policy.group_role(rules, groups, group["name"]), + "patreon": group["name"] in rules.patreon_groups, + } + + +def _sorted_groups(groups: dict[str, dict]) -> list[dict]: + return sorted(groups.values(), key=lambda g: (-(g["weight"] or 0), g["name"])) + + +def _rights(viewer: dict) -> dict: + writable = not changes.read_only() + return { + "read_only": not writable, + "change_players": writable and _can_change(viewer), + "edit_definitions": writable and policy.may_edit_definitions(viewer["role"]), + } + + +def overview(viewer: dict) -> dict: + rules = policy.load() + now = int(time.time()) + with connect() as conn: + groups = mirror.load_groups(conn) + counts = mirror.group_counts(conn) + tracks = mirror.load_tracks(conn) + status = mirror.status(conn) + players = conn.execute("SELECT COUNT(*) FROM lp_users").fetchone()[0] + return { + "status": status, + "players": players, + "groups": [_group_summary(g, groups, counts, rules, now) for g in _sorted_groups(groups)], + "tracks": tracks, + "rights": _rights(viewer), + "patreon_groups": sorted(rules.patreon_groups), + "recent": changes.list_changes(limit=20), + } + + +def _annotate(node: dict, editable: bool) -> dict: + return {**node, "kind": nodes.kind(node["key"]), "group": nodes.group_of(node["key"]), "editable": editable} + + +def group_detail(viewer: dict, name: str) -> dict: + name = (name or "").strip().lower() + if not nodes.GROUP_NAME.match(name): + raise AdminError(400, "bad_group_name", "invalid") + rules = policy.load() + now = int(time.time()) + with connect() as conn: + groups = mirror.load_groups(conn) + if name not in groups: + raise AdminError(404, "group_missing", "not_found") + counts = mirror.group_counts(conn) + tracks = mirror.load_tracks(conn) + members = mirror.search_users(conn, "", name, 1) + status = mirror.status(conn) + group = groups[name] + editable = policy.may_edit_definitions(viewer["role"]) + children = sorted(g["name"] for g in groups.values() if name in mirror.parents(g, now)) + return { + "status": status, + "group": { + **_group_summary(group, groups, counts, rules, now), + "nodes": [_annotate(n, editable) for n in group["nodes"]], + "inherits": mirror.inherited(groups, [name], now)[1:], + "children": children, + "tracks": [t["name"] for t in tracks if name in t["groups"]], + }, + "members": members, + "all_groups": [g["name"] for g in _sorted_groups(groups)], + "rights": _rights(viewer), + "changes": changes.list_changes("group", name, 20), + "pending": _pending("group", name), + } + + +def _pending(target_type: str, target: str) -> dict | None: + for change in changes.list_changes(target_type, target, 5): + if change["status"] in changes.UNFINISHED: + return change + return None + + +def _account(conn, uuid: str) -> dict | None: + row = conn.execute( + """ + SELECT dl.discord_user_id, dl.discord_username, u.id AS user_id, u.role + FROM discord_links dl LEFT JOIN users u ON u.discord_user_id = dl.discord_user_id + WHERE lower(dl.player_uuid) = ? + """, + (uuid,), + ).fetchone() + return dict(row) if row else None + + +def _track_moves(conn, uuid: str, tracks: list[dict], viewer: dict, allowed: bool, now: int) -> list[dict]: + """Where the player stands on each track, and whether the viewer may move them.""" + held = mirror.user_nodes(conn, uuid) + out = [] + for track in tracks: + positions = [track["groups"].index(nodes.group_of(n["key"])) for n in held + if n["value"] and not n["contexts"] and nodes.group_of(n["key"]) in track["groups"]] + moves = {} + for op in ("promote", "demote"): + moves[op] = False + if not allowed: + continue + try: + ops, _ = changes.build_user_ops(conn, uuid, [{"op": op, "track": track["name"]}], now) + changes.check_ops_allowed(conn, viewer["role"], "user", ops) + moves[op] = bool(ops) + except AdminError: + pass + out.append({ + "name": track["name"], + "groups": track["groups"], + "position": positions[0] if len(positions) == 1 else None, + "ambiguous": len(positions) > 1, + "can_promote": moves["promote"], + "can_demote": moves["demote"], + }) + return out + + +def user_detail(viewer: dict, raw_uuid: str) -> dict: + uuid = canonical_uuid(raw_uuid) + if uuid is None: + raise AdminError(400, "bad_uuid", "invalid") + rules = policy.load() + now = int(time.time()) + with connect() as conn: + row = mirror.user_row(conn, uuid) + if row is None: + raise AdminError(404, "player_not_found", "not_found") + groups = mirror.load_groups(conn) + held = mirror.user_nodes(conn, uuid) + tracks = mirror.load_tracks(conn) + status = mirror.status(conn) + account = _account(conn, uuid) + allowed = _can_change(viewer) and not changes.read_only() + if allowed: + try: + changes.check_user_target(conn, viewer, uuid) + except AdminError: + allowed = False + track_moves = _track_moves(conn, uuid, tracks, viewer, allowed, now) + direct = [nodes.group_of(n["key"]) for n in held + if nodes.group_of(n["key"]) and n["value"] and not n["contexts"] and nodes.active(n, now)] + rank = mirror.rank_group(groups, held, now) + rank_group = groups.get(rank) if rank else None + prefix = mirror.meta_value(rank_group, "prefix", now) if rank_group else None + return { + "status": status, + "player": { + "uuid": uuid, + "name": row["name"], + "rank": rank, + "rank_prefix": prefix[1] if prefix else None, + "inherits": mirror.inherited(groups, [d for d in direct if d], now), + "nodes": [_annotate(n, allowed and policy.may_edit_node(rules, groups, viewer["role"], n)) for n in held], + "tracks": track_moves, + "account": account, + }, + "groups": [{"name": g["name"], "weight": g["weight"], + "min_role": policy.group_role(rules, groups, g["name"]), + "patreon": g["name"] in rules.patreon_groups, + "addable": allowed and policy.may_edit_node(rules, groups, viewer["role"], + {"key": f"group.{g['name']}"})} + for g in _sorted_groups(groups)], + "rights": {**_rights(viewer), "change_this_player": allowed, + "admin_permissions": list(rules.admin_permissions)}, + "changes": changes.list_changes("user", uuid, 20), + "pending": _pending("user", uuid), + } + + +def users(viewer: dict, q: str, group: str | None, page: int) -> dict: + group = (group or "").strip().lower() or None + if group is not None and not nodes.GROUP_NAME.match(group): + raise AdminError(400, "bad_group_name", "invalid") + now = int(time.time()) + with connect() as conn: + try: + listing = mirror.search_users(conn, q, group, page) + except mirror.SnapshotError as exc: + raise AdminError(400, exc.code, "invalid") from None + groups = mirror.load_groups(conn) + for row in listing["rows"]: + held = mirror.user_nodes(conn, row["uuid"]) + row["groups"] = [ + {"name": nodes.group_of(n["key"]), "contexts": n["contexts"], "expiry": n["expiry"]} + for n in held if nodes.group_of(n["key"]) and n["value"] and nodes.active(n, now) + ] + row["rank"] = mirror.rank_group(groups, held, now) + return listing diff --git a/backend/src/skins/schema.sql b/backend/src/skins/schema.sql index 0e76e53d..3ca23c18 100644 --- a/backend/src/skins/schema.sql +++ b/backend/src/skins/schema.sql @@ -716,3 +716,85 @@ WHEN NEW.id IS NOT NULL AND EXISTS (SELECT 1 FROM admin_audit WHERE id = NEW.id) BEGIN SELECT RAISE(ABORT, 'admin_audit is append-only'); END; + +-- LuckPerms mirror for the staff panel. The TFMCWeb bridge publishes a whole +-- snapshot, which replaces these tables in one transaction; see +-- src/luckperms/README.md. Contexts are canonical JSON ({} when global) and +-- expiry is unix seconds, 0 when permanent. +CREATE TABLE IF NOT EXISTS lp_state ( + id INTEGER PRIMARY KEY CHECK (id = 1), + server TEXT, + hash TEXT, + generated_at INTEGER, + -- Newest bridge revision applied: a snapshot's, or a change result's. + revision INTEGER, + received_at TEXT, + -- Last time the bridge confirmed the snapshot is still current. + checked_at TEXT, + -- Last time an applying bridge asked for changes. + polled_at TEXT +); + +CREATE TABLE IF NOT EXISTS lp_groups ( + name TEXT PRIMARY KEY, + display_name TEXT, + weight INTEGER +); + +CREATE TABLE IF NOT EXISTS lp_group_nodes ( + group_name TEXT NOT NULL, + key TEXT NOT NULL, + value INTEGER NOT NULL, + contexts TEXT NOT NULL, + expiry INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_lp_group_nodes_group ON lp_group_nodes(group_name); + +CREATE TABLE IF NOT EXISTS lp_tracks ( + name TEXT PRIMARY KEY, + groups_json TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS lp_users ( + uuid TEXT PRIMARY KEY, + name TEXT +); + +CREATE INDEX IF NOT EXISTS idx_lp_users_name ON lp_users(lower(name)); + +CREATE TABLE IF NOT EXISTS lp_user_nodes ( + uuid TEXT NOT NULL, + key TEXT NOT NULL, + value INTEGER NOT NULL, + contexts TEXT NOT NULL, + expiry INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_lp_user_nodes_uuid ON lp_user_nodes(uuid); +CREATE INDEX IF NOT EXISTS idx_lp_user_nodes_key ON lp_user_nodes(key); + +-- Changes staff queued for the bridge. Rows are kept as the change history. +CREATE TABLE IF NOT EXISTS lp_changes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at TEXT NOT NULL, + actor_user_id INTEGER NOT NULL, + actor_name TEXT, + actor_role TEXT, + actor_minecraft_uuid TEXT, + target_type TEXT NOT NULL CHECK (target_type IN ('user', 'group', 'track')), + target TEXT NOT NULL, + target_name TEXT, + description TEXT NOT NULL, + ops_json TEXT NOT NULL, + reason TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'sent', 'applied', 'failed', 'expired', 'unknown')), + attempts INTEGER NOT NULL DEFAULT 0, + sent_at TEXT, + finished_at TEXT, + error TEXT +); + +CREATE INDEX IF NOT EXISTS idx_lp_changes_status ON lp_changes(status, id); +CREATE INDEX IF NOT EXISTS idx_lp_changes_target ON lp_changes(target_type, target, id); From d487c3be73869bb9b040d26f785b9ffddbe4e439 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Wed, 7 Oct 2026 13:47:01 +0000 Subject: [PATCH 2/6] Ranks pages in the staff panel Overview with player search, groups and tracks; a player page with track promote/demote, groups and permissions; a group page with its settings, nodes and members. Changes ask for a reason and follow the bridge until applied. Player profiles link to their ranks and offer the website role actions. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../app/admin/ranks/groups/[name]/page.tsx | 20 + frontend/app/admin/ranks/page.tsx | 17 + .../app/admin/ranks/players/[uuid]/page.tsx | 21 + frontend/app/components/admin/AdminNav.tsx | 3 +- .../app/components/admin/AdminPanel.test.tsx | 3 + .../app/components/admin/PlayerProfile.tsx | 44 +- .../components/admin/ranks/ChangeConfirm.tsx | 118 ++++ .../app/components/admin/ranks/McText.tsx | 22 + .../app/components/admin/ranks/RankGroup.tsx | 441 +++++++++++++++ .../admin/ranks/RankPlayer.test.tsx | 102 ++++ .../app/components/admin/ranks/RankPlayer.tsx | 522 ++++++++++++++++++ .../admin/ranks/RanksOverview.test.tsx | 112 ++++ .../components/admin/ranks/RanksOverview.tsx | 445 +++++++++++++++ frontend/app/components/admin/ranks/parts.tsx | 122 ++++ frontend/app/components/admin/ranks/ui.ts | 17 + frontend/lib/admin/api.ts | 6 +- frontend/lib/admin/luckperms.test.ts | 41 ++ frontend/lib/admin/luckperms.ts | 338 ++++++++++++ 18 files changed, 2385 insertions(+), 9 deletions(-) create mode 100644 frontend/app/admin/ranks/groups/[name]/page.tsx create mode 100644 frontend/app/admin/ranks/page.tsx create mode 100644 frontend/app/admin/ranks/players/[uuid]/page.tsx create mode 100644 frontend/app/components/admin/ranks/ChangeConfirm.tsx create mode 100644 frontend/app/components/admin/ranks/McText.tsx create mode 100644 frontend/app/components/admin/ranks/RankGroup.tsx create mode 100644 frontend/app/components/admin/ranks/RankPlayer.test.tsx create mode 100644 frontend/app/components/admin/ranks/RankPlayer.tsx create mode 100644 frontend/app/components/admin/ranks/RanksOverview.test.tsx create mode 100644 frontend/app/components/admin/ranks/RanksOverview.tsx create mode 100644 frontend/app/components/admin/ranks/parts.tsx create mode 100644 frontend/app/components/admin/ranks/ui.ts create mode 100644 frontend/lib/admin/luckperms.test.ts create mode 100644 frontend/lib/admin/luckperms.ts diff --git a/frontend/app/admin/ranks/groups/[name]/page.tsx b/frontend/app/admin/ranks/groups/[name]/page.tsx new file mode 100644 index 00000000..3a7f3e2b --- /dev/null +++ b/frontend/app/admin/ranks/groups/[name]/page.tsx @@ -0,0 +1,20 @@ +import Link from "next/link"; +import AdminNav from "../../../../components/admin/AdminNav"; +import RankGroup from "../../../../components/admin/ranks/RankGroup"; + +export default async function AdminRankGroupPage({ params }: { params: Promise<{ name: string }> }) { + const { name } = await params; + const group = decodeURIComponent(name); + return ( +
+

+ Staff panel +

+ + + ← Ranks + + +
+ ); +} diff --git a/frontend/app/admin/ranks/page.tsx b/frontend/app/admin/ranks/page.tsx new file mode 100644 index 00000000..4bbfd190 --- /dev/null +++ b/frontend/app/admin/ranks/page.tsx @@ -0,0 +1,17 @@ +import AdminNav from "../../components/admin/AdminNav"; +import RanksOverview from "../../components/admin/ranks/RanksOverview"; + +type SearchParams = Promise>; + +export default async function AdminRanksPage({ searchParams }: { searchParams: SearchParams }) { + const { q } = await searchParams; + return ( +
+

+ Staff panel +

+ + +
+ ); +} diff --git a/frontend/app/admin/ranks/players/[uuid]/page.tsx b/frontend/app/admin/ranks/players/[uuid]/page.tsx new file mode 100644 index 00000000..37fd3955 --- /dev/null +++ b/frontend/app/admin/ranks/players/[uuid]/page.tsx @@ -0,0 +1,21 @@ +import Link from "next/link"; +import AdminNav from "../../../../components/admin/AdminNav"; +import RankPlayer from "../../../../components/admin/ranks/RankPlayer"; + +export default async function AdminRankPlayerPage({ params }: { params: Promise<{ uuid: string }> }) { + const { uuid } = await params; + const id = decodeURIComponent(uuid); + return ( +
+

+ Staff panel +

+ + + ← Ranks + + {/* Keyed so moving between players starts afresh. */} + +
+ ); +} diff --git a/frontend/app/components/admin/AdminNav.tsx b/frontend/app/components/admin/AdminNav.tsx index e3a3c084..eebf36ba 100644 --- a/frontend/app/components/admin/AdminNav.tsx +++ b/frontend/app/components/admin/AdminNav.tsx @@ -4,11 +4,12 @@ const LINKS = [ { href: "/admin", label: "Accounts", key: "accounts" }, { href: "/admin/players", label: "Players", key: "players" }, { href: "/admin/movement", label: "Movement", key: "movement" }, + { href: "/admin/ranks", label: "Ranks", key: "ranks" }, ] as const; export default function AdminNav({ current }: { current: (typeof LINKS)[number]["key"] }) { return ( -