Files
ruvnet--RuView/v2/crates/ruview-auth/src/principal.rs
T
Dragan Spiridonov 499cec7914 feat(auth): ruview-auth — offline Cognitum OAuth access-token verification (ADR-271)
RuView's `/api/v1/*` is gated today by `RUVIEW_API_TOKEN`: one shared secret,
no expiry, no per-user attribution. This adds the verifier half of replacing
that with Cognitum identity — RuView as an OAuth **resource server**, so a user
signs in to their own sensing server with their Cognitum account.

Note the direction: RuView does not call Cognitum. It never obtains a token for
its own use; it verifies tokens users present. Every other Cognitum OAuth
integration in the org (meta-proxy, musica, metaharness, the dashboard CLI) is
the client side of a plane RuView doesn't have. The one existing resource-server
verifier is `meta-llm/src/auth/oauthBearer.ts`, and this crate is a port of its
accept-rule — divergence would be a bug, not a preference: a token meta-llm
rejects must not be one RuView accepts.

Verification is OFFLINE, and that is a requirement rather than an optimisation.
RuView runs on Pi-class hardware that loses WAN, and identity publishes no
introspection endpoint even when the network is up. So: ES256 over identity's
published JWKS, cached by `kid`.

What it accepts, mirroring oauthBearer.ts:
  typ == "access" AND NOT setup AND NOT workload AND account_id non-empty AND
  exp in the future AND iss matches verbatim AND the required scope is held.

Long-lived setup (365-day) and workload credentials are refused outright for
identity's own stated reason: their revocation lives in `oauth_setup_tokens`,
and RuView — like meta-llm — has no database to check it. A 15-minute access
token needs no revocation round-trip because it expires faster than revocation
propagates; a 365-day one does.

