auth: cut over community machine access to agent keys
This commit is contained in:
@@ -2,55 +2,9 @@
|
|||||||
|
|
||||||
## Current
|
## 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`
|
### `feat/edition-capability-model`
|
||||||
|
|
||||||
Status: ready
|
Status: in_progress
|
||||||
|
|
||||||
Goal:
|
Goal:
|
||||||
- ввести capability model по редакциям и перестать смешивать Community и premium surface.
|
- ввести capability model по редакциям и перестать смешивать Community и premium surface.
|
||||||
@@ -74,6 +28,11 @@ Implementation slices:
|
|||||||
3. Привести UI к capability gating вместо скрытых продуктовых допущений.
|
3. Привести UI к capability gating вместо скрытых продуктовых допущений.
|
||||||
4. Зафиксировать Community defaults в docs и runtime config.
|
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:
|
DoD:
|
||||||
- UI знает, какие функции входят в текущую редакцию;
|
- UI знает, какие функции входят в текущую редакцию;
|
||||||
- Community не показывает рабочие controls для premium-only features;
|
- Community не показывает рабочие controls для premium-only features;
|
||||||
@@ -84,6 +43,8 @@ Verification:
|
|||||||
- frontend smoke for hidden/locked states;
|
- frontend smoke for hidden/locked states;
|
||||||
- manual verification on Community build.
|
- manual verification on Community build.
|
||||||
|
|
||||||
|
## Next
|
||||||
|
|
||||||
### `feat/private-token-service-seam`
|
### `feat/private-token-service-seam`
|
||||||
|
|
||||||
Status: ready
|
Status: ready
|
||||||
|
|||||||
+97
-61
@@ -9,13 +9,14 @@ use crate::{
|
|||||||
request_context::apply_request_context,
|
request_context::apply_request_context,
|
||||||
routes::{
|
routes::{
|
||||||
access::{
|
access::{
|
||||||
create_invitation, create_platform_api_key, delete_invitation, delete_membership,
|
create_invitation, delete_invitation, delete_membership, delete_workspace,
|
||||||
delete_platform_api_key, delete_workspace, export_workspace, list_invitations,
|
export_workspace, list_invitations, list_memberships, update_membership,
|
||||||
list_memberships, list_platform_api_keys, revoke_platform_api_key, update_membership,
|
|
||||||
},
|
},
|
||||||
agents::{
|
agents::{
|
||||||
archive_agent, create_agent, delete_agent, get_agent, get_agent_version, list_agents,
|
archive_agent, create_agent, create_agent_platform_api_key, delete_agent,
|
||||||
publish_agent, save_agent_bindings, unpublish_agent, update_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::{
|
auth::{
|
||||||
change_password, get_profile, get_session, login, logout, update_current_workspace,
|
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}/publish", post(publish_agent))
|
||||||
.route("/agents/{agent_id}/unpublish", post(unpublish_agent))
|
.route("/agents/{agent_id}/unpublish", post(unpublish_agent))
|
||||||
.route("/agents/{agent_id}/archive", post(archive_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(
|
.route(
|
||||||
"/auth-profiles",
|
"/auth-profiles",
|
||||||
get(list_auth_profiles).post(create_auth_profile),
|
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("/invitations/{invitation_id}", delete(delete_invitation))
|
||||||
.route("/export", get(export_workspace))
|
.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", get(list_logs))
|
||||||
.route("/logs/{log_id}", get(get_log))
|
.route("/logs/{log_id}", get(get_log))
|
||||||
.route("/usage", get(get_usage))
|
.route("/usage", get(get_usage))
|
||||||
@@ -891,48 +892,12 @@ mod tests {
|
|||||||
.json::<Value>()
|
.json::<Value>()
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.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
|
let delete_invitation_status = client
|
||||||
.delete(format!("{base_url}/invitations/{invitation_id}"))
|
.delete(format!("{base_url}/invitations/{invitation_id}"))
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.status();
|
.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!(members["items"][0]["role"], "owner");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -949,14 +914,89 @@ mod tests {
|
|||||||
invitations["items"][0]["invitation"]["email"],
|
invitations["items"][0]["invitation"]["email"],
|
||||||
"operator@example.com"
|
"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!(
|
assert_eq!(
|
||||||
listed_keys["items"][0]["api_key"]["name"],
|
listed_keys["items"][0]["api_key"]["name"],
|
||||||
"workspace-operator"
|
"sales-routing-primary"
|
||||||
);
|
);
|
||||||
assert!(created_key["secret"].as_str().unwrap().starts_with("crk_"));
|
assert!(created_key["secret"].as_str().unwrap().starts_with("crk_"));
|
||||||
assert_eq!(revoke_status, reqwest::StatusCode::NO_CONTENT);
|
assert_eq!(revoke_status, reqwest::StatusCode::NO_CONTENT);
|
||||||
assert_eq!(delete_invitation_status, reqwest::StatusCode::NO_CONTENT);
|
assert_eq!(delete_status, reqwest::StatusCode::NO_CONTENT);
|
||||||
assert_eq!(delete_key_status, reqwest::StatusCode::NO_CONTENT);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
@@ -1087,11 +1127,7 @@ mod tests {
|
|||||||
let agents = service.list_agents(&default_workspace_id).await.unwrap();
|
let agents = service.list_agents(&default_workspace_id).await.unwrap();
|
||||||
assert!(agents.len() >= 2);
|
assert!(agents.len() >= 2);
|
||||||
|
|
||||||
let api_keys = service
|
assert!(agents.iter().any(|agent| agent.key_count > 0));
|
||||||
.list_platform_api_keys(&default_workspace_id)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert!(api_keys.len() >= 2);
|
|
||||||
|
|
||||||
let logs = service
|
let logs = service
|
||||||
.list_logs(
|
.list_logs(
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use serde_json::{Value, json};
|
|||||||
use crate::{
|
use crate::{
|
||||||
auth::AuthenticatedSession,
|
auth::AuthenticatedSession,
|
||||||
error::ApiError,
|
error::ApiError,
|
||||||
service::{InvitationPayload, PlatformApiKeyPayload, UpdateMembershipPayload},
|
service::{InvitationPayload, UpdateMembershipPayload},
|
||||||
state::AppState,
|
state::AppState,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -30,12 +30,6 @@ pub struct WorkspaceMembershipPath {
|
|||||||
pub user_id: String,
|
pub user_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct WorkspacePlatformApiKeyPath {
|
|
||||||
pub workspace_id: String,
|
|
||||||
pub key_id: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn list_memberships(
|
pub async fn list_memberships(
|
||||||
Path(path): Path<WorkspacePath>,
|
Path(path): Path<WorkspacePath>,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
@@ -118,57 +112,6 @@ pub async fn delete_invitation(
|
|||||||
Ok(StatusCode::NO_CONTENT)
|
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(
|
pub async fn export_workspace(
|
||||||
Path(path): Path<WorkspacePath>,
|
Path(path): Path<WorkspacePath>,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ use serde_json::{Value, json};
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
error::ApiError,
|
error::ApiError,
|
||||||
service::{AgentBindingPayload, AgentPayload, PublishPayload, UpdateAgentPayload},
|
service::{
|
||||||
|
AgentBindingPayload, AgentPayload, PlatformApiKeyPayload, PublishPayload,
|
||||||
|
UpdateAgentPayload,
|
||||||
|
},
|
||||||
state::AppState,
|
state::AppState,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -29,6 +32,13 @@ pub struct WorkspaceAgentVersionPath {
|
|||||||
pub version: u32,
|
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(
|
pub async fn list_agents(
|
||||||
Path(path): Path<WorkspacePath>,
|
Path(path): Path<WorkspacePath>,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
@@ -170,3 +180,63 @@ pub async fn archive_agent(
|
|||||||
.await?;
|
.await?;
|
||||||
Ok(Json(json!(updated)))
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1279,7 +1279,7 @@ impl AdminService {
|
|||||||
.collect();
|
.collect();
|
||||||
let operations = self.list_operations(workspace_id).await?;
|
let operations = self.list_operations(workspace_id).await?;
|
||||||
let agents = self.list_agents(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 {
|
Ok(WorkspaceExportResponse {
|
||||||
workspace,
|
workspace,
|
||||||
@@ -1314,27 +1314,51 @@ impl AdminService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip(self))]
|
#[instrument(skip(self))]
|
||||||
pub async fn list_platform_api_keys(
|
pub async fn list_agent_platform_api_keys(
|
||||||
&self,
|
&self,
|
||||||
workspace_id: &WorkspaceId,
|
workspace_id: &WorkspaceId,
|
||||||
|
agent_id: &AgentId,
|
||||||
) -> Result<Vec<PlatformApiKeyRecord>, ApiError> {
|
) -> Result<Vec<PlatformApiKeyRecord>, ApiError> {
|
||||||
self.ensure_workspace_exists(workspace_id).await?;
|
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))]
|
#[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_platform_api_key(
|
pub async fn create_agent_platform_api_key(
|
||||||
&self,
|
&self,
|
||||||
workspace_id: &WorkspaceId,
|
workspace_id: &WorkspaceId,
|
||||||
|
agent_id: &AgentId,
|
||||||
payload: PlatformApiKeyPayload,
|
payload: PlatformApiKeyPayload,
|
||||||
) -> Result<CreatedPlatformApiKeyResponse, ApiError> {
|
) -> Result<CreatedPlatformApiKeyResponse, ApiError> {
|
||||||
self.ensure_workspace_exists(workspace_id).await?;
|
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 secret = generate_access_secret("crk");
|
||||||
let api_key = PlatformApiKeyRecord {
|
let api_key = PlatformApiKeyRecord {
|
||||||
api_key: PlatformApiKey {
|
api_key: PlatformApiKey {
|
||||||
id: PlatformApiKeyId::new(new_prefixed_id("pk")),
|
id: PlatformApiKeyId::new(new_prefixed_id("pk")),
|
||||||
workspace_id: workspace_id.clone(),
|
workspace_id: workspace_id.clone(),
|
||||||
|
agent_id: Some(agent_id.clone()),
|
||||||
name: payload.name,
|
name: payload.name,
|
||||||
prefix: secret.chars().take(16).collect(),
|
prefix: secret.chars().take(16).collect(),
|
||||||
scopes: payload.scopes,
|
scopes: payload.scopes,
|
||||||
@@ -1354,26 +1378,33 @@ impl AdminService {
|
|||||||
Ok(CreatedPlatformApiKeyResponse { api_key, secret })
|
Ok(CreatedPlatformApiKeyResponse { api_key, secret })
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip(self), fields(workspace_id = %workspace_id.as_str(), key_id = %key_id.as_str()))]
|
#[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_platform_api_key(
|
pub async fn revoke_agent_platform_api_key(
|
||||||
&self,
|
&self,
|
||||||
workspace_id: &WorkspaceId,
|
workspace_id: &WorkspaceId,
|
||||||
|
agent_id: &AgentId,
|
||||||
key_id: &PlatformApiKeyId,
|
key_id: &PlatformApiKeyId,
|
||||||
) -> Result<(), ApiError> {
|
) -> Result<(), ApiError> {
|
||||||
self.registry
|
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?;
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip(self), fields(workspace_id = %workspace_id.as_str(), key_id = %key_id.as_str()))]
|
#[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_platform_api_key(
|
pub async fn delete_agent_platform_api_key(
|
||||||
&self,
|
&self,
|
||||||
workspace_id: &WorkspaceId,
|
workspace_id: &WorkspaceId,
|
||||||
|
agent_id: &AgentId,
|
||||||
key_id: &PlatformApiKeyId,
|
key_id: &PlatformApiKeyId,
|
||||||
) -> Result<(), ApiError> {
|
) -> Result<(), ApiError> {
|
||||||
self.registry
|
self.registry
|
||||||
.delete_platform_api_key(workspace_id, key_id)
|
.delete_platform_api_key_for_agent(workspace_id, agent_id, key_id)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -2818,11 +2849,17 @@ impl AdminService {
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|item| (item.agent_id.as_str().to_owned(), item.calls_total))
|
.map(|item| (item.agent_id.as_str().to_owned(), item.calls_total))
|
||||||
.collect::<BTreeMap<_, _>>();
|
.collect::<BTreeMap<_, _>>();
|
||||||
let key_count = self
|
let key_counts = self
|
||||||
.registry
|
.registry
|
||||||
.list_platform_api_keys(workspace_id)
|
.list_platform_api_keys(workspace_id)
|
||||||
.await?
|
.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());
|
let mut items = Vec::with_capacity(summaries.len());
|
||||||
for summary in summaries {
|
for summary in summaries {
|
||||||
@@ -2837,7 +2874,7 @@ impl AdminService {
|
|||||||
items.push(AgentSummaryView {
|
items.push(AgentSummaryView {
|
||||||
operation_count: operation_ids.len(),
|
operation_count: operation_ids.len(),
|
||||||
operation_ids,
|
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),
|
calls_today: calls_today.get(summary.id.as_str()).copied().unwrap_or(0),
|
||||||
mcp_endpoint: agent_mcp_endpoint(
|
mcp_endpoint: agent_mcp_endpoint(
|
||||||
workspace.workspace.slug.as_str(),
|
workspace.workspace.slug.as_str(),
|
||||||
@@ -2891,7 +2928,7 @@ impl AdminService {
|
|||||||
.await?;
|
.await?;
|
||||||
let key_count = self
|
let key_count = self
|
||||||
.registry
|
.registry
|
||||||
.list_platform_api_keys(workspace_id)
|
.list_platform_api_keys_for_agent(workspace_id, agent_id)
|
||||||
.await?
|
.await?
|
||||||
.len();
|
.len();
|
||||||
|
|
||||||
@@ -3981,6 +4018,7 @@ impl AdminService {
|
|||||||
|
|
||||||
self.ensure_demo_platform_api_key(
|
self.ensure_demo_platform_api_key(
|
||||||
workspace_id,
|
workspace_id,
|
||||||
|
&AgentId::new(revops_agent.id.clone()),
|
||||||
"Web Console Demo Key",
|
"Web Console Demo Key",
|
||||||
vec![PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
vec![PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||||
false,
|
false,
|
||||||
@@ -3988,6 +4026,7 @@ impl AdminService {
|
|||||||
.await?;
|
.await?;
|
||||||
self.ensure_demo_platform_api_key(
|
self.ensure_demo_platform_api_key(
|
||||||
workspace_id,
|
workspace_id,
|
||||||
|
&AgentId::new(support_agent.id.clone()),
|
||||||
"Readonly Export Key",
|
"Readonly Export Key",
|
||||||
vec![PlatformApiKeyScope::Read],
|
vec![PlatformApiKeyScope::Read],
|
||||||
true,
|
true,
|
||||||
@@ -4045,14 +4084,6 @@ impl AdminService {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
self.ensure_demo_platform_api_key(
|
|
||||||
&workspace_id,
|
|
||||||
"Growth Readonly Key",
|
|
||||||
vec![PlatformApiKeyScope::Read],
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4109,20 +4140,22 @@ impl AdminService {
|
|||||||
async fn ensure_demo_platform_api_key(
|
async fn ensure_demo_platform_api_key(
|
||||||
&self,
|
&self,
|
||||||
workspace_id: &WorkspaceId,
|
workspace_id: &WorkspaceId,
|
||||||
|
agent_id: &AgentId,
|
||||||
name: &str,
|
name: &str,
|
||||||
scopes: Vec<PlatformApiKeyScope>,
|
scopes: Vec<PlatformApiKeyScope>,
|
||||||
revoke: bool,
|
revoke: bool,
|
||||||
) -> Result<(), ApiError> {
|
) -> Result<(), ApiError> {
|
||||||
let existing = self
|
let existing = self
|
||||||
.list_platform_api_keys(workspace_id)
|
.list_agent_platform_api_keys(workspace_id, agent_id)
|
||||||
.await?
|
.await?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.find(|record| record.api_key.name == name);
|
.find(|record| record.api_key.name == name);
|
||||||
let key = match existing {
|
let key = match existing {
|
||||||
Some(record) => record,
|
Some(record) => record,
|
||||||
None => {
|
None => {
|
||||||
self.create_platform_api_key(
|
self.create_agent_platform_api_key(
|
||||||
workspace_id,
|
workspace_id,
|
||||||
|
agent_id,
|
||||||
PlatformApiKeyPayload {
|
PlatformApiKeyPayload {
|
||||||
name: name.to_owned(),
|
name: name.to_owned(),
|
||||||
scopes,
|
scopes,
|
||||||
@@ -4134,7 +4167,7 @@ impl AdminService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if revoke && key.api_key.status != PlatformApiKeyStatus::Revoked {
|
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?;
|
.await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1813,7 +1813,11 @@ async fn require_platform_api_key(
|
|||||||
let secret_hash = hash_access_secret(secret);
|
let secret_hash = hash_access_secret(secret);
|
||||||
let Some(api_key) = state
|
let Some(api_key) = state
|
||||||
.registry
|
.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
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||||
else {
|
else {
|
||||||
|
|||||||
+159
-24
@@ -208,6 +208,10 @@ mod tests {
|
|||||||
"default"
|
"default"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn test_agent_id(agent_slug: &str) -> AgentId {
|
||||||
|
AgentId::new(format!("agent_{agent_slug}"))
|
||||||
|
}
|
||||||
|
|
||||||
fn build_test_app(
|
fn build_test_app(
|
||||||
registry: PostgresRegistry,
|
registry: PostgresRegistry,
|
||||||
refresh_interval: Duration,
|
refresh_interval: Duration,
|
||||||
@@ -277,6 +281,7 @@ mod tests {
|
|||||||
publish_agent_for_operation(®istry, &operation, "sales-rest").await;
|
publish_agent_for_operation(®istry, &operation, "sales-rest").await;
|
||||||
let api_key = create_platform_api_key(
|
let api_key = create_platform_api_key(
|
||||||
®istry,
|
®istry,
|
||||||
|
"sales-rest",
|
||||||
"mcp-rest",
|
"mcp-rest",
|
||||||
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||||
)
|
)
|
||||||
@@ -383,6 +388,7 @@ mod tests {
|
|||||||
publish_agent_for_operation(®istry, &operation, "sales-request-id").await;
|
publish_agent_for_operation(®istry, &operation, "sales-request-id").await;
|
||||||
let api_key = create_platform_api_key(
|
let api_key = create_platform_api_key(
|
||||||
®istry,
|
®istry,
|
||||||
|
"sales-request-id",
|
||||||
"mcp-request-id",
|
"mcp-request-id",
|
||||||
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||||
)
|
)
|
||||||
@@ -466,6 +472,7 @@ mod tests {
|
|||||||
publish_agent_for_operation(®istry, &operation, "sales-generated-request-id").await;
|
publish_agent_for_operation(®istry, &operation, "sales-generated-request-id").await;
|
||||||
let api_key = create_platform_api_key(
|
let api_key = create_platform_api_key(
|
||||||
®istry,
|
®istry,
|
||||||
|
"sales-generated-request-id",
|
||||||
"mcp-generated-request-id",
|
"mcp-generated-request-id",
|
||||||
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||||
)
|
)
|
||||||
@@ -553,6 +560,7 @@ mod tests {
|
|||||||
publish_agent_for_operation(®istry, &operation, "sales-request-trace").await;
|
publish_agent_for_operation(®istry, &operation, "sales-request-trace").await;
|
||||||
let api_key = create_platform_api_key(
|
let api_key = create_platform_api_key(
|
||||||
®istry,
|
®istry,
|
||||||
|
"sales-request-trace",
|
||||||
"mcp-request-trace",
|
"mcp-request-trace",
|
||||||
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||||
)
|
)
|
||||||
@@ -633,6 +641,7 @@ mod tests {
|
|||||||
publish_agent_for_operation(®istry, &operation, "sales-graphql").await;
|
publish_agent_for_operation(®istry, &operation, "sales-graphql").await;
|
||||||
let api_key = create_platform_api_key(
|
let api_key = create_platform_api_key(
|
||||||
®istry,
|
®istry,
|
||||||
|
"sales-graphql",
|
||||||
"mcp-graphql",
|
"mcp-graphql",
|
||||||
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||||
)
|
)
|
||||||
@@ -697,6 +706,7 @@ mod tests {
|
|||||||
publish_agent_for_operation(®istry, &operation, "sales-grpc").await;
|
publish_agent_for_operation(®istry, &operation, "sales-grpc").await;
|
||||||
let api_key = create_platform_api_key(
|
let api_key = create_platform_api_key(
|
||||||
®istry,
|
®istry,
|
||||||
|
"sales-grpc",
|
||||||
"mcp-grpc",
|
"mcp-grpc",
|
||||||
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||||
)
|
)
|
||||||
@@ -742,8 +752,13 @@ mod tests {
|
|||||||
async fn requires_initialized_notification_before_tool_methods() {
|
async fn requires_initialized_notification_before_tool_methods() {
|
||||||
let registry = test_registry().await;
|
let registry = test_registry().await;
|
||||||
publish_agent_with_bindings(®istry, "sales-init", vec![]).await;
|
publish_agent_with_bindings(®istry, "sales-init", vec![]).await;
|
||||||
let api_key =
|
let api_key = create_platform_api_key(
|
||||||
create_platform_api_key(®istry, "mcp-init", &[PlatformApiKeyScope::Read]).await;
|
®istry,
|
||||||
|
"sales-init",
|
||||||
|
"mcp-init",
|
||||||
|
&[PlatformApiKeyScope::Read],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
let base_url = spawn_mcp_server(build_test_app(
|
let base_url = spawn_mcp_server(build_test_app(
|
||||||
registry,
|
registry,
|
||||||
Duration::from_millis(0),
|
Duration::from_millis(0),
|
||||||
@@ -802,6 +817,7 @@ mod tests {
|
|||||||
publish_agent_with_bindings(®istry, "sales-sse-init", vec![]).await;
|
publish_agent_with_bindings(®istry, "sales-sse-init", vec![]).await;
|
||||||
let api_key = create_platform_api_key(
|
let api_key = create_platform_api_key(
|
||||||
®istry,
|
®istry,
|
||||||
|
"sales-sse-init",
|
||||||
"mcp-sse-init",
|
"mcp-sse-init",
|
||||||
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||||
)
|
)
|
||||||
@@ -852,6 +868,7 @@ mod tests {
|
|||||||
publish_agent_with_bindings(®istry, "sales-get-sse", vec![]).await;
|
publish_agent_with_bindings(®istry, "sales-get-sse", vec![]).await;
|
||||||
let api_key = create_platform_api_key(
|
let api_key = create_platform_api_key(
|
||||||
®istry,
|
®istry,
|
||||||
|
"sales-get-sse",
|
||||||
"mcp-get-sse",
|
"mcp-get-sse",
|
||||||
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||||
)
|
)
|
||||||
@@ -903,6 +920,7 @@ mod tests {
|
|||||||
publish_agent_with_bindings(®istry, "sales-expired-session", vec![]).await;
|
publish_agent_with_bindings(®istry, "sales-expired-session", vec![]).await;
|
||||||
let api_key = create_platform_api_key(
|
let api_key = create_platform_api_key(
|
||||||
®istry,
|
®istry,
|
||||||
|
"sales-expired-session",
|
||||||
"mcp-expired-session",
|
"mcp-expired-session",
|
||||||
&[PlatformApiKeyScope::Read],
|
&[PlatformApiKeyScope::Read],
|
||||||
)
|
)
|
||||||
@@ -954,6 +972,7 @@ mod tests {
|
|||||||
publish_agent_with_bindings(®istry, "sales-get-rate-limit", vec![]).await;
|
publish_agent_with_bindings(®istry, "sales-get-rate-limit", vec![]).await;
|
||||||
let api_key = create_platform_api_key(
|
let api_key = create_platform_api_key(
|
||||||
®istry,
|
®istry,
|
||||||
|
"sales-get-rate-limit",
|
||||||
"mcp-get-rate-limit",
|
"mcp-get-rate-limit",
|
||||||
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||||
)
|
)
|
||||||
@@ -1003,6 +1022,7 @@ mod tests {
|
|||||||
publish_agent_with_bindings(®istry, "sales-get-sse-missing", vec![]).await;
|
publish_agent_with_bindings(®istry, "sales-get-sse-missing", vec![]).await;
|
||||||
let api_key = create_platform_api_key(
|
let api_key = create_platform_api_key(
|
||||||
®istry,
|
®istry,
|
||||||
|
"sales-get-sse-missing",
|
||||||
"mcp-get-sse-missing",
|
"mcp-get-sse-missing",
|
||||||
&[PlatformApiKeyScope::Read],
|
&[PlatformApiKeyScope::Read],
|
||||||
)
|
)
|
||||||
@@ -1032,6 +1052,7 @@ mod tests {
|
|||||||
publish_agent_with_bindings(®istry, "sales-delete-session", vec![]).await;
|
publish_agent_with_bindings(®istry, "sales-delete-session", vec![]).await;
|
||||||
let api_key = create_platform_api_key(
|
let api_key = create_platform_api_key(
|
||||||
®istry,
|
®istry,
|
||||||
|
"sales-delete-session",
|
||||||
"mcp-delete-session",
|
"mcp-delete-session",
|
||||||
&[PlatformApiKeyScope::Read],
|
&[PlatformApiKeyScope::Read],
|
||||||
)
|
)
|
||||||
@@ -1074,8 +1095,12 @@ mod tests {
|
|||||||
async fn initialize_accepts_json_only_response_negotiation() {
|
async fn initialize_accepts_json_only_response_negotiation() {
|
||||||
let registry = test_registry().await;
|
let registry = test_registry().await;
|
||||||
publish_agent_with_bindings(®istry, "sales-json-accept", vec![]).await;
|
publish_agent_with_bindings(®istry, "sales-json-accept", vec![]).await;
|
||||||
let api_key =
|
let api_key = create_platform_api_key(
|
||||||
create_platform_api_key(®istry, "mcp-json-accept", &[PlatformApiKeyScope::Read])
|
®istry,
|
||||||
|
"sales-json-accept",
|
||||||
|
"mcp-json-accept",
|
||||||
|
&[PlatformApiKeyScope::Read],
|
||||||
|
)
|
||||||
.await;
|
.await;
|
||||||
let base_url = spawn_mcp_server(build_test_app(
|
let base_url = spawn_mcp_server(build_test_app(
|
||||||
registry,
|
registry,
|
||||||
@@ -1117,6 +1142,7 @@ mod tests {
|
|||||||
publish_agent_with_bindings(®istry, "sales-get-bad-version", vec![]).await;
|
publish_agent_with_bindings(®istry, "sales-get-bad-version", vec![]).await;
|
||||||
let api_key = create_platform_api_key(
|
let api_key = create_platform_api_key(
|
||||||
®istry,
|
®istry,
|
||||||
|
"sales-get-bad-version",
|
||||||
"mcp-get-bad-version",
|
"mcp-get-bad-version",
|
||||||
&[PlatformApiKeyScope::Read],
|
&[PlatformApiKeyScope::Read],
|
||||||
)
|
)
|
||||||
@@ -1158,6 +1184,7 @@ mod tests {
|
|||||||
let mcp_url = agent_mcp_url(&base_url, "sales-refresh");
|
let mcp_url = agent_mcp_url(&base_url, "sales-refresh");
|
||||||
let api_key = create_platform_api_key(
|
let api_key = create_platform_api_key(
|
||||||
®istry,
|
®istry,
|
||||||
|
"sales-refresh",
|
||||||
"mcp-refresh",
|
"mcp-refresh",
|
||||||
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||||
)
|
)
|
||||||
@@ -1252,9 +1279,17 @@ mod tests {
|
|||||||
vec![binding_for_operation(&operation_b)],
|
vec![binding_for_operation(&operation_b)],
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
let api_key = create_platform_api_key(
|
let api_key_a = create_platform_api_key(
|
||||||
®istry,
|
®istry,
|
||||||
"mcp-agents",
|
"sales-a",
|
||||||
|
"mcp-agent-a",
|
||||||
|
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let api_key_b = create_platform_api_key(
|
||||||
|
®istry,
|
||||||
|
"sales-b",
|
||||||
|
"mcp-agent-b",
|
||||||
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -1268,13 +1303,13 @@ mod tests {
|
|||||||
let client = reqwest::Client::new();
|
let client = reqwest::Client::new();
|
||||||
let agent_a_url = agent_mcp_url(&base_url, "sales-a");
|
let agent_a_url = agent_mcp_url(&base_url, "sales-a");
|
||||||
let agent_b_url = agent_mcp_url(&base_url, "sales-b");
|
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_a = initialize_session(&client, &agent_a_url, &api_key_a).await;
|
||||||
let session_b = initialize_session(&client, &agent_b_url, &api_key).await;
|
let session_b = initialize_session(&client, &agent_b_url, &api_key_b).await;
|
||||||
|
|
||||||
let tools_a = post_jsonrpc(
|
let tools_a = post_jsonrpc(
|
||||||
&client,
|
&client,
|
||||||
&agent_a_url,
|
&agent_a_url,
|
||||||
&api_key,
|
&api_key_a,
|
||||||
Some(&session_a),
|
Some(&session_a),
|
||||||
json!({
|
json!({
|
||||||
"jsonrpc": "2.0",
|
"jsonrpc": "2.0",
|
||||||
@@ -1287,7 +1322,7 @@ mod tests {
|
|||||||
let tools_b = post_jsonrpc(
|
let tools_b = post_jsonrpc(
|
||||||
&client,
|
&client,
|
||||||
&agent_b_url,
|
&agent_b_url,
|
||||||
&api_key,
|
&api_key_b,
|
||||||
Some(&session_b),
|
Some(&session_b),
|
||||||
json!({
|
json!({
|
||||||
"jsonrpc": "2.0",
|
"jsonrpc": "2.0",
|
||||||
@@ -1302,6 +1337,76 @@ mod tests {
|
|||||||
assert_eq!(tools_b["result"]["tools"][0]["name"], "crm_update_lead");
|
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(
|
||||||
|
®istry,
|
||||||
|
"sales-a",
|
||||||
|
vec![binding_for_operation(&operation_a)],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
publish_agent_with_bindings(
|
||||||
|
®istry,
|
||||||
|
"sales-b",
|
||||||
|
vec![binding_for_operation(&operation_b)],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let api_key_a = create_platform_api_key(
|
||||||
|
®istry,
|
||||||
|
"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]
|
#[tokio::test]
|
||||||
async fn rejects_initialize_without_platform_api_key() {
|
async fn rejects_initialize_without_platform_api_key() {
|
||||||
let registry = test_registry().await;
|
let registry = test_registry().await;
|
||||||
@@ -1352,8 +1457,13 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
publish_agent_for_operation(®istry, &operation, "sales-read-only").await;
|
publish_agent_for_operation(®istry, &operation, "sales-read-only").await;
|
||||||
let api_key =
|
let api_key = create_platform_api_key(
|
||||||
create_platform_api_key(®istry, "mcp-read", &[PlatformApiKeyScope::Read]).await;
|
®istry,
|
||||||
|
"sales-read-only",
|
||||||
|
"mcp-read",
|
||||||
|
&[PlatformApiKeyScope::Read],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
let base_url = spawn_mcp_server(build_test_app(
|
let base_url = spawn_mcp_server(build_test_app(
|
||||||
registry,
|
registry,
|
||||||
@@ -1411,6 +1521,7 @@ mod tests {
|
|||||||
publish_agent_for_operation(®istry, &operation, "sales-session").await;
|
publish_agent_for_operation(®istry, &operation, "sales-session").await;
|
||||||
let api_key = create_platform_api_key(
|
let api_key = create_platform_api_key(
|
||||||
®istry,
|
®istry,
|
||||||
|
"sales-session",
|
||||||
"mcp-session",
|
"mcp-session",
|
||||||
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||||
)
|
)
|
||||||
@@ -1558,9 +1669,17 @@ mod tests {
|
|||||||
|
|
||||||
publish_agent_for_operation(®istry, &operation_a, "sales-session-a").await;
|
publish_agent_for_operation(®istry, &operation_a, "sales-session-a").await;
|
||||||
publish_agent_for_operation(®istry, &operation_b, "sales-session-b").await;
|
publish_agent_for_operation(®istry, &operation_b, "sales-session-b").await;
|
||||||
let api_key = create_platform_api_key(
|
let api_key_a = create_platform_api_key(
|
||||||
®istry,
|
®istry,
|
||||||
"mcp-cross-session",
|
"sales-session-a",
|
||||||
|
"mcp-cross-session-a",
|
||||||
|
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let api_key_b = create_platform_api_key(
|
||||||
|
®istry,
|
||||||
|
"sales-session-b",
|
||||||
|
"mcp-cross-session-b",
|
||||||
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -1574,13 +1693,13 @@ mod tests {
|
|||||||
let client = reqwest::Client::new();
|
let client = reqwest::Client::new();
|
||||||
let agent_a_url = agent_mcp_url(&base_url, "sales-session-a");
|
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 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_a = initialize_session(&client, &agent_a_url, &api_key_a).await;
|
||||||
let session_b = initialize_session(&client, &agent_b_url, &api_key).await;
|
let session_b = initialize_session(&client, &agent_b_url, &api_key_b).await;
|
||||||
|
|
||||||
let start_response = post_jsonrpc(
|
let start_response = post_jsonrpc(
|
||||||
&client,
|
&client,
|
||||||
&agent_a_url,
|
&agent_a_url,
|
||||||
&api_key,
|
&api_key_a,
|
||||||
Some(&session_a),
|
Some(&session_a),
|
||||||
json!({
|
json!({
|
||||||
"jsonrpc": "2.0",
|
"jsonrpc": "2.0",
|
||||||
@@ -1601,7 +1720,7 @@ mod tests {
|
|||||||
let poll_response = post_jsonrpc(
|
let poll_response = post_jsonrpc(
|
||||||
&client,
|
&client,
|
||||||
&agent_b_url,
|
&agent_b_url,
|
||||||
&api_key,
|
&api_key_b,
|
||||||
Some(&session_b),
|
Some(&session_b),
|
||||||
json!({
|
json!({
|
||||||
"jsonrpc": "2.0",
|
"jsonrpc": "2.0",
|
||||||
@@ -1650,6 +1769,7 @@ mod tests {
|
|||||||
publish_agent_for_operation(®istry, &operation, "sales-session-rate").await;
|
publish_agent_for_operation(®istry, &operation, "sales-session-rate").await;
|
||||||
let api_key = create_platform_api_key(
|
let api_key = create_platform_api_key(
|
||||||
®istry,
|
®istry,
|
||||||
|
"sales-session-rate",
|
||||||
"mcp-session-rate",
|
"mcp-session-rate",
|
||||||
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||||
)
|
)
|
||||||
@@ -1755,6 +1875,7 @@ mod tests {
|
|||||||
publish_agent_for_operation(®istry, &operation, "sales-async-rate").await;
|
publish_agent_for_operation(®istry, &operation, "sales-async-rate").await;
|
||||||
let api_key = create_platform_api_key(
|
let api_key = create_platform_api_key(
|
||||||
®istry,
|
®istry,
|
||||||
|
"sales-async-rate",
|
||||||
"mcp-async-rate",
|
"mcp-async-rate",
|
||||||
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||||
)
|
)
|
||||||
@@ -1866,6 +1987,7 @@ mod tests {
|
|||||||
publish_agent_for_operation(®istry, &operation, "sales-async-completed").await;
|
publish_agent_for_operation(®istry, &operation, "sales-async-completed").await;
|
||||||
let api_key = create_platform_api_key(
|
let api_key = create_platform_api_key(
|
||||||
®istry,
|
®istry,
|
||||||
|
"sales-async-completed",
|
||||||
"mcp-async-completed",
|
"mcp-async-completed",
|
||||||
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||||
)
|
)
|
||||||
@@ -1951,6 +2073,7 @@ mod tests {
|
|||||||
publish_agent_for_operation(®istry, &operation, "sales-rate-limited").await;
|
publish_agent_for_operation(®istry, &operation, "sales-rate-limited").await;
|
||||||
let api_key = create_platform_api_key(
|
let api_key = create_platform_api_key(
|
||||||
®istry,
|
®istry,
|
||||||
|
"sales-rate-limited",
|
||||||
"mcp-rate-limit",
|
"mcp-rate-limit",
|
||||||
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||||
)
|
)
|
||||||
@@ -2050,9 +2173,17 @@ mod tests {
|
|||||||
|
|
||||||
publish_agent_for_operation(®istry, &operation_a, "sales-async-a").await;
|
publish_agent_for_operation(®istry, &operation_a, "sales-async-a").await;
|
||||||
publish_agent_for_operation(®istry, &operation_b, "sales-async-b").await;
|
publish_agent_for_operation(®istry, &operation_b, "sales-async-b").await;
|
||||||
let api_key = create_platform_api_key(
|
let api_key_a = create_platform_api_key(
|
||||||
®istry,
|
®istry,
|
||||||
"mcp-cross-async",
|
"sales-async-a",
|
||||||
|
"mcp-cross-async-a",
|
||||||
|
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let api_key_b = create_platform_api_key(
|
||||||
|
®istry,
|
||||||
|
"sales-async-b",
|
||||||
|
"mcp-cross-async-b",
|
||||||
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -2066,13 +2197,13 @@ mod tests {
|
|||||||
let client = reqwest::Client::new();
|
let client = reqwest::Client::new();
|
||||||
let agent_a_url = agent_mcp_url(&base_url, "sales-async-a");
|
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 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_a = initialize_session(&client, &agent_a_url, &api_key_a).await;
|
||||||
let session_b = initialize_session(&client, &agent_b_url, &api_key).await;
|
let session_b = initialize_session(&client, &agent_b_url, &api_key_b).await;
|
||||||
|
|
||||||
let start_response = post_jsonrpc(
|
let start_response = post_jsonrpc(
|
||||||
&client,
|
&client,
|
||||||
&agent_a_url,
|
&agent_a_url,
|
||||||
&api_key,
|
&api_key_a,
|
||||||
Some(&session_a),
|
Some(&session_a),
|
||||||
json!({
|
json!({
|
||||||
"jsonrpc": "2.0",
|
"jsonrpc": "2.0",
|
||||||
@@ -2093,7 +2224,7 @@ mod tests {
|
|||||||
let status_response = post_jsonrpc(
|
let status_response = post_jsonrpc(
|
||||||
&client,
|
&client,
|
||||||
&agent_b_url,
|
&agent_b_url,
|
||||||
&api_key,
|
&api_key_b,
|
||||||
Some(&session_b),
|
Some(&session_b),
|
||||||
json!({
|
json!({
|
||||||
"jsonrpc": "2.0",
|
"jsonrpc": "2.0",
|
||||||
@@ -2141,6 +2272,7 @@ mod tests {
|
|||||||
publish_agent_for_operation(®istry, &operation, "sales-async").await;
|
publish_agent_for_operation(®istry, &operation, "sales-async").await;
|
||||||
let api_key = create_platform_api_key(
|
let api_key = create_platform_api_key(
|
||||||
®istry,
|
®istry,
|
||||||
|
"sales-async",
|
||||||
"mcp-async",
|
"mcp-async",
|
||||||
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||||
)
|
)
|
||||||
@@ -2290,6 +2422,7 @@ mod tests {
|
|||||||
publish_agent_for_operation(®istry, &operation, "sales-window").await;
|
publish_agent_for_operation(®istry, &operation, "sales-window").await;
|
||||||
let api_key = create_platform_api_key(
|
let api_key = create_platform_api_key(
|
||||||
®istry,
|
®istry,
|
||||||
|
"sales-window",
|
||||||
"mcp-window",
|
"mcp-window",
|
||||||
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||||
)
|
)
|
||||||
@@ -2418,6 +2551,7 @@ mod tests {
|
|||||||
|
|
||||||
async fn create_platform_api_key(
|
async fn create_platform_api_key(
|
||||||
registry: &PostgresRegistry,
|
registry: &PostgresRegistry,
|
||||||
|
agent_slug: &str,
|
||||||
name: &str,
|
name: &str,
|
||||||
scopes: &[PlatformApiKeyScope],
|
scopes: &[PlatformApiKeyScope],
|
||||||
) -> String {
|
) -> String {
|
||||||
@@ -2425,6 +2559,7 @@ mod tests {
|
|||||||
let api_key = PlatformApiKey {
|
let api_key = PlatformApiKey {
|
||||||
id: PlatformApiKeyId::new(format!("pk_{name}")),
|
id: PlatformApiKeyId::new(format!("pk_{name}")),
|
||||||
workspace_id: test_workspace_id(),
|
workspace_id: test_workspace_id(),
|
||||||
|
agent_id: Some(test_agent_id(agent_slug)),
|
||||||
name: name.to_owned(),
|
name: name.to_owned(),
|
||||||
prefix: secret.chars().take(16).collect(),
|
prefix: secret.chars().take(16).collect(),
|
||||||
scopes: scopes.to_vec(),
|
scopes: scopes.to_vec(),
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<base href="../">
|
<base href="../">
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<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 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/variables.css">
|
||||||
<link rel="stylesheet" href="css/layout.css">
|
<link rel="stylesheet" href="css/layout.css">
|
||||||
@@ -107,8 +107,8 @@
|
|||||||
|
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div class="page-header-text">
|
<div class="page-header-text">
|
||||||
<h1 class="page-title" data-i18n="apikeys.title">API Keys</h1>
|
<h1 class="page-title" data-i18n="apikeys.title">Agent Keys</h1>
|
||||||
<p class="page-subtitle" data-i18n="apikeys.subtitle">Keys authenticate external MCP clients and workspace automation against Crank.</p>
|
<p class="page-subtitle" data-i18n="apikeys.subtitle">Keys authenticate external MCP clients against a specific AI agent endpoint.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="page-header-actions">
|
<div class="page-header-actions">
|
||||||
<button class="btn-primary" id="btn-create-key" type="button">
|
<button class="btn-primary" id="btn-create-key" type="button">
|
||||||
@@ -129,11 +129,27 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
||||||
<div class="section-card-header">
|
<div class="section-card-header">
|
||||||
<div>
|
<div>
|
||||||
<div class="section-card-title" data-i18n="apikeys.active.title">Active keys</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>
|
||||||
<div class="filter-bar-search" style="max-width: 220px; margin: 0;">
|
<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>
|
<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-overlay" id="modal-create">
|
||||||
<div class="modal">
|
<div class="modal">
|
||||||
<div class="modal-header">
|
<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">
|
<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">
|
<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"/>
|
<line x1="1" y1="1" x2="11" y2="11"/><line x1="11" y1="1" x2="1" y2="11"/>
|
||||||
|
|||||||
+101
-8
@@ -1,5 +1,7 @@
|
|||||||
var KEYS = [];
|
var KEYS = [];
|
||||||
|
var AGENTS = [];
|
||||||
var currentWorkspaceId = null;
|
var currentWorkspaceId = null;
|
||||||
|
var currentAgentId = null;
|
||||||
var selectedScopes = new Set(['read']);
|
var selectedScopes = new Set(['read']);
|
||||||
var search = '';
|
var search = '';
|
||||||
|
|
||||||
@@ -32,6 +34,7 @@ function mapKeyRecord(record) {
|
|||||||
var apiKey = record.api_key || {};
|
var apiKey = record.api_key || {};
|
||||||
return {
|
return {
|
||||||
id: apiKey.id,
|
id: apiKey.id,
|
||||||
|
agentId: apiKey.agent_id || null,
|
||||||
name: apiKey.name,
|
name: apiKey.name,
|
||||||
prefix: apiKey.prefix || '',
|
prefix: apiKey.prefix || '',
|
||||||
scopes: apiKey.scopes || [],
|
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() {
|
function currentWorkspace() {
|
||||||
return window.getCurrentWorkspace ? window.getCurrentWorkspace() : null;
|
return window.getCurrentWorkspace ? window.getCurrentWorkspace() : null;
|
||||||
}
|
}
|
||||||
@@ -52,19 +64,46 @@ async function loadKeys() {
|
|||||||
setTableLoading(true);
|
setTableLoading(true);
|
||||||
|
|
||||||
if (!currentWorkspaceId || !window.CrankApi) {
|
if (!currentWorkspaceId || !window.CrankApi) {
|
||||||
|
AGENTS = [];
|
||||||
KEYS = [];
|
KEYS = [];
|
||||||
|
currentAgentId = null;
|
||||||
|
renderAgentPicker();
|
||||||
|
setCreateButtonState();
|
||||||
setTableLoading(false);
|
setTableLoading(false);
|
||||||
renderTable(tKey('apikeys.error.api'));
|
renderTable(tKey('apikeys.error.api'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
var response = await window.CrankApi.listPlatformApiKeys(currentWorkspaceId);
|
var response = await window.CrankApi.listAgents(currentWorkspaceId);
|
||||||
KEYS = ((response && response.items) || []).map(mapKeyRecord);
|
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);
|
setTableLoading(false);
|
||||||
renderTable();
|
renderTable();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
AGENTS = [];
|
||||||
KEYS = [];
|
KEYS = [];
|
||||||
|
currentAgentId = null;
|
||||||
|
renderAgentPicker();
|
||||||
|
setCreateButtonState();
|
||||||
setTableLoading(false);
|
setTableLoading(false);
|
||||||
renderTable(error.message || tKey('apikeys.error.load'));
|
renderTable(error.message || tKey('apikeys.error.load'));
|
||||||
}
|
}
|
||||||
@@ -85,10 +124,11 @@ function setTableLoading(on) {
|
|||||||
|
|
||||||
async function revokeKey(id) {
|
async function revokeKey(id) {
|
||||||
if (!confirm(tKey('apikeys.confirm.revoke'))) return;
|
if (!confirm(tKey('apikeys.confirm.revoke'))) return;
|
||||||
|
if (!currentAgentId) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
var key = KEYS.find(function(item) { return item.id === id; });
|
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();
|
await loadKeys();
|
||||||
if (window.CrankUi) {
|
if (window.CrankUi) {
|
||||||
window.CrankUi.success(
|
window.CrankUi.success(
|
||||||
@@ -108,10 +148,11 @@ async function revokeKey(id) {
|
|||||||
|
|
||||||
async function deleteKey(id) {
|
async function deleteKey(id) {
|
||||||
if (!confirm(tKey('apikeys.confirm.delete'))) return;
|
if (!confirm(tKey('apikeys.confirm.delete'))) return;
|
||||||
|
if (!currentAgentId) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
var key = KEYS.find(function(item) { return item.id === id; });
|
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();
|
await loadKeys();
|
||||||
if (window.CrankUi) {
|
if (window.CrankUi) {
|
||||||
window.CrankUi.success(
|
window.CrankUi.success(
|
||||||
@@ -130,7 +171,7 @@ async function deleteKey(id) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function createKey(name, scopes) {
|
async function createKey(name, scopes) {
|
||||||
var created = await window.CrankApi.createPlatformApiKey(currentWorkspaceId, {
|
var created = await window.CrankApi.createAgentPlatformApiKey(currentWorkspaceId, currentAgentId, {
|
||||||
name: name,
|
name: name,
|
||||||
scopes: scopes,
|
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() {
|
function renderScopes() {
|
||||||
var el = document.getElementById('scope-checkboxes');
|
var el = document.getElementById('scope-checkboxes');
|
||||||
var tmpl = document.getElementById('tmpl-scope-checkbox');
|
var tmpl = document.getElementById('tmpl-scope-checkbox');
|
||||||
@@ -165,14 +248,16 @@ function renderTable(errorMessage) {
|
|||||||
var rows = KEYS.filter(function(key) {
|
var rows = KEYS.filter(function(key) {
|
||||||
return !q || key.name.toLowerCase().includes(q) || key.prefix.toLowerCase().includes(q);
|
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 = '';
|
tbody.innerHTML = '';
|
||||||
|
|
||||||
if (subtitle) {
|
if (subtitle) {
|
||||||
var active = KEYS.filter(function(key) { return key.status === 'active'; }).length;
|
var active = KEYS.filter(function(key) { return key.status === 'active'; }).length;
|
||||||
var revoked = KEYS.filter(function(key) { return key.status === 'revoked'; }).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) {
|
if (errorMessage) {
|
||||||
@@ -191,7 +276,9 @@ function renderTable(errorMessage) {
|
|||||||
var td = document.createElement('td');
|
var td = document.createElement('td');
|
||||||
td.colSpan = 7;
|
td.colSpan = 7;
|
||||||
td.style.cssText = 'text-align:center;padding:36px;color:var(--text-muted);';
|
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);
|
empty.appendChild(td);
|
||||||
tbody.appendChild(empty);
|
tbody.appendChild(empty);
|
||||||
return;
|
return;
|
||||||
@@ -242,6 +329,7 @@ function renderTable(errorMessage) {
|
|||||||
var modal = document.getElementById('modal-create');
|
var modal = document.getElementById('modal-create');
|
||||||
|
|
||||||
function openModal() {
|
function openModal() {
|
||||||
|
if (!currentAgentId) return;
|
||||||
document.getElementById('modal-form-body').hidden = false;
|
document.getElementById('modal-form-body').hidden = false;
|
||||||
document.getElementById('modal-reveal-body').hidden = true;
|
document.getElementById('modal-reveal-body').hidden = true;
|
||||||
document.getElementById('modal-footer-create').hidden = false;
|
document.getElementById('modal-footer-create').hidden = false;
|
||||||
@@ -340,6 +428,11 @@ document.getElementById('key-search').addEventListener('input', function() {
|
|||||||
renderTable();
|
renderTable();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.getElementById('agent-select').addEventListener('change', async function() {
|
||||||
|
currentAgentId = this.value || null;
|
||||||
|
await loadKeys();
|
||||||
|
});
|
||||||
|
|
||||||
function copyPrefix(prefix) {
|
function copyPrefix(prefix) {
|
||||||
if (navigator.clipboard) {
|
if (navigator.clipboard) {
|
||||||
navigator.clipboard.writeText(prefix).catch(function() {});
|
navigator.clipboard.writeText(prefix).catch(function() {});
|
||||||
|
|||||||
+8
-8
@@ -331,17 +331,17 @@
|
|||||||
archiveAgent: function(workspaceId, agentId) {
|
archiveAgent: function(workspaceId, agentId) {
|
||||||
return post('/workspaces/' + encodeURIComponent(workspaceId) + '/agents/' + encodeURIComponent(agentId) + '/archive', {});
|
return post('/workspaces/' + encodeURIComponent(workspaceId) + '/agents/' + encodeURIComponent(agentId) + '/archive', {});
|
||||||
},
|
},
|
||||||
listPlatformApiKeys: function(workspaceId) {
|
listAgentPlatformApiKeys: function(workspaceId, agentId) {
|
||||||
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/platform-api-keys');
|
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/agents/' + encodeURIComponent(agentId) + '/platform-api-keys');
|
||||||
},
|
},
|
||||||
createPlatformApiKey: function(workspaceId, payload) {
|
createAgentPlatformApiKey: function(workspaceId, agentId, payload) {
|
||||||
return post('/workspaces/' + encodeURIComponent(workspaceId) + '/platform-api-keys', payload);
|
return post('/workspaces/' + encodeURIComponent(workspaceId) + '/agents/' + encodeURIComponent(agentId) + '/platform-api-keys', payload);
|
||||||
},
|
},
|
||||||
revokePlatformApiKey: function(workspaceId, keyId) {
|
revokeAgentPlatformApiKey: function(workspaceId, agentId, keyId) {
|
||||||
return post('/workspaces/' + encodeURIComponent(workspaceId) + '/platform-api-keys/' + encodeURIComponent(keyId) + '/revoke', {});
|
return post('/workspaces/' + encodeURIComponent(workspaceId) + '/agents/' + encodeURIComponent(agentId) + '/platform-api-keys/' + encodeURIComponent(keyId) + '/revoke', {});
|
||||||
},
|
},
|
||||||
deletePlatformApiKey: function(workspaceId, keyId) {
|
deleteAgentPlatformApiKey: function(workspaceId, agentId, keyId) {
|
||||||
return del('/workspaces/' + encodeURIComponent(workspaceId) + '/platform-api-keys/' + encodeURIComponent(keyId));
|
return del('/workspaces/' + encodeURIComponent(workspaceId) + '/agents/' + encodeURIComponent(agentId) + '/platform-api-keys/' + encodeURIComponent(keyId));
|
||||||
},
|
},
|
||||||
listSecrets: function(workspaceId) {
|
listSecrets: function(workspaceId) {
|
||||||
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/secrets');
|
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/secrets');
|
||||||
|
|||||||
+30
-16
@@ -96,9 +96,16 @@ var TRANSLATIONS = {
|
|||||||
'page.ops': 'operations',
|
'page.ops': 'operations',
|
||||||
|
|
||||||
// API Keys page
|
// API Keys page
|
||||||
'apikeys.title': 'API Keys',
|
'apikeys.title': 'Agent Keys',
|
||||||
'apikeys.subtitle': 'Keys authenticate external MCP clients and workspace automation against Crank.',
|
'apikeys.subtitle': 'Keys authenticate external MCP clients against a specific AI agent endpoint.',
|
||||||
'apikeys.new': 'Create key',
|
'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.name': 'NAME',
|
||||||
'apikeys.th.scope': 'SCOPE',
|
'apikeys.th.scope': 'SCOPE',
|
||||||
'apikeys.th.created':'CREATED',
|
'apikeys.th.created':'CREATED',
|
||||||
@@ -112,10 +119,10 @@ var TRANSLATIONS = {
|
|||||||
'apikeys.th.scopes': 'SCOPES',
|
'apikeys.th.scopes': 'SCOPES',
|
||||||
'apikeys.th.status': 'STATUS',
|
'apikeys.th.status': 'STATUS',
|
||||||
'apikeys.scope_ref': 'Scope reference',
|
'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.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.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': 'Key name',
|
||||||
'apikeys.modal.name_hint': 'A descriptive label to identify the key. Only visible to admins.',
|
'apikeys.modal.name_hint': 'A descriptive label to identify the key. Only visible to admins.',
|
||||||
'apikeys.modal.name_placeholder': 'e.g. Production, CI pipeline',
|
'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.delete_error_message': 'Failed to delete key',
|
||||||
'apikeys.toast.scope_missing_title': 'Missing scope',
|
'apikeys.toast.scope_missing_title': 'Missing scope',
|
||||||
'apikeys.toast.scope_missing_message': 'Select at least one scope before creating a key.',
|
'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_message': 'Copy the key now. It will not be shown again.',
|
||||||
'apikeys.toast.create_error_title': 'Key creation failed',
|
'apikeys.toast.create_error_title': 'Key creation failed',
|
||||||
'apikeys.toast.create_error_message': 'Failed to create key',
|
'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.copy_message': 'Store the raw key securely. It cannot be revealed again.',
|
||||||
'apikeys.toast.prefix_title': 'Key prefix copied',
|
'apikeys.toast.prefix_title': 'Key prefix copied',
|
||||||
'apikeys.action.copy_prefix': 'Copy key prefix',
|
'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.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.title': 'Danger zone',
|
||||||
'workspace_setup.danger.export_title': 'Export all data',
|
'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_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.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',
|
'workspace_setup.export': 'Export',
|
||||||
@@ -927,7 +934,7 @@ var TRANSLATIONS = {
|
|||||||
'agents.empty.search.title': 'No agents match "{query}"',
|
'agents.empty.search.title': 'No agents match "{query}"',
|
||||||
'agents.empty.search.sub': 'Try a different search term.',
|
'agents.empty.search.sub': 'Try a different search term.',
|
||||||
'agents.card.tools': 'tools',
|
'agents.card.tools': 'tools',
|
||||||
'agents.card.keys': 'API keys',
|
'agents.card.keys': 'keys',
|
||||||
'agents.card.calls_today': 'calls today',
|
'agents.card.calls_today': 'calls today',
|
||||||
'agents.card.created': 'Created {date}',
|
'agents.card.created': 'Created {date}',
|
||||||
'agents.card.copy_endpoint': 'Copy endpoint',
|
'agents.card.copy_endpoint': 'Copy endpoint',
|
||||||
@@ -1112,9 +1119,16 @@ var TRANSLATIONS = {
|
|||||||
'page.ops': 'операций',
|
'page.ops': 'операций',
|
||||||
|
|
||||||
// API Keys page
|
// API Keys page
|
||||||
'apikeys.title': 'API Ключи',
|
'apikeys.title': 'Ключи агентов',
|
||||||
'apikeys.subtitle': 'Ключи аутентифицируют внешние MCP-клиенты и автоматизацию воркспейса в Crank.',
|
'apikeys.subtitle': 'Ключи аутентифицируют внешние MCP-клиенты для конкретного endpoint AI-агента.',
|
||||||
'apikeys.new': 'Создать ключ',
|
'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.name': 'НАЗВАНИЕ',
|
||||||
'apikeys.th.scope': 'ОБЛАСТЬ',
|
'apikeys.th.scope': 'ОБЛАСТЬ',
|
||||||
'apikeys.th.created':'СОЗДАН',
|
'apikeys.th.created':'СОЗДАН',
|
||||||
@@ -1128,10 +1142,10 @@ var TRANSLATIONS = {
|
|||||||
'apikeys.th.scopes': 'ОБЛАСТИ',
|
'apikeys.th.scopes': 'ОБЛАСТИ',
|
||||||
'apikeys.th.status': 'СТАТУС',
|
'apikeys.th.status': 'СТАТУС',
|
||||||
'apikeys.scope_ref': 'Справка по областям',
|
'apikeys.scope_ref': 'Справка по областям',
|
||||||
'apikeys.scope.read': 'Инициализация MCP-сессий, ping сервера и получение списка инструментов для агента воркспейса.',
|
'apikeys.scope.read': 'Инициализация MCP-сессий, ping сервера и получение списка инструментов для выбранного AI-агента.',
|
||||||
'apikeys.scope.write': 'Выполнение `tools/call` для опубликованных наборов инструментов агента.',
|
'apikeys.scope.write': 'Выполнение `tools/call` для опубликованных наборов инструментов агента.',
|
||||||
'apikeys.scope.deploy': 'Зарезервировано для deploy-автоматизации. Сейчас также разрешает MCP read/write сценарии.',
|
'apikeys.scope.deploy': 'Зарезервировано для deploy-автоматизации. Сейчас также разрешает MCP read/write сценарии.',
|
||||||
'apikeys.modal.title': 'Создать API-ключ',
|
'apikeys.modal.title': 'Создать ключ агента',
|
||||||
'apikeys.modal.name': 'Имя ключа',
|
'apikeys.modal.name': 'Имя ключа',
|
||||||
'apikeys.modal.name_hint': 'Понятная метка для идентификации ключа. Видна только администраторам.',
|
'apikeys.modal.name_hint': 'Понятная метка для идентификации ключа. Видна только администраторам.',
|
||||||
'apikeys.modal.name_placeholder': 'например, Production, CI pipeline',
|
'apikeys.modal.name_placeholder': 'например, Production, CI pipeline',
|
||||||
@@ -1162,11 +1176,11 @@ var TRANSLATIONS = {
|
|||||||
'apikeys.toast.delete_error_message': 'Не удалось удалить ключ',
|
'apikeys.toast.delete_error_message': 'Не удалось удалить ключ',
|
||||||
'apikeys.toast.scope_missing_title': 'Не выбрана область',
|
'apikeys.toast.scope_missing_title': 'Не выбрана область',
|
||||||
'apikeys.toast.scope_missing_message': 'Выберите хотя бы одну область перед созданием ключа.',
|
'apikeys.toast.scope_missing_message': 'Выберите хотя бы одну область перед созданием ключа.',
|
||||||
'apikeys.toast.create_title': 'API-ключ создан',
|
'apikeys.toast.create_title': 'Ключ агента создан',
|
||||||
'apikeys.toast.create_message': 'Скопируйте ключ сейчас. Повторно он не будет показан.',
|
'apikeys.toast.create_message': 'Скопируйте ключ сейчас. Повторно он не будет показан.',
|
||||||
'apikeys.toast.create_error_title': 'Не удалось создать ключ',
|
'apikeys.toast.create_error_title': 'Не удалось создать ключ',
|
||||||
'apikeys.toast.create_error_message': 'Не удалось создать ключ',
|
'apikeys.toast.create_error_message': 'Не удалось создать ключ',
|
||||||
'apikeys.toast.copy_title': 'API-ключ скопирован',
|
'apikeys.toast.copy_title': 'Ключ агента скопирован',
|
||||||
'apikeys.toast.copy_message': 'Сохраните исходный ключ в надежном месте. Повторно показать его нельзя.',
|
'apikeys.toast.copy_message': 'Сохраните исходный ключ в надежном месте. Повторно показать его нельзя.',
|
||||||
'apikeys.toast.prefix_title': 'Префикс ключа скопирован',
|
'apikeys.toast.prefix_title': 'Префикс ключа скопирован',
|
||||||
'apikeys.action.copy_prefix': 'Скопировать префикс ключа',
|
'apikeys.action.copy_prefix': 'Скопировать префикс ключа',
|
||||||
@@ -1493,7 +1507,7 @@ var TRANSLATIONS = {
|
|||||||
'workspace_setup.create.footer': 'Вы станете владельцем этого воркспейса. После создания можно пригласить дополнительных участников.',
|
'workspace_setup.create.footer': 'Вы станете владельцем этого воркспейса. После создания можно пригласить дополнительных участников.',
|
||||||
'workspace_setup.danger.title': 'Опасная зона',
|
'workspace_setup.danger.title': 'Опасная зона',
|
||||||
'workspace_setup.danger.export_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_title': 'Удалить воркспейс',
|
||||||
'workspace_setup.danger.delete_body': 'Безвозвратно удаляет все операции, ключи, логи и настройки. Действие нельзя отменить. Вы будете немедленно разлогинены.',
|
'workspace_setup.danger.delete_body': 'Безвозвратно удаляет все операции, ключи, логи и настройки. Действие нельзя отменить. Вы будете немедленно разлогинены.',
|
||||||
'workspace_setup.export': 'Экспорт',
|
'workspace_setup.export': 'Экспорт',
|
||||||
@@ -1957,7 +1971,7 @@ var TRANSLATIONS = {
|
|||||||
'agents.empty.search.title': 'Нет агентов по запросу "{query}"',
|
'agents.empty.search.title': 'Нет агентов по запросу "{query}"',
|
||||||
'agents.empty.search.sub': 'Попробуйте другой поисковый запрос.',
|
'agents.empty.search.sub': 'Попробуйте другой поисковый запрос.',
|
||||||
'agents.card.tools': 'инструментов',
|
'agents.card.tools': 'инструментов',
|
||||||
'agents.card.keys': 'API-ключей',
|
'agents.card.keys': 'ключей',
|
||||||
'agents.card.calls_today': 'вызовов сегодня',
|
'agents.card.calls_today': 'вызовов сегодня',
|
||||||
'agents.card.created': 'Создан {date}',
|
'agents.card.created': 'Создан {date}',
|
||||||
'agents.card.copy_endpoint': 'Скопировать endpoint',
|
'agents.card.copy_endpoint': 'Скопировать endpoint',
|
||||||
|
|||||||
@@ -1,12 +1,23 @@
|
|||||||
const { test, expect } = require('@playwright/test');
|
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 }) => {
|
test('api keys page opens create key flow', async ({ page }) => {
|
||||||
await login(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 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 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('#new-key-name').fill(`playwright-${Date.now()}`);
|
||||||
await page.locator('#modal-confirm-btn').click();
|
await page.locator('#modal-confirm-btn').click();
|
||||||
await expect(page.locator('#modal-reveal-body')).toContainText(localized('Copy this key now', 'Скопируйте этот ключ сейчас'));
|
await expect(page.locator('#modal-reveal-body')).toContainText(localized('Copy this key now', 'Скопируйте этот ключ сейчас'));
|
||||||
|
|||||||
@@ -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(
|
return browserJson(
|
||||||
page,
|
page,
|
||||||
'POST',
|
'POST',
|
||||||
`/api/admin/workspaces/${encodeURIComponent(workspaceId)}/platform-api-keys`,
|
`/api/admin/workspaces/${encodeURIComponent(workspaceId)}/agents/${encodeURIComponent(agentId)}/platform-api-keys`,
|
||||||
{ name, scopes },
|
{ name, scopes },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -520,7 +520,13 @@ async function setupPublishedAgent(page, { operationPayload, agentSlug, toolName
|
|||||||
]);
|
]);
|
||||||
await publishAgent(page, workspace.id, createdAgent.agent_id, createdAgent.version);
|
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 {
|
return {
|
||||||
workspace,
|
workspace,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use time::OffsetDateTime;
|
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)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
@@ -80,6 +80,8 @@ pub struct InvitationToken {
|
|||||||
pub struct PlatformApiKey {
|
pub struct PlatformApiKey {
|
||||||
pub id: PlatformApiKeyId,
|
pub id: PlatformApiKeyId,
|
||||||
pub workspace_id: WorkspaceId,
|
pub workspace_id: WorkspaceId,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub agent_id: Option<AgentId>,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub prefix: String,
|
pub prefix: String,
|
||||||
pub scopes: Vec<PlatformApiKeyScope>,
|
pub scopes: Vec<PlatformApiKeyScope>,
|
||||||
@@ -96,7 +98,7 @@ mod tests {
|
|||||||
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
||||||
|
|
||||||
use super::{PlatformApiKey, PlatformApiKeyScope, PlatformApiKeyStatus, User, UserStatus};
|
use super::{PlatformApiKey, PlatformApiKeyScope, PlatformApiKeyStatus, User, UserStatus};
|
||||||
use crate::ids::{PlatformApiKeyId, UserId, WorkspaceId};
|
use crate::ids::{AgentId, PlatformApiKeyId, UserId, WorkspaceId};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn user_serializes_created_at_as_rfc3339() {
|
fn user_serializes_created_at_as_rfc3339() {
|
||||||
@@ -135,6 +137,7 @@ mod tests {
|
|||||||
let api_key = PlatformApiKey {
|
let api_key = PlatformApiKey {
|
||||||
id: PlatformApiKeyId::new("pk_01"),
|
id: PlatformApiKeyId::new("pk_01"),
|
||||||
workspace_id: WorkspaceId::new("ws_01"),
|
workspace_id: WorkspaceId::new("ws_01"),
|
||||||
|
agent_id: Some(AgentId::new("agent_01")),
|
||||||
name: "Primary".to_owned(),
|
name: "Primary".to_owned(),
|
||||||
prefix: "crk_live".to_owned(),
|
prefix: "crk_live".to_owned(),
|
||||||
scopes: vec![PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
scopes: vec![PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||||
|
|||||||
@@ -144,6 +144,7 @@ pub async fn apply_postgres(pool: &PgPool) -> Result<(), sqlx::Error> {
|
|||||||
"create table if not exists platform_api_keys (
|
"create table if not exists platform_api_keys (
|
||||||
id text primary key,
|
id text primary key,
|
||||||
workspace_id text not null references workspaces(id) on delete cascade,
|
workspace_id text not null references workspaces(id) on delete cascade,
|
||||||
|
agent_id text null,
|
||||||
name text not null,
|
name text not null,
|
||||||
prefix text not null,
|
prefix text not null,
|
||||||
secret_hash text not null,
|
secret_hash text not null,
|
||||||
@@ -162,6 +163,9 @@ pub async fn apply_postgres(pool: &PgPool) -> Result<(), sqlx::Error> {
|
|||||||
)
|
)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
query("alter table platform_api_keys add column if not exists agent_id text null")
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
query(
|
query(
|
||||||
"insert into workspaces (
|
"insert into workspaces (
|
||||||
|
|||||||
@@ -1,25 +1,29 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
impl PostgresRegistry {
|
impl PostgresRegistry {
|
||||||
pub async fn list_platform_api_keys(
|
pub async fn list_platform_api_keys_for_agent(
|
||||||
&self,
|
&self,
|
||||||
workspace_id: &WorkspaceId,
|
workspace_id: &WorkspaceId,
|
||||||
|
agent_id: &AgentId,
|
||||||
) -> Result<Vec<PlatformApiKeyRecord>, RegistryError> {
|
) -> Result<Vec<PlatformApiKeyRecord>, RegistryError> {
|
||||||
let rows = sqlx::query!(
|
let rows = sqlx::query(
|
||||||
"select
|
"select
|
||||||
id,
|
id,
|
||||||
workspace_id,
|
workspace_id,
|
||||||
|
agent_id,
|
||||||
name,
|
name,
|
||||||
prefix,
|
prefix,
|
||||||
scopes_json,
|
scopes_json,
|
||||||
status,
|
status,
|
||||||
created_at as \"created_at!: time::OffsetDateTime\",
|
created_at,
|
||||||
last_used_at as \"last_used_at: time::OffsetDateTime\"
|
last_used_at
|
||||||
from platform_api_keys
|
from platform_api_keys
|
||||||
where workspace_id = $1
|
where workspace_id = $1
|
||||||
|
and agent_id = $2
|
||||||
order by created_at desc",
|
order by created_at desc",
|
||||||
workspace_id.as_str(),
|
|
||||||
)
|
)
|
||||||
|
.bind(workspace_id.as_str())
|
||||||
|
.bind(agent_id.as_str())
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -27,58 +31,107 @@ impl PostgresRegistry {
|
|||||||
.map(|row| {
|
.map(|row| {
|
||||||
Ok(PlatformApiKeyRecord {
|
Ok(PlatformApiKeyRecord {
|
||||||
api_key: PlatformApiKey {
|
api_key: PlatformApiKey {
|
||||||
id: PlatformApiKeyId::new(row.id),
|
id: PlatformApiKeyId::new(row.get::<String, _>("id")),
|
||||||
workspace_id: WorkspaceId::new(row.workspace_id),
|
workspace_id: WorkspaceId::new(row.get::<String, _>("workspace_id")),
|
||||||
name: row.name,
|
agent_id: row.get::<Option<String>, _>("agent_id").map(AgentId::new),
|
||||||
prefix: row.prefix,
|
name: row.get("name"),
|
||||||
scopes: deserialize_json_value(row.scopes_json)?,
|
prefix: row.get("prefix"),
|
||||||
status: deserialize_enum_text(&row.status, "status")?,
|
scopes: deserialize_json_value(row.get::<Value, _>("scopes_json"))?,
|
||||||
created_at: row.created_at,
|
status: deserialize_enum_text(&row.get::<String, _>("status"), "status")?,
|
||||||
last_used_at: row.last_used_at,
|
created_at: row.get("created_at"),
|
||||||
|
last_used_at: row.get("last_used_at"),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect()
|
.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,
|
&self,
|
||||||
workspace_slug: &str,
|
workspace_slug: &str,
|
||||||
|
agent_slug: &str,
|
||||||
secret_hash: &str,
|
secret_hash: &str,
|
||||||
) -> Result<Option<PlatformApiKeyRecord>, RegistryError> {
|
) -> Result<Option<PlatformApiKeyRecord>, RegistryError> {
|
||||||
let row = sqlx::query!(
|
let row = sqlx::query(
|
||||||
"select
|
"select
|
||||||
k.id,
|
k.id,
|
||||||
k.workspace_id,
|
k.workspace_id,
|
||||||
|
k.agent_id,
|
||||||
k.name,
|
k.name,
|
||||||
k.prefix,
|
k.prefix,
|
||||||
k.scopes_json,
|
k.scopes_json,
|
||||||
k.status,
|
k.status,
|
||||||
k.created_at as \"created_at!: time::OffsetDateTime\",
|
k.created_at,
|
||||||
k.last_used_at as \"last_used_at: time::OffsetDateTime\"
|
k.last_used_at
|
||||||
from platform_api_keys k
|
from platform_api_keys k
|
||||||
join workspaces w on w.id = k.workspace_id
|
join workspaces w on w.id = k.workspace_id
|
||||||
|
join agents a on a.id = k.agent_id
|
||||||
where w.slug = $1
|
where w.slug = $1
|
||||||
and k.secret_hash = $2
|
and a.slug = $2
|
||||||
|
and k.secret_hash = $3
|
||||||
and k.status = 'active'
|
and k.status = 'active'
|
||||||
limit 1",
|
limit 1",
|
||||||
workspace_slug,
|
|
||||||
secret_hash,
|
|
||||||
)
|
)
|
||||||
|
.bind(workspace_slug)
|
||||||
|
.bind(agent_slug)
|
||||||
|
.bind(secret_hash)
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
row.map(|row| {
|
row.map(|row| {
|
||||||
Ok(PlatformApiKeyRecord {
|
Ok(PlatformApiKeyRecord {
|
||||||
api_key: PlatformApiKey {
|
api_key: PlatformApiKey {
|
||||||
id: PlatformApiKeyId::new(row.id),
|
id: PlatformApiKeyId::new(row.get::<String, _>("id")),
|
||||||
workspace_id: WorkspaceId::new(row.workspace_id),
|
workspace_id: WorkspaceId::new(row.get::<String, _>("workspace_id")),
|
||||||
name: row.name,
|
agent_id: row.get::<Option<String>, _>("agent_id").map(AgentId::new),
|
||||||
prefix: row.prefix,
|
name: row.get("name"),
|
||||||
scopes: deserialize_json_value(row.scopes_json)?,
|
prefix: row.get("prefix"),
|
||||||
status: deserialize_enum_text(&row.status, "status")?,
|
scopes: deserialize_json_value(row.get::<Value, _>("scopes_json"))?,
|
||||||
created_at: row.created_at,
|
status: deserialize_enum_text(&row.get::<String, _>("status"), "status")?,
|
||||||
last_used_at: row.last_used_at,
|
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 (
|
"insert into platform_api_keys (
|
||||||
id,
|
id,
|
||||||
workspace_id,
|
workspace_id,
|
||||||
|
agent_id,
|
||||||
name,
|
name,
|
||||||
prefix,
|
prefix,
|
||||||
secret_hash,
|
secret_hash,
|
||||||
@@ -102,11 +156,12 @@ impl PostgresRegistry {
|
|||||||
last_used_at,
|
last_used_at,
|
||||||
revoked_at
|
revoked_at
|
||||||
) values (
|
) 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.id.as_str())
|
||||||
.bind(request.api_key.workspace_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.name)
|
||||||
.bind(&request.api_key.prefix)
|
.bind(&request.api_key.prefix)
|
||||||
.bind(request.secret_hash)
|
.bind(request.secret_hash)
|
||||||
@@ -160,6 +215,39 @@ impl PostgresRegistry {
|
|||||||
Ok(())
|
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(
|
pub async fn delete_platform_api_key(
|
||||||
&self,
|
&self,
|
||||||
workspace_id: &WorkspaceId,
|
workspace_id: &WorkspaceId,
|
||||||
@@ -181,6 +269,30 @@ impl PostgresRegistry {
|
|||||||
Ok(())
|
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(
|
pub async fn touch_platform_api_key(
|
||||||
&self,
|
&self,
|
||||||
workspace_id: &WorkspaceId,
|
workspace_id: &WorkspaceId,
|
||||||
|
|||||||
@@ -1670,9 +1670,12 @@ mod tests {
|
|||||||
created_at: timestamp("2026-03-25T12:00:00Z"),
|
created_at: timestamp("2026-03-25T12:00:00Z"),
|
||||||
updated_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 {
|
let api_key = PlatformApiKey {
|
||||||
id: PlatformApiKeyId::new("key_01"),
|
id: PlatformApiKeyId::new("key_01"),
|
||||||
workspace_id: workspace.id.clone(),
|
workspace_id: workspace.id.clone(),
|
||||||
|
agent_id: Some(agent.id.clone()),
|
||||||
name: "Primary".to_owned(),
|
name: "Primary".to_owned(),
|
||||||
prefix: "crk_live".to_owned(),
|
prefix: "crk_live".to_owned(),
|
||||||
scopes: vec![PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
scopes: vec![PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||||
@@ -1688,6 +1691,14 @@ mod tests {
|
|||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
registry
|
||||||
|
.create_agent(CreateAgentRequest {
|
||||||
|
agent: &agent,
|
||||||
|
version: &version,
|
||||||
|
bindings: &[],
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
registry
|
registry
|
||||||
.create_platform_api_key(CreatePlatformApiKeyRequest {
|
.create_platform_api_key(CreatePlatformApiKeyRequest {
|
||||||
api_key: &api_key,
|
api_key: &api_key,
|
||||||
@@ -1700,15 +1711,29 @@ mod tests {
|
|||||||
.list_platform_api_keys(&workspace.id)
|
.list_platform_api_keys(&workspace.id)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
let listed_for_agent = registry
|
||||||
|
.list_platform_api_keys_for_agent(&workspace.id, &agent.id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
listed,
|
listed,
|
||||||
vec![PlatformApiKeyRecord {
|
vec![PlatformApiKeyRecord {
|
||||||
api_key: api_key.clone()
|
api_key: api_key.clone()
|
||||||
}]
|
}]
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
listed_for_agent,
|
||||||
|
vec![PlatformApiKeyRecord {
|
||||||
|
api_key: api_key.clone()
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
|
||||||
let resolved = registry
|
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
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|||||||
+8
-8
@@ -175,18 +175,18 @@
|
|||||||
- `POST /api/admin/workspaces/{workspace_id}/agents/{agent_id}/archive`
|
- `POST /api/admin/workspaces/{workspace_id}/agents/{agent_id}/archive`
|
||||||
- `POST /api/admin/workspaces/{workspace_id}/agents/{agent_id}/bindings`
|
- `POST /api/admin/workspaces/{workspace_id}/agents/{agent_id}/bindings`
|
||||||
- `DELETE /api/admin/workspaces/{workspace_id}/agents/{agent_id}/bindings/{operation_id}`
|
- `DELETE /api/admin/workspaces/{workspace_id}/agents/{agent_id}/bindings/{operation_id}`
|
||||||
- `GET /api/admin/workspaces/{workspace_id}/agents/{agent_id}/keys`
|
- `GET /api/admin/workspaces/{workspace_id}/agents/{agent_id}/platform-api-keys`
|
||||||
- `POST /api/admin/workspaces/{workspace_id}/agents/{agent_id}/keys`
|
- `POST /api/admin/workspaces/{workspace_id}/agents/{agent_id}/platform-api-keys`
|
||||||
- `POST /api/admin/workspaces/{workspace_id}/agents/{agent_id}/keys/{key_id}/revoke`
|
- `POST /api/admin/workspaces/{workspace_id}/agents/{agent_id}/platform-api-keys/{key_id}/revoke`
|
||||||
- `DELETE /api/admin/workspaces/{workspace_id}/agents/{agent_id}/keys/{key_id}`
|
- `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;
|
- полное значение ключа доступно только в create-response;
|
||||||
- list endpoints возвращают только metadata, `prefix`, `status`, `scopes`, `expires_at` и `last_used_at`;
|
- list endpoints возвращают только metadata, `prefix`, `status`, `scopes`, `expires_at` и `last_used_at`;
|
||||||
- ключ агента принадлежит одному `agent` и не должен использоваться как основной рабочий токен вызова в целевой защищенной схеме;
|
- в `Community` ключ агента является основным машинным credential;
|
||||||
- ключ агента используется для получения короткоживущего токена доступа к MCP endpoint.
|
- в коммерческих редакциях этот же ключ используется как исходный credential для более строгих токенных режимов.
|
||||||
|
|
||||||
### 5.7. Token issuance
|
### 5.7. Token issuance
|
||||||
|
|
||||||
@@ -199,7 +199,7 @@
|
|||||||
|
|
||||||
- `POST /mcp-auth/v1/token` выдает короткоживущий токен доступа для операций уровня `elevated`;
|
- `POST /mcp-auth/v1/token` выдает короткоживущий токен доступа для операций уровня `elevated`;
|
||||||
- `POST /mcp-auth/v1/token/one-time` выдает одноразовый токен для операций уровня `strict`;
|
- `POST /mcp-auth/v1/token/one-time` выдает одноразовый токен для операций уровня `strict`;
|
||||||
- открытая редакция может не использовать эти конечные точки и работать только со статическим ключом агента;
|
- открытая редакция работает только со статическим ключом агента и не обязана реализовывать эти конечные точки;
|
||||||
- детальная схема уровней и режимов доступа зафиксирована в `docs/agent-auth-model.md`.
|
- детальная схема уровней и режимов доступа зафиксирована в `docs/agent-auth-model.md`.
|
||||||
|
|
||||||
### 5.8. Observability
|
### 5.8. Observability
|
||||||
|
|||||||
Reference in New Issue
Block a user