anda_brain 0.13.6

๐Ÿง  Anda Brain (ๅคง่„‘) โ€” Autonomous Graph Memory for AI Agents
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
//! Shared authorization layer for the HTTP and MCP channels.
//!
//! Every space-scoped endpoint runs the same prelude โ€” sharding check, CWT
//! verification, space load, space-token verification โ€” and both channels
//! must resolve the caller's audit actor and wiki ACL view identically (the
//! wiki launch review's P0-1 was exactly such a divergence). This module is
//! the single source for that logic.
//!
//! # Naming the admission rule
//!
//! [`authorize`] takes the token scope and the admission mode as separate
//! arguments, and only a handful of their pairings are ones this service
//! means. Spelling both out per call site made that pairing a convention
//! every new endpoint had to know rather than a name it could pick, so HTTP
//! handlers call one of [`read_public`], [`read_lenient`], [`credentialed`]
//! or [`cwt_only`] instead โ€” or the [`ensure_sharding`]/[`check_cwt`]/
//! [`load_space`] pieces where a body parse sits between the historical steps
//! and error precedence must be preserved. The MCP channel reaches
//! [`authorize`] directly, because its auto-create fallback re-runs the whole
//! prelude against a space that did not exist the first time.
//!
//! Channel-specific error surfaces stay channel-local:
//! `From<AuthzError> for AppError` maps to HTTP statuses without leaking
//! storage paths (Debug detail goes to the log only), and the MCP side
//! provides an equivalent `authz_error_data` mapping in `mcp.rs`.

use http::StatusCode;
use std::sync::Arc;

#[cfg(feature = "wiki")]
use crate::wiki::WikiAccess;
use crate::{
    payload::AppError,
    space::{AppState, Space},
    types::{CWToken, SpaceToken, TokenScope},
};

/// How an endpoint admits callers.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AuthzMode {
    /// Read endpoints whose result depends on the caller's identity or ACL
    /// view (wiki reads, conversations, agentic recall): public spaces are
    /// anonymously readable, but a supplied space token is verified even
    /// there so a labeled token keeps its granted labels instead of
    /// silently widening to the anonymous view; only absent/invalid
    /// credentials fall back to anonymous.
    PublicRead,
    /// Read endpoints that never consume the caller's identity (info,
    /// status, probe, read-only KIP): token verification is skipped
    /// entirely on public spaces, preserving their historical semantics โ€”
    /// including not bumping a supplied space token's usage counter there.
    PublicReadLenient,
    /// Endpoints that always require a credential with the given scope: a
    /// CWT, or failing that a space token.
    Credentialed,
    /// Management endpoints: only a CWT is accepted; space tokens are
    /// rejected outright.
    CwtOnly,
}

/// The verified caller: at most one of a CWT or a space token (the CWT wins
/// when both could apply, matching `check_auth_if`'s precedence).
pub struct Caller {
    pub cwt: Option<CWToken>,
    pub st: Option<SpaceToken>,
}

/// Product runtime authentication deliberately excludes the legacy local CWT
/// fallback. A verified Space token can read/respond, but never impersonates an
/// independent observer. The immutable host mapping supplies the native subject.
pub(crate) async fn runtime_credentials(
    app: &AppState,
    space_id: &str,
    token: &str,
    sharding: Option<u32>,
    scope: TokenScope,
) -> crate::runtime_api::RuntimeResult<(Arc<Space>, crate::runtime_api::RuntimeCredential)> {
    use crate::runtime_api::{RuntimeCredential, RuntimeError};
    if let Some(sharding) = sharding {
        ensure_sharding(app, sharding)
            .map_err(|_| RuntimeError::Invalid("runtime shard does not match this host".into()))?;
    }
    if token.is_empty() {
        return Err(RuntimeError::Unauthorized);
    }
    let cwt = if app.runtime_cwt_verifier_enabled() && !token.starts_with("ST") {
        Some(
            app.check_auth(token, space_id, scope, anda_engine::unix_ms())
                .map_err(|_| RuntimeError::Unauthorized)?,
        )
    } else {
        None
    };
    if cwt.is_none() && !token.starts_with("ST") {
        return Err(RuntimeError::Unauthorized);
    }
    let space = app.load_space(space_id, false).await.map_err(|e| {
        if matches!(
            e.downcast_ref::<anda_db::error::DBError>(),
            Some(anda_db::error::DBError::NotFound { .. })
        ) {
            RuntimeError::NotFound
        } else {
            RuntimeError::Storage(e)
        }
    })?;
    let credential = if let Some(cwt) = cwt {
        RuntimeCredential::CwtSubject {
            subject: cwt.user.to_string(),
        }
    } else {
        let verified = space
            .verify_space_token(token.into(), scope, anda_engine::unix_ms())
            .map_err(|_| RuntimeError::Unauthorized)?;
        if verified.labels.is_some() {
            return Err(RuntimeError::Forbidden);
        }
        RuntimeCredential::SpaceTokenDigest {
            digest: crate::runtime_api::credential_digest(token),
        }
    };
    Ok((space, credential))
}

