Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ sigma/
| All GET endpoints (read) | ✓ | ✓ | ✓ | ✓ |
| VPS / Provider / DNS / Cloud CRUD | ✓ | ✓ | — | — |
| Tickets / IP Checks / Costs / Import | ✓ | ✓ | — | — |
| `POST /api/ai/triage` (spends LLM tokens) | ✓ | ✓ | — | — |
| `POST /agent/register`, `/agent/heartbeat` | ✓ | ✓ | ✓ | — |
| Envoy nodes & routes write (8 endpoints) | ✓ | ✓ | ✓ | — |
| User management (`/api/users`) | ✓ | — | — | — |
Expand Down
2 changes: 1 addition & 1 deletion docs/ai-triage.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ All three paths are unit-tested.

### Auth

The endpoint sits behind the API's standard `auth` middleware (JWT or `X-Api-Key`). It does not currently enforce a minimum role beyond authenticated — operators should treat the rate limit and provider-side quota as the primary cost control, and consider gating to `operator` or `admin` if their key inventory includes `readonly` consumers that shouldn't spend LLM tokens.
The endpoint sits behind the API's standard `auth` middleware (JWT or `X-Api-Key`) **and requires `admin` or `operator` role**. `readonly` consumers (dashboards, monitoring) and per-VPS `agent` keys receive a `403 Forbidden` before any LLM call is made — they can't spend tokens. The global rate limit still applies on top, and provider-side quota remains the second line of defence.

### OpenAPI

Expand Down
2 changes: 1 addition & 1 deletion docs/ai-triage.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ Content-Type: application/json

### 认证

端点位于 API 的标准 `auth` 中间件之后(JWT 或 `X-Api-Key`)。**当前不强制最低角色**,只要通过认证即可。运维应把速率限制和 provider 侧的 quota 作为主要成本控制手段;如果 key 库存中包含本不应消费 LLM token 的 `readonly` 消费者,建议把端点收紧到 `operator` 或 `admin` 角色
端点位于 API 的标准 `auth` 中间件之后(JWT 或 `X-Api-Key`),**并要求 `admin` 或 `operator` 角色**。`readonly` 消费者(仪表盘、监控)和每个 VPS 的 `agent` key 在到达 LLM 调用之前就会收到 `403 Forbidden` —— 它们无法消费 token。全局速率限制仍然叠加生效,provider 侧的 quota 是第二道防线

### OpenAPI

Expand Down
1 change: 1 addition & 0 deletions docs/api-authentication.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ Sigma supports two authentication methods:
| All GET endpoints | Y | Y | Y | Y |
| VPS / Provider / DNS / Cloud CRUD | Y | Y | - | - |
| Tickets / IP Checks / Costs / Import | Y | Y | - | - |
| AI Triage (`POST /api/ai/triage`, spends LLM tokens) | Y | Y | - | - |
| Agent register & heartbeat | Y | Y | Y | - |
| Envoy nodes & routes write | Y | Y | Y | - |
| User management | Y | - | - | - |
Expand Down
1 change: 1 addition & 0 deletions docs/api-authentication.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ Sigma 支持两种认证方式:
| 所有 GET 端点 | Y | Y | Y | Y |
| VPS / Provider / DNS / Cloud 增删改 | Y | Y | - | - |
| Ticket / IP 检测 / 费用 / 导入 | Y | Y | - | - |
| AI 诊断(`POST /api/ai/triage`,消费 LLM token)| Y | Y | - | - |
| Agent 注册与心跳 | Y | Y | Y | - |
| Envoy 节点与路由写操作 | Y | Y | Y | - |
| 用户管理 | Y | - | - | - |
Expand Down
11 changes: 10 additions & 1 deletion sigma-api/src/routes/ai_triage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,14 @@
//! mutates fleet state. Auto-remediation is an explicit non-goal —
//! human-in-the-loop is the design.

use axum::{extract::State, routing::post, Json, Router};
use axum::{extract::State, routing::post, Extension, Json, Router};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::time::Duration;
use tracing::{info, warn};
use utoipa::ToSchema;

use crate::auth::{require_role, CurrentUser};
use crate::errors::AppError;
use crate::routes::AppState;

