From acd51c969c77247a13e5e06350e199eda04ef43e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Fri, 14 Aug 2026 20:04:18 +0000 Subject: [PATCH] the platform grants read or write; richer rules belong to the plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the platform's contract is what it already has: a role holds read or write on a capability, stored in role_capabilities and enforced by the gate. anything beyond — who sees whose rows, per-user isolation, record ownership, visibility of any kind — is the plugin author's job, inside the plugin. 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 exactly. one shared resource: read sees what the owner sees, write can change it including deleting a server the owner registered. that is dangerous on purpose — the stored credential is a headscale admin key with no read-only equivalent, so write is close to full control of the tailnet, and that is the owner's call. expected use is read for most roles. two consequences, both inside the plugin. the queries stop scoping by the caller and resolve to the owner's id, leaving the per-user shape in the table unused as the seam if isolation is ever wanted. and two POSTs are really reads — /ssh-test probes and /policy/assist explicitly never saves — so they need readOnlyWrites, or a read-level account finds a broken feature where a withheld permission should be. Co-Authored-By: Claude Opus 5 --- docs/offscale-plugin.md | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) 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: