diff --git a/CLAUDE.md b/CLAUDE.md index 97c16ae..58a35c9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -77,7 +77,7 @@ Two rules keep the boundary real, and both are enforced by the compiler: - **The core never names a plugin.** A plugin contributes tools through `Plugin::tools(self: Arc)` — the sibling of `http_router()` — so nothing in the core has to downcast to a concrete type. Naming one would drag every plugin in the tree into the core, including a C build via `plugin-transcribe-whisper-local`. - **The core never learns about the process shell.** There is no in-core restart hook — the former `restart` tool and its `tools::restart::set_restart_handler` seam were removed. The only coupling to the supervisor is now the `run.sh` exit-code protocol (exit `255` ⇒ re-exec the same binary by path), a seam no code currently triggers (kept for a future admin-driven restart). The live expression of this principle is `skald_core::boot`, which emits startup lines each shell renders (`src/boot_format.rs` here). -**Plugin visibility & per-user config.** The admin surface is `#plugins` (`plugin-catalog.js`), a status board — one card per plugin with an enable toggle + health dot + a Configure button — plus `#plugin-detail?id=` (`plugin-detail.js`), which holds the instance-config form for one plugin (the plugin counterpart of `connector-detail.js`). **Granting is user-side, exactly like a connector grant**: the checkboxes live in the **Plugins** section of `#users/{id}` (`users-page.js`), right below that person's connectors, and the plugin's own page keeps only a read-only roster of who holds it, linking there. The question an admin asks is "what may this person use", and answering it plugin-by-plugin meant opening every plugin in turn; one write path also means the two surfaces cannot disagree. Unlike an MCP grant — which gates a runtime snapshotted at login and so needs a synchronous revoke — a plugin grant is re-read from `plugin_access` on every request that depends on it (sidebar pages, `/plugins/mine`, and each inbound channel message: Telegram checks it per message), so a revoke lands with no push and nothing on the bus. Binding-managed plugins (`Plugin::manages_own_access`, e.g. mobile-connector) are absent from the user-side list and rejected by its writer — a box that controls nothing is worse than no box. There is **no generic per-user plugin page**: a plugin with per-user settings (Telegram's pairing, Honcho's opt-in) hosts them in its own sidebar page via `Plugin::web_pages()`, like mobile-connector. Enable/disable + instance config + access grants are gated by the `plugin.manage` capability (admin-only by construction). Visibility is **opt-in**: a row in `plugin_access(plugin_id, user_id)` grants a user sight of an enabled plugin (`plugin_id` is bare TEXT, never a FK — a `plugins` row exists only after the first toggle). Per-user values are stored in `plugin_user_configs` (**admin-readable system.db — never secrets**) and applied through the `Plugin::update_user_config` hook, whose default just stores the blob via the `PluginUserConfigApi` on `PluginContext.user_config`. Telegram is the reference impl: its pairing page (a `web_pages()` fragment with no backend of its own) reads the `{linked, chat_id}` status blob from `GET /api/plugins/mine` and submits the code through `PUT /api/plugins/{id}/my-config`; the override turns it into a `chat_id → user_id` binding (same write path as the `telegram_pairing` tool). Endpoints: admin `GET/PUT /api/plugins[/{id}]`, `GET /api/plugins/{id}/access` (read-only roster) + **`GET/PUT /api/users/{id}/plugins`** (the grant write path, the twin of `/api/users/{id}/connectors`); user `GET /api/plugins/mine` + `PUT /api/plugins/{id}/my-config`. +**Plugin visibility & per-user config.** The admin surface is `#plugins` (`plugin-catalog.js`), a status board — one card per plugin with an enable toggle + health dot + a Configure button — plus `#plugin-detail?id=` (`plugin-detail.js`), which holds the instance-config form for one plugin (the plugin counterpart of `connector-detail.js`). **Granting is user-side, exactly like a connector grant**: the checkboxes live in the **Plugins** section of `#users/{id}` (`users-page.js`), right below that person's connectors, and the plugin's own page keeps only a read-only roster of who holds it, linking there. The question an admin asks is "what may this person use", and answering it plugin-by-plugin meant opening every plugin in turn; one write path also means the two surfaces cannot disagree. Unlike an MCP grant — which gates a runtime snapshotted at login and so needs a synchronous revoke — a plugin grant is re-read from `plugin_access` on every request that depends on it (sidebar pages, `/plugins/mine`, and each inbound channel message: Telegram checks it per message), so a revoke lands with no push and nothing on the bus. Binding-managed plugins (`Plugin::manages_own_access`, e.g. mobile-connector) are absent from the user-side list and rejected by its writer — a box that controls nothing is worse than no box. There is **no generic per-user plugin page**: a plugin with per-user settings (Telegram's pairing, Honcho's opt-in) hosts them in its own sidebar page via `Plugin::web_pages()`, like mobile-connector. Enable/disable + instance config + access grants are gated by the `plugin.manage` capability (admin-only by construction). Visibility is a row in `plugin_access(plugin_id, user_id)`, which grants a user sight of an enabled plugin (`plugin_id` is bare TEXT, never a FK — a `plugins` row exists only after the first toggle); the table is deny-by-default but the rows are **written for you at install time** — see the default-access section below. Per-user values are stored in `plugin_user_configs` (**admin-readable system.db — never secrets**) and applied through the `Plugin::update_user_config` hook, whose default just stores the blob via the `PluginUserConfigApi` on `PluginContext.user_config`. Telegram is the reference impl: its pairing page (a `web_pages()` fragment with no backend of its own) reads the `{linked, chat_id}` status blob from `GET /api/plugins/mine` and submits the code through `PUT /api/plugins/{id}/my-config`; the override turns it into a `chat_id → user_id` binding (same write path as the `telegram_pairing` tool). Endpoints: admin `GET/PUT /api/plugins[/{id}]`, `GET /api/plugins/{id}/access` (read-only roster) + **`GET/PUT /api/users/{id}/plugins`** (the grant write path, the twin of `/api/users/{id}/connectors`); user `GET /api/plugins/mine` + `PUT /api/plugins/{id}/my-config`. **Plugin HTTP routes & web pages.** Every plugin's `http_router()` mounts at boot under `/api/plugin//` — **enabled or not**: two shared gates wrap each router (`require_auth`, then `guard::plugin_enabled_gate`, which re-checks the DB flag per request and answers 404 while disabled), so enable/disable serves/stops routes immediately with no restart, and plugin responses carry `Cache-Control: no-cache`. The router contract: cheap and safe to build pre-start, handlers tolerant of the not-running state (resolve runtime state per request through a shared cell, as mobile-connector does). A plugin may also contribute **frontend pages** via `Plugin::web_pages()` (`PluginPage { page_id, title, icon, entry, admin_only, priority }`): `GET /api/plugins/pages` returns the caller's visible pages (admin: all; others: non-`admin_only` pages of granted, enabled plugins) with `entry_url` resolved, and the sidebar renders them as menu entries routed `#plugin//`. A single `` (`web/components/plugin-page-host.js`) dynamic-imports the fragment ES module the plugin serves from its own router, registers its default-exported HTMLElement class, and mounts it with the `plugin-id` attribute — the fragment talks to its backend only through `/api/plugin//…` and runs with full session privileges (plugins are trusted: they ship in the binary). The frontend knows nothing about plugin page contents or behavior. @@ -144,7 +144,7 @@ Schema is greenfield (no migrations, §0), but a purely **additive** column land `system.db` still gets **both** bucket functions — but no longer because the migration is unstarted. It gets the owner schema because it *is* the owner of **shared** memory (`memory_docs`) plus, for now, the globally-scoped `secrets` (`SecretsStore` is built on the system pool and shared by reference into every `UserContext`; the global runtime's *config* now lives in the registry table `mcp_global_servers`, and per-user connector config in each user's owner `mcp_user_servers`). The global runtime no longer writes `mcp_events` there: notification persistence is an explicit `McpManager::new` argument (`EventLog::{Persist,Discard}`), `Discard` for the ownerless global runtime and `Persist` for each per-user one, because an event belongs to whoever it happened to and its only reader (event triage) is per-user. Every *other* owner table is created there but never written to anymore — the global owner-bound managers that would write them (chat/jobs/etc.) are inert (see "Current state"). Fully dropping `create_owner_tables` from `system.db` is blocked on the §4 scope decision for secrets, not on call-site migration. -`users` (`crates/skald-core/src/db/users.rs`) holds the directory plus auth material. It lives in the system DB, which the box owner can read, so it must never store anything that derives a user's key. `Credentials` is an enum mirroring the table's `CHECK`: an encrypted user carries a **wrapped DEK** (whose AEAD tag *is* the password verifier — hence no `password_hash`); a cleartext user carries an ordinary verifier, or none. `User` is deliberately not `Serialize` and its `Debug` redacts key material — use `User::summary()` for anything leaving the process. `role_id` references `roles(id)` (the `roles` table is now seeded before `users` in `create_registry_tables`). A nullable `locale` column (additive via `ensure_column`) holds the per-user UI language override; role-driven conventions live in the free-form `roles.attrs` JSON — never new columns per attribute — parsed at a **single point** by the typed `db::roles::RoleAttrs` (`ui_mode`, `permission_groups`, `chat_agent`): `ui_mode` (see the frontend section) plus the role's **security-group set** (`roles.permission_group` = the default group, `attrs.permission_groups` = additional allowed groups; `Role::effective_groups()` = the union, `roles::role_allows_group()` gates it with `admin` short-circuiting to all). See the security-group picker in the frontend section. The role's **default entry (chat) agent** is `attrs.chat_agent` — the neutral `chat`-type agent members of the role land on (§0.1: data, not an enum). Resolved by `roles::default_chat_agent_for_user(registry_pool, user_id)` — the single seam behind both the per-user `ChatHub`'s `default_agent` (snapshotted at login in `UserContextFactory::build`, like fs/MCP access, so **every** session-creation path — explicit `provision_session`, lazy WS `get_or_create_session`, notify — honors it) and `provisioning_for_source`'s non-project branch. Falls back to `agents::DEFAULT_CHAT_AGENT` (`"assistant"`, the renamed former `main`) when unset. Seeded: `admin`/`member` → `assistant`, `children` → `kid` (Companion). A per-user override is future work, layering on top in the same resolver. The stack **root frame** is created with the session's own `agent_id` (not a literal) — `config.agent_id` (from the frame) drives which prompt runs, so a wrong id there silently runs the wrong agent. The admin-managed **directory profile** lives in three more additive columns — `birthdate` (ISO `YYYY-MM-DD`), `sex` (free text), `notes` (admin-authored) — edited only from the Users admin page (`set_directory_fields`; validation — real non-future date, length caps — lives in the `users_mgmt` API, not the db layer) and rendered into agent prompts by the `__USER_PROFILE__` substitution (see above). They are directory metadata written *by* the admin *about* the user, so the registry is their honest home under the §2 threat model. +`users` (`crates/skald-core/src/db/users.rs`) holds the directory plus auth material. It lives in the system DB, which the box owner can read, so it must never store anything that derives a user's key. `Credentials` is an enum mirroring the table's `CHECK`: an encrypted user carries a **wrapped DEK** (whose AEAD tag *is* the password verifier — hence no `password_hash`); a cleartext user carries an ordinary verifier, or none. `User` is deliberately not `Serialize` and its `Debug` redacts key material — use `User::summary()` for anything leaving the process. `role_id` references `roles(id)` (the `roles` table is now seeded before `users` in `create_registry_tables`). A nullable `locale` column (additive via `ensure_column`) holds the per-user UI language override; role-driven conventions live in the free-form `roles.attrs` JSON — never new columns per attribute — parsed at a **single point** by the typed `db::roles::RoleAttrs` (`ui_mode`, `permission_groups`, `chat_agent`, `auto_grant` — the last one being why that struct's `Default` is hand-written, see the default-access section): `ui_mode` (see the frontend section) plus the role's **security-group set** (`roles.permission_group` = the default group, `attrs.permission_groups` = additional allowed groups; `Role::effective_groups()` = the union, `roles::role_allows_group()` gates it with `admin` short-circuiting to all). See the security-group picker in the frontend section. The role's **default entry (chat) agent** is `attrs.chat_agent` — the neutral `chat`-type agent members of the role land on (§0.1: data, not an enum). Resolved by `roles::default_chat_agent_for_user(registry_pool, user_id)` — the single seam behind both the per-user `ChatHub`'s `default_agent` (snapshotted at login in `UserContextFactory::build`, like fs/MCP access, so **every** session-creation path — explicit `provision_session`, lazy WS `get_or_create_session`, notify — honors it) and `provisioning_for_source`'s non-project branch. Falls back to `agents::DEFAULT_CHAT_AGENT` (`"assistant"`, the renamed former `main`) when unset. Seeded: `admin`/`member` → `assistant`, `children` → `kid` (Companion). A per-user override is future work, layering on top in the same resolver. The stack **root frame** is created with the session's own `agent_id` (not a literal) — `config.agent_id` (from the frame) drives which prompt runs, so a wrong id there silently runs the wrong agent. The admin-managed **directory profile** lives in three more additive columns — `birthdate` (ISO `YYYY-MM-DD`), `sex` (free text), `notes` (admin-authored) — edited only from the Users admin page (`set_directory_fields`; validation — real non-future date, length caps — lives in the `users_mgmt` API, not the db layer) and rendered into agent prompts by the `__USER_PROFILE__` substitution (see above). They are directory metadata written *by* the admin *about* the user, so the registry is their honest home under the §2 threat model. ## Filesystem & containers (blueprint §6) @@ -217,6 +217,25 @@ For a per-user connector whose credential is produced by **pairing** (`auth.type **Deferred:** SSH and other §15 device kinds (would reuse the `login_status` contract), `deliver.as=file`, and non-Google OAuth providers are unimplemented paths that error clearly rather than half-work. No boot seed of catalog presets; the admin populates the catalog from the Marketplace. +## Default access — the grant tables are deny-by-default, but the rows are written for you + +`plugin_access`, `mcp_global_access` and `mcp_catalog_access` still mean exactly what they meant: **a row is access, its absence is none, every read fails closed**. What changed is who writes the rows. Installing something used to leave it granted to nobody, so the admin then walked the user list; now `db::access_defaults` grants it to the household at the moment of installation and the admin's remaining job is *removal*. + +**The default is materialized, never evaluated.** The tempting alternative — leave the junctions lazy and answer each check as `COALESCE(grant.allowed, object.grant_by_default)` with signed rows for exceptions — needs no seeding but costs two things worth more. The checkbox loses a state (an unticked box would mean either "denied" or "inheriting", indistinguishable to the admin), and "who has what" stops being one query: the gate, the plugin roster and the user checklist all read the same junction today, and `plugin_access.plugin_id` is bare TEXT with no `plugins` row to join a default against. So the default is applied at exactly **two moments** and never again: + +| moment | seam | what fires | +| ---- | ---- | ---- | +| an object is **created** | `access_defaults::seed_new_object` | `PluginManager::update_config` (first toggle — the `plugins` row's birth), `mcp::global_enable`, `mcp::catalog_upsert`, `marketplace` install | +| a user is **created** | `access_defaults::seed_new_user` | `UserManager::register_user` — in the core, so no future user-creation endpoint can forget it | + +**Not on enable/disable**, and that is the load-bearing part: re-enabling a plugin must never resurrect a grant the admin took away, so the trigger is the row's *birth*, not its flag. Every call site therefore checks existence **before** its upsert (`is_new_row` / `is_new_server` / `is_new_entry`) — a re-install or an edit seeds nothing. Seeding is additive-only and idempotent on the PK, which is why every call site is best-effort (a `warn!`, never a failed request): a grant that did not get written is fixable from the user's page, and nothing here can ever widen further than the two moments allow. + +**Who is included is a role attribute, not a role id** (§0.1): `roles.attrs.auto_grant`, parsed by `RoleAttrs` like everything else there. It defaults to **`true`** — hence the hand-written `impl Default for RoleAttrs`, since a derived one would give `false` and silently invert the feature for every role predating the attribute. The seeded `children` preset sets it to `false`, which is the whole reason the attribute exists. `admin` answers `false` too, but as a *skip*, not a denial: admins hold everything implicitly (`plugin_access::effective_access` short-circuits), so rows for them would only be noise in every roster. Editable in the role editor (`roles-page.js`, which persists only the opt-out). + +**Per-object opt-out** is `grant_by_default` on `plugins` / `mcp_global_servers` / `mcp_catalog` (additive via `ensure_column`, default 1). One thing sets it today: a binding-managed plugin (`Plugin::manages_own_access`, mobile-connector) is marked `0` at row creation, because it never reads `plugin_access` and rows for it would make its roster claim an audience that means nothing. There is no UI for the flag yet — `access_defaults::set_grant_by_default` is the seam when one is wanted. Changing it is deliberately **not** retroactive in either direction. + +**A role change does not re-seed.** Promoting a child to an adult role leaves their grants as they were; the admin ticks the boxes once on that person's page. Deliberate: the reverse (demotion) would then have to *revoke*, and a revocation that fires as a side effect of an unrelated edit is exactly the class of surprise the two-moment rule exists to avoid. + ## System agents (event triage, memory lints) A **system agent** runs on a user's behalf without being asked. There are three — event triage (the background event processor) and the two memory lints — behind **one** scheduler, and the machinery is deliberately shaped so a fourth is a trait impl plus one line in a registry. diff --git a/crates/skald-core/src/db/access_defaults.rs b/crates/skald-core/src/db/access_defaults.rs new file mode 100644 index 0000000..22e7b25 --- /dev/null +++ b/crates/skald-core/src/db/access_defaults.rs @@ -0,0 +1,380 @@ +//! Default access: who a newly-installed plugin/connector reaches, and what a +//! newly-created user starts out holding. +//! +//! The three grant tables ([`plugin_access`], [`mcp_global_access`], +//! [`mcp_catalog_access`]) are **deny-by-default and stay that way**: a row means +//! access, its absence means none, and every read fails closed. What changed is +//! not the semantics of the tables but *who writes the rows and when* — the admin +//! no longer has to grant a plugin person by person after enabling it. +//! +//! ## Why the default is materialized rather than evaluated +//! +//! The tempting alternative is to leave the tables lazy and answer each check as +//! `COALESCE(grant.allowed, object.grant_by_default)`, with signed rows recording +//! exceptions. It needs no seeding, but it costs two things worth more: +//! +//! - **The checkbox loses a state.** With signed exceptions an unticked box on the +//! user's page means either "denied" or "just following the default", and the +//! admin cannot see which. Materialized, a tick is a row and nothing else. +//! - **"Who has what" stops being one query.** The gate, the plugin's roster and +//! the user's checklist all read the same junction today; under lazy evaluation +//! each would have to recompose default + exception, and `plugin_access.plugin_id` +//! is bare TEXT with no `plugins` row to join against (see that module's header). +//! +//! So the default is applied at exactly **two moments**, and never again: +//! +//! | moment | what happens | +//! |---|---| +//! | an object is **created** (plugin first toggled, global connector enabled, catalog entry installed) | [`seed_new_object`] grants it to every auto-grant user | +//! | a user is **created** | [`seed_new_user`] grants them every default-on object | +//! +//! Deliberately *not* on enable/disable: re-enabling a plugin must not resurrect a +//! grant the admin took away, so the trigger is the row's birth, not its flag. +//! +//! ## Who counts as an auto-grant user +//! +//! The role decides, through `roles.attrs.auto_grant` (§0.1: an attribute, never a +//! hardcoded role id). It defaults to `true`, so the open behaviour needs no +//! configuration; the seeded `children` preset sets it to `false`, which is the +//! reason the attribute exists — an admin installing a connector at 11pm should not +//! be silently handing it to a minor. Admins are skipped: they already hold every +//! plugin and connector implicitly, so a row for them would be noise. +//! +//! Seeding **only ever adds** access. Nothing here can revoke, which is why it is +//! safe to run best-effort from a creation path (a failure means a missing +//! convenience grant, never an unintended one). + +use anyhow::Result; +use sqlx::SqlitePool; + +use super::{mcp_catalog_access, mcp_global_access, plugin_access, roles}; + +/// One grantable object, in whichever of the three junctions owns it. +#[derive(Debug, Clone, Copy)] +pub enum Grantable<'a> { + /// A plugin id (`plugins.id`). + Plugin(&'a str), + /// A globally-active connector (`mcp_global_servers.id`). + GlobalServer(i64), + /// A `per_user` catalog entry, by name (`mcp_catalog.name`). + Catalog(&'a str), +} + +// ── Who ────────────────────────────────────────────────────────────────────── + +/// Whether a role's members are auto-granted new objects. `admin` is `false`: +/// not a denial — admins hold everything implicitly, so seeding rows for them +/// would only add noise to every roster. An unknown role grants nothing. +pub async fn role_auto_grants(pool: &SqlitePool, role_id: &str) -> Result { + if role_id == roles::ADMIN_ROLE_ID { + return Ok(false); + } + match roles::get(pool, role_id).await? { + Some(role) => Ok(role.attrs_parsed().auto_grant), + None => Ok(false), + } +} + +/// The ids of the users a newly-created object is granted to. +/// +/// Deactivated users are included: `active = 0` gates logging in, not what the +/// directory says a person may use, and skipping them would leave a hole the day +/// they are switched back on. +pub async fn auto_grant_user_ids(pool: &SqlitePool) -> Result> { + let rows = + sqlx::query_as::<_, (String, String)>("SELECT id, role_id FROM users ORDER BY id") + .fetch_all(pool) + .await?; + + // Resolve each distinct role once — a household has a handful of roles and + // potentially many more users. + let mut verdict: std::collections::HashMap = std::collections::HashMap::new(); + let mut out = Vec::new(); + for (user_id, role_id) in rows { + let allowed = match verdict.get(&role_id) { + Some(v) => *v, + None => { + let v = role_auto_grants(pool, &role_id).await?; + verdict.insert(role_id.clone(), v); + v + } + }; + if allowed { + out.push(user_id); + } + } + Ok(out) +} + +// ── The two seeding moments ────────────────────────────────────────────────── + +/// Grants a **newly-created** object to every auto-grant user. A no-op when the +/// object opts out of the default (`grant_by_default = 0`) or has vanished. +/// Returns how many grants were written. +/// +/// Call it once, right after the row is inserted — never on a re-enable. +pub async fn seed_new_object(pool: &SqlitePool, target: Grantable<'_>) -> Result { + if !object_grants_by_default(pool, target).await? { + return Ok(0); + } + let users = auto_grant_user_ids(pool).await?; + for user_id in &users { + match target { + Grantable::Plugin(id) => plugin_access::grant(pool, id, user_id).await?, + Grantable::GlobalServer(id) => mcp_global_access::grant(pool, id, user_id).await?, + Grantable::Catalog(name) => mcp_catalog_access::grant(pool, name, user_id).await?, + } + } + Ok(users.len()) +} + +/// Grants a **newly-created** user every object that is on by default, so a new +/// member arrives with the same tools everyone else already has. A no-op for a +/// role that opts out (and for `admin`, who needs no rows). Returns how many +/// grants were written. +pub async fn seed_new_user(pool: &SqlitePool, user_id: &str, role_id: &str) -> Result { + if !role_auto_grants(pool, role_id).await? { + return Ok(0); + } + let mut n = 0; + + let plugins = sqlx::query_as::<_, (String,)>( + "SELECT id FROM plugins WHERE grant_by_default = 1 ORDER BY id", + ) + .fetch_all(pool) + .await?; + for (id,) in plugins { + plugin_access::grant(pool, &id, user_id).await?; + n += 1; + } + + let globals = sqlx::query_as::<_, (i64,)>( + "SELECT id FROM mcp_global_servers WHERE grant_by_default = 1 ORDER BY id", + ) + .fetch_all(pool) + .await?; + for (id,) in globals { + mcp_global_access::grant(pool, id, user_id).await?; + n += 1; + } + + // Only `per_user` entries: `mcp_catalog_access` gates activation, and a + // `global` entry is never activated by a user — a row for one would be dead. + let catalog = sqlx::query_as::<_, (String,)>( + "SELECT name FROM mcp_catalog + WHERE grant_by_default = 1 AND scope = 'per_user' + ORDER BY name", + ) + .fetch_all(pool) + .await?; + for (name,) in catalog { + mcp_catalog_access::grant(pool, &name, user_id).await?; + n += 1; + } + + Ok(n) +} + +// ── Per-object opt-out ─────────────────────────────────────────────────────── + +/// Reads the object's own `grant_by_default`. A missing row answers `false`: the +/// object was deleted between insert and seed, and granting it would be a dangling +/// row in a junction whose FK does not always exist to catch it. +async fn object_grants_by_default(pool: &SqlitePool, target: Grantable<'_>) -> Result { + let flag: Option<(i64,)> = match target { + Grantable::Plugin(id) => { + sqlx::query_as("SELECT grant_by_default FROM plugins WHERE id = ?") + .bind(id) + .fetch_optional(pool) + .await? + } + Grantable::GlobalServer(id) => { + sqlx::query_as("SELECT grant_by_default FROM mcp_global_servers WHERE id = ?") + .bind(id) + .fetch_optional(pool) + .await? + } + // The scope guard lives here rather than in every call site: only a + // `per_user` entry is ever activated by a user. + Grantable::Catalog(name) => { + sqlx::query_as( + "SELECT grant_by_default FROM mcp_catalog WHERE name = ? AND scope = 'per_user'", + ) + .bind(name) + .fetch_optional(pool) + .await? + } + }; + Ok(matches!(flag, Some((1,)))) +} + +/// Sets whether an object is auto-granted from now on. Changing it is **not** +/// retroactive in either direction — existing grants are the admin's, and the two +/// seeding moments are the only writers. +pub async fn set_grant_by_default( + pool: &SqlitePool, + target: Grantable<'_>, + enabled: bool, +) -> Result<()> { + let on = enabled as i64; + match target { + Grantable::Plugin(id) => { + sqlx::query("UPDATE plugins SET grant_by_default = ? WHERE id = ?") + .bind(on).bind(id).execute(pool).await?; + } + Grantable::GlobalServer(id) => { + sqlx::query("UPDATE mcp_global_servers SET grant_by_default = ? WHERE id = ?") + .bind(on).bind(id).execute(pool).await?; + } + Grantable::Catalog(name) => { + sqlx::query("UPDATE mcp_catalog SET grant_by_default = ? WHERE name = ?") + .bind(on).bind(name).execute(pool).await?; + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + /// A registry database holding an admin, an adult member and a child, on the + /// three roles the family profile seeds. FK enforcement is on, so roles exist + /// before users and catalog entries before grants. + async fn fixture(tag: &str) -> (SqlitePool, PathBuf) { + use std::sync::atomic::{AtomicU64, Ordering}; + static SEQ: AtomicU64 = AtomicU64::new(0); + let n = SEQ.fetch_add(1, Ordering::Relaxed); + let dir = std::env::temp_dir() + .join(format!("skald-accessdefaults-{}-{tag}-{n}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let pool = crate::db::init_system_pool(&dir.join("system.db").to_string_lossy()) + .await + .unwrap(); + + // `member` leaves auto_grant unset — it must still behave as `true`. + roles::insert(&pool, "member", "Member", "default", Some(r#"{"ui_mode":"full"}"#)) + .await.unwrap(); + roles::insert(&pool, "children", "Children", "default", + Some(r#"{"ui_mode":"simple","auto_grant":false}"#)).await.unwrap(); + for (id, name, role) in [ + ("u_admin", "ada", "admin"), + ("u_adult", "bob", "member"), + ("u_kid", "kim", "children"), + ] { + sqlx::query("INSERT INTO users (id, username, role_id, encrypted) VALUES (?, ?, ?, 0)") + .bind(id).bind(name).bind(role).execute(&pool).await.unwrap(); + } + (pool, dir) + } + + async fn add_plugin(pool: &SqlitePool, id: &str) { + crate::db::plugins::upsert(pool, id, true, "{}").await.unwrap(); + } + + async fn add_catalog(pool: &SqlitePool, name: &str, scope: &str) { + sqlx::query("INSERT INTO mcp_catalog (name, scope, source) VALUES (?, ?, 'remote')") + .bind(name).bind(scope).execute(pool).await.unwrap(); + } + + async fn add_global(pool: &SqlitePool, name: &str) -> i64 { + sqlx::query("INSERT INTO mcp_global_servers (name) VALUES (?)") + .bind(name).execute(pool).await.unwrap().last_insert_rowid() + } + + #[tokio::test] + async fn a_new_object_reaches_auto_grant_roles_only() { + let (pool, dir) = fixture("object").await; + + add_plugin(&pool, "telegram").await; + let n = seed_new_object(&pool, Grantable::Plugin("telegram")).await.unwrap(); + + // The adult gets it; the child's role opted out and the admin needs no row. + assert_eq!(n, 1); + assert!(plugin_access::has_access(&pool, "telegram", "u_adult").await.unwrap()); + assert!(!plugin_access::has_access(&pool, "telegram", "u_kid").await.unwrap()); + assert!(!plugin_access::has_access(&pool, "telegram", "u_admin").await.unwrap()); + + // The same for a global connector and a per-user catalog entry. + let sid = add_global(&pool, "tavily").await; + seed_new_object(&pool, Grantable::GlobalServer(sid)).await.unwrap(); + assert!(mcp_global_access::has_access(&pool, sid, "u_adult").await.unwrap()); + assert!(!mcp_global_access::has_access(&pool, sid, "u_kid").await.unwrap()); + + add_catalog(&pool, "gmail", "per_user").await; + seed_new_object(&pool, Grantable::Catalog("gmail")).await.unwrap(); + assert!(mcp_catalog_access::has_access(&pool, "gmail", "u_adult").await.unwrap()); + assert!(!mcp_catalog_access::has_access(&pool, "gmail", "u_kid").await.unwrap()); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[tokio::test] + async fn a_new_user_starts_with_every_default_on_object() { + let (pool, dir) = fixture("user").await; + add_plugin(&pool, "telegram").await; + let sid = add_global(&pool, "tavily").await; + add_catalog(&pool, "gmail", "per_user").await; + // A `global` catalog entry is never user-activated — no row for it. + add_catalog(&pool, "websearch", "global").await; + + // An adult joining later lands on the same set as everyone else. + sqlx::query("INSERT INTO users (id, username, role_id, encrypted) VALUES ('u_new', 'eve', 'member', 0)") + .execute(&pool).await.unwrap(); + let n = seed_new_user(&pool, "u_new", "member").await.unwrap(); + assert_eq!(n, 3); + assert!(plugin_access::has_access(&pool, "telegram", "u_new").await.unwrap()); + assert!(mcp_global_access::has_access(&pool, sid, "u_new").await.unwrap()); + assert!(mcp_catalog_access::has_access(&pool, "gmail", "u_new").await.unwrap()); + assert!(!mcp_catalog_access::has_access(&pool, "websearch", "u_new").await.unwrap()); + + // A child joining gets nothing, and neither does a new admin. + assert_eq!(seed_new_user(&pool, "u_kid", "children").await.unwrap(), 0); + assert_eq!(seed_new_user(&pool, "u_admin", "admin").await.unwrap(), 0); + assert!(!plugin_access::has_access(&pool, "telegram", "u_kid").await.unwrap()); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[tokio::test] + async fn an_opted_out_object_seeds_nobody_in_either_direction() { + let (pool, dir) = fixture("optout").await; + + add_plugin(&pool, "mobile-connector").await; + set_grant_by_default(&pool, Grantable::Plugin("mobile-connector"), false).await.unwrap(); + + assert_eq!(seed_new_object(&pool, Grantable::Plugin("mobile-connector")).await.unwrap(), 0); + assert!(!plugin_access::has_access(&pool, "mobile-connector", "u_adult").await.unwrap()); + + // ...and it is skipped when a new user is seeded, too. + sqlx::query("INSERT INTO users (id, username, role_id, encrypted) VALUES ('u_new', 'eve', 'member', 0)") + .execute(&pool).await.unwrap(); + assert_eq!(seed_new_user(&pool, "u_new", "member").await.unwrap(), 0); + + // An object that vanished between insert and seed is a no-op, not an error. + assert_eq!(seed_new_object(&pool, Grantable::Plugin("ghost")).await.unwrap(), 0); + assert_eq!(seed_new_object(&pool, Grantable::GlobalServer(4242)).await.unwrap(), 0); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[tokio::test] + async fn seeding_is_idempotent_and_never_revokes() { + let (pool, dir) = fixture("idempotent").await; + add_plugin(&pool, "telegram").await; + + seed_new_object(&pool, Grantable::Plugin("telegram")).await.unwrap(); + // The admin takes it away from the one person who had it... + plugin_access::revoke(&pool, "telegram", "u_adult").await.unwrap(); + // ...and re-running the seed is what a re-enable must never do. The call + // site guards that (it only fires on row creation); this pins the fact + // that seeding itself is purely additive and idempotent on the PK. + seed_new_object(&pool, Grantable::Plugin("telegram")).await.unwrap(); + assert!(plugin_access::has_access(&pool, "telegram", "u_adult").await.unwrap()); + assert_eq!(plugin_access::users_for_plugin(&pool, "telegram").await.unwrap(), vec!["u_adult"]); + + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/skald-core/src/db/mod.rs b/crates/skald-core/src/db/mod.rs index 12d7b3d..5c26d83 100644 --- a/crates/skald-core/src/db/mod.rs +++ b/crates/skald-core/src/db/mod.rs @@ -1,3 +1,4 @@ +pub mod access_defaults; pub mod activated_tools; pub mod approval_rules; pub mod project_members; @@ -275,14 +276,19 @@ async fn create_registry_tables(pool: &SqlitePool) -> Result<()> { sqlx::query( "CREATE TABLE IF NOT EXISTS plugins ( - id TEXT PRIMARY KEY, - enabled INTEGER NOT NULL DEFAULT 0, - config TEXT NOT NULL DEFAULT '{}', - created_at TEXT NOT NULL DEFAULT (datetime('now')) + id TEXT PRIMARY KEY, + enabled INTEGER NOT NULL DEFAULT 0, + config TEXT NOT NULL DEFAULT '{}', + grant_by_default INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')) )", ) .execute(pool) .await?; + // See `db::access_defaults`: the default audience of a newly-created object. + // Additive so an existing box keeps its rows — and inherits the open default, + // which only matters for *future* users (existing ones keep their grants). + ensure_column(pool, "plugins", "grant_by_default", "INTEGER NOT NULL DEFAULT 1").await?; // Which users may see/configure each plugin. `plugin_id` is deliberately // NOT a foreign key to plugins.id: plugin identity comes from compiled @@ -561,11 +567,13 @@ async fn create_registry_tables(pool: &SqlitePool) -> Result<()> { version INTEGER, -- marketplace build number: the update-comparison key version_string TEXT, -- semver, display only version_release_date TEXT, -- ISO date, display only + grant_by_default INTEGER NOT NULL DEFAULT 1, -- auto-grant to auto-grant roles (db::access_defaults) created_at TEXT NOT NULL DEFAULT (datetime('now')) )", ) .execute(pool) .await?; + ensure_column(pool, "mcp_catalog", "grant_by_default", "INTEGER NOT NULL DEFAULT 1").await?; // OAuth columns are additive (§15) — reach an already-created catalog in place. ensure_column(pool, "mcp_catalog", "oauth_provider", "TEXT").await?; ensure_column(pool, "mcp_catalog", "oauth_scopes_json", "TEXT").await?; @@ -598,11 +606,13 @@ async fn create_registry_tables(pool: &SqlitePool) -> Result<()> { friendly_name TEXT, description TEXT, enabled INTEGER NOT NULL DEFAULT 1, + grant_by_default INTEGER NOT NULL DEFAULT 1, -- auto-grant to auto-grant roles (db::access_defaults) created_at TEXT NOT NULL DEFAULT (datetime('now')) )", ) .execute(pool) .await?; + ensure_column(pool, "mcp_global_servers", "grant_by_default", "INTEGER NOT NULL DEFAULT 1").await?; // Which users may use each globally-active connector (§15 per-user access). // Mirrors `shared_folder_members`: both FKs are registry→registry, allowed. diff --git a/crates/skald-core/src/db/roles.rs b/crates/skald-core/src/db/roles.rs index 29c9be5..d001540 100644 --- a/crates/skald-core/src/db/roles.rs +++ b/crates/skald-core/src/db/roles.rs @@ -45,8 +45,9 @@ impl UiMode { /// Typed parse of `roles.attrs`. The **single** place that reads the attrs JSON, so /// scattered `serde_json::Value.get(...)` calls don't drift. Tolerant: any parse -/// error or missing key yields defaults. -#[derive(Debug, Clone, Default, Deserialize)] +/// error or missing key yields defaults — see the hand-written [`Default`] below, +/// which is what the container-level `#[serde(default)]` fills missing fields from. +#[derive(Debug, Clone, Deserialize)] #[serde(default)] pub struct RoleAttrs { pub ui_mode: UiMode, @@ -60,6 +61,30 @@ pub struct RoleAttrs { /// attrs) falls back to [`crate::agents::DEFAULT_CHAT_AGENT`]. Data-driven, not an /// enum (§0.1): a future per-user override layers on top of this. pub chat_agent: Option, + /// Whether members of this role are **automatically granted** a plugin or + /// connector the admin installs (and, conversely, whether a newly-created + /// member starts with everything already installed). See + /// [`crate::db::access_defaults`] for the two seeding moments. + /// + /// Defaults to `true` — the open default the whole feature exists for — so a + /// role predating this attribute, or one whose attrs are malformed, behaves + /// like an adult member. A role that must stay opt-in (the seeded `children` + /// preset) says so explicitly. + pub auto_grant: bool, +} + +/// Hand-written rather than derived because `auto_grant` defaults to `true`: this +/// impl is the fallback for both a malformed `attrs` blob and any field missing +/// from a well-formed one. +impl Default for RoleAttrs { + fn default() -> Self { + RoleAttrs { + ui_mode: UiMode::default(), + permission_groups: Vec::new(), + chat_agent: None, + auto_grant: true, + } + } } impl RoleAttrs { @@ -245,11 +270,17 @@ mod tests { #[test] fn role_attrs_are_tolerant() { - // Missing → defaults. + // Missing → defaults. `auto_grant` is the one that defaults to `true`, so a + // role written before the attribute existed behaves like an adult member. let a = RoleAttrs::from_opt(&None); assert_eq!(a.ui_mode, UiMode::Full); assert!(a.permission_groups.is_empty()); assert!(a.chat_agent.is_none()); + assert!(a.auto_grant); + + // Present in a blob that omits it → still true; explicit `false` is honoured. + assert!(RoleAttrs::from_opt(&Some(r#"{"ui_mode":"simple"}"#.into())).auto_grant); + assert!(!RoleAttrs::from_opt(&Some(r#"{"auto_grant":false}"#.into())).auto_grant); // Populated. let a = RoleAttrs::from_opt(&Some( @@ -263,6 +294,7 @@ mod tests { let a = RoleAttrs::from_opt(&Some("not json".into())); assert_eq!(a.ui_mode, UiMode::Full); assert!(a.permission_groups.is_empty()); + assert!(a.auto_grant); } #[test] diff --git a/crates/skald-core/src/plugin/mod.rs b/crates/skald-core/src/plugin/mod.rs index d2b5265..0a4884f 100644 --- a/crates/skald-core/src/plugin/mod.rs +++ b/crates/skald-core/src/plugin/mod.rs @@ -20,6 +20,7 @@ use tokio::sync::Mutex; use tokio::time::timeout; use tracing::{error, info, warn}; +use crate::db::access_defaults::{self, Grantable}; use crate::db::{plugin_access, plugin_user_configs, plugins as db}; use crate::skald::Skald; @@ -327,7 +328,15 @@ impl PluginManager { pub async fn update_config(&self, id: &str, enabled: bool, config: Value) -> Result<()> { let plugin = self.find(id)?; let config_json = serde_json::to_string(&config)?; + // A `plugins` row is born on the first toggle, and that birth — not this + // or any later enable — is when the default audience is applied + // (`db::access_defaults`), so re-enabling never resurrects a grant the + // admin removed. + let is_new_row = db::get(&self.db, id).await?.is_none(); db::upsert(&self.db, id, enabled, &config_json).await?; + if is_new_row { + self.apply_default_access(&plugin).await; + } let skald = self.skald()?; plugin.reload(enabled, config, self.build_context(&skald)?).await?; self.known_state.lock().await @@ -336,6 +345,34 @@ impl PluginManager { Ok(()) } + /// Grants a just-installed plugin to everyone whose role auto-grants, so the + /// admin's next step is *removing* access rather than handing it out one + /// person at a time. + /// + /// Best-effort: the plugin row already landed, and a missing convenience grant + /// is fixable from the user's page — failing the whole enable over it would be + /// the worse outcome. Seeding can only ever add access, so a retry is safe. + /// + /// A binding-managed plugin (`manages_own_access` — mobile-connector) opts out + /// permanently: it never reads `plugin_access`, so rows for it would only make + /// its roster claim an audience that means nothing. + async fn apply_default_access(&self, plugin: &Arc) { + let id = plugin.id(); + if plugin.manages_own_access() { + if let Err(e) = + access_defaults::set_grant_by_default(&self.db, Grantable::Plugin(id), false).await + { + warn!(plugin = id, error = %e, "could not mark plugin as never auto-granted"); + } + return; + } + match access_defaults::seed_new_object(&self.db, Grantable::Plugin(id)).await { + Ok(0) => {} + Ok(n) => info!(plugin = id, users = n, "plugin granted to auto-grant users"), + Err(e) => warn!(plugin = id, error = %e, "default plugin grants failed (non-fatal)"), + } + } + /// Toggle only the enabled flag, keeping existing config. pub async fn toggle(&self, id: &str, enabled: bool) -> Result<()> { let row = db::get(&self.db, id).await? @@ -715,4 +752,47 @@ mod tests { .map(|p| (p.plugin_id.as_str(), p.page_id.as_str())).collect(); assert_eq!(got, vec![("gamma", "pairing")]); } + + /// The half of `update_config` that can run without a wired `Skald`: what a + /// plugin's first toggle hands out, and to whom. + #[tokio::test] + async fn a_new_plugin_is_granted_to_auto_grant_roles_but_never_binding_managed() { + let mut mgr = test_manager("default-access").await; + let alpha: Arc = Arc::new(FakePlugin { id: "alpha", pages: vec![], owns_access: false }); + let gamma: Arc = Arc::new(FakePlugin { id: "gamma", pages: vec![], owns_access: true }); + mgr.register_arc(Arc::clone(&alpha)); + mgr.register_arc(Arc::clone(&gamma)); + + crate::db::roles::insert(&mgr.db, "member", "Member", "default", None).await.unwrap(); + crate::db::roles::insert(&mgr.db, "children", "Children", "default", + Some(r#"{"auto_grant":false}"#)).await.unwrap(); + for (id, username, role) in [ + ("u_admin", "ada", "admin"), + ("u_adult", "bob", "member"), + ("u_kid", "kim", "children"), + ] { + sqlx::query("INSERT INTO users (id, username, role_id, encrypted) VALUES (?, ?, ?, 0)") + .bind(id).bind(username).bind(role).execute(&*mgr.db).await.unwrap(); + } + + // The row's birth is what `update_config` would have just written. + db::upsert(&mgr.db, "alpha", true, "{}").await.unwrap(); + mgr.apply_default_access(&alpha).await; + assert!(plugin_access::has_access(&mgr.db, "alpha", "u_adult").await.unwrap()); + assert!(!plugin_access::has_access(&mgr.db, "alpha", "u_kid").await.unwrap()); + // The admin holds it implicitly, so no row is written for them. + assert!(!plugin_access::has_access(&mgr.db, "alpha", "u_admin").await.unwrap()); + assert!(mgr.list_accessible("u_adult", false).await.unwrap().iter().any(|p| p.id == "alpha")); + + // A binding-managed plugin grants nobody and is marked to stay that way, + // so a later user creation skips it too. + db::upsert(&mgr.db, "gamma", true, "{}").await.unwrap(); + mgr.apply_default_access(&gamma).await; + assert!(plugin_access::users_for_plugin(&mgr.db, "gamma").await.unwrap().is_empty()); + sqlx::query("INSERT INTO users (id, username, role_id, encrypted) VALUES ('u_new', 'eve', 'member', 0)") + .execute(&*mgr.db).await.unwrap(); + crate::db::access_defaults::seed_new_user(&mgr.db, "u_new", "member").await.unwrap(); + assert!(plugin_access::has_access(&mgr.db, "alpha", "u_new").await.unwrap()); + assert!(!plugin_access::has_access(&mgr.db, "gamma", "u_new").await.unwrap()); + } } diff --git a/crates/skald-core/src/setup/mod.rs b/crates/skald-core/src/setup/mod.rs index d67eef0..3f3de60 100644 --- a/crates/skald-core/src/setup/mod.rs +++ b/crates/skald-core/src/setup/mod.rs @@ -45,14 +45,18 @@ pub fn seed_profiles() -> Vec { id: "member", label: "Member", permission_group: "default", - attrs: Some(r#"{"ui_mode":"full","chat_agent":"assistant"}"#), + attrs: Some(r#"{"ui_mode":"full","chat_agent":"assistant","auto_grant":true}"#), }, RoleSeed { id: "children", label: "Children", permission_group: "default", // `kid` = the Companion agent (its display name is copy, §0.1). - attrs: Some(r#"{"ui_mode":"simple","chat_agent":"kid"}"#), + // `auto_grant:false` is the whole reason the attribute exists: a + // connector the admin installs tonight must not reach this role + // until they say so (see `db::access_defaults`). Every other role + // omits it and gets the open default. + attrs: Some(r#"{"ui_mode":"simple","chat_agent":"kid","auto_grant":false}"#), }, ], }] @@ -146,6 +150,11 @@ mod tests { let children = db::roles::get(&pool, "children").await.unwrap().unwrap(); assert_eq!(children.attrs_parsed().ui_mode, UiMode::Simple); + // A connector the admin installs reaches the adults on its own and stops at + // the children (`db::access_defaults`) — the point of the preset. + assert!(member.attrs_parsed().auto_grant); + assert!(!children.attrs_parsed().auto_grant); + // The standard self-service capabilities were granted to a seeded role. assert!(db::role_capabilities::has( &pool, "member", db::role_capabilities::REGISTER_REMOTE, diff --git a/crates/skald-core/src/users/mod.rs b/crates/skald-core/src/users/mod.rs index 0462c58..6d6f198 100644 --- a/crates/skald-core/src/users/mod.rs +++ b/crates/skald-core/src/users/mod.rs @@ -340,6 +340,19 @@ impl UserManager { return Err(e); } + // A new member should arrive holding what the household already uses, + // rather than an empty account the admin has to walk the plugin and + // connector lists to furnish. Whether that happens at all is the role's + // call (`attrs.auto_grant`) — see `db::access_defaults`. + // + // Here rather than in the endpoint so no future user-creation path can + // forget it; non-fatal for the mirror-image reason that the memory + // scaffold above is: a missing convenience grant is fixable from the + // user's page, a half-registered account is not. + if let Err(e) = db::access_defaults::seed_new_user(&self.system, &id, role_id).await { + warn!(user = %id, error = %e, "default access grants failed (non-fatal)"); + } + info!(user = %id, %username, encrypted, "user registered"); Ok(id) } diff --git a/docs/access.md b/docs/access.md new file mode 100644 index 0000000..abede7d --- /dev/null +++ b/docs/access.md @@ -0,0 +1,40 @@ +# Who can use what: plugins, connectors and roles + +Plugins and connectors are installed once by the admin, for the whole instance. Whether a *given person* can use one is a separate question, answered by a **grant**. + +## The default is open + +When the admin installs something new — a plugin, a globally-shared connector, or a connector from the marketplace — it is **handed to everyone straight away**. The admin's remaining job is to take it away from whoever should not have it, not to hand it out one person at a time. + +The same applies in the other direction: a **new user** starts out holding everything the household already uses, so a new member does not arrive to an empty account. + +Two things are worth knowing about how this works, because they explain behaviour that would otherwise look surprising: + +- **It applies at installation, not at every switch-on.** Disabling a plugin and enabling it again does *not* re-grant it to people the admin removed it from. Their decision stands. +- **Removing access is normal and expected.** Access is taken away per person, from that person's own page: sidebar → **Users** → click the person → the **Connectors** and **Plugins** sections. Unticking a box there is the intended way to say "not for you", and nothing later puts it back. + +Admins never need a grant: they can use every enabled plugin and connector by construction. + +## Roles decide who is included + +Whether a role's members are included in that automatic hand-out is a property of the **role**, set in the role editor (sidebar → Roles → edit a role → **New plugins and connectors**): + +- **On** (the default) — anything the admin installs reaches these people immediately. This is what an adult member of the household normally wants. +- **Off** — these people only ever get what the admin explicitly gives them, one at a time, from their own page. The **Children** role ships with this switched off, and it is the reason the setting exists: a connector installed late at night should not silently become available to a child. + +Turning the switch on or off changes nothing about access that has already been granted — it only decides what happens the next time something is installed, or the next time a person is added to that role. + +Two consequences worth mentioning to a user who runs into them: + +- Someone whose role has the switch **off** will see nothing new appear, ever, until the admin ticks their box. That is working as intended, not a bug. +- Changing a person's role does **not** retroactively hand them everything installed so far. If a child is moved to an adult role, the admin still ticks the boxes on that person's page once. + +## What a grant actually does + +The three things being granted are not the same, and the difference matters when explaining it: + +- **A plugin grant** makes the plugin visible and usable for that person — its sidebar page, its tools, its channel (Telegram checks the grant on every incoming message). It is re-checked continuously, so removing it takes effect immediately. +- **A shared connector grant** (one the admin runs centrally, e.g. web search) puts that connector's tools in that person's assistant. +- **A per-user connector grant** (e.g. Gmail, WhatsApp) only authorizes the person to *set it up* — they still have to sign in with their own account. Nobody ever uses somebody else's credentials through a grant. + +See also: [index.md](index.md) for the plugin list, and each plugin's own page under [`plugins/`](plugins/). diff --git a/docs/index.md b/docs/index.md index 411e75c..7b1982d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -4,7 +4,7 @@ This folder is written for **you, the assistant**, not for the human directly. I Keep answers grounded in what's actually enabled and configured for this instance — check with the relevant tool (e.g. list installed/enabled plugins) rather than assuming everything described here is turned on. A feature documented here may not be enabled on this particular instance. -This index will grow over time. Right now it covers memory, projects, system agents and plugins; more sections (agents, connectors, security groups, shared folders…) will be added later. +This index will grow over time. Right now it covers memory, projects, system agents, access grants and plugins; more sections (agents, connectors, security groups, shared folders…) will be added later. ## Features @@ -14,6 +14,7 @@ This index will grow over time. Right now it covers memory, projects, system age | [projects.md](projects.md) | Projects: shared folders with their own assistant chat, a live file explorer, and member sharing | | [system-agents.md](system-agents.md) | Background agents that run on a schedule (event triage, the two memory lints): what they watch, why they only ever report, why a run can be skipped, and their settings | | [settings.md](settings.md) | The admin's Config page: interface language, the compaction model picker, debug mode | +| [access.md](access.md) | Who can use which plugin or connector: the open default, removing access per person, and the role switch that keeps children out of it | ## Plugins @@ -34,7 +35,7 @@ Plugins are optional add-ons an admin can enable and configure — extra voices, General plugin mechanics that apply to all of them: - An admin enables/disables and configures each plugin from the **Plugins** page (sidebar → Plugins, admin-only): one card per plugin, an enable toggle, and a **Configure** button opening its settings form. -- A plugin only becomes visible to a given user once the admin grants them access — being enabled instance-wide isn't enough by itself (Mobile Connector is the one exception: access there is the device-pairing itself, not a grant list). -- Access is granted **per person, from that person's own page**: sidebar → Users → click the user → the **Plugins** section, right below their Connectors. So "what may this person use?" is answered in one place, for plugins and connectors together. (The plugin's own page shows the reverse view — who currently holds it — but read-only.) Admins can use every enabled plugin without being granted anything. +- Enabling a plugin hands it to everyone straight away — except to roles that opt out of that (the Children role does). The admin then *removes* it from whoever should not have it, rather than granting it person by person. Full details in [access.md](access.md). (Mobile Connector is the one exception to the whole grant model: access there is the device-pairing itself, not a grant list.) +- Access is changed **per person, from that person's own page**: sidebar → Users → click the user → the **Plugins** section, right below their Connectors. So "what may this person use?" is answered in one place, for plugins and connectors together. (The plugin's own page shows the reverse view — who currently holds it — but read-only.) Admins can use every enabled plugin without being granted anything. - A plugin with **per-user** settings (e.g. Telegram's pairing code, Honcho's memory opt-in) gives each granted user its own dedicated **sidebar page** to manage them — separate from the admin's instance-wide config. - A plugin can add tools the assistant calls directly (e.g. `set_secret`, `telegram_pairing`), a dedicated sidebar page, or both. diff --git a/src/frontend/api/marketplace.rs b/src/frontend/api/marketplace.rs index cc0cf06..681ee12 100644 --- a/src/frontend/api/marketplace.rs +++ b/src/frontend/api/marketplace.rs @@ -38,6 +38,7 @@ use serde_json::{json, Value}; use sha2::{Digest, Sha256}; use tokio::sync::RwLock; +use skald_core::db::access_defaults; use skald_core::db::{mcp_catalog, role_capabilities}; use skald_core::skald::Skald; @@ -743,6 +744,10 @@ pub async fn install( let icon_small_path = installed_icon(h.entry.icon_small.as_deref(), &folder, &installed); let icon_large_path = installed_icon(h.entry.icon_large.as_deref(), &folder, &installed); + // An update or re-install keeps the audience the admin has curated since; only + // a first install applies the default one (`db::access_defaults`). + let is_new_entry = mcp_catalog::get_by_name(skald.db(), &h.entry.id).await?.is_none(); + let id = mcp_catalog::upsert( skald.db(), mcp_catalog::UpsertCatalog { @@ -795,6 +800,20 @@ pub async fn install( ) .await?; + // Authorize the standard audience to activate it, so an installed connector is + // usable by the household without a second pass on the Users page. Best-effort + // and additive: a failure leaves grants to be set by hand, never withdraws one. + if is_new_entry { + match access_defaults::seed_new_object( + skald.db(), + access_defaults::Grantable::Catalog(&h.entry.id), + ).await { + Ok(0) => {} + Ok(n) => tracing::info!(connector = %h.entry.id, users = n, "connector granted to auto-grant users"), + Err(e) => tracing::warn!(connector = %h.entry.id, error = %e, "default connector grants failed (non-fatal)"), + } + } + // Announce the (re)install so anything already running it catches up without // waiting for each user's next login: enabled global servers re-snapshot the // description and restart; each live user who activated it gets its files/deps diff --git a/src/frontend/api/mcp.rs b/src/frontend/api/mcp.rs index 06a1fa2..095c9aa 100644 --- a/src/frontend/api/mcp.rs +++ b/src/frontend/api/mcp.rs @@ -18,6 +18,7 @@ use core_api::system_bus::SystemEvent; use serde::Deserialize; use serde_json::{json, Value}; +use skald_core::db::access_defaults as mcp_access; use skald_core::db::{mcp_catalog, mcp_catalog_access, mcp_global_access, mcp_global_servers, mcp_user_servers, oauth_providers, role_capabilities}; use skald_core::skald::Skald; @@ -31,6 +32,21 @@ fn to_json_opt(v: &Option) -> Option { v.as_ref().and_then(|x| serde_json::to_string(x).ok()) } +/// Grants a **just-created** connector to everyone whose role auto-grants, so the +/// admin's remaining job is to take it away from whoever should not have it rather +/// than to hand it out one person at a time (see `db::access_defaults`). +/// +/// Best-effort by design: the connector row already landed, seeding only ever adds +/// access, and a grant that did not get written is fixable from the user's page — +/// failing the install over it would be the worse trade. +async fn seed_default_access(skald: &Skald, target: mcp_access::Grantable<'_>) { + match mcp_access::seed_new_object(skald.db(), target).await { + Ok(0) => {} + Ok(n) => tracing::info!(?target, users = n, "connector granted to auto-grant users"), + Err(e) => tracing::warn!(?target, error = %e, "default connector grants failed (non-fatal)"), + } +} + /// Installs the connector folder that `script_path` (`/`) belongs /// to into the caller's container home, and returns the path the entry file will /// have INSIDE the container. @@ -297,6 +313,8 @@ pub async fn catalog_upsert( if body.source == "local_script" { require_cap(&skald, &auth.user_id, role_capabilities::REGISTER_LOCAL_SCRIPT).await?; } + // An edit of an existing entry must not re-apply the default audience. + let is_new_entry = mcp_catalog::get_by_name(skald.db(), &body.name).await?.is_none(); let id = mcp_catalog::upsert(skald.db(), mcp_catalog::UpsertCatalog { name: &body.name, scope: &body.scope, @@ -332,6 +350,11 @@ pub async fn catalog_upsert( version_string: None, version_release_date: None, }).await?; + // Authorize the standard audience to activate it (a no-op for a `global` entry, + // which nobody activates — `seed_new_object` filters on scope). + if is_new_entry { + seed_default_access(&skald, mcp_access::Grantable::Catalog(&body.name)).await; + } Ok(Json(json!({ "id": id }))) } @@ -470,6 +493,11 @@ pub async fn global_enable( entry.args_json.clone() }; + // Whether this is an install or a re-configuration of an existing connector — + // the default audience is applied to a brand-new row only (see the seed call + // below and `db::access_defaults`). + let is_new_server = mcp_global_servers::get_by_name(skald.db(), &name).await?.is_none(); + // Snapshot the concrete config from the catalog; the admin supplies the secret. let id = mcp_global_servers::upsert(skald.db(), mcp_global_servers::UpsertGlobal { name: &name, @@ -486,6 +514,14 @@ pub async fn global_enable( description: entry.description.as_deref(), }).await?; + // Hand the new connector to everyone whose role auto-grants, before it is even + // verified: the grants are what make it appear, and a failed verify only leaves + // the row disabled — the admin fixes the key and re-enables without having to + // remember an audience. Best-effort, and additive only. + if is_new_server { + seed_default_access(&skald, mcp_access::Grantable::GlobalServer(id)).await; + } + // Verify the admin-supplied credentials before starting the server. A failure // disables the row so it does not run with bad creds; the admin sees the // message and can fix + re-enable. A connector with no verify step is allowed diff --git a/web/components/roles-page.js b/web/components/roles-page.js index 748c5f9..141e4f4 100644 --- a/web/components/roles-page.js +++ b/web/components/roles-page.js @@ -99,13 +99,23 @@ export class RolesPage extends LightElement { return this._agents?.find(a => a.id === id)?.name ?? id; } - _mergeAttrs(attrs, uiMode, allowedGroups, chatAgent) { + // Whether a plugin or connector the admin installs reaches this role on its own. + // Absent means yes — the server's RoleAttrs defaults it to true, so only an + // opt-out is ever written (see `db::access_defaults`). + _attrsAutoGrant(attrs) { + try { return JSON.parse(attrs || '{}').auto_grant !== false; } + catch { return true; } + } + + _mergeAttrs(attrs, uiMode, allowedGroups, chatAgent, autoGrant) { let o = {}; try { o = JSON.parse(attrs || '{}') ?? {}; } catch { o = {}; } if (uiMode === 'simple') o.ui_mode = 'simple'; else delete o.ui_mode; const extras = Array.isArray(allowedGroups) ? allowedGroups.filter(Boolean) : []; if (extras.length) o.permission_groups = extras; else delete o.permission_groups; if (chatAgent) o.chat_agent = chatAgent; else delete o.chat_agent; + // Only the opt-out is persisted; `true` is the server-side default. + if (autoGrant === false) o.auto_grant = false; else delete o.auto_grant; const keys = Object.keys(o); return keys.length ? JSON.stringify(o) : null; } @@ -113,7 +123,7 @@ export class RolesPage extends LightElement { _openCreate() { this._modal = { mode: 'create', - form: { id: '', label: '', permission_group: this._groups?.[0]?.id ?? 'default', attrs: '', ui_mode: 'full', allowed_groups: [], chat_agent: '' }, + form: { id: '', label: '', permission_group: this._groups?.[0]?.id ?? 'default', attrs: '', ui_mode: 'full', allowed_groups: [], chat_agent: '', auto_grant: true }, }; } @@ -121,7 +131,7 @@ export class RolesPage extends LightElement { this._modal = { mode: 'edit', role, - form: { label: role.label, permission_group: role.permission_group, attrs: role.attrs ?? '', ui_mode: this._attrsUiMode(role.attrs), allowed_groups: this._attrsAllowedGroups(role.attrs), chat_agent: this._attrsChatAgent(role.attrs) }, + form: { label: role.label, permission_group: role.permission_group, attrs: role.attrs ?? '', ui_mode: this._attrsUiMode(role.attrs), allowed_groups: this._attrsAllowedGroups(role.attrs), chat_agent: this._attrsChatAgent(role.attrs), auto_grant: this._attrsAutoGrant(role.attrs) }, }; } @@ -153,7 +163,7 @@ export class RolesPage extends LightElement { id: form.id.trim(), label: form.label.trim(), permission_group: form.permission_group, - attrs: this._mergeAttrs(form.attrs, form.ui_mode, form.allowed_groups, form.chat_agent), + attrs: this._mergeAttrs(form.attrs, form.ui_mode, form.allowed_groups, form.chat_agent, form.auto_grant), }), }); if (!res.ok) throw new Error(await res.text()); @@ -170,7 +180,7 @@ export class RolesPage extends LightElement { body: JSON.stringify({ label: form.label.trim(), permission_group: form.permission_group, - attrs: this._mergeAttrs(form.attrs, form.ui_mode, form.allowed_groups, form.chat_agent), + attrs: this._mergeAttrs(form.attrs, form.ui_mode, form.allowed_groups, form.chat_agent, form.auto_grant), }), }); if (!res.ok) throw new Error(await res.text()); @@ -258,6 +268,16 @@ export class RolesPage extends LightElement {
${t('roles.form.assistant_hint')}
+
+ +
+ this._patch('auto_grant', e.target.checked)} /> + +
+
${t('roles.form.auto_grant_hint')}
+
${t('roles.col.group')} ${t('roles.col.interface')} ${t('roles.col.assistant')} + ${t('roles.col.auto_grant')} @@ -325,6 +346,9 @@ export class RolesPage extends LightElement { ? html`${t('roles.badge.simple')}` : html`${t('roles.badge.full')}`} ${this._agentName(this._attrsChatAgent(r.attrs))} + ${isAdmin || this._attrsAutoGrant(r.attrs) + ? html`${t('roles.badge.auto_grant_on')}` + : html`${t('roles.badge.auto_grant_off')}`}