mcp: per-user connector access control with deny-by-default grants
Nightly Build / build (push) Successful in 6m33s

This commit is contained in:
2026-07-21 20:48:56 +01:00
parent c8e4cb4384
commit 17f5769e0d
11 changed files with 503 additions and 8 deletions
+97
View File
@@ -155,6 +155,40 @@ export class UsersPage extends LightElement {
} catch (e) { this._error = e.message; }
}
// ── Per-user connector access ────────────────────────────────────────────────
async _openConnectors(user) {
this._modal = { mode: 'connectors', user, conns: null };
this._error = null;
try {
const res = await fetch(`/api/users/${user.id}/connectors`);
if (!res.ok) throw new Error(await res.text());
const conns = await res.json();
this._modal = { ...this._modal, conns };
} catch (e) { this._error = e.message; }
}
_toggleConn(idx) {
const conns = this._modal.conns.map((c, i) => i === idx ? { ...c, granted: !c.granted } : c);
this._modal = { ...this._modal, conns };
}
async _saveConnectors() {
const { user, conns } = this._modal;
const global_ids = conns.filter(c => c.kind === 'global' && c.granted).map(c => c.id);
const catalog_names = conns.filter(c => c.kind === 'catalog' && c.granted).map(c => c.name);
this._error = null;
try {
const res = await fetch(`/api/users/${user.id}/connectors`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ global_ids, catalog_names }),
});
if (!res.ok) throw new Error(await res.text());
this._closeModal();
} catch (e) { this._error = e.message; }
}
// ── Render ──────────────────────────────────────────────────────────────────
_roleLabel(roleId) {
@@ -179,8 +213,68 @@ export class UsersPage extends LightElement {
`;
}
_renderConnectorsModal() {
const { user, conns } = this._modal;
const globals = (conns ?? []).filter(c => c.kind === 'global');
const catalog = (conns ?? []).filter(c => c.kind === 'catalog');
const row = (c) => {
const idx = this._modal.conns.indexOf(c);
return html`
<label class="um-conn-row">
<input class="form-check-input" type="checkbox" .checked=${c.granted} @change=${() => this._toggleConn(idx)} />
<span class="um-conn-main">
<span class="um-conn-name">
${c.friendly_name || c.name}
${c.kind === 'global' && !c.enabled ? html`<span class="um-badge um-badge-inactive">${t('users.conn.disabled')}</span>` : nothing}
</span>
${c.description ? html`<span class="um-conn-desc">${c.description}</span>` : nothing}
</span>
</label>
`;
};
return html`
<div class="um-modal-overlay" @click=${(e) => { if (e.target.classList.contains('um-modal-overlay')) this._closeModal(); }}>
<div class="um-modal">
<div class="um-modal-header">
<i class="bi bi-plug"></i>
<span>${t('users.modal.connectors_title', { username: user.username })}</span>
<button class="um-btn-icon ms-auto" @click=${() => this._closeModal()}><i class="bi bi-x-lg"></i></button>
</div>
<div class="um-modal-body">
${this._error ? html`<div class="alert alert-danger py-2 mb-3" style="font-size:.85rem">${this._error}</div>` : nothing}
${conns === null ? html`
<div class="um-empty"><i class="bi bi-hourglass-split"></i> ${t('users.loading')}</div>
` : (globals.length === 0 && catalog.length === 0) ? html`
<div class="um-empty"><i class="bi bi-plug"></i><p>${t('users.conn.empty')}</p></div>
` : html`
${globals.length ? html`
<div class="um-conn-group">
<div class="um-conn-group-title">${t('users.conn.globals')}</div>
<div class="form-text mb-2">${t('users.conn.hint_global')}</div>
${globals.map(row)}
</div>` : nothing}
${catalog.length ? html`
<div class="um-conn-group">
<div class="um-conn-group-title">${t('users.conn.catalog')}</div>
<div class="form-text mb-2">${t('users.conn.hint_catalog')}</div>
${catalog.map(row)}
</div>` : nothing}
`}
</div>
<div class="um-modal-footer">
<button class="btn btn-sm btn-outline-secondary" @click=${() => this._closeModal()}>${t('users.modal.cancel')}</button>
<button class="btn btn-sm btn-primary" ?disabled=${conns === null} @click=${() => this._saveConnectors()}>
<i class="bi bi-check-lg me-1"></i>${t('users.modal.save_btn')}
</button>
</div>
</div>
</div>
`;
}
_renderModal() {
if (!this._modal) return nothing;
if (this._modal.mode === 'connectors') return this._renderConnectorsModal();
const { mode, form, user } = this._modal;
const title = mode === 'create' ? t('users.modal.create_title')
: mode === 'edit' ? t('users.modal.edit_title', { username: user.username })
@@ -325,6 +419,9 @@ export class UsersPage extends LightElement {
<button class="um-btn-icon" title=${t('users.action.edit')} @click=${() => this._openEdit(u)}>
<i class="bi bi-pencil"></i>
</button>
<button class="um-btn-icon" title=${t('users.action.connectors')} @click=${() => this._openConnectors(u)}>
<i class="bi bi-plug"></i>
</button>
<button class="um-btn-icon" title=${t('users.action.delete')} @click=${() => this._delete(u)}>
<i class="bi bi-trash"></i>
</button>
+26
View File
@@ -136,3 +136,29 @@
padding: 48px 24px;
color: var(--placeholder-color);
}
/* Per-user connector access checklist */
.um-conn-group { margin-bottom: 18px; }
.um-conn-group:last-child { margin-bottom: 0; }
.um-conn-group-title {
font-weight: 600;
font-size: .8rem;
text-transform: uppercase;
letter-spacing: .03em;
color: var(--placeholder-color);
}
.um-conn-row {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 10px 12px;
border: 1px solid var(--card-border);
border-radius: var(--radius-sm, 8px);
margin-bottom: 8px;
cursor: pointer;
}
.um-conn-row:hover { background: var(--bs-tertiary-bg); }
.um-conn-row input { margin-top: 3px; flex: 0 0 auto; }
.um-conn-main { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.um-conn-name { font-weight: 500; display: flex; align-items: center; gap: 8px; }
.um-conn-desc { font-size: .82rem; color: var(--placeholder-color); }
+9
View File
@@ -999,8 +999,17 @@ export default {
'users.action.reset_pw': 'Reset password',
'users.action.edit': 'Edit',
'users.action.connectors': 'Connectors',
'users.action.delete': 'Delete',
'users.modal.connectors_title': 'Connectors — {username}',
'users.conn.globals': 'Shared connectors',
'users.conn.catalog': 'Personal connectors',
'users.conn.hint_global': 'Available to this user as soon as you enable it.',
'users.conn.hint_catalog': 'This user may activate these and sign in themselves from their Connectors page.',
'users.conn.empty': 'No connectors registered yet.',
'users.conn.disabled': 'disabled',
'users.modal.create_title': 'New user',
'users.modal.edit_title': 'Edit {username}',
'users.modal.reset_title': 'Reset password — {username}',
+9
View File
@@ -986,8 +986,17 @@ export default {
'users.action.reset_pw': 'Réinitialiser le mot de passe',
'users.action.edit': 'Modifier',
'users.action.connectors': 'Connecteurs',
'users.action.delete': 'Supprimer',
'users.modal.connectors_title': 'Connecteurs — {username}',
'users.conn.globals': 'Connecteurs partagés',
'users.conn.catalog': 'Connecteurs personnels',
'users.conn.hint_global': 'Disponible pour cet utilisateur dès que vous l\'activez.',
'users.conn.hint_catalog': 'Cet utilisateur peut les activer et se connecter lui-même depuis sa page Connecteurs.',
'users.conn.empty': 'Aucun connecteur enregistré pour le moment.',
'users.conn.disabled': 'désactivé',
'users.modal.create_title': 'Nouvel utilisateur',
'users.modal.edit_title': 'Modifier {username}',
'users.modal.reset_title': 'Réinitialiser le mot de passe — {username}',
+9
View File
@@ -986,8 +986,17 @@ export default {
'users.action.reset_pw': 'Reimposta password',
'users.action.edit': 'Modifica',
'users.action.connectors': 'Connettori',
'users.action.delete': 'Elimina',
'users.modal.connectors_title': 'Connettori — {username}',
'users.conn.globals': 'Connettori condivisi',
'users.conn.catalog': 'Connettori personali',
'users.conn.hint_global': 'Disponibile per questo utente non appena lo abiliti.',
'users.conn.hint_catalog': 'Questo utente può attivarli e accedere da sé dalla propria pagina Connettori.',
'users.conn.empty': 'Nessun connettore ancora registrato.',
'users.conn.disabled': 'disabilitato',
'users.modal.create_title': 'Nuovo utente',
'users.modal.edit_title': 'Modifica {username}',
'users.modal.reset_title': 'Reimposta password — {username}',