auth: add community token service seam
This commit is contained in:
@@ -37,6 +37,15 @@ Verification:
|
|||||||
- unit tests for credential-kind enforcement;
|
- unit tests for credential-kind enforcement;
|
||||||
- docs sync check.
|
- docs sync check.
|
||||||
|
|
||||||
|
Progress:
|
||||||
|
- done:
|
||||||
|
- stable DTO и Community stub endpoints для `POST /mcp-auth/v1/token` и `POST /mcp-auth/v1/token/one-time`
|
||||||
|
- structured `403 forbidden` contract с `edition`, `machine_access_mode` и `upgrade_required`
|
||||||
|
- pending:
|
||||||
|
- abstraction для token verification в `mcp-server`
|
||||||
|
- capability-gated UI/API surface для premium machine access
|
||||||
|
- final separation of Community `standard` flow from commercial `elevated/strict`
|
||||||
|
|
||||||
## Planned
|
## Planned
|
||||||
|
|
||||||
### `feat/frontend-commercial-polish`
|
### `feat/frontend-commercial-polish`
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ use crate::{
|
|||||||
},
|
},
|
||||||
auth_profiles::{create_auth_profile, get_auth_profile, list_auth_profiles},
|
auth_profiles::{create_auth_profile, get_auth_profile, list_auth_profiles},
|
||||||
capabilities::get_capabilities,
|
capabilities::get_capabilities,
|
||||||
|
machine_auth::{issue_agent_token, issue_one_time_agent_token},
|
||||||
observability::{get_agent_usage, get_log, get_operation_usage, get_usage, list_logs},
|
observability::{get_agent_usage, get_log, get_operation_usage, get_usage, list_logs},
|
||||||
operations::{
|
operations::{
|
||||||
archive_operation, create_operation, create_version, delete_operation,
|
archive_operation, create_operation, create_version, delete_operation,
|
||||||
@@ -209,6 +210,12 @@ pub fn build_app(state: AppState) -> Router {
|
|||||||
.route("/login", post(login))
|
.route("/login", post(login))
|
||||||
.merge(protected_auth_router),
|
.merge(protected_auth_router),
|
||||||
)
|
)
|
||||||
|
.nest(
|
||||||
|
"/mcp-auth/v1",
|
||||||
|
Router::new()
|
||||||
|
.route("/token", post(issue_agent_token))
|
||||||
|
.route("/token/one-time", post(issue_one_time_agent_token)),
|
||||||
|
)
|
||||||
.nest("/api/admin", admin_router)
|
.nest("/api/admin", admin_router)
|
||||||
.layer(middleware::from_fn_with_state(
|
.layer(middleware::from_fn_with_state(
|
||||||
state.clone(),
|
state.clone(),
|
||||||
@@ -1038,6 +1045,87 @@ mod tests {
|
|||||||
assert_eq!(response["limits"]["max_agents_per_workspace"], 1);
|
assert_eq!(response["limits"]["max_agents_per_workspace"], 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
#[serial]
|
||||||
|
async fn rejects_short_lived_machine_token_issue_in_community() {
|
||||||
|
let registry = test_registry().await;
|
||||||
|
let storage_root = test_storage_root("community_short_lived_token_contract");
|
||||||
|
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
|
||||||
|
let root_url = base_url
|
||||||
|
.as_ref()
|
||||||
|
.split("/api/admin/workspaces/")
|
||||||
|
.next()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let response = reqwest::Client::new()
|
||||||
|
.post(format!("{root_url}/mcp-auth/v1/token"))
|
||||||
|
.json(&json!({
|
||||||
|
"grant_type": "agent_key",
|
||||||
|
"agent_key": "crk_agent_demo_secret",
|
||||||
|
"scope": ["tools:call"]
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(response.status(), reqwest::StatusCode::FORBIDDEN);
|
||||||
|
let payload = response.json::<Value>().await.unwrap();
|
||||||
|
assert_eq!(payload["error"]["code"], "forbidden");
|
||||||
|
assert_eq!(
|
||||||
|
payload["error"]["message"],
|
||||||
|
"short-lived machine access is not available in Community"
|
||||||
|
);
|
||||||
|
assert_eq!(payload["error"]["context"]["edition"], "community");
|
||||||
|
assert_eq!(
|
||||||
|
payload["error"]["context"]["machine_access_mode"],
|
||||||
|
"short_lived_token"
|
||||||
|
);
|
||||||
|
assert_eq!(payload["error"]["context"]["grant_type"], "agent_key");
|
||||||
|
assert_eq!(payload["error"]["context"]["upgrade_required"], json!(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
#[serial]
|
||||||
|
async fn rejects_one_time_machine_token_issue_in_community() {
|
||||||
|
let registry = test_registry().await;
|
||||||
|
let storage_root = test_storage_root("community_one_time_token_contract");
|
||||||
|
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
|
||||||
|
let root_url = base_url
|
||||||
|
.as_ref()
|
||||||
|
.split("/api/admin/workspaces/")
|
||||||
|
.next()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let response = reqwest::Client::new()
|
||||||
|
.post(format!("{root_url}/mcp-auth/v1/token/one-time"))
|
||||||
|
.json(&json!({
|
||||||
|
"agent_key": "crk_agent_demo_secret",
|
||||||
|
"operation_id": "op_sensitive_01",
|
||||||
|
"scope": ["tools:call"]
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(response.status(), reqwest::StatusCode::FORBIDDEN);
|
||||||
|
let payload = response.json::<Value>().await.unwrap();
|
||||||
|
assert_eq!(payload["error"]["code"], "forbidden");
|
||||||
|
assert_eq!(
|
||||||
|
payload["error"]["message"],
|
||||||
|
"one-time machine access is not available in Community"
|
||||||
|
);
|
||||||
|
assert_eq!(payload["error"]["context"]["edition"], "community");
|
||||||
|
assert_eq!(
|
||||||
|
payload["error"]["context"]["machine_access_mode"],
|
||||||
|
"one_time_token"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
payload["error"]["context"]["operation_id"],
|
||||||
|
"op_sensitive_01"
|
||||||
|
);
|
||||||
|
assert_eq!(payload["error"]["context"]["upgrade_required"], json!(true));
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
#[serial]
|
#[serial]
|
||||||
async fn returns_community_protocol_capabilities_only_for_supported_protocols() {
|
async fn returns_community_protocol_capabilities_only_for_supported_protocols() {
|
||||||
|
|||||||
@@ -109,6 +109,13 @@ impl ApiError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn forbidden_with_context(message: impl Into<String>, context: Value) -> Self {
|
||||||
|
Self::Forbidden {
|
||||||
|
message: message.into(),
|
||||||
|
context: Some(context),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn status_code(&self) -> StatusCode {
|
fn status_code(&self) -> StatusCode {
|
||||||
match self {
|
match self {
|
||||||
Self::Unauthorized { .. } => StatusCode::UNAUTHORIZED,
|
Self::Unauthorized { .. } => StatusCode::UNAUTHORIZED,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ pub mod agents;
|
|||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod auth_profiles;
|
pub mod auth_profiles;
|
||||||
pub mod capabilities;
|
pub mod capabilities;
|
||||||
|
pub mod machine_auth;
|
||||||
pub mod observability;
|
pub mod observability;
|
||||||
pub mod operations;
|
pub mod operations;
|
||||||
pub mod secrets;
|
pub mod secrets;
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
use axum::{Json, extract::State};
|
||||||
|
use crank_core::{IssueAgentTokenRequest, IssueOneTimeAgentTokenRequest};
|
||||||
|
|
||||||
|
use crate::{error::ApiError, state::AppState};
|
||||||
|
|
||||||
|
pub async fn issue_agent_token(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(payload): Json<IssueAgentTokenRequest>,
|
||||||
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
|
let response = state.service.issue_agent_token(payload).await?;
|
||||||
|
Ok(Json(serde_json::json!(response)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn issue_one_time_agent_token(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(payload): Json<IssueOneTimeAgentTokenRequest>,
|
||||||
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
|
let response = state.service.issue_one_time_agent_token(payload).await?;
|
||||||
|
Ok(Json(serde_json::json!(response)))
|
||||||
|
}
|
||||||
@@ -12,12 +12,13 @@ use crank_core::{
|
|||||||
AsyncJobHandle, AuthConfig, AuthKind, AuthProfile, AuthProfileId, ConfigExport,
|
AsyncJobHandle, AuthConfig, AuthKind, AuthProfile, AuthProfileId, ConfigExport,
|
||||||
EditionCapabilities, EditionLimits, ExecutionMode, ExportMode, GeneratedDraft,
|
EditionCapabilities, EditionLimits, ExecutionMode, ExportMode, GeneratedDraft,
|
||||||
GeneratedDraftStatus, InvitationId, InvitationStatus, InvitationToken, InvocationLevel,
|
GeneratedDraftStatus, InvitationId, InvitationStatus, InvitationToken, InvocationLevel,
|
||||||
InvocationLog, InvocationLogId, InvocationSource, InvocationStatus, JobStatus,
|
InvocationLog, InvocationLogId, InvocationSource, InvocationStatus, IssueAgentTokenRequest,
|
||||||
MachineAccessMode, MembershipRole, OperationId, OperationSecurityLevel, OperationStatus,
|
IssueOneTimeAgentTokenRequest, IssuedAgentTokenResponse, JobStatus, MachineAccessMode,
|
||||||
PlatformApiKey, PlatformApiKeyId, PlatformApiKeyScope, PlatformApiKeyStatus, ProductEdition,
|
MembershipRole, OperationId, OperationSecurityLevel, OperationStatus, PlatformApiKey,
|
||||||
Protocol, SampleId, Samples, Secret, SecretId, SecretKind, SecretStatus, StreamSession,
|
PlatformApiKeyId, PlatformApiKeyScope, PlatformApiKeyStatus, ProductEdition, Protocol,
|
||||||
StreamStatus, Target, TransportBehavior, UsagePeriod, UserId, UserSessionId, Workspace,
|
SampleId, Samples, Secret, SecretId, SecretKind, SecretStatus, StreamSession, StreamStatus,
|
||||||
WorkspaceId, WorkspaceStatus,
|
Target, TransportBehavior, UsagePeriod, UserId, UserSessionId, Workspace, WorkspaceId,
|
||||||
|
WorkspaceStatus,
|
||||||
};
|
};
|
||||||
use crank_mapping::{JsonPathRoot, MappingSet, infer_mapping_from_samples};
|
use crank_mapping::{JsonPathRoot, MappingSet, infer_mapping_from_samples};
|
||||||
use crank_proto::{ProtoService, services_from_descriptor_set_bytes};
|
use crank_proto::{ProtoService, services_from_descriptor_set_bytes};
|
||||||
@@ -1584,6 +1585,42 @@ impl AdminService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn issue_agent_token(
|
||||||
|
&self,
|
||||||
|
payload: IssueAgentTokenRequest,
|
||||||
|
) -> Result<IssuedAgentTokenResponse, ApiError> {
|
||||||
|
let edition = self.get_capabilities().await.edition;
|
||||||
|
let machine_access_mode = MachineAccessMode::ShortLivedToken;
|
||||||
|
|
||||||
|
Err(ApiError::forbidden_with_context(
|
||||||
|
"short-lived machine access is not available in Community",
|
||||||
|
json!({
|
||||||
|
"edition": edition,
|
||||||
|
"machine_access_mode": machine_access_mode,
|
||||||
|
"grant_type": payload.grant_type,
|
||||||
|
"upgrade_required": true,
|
||||||
|
}),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn issue_one_time_agent_token(
|
||||||
|
&self,
|
||||||
|
payload: IssueOneTimeAgentTokenRequest,
|
||||||
|
) -> Result<IssuedAgentTokenResponse, ApiError> {
|
||||||
|
let edition = self.get_capabilities().await.edition;
|
||||||
|
let machine_access_mode = MachineAccessMode::OneTimeToken;
|
||||||
|
|
||||||
|
Err(ApiError::forbidden_with_context(
|
||||||
|
"one-time machine access is not available in Community",
|
||||||
|
json!({
|
||||||
|
"edition": edition,
|
||||||
|
"machine_access_mode": machine_access_mode,
|
||||||
|
"operation_id": payload.operation_id,
|
||||||
|
"upgrade_required": true,
|
||||||
|
}),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
#[instrument(skip(self))]
|
#[instrument(skip(self))]
|
||||||
pub async fn list_stream_sessions(
|
pub async fn list_stream_sessions(
|
||||||
&self,
|
&self,
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ use serde::{Deserialize, Serialize};
|
|||||||
use time::OffsetDateTime;
|
use time::OffsetDateTime;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
ids::{AuthProfileId, SecretId, WorkspaceId},
|
edition::{MachineAccessMode, OperationSecurityLevel},
|
||||||
|
ids::{AgentId, AuthProfileId, OperationId, SecretId, WorkspaceId},
|
||||||
protocol::AuthKind,
|
protocol::AuthKind,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -63,13 +64,55 @@ pub struct AuthProfile {
|
|||||||
pub updated_at: OffsetDateTime,
|
pub updated_at: OffsetDateTime,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum AgentTokenGrantType {
|
||||||
|
AgentKey,
|
||||||
|
RefreshToken,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct IssueAgentTokenRequest {
|
||||||
|
pub grant_type: AgentTokenGrantType,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub agent_key: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub refresh_token: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub scope: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct IssueOneTimeAgentTokenRequest {
|
||||||
|
pub agent_key: String,
|
||||||
|
pub operation_id: OperationId,
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub scope: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct IssuedAgentTokenResponse {
|
||||||
|
pub access_token: String,
|
||||||
|
pub token_type: String,
|
||||||
|
pub expires_in: u64,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub refresh_token: Option<String>,
|
||||||
|
pub machine_access_mode: MachineAccessMode,
|
||||||
|
pub security_level: OperationSecurityLevel,
|
||||||
|
pub agent_id: AgentId,
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
||||||
|
|
||||||
use super::{ApiKeyHeaderAuthConfig, AuthConfig, AuthProfile};
|
use super::{
|
||||||
|
AgentTokenGrantType, ApiKeyHeaderAuthConfig, AuthConfig, AuthProfile,
|
||||||
|
IssueAgentTokenRequest, IssueOneTimeAgentTokenRequest, IssuedAgentTokenResponse,
|
||||||
|
};
|
||||||
use crate::{
|
use crate::{
|
||||||
|
edition::{MachineAccessMode, OperationSecurityLevel},
|
||||||
ids::{AuthProfileId, SecretId, WorkspaceId},
|
ids::{AuthProfileId, SecretId, WorkspaceId},
|
||||||
protocol::AuthKind,
|
protocol::AuthKind,
|
||||||
};
|
};
|
||||||
@@ -94,4 +137,59 @@ mod tests {
|
|||||||
assert_eq!(value["created_at"], json!("2026-03-25T12:00:00Z"));
|
assert_eq!(value["created_at"], json!("2026-03-25T12:00:00Z"));
|
||||||
assert_eq!(value["updated_at"], json!("2026-03-25T12:05:00Z"));
|
assert_eq!(value["updated_at"], json!("2026-03-25T12:05:00Z"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn issue_agent_token_request_serializes_stable_contract() {
|
||||||
|
let request = IssueAgentTokenRequest {
|
||||||
|
grant_type: AgentTokenGrantType::AgentKey,
|
||||||
|
agent_key: Some("crk_agent_secret".to_owned()),
|
||||||
|
refresh_token: None,
|
||||||
|
scope: vec!["tools:call".to_owned()],
|
||||||
|
};
|
||||||
|
|
||||||
|
let value = serde_json::to_value(&request).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(value["grant_type"], json!("agent_key"));
|
||||||
|
assert_eq!(value["agent_key"], json!("crk_agent_secret"));
|
||||||
|
assert_eq!(value["scope"], json!(["tools:call"]));
|
||||||
|
assert_eq!(value.get("refresh_token"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn issue_one_time_agent_token_request_serializes_stable_contract() {
|
||||||
|
let request = IssueOneTimeAgentTokenRequest {
|
||||||
|
agent_key: "crk_agent_secret".to_owned(),
|
||||||
|
operation_id: "op_01".into(),
|
||||||
|
scope: vec!["tools:call".to_owned()],
|
||||||
|
};
|
||||||
|
|
||||||
|
let value = serde_json::to_value(&request).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(value["agent_key"], json!("crk_agent_secret"));
|
||||||
|
assert_eq!(value["operation_id"], json!("op_01"));
|
||||||
|
assert_eq!(value["scope"], json!(["tools:call"]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn issued_agent_token_response_serializes_machine_access_metadata() {
|
||||||
|
let response = IssuedAgentTokenResponse {
|
||||||
|
access_token: "tok_01".to_owned(),
|
||||||
|
token_type: "Bearer".to_owned(),
|
||||||
|
expires_in: 300,
|
||||||
|
refresh_token: Some("rfr_01".to_owned()),
|
||||||
|
machine_access_mode: MachineAccessMode::ShortLivedToken,
|
||||||
|
security_level: OperationSecurityLevel::Elevated,
|
||||||
|
agent_id: "agt_01".into(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let value = serde_json::to_value(&response).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(value["access_token"], json!("tok_01"));
|
||||||
|
assert_eq!(value["token_type"], json!("Bearer"));
|
||||||
|
assert_eq!(value["expires_in"], json!(300));
|
||||||
|
assert_eq!(value["refresh_token"], json!("rfr_01"));
|
||||||
|
assert_eq!(value["machine_access_mode"], json!("short_lived_token"));
|
||||||
|
assert_eq!(value["security_level"], json!("elevated"));
|
||||||
|
assert_eq!(value["agent_id"], json!("agt_01"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,8 +18,9 @@ pub use access::{
|
|||||||
};
|
};
|
||||||
pub use agent::{Agent, AgentOperationBinding, AgentStatus, AgentVersion};
|
pub use agent::{Agent, AgentOperationBinding, AgentStatus, AgentVersion};
|
||||||
pub use auth::{
|
pub use auth::{
|
||||||
ApiKeyHeaderAuthConfig, ApiKeyQueryAuthConfig, AuthConfig, AuthProfile, BasicAuthConfig,
|
AgentTokenGrantType, ApiKeyHeaderAuthConfig, ApiKeyQueryAuthConfig, AuthConfig, AuthProfile,
|
||||||
BearerAuthConfig,
|
BasicAuthConfig, BearerAuthConfig, IssueAgentTokenRequest, IssueOneTimeAgentTokenRequest,
|
||||||
|
IssuedAgentTokenResponse,
|
||||||
};
|
};
|
||||||
pub use edition::{
|
pub use edition::{
|
||||||
EditionCapabilities, EditionLimits, MachineAccessMode, OperationSecurityLevel, ProductEdition,
|
EditionCapabilities, EditionLimits, MachineAccessMode, OperationSecurityLevel, ProductEdition,
|
||||||
|
|||||||
@@ -230,6 +230,45 @@
|
|||||||
- открытая редакция работает только со статическим ключом агента и не обязана реализовывать эти конечные точки;
|
- открытая редакция работает только со статическим ключом агента и не обязана реализовывать эти конечные точки;
|
||||||
- детальная схема уровней и режимов доступа зафиксирована в `docs/agent-auth-model.md`.
|
- детальная схема уровней и режимов доступа зафиксирована в `docs/agent-auth-model.md`.
|
||||||
|
|
||||||
|
JSON payloads:
|
||||||
|
|
||||||
|
- `POST /mcp-auth/v1/token`
|
||||||
|
- request:
|
||||||
|
- `grant_type`: `agent_key | refresh_token`
|
||||||
|
- `agent_key?`
|
||||||
|
- `refresh_token?`
|
||||||
|
- `scope[]`
|
||||||
|
- response:
|
||||||
|
- `access_token`
|
||||||
|
- `token_type`
|
||||||
|
- `expires_in`
|
||||||
|
- `refresh_token?`
|
||||||
|
- `machine_access_mode`
|
||||||
|
- `security_level`
|
||||||
|
- `agent_id`
|
||||||
|
|
||||||
|
- `POST /mcp-auth/v1/token/one-time`
|
||||||
|
- request:
|
||||||
|
- `agent_key`
|
||||||
|
- `operation_id`
|
||||||
|
- `scope[]`
|
||||||
|
- response:
|
||||||
|
- `access_token`
|
||||||
|
- `token_type`
|
||||||
|
- `expires_in`
|
||||||
|
- `machine_access_mode`
|
||||||
|
- `security_level`
|
||||||
|
- `agent_id`
|
||||||
|
|
||||||
|
Community behavior:
|
||||||
|
|
||||||
|
- открытая редакция публикует эти конечные точки как stable public contract;
|
||||||
|
- в Community вызовы возвращают `403 forbidden` с structured `error.context`, где явно указаны:
|
||||||
|
- `edition`
|
||||||
|
- `machine_access_mode`
|
||||||
|
- `upgrade_required`
|
||||||
|
- private реализация short-lived и one-time token service подключается без изменения публичного HTTP surface.
|
||||||
|
|
||||||
### 5.8. Observability
|
### 5.8. Observability
|
||||||
|
|
||||||
- `GET /api/admin/workspaces/{workspace_id}/logs`
|
- `GET /api/admin/workspaces/{workspace_id}/logs`
|
||||||
|
|||||||
@@ -283,3 +283,22 @@ Enterprise-реализация должна поставляться через
|
|||||||
- в Enterprise — при необходимости одноразовые токены;
|
- в Enterprise — при необходимости одноразовые токены;
|
||||||
- уровень защиты определяется операцией, а не агентом;
|
- уровень защиты определяется операцией, а не агентом;
|
||||||
- агент никогда не ослабляет обязательную защиту опубликованной операции.
|
- агент никогда не ослабляет обязательную защиту опубликованной операции.
|
||||||
|
|
||||||
|
## 13. Public contract seam
|
||||||
|
|
||||||
|
В public Community-коде должны существовать стабильные HTTP contracts для будущего token service:
|
||||||
|
|
||||||
|
- `POST /mcp-auth/v1/token`
|
||||||
|
- `POST /mcp-auth/v1/token/one-time`
|
||||||
|
|
||||||
|
На первом этапе Community может не содержать private issuer implementation, но должна:
|
||||||
|
|
||||||
|
- сохранить DTO и shape ответов;
|
||||||
|
- возвращать предсказуемый `403 forbidden` для short-lived и one-time token flows;
|
||||||
|
- явно сообщать через structured context, что для выбранного `machine_access_mode` требуется расширенная редакция.
|
||||||
|
|
||||||
|
Это позволяет:
|
||||||
|
|
||||||
|
- не менять public API при подключении private реализации;
|
||||||
|
- держать open-core границу на уровне реализации, а не на уровне transport contract;
|
||||||
|
- не смешивать Community static agent-key flow с commercial `elevated/strict` реализацией.
|
||||||
|
|||||||
Reference in New Issue
Block a user