diff --git a/docs/offscale-plugin.md b/docs/offscale-plugin.md index e6696cc5..0e656678 100644 --- a/docs/offscale-plugin.md +++ b/docs/offscale-plugin.md @@ -224,6 +224,44 @@ deniable_, so a third-party manifest naming its own kind is a privilege-escalati plugin declares itself core" is an ungated grant to every user. `core`, `execution` and `admin` stay the platform's to assign. +### The platform grants read or write. Everything richer is the plugin's own job + +The platform's contract is exactly what it already has and no more: **a role holds `read` or `write` on a +capability**, stored in `role_capabilities`, enforced by the gate. `read` permits safe methods anywhere in +the surface; `write` permits everything. + +Anything beyond that — who may see whose rows, per-user isolation, ownership of individual records, +visibility rules of any kind — is **implemented inside the plugin**, by the plugin's author. It is not the +platform's responsibility and the platform should not grow machinery for it. A plugin knows what its data +means; the platform only knows whether this account got through the door. + +### Offscale v1 uses that model exactly, with nothing added + +One shared resource, role-gated: + +- **read** — sees what the owner sees: the owner's registered servers, nodes, users, keys, policy +- **write** — can change them, including deleting a server the owner registered + +The second is genuinely dangerous, and deliberately allowed. The stored credential is a Headscale **admin** +key that can delete every node on a tailnet, and there is no read-only version of it. So `write` on +offscale is close to full control of the tailnet — which is the owner's decision to make, and the expected +use is read for most roles. Say Developers get `read` and nobody gets `write`. + +Two implementation consequences, both inside the plugin: + +1. **The queries stop scoping by the caller.** Every one takes the caller's `userId` today — + `listHeadscaleServers(userId)`, `getActiveHeadscaleCredentials(userId)` — and the schema is per-user + because of it. Under this model a member sees the **owner's** rows, so those resolve to the owner's id + always. The per-user shape stays in the table, unused, and becomes the seam if isolation is ever wanted. + +2. **Two POSTs are really reads, and must be declared `readOnlyWrites`:** + - `POST /ssh-test` — a reachability probe that mutates nothing + - `POST /policy/assist` — proposes a document and, emphatically, never saves one + + Without them a read-level account cannot test a connection or draft a policy, which reads as a broken + feature rather than a withheld permission. Everything else — activate, rename, tags, routes, expire, + delete, policy `PUT` — is a genuine write. + ### Three different things are called "capability" here A manifest needs three names, not one: