From 08b1e68896aff1c01ed9666422c133f1a25729bb Mon Sep 17 00:00:00 2001 From: Bogdan Szabo Date: Fri, 16 Oct 2026 12:47:24 +0200 Subject: [PATCH] A token reads the entities, history and fact evidence of the bases it reaches, so a service mirroring into Utopia can read its own base back without a person's session Signed-off-by: Bogdan Szabo --- .../utopia-server/src/api/documents_routes.rs | 2 +- crates/utopia-server/src/api/graph_routes.rs | 19 +-- crates/utopia-server/src/api/mod.rs | 3 + .../utopia-server/src/api/token_read_tests.rs | 146 ++++++++++++++++++ crates/utopia-server/src/auth.rs | 2 +- ...ty-from-the-person-scope-from-the-token.md | 15 +- docs/design/access-and-audit.md | 5 +- 7 files changed, 178 insertions(+), 14 deletions(-) create mode 100644 crates/utopia-server/src/api/token_read_tests.rs diff --git a/crates/utopia-server/src/api/documents_routes.rs b/crates/utopia-server/src/api/documents_routes.rs index da196cc5e..0d184dea7 100644 --- a/crates/utopia-server/src/api/documents_routes.rs +++ b/crates/utopia-server/src/api/documents_routes.rs @@ -81,7 +81,7 @@ fn content_headers( Ok(headers) } -async fn require_reader_kb( +pub(crate) async fn require_reader_kb( state: &AppState, reader: &SessionOrToken, kb_id: Uuid, diff --git a/crates/utopia-server/src/api/graph_routes.rs b/crates/utopia-server/src/api/graph_routes.rs index eed176b99..95e48573f 100644 --- a/crates/utopia-server/src/api/graph_routes.rs +++ b/crates/utopia-server/src/api/graph_routes.rs @@ -6,7 +6,8 @@ use utopia_core::models::Role; use utopia_core::AppError; use uuid::Uuid; -use crate::auth::AuthUser; +use super::documents_routes::require_reader_kb; +use crate::auth::{AuthUser, SessionOrToken}; use crate::error::ApiResult; use crate::state::AppState; @@ -134,11 +135,11 @@ pub struct EntitySearchQuery { pub async fn search_entities( State(state): State, - AuthUser(user): AuthUser, + reader: SessionOrToken, Path(kb_id): Path, Query(query): Query, ) -> ApiResult> { - require_kb(&state, &user, kb_id, Role::Viewer).await?; + require_reader_kb(&state, &reader, kb_id).await?; // 先校验时刻:一个写错的 `as_of` 配空查询也该回 400,而不是看起来成功 let as_of = parse_instant("as_of", query.as_of.as_deref())?; if query.q.trim().is_empty() { @@ -167,11 +168,11 @@ pub struct EntityDetailQuery { pub async fn entity_detail( State(state): State, - AuthUser(user): AuthUser, + reader: SessionOrToken, Path((kb_id, entity_id)): Path<(Uuid, Uuid)>, Query(q): Query, ) -> ApiResult> { - require_kb(&state, &user, kb_id, Role::Viewer).await?; + require_reader_kb(&state, &reader, kb_id).await?; let as_of = parse_instant("as_of", q.as_of.as_deref())?; // 世界轴不过滤(at = None):面板要的是整条时间线,「此刻成立」那一档由前端 // 按 holds_from / holds_to 分出来(0022) @@ -450,10 +451,10 @@ pub async fn update_fact_time( pub async fn fact_evidence( State(state): State, - AuthUser(user): AuthUser, + reader: SessionOrToken, Path((kb_id, fact_id)): Path<(Uuid, Uuid)>, ) -> ApiResult> { - require_kb(&state, &user, kb_id, Role::Viewer).await?; + require_reader_kb(&state, &reader, kb_id).await?; let evidence = utopia_store::graph::fact_evidence(&state.pool, kb_id, fact_id).await?; Ok(Json(json!({ "evidence": evidence }))) } @@ -558,11 +559,11 @@ fn default_history_per() -> i64 { /// 与 entity_detail(有效时间轴,只看现行)互补。 pub async fn entity_history( State(state): State, - AuthUser(user): AuthUser, + reader: SessionOrToken, Path((kb_id, entity_id)): Path<(Uuid, Uuid)>, Query(q): Query, ) -> ApiResult> { - require_kb(&state, &user, kb_id, Role::Viewer).await?; + require_reader_kb(&state, &reader, kb_id).await?; let per = q.per.clamp(1, 200); let page = q.page.max(0); let (events, total) = diff --git a/crates/utopia-server/src/api/mod.rs b/crates/utopia-server/src/api/mod.rs index f0773a6c0..ec25e1fe6 100644 --- a/crates/utopia-server/src/api/mod.rs +++ b/crates/utopia-server/src/api/mod.rs @@ -661,5 +661,8 @@ mod oidc_exchange_tests; #[cfg(test)] mod token_setup_tests; +#[cfg(test)] +mod token_read_tests; + #[cfg(test)] mod rule_expression_tests; diff --git a/crates/utopia-server/src/api/token_read_tests.rs b/crates/utopia-server/src/api/token_read_tests.rs new file mode 100644 index 000000000..1855c84f1 --- /dev/null +++ b/crates/utopia-server/src/api/token_read_tests.rs @@ -0,0 +1,146 @@ +//! 个人令牌读实体(0014 决定 7):搜实体、实体详情、认知历史、事实证据四条读路由 +//! 收任何一枚活着的令牌。角色照旧是上限,`kb_ids` 排除了的库答 404;改实体仍只认会话。 +use crate::state::AppState; +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use std::sync::Arc; +use tower::ServiceExt; +use uuid::Uuid; + +struct Fixture { + pool: sqlx::PgPool, + app: axum::Router, + org: Uuid, + kb: Uuid, + /// 令牌够不着的另一个库 + other_kb: Uuid, + reader: Uuid, + entity: Uuid, + fact: Uuid, + _dir: tempfile::TempDir, +} + +impl Fixture { + async fn new() -> anyhow::Result> { + let Some(url) = utopia_store::test_db::url() else { + return Ok(None); + }; + let pool = sqlx::PgPool::connect(&url).await?; + utopia_store::db::migrate(&pool).await?; + let id = Uuid::now_v7; + let (org, ws, reader, kb, other_kb) = (id(), id(), id(), id(), id()); + let (person, knows, entity, peer, fact) = (id(), id(), id(), id(), id()); + // 插进去的只有本地生成的 UUID 和写死的日期 + sqlx::raw_sql(&format!( + "INSERT INTO organizations(id,name) VALUES ('{org}','token-read-test'); + INSERT INTO workspaces(id,org_id,name) VALUES ('{ws}','{org}','token-read-test'); + INSERT INTO users(id,org_id,email,display_name,password_hash) VALUES + ('{reader}','{org}','{reader}@token-read.test','Reader','unused'); + INSERT INTO knowledge_bases(id,workspace_id,name) VALUES + ('{kb}','{ws}','mirror'), ('{other_kb}','{ws}','other'); + INSERT INTO kb_members(kb_id,user_id,role) VALUES + ('{kb}','{reader}','viewer'), ('{other_kb}','{reader}','viewer'); + INSERT INTO entity_types(id,kb_id,key,label) VALUES ('{person}','{kb}','person','Person'); + INSERT INTO relation_types(id,kb_id,key,label,temporal) VALUES + ('{knows}','{kb}','knows','knows','state'); + INSERT INTO entities(id,kb_id,type_id,canonical_name,created_at) VALUES + ('{entity}','{kb}','{person}','Zhang San','2026-01-01'), + ('{peer}','{kb}','{person}','Li Si','2026-01-01'); + INSERT INTO facts(id,kb_id,subject_id,predicate_id,object_id,recorded_at,confidence) + VALUES ('{fact}','{kb}','{entity}','{knows}','{peer}','2026-01-01',0.9);" + )) + .execute(&pool) + .await?; + let dir = tempfile::tempdir()?; + let cfg = utopia_core::config::AppConfig { + data_dir: dir.path().to_string_lossy().into_owned(), + ..Default::default() + }; + let search = Arc::new(utopia_search::SearchIndex::open( + &dir.path().join("search"), + )?); + let state = AppState::new(pool.clone(), &cfg, search, "test-only".into()); + let app = super::router(state, &cfg); + Ok(Some(Self { + pool, + app, + org, + kb, + other_kb, + reader, + entity, + fact, + _dir: dir, + })) + } + + async fn read_token(&self, kb: Uuid) -> String { + utopia_store::tokens::issue(&self.pool, self.reader, "ci", "read", Some(&[kb]), None) + .await + .expect("issuing a token") + .1 + } + + fn read_paths(&self) -> [String; 4] { + let (kb, entity, fact) = (self.kb, self.entity, self.fact); + [ + format!("/kbs/{kb}/entities?q=Zhang"), + format!("/kbs/{kb}/entities/{entity}"), + format!("/kbs/{kb}/entities/{entity}/history"), + format!("/kbs/{kb}/facts/{fact}/evidence"), + ] + } + + async fn get(&self, path: &str, token: &str) -> anyhow::Result { + let request = Request::builder() + .uri(format!("/api/v1{path}")) + .header("authorization", format!("Bearer {token}")) + .body(Body::empty())?; + Ok(self.app.clone().oneshot(request).await?.status()) + } + + async fn cleanup(self) -> anyhow::Result<()> { + sqlx::query("DELETE FROM organizations WHERE id = $1") + .bind(self.org) + .execute(&self.pool) + .await?; + Ok(()) + } +} + +#[tokio::test] +async fn a_read_token_of_a_base_viewer_reads_its_entities_facts_and_history() -> anyhow::Result<()> +{ + let Some(f) = Fixture::new().await? else { + return Ok(()); + }; + let result = async { + let token = f.read_token(f.kb).await; + for path in f.read_paths() { + let status = f.get(&path, &token).await?; + anyhow::ensure!(status == StatusCode::OK, "{path}: {status}"); + } + Ok(()) + } + .await; + f.cleanup().await?; + result +} + +#[tokio::test] +async fn a_token_limited_to_another_base_gets_404_on_every_entity_read() -> anyhow::Result<()> { + let Some(f) = Fixture::new().await? else { + return Ok(()); + }; + let result = async { + let token = f.read_token(f.other_kb).await; + for path in f.read_paths() { + let status = f.get(&path, &token).await?; + anyhow::ensure!(status == StatusCode::NOT_FOUND, "{path}: {status}"); + } + Ok(()) + } + .await; + f.cleanup().await?; + result +} diff --git a/crates/utopia-server/src/auth.rs b/crates/utopia-server/src/auth.rs index 49ebf01c6..5a7889866 100644 --- a/crates/utopia-server/src/auth.rs +++ b/crates/utopia-server/src/auth.rs @@ -176,7 +176,7 @@ impl FromRequestParts for AuthUser { /// /// **不替换 `AuthUser`。** `AuthUser` 把每个 Bearer 都当 JWT 解,令牌在那几百条 /// 路由上一律是 401——那是有意的:哪条路由准令牌进来,要在 0014 里说出理由。 -/// 用这个提取器的只有文档原件(0052)和「把一个库搭起来」那几条。 +/// 用这个提取器的只有文档原件(0052)、「把一个库搭起来」那几条和读实体那几条(决定 7)。 /// /// 令牌只收窄:`pat` 在的时候,路由先问 [`Self::require_write`] 或 /// [`Self::reaches`],角色照旧由 `require_kb` 与工作区角色判。 diff --git a/docs/decisions/0014-identity-from-the-person-scope-from-the-token.md b/docs/decisions/0014-identity-from-the-person-scope-from-the-token.md index ed3082aec..58cff2554 100644 --- a/docs/decisions/0014-identity-from-the-person-scope-from-the-token.md +++ b/docs/decisions/0014-identity-from-the-person-scope-from-the-token.md @@ -1,6 +1,6 @@ # 0014 · Identity from the person, scope from the token -- **Status**: Implemented · 2026-10-15 · #180, #318, #1243 · open: tokens across workspaces +- **Status**: Implemented · 2026-10-16 · #180, #318, #1243 · open: tokens across workspaces - **Written**: 2026-09-01 · condensed into English 2026-09-03 - **Related**: migration `0014_data_source_grants` gave data sources a grant layer; this is the same question where a machine knocks. [0004](0004-language-and-localization.md) has the server speak English only, MCP error codes included. [0015](0015-recording-a-sentence-is-not-asserting-a-fact.md) removes the main objection to opening `remember` over MCP. @@ -85,6 +85,17 @@ push documents in. Not done: rotating or deleting a source, changing a base's settings or members, and every other write still take a session. A token does not create workspaces or tokens. +7. **A token reads the graph** (added 2026-10-16, #1262). A service that mirrors into a base reads the + base back to check what landed, and a session JWT is the wrong credential for a backend + (decision 6). Four reads now also take any live token, acting as its owner: searching a base's + entities (`GET /kbs/{id}/entities`), an entity's detail (`GET /kbs/{id}/entities/{eid}`), its + record-axis history (`…/history`), and a fact's evidence (`GET /kbs/{id}/facts/{fid}/evidence`). + Search finds the entity by name; the detail gives its facts, names and derivations; the + history says when we changed our mind; the evidence ties a fact to its sentence. Read is the + lowest scope, so any token qualifies; the owner's role is still the ceiling and Viewer + suffices; a token whose `kb_ids` exclude the base gets 404, as for document content (0052). + Changing an entity on the same path (`PATCH`) still takes a session. + ## Dead ends - **Base-level machine tokens**, one per base and unrelated to a person. It would be a third @@ -109,6 +120,8 @@ push documents in. - 2026-10-16: decision 6 let a token create only `statements` sources, though an `api` source is push-only as well; a backend mirroring several kinds of documents pushes them through one. A token now creates `api` sources too. +- 2026-10-16: decision 7 opens four graph reads to any token; decision 6's "only these routes + and document content" now also counts them. ## Open questions diff --git a/docs/design/access-and-audit.md b/docs/design/access-and-audit.md index cc331f69d..70770528c 100644 --- a/docs/design/access-and-audit.md +++ b/docs/design/access-and-audit.md @@ -19,8 +19,9 @@ writes an audit row `mcp.tool_called`; the tokens page shows the plaintext once snippet [0014]. A `write` token also sets up a base as its owner: creating a base and a `statements` or `api` source, reading the source's push key, importing an ontology and deciding a kind word, with the owner's role unchanged as the ceiling; any token lists a workspace's bases; a token limited by `kb_ids` -creates no base. These routes and document content take `SessionOrToken`; apart from MCP, every -other route is session-only [0014 d6]. `ingest_token` on a source is a different kind: plaintext, push-only [0014]. +creates no base. These routes, document content and four graph reads (entity search, detail, +history, fact evidence; Viewer suffices, `kb_ids` answers 404) take `SessionOrToken`; apart from +MCP, every other route is session-only [0014 d6, d7]. `ingest_token` on a source is a different kind: plaintext, push-only [0014]. **Another app can trade its user's fresh ID token for a session.** `POST /auth/oidc/exchange` accepts an ID token from the configured issuer whose audience is in