Expand Down Expand Up @@ -176,12 +177,20 @@ pub struct TriageResponse {
request_body = TriageRequest,
responses(
(status = 200, description = "Triage suggestion (degrades gracefully when LLM unavailable)", body = TriageResponse),
(status = 403, description = "Caller's role is not permitted to spend LLM tokens (requires admin or operator)"),
)
)]
pub async fn triage(
State(state): State<AppState>,
Extension(user): Extension<CurrentUser>,
Json(req): Json<TriageRequest>,
) -> Result<Json<TriageResponse>, AppError> {
// LLM calls cost real tokens. Restrict to roles that can already mutate
// fleet state — keeps `readonly` consumers (dashboards, monitoring) and
// per-VPS `agent` keys from spending budget. The global rate-limit
// middleware still applies on top.
require_role(&user, &["admin", "operator"])?;

let provider = state.llm_provider;
let provider_str = provider.as_str();

Expand Down
110 changes: 110 additions & 0 deletions sigma-api/tests/ai_triage_test.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
//! Integration tests for `POST /api/ai/triage` RBAC gating.
//!
//! The endpoint spends LLM tokens, so it is restricted to `admin` and
//! `operator`. `readonly` and `agent` roles must be rejected at the
//! request boundary — before any LLM call is made.
//!
//! The test environment has no `LLM_API_KEY`, so allowed roles get back
//! a 200 OK with `available: false`. That's sufficient: it proves RBAC
//! passed without depending on external network calls.

mod common;

use axum::body::Body;
use http_body_util::BodyExt;
use serde_json::{json, Value};
use tower::ServiceExt;

async fn login_as(
router: &axum::Router,
admin_token: &str,
email: &str,
role: &str,
) -> String {
let user_body = json!({
"email": email,
"password": "password123",
"name": format!("{role} user"),
"role": role,
});
let (status, _) =
common::request_with_token(router, "POST", "/api/users", admin_token, Some(user_body))
.await;
assert_eq!(status, 200, "creating {role} user should succeed");

let login_body = json!({ "email": email, "password": "password123" });
let req = axum::http::Request::builder()
.method("POST")
.uri("/api/auth/login")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_string(&login_body).unwrap()))
.unwrap();
let response = router.clone().oneshot(req).await.unwrap();
assert_eq!(response.status(), 200, "{role} login should succeed");
let bytes = response.into_body().collect().await.unwrap().to_bytes();
let login_json: Value = serde_json::from_slice(&bytes).unwrap();
login_json["token"].as_str().unwrap().to_string()
}

fn alert_body() -> Value {
json!({ "alert": { "name": "test alert" } })
}

#[tokio::test]
async fn test_admin_can_triage() {
let (router, pool) = common::setup().await;
let token = common::login_admin(&router).await;

let (status, body) =
common::request_with_token(&router, "POST", "/api/ai/triage", &token, Some(alert_body()))
.await;
assert_eq!(status, 200);
// No LLM_API_KEY in the test env, so the endpoint degrades — but the
// request itself was accepted, which is what we're verifying here.
assert_eq!(body["available"], false);

common::cleanup(&pool).await;
}

#[tokio::test]
async fn test_operator_can_triage() {
let (router, pool) = common::setup().await;
let admin_token = common::login_admin(&router).await;
let token = login_as(&router, &admin_token, "operator@test.local", "operator").await;

let (status, body) =
common::request_with_token(&router, "POST", "/api/ai/triage", &token, Some(alert_body()))
.await;
assert_eq!(status, 200);
assert_eq!(body["available"], false);

common::cleanup(&pool).await;
}

#[tokio::test]
async fn test_readonly_cannot_triage() {
let (router, pool) = common::setup().await;
let admin_token = common::login_admin(&router).await;
let token = login_as(&router, &admin_token, "readonly@test.local", "readonly").await;

let (status, _) =
common::request_with_token(&router, "POST", "/api/ai/triage", &token, Some(alert_body()))
.await;
assert_eq!(status, 403);

common::cleanup(&pool).await;
}

#[tokio::test]
async fn test_agent_cannot_triage() {
let (router, pool) = common::setup().await;
let admin_token = common::login_admin(&router).await;
let token = login_as(&router, &admin_token, "agent@test.local", "agent").await;

let (status, _) =
common::request_with_token(&router, "POST", "/api/ai/triage", &token, Some(alert_body()))
.await;
assert_eq!(status, 403);

common::cleanup(&pool).await;
}
3 changes: 3 additions & 0 deletions sigma-api/tests/common/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ pub async fn setup() -> (Router, PgPool) {
http_client: reqwest::Client::new(),
jwt_secret: "test-jwt-secret".to_string(),
jwt_expiry_hours: 24,
llm_provider: routes::ai_triage::LlmProvider::default(),
llm_api_key: None,
};

// Build router matching main.rs structure
Expand All @@ -82,6 +84,7 @@ pub async fn setup() -> (Router, PgPool) {
.merge(routes::users::router())
.merge(routes::audit_logs::router())
.merge(routes::tickets::router())
.merge(routes::ai_triage::router())
.layer(axum::middleware::from_fn_with_state(
app_state.clone(),
routes::rate_limit::rate_limit,
Expand Down
Loading