impl Caller {
    /// Resolves the audit actor: the authenticated CWT user, else a stable
    /// space-token identity, else the anonymous marker (public-space readers
    /// with no credential must not be recorded as the space's own identity).
    /// Shared with the MCP channel so both audit trails name identical
    /// subjects.
    #[cfg(feature = "wiki")]
    pub fn actor(&self) -> String {
        caller_identity(&self.cwt, self.st.as_ref())
    }

    /// The caller's wiki read scope; see [`wiki_read_access`].
    #[cfg(feature = "wiki")]
    pub fn wiki_access(&self) -> WikiAccess {
        wiki_read_access(&self.cwt, self.st.as_ref())
    }

    /// The identity Memory Interface handles are scoped to: the CWT subject,
    /// a Space token by name, or the anonymous reader (the same spelling the
    /// wiki audit trail uses). Idempotency keys, staged sources, receipts and
    /// retained recall bases are all scoped to it, so a handle issued to one
    /// caller is not visible to another (MI ยง3, ยง5).
    pub fn namespace(&self) -> String {
        caller_identity(&self.cwt, self.st.as_ref())
    }

    /// Whether the caller presented an owner credential (a CWT for this
    /// Space) rather than a delegated Space token.
    pub fn is_owner(&self) -> bool {
        self.cwt.is_some()
    }

    /// True when the caller is label-restricted; see [`label_restricted`].
    pub fn label_restricted(&self) -> bool {
        label_restricted(self.st.as_ref())
    }

    /// Agentic-recall guard: RecallAgent's wiki tools span all labels, so a
    /// label-restricted token cannot use recall (mirrors the /wiki/events
    /// guard). Single source for HTTP `/recall{,_structured}` and the MCP
    /// recall tool, so the denial reason cannot drift between channels.
    pub fn recall_forbidden(&self) -> Option<&'static str> {
        if self.label_restricted() {
            Some("recall requires an unrestricted token")
        } else {
            None
        }
    }

    /// Conversation-read guard; see [`conversation_read_forbidden`].
    pub fn conversation_read_forbidden(&self, collection: Option<&str>) -> Option<&'static str> {
        conversation_read_forbidden(&self.cwt, self.st.as_ref(), collection)
    }
}

