From 8cc51cfb40f7bbd0dc145bcd2be839ba892a992e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sat, 15 Aug 2026 00:42:35 +0000 Subject: [PATCH] every plugin permission is grantable, and there is no field to say otherwise MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit offscale was missing from the permissions page because it declared ownerOnly, which mapped to kind admin, and admin capabilities are never offered for granting. correct by the old rule and wrong by the standard: every plugin follows the same platform-level permission model, appearing on the same page with the same read/write/none per role. so the field is gone rather than flipped. a plugin has no way to say owner-only, which makes the standard structural instead of remembered — the same move as the workspace rule. core, execution, confined and admin stay the platform's to assign and a plugin cannot name any of them, so the escalation question is removed rather than answered. this is the second draft of this decision to be deleted: first a full CapabilityKind with three of five values forbidden, then an ownerOnly boolean, now nothing. the doc records all three so the reasoning is visible rather than just the conclusion. finer visibility stays the plugin's job. offscale is the worked example of the gap that leaves and its manifest says so: it is grantable now, and its queries still scope by the caller, so a granted member would see their own empty server list rather than the owner's. closing that is a change inside the plugin. Co-Authored-By: Claude Opus 5 --- plugins/example/manifest.ts | 1 - plugins/offscale/manifest.ts | 17 +++++++++-------- src/servers/plugins/manifest.test.ts | 2 +- src/servers/plugins/manifest.ts | 18 ++++++++++-------- src/servers/plugins/mount.ts | 9 +++++---- 5 files changed, 25 insertions(+), 22 deletions(-) diff --git a/plugins/example/manifest.ts b/plugins/example/manifest.ts index 63aef46b..1ea71cdc 100644 --- a/plugins/example/manifest.ts +++ b/plugins/example/manifest.ts @@ -30,7 +30,6 @@ export const manifest: PluginManifest = { key: 'example', label: 'Example', description: 'The reference plugin', - ownerOnly: false, }, ], }; diff --git a/plugins/offscale/manifest.ts b/plugins/offscale/manifest.ts index a08d3a87..a1c34360 100644 --- a/plugins/offscale/manifest.ts +++ b/plugins/offscale/manifest.ts @@ -22,21 +22,22 @@ export const manifest: PluginManifest = { icon: 'Network', color: '#818cf8', - // One permission gating the whole surface. + // One permission gating the whole surface, grantable per role at read or write like every other. // - // `ownerOnly` because the credential behind it is a Headscale ADMIN api key that can delete every node - // on a tailnet, and there is no read-only version of it. A read grant would still be reading through - // that key; the protection is that non-owners cannot reach the routes at all. + // `[open]` What a member's grant MEANS here is this plugin's own job and is not finished. The queries + // still scope by the caller (`listHeadscaleServers(userId)`), so a granted member would see their own + // empty server list rather than the owner's, and could register a Headscale of their own. The model in + // docs/offscale-plugin.md is one shared resource: read sees what the owner sees, write can change it. + // That is a change inside these queries, not a flag on the manifest. // - // Read/write for members is the model recorded in docs/offscale-plugin.md and deliberately not enabled - // here yet: it needs the queries to resolve to the OWNER's rows rather than the caller's, which is a - // change inside this plugin and not a flag. + // Worth knowing while it is unfinished: the stored credential is a Headscale ADMIN api key that can + // delete every node on a tailnet, and there is no read-only version of it — so `write` here is close to + // full control of the tailnet, which is the owner's decision to make deliberately. permissions: [ { key: 'offscale', label: 'Offscale', description: 'The tailnet: machines, routes, keys and ACLs', - ownerOnly: true, // Two POSTs that are really reads — a reachability probe and a policy DRAFT that never saves. // Without declaring them a read-level account meets a broken feature where a withheld permission // should be. Inert while ownerOnly, and correct the moment that changes. diff --git a/src/servers/plugins/manifest.test.ts b/src/servers/plugins/manifest.test.ts index e380b50e..d1aa44ba 100644 --- a/src/servers/plugins/manifest.test.ts +++ b/src/servers/plugins/manifest.test.ts @@ -9,7 +9,7 @@ const valid = (over: Partial = {}): PluginManifest => ({ summary: 'Your tailnet', icon: 'Network', color: '#818cf8', - permissions: [{ key: 'offscale', label: 'Offscale', description: 'The tailnet', ownerOnly: true }], + permissions: [{ key: 'offscale', label: 'Offscale', description: 'The tailnet' }], ...over, }); diff --git a/src/servers/plugins/manifest.ts b/src/servers/plugins/manifest.ts index f96dc636..a8764d62 100644 --- a/src/servers/plugins/manifest.ts +++ b/src/servers/plugins/manifest.ts @@ -24,14 +24,16 @@ export type PluginPermission = { key: string; label: string; description: string; - /** - * Owner-only, or grantable to members. The whole distinction a plugin needs. - * - * The platform's own `CapabilityKind` has five values because the PLATFORM has five sorts of surface. - * A plugin has two states, so this is a boolean — which also removes the escalation question rather - * than answering it: a plugin cannot claim `core` if `core` is not a word it can say. - */ - ownerOnly?: boolean; + // There is deliberately NO `ownerOnly`, and no kind of any sort. + // + // Every plugin permission is grantable, per role, at read or write — the same configuration as every + // core capability, on the same page, with no special cases to learn. A plugin that wanted to be + // owner-only would be a plugin the owner cannot delegate, and "which members may reach this" is a + // decision that belongs to the person running the server rather than to the person who wrote the code. + // + // Finer visibility — whose rows a member sees, what a read means for this plugin's data — is the + // PLUGIN's business and lives in its own queries. The platform's answer is uniform: read, write, or + // nothing. See docs/offscale-plugin.md. /** * Requests that look like writes and are not — `POST /ssh-test` probes, `POST /policy/assist` proposes * a document and never saves one. Without declaring them, a read-level account meets what reads as a diff --git a/src/servers/plugins/mount.ts b/src/servers/plugins/mount.ts index 7ba5d6bd..48064f85 100644 --- a/src/servers/plugins/mount.ts +++ b/src/servers/plugins/mount.ts @@ -89,9 +89,10 @@ export async function mountablePlugins(snapshot: PluginsSnapshot): Promise