feat: grant a new plugin or connector to everyone by default — the admin's job is now removal, not distribution
Nightly Build / build (push) Successful in 7m23s

The grant junctions (plugin_access, mcp_global_access, mcp_catalog_access)
stay deny-by-default internally, but the rows are written for you at two
moments and never again:

  — an object is CREATED: PluginManager::update_config (first toggle —
    the plugins row's birth), mcp::catalog_upsert, marketplace install,
    mcp::global_enable
  — a user is CREATED: UserManager::register_user

Who is included is the role attrs.auto_grant flag (default true, so every
role predating the attribute behaves like an adult member). The seeded
Children preset sets it to false, which is the whole reason the attribute
exists. Admins are skipped because they hold everything implicitly. The
role editor now exposes the switch as a checkbox.

New crate module: db::access_defaults (seed_new_object, seed_new_user,
set_grant_by_default). Additive columns: grant_by_default on plugins,
mcp_catalog, mcp_global_servers (INTEGER NOT NULL DEFAULT 1).

On the frontend the Roles page gets a "New extensions" column and
checklist; the user's plugin/connector rosters are unchanged. i18n:
en, fr, it.

Docs: new docs/access.md for the assistant, plus index.md cross-link.
CLAUDE.md updated with a full default-access section.
This commit is contained in:
2026-07-29 15:53:51 +01:00
parent 0ed94225b2
commit e6818408cb
15 changed files with 700 additions and 19 deletions
+29 -5
View File
@@ -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 {
</select>
<div class="form-text" style="font-size:.75rem">${t('roles.form.assistant_hint')}</div>
</div>
<div class="mb-3">
<label class="form-label">${t('roles.form.auto_grant')}</label>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="role-auto-grant"
.checked=${form.auto_grant !== false}
@change=${e => this._patch('auto_grant', e.target.checked)} />
<label class="form-check-label" for="role-auto-grant">${t('roles.form.auto_grant_label')}</label>
</div>
<div class="form-text" style="font-size:.75rem">${t('roles.form.auto_grant_hint')}</div>
</div>
<div class="mb-3">
<label class="form-label">${t('roles.form.attrs')} <span class="text-muted">${t('roles.form.attrs_hint')}</span></label>
<input class="form-control font-monospace" placeholder=${t('roles.form.attrs_ph')} .value=${form.attrs}
@@ -310,6 +330,7 @@ export class RolesPage extends LightElement {
<th>${t('roles.col.group')}</th>
<th>${t('roles.col.interface')}</th>
<th>${t('roles.col.assistant')}</th>
<th>${t('roles.col.auto_grant')}</th>
<th></th>
</tr>
</thead>
@@ -325,6 +346,9 @@ export class RolesPage extends LightElement {
? html`<span class="badge" style="background:var(--accent-soft);color:var(--accent)">${t('roles.badge.simple')}</span>`
: html`<span class="badge bg-secondary">${t('roles.badge.full')}</span>`}</td>
<td>${this._agentName(this._attrsChatAgent(r.attrs))}</td>
<td>${isAdmin || this._attrsAutoGrant(r.attrs)
? html`<span class="badge bg-secondary">${t('roles.badge.auto_grant_on')}</span>`
: html`<span class="badge" style="background:var(--accent-soft);color:var(--accent)">${t('roles.badge.auto_grant_off')}</span>`}</td>
<td>
<div class="um-actions">
<button class="um-btn-icon" title=${isAdmin ? t('roles.tooltip.locked') : t('roles.tooltip.edit')}