Split MCP and approval agent keys

This commit is contained in:
github-ops
2026-06-24 10:31:52 +00:00
parent d739f17393
commit 5922aea68f
16 changed files with 471 additions and 56 deletions
+12 -2
View File
@@ -1,8 +1,8 @@
use crank_core::{
AgentId, AgentStatus, AuthConfig, AuthKind, ExecutionMode, ExportMode, GeneratedDraft,
InvocationLevel, InvocationSource, InvocationStatus, OperationSecurityLevel, OperationStatus,
PlatformApiKeyScope, Protocol, SecretKind, Target, UsagePeriod, WizardState, WorkspaceId,
WorkspaceStatus,
PlatformApiKeyKind, PlatformApiKeyScope, Protocol, SecretKind, Target, UsagePeriod,
WizardState, WorkspaceId, WorkspaceStatus,
};
use crank_mapping::MappingSet;
use crank_registry::{
@@ -211,7 +211,17 @@ pub struct AgentMutationResult {
#[derive(Clone, Debug, Deserialize)]
pub struct PlatformApiKeyPayload {
pub name: String,
#[serde(default = "default_platform_api_key_kind")]
pub key_kind: PlatformApiKeyKind,
pub scopes: Vec<PlatformApiKeyScope>,
#[serde(default)]
pub expires_at: Option<String>,
#[serde(default)]
pub allowed_origins: Vec<String>,
}
fn default_platform_api_key_kind() -> PlatformApiKeyKind {
PlatformApiKeyKind::McpClient
}
#[derive(Clone, Debug, Serialize)]
+56 -3
View File
@@ -1,7 +1,10 @@
use crank_core::{AgentId, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyStatus, WorkspaceId};
use crank_core::{
AgentId, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyKind, PlatformApiKeyScope,
PlatformApiKeyStatus, WorkspaceId,
};
use crank_registry::{CreatePlatformApiKeyRequest, PlatformApiKeyRecord};
use serde_json::json;
use time::OffsetDateTime;
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
use tracing::instrument;
use crate::{
@@ -53,7 +56,19 @@ impl AdminService {
)
})?;
let secret = generate_access_secret("crk");
validate_platform_api_key_payload(&payload)?;
let expires_at = match payload.expires_at.as_deref() {
Some(value) => Some(
OffsetDateTime::parse(value, &Rfc3339)
.map_err(|_| ApiError::validation("expires_at must be RFC3339 timestamp"))?,
),
None => None,
};
let secret = generate_access_secret(match payload.key_kind {
PlatformApiKeyKind::McpClient => "crk",
PlatformApiKeyKind::Approval => "crk_appr",
});
let api_key = PlatformApiKeyRecord {
api_key: PlatformApiKey {
id: PlatformApiKeyId::new(new_prefixed_id("pk")),
@@ -61,10 +76,13 @@ impl AdminService {
agent_id: Some(agent_id.clone()),
name: payload.name,
prefix: secret.chars().take(16).collect(),
key_kind: payload.key_kind,
scopes: payload.scopes,
status: PlatformApiKeyStatus::Active,
created_at: OffsetDateTime::now_utc(),
last_used_at: None,
expires_at,
allowed_origins: payload.allowed_origins,
},
};
@@ -109,3 +127,38 @@ impl AdminService {
Ok(())
}
}
fn validate_platform_api_key_payload(payload: &PlatformApiKeyPayload) -> Result<(), ApiError> {
if payload.name.trim().is_empty() {
return Err(ApiError::validation("key name is required"));
}
if payload.scopes.is_empty() {
return Err(ApiError::validation("at least one key scope is required"));
}
let valid = payload.scopes.iter().all(|scope| match payload.key_kind {
PlatformApiKeyKind::McpClient => matches!(
scope,
PlatformApiKeyScope::Read | PlatformApiKeyScope::Write | PlatformApiKeyScope::Deploy
),
PlatformApiKeyKind::Approval => matches!(
scope,
PlatformApiKeyScope::Approve
| PlatformApiKeyScope::Deny
| PlatformApiKeyScope::ReadPending
),
});
if !valid {
return Err(ApiError::validation(
"key scopes do not match selected key kind",
));
}
if payload.key_kind == PlatformApiKeyKind::Approval && payload.allowed_origins.len() > 20 {
return Err(ApiError::validation(
"approval key can contain at most 20 allowed origins",
));
}
Ok(())
}
+5 -2
View File
@@ -2,8 +2,8 @@ use std::collections::BTreeMap;
use crank_core::{
AgentId, InvocationLevel, InvocationSource, InvocationStatus, MembershipRole, OperationId,
OperationSecurityLevel, PlatformApiKeyScope, PlatformApiKeyStatus, Protocol, Target,
WizardState, WorkspaceId,
OperationSecurityLevel, PlatformApiKeyKind, PlatformApiKeyScope, PlatformApiKeyStatus,
Protocol, Target, WizardState, WorkspaceId,
};
use crank_mapping::{JsonPathRoot, infer_mapping_from_samples};
use crank_mapping::{MappingRule, MappingSet};
@@ -162,7 +162,10 @@ impl AdminService {
agent_id,
PlatformApiKeyPayload {
name: name.to_owned(),
key_kind: PlatformApiKeyKind::McpClient,
scopes,
expires_at: None,
allowed_origins: Vec::new(),
},
)
.await?
@@ -153,6 +153,7 @@ async fn manages_agent_platform_api_keys() {
.post(format!("{base_url}/agents/{agent_id}/platform-api-keys"))
.json(&json!({
"name": "sales-routing-primary",
"key_kind": "mcp_client",
"scopes": ["read", "write"]
}))
.send()
@@ -164,6 +165,31 @@ async fn manages_agent_platform_api_keys() {
.as_str()
.unwrap()
.to_owned();
let created_approval_key = assert_success_json(
client
.post(format!("{base_url}/agents/{agent_id}/platform-api-keys"))
.json(&json!({
"name": "sales-routing-approver",
"key_kind": "approval",
"scopes": ["approve", "deny"],
"allowed_origins": ["https://client.example.test"]
}))
.send()
.await
.unwrap(),
)
.await;
let invalid_mixed_scope_status = client
.post(format!("{base_url}/agents/{agent_id}/platform-api-keys"))
.json(&json!({
"name": "invalid-mixed-scope",
"key_kind": "approval",
"scopes": ["read", "approve"]
}))
.send()
.await
.unwrap()
.status();
let listed_keys = assert_success_json(
client
@@ -192,14 +218,27 @@ async fn manages_agent_platform_api_keys() {
.status();
assert_eq!(created_key["api_key"]["api_key"]["agent_id"], agent_id);
assert_eq!(created_key["api_key"]["api_key"]["key_kind"], "mcp_client");
assert_eq!(
created_approval_key["api_key"]["api_key"]["key_kind"],
"approval"
);
assert!(
created_approval_key["secret"]
.as_str()
.unwrap()
.starts_with("crk_appr_")
);
assert_eq!(
created_approval_key["api_key"]["api_key"]["allowed_origins"],
json!(["https://client.example.test"])
);
assert_eq!(invalid_mixed_scope_status, reqwest::StatusCode::BAD_REQUEST);
assert_eq!(
listed_keys["items"][0]["api_key"]["agent_id"],
json!(agent_id)
);
assert_eq!(
listed_keys["items"][0]["api_key"]["name"],
"sales-routing-primary"
);
assert_eq!(listed_keys["items"].as_array().unwrap().len(), 2);
assert!(created_key["secret"].as_str().unwrap().starts_with("crk_"));
assert_eq!(revoke_status, reqwest::StatusCode::NO_CONTENT);
assert_eq!(delete_status, reqwest::StatusCode::NO_CONTENT);
@@ -476,6 +476,38 @@ async fn rejects_initialize_without_platform_api_key() {
assert_eq!(response.status(), reqwest::StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn rejects_initialize_with_approval_platform_api_key() {
let registry = test_registry().await;
publish_agent_with_bindings(&registry, "sales-approval-key", vec![]).await;
let api_key =
create_approval_platform_api_key(&registry, "sales-approval-key", "approval-only").await;
let base_url = spawn_mcp_server(build_test_app(
registry,
Duration::from_millis(0),
Some("https://crank.example.com".to_owned()),
))
.await;
let client = reqwest::Client::new();
let response = client
.post(agent_mcp_url(&base_url, "sales-approval-key"))
.header(header::ACCEPT, "application/json, text/event-stream")
.header(header::AUTHORIZATION, format!("Bearer {api_key}"))
.json(&json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-11-25"
}
}))
.send()
.await
.unwrap();
assert_eq!(response.status(), reqwest::StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn rejects_tool_call_with_read_only_platform_api_key() {
let registry = test_registry().await;
+42 -3
View File
@@ -16,8 +16,9 @@ use axum::{
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use crank_core::{
Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, ExecutionConfig, HttpMethod,
Operation, OperationId, OperationStatus, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyScope,
PlatformApiKeyStatus, Protocol, RestTarget, Target, ToolDescription, WorkspaceId,
Operation, OperationId, OperationStatus, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyKind,
PlatformApiKeyScope, PlatformApiKeyStatus, Protocol, RestTarget, Target, ToolDescription,
WorkspaceId,
};
use crank_mapping::{MappingRule, MappingSet};
use crank_registry::{
@@ -236,17 +237,55 @@ pub(super) async fn create_platform_api_key(
name: &str,
scopes: &[PlatformApiKeyScope],
) -> String {
let secret = format!("crk_{}_{}", name, uuid::Uuid::now_v7().simple());
create_platform_api_key_with_kind(
registry,
agent_slug,
name,
PlatformApiKeyKind::McpClient,
scopes,
"crk",
)
.await
}
pub(super) async fn create_approval_platform_api_key(
registry: &PostgresRegistry,
agent_slug: &str,
name: &str,
) -> String {
create_platform_api_key_with_kind(
registry,
agent_slug,
name,
PlatformApiKeyKind::Approval,
&[PlatformApiKeyScope::Approve, PlatformApiKeyScope::Deny],
"crk_appr",
)
.await
}
async fn create_platform_api_key_with_kind(
registry: &PostgresRegistry,
agent_slug: &str,
name: &str,
key_kind: PlatformApiKeyKind,
scopes: &[PlatformApiKeyScope],
prefix: &str,
) -> String {
let secret = format!("{prefix}_{}_{}", name, uuid::Uuid::now_v7().simple());
let api_key = PlatformApiKey {
id: PlatformApiKeyId::new(format!("pk_{name}")),
workspace_id: test_workspace_id(),
agent_id: Some(test_agent_id(agent_slug)),
key_kind,
name: name.to_owned(),
prefix: secret.chars().take(16).collect(),
scopes: scopes.to_vec(),
status: PlatformApiKeyStatus::Active,
created_at: OffsetDateTime::parse("2026-03-26T10:00:00Z", &Rfc3339).unwrap(),
last_used_at: None,
expires_at: None,
allowed_origins: Vec::new(),
};
registry
+58 -21
View File
@@ -23,9 +23,48 @@
.scope-checkbox-name { font-size: 13px; font-weight: 500; color: var(--text-primary); }
.scope-checkbox-desc { font-size: 11.5px; color: var(--text-muted); }
.keys-card-list { display: none; }
.key-kind-tabs {
display: inline-flex;
gap: 4px;
padding: 4px;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: var(--bg-overlay);
}
.key-kind-tab {
border: 0;
border-radius: var(--radius);
background: transparent;
color: var(--text-secondary);
padding: 7px 12px;
font-size: 13px;
font-weight: 600;
}
.key-kind-tab.active {
background: var(--accent);
color: #fff;
}
.approval-warning-callout {
display: flex;
gap: 10px;
padding: 12px 14px;
border: 1px solid var(--amber-border);
border-radius: var(--radius-lg);
background: var(--amber-bg);
color: var(--text-primary);
font-size: 13px;
line-height: 1.55;
}
.approval-warning-callout svg {
color: var(--amber);
flex: 0 0 auto;
margin-top: 2px;
}
@media (max-width: 720px) {
#keys-table-wrap { display: none; }
.keys-card-list { display: grid; }
.key-kind-tabs { width: 100%; }
.key-kind-tab { flex: 1; }
}
</style>
<script src="%CRANK_BUNDLE_PROTECTED_CORE%"></script>
@@ -102,7 +141,7 @@
<div class="page-header-actions">
<button class="btn-primary" id="btn-create-key" type="button">
<svg width="13" height="13" viewBox="0 0 16 16" fill="currentColor"><path d="M7.75 2a.75.75 0 01.75.75V7h4.25a.75.75 0 010 1.5H8.5v4.25a.75.75 0 01-1.5 0V8.5H2.75a.75.75 0 010-1.5H7V2.75A.75.75 0 017.75 2z"/></svg>
<span data-i18n="apikeys.new">Create key</span>
<span id="btn-create-key-label" data-i18n="apikeys.new">Create key</span>
</button>
</div>
</div>
@@ -118,6 +157,16 @@
</div>
</div>
<div class="section-card">
<div class="section-card-body" style="display:flex;align-items:center;justify-content:space-between;gap:14px;flex-wrap:wrap;">
<div class="key-kind-tabs" role="tablist" aria-label="Key type">
<button class="key-kind-tab active" id="key-kind-mcp-client" type="button" data-key-kind="mcp_client" data-i18n="apikeys.kind.mcp">MCP clients</button>
<button class="key-kind-tab" id="key-kind-approval" type="button" data-key-kind="approval" data-i18n="apikeys.kind.approval">Approvals</button>
</div>
<div class="field-hint" id="key-kind-hint" style="max-width:560px;margin:0;"></div>
</div>
</div>
<div class="section-card">
<div class="section-card-header">
<div>
@@ -170,25 +219,7 @@
<div class="section-card-header">
<div class="section-card-title" data-i18n="apikeys.scope_ref">Access reference</div>
</div>
<div class="section-card-body" style="display:grid;grid-template-columns:repeat(3,1fr);gap:12px;">
<div>
<div style="font-size:12.5px;font-weight:600;color:var(--text-primary);margin-bottom:4px;display:flex;align-items:center;gap:6px;">
<span class="badge badge-scope">read</span>
</div>
<div style="font-size:12px;color:var(--text-muted);line-height:1.55;" data-i18n="apikeys.scope.read">Initialize MCP sessions, ping the server, and list tools for a workspace agent.</div>
</div>
<div>
<div style="font-size:12.5px;font-weight:600;color:var(--text-primary);margin-bottom:4px;">
<span class="badge badge-scope">write</span>
</div>
<div style="font-size:12px;color:var(--text-muted);line-height:1.55;" data-i18n="apikeys.scope.write">Execute `tools/call` requests against published agent toolsets.</div>
</div>
<div>
<div style="font-size:12.5px;font-weight:600;color:var(--text-primary);margin-bottom:4px;">
<span class="badge badge-scope">deploy</span>
</div>
<div style="font-size:12px;color:var(--text-muted);line-height:1.55;" data-i18n="apikeys.scope.deploy">Reserved for deploy-scoped automation. Today it also permits MCP read/write flows.</div>
</div>
<div class="section-card-body" id="scope-reference-grid" style="display:grid;grid-template-columns:repeat(3,1fr);gap:12px;">
</div>
</div>
@@ -198,7 +229,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 agent key</span>
<span class="modal-title" id="modal-create-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"/>
@@ -211,6 +242,12 @@
<input class="field-input" id="new-key-name" type="text" data-i18n-ph="apikeys.modal.name_placeholder" placeholder="e.g. Production, CI pipeline" autocomplete="off">
<div class="field-hint" data-i18n="apikeys.modal.name_hint">A descriptive label to identify the key. Only visible to admins.</div>
</div>
<div class="approval-warning-callout" id="approval-key-warning" hidden>
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<polygon points="8,1.5 15.5,14.5 0.5,14.5" fill="none"/><path d="M8 6v4M8 11.5v.5"/>
</svg>
<div data-i18n="apikeys.approval.warning">Do not pass this key to an LLM or MCP client. It is only for an external interface where a human confirms an action.</div>
</div>
<div class="field-group" style="margin-bottom:0;">
<label class="field-label" data-i18n="apikeys.modal.scopes">Access</label>
<div style="display:flex;flex-direction:column;gap:8px;margin-top:2px;" id="scope-checkboxes">
+81 -8
View File
@@ -2,14 +2,20 @@ var KEYS = [];
var AGENTS = [];
var currentWorkspaceId = null;
var currentAgentId = null;
var activeKeyKind = 'mcp_client';
var selectedScopes = new Set(['read']);
var search = '';
var SCOPES = ['read', 'write', 'deploy'];
var SCOPES_BY_KIND = {
mcp_client: ['read', 'write', 'deploy'],
approval: ['approve', 'deny'],
};
var SCOPE_DESC = {
read: 'apikeys.scope.read',
write: 'apikeys.scope.write',
deploy: 'apikeys.scope.deploy',
approve: 'apikeys.scope.approve',
deny: 'apikeys.scope.deny',
};
function tKey(key) {
@@ -35,6 +41,7 @@ function mapKeyRecord(record) {
return {
id: apiKey.id,
agentId: apiKey.agent_id || null,
keyKind: apiKey.key_kind || 'mcp_client',
name: apiKey.name,
prefix: apiKey.prefix || '',
scopes: apiKey.scopes || [],
@@ -173,6 +180,7 @@ async function deleteKey(id) {
async function createKey(name, scopes) {
var created = await window.CrankApi.createAgentPlatformApiKey(currentWorkspaceId, currentAgentId, {
name: name,
key_kind: activeKeyKind,
scopes: scopes,
});
return {
@@ -221,13 +229,19 @@ function setCreateButtonState() {
var button = document.getElementById('btn-create-key');
if (!button) return;
button.disabled = !currentAgentId;
var label = document.getElementById('btn-create-key-label');
if (label) {
label.textContent = activeKeyKind === 'approval'
? tKey('apikeys.new_approval')
: tKey('apikeys.new_mcp');
}
}
function renderScopes() {
var el = document.getElementById('scope-checkboxes');
var tmpl = document.getElementById('tmpl-scope-checkbox');
el.innerHTML = '';
SCOPES.forEach(function(scope) {
(SCOPES_BY_KIND[activeKeyKind] || []).forEach(function(scope) {
var node = tmpl.content.cloneNode(true);
var input = node.querySelector('input');
input.dataset.scope = scope;
@@ -242,11 +256,49 @@ function renderScopes() {
});
}
function renderKeyKindTabs() {
document.querySelectorAll('[data-key-kind]').forEach(function(button) {
var kind = button.dataset.keyKind;
button.classList.toggle('active', kind === activeKeyKind);
button.setAttribute('aria-selected', kind === activeKeyKind ? 'true' : 'false');
});
var hint = document.getElementById('key-kind-hint');
if (hint) {
hint.textContent = activeKeyKind === 'approval'
? tKey('apikeys.kind.approval_hint')
: tKey('apikeys.kind.mcp_hint');
}
renderScopeReference();
setCreateButtonState();
}
function renderScopeReference() {
var grid = document.getElementById('scope-reference-grid');
if (!grid) return;
grid.innerHTML = '';
(SCOPES_BY_KIND[activeKeyKind] || []).forEach(function(scope) {
var item = document.createElement('div');
var title = document.createElement('div');
title.style.cssText = 'font-size:12.5px;font-weight:600;color:var(--text-primary);margin-bottom:4px;';
var badge = document.createElement('span');
badge.className = 'badge badge-scope';
badge.textContent = scope;
title.appendChild(badge);
var description = document.createElement('div');
description.style.cssText = 'font-size:12px;color:var(--text-muted);line-height:1.55;';
description.textContent = tKey(SCOPE_DESC[scope]);
item.appendChild(title);
item.appendChild(description);
grid.appendChild(item);
});
}
function renderTable(errorMessage) {
var q = search.toLowerCase();
var tbody = document.getElementById('keys-tbody');
var cardList = document.getElementById('keys-card-list');
var rows = KEYS.filter(function(key) {
var visibleKeys = KEYS.filter(function(key) { return key.keyKind === activeKeyKind; });
var rows = visibleKeys.filter(function(key) {
return !q || key.name.toLowerCase().includes(q) || key.prefix.toLowerCase().includes(q);
});
var subtitle = document.getElementById('keys-summary-subtitle');
@@ -257,8 +309,8 @@ function renderTable(errorMessage) {
}
if (subtitle) {
var active = KEYS.filter(function(key) { return key.status === 'active'; }).length;
var revoked = KEYS.filter(function(key) { return key.status === 'revoked'; }).length;
var active = visibleKeys.filter(function(key) { return key.status === 'active'; }).length;
var revoked = visibleKeys.filter(function(key) { return key.status === 'revoked'; }).length;
subtitle.textContent = currentAgentId
? tfKey('apikeys.active.subtitle', { active: active, revoked: revoked })
: tKey('apikeys.agent.empty_hint');
@@ -282,7 +334,7 @@ function renderTable(errorMessage) {
td.colSpan = 7;
td.style.cssText = 'text-align:center;padding:36px;color:var(--text-muted);';
td.textContent = currentAgentId
? (KEYS.length ? tKey('apikeys.empty.search') : tKey('apikeys.empty.none'))
? (visibleKeys.length ? tKey('apikeys.empty.search') : emptyTextForKind())
: tKey('apikeys.agent.empty_hint');
empty.appendChild(td);
tbody.appendChild(empty);
@@ -349,7 +401,7 @@ function renderKeyCards(rows, errorMessage) {
cardList.appendChild(
buildKeyCardMessage(
currentAgentId
? (KEYS.length ? tKey('apikeys.empty.search') : tKey('apikeys.empty.none'))
? (visibleKeys.length ? tKey('apikeys.empty.search') : emptyTextForKind())
: tKey('apikeys.agent.empty_hint'),
false
)
@@ -424,6 +476,12 @@ function buildKeyCardMessage(text, isError) {
return card;
}
function emptyTextForKind() {
return activeKeyKind === 'approval'
? tKey('apikeys.empty.approval')
: tKey('apikeys.empty.none');
}
function buildMetaItem(labelKey, valueText) {
var item = document.createElement('div');
item.className = 'resource-meta-item';
@@ -456,7 +514,11 @@ function openModal() {
document.getElementById('modal-footer-create').hidden = false;
document.getElementById('modal-footer-done').hidden = true;
document.getElementById('new-key-name').value = '';
selectedScopes = new Set(['read']);
selectedScopes = new Set([activeKeyKind === 'approval' ? 'approve' : 'read']);
document.getElementById('modal-create-title').textContent = activeKeyKind === 'approval'
? tKey('apikeys.modal.title_approval')
: tKey('apikeys.modal.title_mcp');
document.getElementById('approval-key-warning').hidden = activeKeyKind !== 'approval';
renderScopes();
modal.classList.add('open');
setTimeout(function() {
@@ -554,6 +616,16 @@ document.getElementById('agent-select').addEventListener('change', async functio
await loadKeys();
});
document.querySelectorAll('[data-key-kind]').forEach(function(button) {
button.addEventListener('click', function() {
activeKeyKind = this.dataset.keyKind || 'mcp_client';
search = '';
document.getElementById('key-search').value = '';
renderKeyKindTabs();
renderTable();
});
});
function copyPrefix(prefix) {
if (navigator.clipboard) {
navigator.clipboard.writeText(prefix).catch(function() {});
@@ -564,6 +636,7 @@ function copyPrefix(prefix) {
}
document.addEventListener('DOMContentLoaded', async function() {
renderKeyKindTabs();
await (window.whenWorkspacesReady ? window.whenWorkspacesReady() : Promise.resolve());
await loadKeys();
window.addEventListener('crank:workspacechange', function() {
+24
View File
@@ -95,6 +95,12 @@ var TRANSLATIONS = {
'apikeys.title': 'Agent Keys',
'apikeys.subtitle': 'These keys connect an MCP client to the MCP server and are issued for a specific agent.',
'apikeys.new': 'Create key',
'apikeys.new_mcp': 'Create MCP client key',
'apikeys.new_approval': 'Create approval key',
'apikeys.kind.mcp': 'MCP clients',
'apikeys.kind.approval': 'Approvals',
'apikeys.kind.mcp_hint': 'MCP client keys connect an agent to tools/list and tools/call.',
'apikeys.kind.approval_hint': 'Approval keys are used only by an external human confirmation interface.',
'apikeys.agent.title': 'Agent selection',
'apikeys.agent.subtitle': 'Select the AI agent this key is issued for.',
'apikeys.agent.label': 'AI agent',
@@ -118,7 +124,11 @@ var TRANSLATIONS = {
'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.scope.approve': 'Confirm a pending human approval request for this agent.',
'apikeys.scope.deny': 'Reject a pending human approval request for this agent.',
'apikeys.modal.title': 'Create agent key',
'apikeys.modal.title_mcp': 'Create MCP client key',
'apikeys.modal.title_approval': 'Create approval 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',
@@ -133,6 +143,7 @@ var TRANSLATIONS = {
'apikeys.status.revoked': 'Revoked',
'apikeys.last_used.never': 'Never',
'apikeys.empty.none': 'No API keys yet',
'apikeys.empty.approval': 'No approval keys yet. Create one only if this agent has tools that require human confirmation.',
'apikeys.empty.search': 'No keys match your search',
'apikeys.loading': 'Loading…',
'apikeys.error.api': 'Workspace or API is unavailable',
@@ -160,6 +171,7 @@ var TRANSLATIONS = {
'apikeys.action.revoke': 'Revoke key',
'apikeys.action.delete': 'Delete',
'apikeys.creating': 'Creating…',
'apikeys.approval.warning': 'Do not pass this key to an LLM or MCP client. It is only for an external interface where a human confirms an action.',
// Secrets page
'secrets.title': 'Secrets',
@@ -905,6 +917,12 @@ var TRANSLATIONS = {
'apikeys.title': 'Ключи агентов',
'apikeys.subtitle': 'Эти ключи используются для подключения MCP клиента к MCP серверу и выдаются на конкретного агента.',
'apikeys.new': 'Создать ключ',
'apikeys.new_mcp': 'Создать ключ MCP-клиента',
'apikeys.new_approval': 'Создать ключ подтверждения',
'apikeys.kind.mcp': 'MCP-клиенты',
'apikeys.kind.approval': 'Подтверждения',
'apikeys.kind.mcp_hint': 'Ключи MCP-клиентов используются для подключения к tools/list и tools/call агента.',
'apikeys.kind.approval_hint': 'Ключи подтверждения используются только внешним интерфейсом, где человек подтверждает действие.',
'apikeys.agent.title': 'Выбор агента',
'apikeys.agent.subtitle': 'Выберите AI-агента для которого выпускается ключ.',
'apikeys.agent.label': 'AI-агент',
@@ -928,7 +946,11 @@ var TRANSLATIONS = {
'apikeys.scope.read': 'Инициализация MCP-сессий, ping сервера и получение списка инструментов для выбранного AI-агента.',
'apikeys.scope.write': 'Выполнение `tools/call` для опубликованных наборов инструментов агента.',
'apikeys.scope.deploy': 'Зарезервировано для deploy-автоматизации. Сейчас также разрешает MCP read/write сценарии.',
'apikeys.scope.approve': 'Подтверждение ожидающего human approval запроса для этого агента.',
'apikeys.scope.deny': 'Отклонение ожидающего human approval запроса для этого агента.',
'apikeys.modal.title': 'Создать ключ агента',
'apikeys.modal.title_mcp': 'Создать ключ MCP-клиента',
'apikeys.modal.title_approval': 'Создать ключ подтверждения',
'apikeys.modal.name': 'Имя ключа',
'apikeys.modal.name_hint': 'Понятная метка для идентификации ключа. Видна только администраторам.',
'apikeys.modal.name_placeholder': 'например, Production, CI pipeline',
@@ -943,6 +965,7 @@ var TRANSLATIONS = {
'apikeys.status.revoked': 'Отозван',
'apikeys.last_used.never': 'Никогда',
'apikeys.empty.none': 'API-ключей пока нет',
'apikeys.empty.approval': 'Ключей подтверждения пока нет. Они нужны только агентам с инструментами, требующими подтверждения человеком.',
'apikeys.empty.search': 'Нет ключей по текущему поиску',
'apikeys.loading': 'Загрузка…',
'apikeys.error.api': 'Воркспейс или API недоступен',
@@ -970,6 +993,7 @@ var TRANSLATIONS = {
'apikeys.action.revoke': 'Отозвать ключ',
'apikeys.action.delete': 'Удалить',
'apikeys.creating': 'Создание…',
'apikeys.approval.warning': 'Не передавайте этот ключ LLM или MCP-клиенту. Он нужен только внешнему интерфейсу, где человек подтверждает действие.',
// Secrets page
'secrets.title': 'Секреты',
+18 -1
View File
@@ -22,8 +22,25 @@ test('api keys page opens create key flow', async ({ page }) => {
await expect(page.locator('#btn-create-key')).toBeEnabled();
await page.locator('#btn-create-key').click();
await expect(page.locator('#modal-create')).toHaveClass(/open/);
await expect(page.locator('.modal-title')).toHaveText(localized('Create agent key', 'Создать ключ агента'));
await expect(page.locator('.modal-title')).toHaveText(localized('Create MCP client key', 'Создать ключ MCP-клиента'));
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', 'Скопируйте этот ключ сейчас'));
await page.locator('#modal-done-btn').click();
await page.locator('#key-kind-approval').click();
await expect(page.locator('#key-kind-hint')).toContainText(
localized('human confirmation interface', 'человек подтверждает действие')
);
await expect(page.locator('#btn-create-key')).toContainText(
localized('Create approval key', 'Создать ключ подтверждения')
);
await page.locator('#btn-create-key').click();
await expect(page.locator('.modal-title')).toHaveText(localized('Create approval key', 'Создать ключ подтверждения'));
await expect(page.locator('#approval-key-warning')).toContainText(
localized('Do not pass this key to an LLM', 'Не передавайте этот ключ LLM')
);
await page.locator('#new-key-name').fill(`playwright-approval-${Date.now()}`);
await page.locator('#modal-confirm-btn').click();
await expect(page.locator('#reveal-key-value')).toContainText('crk_appr_');
});
+3
View File
@@ -130,6 +130,9 @@ fn allows_scope(scopes: &[PlatformApiKeyScope], required_scope: PlatformApiKeySc
PlatformApiKeyScope::Deploy => scopes
.iter()
.any(|scope| matches!(scope, PlatformApiKeyScope::Deploy)),
PlatformApiKeyScope::Approve
| PlatformApiKeyScope::Deny
| PlatformApiKeyScope::ReadPending => false,
}
}
+28 -1
View File
@@ -35,12 +35,22 @@ pub enum PlatformApiKeyStatus {
Revoked,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PlatformApiKeyKind {
McpClient,
Approval,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PlatformApiKeyScope {
Read,
Write,
Deploy,
Approve,
Deny,
ReadPending,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
@@ -82,6 +92,8 @@ pub struct PlatformApiKey {
pub workspace_id: WorkspaceId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_id: Option<AgentId>,
#[serde(default = "default_platform_api_key_kind")]
pub key_kind: PlatformApiKeyKind,
pub name: String,
pub prefix: String,
pub scopes: Vec<PlatformApiKeyScope>,
@@ -90,6 +102,14 @@ pub struct PlatformApiKey {
pub created_at: OffsetDateTime,
#[serde(with = "time::serde::rfc3339::option")]
pub last_used_at: Option<OffsetDateTime>,
#[serde(with = "time::serde::rfc3339::option")]
pub expires_at: Option<OffsetDateTime>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub allowed_origins: Vec<String>,
}
fn default_platform_api_key_kind() -> PlatformApiKeyKind {
PlatformApiKeyKind::McpClient
}
#[cfg(test)]
@@ -97,7 +117,10 @@ mod tests {
use serde_json::json;
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
use super::{PlatformApiKey, PlatformApiKeyScope, PlatformApiKeyStatus, User, UserStatus};
use super::{
PlatformApiKey, PlatformApiKeyKind, PlatformApiKeyScope, PlatformApiKeyStatus, User,
UserStatus,
};
use crate::ids::{AgentId, PlatformApiKeyId, UserId, WorkspaceId};
#[test]
@@ -138,16 +161,20 @@ mod tests {
id: PlatformApiKeyId::new("pk_01"),
workspace_id: WorkspaceId::new("ws_01"),
agent_id: Some(AgentId::new("agent_01")),
key_kind: PlatformApiKeyKind::McpClient,
name: "Primary".to_owned(),
prefix: "crk_live".to_owned(),
scopes: vec![PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
status: PlatformApiKeyStatus::Active,
created_at: OffsetDateTime::parse("2026-03-25T12:01:00Z", &Rfc3339).unwrap(),
last_used_at: Some(OffsetDateTime::parse("2026-03-25T12:05:00Z", &Rfc3339).unwrap()),
expires_at: None,
allowed_origins: Vec::new(),
};
let value = serde_json::to_value(&api_key).unwrap();
assert_eq!(value["key_kind"], json!("mcp_client"));
assert_eq!(value["created_at"], json!("2026-03-25T12:01:00Z"));
assert_eq!(value["last_used_at"], json!("2026-03-25T12:05:00Z"));
}
+2 -2
View File
@@ -15,7 +15,7 @@ pub mod workspace;
pub mod domain {
pub use crate::access::{
InvitationStatus, InvitationToken, Membership, MembershipRole, PlatformApiKey,
PlatformApiKeyScope, PlatformApiKeyStatus, User, UserStatus,
PlatformApiKeyKind, PlatformApiKeyScope, PlatformApiKeyStatus, User, UserStatus,
};
pub use crate::agent::{Agent, AgentOperationBinding, AgentStatus, AgentVersion};
pub use crate::auth::{
@@ -85,7 +85,7 @@ pub mod ports {
pub use access::{
InvitationStatus, InvitationToken, Membership, MembershipRole, PlatformApiKey,
PlatformApiKeyScope, PlatformApiKeyStatus, User, UserStatus,
PlatformApiKeyKind, PlatformApiKeyScope, PlatformApiKeyStatus, User, UserStatus,
};
pub use agent::{Agent, AgentOperationBinding, AgentStatus, AgentVersion};
pub use auth::{
+17 -1
View File
@@ -148,11 +148,14 @@ pub async fn apply_postgres(pool: &PgPool) -> Result<(), sqlx::Error> {
name text not null,
prefix text not null,
secret_hash text not null,
key_kind text not null default 'mcp_client',
scopes_json jsonb not null,
status text not null,
created_at timestamptz not null,
last_used_at timestamptz null,
revoked_at timestamptz null
revoked_at timestamptz null,
expires_at timestamptz null,
allowed_origins_json jsonb not null default '[]'::jsonb
)",
)
.execute(pool)
@@ -166,6 +169,19 @@ pub async fn apply_postgres(pool: &PgPool) -> Result<(), sqlx::Error> {
query("alter table platform_api_keys add column if not exists agent_id text null")
.execute(pool)
.await?;
query(
"alter table platform_api_keys add column if not exists key_kind text not null default 'mcp_client'",
)
.execute(pool)
.await?;
query("alter table platform_api_keys add column if not exists expires_at timestamptz null")
.execute(pool)
.await?;
query(
"alter table platform_api_keys add column if not exists allowed_origins_json jsonb not null default '[]'::jsonb",
)
.execute(pool)
.await?;
query(
"insert into workspaces (
+43 -5
View File
@@ -11,12 +11,15 @@ impl PostgresRegistry {
id,
workspace_id,
agent_id,
key_kind,
name,
prefix,
scopes_json,
status,
created_at,
last_used_at
last_used_at,
expires_at,
allowed_origins_json
from platform_api_keys
where workspace_id = $1
and agent_id = $2
@@ -34,12 +37,20 @@ impl PostgresRegistry {
id: PlatformApiKeyId::new(row.get::<String, _>("id")),
workspace_id: WorkspaceId::new(row.get::<String, _>("workspace_id")),
agent_id: row.get::<Option<String>, _>("agent_id").map(AgentId::new),
key_kind: deserialize_enum_text(
&row.get::<String, _>("key_kind"),
"key_kind",
)?,
name: row.get("name"),
prefix: row.get("prefix"),
scopes: deserialize_json_value(row.get::<Value, _>("scopes_json"))?,
status: deserialize_enum_text(&row.get::<String, _>("status"), "status")?,
created_at: row.get("created_at"),
last_used_at: row.get("last_used_at"),
expires_at: row.get("expires_at"),
allowed_origins: deserialize_json_value(
row.get::<Value, _>("allowed_origins_json"),
)?,
},
})
})
@@ -55,12 +66,15 @@ impl PostgresRegistry {
id,
workspace_id,
agent_id,
key_kind,
name,
prefix,
scopes_json,
status,
created_at,
last_used_at
last_used_at,
expires_at,
allowed_origins_json
from platform_api_keys
where workspace_id = $1
order by created_at desc",
@@ -76,12 +90,20 @@ impl PostgresRegistry {
id: PlatformApiKeyId::new(row.get::<String, _>("id")),
workspace_id: WorkspaceId::new(row.get::<String, _>("workspace_id")),
agent_id: row.get::<Option<String>, _>("agent_id").map(AgentId::new),
key_kind: deserialize_enum_text(
&row.get::<String, _>("key_kind"),
"key_kind",
)?,
name: row.get("name"),
prefix: row.get("prefix"),
scopes: deserialize_json_value(row.get::<Value, _>("scopes_json"))?,
status: deserialize_enum_text(&row.get::<String, _>("status"), "status")?,
created_at: row.get("created_at"),
last_used_at: row.get("last_used_at"),
expires_at: row.get("expires_at"),
allowed_origins: deserialize_json_value(
row.get::<Value, _>("allowed_origins_json"),
)?,
},
})
})
@@ -99,19 +121,24 @@ impl PostgresRegistry {
k.id,
k.workspace_id,
k.agent_id,
k.key_kind,
k.name,
k.prefix,
k.scopes_json,
k.status,
k.created_at,
k.last_used_at
k.last_used_at,
k.expires_at,
k.allowed_origins_json
from platform_api_keys k
join workspaces w on w.id = k.workspace_id
join agents a on a.id = k.agent_id
where w.slug = $1
and a.slug = $2
and k.secret_hash = $3
and k.key_kind = 'mcp_client'
and k.status = 'active'
and (k.expires_at is null or k.expires_at > now())
limit 1",
)
.bind(workspace_slug)
@@ -126,12 +153,17 @@ impl PostgresRegistry {
id: PlatformApiKeyId::new(row.get::<String, _>("id")),
workspace_id: WorkspaceId::new(row.get::<String, _>("workspace_id")),
agent_id: row.get::<Option<String>, _>("agent_id").map(AgentId::new),
key_kind: deserialize_enum_text(&row.get::<String, _>("key_kind"), "key_kind")?,
name: row.get("name"),
prefix: row.get("prefix"),
scopes: deserialize_json_value(row.get::<Value, _>("scopes_json"))?,
status: deserialize_enum_text(&row.get::<String, _>("status"), "status")?,
created_at: row.get("created_at"),
last_used_at: row.get("last_used_at"),
expires_at: row.get("expires_at"),
allowed_origins: deserialize_json_value(
row.get::<Value, _>("allowed_origins_json"),
)?,
},
})
})
@@ -150,13 +182,16 @@ impl PostgresRegistry {
name,
prefix,
secret_hash,
key_kind,
scopes_json,
status,
created_at,
last_used_at,
revoked_at
revoked_at,
expires_at,
allowed_origins_json
) values (
$1, $2, $3, $4, $5, $6, $7, $8, $9::timestamptz, $10::timestamptz, $11::timestamptz
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10::timestamptz, $11::timestamptz, $12::timestamptz, $13::timestamptz, $14
)",
)
.bind(request.api_key.id.as_str())
@@ -165,11 +200,14 @@ impl PostgresRegistry {
.bind(&request.api_key.name)
.bind(&request.api_key.prefix)
.bind(request.secret_hash)
.bind(serialize_enum_text(&request.api_key.key_kind, "key_kind")?)
.bind(Json(serialize_json_value(&request.api_key.scopes)?))
.bind(serialize_enum_text(&request.api_key.status, "status")?)
.bind(request.api_key.created_at)
.bind(request.api_key.last_used_at)
.bind(Option::<&str>::None)
.bind(request.api_key.expires_at)
.bind(Json(serialize_json_value(&request.api_key.allowed_origins)?))
.execute(&self.pool)
.await;
@@ -8,9 +8,10 @@ use crank_core::{
AgentId, AgentOperationBinding, AgentStatus, AgentVersion, ApiKeyHeaderAuthConfig, AuthConfig,
AuthKind, AuthProfile, ConfigExport, ExecutionConfig, ExportMode, GeneratedDraft,
GeneratedDraftStatus, HttpMethod, InvocationLog, MembershipRole, OperationId,
OperationSecurityLevel, OperationStatus, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyScope,
PlatformApiKeyStatus, Protocol, RestTarget, RetryPolicy, Samples, SecretId, Target,
ToolDescription, ToolExample, User, UserId, UserSessionId, WizardState, Workspace, WorkspaceId,
OperationSecurityLevel, OperationStatus, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyKind,
PlatformApiKeyScope, PlatformApiKeyStatus, Protocol, RestTarget, RetryPolicy, Samples,
SecretId, Target, ToolDescription, ToolExample, User, UserId, UserSessionId, WizardState,
Workspace, WorkspaceId,
};
use crank_mapping::{MappingRule, MappingSet};
use crank_schema::{Schema, SchemaKind};
@@ -372,12 +373,15 @@ async fn manages_platform_api_key_read_paths() {
id: PlatformApiKeyId::new("key_01"),
workspace_id: workspace.id.clone(),
agent_id: Some(agent.id.clone()),
key_kind: PlatformApiKeyKind::McpClient,
name: "Primary".to_owned(),
prefix: "crk_live".to_owned(),
scopes: vec![PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
status: PlatformApiKeyStatus::Active,
created_at: timestamp("2026-03-25T12:01:00Z"),
last_used_at: None,
expires_at: None,
allowed_origins: Vec::new(),
};
let secret_hash = "secret_hash_01";