/// Authorization failure. Channel-specific renderings live in
/// `From<AuthzError> for AppError` (HTTP) and `mcp::authz_error_data` (MCP);
/// the `display`/`debug` load details are pre-rendered strings so no live
/// error object (which could embed storage paths) crosses this boundary.
#[derive(Debug)]
pub enum AuthzError {
    ShardMismatch {
        sharding: u32,
        expected: u32,
    },
    Unauthorized(TokenScope),
    /// The space does not exist (`DBError::NotFound`).
    SpaceNotFound {
        space_id: String,
        display: String,
        debug: String,
    },
    /// The space exists but failed to load.
    SpaceLoad {
        space_id: String,
        display: String,
        debug: String,
    },
    /// The credential is valid but not allowed on this surface (e.g. a
    /// label-restricted token reaching an all-labels endpoint).
    Forbidden(&'static str),
}

impl From<AuthzError> for AppError {
    fn from(err: AuthzError) -> Self {
        match err {
            AuthzError::ShardMismatch { sharding, expected } => AppError::bad_request(format!(
                "space_id sharding {} does not match server sharding {}",
                sharding, expected
            )),
            AuthzError::Unauthorized(_) => AppError::unauthorized(),
            // A nonexistent space is 404 with a generic message, anything
            // else 500. Either way the Debug detail stays in the log โ€”
            // AndaDB/object-store errors can embed storage paths, which must
            // not leak into response bodies.
            AuthzError::SpaceNotFound {
                space_id, debug, ..
            } => {
                let space_id = space_id.as_str();
                log::warn!(target: "brain", space_id; "failed to load space: {debug}");
                AppError::with_status(StatusCode::NOT_FOUND, "space not found")
            }
            AuthzError::SpaceLoad {
                space_id, debug, ..
            } => {
                let space_id = space_id.as_str();
                log::warn!(target: "brain", space_id; "failed to load space: {debug}");
                AppError::with_status(StatusCode::INTERNAL_SERVER_ERROR, "failed to load space")
            }
            AuthzError::Forbidden(message) => AppError::with_status(StatusCode::FORBIDDEN, message),
        }
    }
}

pub fn ensure_sharding(app: &AppState, sharding: u32) -> Result<(), AuthzError> {
    if sharding != app.sharding {
        return Err(AuthzError::ShardMismatch {
            sharding,
            expected: app.sharding,
        });
    }
    Ok(())
}

/// Verifies a CWT with the required scope (management surfaces where space
/// tokens are never accepted).
pub fn check_cwt(
    app: &AppState,
    space_id: &str,
    token: &str,
    scope: TokenScope,
    now_ms: u64,
) -> Result<CWToken, AuthzError> {
    app.check_auth(token, space_id, scope, now_ms)
        .map_err(|_| AuthzError::Unauthorized(scope))
}

/// Loads a space, classifying the failure as [`AuthzError::SpaceNotFound`]
/// or [`AuthzError::SpaceLoad`]. The raw error is rendered here (Display for
/// the MCP message, Debug for the HTTP-side log) and dropped.
pub async fn load_space(app: &AppState, space_id: &str) -> Result<Arc<Space>, AuthzError> {
    app.load_space(space_id, false).await.map_err(|err| {
        let not_found = matches!(
            err.downcast_ref::<anda_db::error::DBError>(),
            Some(anda_db::error::DBError::NotFound { .. })
        );
        let space_id = space_id.to_string();
        let display = err.to_string();
        let debug = format!("{err:?}");
        if not_found {
            AuthzError::SpaceNotFound {
                space_id,
                display,
                debug,
            }
        } else {
            AuthzError::SpaceLoad {
                space_id,
                display,
                debug,
            }
        }
    })
}

/// Admits a caller to a read endpoint whose result depends on who is asking
/// โ€” wiki reads, conversations, agentic recall. See [`AuthzMode::PublicRead`].
pub async fn read_public(
    app: &AppState,
    space_id: &str,
    token: &str,
    sharding: u32,
    now_ms: u64,
) -> Result<(Arc<Space>, Caller), AuthzError> {
    authorize(
        app,
        space_id,
        token,
        Some(sharding),
        TokenScope::Read,
        AuthzMode::PublicRead,
        now_ms,
    )
    .await
}

/// Admits a caller to a read endpoint that never consumes their identity โ€”
/// info, status, probe, read-only KIP. See [`AuthzMode::PublicReadLenient`].
///
/// Returns the space alone: an endpoint that admits anonymous readers without
/// verifying a supplied token has no caller to speak of, and handing one back
/// would invite a handler to read an ACL view this mode never established.
pub async fn read_lenient(
    app: &AppState,
    space_id: &str,
    token: &str,
    sharding: u32,
    now_ms: u64,
) -> Result<Arc<Space>, AuthzError> {
    let (space, _) = authorize(
        app,
        space_id,
        token,
        Some(sharding),
        TokenScope::Read,
        AuthzMode::PublicReadLenient,
        now_ms,
    )
    .await?;
    Ok(space)
}

/// Admits a caller that always has to present a credential with `scope`: a
/// CWT, or failing that a space token. See [`AuthzMode::Credentialed`].
pub async fn credentialed(
    app: &AppState,
    space_id: &str,
    token: &str,
    sharding: u32,
    scope: TokenScope,
    now_ms: u64,
) -> Result<(Arc<Space>, Caller), AuthzError> {
    authorize(
        app,
        space_id,
        token,
        Some(sharding),
        scope,
        AuthzMode::Credentialed,
        now_ms,
    )
    .await
}

/// Admits a caller to a management endpoint: only a CWT is accepted, and
/// space tokens are rejected outright. See [`AuthzMode::CwtOnly`].
pub async fn cwt_only(
    app: &AppState,
    space_id: &str,
    token: &str,
    sharding: u32,
    scope: TokenScope,
    now_ms: u64,
) -> Result<(Arc<Space>, Caller), AuthzError> {
    authorize(
        app,
        space_id,
        token,
        Some(sharding),
        scope,
        AuthzMode::CwtOnly,
        now_ms,
    )
    .await
}

/// The shared authorization prelude: sharding check (when `sharding` is
/// `Some`), CWT verification, space load, then space-token verification per
/// [`AuthzMode`]. Step order matters and mirrors what every call site did
/// historically: authentication failures surface before load failures, and
/// space-token verification (which bumps the token's usage counter) runs
/// only after the space is available.
///
/// Prefer the named entries above; reach for this one only where the mode is
/// genuinely a variable, as it is for the MCP channel's auto-create retry.
pub async fn authorize(
    app: &AppState,
    space_id: &str,
    token: &str,
    sharding: Option<u32>,
    scope: TokenScope,
    mode: AuthzMode,
    now_ms: u64,
) -> Result<(Arc<Space>, Caller), AuthzError> {
    if let Some(sharding) = sharding {
        ensure_sharding(app, sharding)?;
    }

    let cwt = match mode {
        AuthzMode::CwtOnly => Some(check_cwt(app, space_id, token, scope, now_ms)?),
        _ => app
            .check_auth_if(token, space_id, scope, now_ms)
            .map_err(|_| AuthzError::Unauthorized(scope))?,
    };

    let space = load_space(app, space_id).await?;

    let st = match mode {
        AuthzMode::CwtOnly => None,
        _ if cwt.is_some() => None,
        AuthzMode::Credentialed => Some(
            space
                .verify_space_token(token.to_string(), scope, now_ms)
                .map_err(|_| AuthzError::Unauthorized(scope))?,
        ),
        AuthzMode::PublicRead => {
            // A supplied token is verified even on public spaces so its ACL
            // restriction is honored rather than silently widened; only
            // absent/invalid credentials fall back to the anonymous view.
            match space.verify_space_token(token.to_string(), scope, now_ms) {
                Ok(st) => Some(st),
                Err(_) if space.is_public() => None,
                Err(_) => return Err(AuthzError::Unauthorized(scope)),
            }
        }
        AuthzMode::PublicReadLenient => {
            if space.is_public() {
                None
            } else {
                Some(
                    space
                        .verify_space_token(token.to_string(), scope, now_ms)
                        .map_err(|_| AuthzError::Unauthorized(scope))?,
                )
            }
        }
    };

    Ok((space, Caller { cwt, st }))
}

/// Resolves the audit actor: the authenticated CWT user, else a stable
/// space-token identity, else the anonymous marker (public-space readers
/// with no credential must not be recorded as the space's own identity).
/// Shared by the HTTP and MCP channels so both audit trails name identical
/// subjects.
fn caller_identity(t: &Option<CWToken>, st: Option<&SpaceToken>) -> String {
    if let Some(t) = t {
        return t.user.to_string();
    }
    match st {
        Some(st) if !st.name.trim().is_empty() => format!("st:{}", st.name.trim()),
        Some(_) => "st:unnamed".to_string(),
        None => "anonymous".to_string(),
    }
}

/// True when the space token is label-restricted (a read-only wiki viewer,
/// PRD ยง8.2). Such tokens must not reach surfaces that span all labels:
/// agentic recall (its wiki tools run unrestricted), the audit event log,
/// and stored conversations โ€” recall conversations persist the full runner
/// history, including wiki tool output from behind any label. Shared by the
/// HTTP and MCP channels so the guards cannot drift apart.
fn label_restricted(st: Option<&SpaceToken>) -> bool {
    st.is_some_and(|st| st.labels.is_some())
}

/// Conversation-read guard shared by HTTP and MCP (so the channels cannot
/// drift apart). Label-restricted tokens are denied for every collection:
/// stored conversations persist the full runner history, which would bypass
/// the token's wiki ACL. Recall conversations additionally deny the
/// anonymous public fallback: recall runs on a then-private space embed
/// labeled wiki tool output verbatim, and flipping the space public later
/// must not hand that history to the world.
fn conversation_read_forbidden(
    t: &Option<CWToken>,
    st: Option<&SpaceToken>,
    collection: Option<&str>,
) -> Option<&'static str> {
    if label_restricted(st) {
        return Some("conversations require an unrestricted token");
    }
    if collection == Some("recall") && t.is_none() && st.is_none() {
        return Some(
            "recall conversations require a credential; anonymous public access is denied",
        );
    }
    None
}

/// Read scope resolution (PRD ยง8.2): CWT holders and label-less space
/// tokens are unrestricted; labeled tokens see unlabeled content plus their
/// labels; anonymous public-space readers see unlabeled content only.
/// Shared by the HTTP and MCP channels so the two never diverge (the launch
/// review's P0-1 was exactly such a divergence).
#[cfg(feature = "wiki")]
fn wiki_read_access(t: &Option<CWToken>, st: Option<&SpaceToken>) -> WikiAccess {
    let labels = if t.is_some() {
        None
    } else if let Some(st) = st {
        st.labels.clone()
    } else {
        Some(Vec::new())
    };
    WikiAccess {
        actor: caller_identity(t, st),
        labels,
    }
}