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
11 changes: 6 additions & 5 deletions crates/utopia-server/src/api/documents_routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<utopia_core::models::KnowledgeBase> {
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.
Expand Down Expand Up @@ -448,11 +449,11 @@ pub async fn detail(
/// 反向证据链:文档各分块抽出的事实(文档查看器右栏)。
pub async fn extractions(
State(state): State<AppState>,
AuthUser(user): AuthUser,
reader: SessionOrToken,
Path(id): Path<Uuid>,
) -> ApiResult<Json<serde_json::Value>> {
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 })))
}
Expand Down
19 changes: 11 additions & 8 deletions crates/utopia-server/src/api/export_routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -38,27 +37,31 @@ pub struct ExportQuery {

pub async fn export(
State(state): State<AppState>,
AuthUser(user): AuthUser,
reader: SessionOrToken,
Path(kb_id): Path<Uuid>,
Query(q): Query<ExportQuery>,
) -> ApiResult<Response> {
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;

Expand Down
49 changes: 44 additions & 5 deletions crates/utopia-server/src/api/token_read_tests.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -18,6 +19,7 @@ struct Fixture {
person: Uuid,
entity: Uuid,
fact: Uuid,
doc: Uuid,
_dir: tempfile::TempDir,
}

Expand All @@ -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');
Expand All @@ -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?;
Expand All @@ -72,6 +75,7 @@ impl Fixture {
person,
entity,
fact,
doc,
_dir: dir,
}))
}
Expand All @@ -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}"),
Expand All @@ -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"),
]
}

Expand Down Expand Up @@ -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<Uuid>, 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
}
3 changes: 2 additions & 1 deletion crates/utopia-server/src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,8 @@ impl FromRequestParts<AppState> for AuthUser {
/// **不替换 `AuthUser`。** `AuthUser` 把每个 Bearer 都当 JWT 解,令牌在那几百条
/// 路由上一律是 401——那是有意的:哪条路由准令牌进来,要在 0014 里说出理由。
/// 用这个提取器的只有文档原件(0052)、「把一个库搭起来」那几条和读图那几条:搜实体、
/// 实体详情、认知历史、事实证据、图总览、邻域、本体、某类的实例(决定 7)。
/// 实体详情、认知历史、事实证据、图总览、邻域、本体、某类的实例、文档的抽取结果、
/// 整库导出(决定 7)。
///
/// 按推送时的 `external_id` 读回一篇文档(#1276)也是文档原件,开给令牌的理由写在决定 7。
///
Expand Down
Original file line number Diff line number Diff line change
@@ -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.

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion docs/design/access-and-audit.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
Loading