Split MCP and approval agent keys
This commit is contained in:
@@ -130,6 +130,9 @@ fn allows_scope(scopes: &[PlatformApiKeyScope], required_scope: PlatformApiKeySc
|
||||
PlatformApiKeyScope::Deploy => scopes
|
||||
.iter()
|
||||
.any(|scope| matches!(scope, PlatformApiKeyScope::Deploy)),
|
||||
PlatformApiKeyScope::Approve
|
||||
| PlatformApiKeyScope::Deny
|
||||
| PlatformApiKeyScope::ReadPending => false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -35,12 +35,22 @@ pub enum PlatformApiKeyStatus {
|
||||
Revoked,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PlatformApiKeyKind {
|
||||
McpClient,
|
||||
Approval,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PlatformApiKeyScope {
|
||||
Read,
|
||||
Write,
|
||||
Deploy,
|
||||
Approve,
|
||||
Deny,
|
||||
ReadPending,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -82,6 +92,8 @@ pub struct PlatformApiKey {
|
||||
pub workspace_id: WorkspaceId,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub agent_id: Option<AgentId>,
|
||||
#[serde(default = "default_platform_api_key_kind")]
|
||||
pub key_kind: PlatformApiKeyKind,
|
||||
pub name: String,
|
||||
pub prefix: String,
|
||||
pub scopes: Vec<PlatformApiKeyScope>,
|
||||
@@ -90,6 +102,14 @@ pub struct PlatformApiKey {
|
||||
pub created_at: OffsetDateTime,
|
||||
#[serde(with = "time::serde::rfc3339::option")]
|
||||
pub last_used_at: Option<OffsetDateTime>,
|
||||
#[serde(with = "time::serde::rfc3339::option")]
|
||||
pub expires_at: Option<OffsetDateTime>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub allowed_origins: Vec<String>,
|
||||
}
|
||||
|
||||
fn default_platform_api_key_kind() -> PlatformApiKeyKind {
|
||||
PlatformApiKeyKind::McpClient
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -97,7 +117,10 @@ mod tests {
|
||||
use serde_json::json;
|
||||
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
||||
|
||||
use super::{PlatformApiKey, PlatformApiKeyScope, PlatformApiKeyStatus, User, UserStatus};
|
||||
use super::{
|
||||
PlatformApiKey, PlatformApiKeyKind, PlatformApiKeyScope, PlatformApiKeyStatus, User,
|
||||
UserStatus,
|
||||
};
|
||||
use crate::ids::{AgentId, PlatformApiKeyId, UserId, WorkspaceId};
|
||||
|
||||
#[test]
|
||||
@@ -138,16 +161,20 @@ mod tests {
|
||||
id: PlatformApiKeyId::new("pk_01"),
|
||||
workspace_id: WorkspaceId::new("ws_01"),
|
||||
agent_id: Some(AgentId::new("agent_01")),
|
||||
key_kind: PlatformApiKeyKind::McpClient,
|
||||
name: "Primary".to_owned(),
|
||||
prefix: "crk_live".to_owned(),
|
||||
scopes: vec![PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||
status: PlatformApiKeyStatus::Active,
|
||||
created_at: OffsetDateTime::parse("2026-03-25T12:01:00Z", &Rfc3339).unwrap(),
|
||||
last_used_at: Some(OffsetDateTime::parse("2026-03-25T12:05:00Z", &Rfc3339).unwrap()),
|
||||
expires_at: None,
|
||||
allowed_origins: Vec::new(),
|
||||
};
|
||||
|
||||
let value = serde_json::to_value(&api_key).unwrap();
|
||||
|
||||
assert_eq!(value["key_kind"], json!("mcp_client"));
|
||||
assert_eq!(value["created_at"], json!("2026-03-25T12:01:00Z"));
|
||||
assert_eq!(value["last_used_at"], json!("2026-03-25T12:05:00Z"));
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ pub mod workspace;
|
||||
pub mod domain {
|
||||
pub use crate::access::{
|
||||
InvitationStatus, InvitationToken, Membership, MembershipRole, PlatformApiKey,
|
||||
PlatformApiKeyScope, PlatformApiKeyStatus, User, UserStatus,
|
||||
PlatformApiKeyKind, PlatformApiKeyScope, PlatformApiKeyStatus, User, UserStatus,
|
||||
};
|
||||
pub use crate::agent::{Agent, AgentOperationBinding, AgentStatus, AgentVersion};
|
||||
pub use crate::auth::{
|
||||
@@ -85,7 +85,7 @@ pub mod ports {
|
||||
|
||||
pub use access::{
|
||||
InvitationStatus, InvitationToken, Membership, MembershipRole, PlatformApiKey,
|
||||
PlatformApiKeyScope, PlatformApiKeyStatus, User, UserStatus,
|
||||
PlatformApiKeyKind, PlatformApiKeyScope, PlatformApiKeyStatus, User, UserStatus,
|
||||
};
|
||||
pub use agent::{Agent, AgentOperationBinding, AgentStatus, AgentVersion};
|
||||
pub use auth::{
|
||||
|
||||
@@ -148,11 +148,14 @@ pub async fn apply_postgres(pool: &PgPool) -> Result<(), sqlx::Error> {
|
||||
name text not null,
|
||||
prefix text not null,
|
||||
secret_hash text not null,
|
||||
key_kind text not null default 'mcp_client',
|
||||
scopes_json jsonb not null,
|
||||
status text not null,
|
||||
created_at timestamptz not null,
|
||||
last_used_at timestamptz null,
|
||||
revoked_at timestamptz null
|
||||
revoked_at timestamptz null,
|
||||
expires_at timestamptz null,
|
||||
allowed_origins_json jsonb not null default '[]'::jsonb
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
@@ -166,6 +169,19 @@ pub async fn apply_postgres(pool: &PgPool) -> Result<(), sqlx::Error> {
|
||||
query("alter table platform_api_keys add column if not exists agent_id text null")
|
||||
.execute(pool)
|
||||
.await?;
|
||||
query(
|
||||
"alter table platform_api_keys add column if not exists key_kind text not null default 'mcp_client'",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
query("alter table platform_api_keys add column if not exists expires_at timestamptz null")
|
||||
.execute(pool)
|
||||
.await?;
|
||||
query(
|
||||
"alter table platform_api_keys add column if not exists allowed_origins_json jsonb not null default '[]'::jsonb",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"insert into workspaces (
|
||||
|
||||
@@ -11,12 +11,15 @@ impl PostgresRegistry {
|
||||
id,
|
||||
workspace_id,
|
||||
agent_id,
|
||||
key_kind,
|
||||
name,
|
||||
prefix,
|
||||
scopes_json,
|
||||
status,
|
||||
created_at,
|
||||
last_used_at
|
||||
last_used_at,
|
||||
expires_at,
|
||||
allowed_origins_json
|
||||
from platform_api_keys
|
||||
where workspace_id = $1
|
||||
and agent_id = $2
|
||||
@@ -34,12 +37,20 @@ impl PostgresRegistry {
|
||||
id: PlatformApiKeyId::new(row.get::<String, _>("id")),
|
||||
workspace_id: WorkspaceId::new(row.get::<String, _>("workspace_id")),
|
||||
agent_id: row.get::<Option<String>, _>("agent_id").map(AgentId::new),
|
||||
key_kind: deserialize_enum_text(
|
||||
&row.get::<String, _>("key_kind"),
|
||||
"key_kind",
|
||||
)?,
|
||||
name: row.get("name"),
|
||||
prefix: row.get("prefix"),
|
||||
scopes: deserialize_json_value(row.get::<Value, _>("scopes_json"))?,
|
||||
status: deserialize_enum_text(&row.get::<String, _>("status"), "status")?,
|
||||
created_at: row.get("created_at"),
|
||||
last_used_at: row.get("last_used_at"),
|
||||
expires_at: row.get("expires_at"),
|
||||
allowed_origins: deserialize_json_value(
|
||||
row.get::<Value, _>("allowed_origins_json"),
|
||||
)?,
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -55,12 +66,15 @@ impl PostgresRegistry {
|
||||
id,
|
||||
workspace_id,
|
||||
agent_id,
|
||||
key_kind,
|
||||
name,
|
||||
prefix,
|
||||
scopes_json,
|
||||
status,
|
||||
created_at,
|
||||
last_used_at
|
||||
last_used_at,
|
||||
expires_at,
|
||||
allowed_origins_json
|
||||
from platform_api_keys
|
||||
where workspace_id = $1
|
||||
order by created_at desc",
|
||||
@@ -76,12 +90,20 @@ impl PostgresRegistry {
|
||||
id: PlatformApiKeyId::new(row.get::<String, _>("id")),
|
||||
workspace_id: WorkspaceId::new(row.get::<String, _>("workspace_id")),
|
||||
agent_id: row.get::<Option<String>, _>("agent_id").map(AgentId::new),
|
||||
key_kind: deserialize_enum_text(
|
||||
&row.get::<String, _>("key_kind"),
|
||||
"key_kind",
|
||||
)?,
|
||||
name: row.get("name"),
|
||||
prefix: row.get("prefix"),
|
||||
scopes: deserialize_json_value(row.get::<Value, _>("scopes_json"))?,
|
||||
status: deserialize_enum_text(&row.get::<String, _>("status"), "status")?,
|
||||
created_at: row.get("created_at"),
|
||||
last_used_at: row.get("last_used_at"),
|
||||
expires_at: row.get("expires_at"),
|
||||
allowed_origins: deserialize_json_value(
|
||||
row.get::<Value, _>("allowed_origins_json"),
|
||||
)?,
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -99,19 +121,24 @@ impl PostgresRegistry {
|
||||
k.id,
|
||||
k.workspace_id,
|
||||
k.agent_id,
|
||||
k.key_kind,
|
||||
k.name,
|
||||
k.prefix,
|
||||
k.scopes_json,
|
||||
k.status,
|
||||
k.created_at,
|
||||
k.last_used_at
|
||||
k.last_used_at,
|
||||
k.expires_at,
|
||||
k.allowed_origins_json
|
||||
from platform_api_keys k
|
||||
join workspaces w on w.id = k.workspace_id
|
||||
join agents a on a.id = k.agent_id
|
||||
where w.slug = $1
|
||||
and a.slug = $2
|
||||
and k.secret_hash = $3
|
||||
and k.key_kind = 'mcp_client'
|
||||
and k.status = 'active'
|
||||
and (k.expires_at is null or k.expires_at > now())
|
||||
limit 1",
|
||||
)
|
||||
.bind(workspace_slug)
|
||||
@@ -126,12 +153,17 @@ impl PostgresRegistry {
|
||||
id: PlatformApiKeyId::new(row.get::<String, _>("id")),
|
||||
workspace_id: WorkspaceId::new(row.get::<String, _>("workspace_id")),
|
||||
agent_id: row.get::<Option<String>, _>("agent_id").map(AgentId::new),
|
||||
key_kind: deserialize_enum_text(&row.get::<String, _>("key_kind"), "key_kind")?,
|
||||
name: row.get("name"),
|
||||
prefix: row.get("prefix"),
|
||||
scopes: deserialize_json_value(row.get::<Value, _>("scopes_json"))?,
|
||||
status: deserialize_enum_text(&row.get::<String, _>("status"), "status")?,
|
||||
created_at: row.get("created_at"),
|
||||
last_used_at: row.get("last_used_at"),
|
||||
expires_at: row.get("expires_at"),
|
||||
allowed_origins: deserialize_json_value(
|
||||
row.get::<Value, _>("allowed_origins_json"),
|
||||
)?,
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -150,13 +182,16 @@ impl PostgresRegistry {
|
||||
name,
|
||||
prefix,
|
||||
secret_hash,
|
||||
key_kind,
|
||||
scopes_json,
|
||||
status,
|
||||
created_at,
|
||||
last_used_at,
|
||||
revoked_at
|
||||
revoked_at,
|
||||
expires_at,
|
||||
allowed_origins_json
|
||||
) values (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9::timestamptz, $10::timestamptz, $11::timestamptz
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10::timestamptz, $11::timestamptz, $12::timestamptz, $13::timestamptz, $14
|
||||
)",
|
||||
)
|
||||
.bind(request.api_key.id.as_str())
|
||||
@@ -165,11 +200,14 @@ impl PostgresRegistry {
|
||||
.bind(&request.api_key.name)
|
||||
.bind(&request.api_key.prefix)
|
||||
.bind(request.secret_hash)
|
||||
.bind(serialize_enum_text(&request.api_key.key_kind, "key_kind")?)
|
||||
.bind(Json(serialize_json_value(&request.api_key.scopes)?))
|
||||
.bind(serialize_enum_text(&request.api_key.status, "status")?)
|
||||
.bind(request.api_key.created_at)
|
||||
.bind(request.api_key.last_used_at)
|
||||
.bind(Option::<&str>::None)
|
||||
.bind(request.api_key.expires_at)
|
||||
.bind(Json(serialize_json_value(&request.api_key.allowed_origins)?))
|
||||
.execute(&self.pool)
|
||||
.await;
|
||||
|
||||
|
||||
@@ -8,9 +8,10 @@ use crank_core::{
|
||||
AgentId, AgentOperationBinding, AgentStatus, AgentVersion, ApiKeyHeaderAuthConfig, AuthConfig,
|
||||
AuthKind, AuthProfile, ConfigExport, ExecutionConfig, ExportMode, GeneratedDraft,
|
||||
GeneratedDraftStatus, HttpMethod, InvocationLog, MembershipRole, OperationId,
|
||||
OperationSecurityLevel, OperationStatus, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyScope,
|
||||
PlatformApiKeyStatus, Protocol, RestTarget, RetryPolicy, Samples, SecretId, Target,
|
||||
ToolDescription, ToolExample, User, UserId, UserSessionId, WizardState, Workspace, WorkspaceId,
|
||||
OperationSecurityLevel, OperationStatus, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyKind,
|
||||
PlatformApiKeyScope, PlatformApiKeyStatus, Protocol, RestTarget, RetryPolicy, Samples,
|
||||
SecretId, Target, ToolDescription, ToolExample, User, UserId, UserSessionId, WizardState,
|
||||
Workspace, WorkspaceId,
|
||||
};
|
||||
use crank_mapping::{MappingRule, MappingSet};
|
||||
use crank_schema::{Schema, SchemaKind};
|
||||
@@ -372,12 +373,15 @@ async fn manages_platform_api_key_read_paths() {
|
||||
id: PlatformApiKeyId::new("key_01"),
|
||||
workspace_id: workspace.id.clone(),
|
||||
agent_id: Some(agent.id.clone()),
|
||||
key_kind: PlatformApiKeyKind::McpClient,
|
||||
name: "Primary".to_owned(),
|
||||
prefix: "crk_live".to_owned(),
|
||||
scopes: vec![PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||
status: PlatformApiKeyStatus::Active,
|
||||
created_at: timestamp("2026-03-25T12:01:00Z"),
|
||||
last_used_at: None,
|
||||
expires_at: None,
|
||||
allowed_origins: Vec::new(),
|
||||
};
|
||||
let secret_hash = "secret_hash_01";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user