Split MCP and approval agent keys
This commit is contained in:
@@ -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)]
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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(®istry, "sales-approval-key", vec![]).await;
|
||||
let api_key =
|
||||
create_approval_platform_api_key(®istry, "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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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': 'Секреты',
|
||||
|
||||
@@ -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_');
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user