auth: cut over community machine access to agent keys

This commit is contained in:
a.tolmachev
2026-05-03 15:42:07 +00:00
parent c7b33930db
commit 6fd62df2a8
18 changed files with 768 additions and 302 deletions
+21 -5
View File
@@ -4,7 +4,7 @@
<base href="../">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Crank — API Keys</title>
<title>Crank — Agent Keys</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="css/variables.css">
<link rel="stylesheet" href="css/layout.css">
@@ -107,8 +107,8 @@
<div class="page-header">
<div class="page-header-text">
<h1 class="page-title" data-i18n="apikeys.title">API Keys</h1>
<p class="page-subtitle" data-i18n="apikeys.subtitle">Keys authenticate external MCP clients and workspace automation against Crank.</p>
<h1 class="page-title" data-i18n="apikeys.title">Agent Keys</h1>
<p class="page-subtitle" data-i18n="apikeys.subtitle">Keys authenticate external MCP clients against a specific AI agent endpoint.</p>
</div>
<div class="page-header-actions">
<button class="btn-primary" id="btn-create-key" type="button">
@@ -129,11 +129,27 @@
</div>
</div>
<div class="section-card">
<div class="section-card-header">
<div>
<div class="section-card-title" data-i18n="apikeys.agent.title">Agent access</div>
<div class="section-card-subtitle" id="agent-access-subtitle" data-i18n="apikeys.agent.subtitle">Select the AI agent whose MCP endpoint should accept this key.</div>
</div>
</div>
<div class="section-card-body" style="display:grid;gap:12px;">
<div class="field-group" style="margin-bottom:0;">
<label class="field-label" for="agent-select" data-i18n="apikeys.agent.label">AI agent</label>
<select class="field-input" id="agent-select"></select>
<div class="field-hint" id="agent-endpoint-hint" data-testid="agent-key-endpoint-hint"></div>
</div>
</div>
</div>
<div class="section-card">
<div class="section-card-header">
<div>
<div class="section-card-title" data-i18n="apikeys.active.title">Active keys</div>
<div class="section-card-subtitle">3 active · 1 revoked</div>
<div class="section-card-subtitle" id="keys-summary-subtitle">3 active · 1 revoked</div>
</div>
<div class="filter-bar-search" style="max-width: 220px; margin: 0;">
<svg width="13" height="13" style="position:absolute; left:9px; top:50%; transform:translateY(-50%); color:var(--text-muted);" viewBox="0 0 16 16" fill="currentColor"><path d="M10.68 11.74a6 6 0 01-7.922-8.982 6 6 0 018.982 7.922l3.04 3.04a.749.749 0 01-.326 1.275.749.749 0 01-.734-.215zM11.5 7a4.499 4.499 0 11-8.997 0A4.499 4.499 0 0111.5 7z"/></svg>
@@ -192,7 +208,7 @@
<div class="modal-overlay" id="modal-create">
<div class="modal">
<div class="modal-header">
<span class="modal-title" data-i18n="apikeys.modal.title">Create API key</span>
<span class="modal-title" data-i18n="apikeys.modal.title">Create agent key</span>
<button class="modal-close" id="modal-close-btn" type="button">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round">
<line x1="1" y1="1" x2="11" y2="11"/><line x1="11" y1="1" x2="1" y2="11"/>
+101 -8
View File
@@ -1,5 +1,7 @@
var KEYS = [];
var AGENTS = [];
var currentWorkspaceId = null;
var currentAgentId = null;
var selectedScopes = new Set(['read']);
var search = '';
@@ -32,6 +34,7 @@ function mapKeyRecord(record) {
var apiKey = record.api_key || {};
return {
id: apiKey.id,
agentId: apiKey.agent_id || null,
name: apiKey.name,
prefix: apiKey.prefix || '',
scopes: apiKey.scopes || [],
@@ -41,6 +44,15 @@ function mapKeyRecord(record) {
};
}
function mapAgentRecord(record) {
return {
id: record.id,
slug: record.slug,
displayName: record.display_name || record.slug || record.id,
mcpEndpoint: record.mcp_endpoint || '',
};
}
function currentWorkspace() {
return window.getCurrentWorkspace ? window.getCurrentWorkspace() : null;
}
@@ -52,19 +64,46 @@ async function loadKeys() {
setTableLoading(true);
if (!currentWorkspaceId || !window.CrankApi) {
AGENTS = [];
KEYS = [];
currentAgentId = null;
renderAgentPicker();
setCreateButtonState();
setTableLoading(false);
renderTable(tKey('apikeys.error.api'));
return;
}
try {
var response = await window.CrankApi.listPlatformApiKeys(currentWorkspaceId);
KEYS = ((response && response.items) || []).map(mapKeyRecord);
var response = await window.CrankApi.listAgents(currentWorkspaceId);
AGENTS = ((response && response.items) || []).map(mapAgentRecord);
if (!AGENTS.length) {
currentAgentId = null;
KEYS = [];
renderAgentPicker();
setCreateButtonState();
setTableLoading(false);
renderTable();
return;
}
if (!currentAgentId || !AGENTS.some(function(agent) { return agent.id === currentAgentId; })) {
currentAgentId = AGENTS[0].id;
}
renderAgentPicker();
var keysResponse = await window.CrankApi.listAgentPlatformApiKeys(
currentWorkspaceId,
currentAgentId
);
KEYS = ((keysResponse && keysResponse.items) || []).map(mapKeyRecord);
setCreateButtonState();
setTableLoading(false);
renderTable();
} catch (error) {
AGENTS = [];
KEYS = [];
currentAgentId = null;
renderAgentPicker();
setCreateButtonState();
setTableLoading(false);
renderTable(error.message || tKey('apikeys.error.load'));
}
@@ -85,10 +124,11 @@ function setTableLoading(on) {
async function revokeKey(id) {
if (!confirm(tKey('apikeys.confirm.revoke'))) return;
if (!currentAgentId) return;
try {
var key = KEYS.find(function(item) { return item.id === id; });
await window.CrankApi.revokePlatformApiKey(currentWorkspaceId, id);
await window.CrankApi.revokeAgentPlatformApiKey(currentWorkspaceId, currentAgentId, id);
await loadKeys();
if (window.CrankUi) {
window.CrankUi.success(
@@ -108,10 +148,11 @@ async function revokeKey(id) {
async function deleteKey(id) {
if (!confirm(tKey('apikeys.confirm.delete'))) return;
if (!currentAgentId) return;
try {
var key = KEYS.find(function(item) { return item.id === id; });
await window.CrankApi.deletePlatformApiKey(currentWorkspaceId, id);
await window.CrankApi.deleteAgentPlatformApiKey(currentWorkspaceId, currentAgentId, id);
await loadKeys();
if (window.CrankUi) {
window.CrankUi.success(
@@ -130,7 +171,7 @@ async function deleteKey(id) {
}
async function createKey(name, scopes) {
var created = await window.CrankApi.createPlatformApiKey(currentWorkspaceId, {
var created = await window.CrankApi.createAgentPlatformApiKey(currentWorkspaceId, currentAgentId, {
name: name,
scopes: scopes,
});
@@ -140,6 +181,48 @@ async function createKey(name, scopes) {
};
}
function currentAgent() {
return AGENTS.find(function(agent) { return agent.id === currentAgentId; }) || null;
}
function renderAgentPicker() {
var select = document.getElementById('agent-select');
var hint = document.getElementById('agent-endpoint-hint');
if (!select || !hint) return;
select.innerHTML = '';
if (!AGENTS.length) {
var emptyOption = document.createElement('option');
emptyOption.value = '';
emptyOption.textContent = tKey('apikeys.agent.empty');
select.appendChild(emptyOption);
select.disabled = true;
hint.textContent = tKey('apikeys.agent.empty_hint');
return;
}
AGENTS.forEach(function(agent) {
var option = document.createElement('option');
option.value = agent.id;
option.textContent = agent.displayName;
if (agent.id === currentAgentId) option.selected = true;
select.appendChild(option);
});
select.disabled = false;
var agent = currentAgent();
hint.textContent = agent && agent.mcpEndpoint
? tfKey('apikeys.agent.endpoint', { endpoint: agent.mcpEndpoint })
: tKey('apikeys.agent.endpoint_missing');
}
function setCreateButtonState() {
var button = document.getElementById('btn-create-key');
if (!button) return;
button.disabled = !currentAgentId;
}
function renderScopes() {
var el = document.getElementById('scope-checkboxes');
var tmpl = document.getElementById('tmpl-scope-checkbox');
@@ -165,14 +248,16 @@ function renderTable(errorMessage) {
var rows = KEYS.filter(function(key) {
return !q || key.name.toLowerCase().includes(q) || key.prefix.toLowerCase().includes(q);
});
var subtitle = document.querySelector('.section-card-subtitle');
var subtitle = document.getElementById('keys-summary-subtitle');
tbody.innerHTML = '';
if (subtitle) {
var active = KEYS.filter(function(key) { return key.status === 'active'; }).length;
var revoked = KEYS.filter(function(key) { return key.status === 'revoked'; }).length;
subtitle.textContent = tfKey('apikeys.active.subtitle', { active: active, revoked: revoked });
subtitle.textContent = currentAgentId
? tfKey('apikeys.active.subtitle', { active: active, revoked: revoked })
: tKey('apikeys.agent.empty_hint');
}
if (errorMessage) {
@@ -191,7 +276,9 @@ function renderTable(errorMessage) {
var td = document.createElement('td');
td.colSpan = 7;
td.style.cssText = 'text-align:center;padding:36px;color:var(--text-muted);';
td.textContent = KEYS.length ? tKey('apikeys.empty.search') : tKey('apikeys.empty.none');
td.textContent = currentAgentId
? (KEYS.length ? tKey('apikeys.empty.search') : tKey('apikeys.empty.none'))
: tKey('apikeys.agent.empty_hint');
empty.appendChild(td);
tbody.appendChild(empty);
return;
@@ -242,6 +329,7 @@ function renderTable(errorMessage) {
var modal = document.getElementById('modal-create');
function openModal() {
if (!currentAgentId) return;
document.getElementById('modal-form-body').hidden = false;
document.getElementById('modal-reveal-body').hidden = true;
document.getElementById('modal-footer-create').hidden = false;
@@ -340,6 +428,11 @@ document.getElementById('key-search').addEventListener('input', function() {
renderTable();
});
document.getElementById('agent-select').addEventListener('change', async function() {
currentAgentId = this.value || null;
await loadKeys();
});
function copyPrefix(prefix) {
if (navigator.clipboard) {
navigator.clipboard.writeText(prefix).catch(function() {});
+8 -8
View File
@@ -331,17 +331,17 @@
archiveAgent: function(workspaceId, agentId) {
return post('/workspaces/' + encodeURIComponent(workspaceId) + '/agents/' + encodeURIComponent(agentId) + '/archive', {});
},
listPlatformApiKeys: function(workspaceId) {
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/platform-api-keys');
listAgentPlatformApiKeys: function(workspaceId, agentId) {
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/agents/' + encodeURIComponent(agentId) + '/platform-api-keys');
},
createPlatformApiKey: function(workspaceId, payload) {
return post('/workspaces/' + encodeURIComponent(workspaceId) + '/platform-api-keys', payload);
createAgentPlatformApiKey: function(workspaceId, agentId, payload) {
return post('/workspaces/' + encodeURIComponent(workspaceId) + '/agents/' + encodeURIComponent(agentId) + '/platform-api-keys', payload);
},
revokePlatformApiKey: function(workspaceId, keyId) {
return post('/workspaces/' + encodeURIComponent(workspaceId) + '/platform-api-keys/' + encodeURIComponent(keyId) + '/revoke', {});
revokeAgentPlatformApiKey: function(workspaceId, agentId, keyId) {
return post('/workspaces/' + encodeURIComponent(workspaceId) + '/agents/' + encodeURIComponent(agentId) + '/platform-api-keys/' + encodeURIComponent(keyId) + '/revoke', {});
},
deletePlatformApiKey: function(workspaceId, keyId) {
return del('/workspaces/' + encodeURIComponent(workspaceId) + '/platform-api-keys/' + encodeURIComponent(keyId));
deleteAgentPlatformApiKey: function(workspaceId, agentId, keyId) {
return del('/workspaces/' + encodeURIComponent(workspaceId) + '/agents/' + encodeURIComponent(agentId) + '/platform-api-keys/' + encodeURIComponent(keyId));
},
listSecrets: function(workspaceId) {
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/secrets');
+30 -16
View File
@@ -96,9 +96,16 @@ var TRANSLATIONS = {
'page.ops': 'operations',
// API Keys page
'apikeys.title': 'API Keys',
'apikeys.subtitle': 'Keys authenticate external MCP clients and workspace automation against Crank.',
'apikeys.title': 'Agent Keys',
'apikeys.subtitle': 'Keys authenticate external MCP clients against a specific AI agent endpoint.',
'apikeys.new': 'Create key',
'apikeys.agent.title': 'Agent access',
'apikeys.agent.subtitle': 'Select the AI agent whose MCP endpoint should accept this key.',
'apikeys.agent.label': 'AI agent',
'apikeys.agent.empty': 'No agents available',
'apikeys.agent.empty_hint': 'Create an AI agent first, then issue machine access keys for its MCP endpoint.',
'apikeys.agent.endpoint': 'MCP endpoint: {endpoint}',
'apikeys.agent.endpoint_missing': 'The selected agent does not have a published MCP endpoint yet.',
'apikeys.th.name': 'NAME',
'apikeys.th.scope': 'SCOPE',
'apikeys.th.created':'CREATED',
@@ -112,10 +119,10 @@ var TRANSLATIONS = {
'apikeys.th.scopes': 'SCOPES',
'apikeys.th.status': 'STATUS',
'apikeys.scope_ref': 'Scope reference',
'apikeys.scope.read': 'Initialize MCP sessions, ping the server, and list tools for a workspace agent.',
'apikeys.scope.read': 'Initialize MCP sessions, ping the server, and list tools for the selected AI agent.',
'apikeys.scope.write': 'Execute `tools/call` requests against published agent toolsets.',
'apikeys.scope.deploy': 'Reserved for deploy-scoped automation. Today it also permits MCP read/write flows.',
'apikeys.modal.title': 'Create API key',
'apikeys.modal.title': 'Create agent key',
'apikeys.modal.name': 'Key name',
'apikeys.modal.name_hint': 'A descriptive label to identify the key. Only visible to admins.',
'apikeys.modal.name_placeholder': 'e.g. Production, CI pipeline',
@@ -146,11 +153,11 @@ var TRANSLATIONS = {
'apikeys.toast.delete_error_message': 'Failed to delete key',
'apikeys.toast.scope_missing_title': 'Missing scope',
'apikeys.toast.scope_missing_message': 'Select at least one scope before creating a key.',
'apikeys.toast.create_title': 'API key created',
'apikeys.toast.create_title': 'Agent key created',
'apikeys.toast.create_message': 'Copy the key now. It will not be shown again.',
'apikeys.toast.create_error_title': 'Key creation failed',
'apikeys.toast.create_error_message': 'Failed to create key',
'apikeys.toast.copy_title': 'API key copied',
'apikeys.toast.copy_title': 'Agent key copied',
'apikeys.toast.copy_message': 'Store the raw key securely. It cannot be revealed again.',
'apikeys.toast.prefix_title': 'Key prefix copied',
'apikeys.action.copy_prefix': 'Copy key prefix',
@@ -471,7 +478,7 @@ var TRANSLATIONS = {
'workspace_setup.create.footer': "You'll be the Owner of this workspace. You can invite additional members after creation.",
'workspace_setup.danger.title': 'Danger zone',
'workspace_setup.danger.export_title': 'Export all data',
'workspace_setup.danger.export_body': 'Download a JSON snapshot of workspace settings, memberships, invitations, operations, agents and platform API keys.',
'workspace_setup.danger.export_body': 'Download a JSON snapshot of workspace settings, memberships, invitations, operations, agents and agent access keys.',
'workspace_setup.danger.delete_title': 'Delete workspace',
'workspace_setup.danger.delete_body': 'Permanently deletes all operations, keys, logs and settings. This action cannot be undone. You will be logged out immediately.',
'workspace_setup.export': 'Export',
@@ -927,7 +934,7 @@ var TRANSLATIONS = {
'agents.empty.search.title': 'No agents match "{query}"',
'agents.empty.search.sub': 'Try a different search term.',
'agents.card.tools': 'tools',
'agents.card.keys': 'API keys',
'agents.card.keys': 'keys',
'agents.card.calls_today': 'calls today',
'agents.card.created': 'Created {date}',
'agents.card.copy_endpoint': 'Copy endpoint',
@@ -1112,9 +1119,16 @@ var TRANSLATIONS = {
'page.ops': 'операций',
// API Keys page
'apikeys.title': 'API Ключи',
'apikeys.subtitle': 'Ключи аутентифицируют внешние MCP-клиенты и автоматизацию воркспейса в Crank.',
'apikeys.title': 'Ключи агентов',
'apikeys.subtitle': 'Ключи аутентифицируют внешние MCP-клиенты для конкретного endpoint AI-агента.',
'apikeys.new': 'Создать ключ',
'apikeys.agent.title': 'Доступ агента',
'apikeys.agent.subtitle': 'Выберите AI-агента, чей MCP endpoint должен принимать этот ключ.',
'apikeys.agent.label': 'AI-агент',
'apikeys.agent.empty': 'Агенты отсутствуют',
'apikeys.agent.empty_hint': 'Сначала создайте AI-агента, затем выпустите машинный ключ для его MCP endpoint.',
'apikeys.agent.endpoint': 'MCP endpoint: {endpoint}',
'apikeys.agent.endpoint_missing': 'У выбранного агента пока нет опубликованного MCP endpoint.',
'apikeys.th.name': 'НАЗВАНИЕ',
'apikeys.th.scope': 'ОБЛАСТЬ',
'apikeys.th.created':'СОЗДАН',
@@ -1128,10 +1142,10 @@ var TRANSLATIONS = {
'apikeys.th.scopes': 'ОБЛАСТИ',
'apikeys.th.status': 'СТАТУС',
'apikeys.scope_ref': 'Справка по областям',
'apikeys.scope.read': 'Инициализация MCP-сессий, ping сервера и получение списка инструментов для агента воркспейса.',
'apikeys.scope.read': 'Инициализация MCP-сессий, ping сервера и получение списка инструментов для выбранного AI-агента.',
'apikeys.scope.write': 'Выполнение `tools/call` для опубликованных наборов инструментов агента.',
'apikeys.scope.deploy': 'Зарезервировано для deploy-автоматизации. Сейчас также разрешает MCP read/write сценарии.',
'apikeys.modal.title': 'Создать API-ключ',
'apikeys.modal.title': 'Создать ключ агента',
'apikeys.modal.name': 'Имя ключа',
'apikeys.modal.name_hint': 'Понятная метка для идентификации ключа. Видна только администраторам.',
'apikeys.modal.name_placeholder': 'например, Production, CI pipeline',
@@ -1162,11 +1176,11 @@ var TRANSLATIONS = {
'apikeys.toast.delete_error_message': 'Не удалось удалить ключ',
'apikeys.toast.scope_missing_title': 'Не выбрана область',
'apikeys.toast.scope_missing_message': 'Выберите хотя бы одну область перед созданием ключа.',
'apikeys.toast.create_title': 'API-ключ создан',
'apikeys.toast.create_title': 'Ключ агента создан',
'apikeys.toast.create_message': 'Скопируйте ключ сейчас. Повторно он не будет показан.',
'apikeys.toast.create_error_title': 'Не удалось создать ключ',
'apikeys.toast.create_error_message': 'Не удалось создать ключ',
'apikeys.toast.copy_title': 'API-ключ скопирован',
'apikeys.toast.copy_title': 'Ключ агента скопирован',
'apikeys.toast.copy_message': 'Сохраните исходный ключ в надежном месте. Повторно показать его нельзя.',
'apikeys.toast.prefix_title': 'Префикс ключа скопирован',
'apikeys.action.copy_prefix': 'Скопировать префикс ключа',
@@ -1493,7 +1507,7 @@ var TRANSLATIONS = {
'workspace_setup.create.footer': 'Вы станете владельцем этого воркспейса. После создания можно пригласить дополнительных участников.',
'workspace_setup.danger.title': 'Опасная зона',
'workspace_setup.danger.export_title': 'Экспортировать все данные',
'workspace_setup.danger.export_body': 'Скачать JSON-снимок настроек воркспейса, участников, приглашений, операций, агентов и platform API keys.',
'workspace_setup.danger.export_body': 'Скачать JSON-снимок настроек воркспейса, участников, приглашений, операций, агентов и ключей доступа агентов.',
'workspace_setup.danger.delete_title': 'Удалить воркспейс',
'workspace_setup.danger.delete_body': 'Безвозвратно удаляет все операции, ключи, логи и настройки. Действие нельзя отменить. Вы будете немедленно разлогинены.',
'workspace_setup.export': 'Экспорт',
@@ -1957,7 +1971,7 @@ var TRANSLATIONS = {
'agents.empty.search.title': 'Нет агентов по запросу "{query}"',
'agents.empty.search.sub': 'Попробуйте другой поисковый запрос.',
'agents.card.tools': 'инструментов',
'agents.card.keys': 'API-ключей',
'agents.card.keys': 'ключей',
'agents.card.calls_today': 'вызовов сегодня',
'agents.card.created': 'Создан {date}',
'agents.card.copy_endpoint': 'Скопировать endpoint',
+14 -3
View File
@@ -1,12 +1,23 @@
const { test, expect } = require('@playwright/test');
const { login, localized } = require('./helpers');
const { createAgent, getCurrentWorkspace, login, localized, uniqueName } = require('./helpers');
test('api keys page opens create key flow', async ({ page }) => {
await login(page);
const workspace = await getCurrentWorkspace(page);
await createAgent(page, workspace.id, {
slug: uniqueName('playwright_keys_agent'),
display_name: 'Playwright Keys Agent',
description: 'Agent for API key page smoke test.',
instructions: {},
tool_selection_policy: {},
});
await page.goto('/api-keys');
await expect(page.locator('.page-title')).toHaveText(localized('API Keys', 'API Ключи'));
await expect(page.locator('.page-title')).toHaveText(localized('Agent Keys', 'Ключи агентов'));
await expect(page.locator('#agent-select')).not.toBeDisabled();
await expect(page.locator('#btn-create-key')).toBeEnabled();
await page.locator('#btn-create-key').click();
await expect(page.locator('.modal-title')).toHaveText(localized('Create API key', 'Создать API-ключ'));
await expect(page.locator('#modal-create')).toHaveClass(/open/);
await expect(page.locator('.modal-title')).toHaveText(localized('Create agent key', 'Создать ключ агента'));
await page.locator('#new-key-name').fill(`playwright-${Date.now()}`);
await page.locator('#modal-confirm-btn').click();
await expect(page.locator('#modal-reveal-body')).toContainText(localized('Copy this key now', 'Скопируйте этот ключ сейчас'));
+9 -3
View File
@@ -87,11 +87,11 @@ async function publishOperation(page, workspaceId, operationId, version = 1) {
);
}
async function createPlatformApiKey(page, workspaceId, name, scopes) {
async function createPlatformApiKey(page, workspaceId, agentId, name, scopes) {
return browserJson(
page,
'POST',
`/api/admin/workspaces/${encodeURIComponent(workspaceId)}/platform-api-keys`,
`/api/admin/workspaces/${encodeURIComponent(workspaceId)}/agents/${encodeURIComponent(agentId)}/platform-api-keys`,
{ name, scopes },
);
}
@@ -520,7 +520,13 @@ async function setupPublishedAgent(page, { operationPayload, agentSlug, toolName
]);
await publishAgent(page, workspace.id, createdAgent.agent_id, createdAgent.version);
const key = await createPlatformApiKey(page, workspace.id, uniqueName('playwright_key'), ['read', 'write']);
const key = await createPlatformApiKey(
page,
workspace.id,
createdAgent.agent_id,
uniqueName('playwright_key'),
['read', 'write']
);
return {
workspace,