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
+8 -47
View File
@@ -2,55 +2,9 @@
## Current
### `feat/community-agent-key-cutover`
Status: in_progress
Goal:
- довести открытую редакцию до продуктово завершенного машинного доступа через ключ AI-агента.
Main code areas:
- `crates/crank-core/src/agent.rs`
- `crates/crank-core/src/auth.rs`
- `crates/crank-registry/src/postgres/agent.rs`
- `crates/crank-registry/src/postgres/api_key.rs`
- `apps/admin-api/src/service.rs`
- `apps/admin-api/src/app.rs`
- `apps/mcp-server/src/app.rs`
- `apps/ui/js/agents.js`
- `apps/ui/js/api-keys.js`
- `apps/ui/html/agents.html`
- `apps/ui/html/api-keys.html`
- `apps/ui/js/i18n.js`
Implementation slices:
1. Закрыть remaining workspace/platform-key assumptions в `admin-api` и `mcp-server`.
2. Нормализовать list/create/revoke/delete flow для agent keys.
3. Привести UI страницы `Agents` и `API Keys` к одной модели agent-scoped machine access.
4. Явно ограничить `Community` на `security_level = standard`.
Progress:
- done: `mcp-server` принимает ключ только по `(workspace_slug, agent_slug, secret_hash)`;
- done: `admin-api` уже отдает и принимает agent-scoped key routes;
- done: UI `API Keys` переведен на agent-scoped machine access;
- done: removed legacy workspace-key routes, demo/bootstrap assumptions, and per-agent key count bug.
DoD:
- ключ создается и принадлежит конкретному AI-агенту;
- UI объясняет назначение ключа без ссылок на старую workspace-key модель;
- `mcp-server` принимает Community machine access только по agent key;
- Community flow не требует short-lived token service.
Verification:
- backend unit and integration tests for key lifecycle;
- MCP tool-call tests through agent endpoint;
- e2e smoke for agent key create/reveal/revoke.
## Next
### `feat/edition-capability-model`
Status: ready
Status: in_progress
Goal:
- ввести capability model по редакциям и перестать смешивать Community и premium surface.
@@ -74,6 +28,11 @@ Implementation slices:
3. Привести UI к capability gating вместо скрытых продуктовых допущений.
4. Зафиксировать Community defaults в docs и runtime config.
Progress:
- pending: define shared capability payload in `crank-core` and `admin-api`;
- pending: expose Community capability defaults to UI;
- pending: gate premium protocols and security levels in wizard and agents flows.
DoD:
- UI знает, какие функции входят в текущую редакцию;
- Community не показывает рабочие controls для premium-only features;
@@ -84,6 +43,8 @@ Verification:
- frontend smoke for hidden/locked states;
- manual verification on Community build.
## Next
### `feat/private-token-service-seam`
Status: ready
+97 -61
View File
@@ -9,13 +9,14 @@ use crate::{
request_context::apply_request_context,
routes::{
access::{
create_invitation, create_platform_api_key, delete_invitation, delete_membership,
delete_platform_api_key, delete_workspace, export_workspace, list_invitations,
list_memberships, list_platform_api_keys, revoke_platform_api_key, update_membership,
create_invitation, delete_invitation, delete_membership, delete_workspace,
export_workspace, list_invitations, list_memberships, update_membership,
},
agents::{
archive_agent, create_agent, delete_agent, get_agent, get_agent_version, list_agents,
publish_agent, save_agent_bindings, unpublish_agent, update_agent,
archive_agent, create_agent, create_agent_platform_api_key, delete_agent,
delete_agent_platform_api_key, get_agent, get_agent_version,
list_agent_platform_api_keys, list_agents, publish_agent,
revoke_agent_platform_api_key, save_agent_bindings, unpublish_agent, update_agent,
},
auth::{
change_password, get_profile, get_session, login, logout, update_current_workspace,
@@ -114,6 +115,18 @@ pub fn build_app(state: AppState) -> Router {
.route("/agents/{agent_id}/publish", post(publish_agent))
.route("/agents/{agent_id}/unpublish", post(unpublish_agent))
.route("/agents/{agent_id}/archive", post(archive_agent))
.route(
"/agents/{agent_id}/platform-api-keys",
get(list_agent_platform_api_keys).post(create_agent_platform_api_key),
)
.route(
"/agents/{agent_id}/platform-api-keys/{key_id}/revoke",
post(revoke_agent_platform_api_key),
)
.route(
"/agents/{agent_id}/platform-api-keys/{key_id}",
delete(delete_agent_platform_api_key),
)
.route(
"/auth-profiles",
get(list_auth_profiles).post(create_auth_profile),
@@ -136,18 +149,6 @@ pub fn build_app(state: AppState) -> Router {
)
.route("/invitations/{invitation_id}", delete(delete_invitation))
.route("/export", get(export_workspace))
.route(
"/platform-api-keys",
get(list_platform_api_keys).post(create_platform_api_key),
)
.route(
"/platform-api-keys/{key_id}/revoke",
post(revoke_platform_api_key),
)
.route(
"/platform-api-keys/{key_id}",
delete(delete_platform_api_key),
)
.route("/logs", get(list_logs))
.route("/logs/{log_id}", get(get_log))
.route("/usage", get(get_usage))
@@ -891,48 +892,12 @@ mod tests {
.json::<Value>()
.await
.unwrap();
let created_key = client
.post(format!("{base_url}/platform-api-keys"))
.json(&json!({
"name": "workspace-operator",
"scopes": ["read", "write"]
}))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
let key_id = created_key["api_key"]["api_key"]["id"]
.as_str()
.unwrap()
.to_owned();
let listed_keys = client
.get(format!("{base_url}/platform-api-keys"))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
let revoke_status = client
.post(format!("{base_url}/platform-api-keys/{key_id}/revoke"))
.send()
.await
.unwrap()
.status();
let delete_invitation_status = client
.delete(format!("{base_url}/invitations/{invitation_id}"))
.send()
.await
.unwrap()
.status();
let delete_key_status = client
.delete(format!("{base_url}/platform-api-keys/{key_id}"))
.send()
.await
.unwrap()
.status();
assert_eq!(members["items"][0]["role"], "owner");
assert_eq!(
@@ -949,14 +914,89 @@ mod tests {
invitations["items"][0]["invitation"]["email"],
"operator@example.com"
);
assert_eq!(delete_invitation_status, reqwest::StatusCode::NO_CONTENT);
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn manages_agent_platform_api_keys() {
let registry = test_registry().await;
let storage_root = test_storage_root("agent_platform_keys");
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
let client = authorized_client(&base_url).await;
let created_agent = assert_success_json(
client
.post(format!("{base_url}/agents"))
.json(&json!({
"slug": "sales-routing",
"display_name": "Sales Routing",
"description": "Routing agent",
"instructions": {},
"tool_selection_policy": {}
}))
.send()
.await
.unwrap(),
)
.await;
let agent_id = created_agent["agent_id"].as_str().unwrap().to_owned();
let created_key = assert_success_json(
client
.post(format!("{base_url}/agents/{agent_id}/platform-api-keys"))
.json(&json!({
"name": "sales-routing-primary",
"scopes": ["read", "write"]
}))
.send()
.await
.unwrap(),
)
.await;
let key_id = created_key["api_key"]["api_key"]["id"]
.as_str()
.unwrap()
.to_owned();
let listed_keys = assert_success_json(
client
.get(format!("{base_url}/agents/{agent_id}/platform-api-keys"))
.send()
.await
.unwrap(),
)
.await;
let revoke_status = client
.post(format!(
"{base_url}/agents/{agent_id}/platform-api-keys/{key_id}/revoke"
))
.send()
.await
.unwrap()
.status();
let delete_status = client
.delete(format!(
"{base_url}/agents/{agent_id}/platform-api-keys/{key_id}"
))
.send()
.await
.unwrap()
.status();
assert_eq!(created_key["api_key"]["api_key"]["agent_id"], agent_id);
assert_eq!(
listed_keys["items"][0]["api_key"]["agent_id"],
json!(agent_id)
);
assert_eq!(
listed_keys["items"][0]["api_key"]["name"],
"workspace-operator"
"sales-routing-primary"
);
assert!(created_key["secret"].as_str().unwrap().starts_with("crk_"));
assert_eq!(revoke_status, reqwest::StatusCode::NO_CONTENT);
assert_eq!(delete_invitation_status, reqwest::StatusCode::NO_CONTENT);
assert_eq!(delete_key_status, reqwest::StatusCode::NO_CONTENT);
assert_eq!(delete_status, reqwest::StatusCode::NO_CONTENT);
}
#[tokio::test(flavor = "multi_thread")]
@@ -1087,11 +1127,7 @@ mod tests {
let agents = service.list_agents(&default_workspace_id).await.unwrap();
assert!(agents.len() >= 2);
let api_keys = service
.list_platform_api_keys(&default_workspace_id)
.await
.unwrap();
assert!(api_keys.len() >= 2);
assert!(agents.iter().any(|agent| agent.key_count > 0));
let logs = service
.list_logs(
+1 -58
View File
@@ -9,7 +9,7 @@ use serde_json::{Value, json};
use crate::{
auth::AuthenticatedSession,
error::ApiError,
service::{InvitationPayload, PlatformApiKeyPayload, UpdateMembershipPayload},
service::{InvitationPayload, UpdateMembershipPayload},
state::AppState,
};
@@ -30,12 +30,6 @@ pub struct WorkspaceMembershipPath {
pub user_id: String,
}
#[derive(Deserialize)]
pub struct WorkspacePlatformApiKeyPath {
pub workspace_id: String,
pub key_id: String,
}
pub async fn list_memberships(
Path(path): Path<WorkspacePath>,
State(state): State<AppState>,
@@ -118,57 +112,6 @@ pub async fn delete_invitation(
Ok(StatusCode::NO_CONTENT)
}
pub async fn list_platform_api_keys(
Path(path): Path<WorkspacePath>,
State(state): State<AppState>,
) -> Result<Json<Value>, ApiError> {
let items = state
.service
.list_platform_api_keys(&path.workspace_id.as_str().into())
.await?;
Ok(Json(json!({ "items": items })))
}
pub async fn create_platform_api_key(
Path(path): Path<WorkspacePath>,
State(state): State<AppState>,
Json(payload): Json<PlatformApiKeyPayload>,
) -> Result<Json<Value>, ApiError> {
let created = state
.service
.create_platform_api_key(&path.workspace_id.as_str().into(), payload)
.await?;
Ok(Json(json!(created)))
}
pub async fn revoke_platform_api_key(
Path(path): Path<WorkspacePlatformApiKeyPath>,
State(state): State<AppState>,
) -> Result<StatusCode, ApiError> {
state
.service
.revoke_platform_api_key(
&path.workspace_id.as_str().into(),
&path.key_id.as_str().into(),
)
.await?;
Ok(StatusCode::NO_CONTENT)
}
pub async fn delete_platform_api_key(
Path(path): Path<WorkspacePlatformApiKeyPath>,
State(state): State<AppState>,
) -> Result<StatusCode, ApiError> {
state
.service
.delete_platform_api_key(
&path.workspace_id.as_str().into(),
&path.key_id.as_str().into(),
)
.await?;
Ok(StatusCode::NO_CONTENT)
}
pub async fn export_workspace(
Path(path): Path<WorkspacePath>,
State(state): State<AppState>,
+71 -1
View File
@@ -7,7 +7,10 @@ use serde_json::{Value, json};
use crate::{
error::ApiError,
service::{AgentBindingPayload, AgentPayload, PublishPayload, UpdateAgentPayload},
service::{
AgentBindingPayload, AgentPayload, PlatformApiKeyPayload, PublishPayload,
UpdateAgentPayload,
},
state::AppState,
};
@@ -29,6 +32,13 @@ pub struct WorkspaceAgentVersionPath {
pub version: u32,
}
#[derive(Deserialize)]
pub struct WorkspaceAgentPlatformApiKeyPath {
pub workspace_id: String,
pub agent_id: String,
pub key_id: String,
}
pub async fn list_agents(
Path(path): Path<WorkspacePath>,
State(state): State<AppState>,
@@ -170,3 +180,63 @@ pub async fn archive_agent(
.await?;
Ok(Json(json!(updated)))
}
pub async fn list_agent_platform_api_keys(
Path(path): Path<WorkspaceAgentPath>,
State(state): State<AppState>,
) -> Result<Json<Value>, ApiError> {
let items = state
.service
.list_agent_platform_api_keys(
&path.workspace_id.as_str().into(),
&path.agent_id.as_str().into(),
)
.await?;
Ok(Json(json!({ "items": items })))
}
pub async fn create_agent_platform_api_key(
Path(path): Path<WorkspaceAgentPath>,
State(state): State<AppState>,
Json(payload): Json<PlatformApiKeyPayload>,
) -> Result<Json<Value>, ApiError> {
let created = state
.service
.create_agent_platform_api_key(
&path.workspace_id.as_str().into(),
&path.agent_id.as_str().into(),
payload,
)
.await?;
Ok(Json(json!(created)))
}
pub async fn revoke_agent_platform_api_key(
Path(path): Path<WorkspaceAgentPlatformApiKeyPath>,
State(state): State<AppState>,
) -> Result<axum::http::StatusCode, ApiError> {
state
.service
.revoke_agent_platform_api_key(
&path.workspace_id.as_str().into(),
&path.agent_id.as_str().into(),
&path.key_id.as_str().into(),
)
.await?;
Ok(axum::http::StatusCode::NO_CONTENT)
}
pub async fn delete_agent_platform_api_key(
Path(path): Path<WorkspaceAgentPlatformApiKeyPath>,
State(state): State<AppState>,
) -> Result<axum::http::StatusCode, ApiError> {
state
.service
.delete_agent_platform_api_key(
&path.workspace_id.as_str().into(),
&path.agent_id.as_str().into(),
&path.key_id.as_str().into(),
)
.await?;
Ok(axum::http::StatusCode::NO_CONTENT)
}
+59 -26
View File
@@ -1279,7 +1279,7 @@ impl AdminService {
.collect();
let operations = self.list_operations(workspace_id).await?;
let agents = self.list_agents(workspace_id).await?;
let platform_api_keys = self.list_platform_api_keys(workspace_id).await?;
let platform_api_keys = self.registry.list_platform_api_keys(workspace_id).await?;
Ok(WorkspaceExportResponse {
workspace,
@@ -1314,27 +1314,51 @@ impl AdminService {
}
#[instrument(skip(self))]
pub async fn list_platform_api_keys(
pub async fn list_agent_platform_api_keys(
&self,
workspace_id: &WorkspaceId,
agent_id: &AgentId,
) -> Result<Vec<PlatformApiKeyRecord>, ApiError> {
self.ensure_workspace_exists(workspace_id).await?;
Ok(self.registry.list_platform_api_keys(workspace_id).await?)
self.registry
.get_agent_summary(workspace_id, agent_id)
.await?
.ok_or_else(|| {
ApiError::not_found_with_context(
format!("agent {} was not found", agent_id.as_str()),
json!({ "agent_id": agent_id.as_str() }),
)
})?;
Ok(self
.registry
.list_platform_api_keys_for_agent(workspace_id, agent_id)
.await?)
}
#[instrument(skip(self, payload), fields(workspace_id = %workspace_id.as_str(), key_name = %payload.name))]
pub async fn create_platform_api_key(
#[instrument(skip(self, payload), fields(workspace_id = %workspace_id.as_str(), agent_id = %agent_id.as_str(), key_name = %payload.name))]
pub async fn create_agent_platform_api_key(
&self,
workspace_id: &WorkspaceId,
agent_id: &AgentId,
payload: PlatformApiKeyPayload,
) -> Result<CreatedPlatformApiKeyResponse, ApiError> {
self.ensure_workspace_exists(workspace_id).await?;
self.registry
.get_agent_summary(workspace_id, agent_id)
.await?
.ok_or_else(|| {
ApiError::not_found_with_context(
format!("agent {} was not found", agent_id.as_str()),
json!({ "agent_id": agent_id.as_str() }),
)
})?;
let secret = generate_access_secret("crk");
let api_key = PlatformApiKeyRecord {
api_key: PlatformApiKey {
id: PlatformApiKeyId::new(new_prefixed_id("pk")),
workspace_id: workspace_id.clone(),
agent_id: Some(agent_id.clone()),
name: payload.name,
prefix: secret.chars().take(16).collect(),
scopes: payload.scopes,
@@ -1354,26 +1378,33 @@ impl AdminService {
Ok(CreatedPlatformApiKeyResponse { api_key, secret })
}
#[instrument(skip(self), fields(workspace_id = %workspace_id.as_str(), key_id = %key_id.as_str()))]
pub async fn revoke_platform_api_key(
#[instrument(skip(self), fields(workspace_id = %workspace_id.as_str(), agent_id = %agent_id.as_str(), key_id = %key_id.as_str()))]
pub async fn revoke_agent_platform_api_key(
&self,
workspace_id: &WorkspaceId,
agent_id: &AgentId,
key_id: &PlatformApiKeyId,
) -> Result<(), ApiError> {
self.registry
.revoke_platform_api_key(workspace_id, key_id, &OffsetDateTime::now_utc())
.revoke_platform_api_key_for_agent(
workspace_id,
agent_id,
key_id,
&OffsetDateTime::now_utc(),
)
.await?;
Ok(())
}
#[instrument(skip(self), fields(workspace_id = %workspace_id.as_str(), key_id = %key_id.as_str()))]
pub async fn delete_platform_api_key(
#[instrument(skip(self), fields(workspace_id = %workspace_id.as_str(), agent_id = %agent_id.as_str(), key_id = %key_id.as_str()))]
pub async fn delete_agent_platform_api_key(
&self,
workspace_id: &WorkspaceId,
agent_id: &AgentId,
key_id: &PlatformApiKeyId,
) -> Result<(), ApiError> {
self.registry
.delete_platform_api_key(workspace_id, key_id)
.delete_platform_api_key_for_agent(workspace_id, agent_id, key_id)
.await?;
Ok(())
}
@@ -2818,11 +2849,17 @@ impl AdminService {
.into_iter()
.map(|item| (item.agent_id.as_str().to_owned(), item.calls_total))
.collect::<BTreeMap<_, _>>();
let key_count = self
let key_counts = self
.registry
.list_platform_api_keys(workspace_id)
.await?
.len();
.into_iter()
.fold(BTreeMap::new(), |mut counts, record| {
if let Some(agent_id) = record.api_key.agent_id {
*counts.entry(agent_id.as_str().to_owned()).or_insert(0usize) += 1;
}
counts
});
let mut items = Vec::with_capacity(summaries.len());
for summary in summaries {
@@ -2837,7 +2874,7 @@ impl AdminService {
items.push(AgentSummaryView {
operation_count: operation_ids.len(),
operation_ids,
key_count,
key_count: key_counts.get(summary.id.as_str()).copied().unwrap_or(0),
calls_today: calls_today.get(summary.id.as_str()).copied().unwrap_or(0),
mcp_endpoint: agent_mcp_endpoint(
workspace.workspace.slug.as_str(),
@@ -2891,7 +2928,7 @@ impl AdminService {
.await?;
let key_count = self
.registry
.list_platform_api_keys(workspace_id)
.list_platform_api_keys_for_agent(workspace_id, agent_id)
.await?
.len();
@@ -3981,6 +4018,7 @@ impl AdminService {
self.ensure_demo_platform_api_key(
workspace_id,
&AgentId::new(revops_agent.id.clone()),
"Web Console Demo Key",
vec![PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
false,
@@ -3988,6 +4026,7 @@ impl AdminService {
.await?;
self.ensure_demo_platform_api_key(
workspace_id,
&AgentId::new(support_agent.id.clone()),
"Readonly Export Key",
vec![PlatformApiKeyScope::Read],
true,
@@ -4045,14 +4084,6 @@ impl AdminService {
},
)
.await?;
self.ensure_demo_platform_api_key(
&workspace_id,
"Growth Readonly Key",
vec![PlatformApiKeyScope::Read],
false,
)
.await?;
Ok(())
}
@@ -4109,20 +4140,22 @@ impl AdminService {
async fn ensure_demo_platform_api_key(
&self,
workspace_id: &WorkspaceId,
agent_id: &AgentId,
name: &str,
scopes: Vec<PlatformApiKeyScope>,
revoke: bool,
) -> Result<(), ApiError> {
let existing = self
.list_platform_api_keys(workspace_id)
.list_agent_platform_api_keys(workspace_id, agent_id)
.await?
.into_iter()
.find(|record| record.api_key.name == name);
let key = match existing {
Some(record) => record,
None => {
self.create_platform_api_key(
self.create_agent_platform_api_key(
workspace_id,
agent_id,
PlatformApiKeyPayload {
name: name.to_owned(),
scopes,
@@ -4134,7 +4167,7 @@ impl AdminService {
};
if revoke && key.api_key.status != PlatformApiKeyStatus::Revoked {
self.revoke_platform_api_key(workspace_id, &key.api_key.id)
self.revoke_agent_platform_api_key(workspace_id, agent_id, &key.api_key.id)
.await?;
}
+5 -1
View File
@@ -1813,7 +1813,11 @@ async fn require_platform_api_key(
let secret_hash = hash_access_secret(secret);
let Some(api_key) = state
.registry
.get_platform_api_key_by_secret_for_workspace_slug(&path.workspace_slug, &secret_hash)
.get_platform_api_key_by_secret_for_agent_slug(
&path.workspace_slug,
&path.agent_slug,
&secret_hash,
)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
else {
+160 -25
View File
@@ -208,6 +208,10 @@ mod tests {
"default"
}
fn test_agent_id(agent_slug: &str) -> AgentId {
AgentId::new(format!("agent_{agent_slug}"))
}
fn build_test_app(
registry: PostgresRegistry,
refresh_interval: Duration,
@@ -277,6 +281,7 @@ mod tests {
publish_agent_for_operation(&registry, &operation, "sales-rest").await;
let api_key = create_platform_api_key(
&registry,
"sales-rest",
"mcp-rest",
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
)
@@ -383,6 +388,7 @@ mod tests {
publish_agent_for_operation(&registry, &operation, "sales-request-id").await;
let api_key = create_platform_api_key(
&registry,
"sales-request-id",
"mcp-request-id",
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
)
@@ -466,6 +472,7 @@ mod tests {
publish_agent_for_operation(&registry, &operation, "sales-generated-request-id").await;
let api_key = create_platform_api_key(
&registry,
"sales-generated-request-id",
"mcp-generated-request-id",
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
)
@@ -553,6 +560,7 @@ mod tests {
publish_agent_for_operation(&registry, &operation, "sales-request-trace").await;
let api_key = create_platform_api_key(
&registry,
"sales-request-trace",
"mcp-request-trace",
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
)
@@ -633,6 +641,7 @@ mod tests {
publish_agent_for_operation(&registry, &operation, "sales-graphql").await;
let api_key = create_platform_api_key(
&registry,
"sales-graphql",
"mcp-graphql",
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
)
@@ -697,6 +706,7 @@ mod tests {
publish_agent_for_operation(&registry, &operation, "sales-grpc").await;
let api_key = create_platform_api_key(
&registry,
"sales-grpc",
"mcp-grpc",
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
)
@@ -742,8 +752,13 @@ mod tests {
async fn requires_initialized_notification_before_tool_methods() {
let registry = test_registry().await;
publish_agent_with_bindings(&registry, "sales-init", vec![]).await;
let api_key =
create_platform_api_key(&registry, "mcp-init", &[PlatformApiKeyScope::Read]).await;
let api_key = create_platform_api_key(
&registry,
"sales-init",
"mcp-init",
&[PlatformApiKeyScope::Read],
)
.await;
let base_url = spawn_mcp_server(build_test_app(
registry,
Duration::from_millis(0),
@@ -802,6 +817,7 @@ mod tests {
publish_agent_with_bindings(&registry, "sales-sse-init", vec![]).await;
let api_key = create_platform_api_key(
&registry,
"sales-sse-init",
"mcp-sse-init",
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
)
@@ -852,6 +868,7 @@ mod tests {
publish_agent_with_bindings(&registry, "sales-get-sse", vec![]).await;
let api_key = create_platform_api_key(
&registry,
"sales-get-sse",
"mcp-get-sse",
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
)
@@ -903,6 +920,7 @@ mod tests {
publish_agent_with_bindings(&registry, "sales-expired-session", vec![]).await;
let api_key = create_platform_api_key(
&registry,
"sales-expired-session",
"mcp-expired-session",
&[PlatformApiKeyScope::Read],
)
@@ -954,6 +972,7 @@ mod tests {
publish_agent_with_bindings(&registry, "sales-get-rate-limit", vec![]).await;
let api_key = create_platform_api_key(
&registry,
"sales-get-rate-limit",
"mcp-get-rate-limit",
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
)
@@ -1003,6 +1022,7 @@ mod tests {
publish_agent_with_bindings(&registry, "sales-get-sse-missing", vec![]).await;
let api_key = create_platform_api_key(
&registry,
"sales-get-sse-missing",
"mcp-get-sse-missing",
&[PlatformApiKeyScope::Read],
)
@@ -1032,6 +1052,7 @@ mod tests {
publish_agent_with_bindings(&registry, "sales-delete-session", vec![]).await;
let api_key = create_platform_api_key(
&registry,
"sales-delete-session",
"mcp-delete-session",
&[PlatformApiKeyScope::Read],
)
@@ -1074,9 +1095,13 @@ mod tests {
async fn initialize_accepts_json_only_response_negotiation() {
let registry = test_registry().await;
publish_agent_with_bindings(&registry, "sales-json-accept", vec![]).await;
let api_key =
create_platform_api_key(&registry, "mcp-json-accept", &[PlatformApiKeyScope::Read])
.await;
let api_key = create_platform_api_key(
&registry,
"sales-json-accept",
"mcp-json-accept",
&[PlatformApiKeyScope::Read],
)
.await;
let base_url = spawn_mcp_server(build_test_app(
registry,
Duration::from_millis(0),
@@ -1117,6 +1142,7 @@ mod tests {
publish_agent_with_bindings(&registry, "sales-get-bad-version", vec![]).await;
let api_key = create_platform_api_key(
&registry,
"sales-get-bad-version",
"mcp-get-bad-version",
&[PlatformApiKeyScope::Read],
)
@@ -1158,6 +1184,7 @@ mod tests {
let mcp_url = agent_mcp_url(&base_url, "sales-refresh");
let api_key = create_platform_api_key(
&registry,
"sales-refresh",
"mcp-refresh",
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
)
@@ -1252,9 +1279,17 @@ mod tests {
vec![binding_for_operation(&operation_b)],
)
.await;
let api_key = create_platform_api_key(
let api_key_a = create_platform_api_key(
&registry,
"mcp-agents",
"sales-a",
"mcp-agent-a",
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
)
.await;
let api_key_b = create_platform_api_key(
&registry,
"sales-b",
"mcp-agent-b",
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
)
.await;
@@ -1268,13 +1303,13 @@ mod tests {
let client = reqwest::Client::new();
let agent_a_url = agent_mcp_url(&base_url, "sales-a");
let agent_b_url = agent_mcp_url(&base_url, "sales-b");
let session_a = initialize_session(&client, &agent_a_url, &api_key).await;
let session_b = initialize_session(&client, &agent_b_url, &api_key).await;
let session_a = initialize_session(&client, &agent_a_url, &api_key_a).await;
let session_b = initialize_session(&client, &agent_b_url, &api_key_b).await;
let tools_a = post_jsonrpc(
&client,
&agent_a_url,
&api_key,
&api_key_a,
Some(&session_a),
json!({
"jsonrpc": "2.0",
@@ -1287,7 +1322,7 @@ mod tests {
let tools_b = post_jsonrpc(
&client,
&agent_b_url,
&api_key,
&api_key_b,
Some(&session_b),
json!({
"jsonrpc": "2.0",
@@ -1302,6 +1337,76 @@ mod tests {
assert_eq!(tools_b["result"]["tools"][0]["name"], "crm_update_lead");
}
#[tokio::test]
async fn rejects_initialize_with_key_from_different_agent() {
let registry = test_registry().await;
let upstream_base_url = spawn_upstream_server().await;
let operation_a = test_operation(&upstream_base_url, "crm_create_lead");
let operation_b = test_operation(&upstream_base_url, "crm_update_lead");
for operation in [&operation_a, &operation_b] {
registry
.create_operation(&test_workspace_id(), operation, Some("alice"))
.await
.unwrap();
registry
.publish_operation(PublishRequest {
workspace_id: &test_workspace_id(),
operation_id: &operation.id,
version: 1,
published_at: &OffsetDateTime::parse("2026-03-26T10:00:00Z", &Rfc3339).unwrap(),
published_by: Some("alice"),
})
.await
.unwrap();
}
publish_agent_with_bindings(
&registry,
"sales-a",
vec![binding_for_operation(&operation_a)],
)
.await;
publish_agent_with_bindings(
&registry,
"sales-b",
vec![binding_for_operation(&operation_b)],
)
.await;
let api_key_a = create_platform_api_key(
&registry,
"sales-a",
"mcp-agent-a-only",
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
)
.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-b"))
.header(header::ACCEPT, "application/json, text/event-stream")
.header(header::AUTHORIZATION, format!("Bearer {api_key_a}"))
.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_initialize_without_platform_api_key() {
let registry = test_registry().await;
@@ -1352,8 +1457,13 @@ mod tests {
.await
.unwrap();
publish_agent_for_operation(&registry, &operation, "sales-read-only").await;
let api_key =
create_platform_api_key(&registry, "mcp-read", &[PlatformApiKeyScope::Read]).await;
let api_key = create_platform_api_key(
&registry,
"sales-read-only",
"mcp-read",
&[PlatformApiKeyScope::Read],
)
.await;
let base_url = spawn_mcp_server(build_test_app(
registry,
@@ -1411,6 +1521,7 @@ mod tests {
publish_agent_for_operation(&registry, &operation, "sales-session").await;
let api_key = create_platform_api_key(
&registry,
"sales-session",
"mcp-session",
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
)
@@ -1558,9 +1669,17 @@ mod tests {
publish_agent_for_operation(&registry, &operation_a, "sales-session-a").await;
publish_agent_for_operation(&registry, &operation_b, "sales-session-b").await;
let api_key = create_platform_api_key(
let api_key_a = create_platform_api_key(
&registry,
"mcp-cross-session",
"sales-session-a",
"mcp-cross-session-a",
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
)
.await;
let api_key_b = create_platform_api_key(
&registry,
"sales-session-b",
"mcp-cross-session-b",
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
)
.await;
@@ -1574,13 +1693,13 @@ mod tests {
let client = reqwest::Client::new();
let agent_a_url = agent_mcp_url(&base_url, "sales-session-a");
let agent_b_url = agent_mcp_url(&base_url, "sales-session-b");
let session_a = initialize_session(&client, &agent_a_url, &api_key).await;
let session_b = initialize_session(&client, &agent_b_url, &api_key).await;
let session_a = initialize_session(&client, &agent_a_url, &api_key_a).await;
let session_b = initialize_session(&client, &agent_b_url, &api_key_b).await;
let start_response = post_jsonrpc(
&client,
&agent_a_url,
&api_key,
&api_key_a,
Some(&session_a),
json!({
"jsonrpc": "2.0",
@@ -1601,7 +1720,7 @@ mod tests {
let poll_response = post_jsonrpc(
&client,
&agent_b_url,
&api_key,
&api_key_b,
Some(&session_b),
json!({
"jsonrpc": "2.0",
@@ -1650,6 +1769,7 @@ mod tests {
publish_agent_for_operation(&registry, &operation, "sales-session-rate").await;
let api_key = create_platform_api_key(
&registry,
"sales-session-rate",
"mcp-session-rate",
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
)
@@ -1755,6 +1875,7 @@ mod tests {
publish_agent_for_operation(&registry, &operation, "sales-async-rate").await;
let api_key = create_platform_api_key(
&registry,
"sales-async-rate",
"mcp-async-rate",
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
)
@@ -1866,6 +1987,7 @@ mod tests {
publish_agent_for_operation(&registry, &operation, "sales-async-completed").await;
let api_key = create_platform_api_key(
&registry,
"sales-async-completed",
"mcp-async-completed",
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
)
@@ -1951,6 +2073,7 @@ mod tests {
publish_agent_for_operation(&registry, &operation, "sales-rate-limited").await;
let api_key = create_platform_api_key(
&registry,
"sales-rate-limited",
"mcp-rate-limit",
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
)
@@ -2050,9 +2173,17 @@ mod tests {
publish_agent_for_operation(&registry, &operation_a, "sales-async-a").await;
publish_agent_for_operation(&registry, &operation_b, "sales-async-b").await;
let api_key = create_platform_api_key(
let api_key_a = create_platform_api_key(
&registry,
"mcp-cross-async",
"sales-async-a",
"mcp-cross-async-a",
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
)
.await;
let api_key_b = create_platform_api_key(
&registry,
"sales-async-b",
"mcp-cross-async-b",
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
)
.await;
@@ -2066,13 +2197,13 @@ mod tests {
let client = reqwest::Client::new();
let agent_a_url = agent_mcp_url(&base_url, "sales-async-a");
let agent_b_url = agent_mcp_url(&base_url, "sales-async-b");
let session_a = initialize_session(&client, &agent_a_url, &api_key).await;
let session_b = initialize_session(&client, &agent_b_url, &api_key).await;
let session_a = initialize_session(&client, &agent_a_url, &api_key_a).await;
let session_b = initialize_session(&client, &agent_b_url, &api_key_b).await;
let start_response = post_jsonrpc(
&client,
&agent_a_url,
&api_key,
&api_key_a,
Some(&session_a),
json!({
"jsonrpc": "2.0",
@@ -2093,7 +2224,7 @@ mod tests {
let status_response = post_jsonrpc(
&client,
&agent_b_url,
&api_key,
&api_key_b,
Some(&session_b),
json!({
"jsonrpc": "2.0",
@@ -2141,6 +2272,7 @@ mod tests {
publish_agent_for_operation(&registry, &operation, "sales-async").await;
let api_key = create_platform_api_key(
&registry,
"sales-async",
"mcp-async",
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
)
@@ -2290,6 +2422,7 @@ mod tests {
publish_agent_for_operation(&registry, &operation, "sales-window").await;
let api_key = create_platform_api_key(
&registry,
"sales-window",
"mcp-window",
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
)
@@ -2418,6 +2551,7 @@ mod tests {
async fn create_platform_api_key(
registry: &PostgresRegistry,
agent_slug: &str,
name: &str,
scopes: &[PlatformApiKeyScope],
) -> String {
@@ -2425,6 +2559,7 @@ mod tests {
let api_key = PlatformApiKey {
id: PlatformApiKeyId::new(format!("pk_{name}")),
workspace_id: test_workspace_id(),
agent_id: Some(test_agent_id(agent_slug)),
name: name.to_owned(),
prefix: secret.chars().take(16).collect(),
scopes: scopes.to_vec(),
+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,
+5 -2
View File
@@ -1,7 +1,7 @@
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use crate::ids::{InvitationId, PlatformApiKeyId, UserId, WorkspaceId};
use crate::ids::{AgentId, InvitationId, PlatformApiKeyId, UserId, WorkspaceId};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
@@ -80,6 +80,8 @@ pub struct InvitationToken {
pub struct PlatformApiKey {
pub id: PlatformApiKeyId,
pub workspace_id: WorkspaceId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_id: Option<AgentId>,
pub name: String,
pub prefix: String,
pub scopes: Vec<PlatformApiKeyScope>,
@@ -96,7 +98,7 @@ mod tests {
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
use super::{PlatformApiKey, PlatformApiKeyScope, PlatformApiKeyStatus, User, UserStatus};
use crate::ids::{PlatformApiKeyId, UserId, WorkspaceId};
use crate::ids::{AgentId, PlatformApiKeyId, UserId, WorkspaceId};
#[test]
fn user_serializes_created_at_as_rfc3339() {
@@ -135,6 +137,7 @@ mod tests {
let api_key = PlatformApiKey {
id: PlatformApiKeyId::new("pk_01"),
workspace_id: WorkspaceId::new("ws_01"),
agent_id: Some(AgentId::new("agent_01")),
name: "Primary".to_owned(),
prefix: "crk_live".to_owned(),
scopes: vec![PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
+4
View File
@@ -144,6 +144,7 @@ pub async fn apply_postgres(pool: &PgPool) -> Result<(), sqlx::Error> {
"create table if not exists platform_api_keys (
id text primary key,
workspace_id text not null references workspaces(id) on delete cascade,
agent_id text null,
name text not null,
prefix text not null,
secret_hash text not null,
@@ -162,6 +163,9 @@ pub async fn apply_postgres(pool: &PgPool) -> Result<(), sqlx::Error> {
)
.execute(pool)
.await?;
query("alter table platform_api_keys add column if not exists agent_id text null")
.execute(pool)
.await?;
query(
"insert into workspaces (
+141 -29
View File
@@ -1,25 +1,29 @@
use super::*;
impl PostgresRegistry {
pub async fn list_platform_api_keys(
pub async fn list_platform_api_keys_for_agent(
&self,
workspace_id: &WorkspaceId,
agent_id: &AgentId,
) -> Result<Vec<PlatformApiKeyRecord>, RegistryError> {
let rows = sqlx::query!(
let rows = sqlx::query(
"select
id,
workspace_id,
agent_id,
name,
prefix,
scopes_json,
status,
created_at as \"created_at!: time::OffsetDateTime\",
last_used_at as \"last_used_at: time::OffsetDateTime\"
created_at,
last_used_at
from platform_api_keys
where workspace_id = $1
and agent_id = $2
order by created_at desc",
workspace_id.as_str(),
)
.bind(workspace_id.as_str())
.bind(agent_id.as_str())
.fetch_all(&self.pool)
.await?;
@@ -27,58 +31,107 @@ impl PostgresRegistry {
.map(|row| {
Ok(PlatformApiKeyRecord {
api_key: PlatformApiKey {
id: PlatformApiKeyId::new(row.id),
workspace_id: WorkspaceId::new(row.workspace_id),
name: row.name,
prefix: row.prefix,
scopes: deserialize_json_value(row.scopes_json)?,
status: deserialize_enum_text(&row.status, "status")?,
created_at: row.created_at,
last_used_at: row.last_used_at,
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),
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"),
},
})
})
.collect()
}
pub async fn get_platform_api_key_by_secret_for_workspace_slug(
pub async fn list_platform_api_keys(
&self,
workspace_id: &WorkspaceId,
) -> Result<Vec<PlatformApiKeyRecord>, RegistryError> {
let rows = sqlx::query(
"select
id,
workspace_id,
agent_id,
name,
prefix,
scopes_json,
status,
created_at,
last_used_at
from platform_api_keys
where workspace_id = $1
order by created_at desc",
)
.bind(workspace_id.as_str())
.fetch_all(&self.pool)
.await?;
rows.into_iter()
.map(|row| {
Ok(PlatformApiKeyRecord {
api_key: PlatformApiKey {
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),
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"),
},
})
})
.collect()
}
pub async fn get_platform_api_key_by_secret_for_agent_slug(
&self,
workspace_slug: &str,
agent_slug: &str,
secret_hash: &str,
) -> Result<Option<PlatformApiKeyRecord>, RegistryError> {
let row = sqlx::query!(
let row = sqlx::query(
"select
k.id,
k.workspace_id,
k.agent_id,
k.name,
k.prefix,
k.scopes_json,
k.status,
k.created_at as \"created_at!: time::OffsetDateTime\",
k.last_used_at as \"last_used_at: time::OffsetDateTime\"
k.created_at,
k.last_used_at
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 k.secret_hash = $2
and a.slug = $2
and k.secret_hash = $3
and k.status = 'active'
limit 1",
workspace_slug,
secret_hash,
)
.bind(workspace_slug)
.bind(agent_slug)
.bind(secret_hash)
.fetch_optional(&self.pool)
.await?;
row.map(|row| {
Ok(PlatformApiKeyRecord {
api_key: PlatformApiKey {
id: PlatformApiKeyId::new(row.id),
workspace_id: WorkspaceId::new(row.workspace_id),
name: row.name,
prefix: row.prefix,
scopes: deserialize_json_value(row.scopes_json)?,
status: deserialize_enum_text(&row.status, "status")?,
created_at: row.created_at,
last_used_at: row.last_used_at,
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),
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"),
},
})
})
@@ -93,6 +146,7 @@ impl PostgresRegistry {
"insert into platform_api_keys (
id,
workspace_id,
agent_id,
name,
prefix,
secret_hash,
@@ -102,11 +156,12 @@ impl PostgresRegistry {
last_used_at,
revoked_at
) values (
$1, $2, $3, $4, $5, $6, $7, $8::timestamptz, $9::timestamptz, $10::timestamptz
$1, $2, $3, $4, $5, $6, $7, $8, $9::timestamptz, $10::timestamptz, $11::timestamptz
)",
)
.bind(request.api_key.id.as_str())
.bind(request.api_key.workspace_id.as_str())
.bind(request.api_key.agent_id.as_ref().map(AgentId::as_str))
.bind(&request.api_key.name)
.bind(&request.api_key.prefix)
.bind(request.secret_hash)
@@ -160,6 +215,39 @@ impl PostgresRegistry {
Ok(())
}
pub async fn revoke_platform_api_key_for_agent(
&self,
workspace_id: &WorkspaceId,
agent_id: &AgentId,
key_id: &PlatformApiKeyId,
revoked_at: &time::OffsetDateTime,
) -> Result<(), RegistryError> {
let result = sqlx::query(
"update platform_api_keys
set status = $1,
revoked_at = $2::timestamptz
where workspace_id = $3 and agent_id = $4 and id = $5",
)
.bind(serialize_enum_text(
&PlatformApiKeyStatus::Revoked,
"status",
)?)
.bind(revoked_at)
.bind(workspace_id.as_str())
.bind(agent_id.as_str())
.bind(key_id.as_str())
.execute(&self.pool)
.await?;
if result.rows_affected() == 0 {
return Err(RegistryError::PlatformApiKeyNotFound {
key_id: key_id.as_str().to_owned(),
});
}
Ok(())
}
pub async fn delete_platform_api_key(
&self,
workspace_id: &WorkspaceId,
@@ -181,6 +269,30 @@ impl PostgresRegistry {
Ok(())
}
pub async fn delete_platform_api_key_for_agent(
&self,
workspace_id: &WorkspaceId,
agent_id: &AgentId,
key_id: &PlatformApiKeyId,
) -> Result<(), RegistryError> {
let result = sqlx::query(
"delete from platform_api_keys where workspace_id = $1 and agent_id = $2 and id = $3",
)
.bind(workspace_id.as_str())
.bind(agent_id.as_str())
.bind(key_id.as_str())
.execute(&self.pool)
.await?;
if result.rows_affected() == 0 {
return Err(RegistryError::PlatformApiKeyNotFound {
key_id: key_id.as_str().to_owned(),
});
}
Ok(())
}
pub async fn touch_platform_api_key(
&self,
workspace_id: &WorkspaceId,
+26 -1
View File
@@ -1670,9 +1670,12 @@ mod tests {
created_at: timestamp("2026-03-25T12:00:00Z"),
updated_at: timestamp("2026-03-25T12:00:00Z"),
};
let agent = test_agent("agent_keys_01", AgentStatus::Draft);
let version = test_agent_version(&agent.id, 1, AgentStatus::Draft);
let api_key = PlatformApiKey {
id: PlatformApiKeyId::new("key_01"),
workspace_id: workspace.id.clone(),
agent_id: Some(agent.id.clone()),
name: "Primary".to_owned(),
prefix: "crk_live".to_owned(),
scopes: vec![PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
@@ -1688,6 +1691,14 @@ mod tests {
})
.await
.unwrap();
registry
.create_agent(CreateAgentRequest {
agent: &agent,
version: &version,
bindings: &[],
})
.await
.unwrap();
registry
.create_platform_api_key(CreatePlatformApiKeyRequest {
api_key: &api_key,
@@ -1700,15 +1711,29 @@ mod tests {
.list_platform_api_keys(&workspace.id)
.await
.unwrap();
let listed_for_agent = registry
.list_platform_api_keys_for_agent(&workspace.id, &agent.id)
.await
.unwrap();
assert_eq!(
listed,
vec![PlatformApiKeyRecord {
api_key: api_key.clone()
}]
);
assert_eq!(
listed_for_agent,
vec![PlatformApiKeyRecord {
api_key: api_key.clone()
}]
);
let resolved = registry
.get_platform_api_key_by_secret_for_workspace_slug(&workspace.slug, secret_hash)
.get_platform_api_key_by_secret_for_agent_slug(
&workspace.slug,
&agent.slug,
secret_hash,
)
.await
.unwrap()
.unwrap();
+8 -8
View File
@@ -175,18 +175,18 @@
- `POST /api/admin/workspaces/{workspace_id}/agents/{agent_id}/archive`
- `POST /api/admin/workspaces/{workspace_id}/agents/{agent_id}/bindings`
- `DELETE /api/admin/workspaces/{workspace_id}/agents/{agent_id}/bindings/{operation_id}`
- `GET /api/admin/workspaces/{workspace_id}/agents/{agent_id}/keys`
- `POST /api/admin/workspaces/{workspace_id}/agents/{agent_id}/keys`
- `POST /api/admin/workspaces/{workspace_id}/agents/{agent_id}/keys/{key_id}/revoke`
- `DELETE /api/admin/workspaces/{workspace_id}/agents/{agent_id}/keys/{key_id}`
- `GET /api/admin/workspaces/{workspace_id}/agents/{agent_id}/platform-api-keys`
- `POST /api/admin/workspaces/{workspace_id}/agents/{agent_id}/platform-api-keys`
- `POST /api/admin/workspaces/{workspace_id}/agents/{agent_id}/platform-api-keys/{key_id}/revoke`
- `DELETE /api/admin/workspaces/{workspace_id}/agents/{agent_id}/platform-api-keys/{key_id}`
Контракт:
- `POST /agents/{agent_id}/keys` возвращает metadata ключа и одноразовый `secret`;
- `POST /agents/{agent_id}/platform-api-keys` возвращает metadata ключа и одноразовый `secret`;
- полное значение ключа доступно только в create-response;
- list endpoints возвращают только metadata, `prefix`, `status`, `scopes`, `expires_at` и `last_used_at`;
- ключ агента принадлежит одному `agent` и не должен использоваться как основной рабочий токен вызова в целевой защищенной схеме;
- ключ агента используется для получения короткоживущего токена доступа к MCP endpoint.
- в `Community` ключ агента является основным машинным credential;
- в коммерческих редакциях этот же ключ используется как исходный credential для более строгих токенных режимов.
### 5.7. Token issuance
@@ -199,7 +199,7 @@
- `POST /mcp-auth/v1/token` выдает короткоживущий токен доступа для операций уровня `elevated`;
- `POST /mcp-auth/v1/token/one-time` выдает одноразовый токен для операций уровня `strict`;
- открытая редакция может не использовать эти конечные точки и работать только со статическим ключом агента;
- открытая редакция работает только со статическим ключом агента и не обязана реализовывать эти конечные точки;
- детальная схема уровней и режимов доступа зафиксирована в `docs/agent-auth-model.md`.
### 5.8. Observability