Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion backend/src/api/admin_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
)


Expand All @@ -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",
Expand All @@ -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)
122 changes: 122 additions & 0 deletions backend/src/api/luckperms_routes.py
Original file line number Diff line number Diff line change
@@ -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))
5 changes: 3 additions & 2 deletions backend/src/auth/admin_cli.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
"""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"
python -m src.auth.admin_cli revoke-root --discord-id 123 --reason "Stepped down"
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

Expand Down
14 changes: 11 additions & 3 deletions backend/src/auth/roles.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}


Expand All @@ -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)]
13 changes: 8 additions & 5 deletions backend/src/auth/test_admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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") == []


Expand Down Expand Up @@ -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"] == []


Expand Down Expand Up @@ -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"),
Expand Down
133 changes: 133 additions & 0 deletions backend/src/luckperms/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
# 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` but not `root_only_permissions` (such as `armourshop.admin`) |
| 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 (`no_longer_allowed`). Every change carries a
reason and is written to `admin_audit`, and LuckPerms logs it (`/lp log`) with
the source `web:<discord name>`.

## 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": "<sha-256 hex of the canonical groups/tracks/users JSON>",
"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],
"guard": {"admin_groups": ["default", "commoner"]} // admin changes only
}]}
```

A group or track change is handed out on its own, and nothing else is handed out until its
result arrives, so later changes are checked against the definitions it leaves.

`guard` is on changes an admin made. The mirror can lag in-game edits, so the bridge checks live
LuckPerms before mutating: the player must hold no group (any context) outside `admin_groups`
(else `target_is_staff`), and every group an `add_node` grants must inherit only groups in
`admin_groups` (else `root_only`).

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": "<code>"}` with codes
`node_exists`, `node_missing`, `group_exists`, `group_missing`, `track_exists`, `track_missing`,
`bad_op`, `bad_target`, `save_failed`. A `group.<name>` 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`. Both timeouts are settled on staff reads too, so they happen even when the
bridge is gone. 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).
Empty file.
Loading