Scope is the capability boundary, and it has to be. Cognitum access tokens carry
no `aud`, and `client_id` cannot substitute because clients borrow each other's
registrations (musica ships DEFAULT_CLIENT_ID = "meta-proxy"). `sensing:read`
covers streams and inference; `sensing:admin` covers training, model delete and
recording delete. No hierarchy — admin does not imply read; consent means what
it said. Registered in identity migration 0016 (cognitum-one/dashboard#116).

Design notes:
- `ureq`, not `reqwest`: the sensing server deliberately chose ureq as "the
  smallest" HTTP client, and pulling reqwest in here would reverse that for the
  whole graph. Transport sits behind a `JwksFetcher` trait, so a host can supply
  its own and take no HTTP dependency at all (feature `ureq-transport`, default).
- A JWKS refetch failure is survivable while a key set is already cached: a key
  that verified a minute ago has not stopped being valid because the network
  blipped, and failing closed there logs every user out of their own sensing
  server whenever their internet wobbles. We fail closed in exactly one case —
  no key set has ever been fetched.
- Unknown `kid` forces one refetch so rotation is picked up without waiting out
  the TTL, rate-limited so junk-kid tokens can't become a request amplifier
  aimed at identity.
- Expiry is reported distinctly from a bad signature: on an RTC-less Pi that is
  usually a clock-sync fault, and an operator must be able to tell them apart.
- Test keypairs are generated at runtime, never committed. This repo tracks zero
  `.pem` files and committed key material shouldn't start here — it also means
  no fixture can drift out of sync with the JWKS it's served by.

Tests: 41 green (19 unit + 21 matrix + 1 doctest) under BOTH
`cargo test --no-default-features` (the repo's canonical gate) and default
features. The matrix signs real ES256 tokens rather than asserting on strings,
and covers alg:none, forged signature, spliced payload, unknown kid, expired,
leeway boundaries both sides, wrong issuer, issuer differing only by a trailing
slash, missing/!=access typ, setup and workload smuggled onto typ=access,
missing and empty account_id, and scope escalation.

The highest-value case is `g2_a_genuinely_valid_token_from_another_cognitum_
product_cannot_reach_the_sensing_surface`: a correctly signed, unexpired,
right-issuer, right-typ token with client_id=meta-proxy and scope=inference is
rejected. Nothing about its signature or identity claims distinguishes it — only
scope does. A naive verifier accepts it, and an `inference` token becomes a key
to someone's home sensor.

No wiring into the sensing server yet; this crate is verification only, with no
login flow and no outbound Cognitum calls.

Co-Authored-By: Ruflo & AQE
2026-07-22 15:08:40 +02:00

143 lines
4.7 KiB
Rust

//! The authenticated caller, and the scopes it consented to.
use std::collections::BTreeSet;
/// RuView's own scopes, registered on the `ruview` OAuth client
/// (identity migration `0016`, ADR-060).
///
/// Split by **blast radius**, not by endpoint count: the question is whether a
/// leaked token can destroy something, not how many routes it covers.
pub mod scope {
/// Observe: sensing/pose streams, one-shot inference, reading metadata.
///
/// Not "harmless" — for a presence and vital-signs sensor, read access tells
/// the holder who is home. It is *non-destructive*, which is a weaker claim.
pub const SENSING_READ: &str = "sensing:read";
/// Mutate or destroy: training, model delete, recording delete.
///
/// Irreversible: a deleted model or labelled capture may represent days of
/// collection, and a training run burns hours of CPU on a Pi.
pub const SENSING_ADMIN: &str = "sensing:admin";
}
/// A verified caller. Constructed only by
/// [`crate::verify::verify_access_token`] — there is deliberately no public
/// constructor, so a `Principal` in hand always means a signature was checked.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Principal {
/// `sub` — the identity user id.
pub subject: String,
/// `account_id` — the billing tenant (the user's Firebase UID; ADR-045).
/// Required and non-empty; see the verifier for why.
pub account_id: String,
pub org_id: String,
pub workspace_id: String,
/// `client_id` — which OAuth client obtained this token.
///
/// **Attribution and logging only — never an authorization input.** Clients
/// borrow each other's registrations when their own has not been deployed
/// yet (musica ships `DEFAULT_CLIENT_ID = "meta-proxy"`), so this claim does
/// not reliably identify the product holding the token.
pub client_id: String,
/// `jti` — unique per token; use for request-log correlation.
pub token_id: String,
/// The consented scopes, split on whitespace.
scopes: BTreeSet<String>,
/// `exp`, unix seconds.
pub expires_at: i64,
}
impl Principal {
pub(crate) fn new(
subject: String,
account_id: String,
org_id: String,
workspace_id: String,
client_id: String,
token_id: String,
scope_claim: &str,
expires_at: i64,
) -> Self {
Self {
subject,
account_id,
org_id,
workspace_id,
client_id,
token_id,
scopes: scope_claim.split_whitespace().map(str::to_owned).collect(),
expires_at,
}
}
/// Does this principal hold `scope`?
///
/// Exact match only. There is **no prefix or hierarchy rule** — holding
/// `sensing:admin` does not imply `sensing:read`, and a token that needs
/// both must have consented to both. Implying one scope from another is how
/// a consent screen ends up meaning less than it said.
pub fn has_scope(&self, scope: &str) -> bool {
self.scopes.contains(scope)
}
/// Scopes, sorted — for logging.
pub fn scopes(&self) -> impl Iterator<Item = &str> {
self.scopes.iter().map(String::as_str)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn principal_with(scope: &str) -> Principal {
Principal::new(
"sub".into(),
"acct".into(),
"org".into(),
"ws".into(),
"ruview".into(),
"jti".into(),
scope,
0,
)
}
#[test]
fn has_scope_matches_a_single_consented_scope() {
assert!(principal_with("sensing:read").has_scope(scope::SENSING_READ));
}
#[test]
fn has_scope_matches_within_a_whitespace_separated_list() {
let p = principal_with("sensing:read sensing:admin");
assert!(p.has_scope(scope::SENSING_READ));
assert!(p.has_scope(scope::SENSING_ADMIN));
}
#[test]
fn admin_does_not_imply_read() {
// Guards the "no hierarchy" rule above. If someone later adds prefix
// matching to be helpful, this fails and they have to read the comment.
assert!(!principal_with("sensing:admin").has_scope(scope::SENSING_READ));
}
#[test]
fn unrelated_scope_grants_nothing() {
let p = principal_with("inference");
assert!(!p.has_scope(scope::SENSING_READ));
assert!(!p.has_scope(scope::SENSING_ADMIN));
}
#[test]
fn empty_scope_claim_grants_nothing() {
assert!(!principal_with("").has_scope(scope::SENSING_READ));
}
#[test]
fn scope_prefix_of_a_real_scope_does_not_match() {
assert!(!principal_with("sensing").has_scope(scope::SENSING_READ));
}
}