auth: cut over community machine access to agent keys
This commit is contained in:
@@ -2,55 +2,9 @@
|
||||
|
||||
## Current
|
||||
|
||||
### `feat/community-agent-key-cutover`
|
||||
|
||||
Status: in_progress
|
||||
|
||||
Goal:
|
||||
- довести открытую редакцию до продуктово завершенного машинного доступа через ключ AI-агента.
|
||||
|
||||
Main code areas:
|
||||
- `crates/crank-core/src/agent.rs`
|
||||
- `crates/crank-core/src/auth.rs`
|
||||
- `crates/crank-registry/src/postgres/agent.rs`
|
||||
- `crates/crank-registry/src/postgres/api_key.rs`
|
||||
- `apps/admin-api/src/service.rs`
|
||||
- `apps/admin-api/src/app.rs`
|
||||
- `apps/mcp-server/src/app.rs`
|
||||
- `apps/ui/js/agents.js`
|
||||
- `apps/ui/js/api-keys.js`
|
||||
- `apps/ui/html/agents.html`
|
||||
- `apps/ui/html/api-keys.html`
|
||||
- `apps/ui/js/i18n.js`
|
||||
|
||||
Implementation slices:
|
||||
1. Закрыть remaining workspace/platform-key assumptions в `admin-api` и `mcp-server`.
|
||||
2. Нормализовать list/create/revoke/delete flow для agent keys.
|
||||
3. Привести UI страницы `Agents` и `API Keys` к одной модели agent-scoped machine access.
|
||||
4. Явно ограничить `Community` на `security_level = standard`.
|
||||
|
||||
Progress:
|
||||
- done: `mcp-server` принимает ключ только по `(workspace_slug, agent_slug, secret_hash)`;
|
||||
- done: `admin-api` уже отдает и принимает agent-scoped key routes;
|
||||
- done: UI `API Keys` переведен на agent-scoped machine access;
|
||||
- done: removed legacy workspace-key routes, demo/bootstrap assumptions, and per-agent key count bug.
|
||||
|
||||
DoD:
|
||||
- ключ создается и принадлежит конкретному AI-агенту;
|
||||
- UI объясняет назначение ключа без ссылок на старую workspace-key модель;
|
||||
- `mcp-server` принимает Community machine access только по agent key;
|
||||
- Community flow не требует short-lived token service.
|
||||
|
||||
Verification:
|
||||
- backend unit and integration tests for key lifecycle;
|
||||
- MCP tool-call tests through agent endpoint;
|
||||
- e2e smoke for agent key create/reveal/revoke.
|
||||
|
||||
## Next
|
||||
|
||||
### `feat/edition-capability-model`
|
||||
|
||||
Status: ready
|
||||
Status: in_progress
|
||||
|
||||
Goal:
|
||||
- ввести capability model по редакциям и перестать смешивать Community и premium surface.
|
||||
@@ -74,6 +28,11 @@ Implementation slices:
|
||||
3. Привести UI к capability gating вместо скрытых продуктовых допущений.
|
||||
4. Зафиксировать Community defaults в docs и runtime config.
|
||||
|
||||
Progress:
|
||||
- pending: define shared capability payload in `crank-core` and `admin-api`;
|
||||
- pending: expose Community capability defaults to UI;
|
||||
- pending: gate premium protocols and security levels in wizard and agents flows.
|
||||
|
||||
DoD:
|
||||
- UI знает, какие функции входят в текущую редакцию;
|
||||
- Community не показывает рабочие controls для premium-only features;
|
||||
@@ -84,6 +43,8 @@ Verification:
|
||||
- frontend smoke for hidden/locked states;
|
||||
- manual verification on Community build.
|
||||
|
||||
## Next
|
||||
|
||||
### `feat/private-token-service-seam`
|
||||
|
||||
Status: ready
|
||||
|
||||
+97
-61
@@ -9,13 +9,14 @@ use crate::{
|
||||
request_context::apply_request_context,
|
||||
routes::{
|
||||
access::{
|
||||
create_invitation, create_platform_api_key, delete_invitation, delete_membership,
|
||||
delete_platform_api_key, delete_workspace, export_workspace, list_invitations,
|
||||
list_memberships, list_platform_api_keys, revoke_platform_api_key, update_membership,
|
||||
create_invitation, delete_invitation, delete_membership, delete_workspace,
|
||||
export_workspace, list_invitations, list_memberships, update_membership,
|
||||
},
|
||||
agents::{
|
||||
archive_agent, create_agent, delete_agent, get_agent, get_agent_version, list_agents,
|
||||
publish_agent, save_agent_bindings, unpublish_agent, update_agent,
|
||||
archive_agent, create_agent, create_agent_platform_api_key, delete_agent,
|
||||
delete_agent_platform_api_key, get_agent, get_agent_version,
|
||||
list_agent_platform_api_keys, list_agents, publish_agent,
|
||||
revoke_agent_platform_api_key, save_agent_bindings, unpublish_agent, update_agent,
|
||||
},
|
||||
auth::{
|
||||
change_password, get_profile, get_session, login, logout, update_current_workspace,
|
||||
@@ -114,6 +115,18 @@ pub fn build_app(state: AppState) -> Router {
|
||||
.route("/agents/{agent_id}/publish", post(publish_agent))
|
||||
.route("/agents/{agent_id}/unpublish", post(unpublish_agent))
|
||||
.route("/agents/{agent_id}/archive", post(archive_agent))
|
||||
.route(
|
||||
"/agents/{agent_id}/platform-api-keys",
|
||||
get(list_agent_platform_api_keys).post(create_agent_platform_api_key),
|
||||
)
|
||||
.route(
|
||||
"/agents/{agent_id}/platform-api-keys/{key_id}/revoke",
|
||||
post(revoke_agent_platform_api_key),
|
||||
)
|
||||
.route(
|
||||
"/agents/{agent_id}/platform-api-keys/{key_id}",
|
||||
delete(delete_agent_platform_api_key),
|
||||
)
|
||||
.route(
|
||||
"/auth-profiles",
|
||||
get(list_auth_profiles).post(create_auth_profile),
|
||||
@@ -136,18 +149,6 @@ pub fn build_app(state: AppState) -> Router {
|
||||
)
|
||||
.route("/invitations/{invitation_id}", delete(delete_invitation))
|
||||
.route("/export", get(export_workspace))
|
||||
.route(
|
||||
"/platform-api-keys",
|
||||
get(list_platform_api_keys).post(create_platform_api_key),
|
||||
)
|
||||
.route(
|
||||
"/platform-api-keys/{key_id}/revoke",
|
||||
post(revoke_platform_api_key),
|
||||
)
|
||||
.route(
|
||||
"/platform-api-keys/{key_id}",
|
||||
delete(delete_platform_api_key),
|
||||
)
|
||||
.route("/logs", get(list_logs))
|
||||
.route("/logs/{log_id}", get(get_log))
|
||||
.route("/usage", get(get_usage))
|
||||
@@ -891,48 +892,12 @@ mod tests {
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
let created_key = client
|
||||
.post(format!("{base_url}/platform-api-keys"))
|
||||
.json(&json!({
|
||||
"name": "workspace-operator",
|
||||
"scopes": ["read", "write"]
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
let key_id = created_key["api_key"]["api_key"]["id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_owned();
|
||||
let listed_keys = client
|
||||
.get(format!("{base_url}/platform-api-keys"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
let revoke_status = client
|
||||
.post(format!("{base_url}/platform-api-keys/{key_id}/revoke"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.status();
|
||||
let delete_invitation_status = client
|
||||
.delete(format!("{base_url}/invitations/{invitation_id}"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.status();
|
||||
let delete_key_status = client
|
||||
.delete(format!("{base_url}/platform-api-keys/{key_id}"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.status();
|
||||
|
||||
assert_eq!(members["items"][0]["role"], "owner");
|
||||
assert_eq!(
|
||||
@@ -949,14 +914,89 @@ mod tests {
|
||||
invitations["items"][0]["invitation"]["email"],
|
||||
"operator@example.com"
|
||||
);
|
||||
assert_eq!(delete_invitation_status, reqwest::StatusCode::NO_CONTENT);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn manages_agent_platform_api_keys() {
|
||||
let registry = test_registry().await;
|
||||
let storage_root = test_storage_root("agent_platform_keys");
|
||||
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
|
||||
let client = authorized_client(&base_url).await;
|
||||
|
||||
let created_agent = assert_success_json(
|
||||
client
|
||||
.post(format!("{base_url}/agents"))
|
||||
.json(&json!({
|
||||
"slug": "sales-routing",
|
||||
"display_name": "Sales Routing",
|
||||
"description": "Routing agent",
|
||||
"instructions": {},
|
||||
"tool_selection_policy": {}
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
let agent_id = created_agent["agent_id"].as_str().unwrap().to_owned();
|
||||
|
||||
let created_key = assert_success_json(
|
||||
client
|
||||
.post(format!("{base_url}/agents/{agent_id}/platform-api-keys"))
|
||||
.json(&json!({
|
||||
"name": "sales-routing-primary",
|
||||
"scopes": ["read", "write"]
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
let key_id = created_key["api_key"]["api_key"]["id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_owned();
|
||||
|
||||
let listed_keys = assert_success_json(
|
||||
client
|
||||
.get(format!("{base_url}/agents/{agent_id}/platform-api-keys"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
|
||||
let revoke_status = client
|
||||
.post(format!(
|
||||
"{base_url}/agents/{agent_id}/platform-api-keys/{key_id}/revoke"
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.status();
|
||||
let delete_status = client
|
||||
.delete(format!(
|
||||
"{base_url}/agents/{agent_id}/platform-api-keys/{key_id}"
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.status();
|
||||
|
||||
assert_eq!(created_key["api_key"]["api_key"]["agent_id"], agent_id);
|
||||
assert_eq!(
|
||||
listed_keys["items"][0]["api_key"]["agent_id"],
|
||||
json!(agent_id)
|
||||
);
|
||||
assert_eq!(
|
||||
listed_keys["items"][0]["api_key"]["name"],
|
||||
"workspace-operator"
|
||||
"sales-routing-primary"
|
||||
);
|
||||
assert!(created_key["secret"].as_str().unwrap().starts_with("crk_"));
|
||||
assert_eq!(revoke_status, reqwest::StatusCode::NO_CONTENT);
|
||||
assert_eq!(delete_invitation_status, reqwest::StatusCode::NO_CONTENT);
|
||||
assert_eq!(delete_key_status, reqwest::StatusCode::NO_CONTENT);
|
||||
assert_eq!(delete_status, reqwest::StatusCode::NO_CONTENT);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
@@ -1087,11 +1127,7 @@ mod tests {
|
||||
let agents = service.list_agents(&default_workspace_id).await.unwrap();
|
||||
assert!(agents.len() >= 2);
|
||||
|
||||
let api_keys = service
|
||||
.list_platform_api_keys(&default_workspace_id)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(api_keys.len() >= 2);
|
||||
assert!(agents.iter().any(|agent| agent.key_count > 0));
|
||||
|
||||
let logs = service
|
||||
.list_logs(
|
||||
|
||||
@@ -9,7 +9,7 @@ use serde_json::{Value, json};
|
||||
use crate::{
|
||||
auth::AuthenticatedSession,
|
||||
error::ApiError,
|
||||
service::{InvitationPayload, PlatformApiKeyPayload, UpdateMembershipPayload},
|
||||
service::{InvitationPayload, UpdateMembershipPayload},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
@@ -30,12 +30,6 @@ pub struct WorkspaceMembershipPath {
|
||||
pub user_id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct WorkspacePlatformApiKeyPath {
|
||||
pub workspace_id: String,
|
||||
pub key_id: String,
|
||||
}
|
||||
|
||||
pub async fn list_memberships(
|
||||
Path(path): Path<WorkspacePath>,
|
||||
State(state): State<AppState>,
|
||||
@@ -118,57 +112,6 @@ pub async fn delete_invitation(
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
pub async fn list_platform_api_keys(
|
||||
Path(path): Path<WorkspacePath>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let items = state
|
||||
.service
|
||||
.list_platform_api_keys(&path.workspace_id.as_str().into())
|
||||
.await?;
|
||||
Ok(Json(json!({ "items": items })))
|
||||
}
|
||||
|
||||
pub async fn create_platform_api_key(
|
||||
Path(path): Path<WorkspacePath>,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<PlatformApiKeyPayload>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let created = state
|
||||
.service
|
||||
.create_platform_api_key(&path.workspace_id.as_str().into(), payload)
|
||||
.await?;
|
||||
Ok(Json(json!(created)))
|
||||
}
|
||||
|
||||
pub async fn revoke_platform_api_key(
|
||||
Path(path): Path<WorkspacePlatformApiKeyPath>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
state
|
||||
.service
|
||||
.revoke_platform_api_key(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.key_id.as_str().into(),
|
||||
)
|
||||
.await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
pub async fn delete_platform_api_key(
|
||||
Path(path): Path<WorkspacePlatformApiKeyPath>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
state
|
||||
.service
|
||||
.delete_platform_api_key(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.key_id.as_str().into(),
|
||||
)
|
||||
.await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
pub async fn export_workspace(
|
||||
Path(path): Path<WorkspacePath>,
|
||||
State(state): State<AppState>,
|
||||
|
||||
@@ -7,7 +7,10 @@ use serde_json::{Value, json};
|
||||
|
||||
use crate::{
|
||||
error::ApiError,
|
||||
service::{AgentBindingPayload, AgentPayload, PublishPayload, UpdateAgentPayload},
|
||||
service::{
|
||||
AgentBindingPayload, AgentPayload, PlatformApiKeyPayload, PublishPayload,
|
||||
UpdateAgentPayload,
|
||||
},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
@@ -29,6 +32,13 @@ pub struct WorkspaceAgentVersionPath {
|
||||
pub version: u32,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct WorkspaceAgentPlatformApiKeyPath {
|
||||
pub workspace_id: String,
|
||||
pub agent_id: String,
|
||||
pub key_id: String,
|
||||
}
|
||||
|
||||
pub async fn list_agents(
|
||||
Path(path): Path<WorkspacePath>,
|
||||
State(state): State<AppState>,
|
||||
@@ -170,3 +180,63 @@ pub async fn archive_agent(
|
||||
.await?;
|
||||
Ok(Json(json!(updated)))
|
||||
}
|
||||
|
||||
pub async fn list_agent_platform_api_keys(
|
||||
Path(path): Path<WorkspaceAgentPath>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let items = state
|
||||
.service
|
||||
.list_agent_platform_api_keys(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.agent_id.as_str().into(),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!({ "items": items })))
|
||||
}
|
||||
|
||||
pub async fn create_agent_platform_api_key(
|
||||
Path(path): Path<WorkspaceAgentPath>,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<PlatformApiKeyPayload>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let created = state
|
||||
.service
|
||||
.create_agent_platform_api_key(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.agent_id.as_str().into(),
|
||||
payload,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(created)))
|
||||
}
|
||||
|
||||
pub async fn revoke_agent_platform_api_key(
|
||||
Path(path): Path<WorkspaceAgentPlatformApiKeyPath>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<axum::http::StatusCode, ApiError> {
|
||||
state
|
||||
.service
|
||||
.revoke_agent_platform_api_key(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.agent_id.as_str().into(),
|
||||
&path.key_id.as_str().into(),
|
||||
)
|
||||
.await?;
|
||||
Ok(axum::http::StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
pub async fn delete_agent_platform_api_key(
|
||||
Path(path): Path<WorkspaceAgentPlatformApiKeyPath>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<axum::http::StatusCode, ApiError> {
|
||||
state
|
||||
.service
|
||||
.delete_agent_platform_api_key(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.agent_id.as_str().into(),
|
||||
&path.key_id.as_str().into(),
|
||||
)
|
||||
.await?;
|
||||
Ok(axum::http::StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
@@ -1279,7 +1279,7 @@ impl AdminService {
|
||||
.collect();
|
||||
let operations = self.list_operations(workspace_id).await?;
|
||||
let agents = self.list_agents(workspace_id).await?;
|
||||
let platform_api_keys = self.list_platform_api_keys(workspace_id).await?;
|
||||
let platform_api_keys = self.registry.list_platform_api_keys(workspace_id).await?;
|
||||
|
||||
Ok(WorkspaceExportResponse {
|
||||
workspace,
|
||||
@@ -1314,27 +1314,51 @@ impl AdminService {
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
pub async fn list_platform_api_keys(
|
||||
pub async fn list_agent_platform_api_keys(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
agent_id: &AgentId,
|
||||
) -> Result<Vec<PlatformApiKeyRecord>, ApiError> {
|
||||
self.ensure_workspace_exists(workspace_id).await?;
|
||||
Ok(self.registry.list_platform_api_keys(workspace_id).await?)
|
||||
self.registry
|
||||
.get_agent_summary(workspace_id, agent_id)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
ApiError::not_found_with_context(
|
||||
format!("agent {} was not found", agent_id.as_str()),
|
||||
json!({ "agent_id": agent_id.as_str() }),
|
||||
)
|
||||
})?;
|
||||
Ok(self
|
||||
.registry
|
||||
.list_platform_api_keys_for_agent(workspace_id, agent_id)
|
||||
.await?)
|
||||
}
|
||||
|
||||
#[instrument(skip(self, payload), fields(workspace_id = %workspace_id.as_str(), key_name = %payload.name))]
|
||||
pub async fn create_platform_api_key(
|
||||
#[instrument(skip(self, payload), fields(workspace_id = %workspace_id.as_str(), agent_id = %agent_id.as_str(), key_name = %payload.name))]
|
||||
pub async fn create_agent_platform_api_key(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
agent_id: &AgentId,
|
||||
payload: PlatformApiKeyPayload,
|
||||
) -> Result<CreatedPlatformApiKeyResponse, ApiError> {
|
||||
self.ensure_workspace_exists(workspace_id).await?;
|
||||
self.registry
|
||||
.get_agent_summary(workspace_id, agent_id)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
ApiError::not_found_with_context(
|
||||
format!("agent {} was not found", agent_id.as_str()),
|
||||
json!({ "agent_id": agent_id.as_str() }),
|
||||
)
|
||||
})?;
|
||||
|
||||
let secret = generate_access_secret("crk");
|
||||
let api_key = PlatformApiKeyRecord {
|
||||
api_key: PlatformApiKey {
|
||||
id: PlatformApiKeyId::new(new_prefixed_id("pk")),
|
||||
workspace_id: workspace_id.clone(),
|
||||
agent_id: Some(agent_id.clone()),
|
||||
name: payload.name,
|
||||
prefix: secret.chars().take(16).collect(),
|
||||
scopes: payload.scopes,
|
||||
@@ -1354,26 +1378,33 @@ impl AdminService {
|
||||
Ok(CreatedPlatformApiKeyResponse { api_key, secret })
|
||||
}
|
||||
|
||||
#[instrument(skip(self), fields(workspace_id = %workspace_id.as_str(), key_id = %key_id.as_str()))]
|
||||
pub async fn revoke_platform_api_key(
|
||||
#[instrument(skip(self), fields(workspace_id = %workspace_id.as_str(), agent_id = %agent_id.as_str(), key_id = %key_id.as_str()))]
|
||||
pub async fn revoke_agent_platform_api_key(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
agent_id: &AgentId,
|
||||
key_id: &PlatformApiKeyId,
|
||||
) -> Result<(), ApiError> {
|
||||
self.registry
|
||||
.revoke_platform_api_key(workspace_id, key_id, &OffsetDateTime::now_utc())
|
||||
.revoke_platform_api_key_for_agent(
|
||||
workspace_id,
|
||||
agent_id,
|
||||
key_id,
|
||||
&OffsetDateTime::now_utc(),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[instrument(skip(self), fields(workspace_id = %workspace_id.as_str(), key_id = %key_id.as_str()))]
|
||||
pub async fn delete_platform_api_key(
|
||||
#[instrument(skip(self), fields(workspace_id = %workspace_id.as_str(), agent_id = %agent_id.as_str(), key_id = %key_id.as_str()))]
|
||||
pub async fn delete_agent_platform_api_key(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
agent_id: &AgentId,
|
||||
key_id: &PlatformApiKeyId,
|
||||
) -> Result<(), ApiError> {
|
||||
self.registry
|
||||
.delete_platform_api_key(workspace_id, key_id)
|
||||
.delete_platform_api_key_for_agent(workspace_id, agent_id, key_id)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -2818,11 +2849,17 @@ impl AdminService {
|
||||
.into_iter()
|
||||
.map(|item| (item.agent_id.as_str().to_owned(), item.calls_total))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
let key_count = self
|
||||
let key_counts = self
|
||||
.registry
|
||||
.list_platform_api_keys(workspace_id)
|
||||
.await?
|
||||
.len();
|
||||
.into_iter()
|
||||
.fold(BTreeMap::new(), |mut counts, record| {
|
||||
if let Some(agent_id) = record.api_key.agent_id {
|
||||
*counts.entry(agent_id.as_str().to_owned()).or_insert(0usize) += 1;
|
||||
}
|
||||
counts
|
||||
});
|
||||
|
||||
let mut items = Vec::with_capacity(summaries.len());
|
||||
for summary in summaries {
|
||||
@@ -2837,7 +2874,7 @@ impl AdminService {
|
||||
items.push(AgentSummaryView {
|
||||
operation_count: operation_ids.len(),
|
||||
operation_ids,
|
||||
key_count,
|
||||
key_count: key_counts.get(summary.id.as_str()).copied().unwrap_or(0),
|
||||
calls_today: calls_today.get(summary.id.as_str()).copied().unwrap_or(0),
|
||||
mcp_endpoint: agent_mcp_endpoint(
|
||||
workspace.workspace.slug.as_str(),
|
||||
@@ -2891,7 +2928,7 @@ impl AdminService {
|
||||
.await?;
|
||||
let key_count = self
|
||||
.registry
|
||||
.list_platform_api_keys(workspace_id)
|
||||
.list_platform_api_keys_for_agent(workspace_id, agent_id)
|
||||
.await?
|
||||
.len();
|
||||
|
||||
@@ -3981,6 +4018,7 @@ impl AdminService {
|
||||
|
||||
self.ensure_demo_platform_api_key(
|
||||
workspace_id,
|
||||
&AgentId::new(revops_agent.id.clone()),
|
||||
"Web Console Demo Key",
|
||||
vec![PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||
false,
|
||||
@@ -3988,6 +4026,7 @@ impl AdminService {
|
||||
.await?;
|
||||
self.ensure_demo_platform_api_key(
|
||||
workspace_id,
|
||||
&AgentId::new(support_agent.id.clone()),
|
||||
"Readonly Export Key",
|
||||
vec![PlatformApiKeyScope::Read],
|
||||
true,
|
||||
@@ -4045,14 +4084,6 @@ impl AdminService {
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
self.ensure_demo_platform_api_key(
|
||||
&workspace_id,
|
||||
"Growth Readonly Key",
|
||||
vec![PlatformApiKeyScope::Read],
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -4109,20 +4140,22 @@ impl AdminService {
|
||||
async fn ensure_demo_platform_api_key(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
agent_id: &AgentId,
|
||||
name: &str,
|
||||
scopes: Vec<PlatformApiKeyScope>,
|
||||
revoke: bool,
|
||||
) -> Result<(), ApiError> {
|
||||
let existing = self
|
||||
.list_platform_api_keys(workspace_id)
|
||||
.list_agent_platform_api_keys(workspace_id, agent_id)
|
||||
.await?
|
||||
.into_iter()
|
||||
.find(|record| record.api_key.name == name);
|
||||
let key = match existing {
|
||||
Some(record) => record,
|
||||
None => {
|
||||
self.create_platform_api_key(
|
||||
self.create_agent_platform_api_key(
|
||||
workspace_id,
|
||||
agent_id,
|
||||
PlatformApiKeyPayload {
|
||||
name: name.to_owned(),
|
||||
scopes,
|
||||
@@ -4134,7 +4167,7 @@ impl AdminService {
|
||||
};
|
||||
|
||||
if revoke && key.api_key.status != PlatformApiKeyStatus::Revoked {
|
||||
self.revoke_platform_api_key(workspace_id, &key.api_key.id)
|
||||
self.revoke_agent_platform_api_key(workspace_id, agent_id, &key.api_key.id)
|
||||
.await?;
|
||||
}
|
||||
|
||||
|
||||
@@ -1813,7 +1813,11 @@ async fn require_platform_api_key(
|
||||
let secret_hash = hash_access_secret(secret);
|
||||
let Some(api_key) = state
|
||||
.registry
|
||||
.get_platform_api_key_by_secret_for_workspace_slug(&path.workspace_slug, &secret_hash)
|
||||
.get_platform_api_key_by_secret_for_agent_slug(
|
||||
&path.workspace_slug,
|
||||
&path.agent_slug,
|
||||
&secret_hash,
|
||||
)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||
else {
|
||||
|
||||
+160
-25
@@ -208,6 +208,10 @@ mod tests {
|
||||
"default"
|
||||
}
|
||||
|
||||
fn test_agent_id(agent_slug: &str) -> AgentId {
|
||||
AgentId::new(format!("agent_{agent_slug}"))
|
||||
}
|
||||
|
||||
fn build_test_app(
|
||||
registry: PostgresRegistry,
|
||||
refresh_interval: Duration,
|
||||
@@ -277,6 +281,7 @@ mod tests {
|
||||
publish_agent_for_operation(®istry, &operation, "sales-rest").await;
|
||||
let api_key = create_platform_api_key(
|
||||
®istry,
|
||||
"sales-rest",
|
||||
"mcp-rest",
|
||||
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||
)
|
||||
@@ -383,6 +388,7 @@ mod tests {
|
||||
publish_agent_for_operation(®istry, &operation, "sales-request-id").await;
|
||||
let api_key = create_platform_api_key(
|
||||
®istry,
|
||||
"sales-request-id",
|
||||
"mcp-request-id",
|
||||
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||
)
|
||||
@@ -466,6 +472,7 @@ mod tests {
|
||||
publish_agent_for_operation(®istry, &operation, "sales-generated-request-id").await;
|
||||
let api_key = create_platform_api_key(
|
||||
®istry,
|
||||
"sales-generated-request-id",
|
||||
"mcp-generated-request-id",
|
||||
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||
)
|
||||
@@ -553,6 +560,7 @@ mod tests {
|
||||
publish_agent_for_operation(®istry, &operation, "sales-request-trace").await;
|
||||
let api_key = create_platform_api_key(
|
||||
®istry,
|
||||
"sales-request-trace",
|
||||
"mcp-request-trace",
|
||||
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||
)
|
||||
@@ -633,6 +641,7 @@ mod tests {
|
||||
publish_agent_for_operation(®istry, &operation, "sales-graphql").await;
|
||||
let api_key = create_platform_api_key(
|
||||
®istry,
|
||||
"sales-graphql",
|
||||
"mcp-graphql",
|
||||
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||
)
|
||||
@@ -697,6 +706,7 @@ mod tests {
|
||||
publish_agent_for_operation(®istry, &operation, "sales-grpc").await;
|
||||
let api_key = create_platform_api_key(
|
||||
®istry,
|
||||
"sales-grpc",
|
||||
"mcp-grpc",
|
||||
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||
)
|
||||
@@ -742,8 +752,13 @@ mod tests {
|
||||
async fn requires_initialized_notification_before_tool_methods() {
|
||||
let registry = test_registry().await;
|
||||
publish_agent_with_bindings(®istry, "sales-init", vec![]).await;
|
||||
let api_key =
|
||||
create_platform_api_key(®istry, "mcp-init", &[PlatformApiKeyScope::Read]).await;
|
||||
let api_key = create_platform_api_key(
|
||||
®istry,
|
||||
"sales-init",
|
||||
"mcp-init",
|
||||
&[PlatformApiKeyScope::Read],
|
||||
)
|
||||
.await;
|
||||
let base_url = spawn_mcp_server(build_test_app(
|
||||
registry,
|
||||
Duration::from_millis(0),
|
||||
@@ -802,6 +817,7 @@ mod tests {
|
||||
publish_agent_with_bindings(®istry, "sales-sse-init", vec![]).await;
|
||||
let api_key = create_platform_api_key(
|
||||
®istry,
|
||||
"sales-sse-init",
|
||||
"mcp-sse-init",
|
||||
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||
)
|
||||
@@ -852,6 +868,7 @@ mod tests {
|
||||
publish_agent_with_bindings(®istry, "sales-get-sse", vec![]).await;
|
||||
let api_key = create_platform_api_key(
|
||||
®istry,
|
||||
"sales-get-sse",
|
||||
"mcp-get-sse",
|
||||
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||
)
|
||||
@@ -903,6 +920,7 @@ mod tests {
|
||||
publish_agent_with_bindings(®istry, "sales-expired-session", vec![]).await;
|
||||
let api_key = create_platform_api_key(
|
||||
®istry,
|
||||
"sales-expired-session",
|
||||
"mcp-expired-session",
|
||||
&[PlatformApiKeyScope::Read],
|
||||
)
|
||||
@@ -954,6 +972,7 @@ mod tests {
|
||||
publish_agent_with_bindings(®istry, "sales-get-rate-limit", vec![]).await;
|
||||
let api_key = create_platform_api_key(
|
||||
®istry,
|
||||
"sales-get-rate-limit",
|
||||
"mcp-get-rate-limit",
|
||||
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||
)
|
||||
@@ -1003,6 +1022,7 @@ mod tests {
|
||||
publish_agent_with_bindings(®istry, "sales-get-sse-missing", vec![]).await;
|
||||
let api_key = create_platform_api_key(
|
||||
®istry,
|
||||
"sales-get-sse-missing",
|
||||
"mcp-get-sse-missing",
|
||||
&[PlatformApiKeyScope::Read],
|
||||
)
|
||||
@@ -1032,6 +1052,7 @@ mod tests {
|
||||
publish_agent_with_bindings(®istry, "sales-delete-session", vec![]).await;
|
||||
let api_key = create_platform_api_key(
|
||||
®istry,
|
||||
"sales-delete-session",
|
||||
"mcp-delete-session",
|
||||
&[PlatformApiKeyScope::Read],
|
||||
)
|
||||
@@ -1074,9 +1095,13 @@ mod tests {
|
||||
async fn initialize_accepts_json_only_response_negotiation() {
|
||||
let registry = test_registry().await;
|
||||
publish_agent_with_bindings(®istry, "sales-json-accept", vec![]).await;
|
||||
let api_key =
|
||||
create_platform_api_key(®istry, "mcp-json-accept", &[PlatformApiKeyScope::Read])
|
||||
.await;
|
||||
let api_key = create_platform_api_key(
|
||||
®istry,
|
||||
"sales-json-accept",
|
||||
"mcp-json-accept",
|
||||
&[PlatformApiKeyScope::Read],
|
||||
)
|
||||
.await;
|
||||
let base_url = spawn_mcp_server(build_test_app(
|
||||
registry,
|
||||
Duration::from_millis(0),
|
||||
@@ -1117,6 +1142,7 @@ mod tests {
|
||||
publish_agent_with_bindings(®istry, "sales-get-bad-version", vec![]).await;
|
||||
let api_key = create_platform_api_key(
|
||||
®istry,
|
||||
"sales-get-bad-version",
|
||||
"mcp-get-bad-version",
|
||||
&[PlatformApiKeyScope::Read],
|
||||
)
|
||||
@@ -1158,6 +1184,7 @@ mod tests {
|
||||
let mcp_url = agent_mcp_url(&base_url, "sales-refresh");
|
||||
let api_key = create_platform_api_key(
|
||||
®istry,
|
||||
"sales-refresh",
|
||||
"mcp-refresh",
|
||||
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||
)
|
||||
@@ -1252,9 +1279,17 @@ mod tests {
|
||||
vec![binding_for_operation(&operation_b)],
|
||||
)
|
||||
.await;
|
||||
let api_key = create_platform_api_key(
|
||||
let api_key_a = create_platform_api_key(
|
||||
®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],
|
||||
)
|
||||
.await;
|
||||
@@ -1268,13 +1303,13 @@ mod tests {
|
||||
let client = reqwest::Client::new();
|
||||
let agent_a_url = agent_mcp_url(&base_url, "sales-a");
|
||||
let agent_b_url = agent_mcp_url(&base_url, "sales-b");
|
||||
let session_a = initialize_session(&client, &agent_a_url, &api_key).await;
|
||||
let session_b = initialize_session(&client, &agent_b_url, &api_key).await;
|
||||
let session_a = initialize_session(&client, &agent_a_url, &api_key_a).await;
|
||||
let session_b = initialize_session(&client, &agent_b_url, &api_key_b).await;
|
||||
|
||||
let tools_a = post_jsonrpc(
|
||||
&client,
|
||||
&agent_a_url,
|
||||
&api_key,
|
||||
&api_key_a,
|
||||
Some(&session_a),
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
@@ -1287,7 +1322,7 @@ mod tests {
|
||||
let tools_b = post_jsonrpc(
|
||||
&client,
|
||||
&agent_b_url,
|
||||
&api_key,
|
||||
&api_key_b,
|
||||
Some(&session_b),
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
@@ -1302,6 +1337,76 @@ mod tests {
|
||||
assert_eq!(tools_b["result"]["tools"][0]["name"], "crm_update_lead");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_initialize_with_key_from_different_agent() {
|
||||
let registry = test_registry().await;
|
||||
let upstream_base_url = spawn_upstream_server().await;
|
||||
let operation_a = test_operation(&upstream_base_url, "crm_create_lead");
|
||||
let operation_b = test_operation(&upstream_base_url, "crm_update_lead");
|
||||
|
||||
for operation in [&operation_a, &operation_b] {
|
||||
registry
|
||||
.create_operation(&test_workspace_id(), operation, Some("alice"))
|
||||
.await
|
||||
.unwrap();
|
||||
registry
|
||||
.publish_operation(PublishRequest {
|
||||
workspace_id: &test_workspace_id(),
|
||||
operation_id: &operation.id,
|
||||
version: 1,
|
||||
published_at: &OffsetDateTime::parse("2026-03-26T10:00:00Z", &Rfc3339).unwrap(),
|
||||
published_by: Some("alice"),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
publish_agent_with_bindings(
|
||||
®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]
|
||||
async fn rejects_initialize_without_platform_api_key() {
|
||||
let registry = test_registry().await;
|
||||
@@ -1352,8 +1457,13 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
publish_agent_for_operation(®istry, &operation, "sales-read-only").await;
|
||||
let api_key =
|
||||
create_platform_api_key(®istry, "mcp-read", &[PlatformApiKeyScope::Read]).await;
|
||||
let api_key = create_platform_api_key(
|
||||
®istry,
|
||||
"sales-read-only",
|
||||
"mcp-read",
|
||||
&[PlatformApiKeyScope::Read],
|
||||
)
|
||||
.await;
|
||||
|
||||
let base_url = spawn_mcp_server(build_test_app(
|
||||
registry,
|
||||
@@ -1411,6 +1521,7 @@ mod tests {
|
||||
publish_agent_for_operation(®istry, &operation, "sales-session").await;
|
||||
let api_key = create_platform_api_key(
|
||||
®istry,
|
||||
"sales-session",
|
||||
"mcp-session",
|
||||
&[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_b, "sales-session-b").await;
|
||||
let api_key = create_platform_api_key(
|
||||
let api_key_a = create_platform_api_key(
|
||||
®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],
|
||||
)
|
||||
.await;
|
||||
@@ -1574,13 +1693,13 @@ mod tests {
|
||||
let client = reqwest::Client::new();
|
||||
let agent_a_url = agent_mcp_url(&base_url, "sales-session-a");
|
||||
let agent_b_url = agent_mcp_url(&base_url, "sales-session-b");
|
||||
let session_a = initialize_session(&client, &agent_a_url, &api_key).await;
|
||||
let session_b = initialize_session(&client, &agent_b_url, &api_key).await;
|
||||
let session_a = initialize_session(&client, &agent_a_url, &api_key_a).await;
|
||||
let session_b = initialize_session(&client, &agent_b_url, &api_key_b).await;
|
||||
|
||||
let start_response = post_jsonrpc(
|
||||
&client,
|
||||
&agent_a_url,
|
||||
&api_key,
|
||||
&api_key_a,
|
||||
Some(&session_a),
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
@@ -1601,7 +1720,7 @@ mod tests {
|
||||
let poll_response = post_jsonrpc(
|
||||
&client,
|
||||
&agent_b_url,
|
||||
&api_key,
|
||||
&api_key_b,
|
||||
Some(&session_b),
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
@@ -1650,6 +1769,7 @@ mod tests {
|
||||
publish_agent_for_operation(®istry, &operation, "sales-session-rate").await;
|
||||
let api_key = create_platform_api_key(
|
||||
®istry,
|
||||
"sales-session-rate",
|
||||
"mcp-session-rate",
|
||||
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||
)
|
||||
@@ -1755,6 +1875,7 @@ mod tests {
|
||||
publish_agent_for_operation(®istry, &operation, "sales-async-rate").await;
|
||||
let api_key = create_platform_api_key(
|
||||
®istry,
|
||||
"sales-async-rate",
|
||||
"mcp-async-rate",
|
||||
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||
)
|
||||
@@ -1866,6 +1987,7 @@ mod tests {
|
||||
publish_agent_for_operation(®istry, &operation, "sales-async-completed").await;
|
||||
let api_key = create_platform_api_key(
|
||||
®istry,
|
||||
"sales-async-completed",
|
||||
"mcp-async-completed",
|
||||
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||
)
|
||||
@@ -1951,6 +2073,7 @@ mod tests {
|
||||
publish_agent_for_operation(®istry, &operation, "sales-rate-limited").await;
|
||||
let api_key = create_platform_api_key(
|
||||
®istry,
|
||||
"sales-rate-limited",
|
||||
"mcp-rate-limit",
|
||||
&[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_b, "sales-async-b").await;
|
||||
let api_key = create_platform_api_key(
|
||||
let api_key_a = create_platform_api_key(
|
||||
®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],
|
||||
)
|
||||
.await;
|
||||
@@ -2066,13 +2197,13 @@ mod tests {
|
||||
let client = reqwest::Client::new();
|
||||
let agent_a_url = agent_mcp_url(&base_url, "sales-async-a");
|
||||
let agent_b_url = agent_mcp_url(&base_url, "sales-async-b");
|
||||
let session_a = initialize_session(&client, &agent_a_url, &api_key).await;
|
||||
let session_b = initialize_session(&client, &agent_b_url, &api_key).await;
|
||||
let session_a = initialize_session(&client, &agent_a_url, &api_key_a).await;
|
||||
let session_b = initialize_session(&client, &agent_b_url, &api_key_b).await;
|
||||
|
||||
let start_response = post_jsonrpc(
|
||||
&client,
|
||||
&agent_a_url,
|
||||
&api_key,
|
||||
&api_key_a,
|
||||
Some(&session_a),
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
@@ -2093,7 +2224,7 @@ mod tests {
|
||||
let status_response = post_jsonrpc(
|
||||
&client,
|
||||
&agent_b_url,
|
||||
&api_key,
|
||||
&api_key_b,
|
||||
Some(&session_b),
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
@@ -2141,6 +2272,7 @@ mod tests {
|
||||
publish_agent_for_operation(®istry, &operation, "sales-async").await;
|
||||
let api_key = create_platform_api_key(
|
||||
®istry,
|
||||
"sales-async",
|
||||
"mcp-async",
|
||||
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||
)
|
||||
@@ -2290,6 +2422,7 @@ mod tests {
|
||||
publish_agent_for_operation(®istry, &operation, "sales-window").await;
|
||||
let api_key = create_platform_api_key(
|
||||
®istry,
|
||||
"sales-window",
|
||||
"mcp-window",
|
||||
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||
)
|
||||
@@ -2418,6 +2551,7 @@ mod tests {
|
||||
|
||||
async fn create_platform_api_key(
|
||||
registry: &PostgresRegistry,
|
||||
agent_slug: &str,
|
||||
name: &str,
|
||||
scopes: &[PlatformApiKeyScope],
|
||||
) -> String {
|
||||
@@ -2425,6 +2559,7 @@ mod tests {
|
||||
let api_key = PlatformApiKey {
|
||||
id: PlatformApiKeyId::new(format!("pk_{name}")),
|
||||
workspace_id: test_workspace_id(),
|
||||
agent_id: Some(test_agent_id(agent_slug)),
|
||||
name: name.to_owned(),
|
||||
prefix: secret.chars().take(16).collect(),
|
||||
scopes: scopes.to_vec(),
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<base href="../">
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Crank — API Keys</title>
|
||||
<title>Crank — Agent Keys</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="css/variables.css">
|
||||
<link rel="stylesheet" href="css/layout.css">
|
||||
@@ -107,8 +107,8 @@
|
||||
|
||||
<div class="page-header">
|
||||
<div class="page-header-text">
|
||||
<h1 class="page-title" data-i18n="apikeys.title">API Keys</h1>
|
||||
<p class="page-subtitle" data-i18n="apikeys.subtitle">Keys authenticate external MCP clients and workspace automation against Crank.</p>
|
||||
<h1 class="page-title" data-i18n="apikeys.title">Agent Keys</h1>
|
||||
<p class="page-subtitle" data-i18n="apikeys.subtitle">Keys authenticate external MCP clients against a specific AI agent endpoint.</p>
|
||||
</div>
|
||||
<div class="page-header-actions">
|
||||
<button class="btn-primary" id="btn-create-key" type="button">
|
||||
@@ -129,11 +129,27 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-card">
|
||||
<div class="section-card-header">
|
||||
<div>
|
||||
<div class="section-card-title" data-i18n="apikeys.agent.title">Agent access</div>
|
||||
<div class="section-card-subtitle" id="agent-access-subtitle" data-i18n="apikeys.agent.subtitle">Select the AI agent whose MCP endpoint should accept this key.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-card-body" style="display:grid;gap:12px;">
|
||||
<div class="field-group" style="margin-bottom:0;">
|
||||
<label class="field-label" for="agent-select" data-i18n="apikeys.agent.label">AI agent</label>
|
||||
<select class="field-input" id="agent-select"></select>
|
||||
<div class="field-hint" id="agent-endpoint-hint" data-testid="agent-key-endpoint-hint"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-card">
|
||||
<div class="section-card-header">
|
||||
<div>
|
||||
<div class="section-card-title" data-i18n="apikeys.active.title">Active keys</div>
|
||||
<div class="section-card-subtitle">3 active · 1 revoked</div>
|
||||
<div class="section-card-subtitle" id="keys-summary-subtitle">3 active · 1 revoked</div>
|
||||
</div>
|
||||
<div class="filter-bar-search" style="max-width: 220px; margin: 0;">
|
||||
<svg width="13" height="13" style="position:absolute; left:9px; top:50%; transform:translateY(-50%); color:var(--text-muted);" viewBox="0 0 16 16" fill="currentColor"><path d="M10.68 11.74a6 6 0 01-7.922-8.982 6 6 0 018.982 7.922l3.04 3.04a.749.749 0 01-.326 1.275.749.749 0 01-.734-.215zM11.5 7a4.499 4.499 0 11-8.997 0A4.499 4.499 0 0111.5 7z"/></svg>
|
||||
@@ -192,7 +208,7 @@
|
||||
<div class="modal-overlay" id="modal-create">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<span class="modal-title" data-i18n="apikeys.modal.title">Create API key</span>
|
||||
<span class="modal-title" data-i18n="apikeys.modal.title">Create agent key</span>
|
||||
<button class="modal-close" id="modal-close-btn" type="button">
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round">
|
||||
<line x1="1" y1="1" x2="11" y2="11"/><line x1="11" y1="1" x2="1" y2="11"/>
|
||||
|
||||
+101
-8
@@ -1,5 +1,7 @@
|
||||
var KEYS = [];
|
||||
var AGENTS = [];
|
||||
var currentWorkspaceId = null;
|
||||
var currentAgentId = null;
|
||||
var selectedScopes = new Set(['read']);
|
||||
var search = '';
|
||||
|
||||
@@ -32,6 +34,7 @@ function mapKeyRecord(record) {
|
||||
var apiKey = record.api_key || {};
|
||||
return {
|
||||
id: apiKey.id,
|
||||
agentId: apiKey.agent_id || null,
|
||||
name: apiKey.name,
|
||||
prefix: apiKey.prefix || '',
|
||||
scopes: apiKey.scopes || [],
|
||||
@@ -41,6 +44,15 @@ function mapKeyRecord(record) {
|
||||
};
|
||||
}
|
||||
|
||||
function mapAgentRecord(record) {
|
||||
return {
|
||||
id: record.id,
|
||||
slug: record.slug,
|
||||
displayName: record.display_name || record.slug || record.id,
|
||||
mcpEndpoint: record.mcp_endpoint || '',
|
||||
};
|
||||
}
|
||||
|
||||
function currentWorkspace() {
|
||||
return window.getCurrentWorkspace ? window.getCurrentWorkspace() : null;
|
||||
}
|
||||
@@ -52,19 +64,46 @@ async function loadKeys() {
|
||||
setTableLoading(true);
|
||||
|
||||
if (!currentWorkspaceId || !window.CrankApi) {
|
||||
AGENTS = [];
|
||||
KEYS = [];
|
||||
currentAgentId = null;
|
||||
renderAgentPicker();
|
||||
setCreateButtonState();
|
||||
setTableLoading(false);
|
||||
renderTable(tKey('apikeys.error.api'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
var response = await window.CrankApi.listPlatformApiKeys(currentWorkspaceId);
|
||||
KEYS = ((response && response.items) || []).map(mapKeyRecord);
|
||||
var response = await window.CrankApi.listAgents(currentWorkspaceId);
|
||||
AGENTS = ((response && response.items) || []).map(mapAgentRecord);
|
||||
if (!AGENTS.length) {
|
||||
currentAgentId = null;
|
||||
KEYS = [];
|
||||
renderAgentPicker();
|
||||
setCreateButtonState();
|
||||
setTableLoading(false);
|
||||
renderTable();
|
||||
return;
|
||||
}
|
||||
if (!currentAgentId || !AGENTS.some(function(agent) { return agent.id === currentAgentId; })) {
|
||||
currentAgentId = AGENTS[0].id;
|
||||
}
|
||||
renderAgentPicker();
|
||||
var keysResponse = await window.CrankApi.listAgentPlatformApiKeys(
|
||||
currentWorkspaceId,
|
||||
currentAgentId
|
||||
);
|
||||
KEYS = ((keysResponse && keysResponse.items) || []).map(mapKeyRecord);
|
||||
setCreateButtonState();
|
||||
setTableLoading(false);
|
||||
renderTable();
|
||||
} catch (error) {
|
||||
AGENTS = [];
|
||||
KEYS = [];
|
||||
currentAgentId = null;
|
||||
renderAgentPicker();
|
||||
setCreateButtonState();
|
||||
setTableLoading(false);
|
||||
renderTable(error.message || tKey('apikeys.error.load'));
|
||||
}
|
||||
@@ -85,10 +124,11 @@ function setTableLoading(on) {
|
||||
|
||||
async function revokeKey(id) {
|
||||
if (!confirm(tKey('apikeys.confirm.revoke'))) return;
|
||||
if (!currentAgentId) return;
|
||||
|
||||
try {
|
||||
var key = KEYS.find(function(item) { return item.id === id; });
|
||||
await window.CrankApi.revokePlatformApiKey(currentWorkspaceId, id);
|
||||
await window.CrankApi.revokeAgentPlatformApiKey(currentWorkspaceId, currentAgentId, id);
|
||||
await loadKeys();
|
||||
if (window.CrankUi) {
|
||||
window.CrankUi.success(
|
||||
@@ -108,10 +148,11 @@ async function revokeKey(id) {
|
||||
|
||||
async function deleteKey(id) {
|
||||
if (!confirm(tKey('apikeys.confirm.delete'))) return;
|
||||
if (!currentAgentId) return;
|
||||
|
||||
try {
|
||||
var key = KEYS.find(function(item) { return item.id === id; });
|
||||
await window.CrankApi.deletePlatformApiKey(currentWorkspaceId, id);
|
||||
await window.CrankApi.deleteAgentPlatformApiKey(currentWorkspaceId, currentAgentId, id);
|
||||
await loadKeys();
|
||||
if (window.CrankUi) {
|
||||
window.CrankUi.success(
|
||||
@@ -130,7 +171,7 @@ async function deleteKey(id) {
|
||||
}
|
||||
|
||||
async function createKey(name, scopes) {
|
||||
var created = await window.CrankApi.createPlatformApiKey(currentWorkspaceId, {
|
||||
var created = await window.CrankApi.createAgentPlatformApiKey(currentWorkspaceId, currentAgentId, {
|
||||
name: name,
|
||||
scopes: scopes,
|
||||
});
|
||||
@@ -140,6 +181,48 @@ async function createKey(name, scopes) {
|
||||
};
|
||||
}
|
||||
|
||||
function currentAgent() {
|
||||
return AGENTS.find(function(agent) { return agent.id === currentAgentId; }) || null;
|
||||
}
|
||||
|
||||
function renderAgentPicker() {
|
||||
var select = document.getElementById('agent-select');
|
||||
var hint = document.getElementById('agent-endpoint-hint');
|
||||
if (!select || !hint) return;
|
||||
|
||||
select.innerHTML = '';
|
||||
|
||||
if (!AGENTS.length) {
|
||||
var emptyOption = document.createElement('option');
|
||||
emptyOption.value = '';
|
||||
emptyOption.textContent = tKey('apikeys.agent.empty');
|
||||
select.appendChild(emptyOption);
|
||||
select.disabled = true;
|
||||
hint.textContent = tKey('apikeys.agent.empty_hint');
|
||||
return;
|
||||
}
|
||||
|
||||
AGENTS.forEach(function(agent) {
|
||||
var option = document.createElement('option');
|
||||
option.value = agent.id;
|
||||
option.textContent = agent.displayName;
|
||||
if (agent.id === currentAgentId) option.selected = true;
|
||||
select.appendChild(option);
|
||||
});
|
||||
select.disabled = false;
|
||||
|
||||
var agent = currentAgent();
|
||||
hint.textContent = agent && agent.mcpEndpoint
|
||||
? tfKey('apikeys.agent.endpoint', { endpoint: agent.mcpEndpoint })
|
||||
: tKey('apikeys.agent.endpoint_missing');
|
||||
}
|
||||
|
||||
function setCreateButtonState() {
|
||||
var button = document.getElementById('btn-create-key');
|
||||
if (!button) return;
|
||||
button.disabled = !currentAgentId;
|
||||
}
|
||||
|
||||
function renderScopes() {
|
||||
var el = document.getElementById('scope-checkboxes');
|
||||
var tmpl = document.getElementById('tmpl-scope-checkbox');
|
||||
@@ -165,14 +248,16 @@ function renderTable(errorMessage) {
|
||||
var rows = KEYS.filter(function(key) {
|
||||
return !q || key.name.toLowerCase().includes(q) || key.prefix.toLowerCase().includes(q);
|
||||
});
|
||||
var subtitle = document.querySelector('.section-card-subtitle');
|
||||
var subtitle = document.getElementById('keys-summary-subtitle');
|
||||
|
||||
tbody.innerHTML = '';
|
||||
|
||||
if (subtitle) {
|
||||
var active = KEYS.filter(function(key) { return key.status === 'active'; }).length;
|
||||
var revoked = KEYS.filter(function(key) { return key.status === 'revoked'; }).length;
|
||||
subtitle.textContent = tfKey('apikeys.active.subtitle', { active: active, revoked: revoked });
|
||||
subtitle.textContent = currentAgentId
|
||||
? tfKey('apikeys.active.subtitle', { active: active, revoked: revoked })
|
||||
: tKey('apikeys.agent.empty_hint');
|
||||
}
|
||||
|
||||
if (errorMessage) {
|
||||
@@ -191,7 +276,9 @@ function renderTable(errorMessage) {
|
||||
var td = document.createElement('td');
|
||||
td.colSpan = 7;
|
||||
td.style.cssText = 'text-align:center;padding:36px;color:var(--text-muted);';
|
||||
td.textContent = KEYS.length ? tKey('apikeys.empty.search') : tKey('apikeys.empty.none');
|
||||
td.textContent = currentAgentId
|
||||
? (KEYS.length ? tKey('apikeys.empty.search') : tKey('apikeys.empty.none'))
|
||||
: tKey('apikeys.agent.empty_hint');
|
||||
empty.appendChild(td);
|
||||
tbody.appendChild(empty);
|
||||
return;
|
||||
@@ -242,6 +329,7 @@ function renderTable(errorMessage) {
|
||||
var modal = document.getElementById('modal-create');
|
||||
|
||||
function openModal() {
|
||||
if (!currentAgentId) return;
|
||||
document.getElementById('modal-form-body').hidden = false;
|
||||
document.getElementById('modal-reveal-body').hidden = true;
|
||||
document.getElementById('modal-footer-create').hidden = false;
|
||||
@@ -340,6 +428,11 @@ document.getElementById('key-search').addEventListener('input', function() {
|
||||
renderTable();
|
||||
});
|
||||
|
||||
document.getElementById('agent-select').addEventListener('change', async function() {
|
||||
currentAgentId = this.value || null;
|
||||
await loadKeys();
|
||||
});
|
||||
|
||||
function copyPrefix(prefix) {
|
||||
if (navigator.clipboard) {
|
||||
navigator.clipboard.writeText(prefix).catch(function() {});
|
||||
|
||||
+8
-8
@@ -331,17 +331,17 @@
|
||||
archiveAgent: function(workspaceId, agentId) {
|
||||
return post('/workspaces/' + encodeURIComponent(workspaceId) + '/agents/' + encodeURIComponent(agentId) + '/archive', {});
|
||||
},
|
||||
listPlatformApiKeys: function(workspaceId) {
|
||||
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/platform-api-keys');
|
||||
listAgentPlatformApiKeys: function(workspaceId, agentId) {
|
||||
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/agents/' + encodeURIComponent(agentId) + '/platform-api-keys');
|
||||
},
|
||||
createPlatformApiKey: function(workspaceId, payload) {
|
||||
return post('/workspaces/' + encodeURIComponent(workspaceId) + '/platform-api-keys', payload);
|
||||
createAgentPlatformApiKey: function(workspaceId, agentId, payload) {
|
||||
return post('/workspaces/' + encodeURIComponent(workspaceId) + '/agents/' + encodeURIComponent(agentId) + '/platform-api-keys', payload);
|
||||
},
|
||||
revokePlatformApiKey: function(workspaceId, keyId) {
|
||||
return post('/workspaces/' + encodeURIComponent(workspaceId) + '/platform-api-keys/' + encodeURIComponent(keyId) + '/revoke', {});
|
||||
revokeAgentPlatformApiKey: function(workspaceId, agentId, keyId) {
|
||||
return post('/workspaces/' + encodeURIComponent(workspaceId) + '/agents/' + encodeURIComponent(agentId) + '/platform-api-keys/' + encodeURIComponent(keyId) + '/revoke', {});
|
||||
},
|
||||
deletePlatformApiKey: function(workspaceId, keyId) {
|
||||
return del('/workspaces/' + encodeURIComponent(workspaceId) + '/platform-api-keys/' + encodeURIComponent(keyId));
|
||||
deleteAgentPlatformApiKey: function(workspaceId, agentId, keyId) {
|
||||
return del('/workspaces/' + encodeURIComponent(workspaceId) + '/agents/' + encodeURIComponent(agentId) + '/platform-api-keys/' + encodeURIComponent(keyId));
|
||||
},
|
||||
listSecrets: function(workspaceId) {
|
||||
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/secrets');
|
||||
|
||||
+30
-16
@@ -96,9 +96,16 @@ var TRANSLATIONS = {
|
||||
'page.ops': 'operations',
|
||||
|
||||
// API Keys page
|
||||
'apikeys.title': 'API Keys',
|
||||
'apikeys.subtitle': 'Keys authenticate external MCP clients and workspace automation against Crank.',
|
||||
'apikeys.title': 'Agent Keys',
|
||||
'apikeys.subtitle': 'Keys authenticate external MCP clients against a specific AI agent endpoint.',
|
||||
'apikeys.new': 'Create key',
|
||||
'apikeys.agent.title': 'Agent access',
|
||||
'apikeys.agent.subtitle': 'Select the AI agent whose MCP endpoint should accept this key.',
|
||||
'apikeys.agent.label': 'AI agent',
|
||||
'apikeys.agent.empty': 'No agents available',
|
||||
'apikeys.agent.empty_hint': 'Create an AI agent first, then issue machine access keys for its MCP endpoint.',
|
||||
'apikeys.agent.endpoint': 'MCP endpoint: {endpoint}',
|
||||
'apikeys.agent.endpoint_missing': 'The selected agent does not have a published MCP endpoint yet.',
|
||||
'apikeys.th.name': 'NAME',
|
||||
'apikeys.th.scope': 'SCOPE',
|
||||
'apikeys.th.created':'CREATED',
|
||||
@@ -112,10 +119,10 @@ var TRANSLATIONS = {
|
||||
'apikeys.th.scopes': 'SCOPES',
|
||||
'apikeys.th.status': 'STATUS',
|
||||
'apikeys.scope_ref': 'Scope reference',
|
||||
'apikeys.scope.read': 'Initialize MCP sessions, ping the server, and list tools for a workspace agent.',
|
||||
'apikeys.scope.read': 'Initialize MCP sessions, ping the server, and list tools for the selected AI agent.',
|
||||
'apikeys.scope.write': 'Execute `tools/call` requests against published agent toolsets.',
|
||||
'apikeys.scope.deploy': 'Reserved for deploy-scoped automation. Today it also permits MCP read/write flows.',
|
||||
'apikeys.modal.title': 'Create API key',
|
||||
'apikeys.modal.title': 'Create agent key',
|
||||
'apikeys.modal.name': 'Key name',
|
||||
'apikeys.modal.name_hint': 'A descriptive label to identify the key. Only visible to admins.',
|
||||
'apikeys.modal.name_placeholder': 'e.g. Production, CI pipeline',
|
||||
@@ -146,11 +153,11 @@ var TRANSLATIONS = {
|
||||
'apikeys.toast.delete_error_message': 'Failed to delete key',
|
||||
'apikeys.toast.scope_missing_title': 'Missing scope',
|
||||
'apikeys.toast.scope_missing_message': 'Select at least one scope before creating a key.',
|
||||
'apikeys.toast.create_title': 'API key created',
|
||||
'apikeys.toast.create_title': 'Agent key created',
|
||||
'apikeys.toast.create_message': 'Copy the key now. It will not be shown again.',
|
||||
'apikeys.toast.create_error_title': 'Key creation failed',
|
||||
'apikeys.toast.create_error_message': 'Failed to create key',
|
||||
'apikeys.toast.copy_title': 'API key copied',
|
||||
'apikeys.toast.copy_title': 'Agent key copied',
|
||||
'apikeys.toast.copy_message': 'Store the raw key securely. It cannot be revealed again.',
|
||||
'apikeys.toast.prefix_title': 'Key prefix copied',
|
||||
'apikeys.action.copy_prefix': 'Copy key prefix',
|
||||
@@ -471,7 +478,7 @@ var TRANSLATIONS = {
|
||||
'workspace_setup.create.footer': "You'll be the Owner of this workspace. You can invite additional members after creation.",
|
||||
'workspace_setup.danger.title': 'Danger zone',
|
||||
'workspace_setup.danger.export_title': 'Export all data',
|
||||
'workspace_setup.danger.export_body': 'Download a JSON snapshot of workspace settings, memberships, invitations, operations, agents and platform API keys.',
|
||||
'workspace_setup.danger.export_body': 'Download a JSON snapshot of workspace settings, memberships, invitations, operations, agents and agent access keys.',
|
||||
'workspace_setup.danger.delete_title': 'Delete workspace',
|
||||
'workspace_setup.danger.delete_body': 'Permanently deletes all operations, keys, logs and settings. This action cannot be undone. You will be logged out immediately.',
|
||||
'workspace_setup.export': 'Export',
|
||||
@@ -927,7 +934,7 @@ var TRANSLATIONS = {
|
||||
'agents.empty.search.title': 'No agents match "{query}"',
|
||||
'agents.empty.search.sub': 'Try a different search term.',
|
||||
'agents.card.tools': 'tools',
|
||||
'agents.card.keys': 'API keys',
|
||||
'agents.card.keys': 'keys',
|
||||
'agents.card.calls_today': 'calls today',
|
||||
'agents.card.created': 'Created {date}',
|
||||
'agents.card.copy_endpoint': 'Copy endpoint',
|
||||
@@ -1112,9 +1119,16 @@ var TRANSLATIONS = {
|
||||
'page.ops': 'операций',
|
||||
|
||||
// API Keys page
|
||||
'apikeys.title': 'API Ключи',
|
||||
'apikeys.subtitle': 'Ключи аутентифицируют внешние MCP-клиенты и автоматизацию воркспейса в Crank.',
|
||||
'apikeys.title': 'Ключи агентов',
|
||||
'apikeys.subtitle': 'Ключи аутентифицируют внешние MCP-клиенты для конкретного endpoint AI-агента.',
|
||||
'apikeys.new': 'Создать ключ',
|
||||
'apikeys.agent.title': 'Доступ агента',
|
||||
'apikeys.agent.subtitle': 'Выберите AI-агента, чей MCP endpoint должен принимать этот ключ.',
|
||||
'apikeys.agent.label': 'AI-агент',
|
||||
'apikeys.agent.empty': 'Агенты отсутствуют',
|
||||
'apikeys.agent.empty_hint': 'Сначала создайте AI-агента, затем выпустите машинный ключ для его MCP endpoint.',
|
||||
'apikeys.agent.endpoint': 'MCP endpoint: {endpoint}',
|
||||
'apikeys.agent.endpoint_missing': 'У выбранного агента пока нет опубликованного MCP endpoint.',
|
||||
'apikeys.th.name': 'НАЗВАНИЕ',
|
||||
'apikeys.th.scope': 'ОБЛАСТЬ',
|
||||
'apikeys.th.created':'СОЗДАН',
|
||||
@@ -1128,10 +1142,10 @@ var TRANSLATIONS = {
|
||||
'apikeys.th.scopes': 'ОБЛАСТИ',
|
||||
'apikeys.th.status': 'СТАТУС',
|
||||
'apikeys.scope_ref': 'Справка по областям',
|
||||
'apikeys.scope.read': 'Инициализация MCP-сессий, ping сервера и получение списка инструментов для агента воркспейса.',
|
||||
'apikeys.scope.read': 'Инициализация MCP-сессий, ping сервера и получение списка инструментов для выбранного AI-агента.',
|
||||
'apikeys.scope.write': 'Выполнение `tools/call` для опубликованных наборов инструментов агента.',
|
||||
'apikeys.scope.deploy': 'Зарезервировано для deploy-автоматизации. Сейчас также разрешает MCP read/write сценарии.',
|
||||
'apikeys.modal.title': 'Создать API-ключ',
|
||||
'apikeys.modal.title': 'Создать ключ агента',
|
||||
'apikeys.modal.name': 'Имя ключа',
|
||||
'apikeys.modal.name_hint': 'Понятная метка для идентификации ключа. Видна только администраторам.',
|
||||
'apikeys.modal.name_placeholder': 'например, Production, CI pipeline',
|
||||
@@ -1162,11 +1176,11 @@ var TRANSLATIONS = {
|
||||
'apikeys.toast.delete_error_message': 'Не удалось удалить ключ',
|
||||
'apikeys.toast.scope_missing_title': 'Не выбрана область',
|
||||
'apikeys.toast.scope_missing_message': 'Выберите хотя бы одну область перед созданием ключа.',
|
||||
'apikeys.toast.create_title': 'API-ключ создан',
|
||||
'apikeys.toast.create_title': 'Ключ агента создан',
|
||||
'apikeys.toast.create_message': 'Скопируйте ключ сейчас. Повторно он не будет показан.',
|
||||
'apikeys.toast.create_error_title': 'Не удалось создать ключ',
|
||||
'apikeys.toast.create_error_message': 'Не удалось создать ключ',
|
||||
'apikeys.toast.copy_title': 'API-ключ скопирован',
|
||||
'apikeys.toast.copy_title': 'Ключ агента скопирован',
|
||||
'apikeys.toast.copy_message': 'Сохраните исходный ключ в надежном месте. Повторно показать его нельзя.',
|
||||
'apikeys.toast.prefix_title': 'Префикс ключа скопирован',
|
||||
'apikeys.action.copy_prefix': 'Скопировать префикс ключа',
|
||||
@@ -1493,7 +1507,7 @@ var TRANSLATIONS = {
|
||||
'workspace_setup.create.footer': 'Вы станете владельцем этого воркспейса. После создания можно пригласить дополнительных участников.',
|
||||
'workspace_setup.danger.title': 'Опасная зона',
|
||||
'workspace_setup.danger.export_title': 'Экспортировать все данные',
|
||||
'workspace_setup.danger.export_body': 'Скачать JSON-снимок настроек воркспейса, участников, приглашений, операций, агентов и platform API keys.',
|
||||
'workspace_setup.danger.export_body': 'Скачать JSON-снимок настроек воркспейса, участников, приглашений, операций, агентов и ключей доступа агентов.',
|
||||
'workspace_setup.danger.delete_title': 'Удалить воркспейс',
|
||||
'workspace_setup.danger.delete_body': 'Безвозвратно удаляет все операции, ключи, логи и настройки. Действие нельзя отменить. Вы будете немедленно разлогинены.',
|
||||
'workspace_setup.export': 'Экспорт',
|
||||
@@ -1957,7 +1971,7 @@ var TRANSLATIONS = {
|
||||
'agents.empty.search.title': 'Нет агентов по запросу "{query}"',
|
||||
'agents.empty.search.sub': 'Попробуйте другой поисковый запрос.',
|
||||
'agents.card.tools': 'инструментов',
|
||||
'agents.card.keys': 'API-ключей',
|
||||
'agents.card.keys': 'ключей',
|
||||
'agents.card.calls_today': 'вызовов сегодня',
|
||||
'agents.card.created': 'Создан {date}',
|
||||
'agents.card.copy_endpoint': 'Скопировать endpoint',
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { login, localized } = require('./helpers');
|
||||
const { createAgent, getCurrentWorkspace, login, localized, uniqueName } = require('./helpers');
|
||||
|
||||
test('api keys page opens create key flow', async ({ page }) => {
|
||||
await login(page);
|
||||
const workspace = await getCurrentWorkspace(page);
|
||||
await createAgent(page, workspace.id, {
|
||||
slug: uniqueName('playwright_keys_agent'),
|
||||
display_name: 'Playwright Keys Agent',
|
||||
description: 'Agent for API key page smoke test.',
|
||||
instructions: {},
|
||||
tool_selection_policy: {},
|
||||
});
|
||||
await page.goto('/api-keys');
|
||||
await expect(page.locator('.page-title')).toHaveText(localized('API Keys', 'API Ключи'));
|
||||
await expect(page.locator('.page-title')).toHaveText(localized('Agent Keys', 'Ключи агентов'));
|
||||
await expect(page.locator('#agent-select')).not.toBeDisabled();
|
||||
await expect(page.locator('#btn-create-key')).toBeEnabled();
|
||||
await page.locator('#btn-create-key').click();
|
||||
await expect(page.locator('.modal-title')).toHaveText(localized('Create API key', 'Создать API-ключ'));
|
||||
await expect(page.locator('#modal-create')).toHaveClass(/open/);
|
||||
await expect(page.locator('.modal-title')).toHaveText(localized('Create agent key', 'Создать ключ агента'));
|
||||
await page.locator('#new-key-name').fill(`playwright-${Date.now()}`);
|
||||
await page.locator('#modal-confirm-btn').click();
|
||||
await expect(page.locator('#modal-reveal-body')).toContainText(localized('Copy this key now', 'Скопируйте этот ключ сейчас'));
|
||||
|
||||
@@ -87,11 +87,11 @@ async function publishOperation(page, workspaceId, operationId, version = 1) {
|
||||
);
|
||||
}
|
||||
|
||||
async function createPlatformApiKey(page, workspaceId, name, scopes) {
|
||||
async function createPlatformApiKey(page, workspaceId, agentId, name, scopes) {
|
||||
return browserJson(
|
||||
page,
|
||||
'POST',
|
||||
`/api/admin/workspaces/${encodeURIComponent(workspaceId)}/platform-api-keys`,
|
||||
`/api/admin/workspaces/${encodeURIComponent(workspaceId)}/agents/${encodeURIComponent(agentId)}/platform-api-keys`,
|
||||
{ name, scopes },
|
||||
);
|
||||
}
|
||||
@@ -520,7 +520,13 @@ async function setupPublishedAgent(page, { operationPayload, agentSlug, toolName
|
||||
]);
|
||||
await publishAgent(page, workspace.id, createdAgent.agent_id, createdAgent.version);
|
||||
|
||||
const key = await createPlatformApiKey(page, workspace.id, uniqueName('playwright_key'), ['read', 'write']);
|
||||
const key = await createPlatformApiKey(
|
||||
page,
|
||||
workspace.id,
|
||||
createdAgent.agent_id,
|
||||
uniqueName('playwright_key'),
|
||||
['read', 'write']
|
||||
);
|
||||
|
||||
return {
|
||||
workspace,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use time::OffsetDateTime;
|
||||
|
||||
use crate::ids::{InvitationId, PlatformApiKeyId, UserId, WorkspaceId};
|
||||
use crate::ids::{AgentId, InvitationId, PlatformApiKeyId, UserId, WorkspaceId};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
@@ -80,6 +80,8 @@ pub struct InvitationToken {
|
||||
pub struct PlatformApiKey {
|
||||
pub id: PlatformApiKeyId,
|
||||
pub workspace_id: WorkspaceId,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub agent_id: Option<AgentId>,
|
||||
pub name: String,
|
||||
pub prefix: String,
|
||||
pub scopes: Vec<PlatformApiKeyScope>,
|
||||
@@ -96,7 +98,7 @@ mod tests {
|
||||
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
||||
|
||||
use super::{PlatformApiKey, PlatformApiKeyScope, PlatformApiKeyStatus, User, UserStatus};
|
||||
use crate::ids::{PlatformApiKeyId, UserId, WorkspaceId};
|
||||
use crate::ids::{AgentId, PlatformApiKeyId, UserId, WorkspaceId};
|
||||
|
||||
#[test]
|
||||
fn user_serializes_created_at_as_rfc3339() {
|
||||
@@ -135,6 +137,7 @@ mod tests {
|
||||
let api_key = PlatformApiKey {
|
||||
id: PlatformApiKeyId::new("pk_01"),
|
||||
workspace_id: WorkspaceId::new("ws_01"),
|
||||
agent_id: Some(AgentId::new("agent_01")),
|
||||
name: "Primary".to_owned(),
|
||||
prefix: "crk_live".to_owned(),
|
||||
scopes: vec![PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||
|
||||
@@ -144,6 +144,7 @@ pub async fn apply_postgres(pool: &PgPool) -> Result<(), sqlx::Error> {
|
||||
"create table if not exists platform_api_keys (
|
||||
id text primary key,
|
||||
workspace_id text not null references workspaces(id) on delete cascade,
|
||||
agent_id text null,
|
||||
name text not null,
|
||||
prefix text not null,
|
||||
secret_hash text not null,
|
||||
@@ -162,6 +163,9 @@ pub async fn apply_postgres(pool: &PgPool) -> Result<(), sqlx::Error> {
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
query("alter table platform_api_keys add column if not exists agent_id text null")
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"insert into workspaces (
|
||||
|
||||
@@ -1,25 +1,29 @@
|
||||
use super::*;
|
||||
|
||||
impl PostgresRegistry {
|
||||
pub async fn list_platform_api_keys(
|
||||
pub async fn list_platform_api_keys_for_agent(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
agent_id: &AgentId,
|
||||
) -> Result<Vec<PlatformApiKeyRecord>, RegistryError> {
|
||||
let rows = sqlx::query!(
|
||||
let rows = sqlx::query(
|
||||
"select
|
||||
id,
|
||||
workspace_id,
|
||||
agent_id,
|
||||
name,
|
||||
prefix,
|
||||
scopes_json,
|
||||
status,
|
||||
created_at as \"created_at!: time::OffsetDateTime\",
|
||||
last_used_at as \"last_used_at: time::OffsetDateTime\"
|
||||
created_at,
|
||||
last_used_at
|
||||
from platform_api_keys
|
||||
where workspace_id = $1
|
||||
and agent_id = $2
|
||||
order by created_at desc",
|
||||
workspace_id.as_str(),
|
||||
)
|
||||
.bind(workspace_id.as_str())
|
||||
.bind(agent_id.as_str())
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
@@ -27,58 +31,107 @@ impl PostgresRegistry {
|
||||
.map(|row| {
|
||||
Ok(PlatformApiKeyRecord {
|
||||
api_key: PlatformApiKey {
|
||||
id: PlatformApiKeyId::new(row.id),
|
||||
workspace_id: WorkspaceId::new(row.workspace_id),
|
||||
name: row.name,
|
||||
prefix: row.prefix,
|
||||
scopes: deserialize_json_value(row.scopes_json)?,
|
||||
status: deserialize_enum_text(&row.status, "status")?,
|
||||
created_at: row.created_at,
|
||||
last_used_at: row.last_used_at,
|
||||
id: PlatformApiKeyId::new(row.get::<String, _>("id")),
|
||||
workspace_id: WorkspaceId::new(row.get::<String, _>("workspace_id")),
|
||||
agent_id: row.get::<Option<String>, _>("agent_id").map(AgentId::new),
|
||||
name: row.get("name"),
|
||||
prefix: row.get("prefix"),
|
||||
scopes: deserialize_json_value(row.get::<Value, _>("scopes_json"))?,
|
||||
status: deserialize_enum_text(&row.get::<String, _>("status"), "status")?,
|
||||
created_at: row.get("created_at"),
|
||||
last_used_at: row.get("last_used_at"),
|
||||
},
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn get_platform_api_key_by_secret_for_workspace_slug(
|
||||
pub async fn list_platform_api_keys(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
) -> Result<Vec<PlatformApiKeyRecord>, RegistryError> {
|
||||
let rows = sqlx::query(
|
||||
"select
|
||||
id,
|
||||
workspace_id,
|
||||
agent_id,
|
||||
name,
|
||||
prefix,
|
||||
scopes_json,
|
||||
status,
|
||||
created_at,
|
||||
last_used_at
|
||||
from platform_api_keys
|
||||
where workspace_id = $1
|
||||
order by created_at desc",
|
||||
)
|
||||
.bind(workspace_id.as_str())
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
rows.into_iter()
|
||||
.map(|row| {
|
||||
Ok(PlatformApiKeyRecord {
|
||||
api_key: PlatformApiKey {
|
||||
id: PlatformApiKeyId::new(row.get::<String, _>("id")),
|
||||
workspace_id: WorkspaceId::new(row.get::<String, _>("workspace_id")),
|
||||
agent_id: row.get::<Option<String>, _>("agent_id").map(AgentId::new),
|
||||
name: row.get("name"),
|
||||
prefix: row.get("prefix"),
|
||||
scopes: deserialize_json_value(row.get::<Value, _>("scopes_json"))?,
|
||||
status: deserialize_enum_text(&row.get::<String, _>("status"), "status")?,
|
||||
created_at: row.get("created_at"),
|
||||
last_used_at: row.get("last_used_at"),
|
||||
},
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn get_platform_api_key_by_secret_for_agent_slug(
|
||||
&self,
|
||||
workspace_slug: &str,
|
||||
agent_slug: &str,
|
||||
secret_hash: &str,
|
||||
) -> Result<Option<PlatformApiKeyRecord>, RegistryError> {
|
||||
let row = sqlx::query!(
|
||||
let row = sqlx::query(
|
||||
"select
|
||||
k.id,
|
||||
k.workspace_id,
|
||||
k.agent_id,
|
||||
k.name,
|
||||
k.prefix,
|
||||
k.scopes_json,
|
||||
k.status,
|
||||
k.created_at as \"created_at!: time::OffsetDateTime\",
|
||||
k.last_used_at as \"last_used_at: time::OffsetDateTime\"
|
||||
k.created_at,
|
||||
k.last_used_at
|
||||
from platform_api_keys k
|
||||
join workspaces w on w.id = k.workspace_id
|
||||
join agents a on a.id = k.agent_id
|
||||
where w.slug = $1
|
||||
and k.secret_hash = $2
|
||||
and a.slug = $2
|
||||
and k.secret_hash = $3
|
||||
and k.status = 'active'
|
||||
limit 1",
|
||||
workspace_slug,
|
||||
secret_hash,
|
||||
)
|
||||
.bind(workspace_slug)
|
||||
.bind(agent_slug)
|
||||
.bind(secret_hash)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
|
||||
row.map(|row| {
|
||||
Ok(PlatformApiKeyRecord {
|
||||
api_key: PlatformApiKey {
|
||||
id: PlatformApiKeyId::new(row.id),
|
||||
workspace_id: WorkspaceId::new(row.workspace_id),
|
||||
name: row.name,
|
||||
prefix: row.prefix,
|
||||
scopes: deserialize_json_value(row.scopes_json)?,
|
||||
status: deserialize_enum_text(&row.status, "status")?,
|
||||
created_at: row.created_at,
|
||||
last_used_at: row.last_used_at,
|
||||
id: PlatformApiKeyId::new(row.get::<String, _>("id")),
|
||||
workspace_id: WorkspaceId::new(row.get::<String, _>("workspace_id")),
|
||||
agent_id: row.get::<Option<String>, _>("agent_id").map(AgentId::new),
|
||||
name: row.get("name"),
|
||||
prefix: row.get("prefix"),
|
||||
scopes: deserialize_json_value(row.get::<Value, _>("scopes_json"))?,
|
||||
status: deserialize_enum_text(&row.get::<String, _>("status"), "status")?,
|
||||
created_at: row.get("created_at"),
|
||||
last_used_at: row.get("last_used_at"),
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -93,6 +146,7 @@ impl PostgresRegistry {
|
||||
"insert into platform_api_keys (
|
||||
id,
|
||||
workspace_id,
|
||||
agent_id,
|
||||
name,
|
||||
prefix,
|
||||
secret_hash,
|
||||
@@ -102,11 +156,12 @@ impl PostgresRegistry {
|
||||
last_used_at,
|
||||
revoked_at
|
||||
) values (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8::timestamptz, $9::timestamptz, $10::timestamptz
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9::timestamptz, $10::timestamptz, $11::timestamptz
|
||||
)",
|
||||
)
|
||||
.bind(request.api_key.id.as_str())
|
||||
.bind(request.api_key.workspace_id.as_str())
|
||||
.bind(request.api_key.agent_id.as_ref().map(AgentId::as_str))
|
||||
.bind(&request.api_key.name)
|
||||
.bind(&request.api_key.prefix)
|
||||
.bind(request.secret_hash)
|
||||
@@ -160,6 +215,39 @@ impl PostgresRegistry {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn revoke_platform_api_key_for_agent(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
agent_id: &AgentId,
|
||||
key_id: &PlatformApiKeyId,
|
||||
revoked_at: &time::OffsetDateTime,
|
||||
) -> Result<(), RegistryError> {
|
||||
let result = sqlx::query(
|
||||
"update platform_api_keys
|
||||
set status = $1,
|
||||
revoked_at = $2::timestamptz
|
||||
where workspace_id = $3 and agent_id = $4 and id = $5",
|
||||
)
|
||||
.bind(serialize_enum_text(
|
||||
&PlatformApiKeyStatus::Revoked,
|
||||
"status",
|
||||
)?)
|
||||
.bind(revoked_at)
|
||||
.bind(workspace_id.as_str())
|
||||
.bind(agent_id.as_str())
|
||||
.bind(key_id.as_str())
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(RegistryError::PlatformApiKeyNotFound {
|
||||
key_id: key_id.as_str().to_owned(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_platform_api_key(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
@@ -181,6 +269,30 @@ impl PostgresRegistry {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_platform_api_key_for_agent(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
agent_id: &AgentId,
|
||||
key_id: &PlatformApiKeyId,
|
||||
) -> Result<(), RegistryError> {
|
||||
let result = sqlx::query(
|
||||
"delete from platform_api_keys where workspace_id = $1 and agent_id = $2 and id = $3",
|
||||
)
|
||||
.bind(workspace_id.as_str())
|
||||
.bind(agent_id.as_str())
|
||||
.bind(key_id.as_str())
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(RegistryError::PlatformApiKeyNotFound {
|
||||
key_id: key_id.as_str().to_owned(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn touch_platform_api_key(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
|
||||
@@ -1670,9 +1670,12 @@ mod tests {
|
||||
created_at: timestamp("2026-03-25T12:00:00Z"),
|
||||
updated_at: timestamp("2026-03-25T12:00:00Z"),
|
||||
};
|
||||
let agent = test_agent("agent_keys_01", AgentStatus::Draft);
|
||||
let version = test_agent_version(&agent.id, 1, AgentStatus::Draft);
|
||||
let api_key = PlatformApiKey {
|
||||
id: PlatformApiKeyId::new("key_01"),
|
||||
workspace_id: workspace.id.clone(),
|
||||
agent_id: Some(agent.id.clone()),
|
||||
name: "Primary".to_owned(),
|
||||
prefix: "crk_live".to_owned(),
|
||||
scopes: vec![PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||
@@ -1688,6 +1691,14 @@ mod tests {
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
registry
|
||||
.create_agent(CreateAgentRequest {
|
||||
agent: &agent,
|
||||
version: &version,
|
||||
bindings: &[],
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
registry
|
||||
.create_platform_api_key(CreatePlatformApiKeyRequest {
|
||||
api_key: &api_key,
|
||||
@@ -1700,15 +1711,29 @@ mod tests {
|
||||
.list_platform_api_keys(&workspace.id)
|
||||
.await
|
||||
.unwrap();
|
||||
let listed_for_agent = registry
|
||||
.list_platform_api_keys_for_agent(&workspace.id, &agent.id)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
listed,
|
||||
vec![PlatformApiKeyRecord {
|
||||
api_key: api_key.clone()
|
||||
}]
|
||||
);
|
||||
assert_eq!(
|
||||
listed_for_agent,
|
||||
vec![PlatformApiKeyRecord {
|
||||
api_key: api_key.clone()
|
||||
}]
|
||||
);
|
||||
|
||||
let resolved = registry
|
||||
.get_platform_api_key_by_secret_for_workspace_slug(&workspace.slug, secret_hash)
|
||||
.get_platform_api_key_by_secret_for_agent_slug(
|
||||
&workspace.slug,
|
||||
&agent.slug,
|
||||
secret_hash,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
+8
-8
@@ -175,18 +175,18 @@
|
||||
- `POST /api/admin/workspaces/{workspace_id}/agents/{agent_id}/archive`
|
||||
- `POST /api/admin/workspaces/{workspace_id}/agents/{agent_id}/bindings`
|
||||
- `DELETE /api/admin/workspaces/{workspace_id}/agents/{agent_id}/bindings/{operation_id}`
|
||||
- `GET /api/admin/workspaces/{workspace_id}/agents/{agent_id}/keys`
|
||||
- `POST /api/admin/workspaces/{workspace_id}/agents/{agent_id}/keys`
|
||||
- `POST /api/admin/workspaces/{workspace_id}/agents/{agent_id}/keys/{key_id}/revoke`
|
||||
- `DELETE /api/admin/workspaces/{workspace_id}/agents/{agent_id}/keys/{key_id}`
|
||||
- `GET /api/admin/workspaces/{workspace_id}/agents/{agent_id}/platform-api-keys`
|
||||
- `POST /api/admin/workspaces/{workspace_id}/agents/{agent_id}/platform-api-keys`
|
||||
- `POST /api/admin/workspaces/{workspace_id}/agents/{agent_id}/platform-api-keys/{key_id}/revoke`
|
||||
- `DELETE /api/admin/workspaces/{workspace_id}/agents/{agent_id}/platform-api-keys/{key_id}`
|
||||
|
||||
Контракт:
|
||||
|
||||
- `POST /agents/{agent_id}/keys` возвращает metadata ключа и одноразовый `secret`;
|
||||
- `POST /agents/{agent_id}/platform-api-keys` возвращает metadata ключа и одноразовый `secret`;
|
||||
- полное значение ключа доступно только в create-response;
|
||||
- list endpoints возвращают только metadata, `prefix`, `status`, `scopes`, `expires_at` и `last_used_at`;
|
||||
- ключ агента принадлежит одному `agent` и не должен использоваться как основной рабочий токен вызова в целевой защищенной схеме;
|
||||
- ключ агента используется для получения короткоживущего токена доступа к MCP endpoint.
|
||||
- в `Community` ключ агента является основным машинным credential;
|
||||
- в коммерческих редакциях этот же ключ используется как исходный credential для более строгих токенных режимов.
|
||||
|
||||
### 5.7. Token issuance
|
||||
|
||||
@@ -199,7 +199,7 @@
|
||||
|
||||
- `POST /mcp-auth/v1/token` выдает короткоживущий токен доступа для операций уровня `elevated`;
|
||||
- `POST /mcp-auth/v1/token/one-time` выдает одноразовый токен для операций уровня `strict`;
|
||||
- открытая редакция может не использовать эти конечные точки и работать только со статическим ключом агента;
|
||||
- открытая редакция работает только со статическим ключом агента и не обязана реализовывать эти конечные точки;
|
||||
- детальная схема уровней и режимов доступа зафиксирована в `docs/agent-auth-model.md`.
|
||||
|
||||
### 5.8. Observability
|
||||
|
||||
Reference in New Issue
Block a user