every plugin permission is grantable, and there is no field to say otherwise
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 <noreply@anthropic.com>
This commit is contained in:
@@ -30,7 +30,6 @@ export const manifest: PluginManifest = {
|
|||||||
key: 'example',
|
key: 'example',
|
||||||
label: 'Example',
|
label: 'Example',
|
||||||
description: 'The reference plugin',
|
description: 'The reference plugin',
|
||||||
ownerOnly: false,
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -22,21 +22,22 @@ export const manifest: PluginManifest = {
|
|||||||
icon: 'Network',
|
icon: 'Network',
|
||||||
color: '#818cf8',
|
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
|
// `[open]` What a member's grant MEANS here is this plugin's own job and is not finished. The queries
|
||||||
// on a tailnet, and there is no read-only version of it. A read grant would still be reading through
|
// still scope by the caller (`listHeadscaleServers(userId)`), so a granted member would see their own
|
||||||
// that key; the protection is that non-owners cannot reach the routes at all.
|
// 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
|
// Worth knowing while it is unfinished: the stored credential is a Headscale ADMIN api key that can
|
||||||
// here yet: it needs the queries to resolve to the OWNER's rows rather than the caller's, which is a
|
// delete every node on a tailnet, and there is no read-only version of it — so `write` here is close to
|
||||||
// change inside this plugin and not a flag.
|
// full control of the tailnet, which is the owner's decision to make deliberately.
|
||||||
permissions: [
|
permissions: [
|
||||||
{
|
{
|
||||||
key: 'offscale',
|
key: 'offscale',
|
||||||
label: 'Offscale',
|
label: 'Offscale',
|
||||||
description: 'The tailnet: machines, routes, keys and ACLs',
|
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.
|
// 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
|
// 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.
|
// should be. Inert while ownerOnly, and correct the moment that changes.
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const valid = (over: Partial<PluginManifest> = {}): PluginManifest => ({
|
|||||||
summary: 'Your tailnet',
|
summary: 'Your tailnet',
|
||||||
icon: 'Network',
|
icon: 'Network',
|
||||||
color: '#818cf8',
|
color: '#818cf8',
|
||||||
permissions: [{ key: 'offscale', label: 'Offscale', description: 'The tailnet', ownerOnly: true }],
|
permissions: [{ key: 'offscale', label: 'Offscale', description: 'The tailnet' }],
|
||||||
...over,
|
...over,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -24,14 +24,16 @@ export type PluginPermission = {
|
|||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
description: string;
|
description: string;
|
||||||
/**
|
// There is deliberately NO `ownerOnly`, and no kind of any sort.
|
||||||
* Owner-only, or grantable to members. The whole distinction a plugin needs.
|
//
|
||||||
*
|
// Every plugin permission is grantable, per role, at read or write — the same configuration as every
|
||||||
* The platform's own `CapabilityKind` has five values because the PLATFORM has five sorts of surface.
|
// core capability, on the same page, with no special cases to learn. A plugin that wanted to be
|
||||||
* A plugin has two states, so this is a boolean — which also removes the escalation question rather
|
// owner-only would be a plugin the owner cannot delegate, and "which members may reach this" is a
|
||||||
* than answering it: a plugin cannot claim `core` if `core` is not a word it can say.
|
// decision that belongs to the person running the server rather than to the person who wrote the code.
|
||||||
*/
|
//
|
||||||
ownerOnly?: boolean;
|
// 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
|
* 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
|
* a document and never saves one. Without declaring them, a read-level account meets what reads as a
|
||||||
|
|||||||
@@ -89,9 +89,10 @@ export async function mountablePlugins(snapshot: PluginsSnapshot): Promise<Mount
|
|||||||
/**
|
/**
|
||||||
* Turn a plugin's declared permissions into capabilities the platform's own machinery understands.
|
* Turn a plugin's declared permissions into capabilities the platform's own machinery understands.
|
||||||
*
|
*
|
||||||
* `ownerOnly` becomes `admin`, everything else becomes `app` — those are the only two kinds a plugin can
|
* ALWAYS `app`, because a plugin permission is always grantable: the same read/write/none per role that
|
||||||
* express, and the mapping is the whole of it. `core`, `execution` and `confined` stay the platform's to
|
* every core capability gets, on the same page. `core`, `execution`, `confined` and `admin` stay the
|
||||||
* assign, and a plugin cannot name them because its manifest has no field for a kind.
|
* platform's to assign, and a plugin cannot name any of them — its manifest has no field for a kind, so
|
||||||
|
* the escalation question is removed rather than answered.
|
||||||
*
|
*
|
||||||
* The FIRST permission gates the plugin's whole surface. Later ones are registered as grantable keys that
|
* The FIRST permission gates the plugin's whole surface. Later ones are registered as grantable keys that
|
||||||
* claim no path — a plugin wanting genuinely separate surfaces needs sub-path claims in the manifest, and
|
* claim no path — a plugin wanting genuinely separate surfaces needs sub-path claims in the manifest, and
|
||||||
@@ -105,7 +106,7 @@ function pluginCapabilities(state: PluginState): Capability[] {
|
|||||||
key: permission.key,
|
key: permission.key,
|
||||||
label: permission.label,
|
label: permission.label,
|
||||||
description: permission.description,
|
description: permission.description,
|
||||||
kind: permission.ownerOnly ? ('admin' as const) : ('app' as const),
|
kind: 'app' as const,
|
||||||
// Only the first claims the routes; see above.
|
// Only the first claims the routes; see above.
|
||||||
api: index === 0 ? [prefix] : [],
|
api: index === 0 ? [prefix] : [],
|
||||||
routes: index === 0 ? [prefix] : [],
|
routes: index === 0 ? [prefix] : [],
|
||||||
|
|||||||
Reference in New Issue
Block a user