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
2 changes: 1 addition & 1 deletion crates/utopia-server/src/api/documents_routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
19 changes: 10 additions & 9 deletions crates/utopia-server/src/api/graph_routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -138,11 +139,11 @@ pub struct EntitySearchQuery {

pub async fn search_entities(
State(state): State<AppState>,
AuthUser(user): AuthUser,
reader: SessionOrToken,
Path(kb_id): Path<Uuid>,
Query(query): Query<EntitySearchQuery>,
) -> ApiResult<Json<serde_json::Value>> {
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() {
Expand Down Expand Up @@ -171,11 +172,11 @@ pub struct EntityDetailQuery {

pub async fn entity_detail(
State(state): State<AppState>,
AuthUser(user): AuthUser,
reader: SessionOrToken,
Path((kb_id, entity_id)): Path<(Uuid, Uuid)>,
Query(q): Query<EntityDetailQuery>,
) -> ApiResult<Json<serde_json::Value>> {
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)
Expand Down Expand Up @@ -518,10 +519,10 @@ pub async fn update_fact_time(

pub async fn fact_evidence(
State(state): State<AppState>,
AuthUser(user): AuthUser,
reader: SessionOrToken,
Path((kb_id, fact_id)): Path<(Uuid, Uuid)>,
) -> ApiResult<Json<serde_json::Value>> {
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 })))
}
Expand Down Expand Up @@ -626,11 +627,11 @@ fn default_history_per() -> i64 {
/// 与 entity_detail(有效时间轴,只看现行)互补。
pub async fn entity_history(
State(state): State<AppState>,
AuthUser(user): AuthUser,
reader: SessionOrToken,
Path((kb_id, entity_id)): Path<(Uuid, Uuid)>,
Query(q): Query<HistoryQuery>,
) -> ApiResult<Json<serde_json::Value>> {
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) =
Expand Down
3 changes: 3 additions & 0 deletions crates/utopia-server/src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
146 changes: 146 additions & 0 deletions crates/utopia-server/src/api/token_read_tests.rs
Original file line number Diff line number Diff line change
@@ -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<Option<Self>> {
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<StatusCode> {
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
}
2 changes: 1 addition & 1 deletion crates/utopia-server/src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ impl FromRequestParts<AppState> for AuthUser {
///
/// **不替换 `AuthUser`。** `AuthUser` 把每个 Bearer 都当 JWT 解,令牌在那几百条
/// 路由上一律是 401——那是有意的:哪条路由准令牌进来,要在 0014 里说出理由。
/// 用这个提取器的只有文档原件(0052)和「把一个库搭起来」那几条。
/// 用这个提取器的只有文档原件(0052)、「把一个库搭起来」那几条和读实体那几条(决定 7)。
///
/// 令牌只收窄:`pat` 在的时候,路由先问 [`Self::require_write`] 或
/// [`Self::reaches`],角色照旧由 `require_kb` 与工作区角色判。
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-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.

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

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