From 155a6dd72baf67d90388dcae443635605a11a51e Mon Sep 17 00:00:00 2001 From: Bogdan Szabo Date: Mon, 19 Oct 2026 22:38:15 +0200 Subject: [PATCH] A token reads a document's extractions and exports its base Co-Authored-By: Claude Opus 5.5 Signed-off-by: Bogdan Szabo --- .../utopia-server/src/api/documents_routes.rs | 11 +++-- crates/utopia-server/src/api/export_routes.rs | 19 ++++--- .../utopia-server/src/api/token_read_tests.rs | 49 +++++++++++++++++-- crates/utopia-server/src/auth.rs | 3 +- ...ty-from-the-person-scope-from-the-token.md | 14 +++++- docs/design/access-and-audit.md | 3 +- 6 files changed, 77 insertions(+), 22 deletions(-) diff --git a/crates/utopia-server/src/api/documents_routes.rs b/crates/utopia-server/src/api/documents_routes.rs index cff69bcca..62746b758 100644 --- a/crates/utopia-server/src/api/documents_routes.rs +++ b/crates/utopia-server/src/api/documents_routes.rs @@ -85,12 +85,13 @@ pub(crate) async fn require_reader_kb( state: &AppState, reader: &SessionOrToken, kb_id: Uuid, -) -> ApiResult<()> { - utopia_store::access::require_kb(&state.pool, &reader.user, kb_id, Role::Viewer).await?; +) -> ApiResult { + let kb = + utopia_store::access::require_kb(&state.pool, &reader.user, kb_id, Role::Viewer).await?; if !reader.reaches(kb_id) { return Err(AppError::NotFound.into()); } - Ok(()) + Ok(kb) } /// Serve the retained original named by the document ledger. @@ -448,11 +449,11 @@ pub async fn detail( /// 反向证据链:文档各分块抽出的事实(文档查看器右栏)。 pub async fn extractions( State(state): State, - AuthUser(user): AuthUser, + reader: SessionOrToken, Path(id): Path, ) -> ApiResult> { let doc = utopia_store::documents::get(&state.pool, id).await?; - utopia_store::access::require_kb(&state.pool, &user, doc.kb_id, Role::Viewer).await?; + require_reader_kb(&state, &reader, doc.kb_id).await?; let facts = utopia_store::graph::document_extractions(&state.pool, id).await?; Ok(Json(json!({ "facts": facts }))) } diff --git a/crates/utopia-server/src/api/export_routes.rs b/crates/utopia-server/src/api/export_routes.rs index 4f1ca0f51..18c2049b8 100644 --- a/crates/utopia-server/src/api/export_routes.rs +++ b/crates/utopia-server/src/api/export_routes.rs @@ -16,12 +16,11 @@ use axum::extract::{Path, Query, State}; use axum::http::{header, HeaderMap, HeaderValue, StatusCode}; use axum::response::{IntoResponse, Response}; use serde::Deserialize; -use utopia_core::models::Role; use utopia_core::AppError; use uuid::Uuid; -use super::graph_routes::require_kb; -use crate::auth::AuthUser; +use super::documents_routes::require_reader_kb; +use crate::auth::SessionOrToken; use crate::error::ApiResult; use crate::rdf::{self, Format, Names, SharedBuf, Sink}; use crate::state::AppState; @@ -38,27 +37,31 @@ pub struct ExportQuery { pub async fn export( State(state): State, - AuthUser(user): AuthUser, + reader: SessionOrToken, Path(kb_id): Path, Query(q): Query, ) -> ApiResult { - let kb = require_kb(&state, &user, kb_id, Role::Viewer).await?; + let kb = require_reader_kb(&state, &reader, kb_id).await?; let format = Format::parse(q.format.as_deref()).ok_or_else(|| { AppError::Validation("Unknown `format` (expected turtle or jsonld)".into()) })?; let names = Names::new(kb_id, q.base.as_deref()).map_err(AppError::Validation)?; - // 导出是一次「整个库离开这台机器」的动作,台账要记下(0014 的同一条理由)。 + // 导出是一次「整个库离开这台机器」的动作,台账要记下(0014 的同一条理由); + // 令牌导出的还要记下是哪一枚(0014 决定 7)。 // 必须在快照事务之前写完并放掉连接:下面那条事务一占就是整个流的时长, // 占着它再回头要连接,会把最小池(两条)上的并发导出互相饿死 let _ = utopia_store::audit::record( &state.pool, Some(kb_id), - user.id, + reader.user.id, "kb.exported", "knowledge_base", Some(kb_id), - serde_json::json!({ "format": format.extension() }), + utopia_store::audit::via_token( + serde_json::json!({ "format": format.extension() }), + reader.token_id(), + ), ) .await; diff --git a/crates/utopia-server/src/api/token_read_tests.rs b/crates/utopia-server/src/api/token_read_tests.rs index 289b35100..22887e4fe 100644 --- a/crates/utopia-server/src/api/token_read_tests.rs +++ b/crates/utopia-server/src/api/token_read_tests.rs @@ -1,8 +1,9 @@ //! 个人令牌读图(0014 决定 7):搜实体、实体详情、认知历史、事实证据、图总览、邻域、本体、 -//! 某类的实例这些读路由收任何一枚活着的令牌。角色照旧是上限,`kb_ids` 排除了的库答 404;改实体仍只认会话。 +//! 某类的实例、文档的抽取结果和整库导出这些读路由收任何一枚活着的令牌。角色照旧是上限,`kb_ids` 排除了的库答 404;改实体仍只认会话。 use crate::state::AppState; use axum::body::Body; use axum::http::{Request, StatusCode}; +use serde_json::json; use std::sync::Arc; use tower::ServiceExt; use uuid::Uuid; @@ -18,6 +19,7 @@ struct Fixture { person: Uuid, entity: Uuid, fact: Uuid, + doc: Uuid, _dir: tempfile::TempDir, } @@ -30,7 +32,7 @@ impl Fixture { 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()); + let (person, knows, entity, peer, fact, doc) = (id(), id(), id(), id(), id(), id()); // 插进去的只有本地生成的 UUID 和写死的日期 sqlx::raw_sql(&format!( "INSERT INTO organizations(id,name) VALUES ('{org}','token-read-test'); @@ -48,7 +50,8 @@ impl Fixture { ('{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);" + VALUES ('{fact}','{kb}','{entity}','{knows}','{peer}','2026-01-01',0.9); + INSERT INTO documents(id,kb_id,filename,sha256) VALUES ('{doc}','{kb}','spec.md','x');" )) .execute(&pool) .await?; @@ -72,6 +75,7 @@ impl Fixture { person, entity, fact, + doc, _dir: dir, })) } @@ -83,8 +87,9 @@ impl Fixture { .1 } - fn read_paths(&self) -> [String; 8] { - let (kb, person, entity, fact) = (self.kb, self.person, self.entity, self.fact); + fn read_paths(&self) -> [String; 10] { + let (kb, person, entity, fact, doc) = + (self.kb, self.person, self.entity, self.fact, self.doc); [ format!("/kbs/{kb}/entities?q=Zhang"), format!("/kbs/{kb}/entities/{entity}"), @@ -94,6 +99,8 @@ impl Fixture { format!("/kbs/{kb}/graph/neighborhood?entity={entity}"), format!("/kbs/{kb}/ontology"), format!("/kbs/{kb}/ontology/entity-types/{person}/entities"), + format!("/documents/{doc}/extractions"), + format!("/kbs/{kb}/export?format=turtle"), ] } @@ -149,3 +156,35 @@ async fn a_token_limited_to_another_base_gets_404_on_every_graph_read() -> anyho f.cleanup().await?; result } + +#[tokio::test] +async fn an_export_by_token_is_audited_under_its_owner_with_the_token_id() -> anyhow::Result<()> { + let Some(f) = Fixture::new().await? else { + return Ok(()); + }; + let result = async { + let token = f.read_token(f.kb).await; + let token_id = utopia_store::tokens::authenticate(&f.pool, &token) + .await? + .token_id; + let path = format!("/kbs/{}/export?format=turtle", f.kb); + let status = f.get(&path, &token).await?; + anyhow::ensure!(status == StatusCode::OK, "{path}: {status}"); + + let audited: (Option, serde_json::Value) = sqlx::query_as( + "SELECT actor_id, detail FROM audit_events WHERE kb_id = $1 AND action = 'kb.exported'", + ) + .bind(f.kb) + .fetch_one(&f.pool) + .await?; + let expected = ( + Some(f.reader), + json!({ "format": "ttl", "token_id": token_id }), + ); + anyhow::ensure!(audited == expected, "{audited:?}"); + Ok(()) + } + .await; + f.cleanup().await?; + result +} diff --git a/crates/utopia-server/src/auth.rs b/crates/utopia-server/src/auth.rs index e45f46416..a40be2926 100644 --- a/crates/utopia-server/src/auth.rs +++ b/crates/utopia-server/src/auth.rs @@ -177,7 +177,8 @@ impl FromRequestParts for AuthUser { /// **不替换 `AuthUser`。** `AuthUser` 把每个 Bearer 都当 JWT 解,令牌在那几百条 /// 路由上一律是 401——那是有意的:哪条路由准令牌进来,要在 0014 里说出理由。 /// 用这个提取器的只有文档原件(0052)、「把一个库搭起来」那几条和读图那几条:搜实体、 -/// 实体详情、认知历史、事实证据、图总览、邻域、本体、某类的实例(决定 7)。 +/// 实体详情、认知历史、事实证据、图总览、邻域、本体、某类的实例、文档的抽取结果、 +/// 整库导出(决定 7)。 /// /// 按推送时的 `external_id` 读回一篇文档(#1276)也是文档原件,开给令牌的理由写在决定 7。 /// 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 747d41e69..8c10d626a 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-16 · #180, #318, #1243 · open: tokens across workspaces +- **Status**: Implemented · 2026-10-16 · #180, #318, #1243, #1262 · 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. @@ -111,6 +111,15 @@ push documents in. without it the client can check what landed only by searching for its own text. The source's push key is not a credential here, so a leaked key stays push-only. + A document's extractions (`GET /documents/{id}/extractions`) and the base's RDF export + (`GET /kbs/{id}/export`) take a token on the same terms. The extractions show what extraction + made of a pushed document; the document's base is resolved first, as for its content, so a + token outside `kb_ids` gets the same 404 whether or not the document exists. A read token may + export the whole base because, with the reads above open, its owner could already copy + everything a Viewer sees out one piece at a time; requiring write scope would only push a + mirror to hold a write token for a read-only job. The control is the `kb.exported` audit row, + which names the owner and, through `via_token`, the token. + ## Dead ends - **Base-level machine tokens**, one per base and unrelated to a person. It would be a third @@ -138,7 +147,8 @@ push documents in. - 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. 2026-10-19: the graph overview and neighbourhood join them, and so does reading a pushed document back by its `external_id` (#1276). The - ontology and a class's instances follow (#1265). + ontology and a class's instances follow (#1265), then a document's extractions and the + base's export (#1266). ## Open questions diff --git a/docs/design/access-and-audit.md b/docs/design/access-and-audit.md index 372ee8be0..2f533da5a 100644 --- a/docs/design/access-and-audit.md +++ b/docs/design/access-and-audit.md @@ -21,7 +21,8 @@ reading the source's push key, importing an ontology and deciding a kind word, w role unchanged as the ceiling; any token lists a workspace's bases; a token limited by `kb_ids` creates no base. These routes, document content and eight graph reads (entity search, detail, history, fact evidence, overview, neighbourhood, ontology, a class's instances; 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]. +`kb_ids` answers 404), a document's extractions and the base's export (its `kb.exported` row +names the token) 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]. **A client reads a pushed document back by its `external_id`.** `GET /kbs/{id}/sources/{sid}/documents/by-external-id?external_id=…` finds an `api` or `statements`