Skip to main content

cellos_core/
principal.rs

1//! ADR-0019 Authority Pluralism — `Principal` as a first-class type.
2//!
3//! Pre-ratification: this module exists so downstream crates can take a
4//! direct dependency on the type and round-trip its wire form. No
5//! signed-event emission site uses it yet — that producer-side migration
6//! lands in a later wave once ADR-0019 is `Accepted`.
7//!
8//! # Variants
9//!
10//! Per ADR-0019 §Decision, every signed event attributes one of four
11//! principal variants:
12//!
13//! - [`Principal::Operator`] — a human operator's bearer-token identity
14//!   (the historical singleton; preserved unchanged so v0.5-shaped
15//!   `principal://operator/<id>` URIs round-trip byte-for-byte).
16//! - [`Principal::Platform`] — the hosted control plane acting on behalf
17//!   of a tenant (compaction, scheduled migration, GC of orphan residue).
18//! - [`Principal::Delegate`] — a principal acting on behalf of an
19//!   authorizing principal, with bounded [`AuthorityScope`]. The
20//!   composition rule ("delegate scope MUST be a subset of authorizing
21//!   scope") is enforced by [`Principal::compose`].
22//! - [`Principal::Federated`] — an external IAM (OIDC issuer, ADO realm,
23//!   GitHub org) acting as a principal via federation. The
24//!   [`TrustRoot`] field is the load-bearing doctrinal marker that an
25//!   external root is in play.
26//!
27//! # Wire form
28//!
29//! Two surfaces, kept synchronised:
30//!
31//! - The CloudEvent `source` URI (audit-readable) — see
32//!   [`Principal::to_source_uri`] / [`Principal::from_source_uri`].
33//! - The structured `principal` payload field (type-checkable) — derived
34//!   `serde` representation with `#[serde(tag = "kind", …)]`.
35//!
36//! # Audit helper
37//!
38//! [`Principal::root_operator`] walks the authorizing chain and answers
39//! the question compliance buyers have asked for since 0.3: *"did this
40//! action originate from human approval at any depth?"* — see ADR-0019
41//! §Verification clause 2.
42
43use std::collections::BTreeSet;
44use std::fmt;
45
46use serde::{Deserialize, Serialize};
47use thiserror::Error;
48
49// --- ID newtypes -------------------------------------------------------------
50//
51// String newtypes per the crate's existing `RoleId(pub String)` idiom
52// (see [`crate::types::RoleId`]). Distinct types prevent accidental
53// cross-casting between an operator id, a platform id, and a delegate
54// session id at the call site — the type-system gate ADR-0019
55// §Verification clause 5 calls for.
56
57macro_rules! string_id {
58    ($(#[$meta:meta])* $name:ident) => {
59        $(#[$meta])*
60        #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
61        pub struct $name(pub String);
62
63        impl $name {
64            /// Borrow the underlying id string.
65            pub fn as_str(&self) -> &str {
66                &self.0
67            }
68        }
69
70        impl fmt::Display for $name {
71            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
72                self.0.fmt(f)
73            }
74        }
75
76        impl From<String> for $name {
77            fn from(s: String) -> Self {
78                Self(s)
79            }
80        }
81
82        impl From<&str> for $name {
83            fn from(s: &str) -> Self {
84                Self(s.to_owned())
85            }
86        }
87    };
88}
89
90string_id! {
91    /// Opaque identifier for a human operator (`Principal::Operator`).
92    ///
93    /// In v0.5 producers this is whatever the bearer-token subject claim
94    /// resolves to; in v1+ producers this is the operator's stable id in
95    /// the authority-keys config.
96    OperatorId
97}
98
99string_id! {
100    /// Opaque identifier for the hosted control plane acting as a
101    /// principal (`Principal::Platform`). Names a specific platform
102    /// deployment (e.g. `hosted-ctrl-plane-prod`).
103    PlatformId
104}
105
106string_id! {
107    /// Opaque identifier for a delegate session (`Principal::Delegate`).
108    /// For an LLM session via an MCP bridge, this is the bridge-issued
109    /// session id (e.g. `llm-claude-session-456`).
110    DelegateId
111}
112
113/// External identity as it appears at the federated trust root —
114/// kept generic (string) so per-issuer parsing stays in the integration
115/// layer. The `TrustRoot` variant disambiguates the grammar.
116#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
117pub struct ExternalId(pub String);
118
119impl ExternalId {
120    pub fn as_str(&self) -> &str {
121        &self.0
122    }
123}
124
125impl fmt::Display for ExternalId {
126    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
127        self.0.fmt(f)
128    }
129}
130
131impl From<String> for ExternalId {
132    fn from(s: String) -> Self {
133        Self(s)
134    }
135}
136
137impl From<&str> for ExternalId {
138    fn from(s: &str) -> Self {
139        Self(s.to_owned())
140    }
141}
142
143// --- TrustRoot ---------------------------------------------------------------
144
145/// Supported federation roots (ADR-0019 §Decision; ADO-IAM forcing).
146///
147/// The variant is the doctrinal marker that an external trust root is in
148/// play; extending this set requires its own ADR per ADR-0019 §Compliance
149/// "Doctrinal red-line".
150#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
151#[serde(tag = "kind", rename_all = "snake_case")]
152pub enum TrustRoot {
153    /// Generic OIDC issuer. `issuer` is the issuer URL exactly as it
154    /// appears in the IdP's discovery document (e.g.
155    /// `https://login.example.com/`).
156    Oidc { issuer: String },
157    /// ADO (Application Delivery Organization) realm.
158    Ado { realm: String },
159    /// GitHub organization acting as the federation root (OIDC-backed
160    /// in practice, but the doctrinal marker is distinct so admission
161    /// can policy-route on org name).
162    GitHub { org: String },
163}
164
165impl TrustRoot {
166    /// URI-segment form used inside the `Federated` `source` URI:
167    /// `oidc/<urlencoded-issuer>`, `ado/<realm>`, `github/<org>`.
168    fn to_uri_segments(&self) -> String {
169        match self {
170            TrustRoot::Oidc { issuer } => format!("oidc/{}", percent_encode(issuer)),
171            TrustRoot::Ado { realm } => format!("ado/{}", percent_encode(realm)),
172            TrustRoot::GitHub { org } => format!("github/{}", percent_encode(org)),
173        }
174    }
175
176    fn from_uri_segments(kind: &str, rest: &str) -> Result<Self, PrincipalParseError> {
177        match kind {
178            "oidc" => Ok(TrustRoot::Oidc {
179                issuer: percent_decode(rest)?,
180            }),
181            "ado" => Ok(TrustRoot::Ado {
182                realm: percent_decode(rest)?,
183            }),
184            "github" => Ok(TrustRoot::GitHub {
185                org: percent_decode(rest)?,
186            }),
187            other => Err(PrincipalParseError::UnknownTrustRoot(other.to_owned())),
188        }
189    }
190}
191
192// --- AuthorityScope / Capability --------------------------------------------
193
194/// A scope is a set of capabilities — the bounded authority a
195/// `Principal::Delegate` may exercise relative to its authorizing
196/// principal. ADR-0019 §Authority chain composition rule 1: a delegate's
197/// scope MUST be a subset of the authorizing principal's effective scope.
198///
199/// Backed by `BTreeSet` so the canonical URI form is deterministic
200/// (sorted) and round-trips byte-stably.
201#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
202#[serde(transparent)]
203pub struct AuthorityScope {
204    capabilities: BTreeSet<Capability>,
205}
206
207impl AuthorityScope {
208    /// Empty scope — useful for `Operator`/`Platform`/`Federated`
209    /// principals (their "effective scope" for composition purposes is
210    /// "all known capabilities", represented by [`AuthorityScope::root`]).
211    pub fn empty() -> Self {
212        Self {
213            capabilities: BTreeSet::new(),
214        }
215    }
216
217    /// Scope containing every known capability. The effective scope a
218    /// non-`Delegate` principal exposes to a downstream delegate per
219    /// ADR-0019 §Authority chain composition rule 1.
220    pub fn root() -> Self {
221        Self {
222            capabilities: BTreeSet::from([Capability::ToolWildcard]),
223        }
224    }
225
226    /// Build a scope from an explicit capability list.
227    pub fn from_capabilities<I: IntoIterator<Item = Capability>>(caps: I) -> Self {
228        Self {
229            capabilities: caps.into_iter().collect(),
230        }
231    }
232
233    pub fn contains(&self, cap: &Capability) -> bool {
234        self.capabilities.contains(cap)
235            || (self.capabilities.contains(&Capability::ToolWildcard) && cap.is_tool())
236    }
237
238    /// Returns `true` iff every capability in `other` is reachable from
239    /// `self` — `other ⊆ self`. The composition rule predicate.
240    pub fn is_superset_of(&self, other: &AuthorityScope) -> bool {
241        other.capabilities.iter().all(|c| self.contains(c))
242    }
243
244    pub fn iter(&self) -> impl Iterator<Item = &Capability> {
245        self.capabilities.iter()
246    }
247
248    pub fn is_empty(&self) -> bool {
249        self.capabilities.is_empty()
250    }
251
252    pub fn len(&self) -> usize {
253        self.capabilities.len()
254    }
255}
256
257/// Capabilities a `Delegate` may carry. The initial vocabulary is the
258/// MCP-tool surface ADR-0019 names; concrete bridge ADRs (MCP, hosted
259/// control plane) extend this as their scope vocabularies are ratified.
260#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
261pub enum Capability {
262    ToolApply,
263    ToolGet,
264    ToolLogs,
265    ToolEvents,
266    /// `tool::*` — matches any `tool::*` capability under [`AuthorityScope::contains`].
267    ToolWildcard,
268}
269
270impl Capability {
271    /// String form used in the `?scope=` query of a principal URI and
272    /// in the serde representation.
273    pub fn as_token(&self) -> &'static str {
274        match self {
275            Capability::ToolApply => "tool:apply",
276            Capability::ToolGet => "tool:get",
277            Capability::ToolLogs => "tool:logs",
278            Capability::ToolEvents => "tool:events",
279            Capability::ToolWildcard => "tool:*",
280        }
281    }
282
283    pub fn from_token(token: &str) -> Result<Self, PrincipalParseError> {
284        match token {
285            "tool:apply" => Ok(Capability::ToolApply),
286            "tool:get" => Ok(Capability::ToolGet),
287            "tool:logs" => Ok(Capability::ToolLogs),
288            "tool:events" => Ok(Capability::ToolEvents),
289            "tool:*" => Ok(Capability::ToolWildcard),
290            other => Err(PrincipalParseError::UnknownCapability(other.to_owned())),
291        }
292    }
293
294    /// Every initial-vocabulary capability is a `tool::*` capability;
295    /// this hook exists so [`AuthorityScope::contains`] can match
296    /// `ToolWildcard` against future non-tool capabilities once a
297    /// future ADR introduces them.
298    fn is_tool(&self) -> bool {
299        matches!(
300            self,
301            Capability::ToolApply
302                | Capability::ToolGet
303                | Capability::ToolLogs
304                | Capability::ToolEvents
305                | Capability::ToolWildcard
306        )
307    }
308}
309
310impl Serialize for Capability {
311    fn serialize<S: serde::Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
312        ser.serialize_str(self.as_token())
313    }
314}
315
316impl<'de> Deserialize<'de> for Capability {
317    fn deserialize<D: serde::Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
318        let s = String::deserialize(de)?;
319        Capability::from_token(&s).map_err(serde::de::Error::custom)
320    }
321}
322
323impl fmt::Display for Capability {
324    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
325        f.write_str(self.as_token())
326    }
327}
328
329// --- Errors ------------------------------------------------------------------
330
331/// Returned by [`Principal::compose`] when the requested delegate scope
332/// is not a subset of the authorizing principal's effective scope —
333/// ADR-0019 §Authority chain composition rule 1, named admission
334/// discriminant `delegate_scope_not_narrowing`.
335#[derive(Debug, Clone, PartialEq, Eq, Error)]
336#[error(
337    "delegate scope not narrowing: requested capability `{missing}` \
338     not held by authorizing principal"
339)]
340pub struct AuthorityScopeViolation {
341    /// The first capability found in `requested_scope` that the
342    /// authorizing principal does not hold. Surfaced verbatim so the
343    /// admission-side log line names the offending token.
344    pub missing: Capability,
345}
346
347/// Returned by [`Principal::from_source_uri`] when the URI does not parse
348/// as a principal chain — the named admission discriminant
349/// `unrecognized_principal_variant` from ADR-0019 §Verification.
350#[derive(Debug, Clone, PartialEq, Eq, Error)]
351pub enum PrincipalParseError {
352    #[error("principal URI must start with `principal://`, got `{0}`")]
353    MissingScheme(String),
354    #[error("principal URI has no variant segment: `{0}`")]
355    EmptyPath(String),
356    #[error("unknown principal kind `{0}` (expected operator|platform|delegate|federated)")]
357    UnknownKind(String),
358    #[error("malformed `{kind}` principal URI: {reason}")]
359    Malformed { kind: &'static str, reason: String },
360    #[error("unknown trust root `{0}` (expected oidc|ado|github)")]
361    UnknownTrustRoot(String),
362    #[error("unknown capability token `{0}`")]
363    UnknownCapability(String),
364    #[error("invalid percent-encoding in URI segment: `{0}`")]
365    BadPercentEncoding(String),
366}
367
368// --- Principal ---------------------------------------------------------------
369
370/// A first-class principal — the entity acting in CellOS authority chains.
371///
372/// Per ADR-0019 §Decision, every signed event attributes one of these
373/// four variants. Composition (`Delegate.scope ⊆ authorizing.scope`) is
374/// enforced by [`Principal::compose`].
375///
376/// # Wire form
377///
378/// The structured representation uses an internally-tagged enum with
379/// `kind` as the discriminant — see the module-level docs for the URI
380/// form. Round-trip is guaranteed:
381/// `Principal::from_source_uri(p.to_source_uri()) == Ok(p)` for every
382/// valid principal.
383#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
384#[serde(tag = "kind", rename_all = "snake_case")]
385pub enum Principal {
386    /// The historical case: a human operator's bearer-token identity.
387    /// Preserved as the v0.5 wire form so existing consumers round-trip
388    /// byte-for-byte.
389    Operator { id: OperatorId },
390
391    /// The hosted control plane itself acting on behalf of a tenant.
392    /// Used for periodic compaction, tenant migration, billing snapshots.
393    Platform { id: PlatformId },
394
395    /// An LLM session / programmatic agent acting on behalf of an
396    /// authorizing principal, with bounded scope. Use
397    /// [`Principal::compose`] to construct — the constructor enforces
398    /// the narrowing invariant.
399    Delegate {
400        authorizing: Box<Principal>,
401        delegate: DelegateId,
402        scope: AuthorityScope,
403    },
404
405    /// An external IAM (OIDC issuer, ADO org, GitHub org) acting as a
406    /// principal via federation.
407    Federated {
408        trust_root: TrustRoot,
409        identity: ExternalId,
410    },
411}
412
413impl Principal {
414    /// Returns `Some(operator_id)` iff the principal chain bottoms out
415    /// at a human [`Principal::Operator`] at any depth; `None`
416    /// otherwise. Used by compliance queries to answer ADR-0019's
417    /// "did a human author this action at any depth?" question.
418    pub fn root_operator(&self) -> Option<&OperatorId> {
419        match self {
420            Principal::Operator { id } => Some(id),
421            Principal::Delegate { authorizing, .. } => authorizing.root_operator(),
422            Principal::Platform { .. } | Principal::Federated { .. } => None,
423        }
424    }
425
426    /// The effective scope a principal exposes to a downstream delegate.
427    ///
428    /// - A `Delegate` exposes its own (already-narrowed) `scope`.
429    /// - Any other variant is treated as holding the root scope
430    ///   ([`AuthorityScope::root`]) for the purpose of composition.
431    ///   ADR-0019 §Out-of-scope leaves "what specific capabilities a
432    ///   non-delegate principal holds" to the tenancy and federated-
433    ///   authority ADRs; pre-ratification we treat the root as
434    ///   unbounded so composition does not block legitimate first
435    ///   delegations.
436    pub fn effective_scope(&self) -> AuthorityScope {
437        match self {
438            Principal::Delegate { scope, .. } => scope.clone(),
439            _ => AuthorityScope::root(),
440        }
441    }
442
443    /// Compose a delegate principal. Returns
444    /// `Ok(Principal::Delegate { … })` iff `requested_scope` is a subset
445    /// of `authorizing.effective_scope()`. Otherwise returns
446    /// [`AuthorityScopeViolation`] naming the first non-narrowing
447    /// capability — the admission discriminant
448    /// `delegate_scope_not_narrowing`.
449    pub fn compose(
450        authorizing: Principal,
451        delegate: DelegateId,
452        requested_scope: AuthorityScope,
453    ) -> Result<Principal, AuthorityScopeViolation> {
454        let upstream = authorizing.effective_scope();
455        if let Some(missing) = requested_scope.iter().find(|cap| !upstream.contains(cap)) {
456            return Err(AuthorityScopeViolation {
457                missing: (*missing).clone(),
458            });
459        }
460        Ok(Principal::Delegate {
461            authorizing: Box::new(authorizing),
462            delegate,
463            scope: requested_scope,
464        })
465    }
466
467    /// Render the CloudEvent `source` URI representation per ADR-0019:
468    ///
469    /// - Operator: `principal://operator/<id>`
470    /// - Platform: `principal://platform/<id>`
471    /// - Delegate: `principal://<chain>/delegate/<id>?scope=<csv>`
472    ///   where `<chain>` is the authorizing principal's URI body
473    ///   (everything after `principal://`, query stripped).
474    /// - Federated: `principal://federated/<root_kind>/<root_id>/identity/<external_id>`
475    ///
476    /// The `?scope=` query carries capabilities in sorted-token form
477    /// (the [`AuthorityScope`] backing `BTreeSet` orders them), comma-
478    /// separated, so the URI is canonical and `to_source_uri ∘ from_source_uri`
479    /// is a deterministic identity on valid principals.
480    pub fn to_source_uri(&self) -> String {
481        format!("principal://{}", self.uri_body_with_query())
482    }
483
484    /// `body[?query]` form. Used internally so `Delegate` can embed its
485    /// authorizing principal's body without the `principal://` scheme.
486    fn uri_body_with_query(&self) -> String {
487        match self {
488            Principal::Operator { id } => format!("operator/{}", percent_encode(id.as_str())),
489            Principal::Platform { id } => format!("platform/{}", percent_encode(id.as_str())),
490            Principal::Federated {
491                trust_root,
492                identity,
493            } => format!(
494                "federated/{}/identity/{}",
495                trust_root.to_uri_segments(),
496                percent_encode(identity.as_str())
497            ),
498            Principal::Delegate {
499                authorizing,
500                delegate,
501                scope,
502            } => {
503                let (auth_body, auth_query) = authorizing.uri_split();
504                let mut body = format!(
505                    "{}/delegate/{}",
506                    auth_body,
507                    percent_encode(delegate.as_str())
508                );
509                // Merge the authorizing chain's existing query (if any)
510                // with this delegate's scope. Authorizing query first
511                // (deeper-chain scopes appear earlier — read top-down).
512                let scope_query = if scope.is_empty() {
513                    String::new()
514                } else {
515                    let tokens: Vec<&str> = scope.iter().map(|c| c.as_token()).collect();
516                    format!("scope={}", tokens.join(","))
517                };
518                let merged = match (auth_query.is_empty(), scope_query.is_empty()) {
519                    (true, true) => String::new(),
520                    (false, true) => auth_query,
521                    (true, false) => scope_query,
522                    (false, false) => format!("{}&{}", auth_query, scope_query),
523                };
524                if merged.is_empty() {
525                    body
526                } else {
527                    body.push('?');
528                    body.push_str(&merged);
529                    body
530                }
531            }
532        }
533    }
534
535    /// Returns `(body_without_query, query_without_leading_qmark)`.
536    fn uri_split(&self) -> (String, String) {
537        let full = self.uri_body_with_query();
538        match full.find('?') {
539            Some(i) => (full[..i].to_owned(), full[i + 1..].to_owned()),
540            None => (full, String::new()),
541        }
542    }
543
544    /// Parse a CloudEvent `source` URI back into a `Principal`. Returns
545    /// [`PrincipalParseError`] for any input that does not match the
546    /// grammar in [`Principal::to_source_uri`].
547    pub fn from_source_uri(uri: &str) -> Result<Principal, PrincipalParseError> {
548        let rest = uri
549            .strip_prefix("principal://")
550            .ok_or_else(|| PrincipalParseError::MissingScheme(uri.to_owned()))?;
551        if rest.is_empty() {
552            return Err(PrincipalParseError::EmptyPath(uri.to_owned()));
553        }
554
555        // Split off the global query (only `scope=` is defined today).
556        let (path, query) = match rest.find('?') {
557            Some(i) => (&rest[..i], &rest[i + 1..]),
558            None => (rest, ""),
559        };
560
561        // Parse out every `scope=…` clause in left-to-right order —
562        // the outermost (deepest-chain) clause comes first, matching
563        // the merge order in `uri_body_with_query`.
564        let mut scope_clauses: Vec<AuthorityScope> = Vec::new();
565        if !query.is_empty() {
566            for clause in query.split('&') {
567                let (k, v) =
568                    clause
569                        .split_once('=')
570                        .ok_or_else(|| PrincipalParseError::Malformed {
571                            kind: "query",
572                            reason: format!("clause `{}` missing `=`", clause),
573                        })?;
574                if k != "scope" {
575                    return Err(PrincipalParseError::Malformed {
576                        kind: "query",
577                        reason: format!("unknown query parameter `{}`", k),
578                    });
579                }
580                let caps: Result<BTreeSet<Capability>, _> = v
581                    .split(',')
582                    .filter(|s| !s.is_empty())
583                    .map(Capability::from_token)
584                    .collect();
585                scope_clauses.push(AuthorityScope {
586                    capabilities: caps?,
587                });
588            }
589        }
590
591        let segments: Vec<&str> = path.split('/').collect();
592        Self::parse_segments(&segments, &mut scope_clauses.into_iter())
593    }
594
595    /// Recursive descent over `<kind>/<id>[/delegate/<id>…]` segments,
596    /// pulling scope clauses off `scopes_left_to_right` in order so the
597    /// innermost (rightmost-in-URI) delegate gets the last clause.
598    fn parse_segments(
599        segments: &[&str],
600        scopes: &mut std::vec::IntoIter<AuthorityScope>,
601    ) -> Result<Principal, PrincipalParseError> {
602        if segments.is_empty() {
603            return Err(PrincipalParseError::Malformed {
604                kind: "principal",
605                reason: "no segments".to_owned(),
606            });
607        }
608        match segments[0] {
609            "operator" => {
610                if segments.len() < 2 {
611                    return Err(PrincipalParseError::Malformed {
612                        kind: "operator",
613                        reason: "missing id segment".to_owned(),
614                    });
615                }
616                let id = percent_decode(segments[1])?;
617                // The operator must be the tail of its sub-chain — if
618                // anything follows it must be `/delegate/…`, handled by
619                // the caller; here we only own segments[0..=1].
620                if segments.len() > 2 && segments[2] != "delegate" {
621                    return Err(PrincipalParseError::Malformed {
622                        kind: "operator",
623                        reason: format!("unexpected segment `{}` after operator id", segments[2]),
624                    });
625                }
626                let principal = Principal::Operator { id: OperatorId(id) };
627                Self::wrap_delegates(principal, &segments[2..], scopes)
628            }
629            "platform" => {
630                if segments.len() < 2 {
631                    return Err(PrincipalParseError::Malformed {
632                        kind: "platform",
633                        reason: "missing id segment".to_owned(),
634                    });
635                }
636                let id = percent_decode(segments[1])?;
637                if segments.len() > 2 && segments[2] != "delegate" {
638                    return Err(PrincipalParseError::Malformed {
639                        kind: "platform",
640                        reason: format!("unexpected segment `{}` after platform id", segments[2]),
641                    });
642                }
643                let principal = Principal::Platform { id: PlatformId(id) };
644                Self::wrap_delegates(principal, &segments[2..], scopes)
645            }
646            "federated" => {
647                // `federated/<root_kind>/<root_id>/identity/<external_id>[/delegate/…]`
648                if segments.len() < 5 || segments[3] != "identity" {
649                    return Err(PrincipalParseError::Malformed {
650                        kind: "federated",
651                        reason: format!(
652                            "expected `federated/<root_kind>/<root_id>/identity/<external_id>`, got `{}`",
653                            segments.join("/")
654                        ),
655                    });
656                }
657                let trust_root = TrustRoot::from_uri_segments(segments[1], segments[2])?;
658                let identity = ExternalId(percent_decode(segments[4])?);
659                if segments.len() > 5 && segments[5] != "delegate" {
660                    return Err(PrincipalParseError::Malformed {
661                        kind: "federated",
662                        reason: format!(
663                            "unexpected segment `{}` after federated identity",
664                            segments[5]
665                        ),
666                    });
667                }
668                let principal = Principal::Federated {
669                    trust_root,
670                    identity,
671                };
672                Self::wrap_delegates(principal, &segments[5..], scopes)
673            }
674            other => Err(PrincipalParseError::UnknownKind(other.to_owned())),
675        }
676    }
677
678    /// Wrap an authorizing principal in zero or more `Delegate` layers
679    /// drawn from the remaining `delegate/<id>` segment pairs, pulling
680    /// one `AuthorityScope` clause per layer from `scopes` (left-to-right
681    /// in the URI = outermost-to-innermost in the chain).
682    fn wrap_delegates(
683        mut principal: Principal,
684        mut remaining: &[&str],
685        scopes: &mut std::vec::IntoIter<AuthorityScope>,
686    ) -> Result<Principal, PrincipalParseError> {
687        while !remaining.is_empty() {
688            if remaining[0] != "delegate" {
689                return Err(PrincipalParseError::Malformed {
690                    kind: "delegate",
691                    reason: format!("expected `delegate`, got `{}`", remaining[0]),
692                });
693            }
694            if remaining.len() < 2 {
695                return Err(PrincipalParseError::Malformed {
696                    kind: "delegate",
697                    reason: "missing delegate id segment".to_owned(),
698                });
699            }
700            let delegate_id = DelegateId(percent_decode(remaining[1])?);
701            let scope = scopes.next().unwrap_or_else(AuthorityScope::empty);
702            principal = Principal::Delegate {
703                authorizing: Box::new(principal),
704                delegate: delegate_id,
705                scope,
706            };
707            remaining = &remaining[2..];
708        }
709        Ok(principal)
710    }
711}
712
713// --- URI helpers -------------------------------------------------------------
714
715/// Minimal percent-encoder for a single URI path segment. Encodes every
716/// byte except the unreserved set (RFC 3986 §2.3:
717/// `A-Z a-z 0-9 - . _ ~`). No external dep — keeps `cellos-core`'s
718/// dependency surface unchanged.
719fn percent_encode(s: &str) -> String {
720    let mut out = String::with_capacity(s.len());
721    for &b in s.as_bytes() {
722        let ok = b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~');
723        if ok {
724            out.push(b as char);
725        } else {
726            out.push('%');
727            out.push_str(&format!("{:02X}", b));
728        }
729    }
730    out
731}
732
733/// Inverse of [`percent_encode`]. Rejects invalid percent triplets so
734/// admission sees a typed parse error rather than a silent lossy decode.
735fn percent_decode(s: &str) -> Result<String, PrincipalParseError> {
736    let bytes = s.as_bytes();
737    let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
738    let mut i = 0;
739    while i < bytes.len() {
740        if bytes[i] == b'%' {
741            if i + 2 >= bytes.len() {
742                return Err(PrincipalParseError::BadPercentEncoding(s.to_owned()));
743            }
744            let hi = hex_nibble(bytes[i + 1])
745                .ok_or_else(|| PrincipalParseError::BadPercentEncoding(s.to_owned()))?;
746            let lo = hex_nibble(bytes[i + 2])
747                .ok_or_else(|| PrincipalParseError::BadPercentEncoding(s.to_owned()))?;
748            out.push((hi << 4) | lo);
749            i += 3;
750        } else {
751            out.push(bytes[i]);
752            i += 1;
753        }
754    }
755    String::from_utf8(out).map_err(|_| PrincipalParseError::BadPercentEncoding(s.to_owned()))
756}
757
758fn hex_nibble(b: u8) -> Option<u8> {
759    match b {
760        b'0'..=b'9' => Some(b - b'0'),
761        b'a'..=b'f' => Some(10 + b - b'a'),
762        b'A'..=b'F' => Some(10 + b - b'A'),
763        _ => None,
764    }
765}
766
767// --- Tests -------------------------------------------------------------------
768
769#[cfg(test)]
770mod tests {
771    use super::*;
772
773    fn op(id: &str) -> Principal {
774        Principal::Operator {
775            id: OperatorId(id.to_owned()),
776        }
777    }
778
779    fn plat(id: &str) -> Principal {
780        Principal::Platform {
781            id: PlatformId(id.to_owned()),
782        }
783    }
784
785    fn fed_ado(realm: &str, ext: &str) -> Principal {
786        Principal::Federated {
787            trust_root: TrustRoot::Ado {
788                realm: realm.to_owned(),
789            },
790            identity: ExternalId(ext.to_owned()),
791        }
792    }
793
794    #[test]
795    fn operator_round_trip() {
796        let p = op("op-123");
797        let uri = p.to_source_uri();
798        assert_eq!(uri, "principal://operator/op-123");
799        assert_eq!(Principal::from_source_uri(&uri).unwrap(), p);
800    }
801
802    #[test]
803    fn platform_round_trip() {
804        let p = plat("hosted-ctrl-plane-prod");
805        let uri = p.to_source_uri();
806        assert_eq!(uri, "principal://platform/hosted-ctrl-plane-prod");
807        assert_eq!(Principal::from_source_uri(&uri).unwrap(), p);
808    }
809
810    #[test]
811    fn federated_round_trip() {
812        let p = fed_ado("realm-acme", "user-7842");
813        let uri = p.to_source_uri();
814        assert_eq!(
815            uri,
816            "principal://federated/ado/realm-acme/identity/user-7842"
817        );
818        assert_eq!(Principal::from_source_uri(&uri).unwrap(), p);
819    }
820
821    #[test]
822    fn federated_oidc_round_trip_with_url_issuer() {
823        let p = Principal::Federated {
824            trust_root: TrustRoot::Oidc {
825                issuer: "https://login.example.com/".to_owned(),
826            },
827            identity: ExternalId("user-42".to_owned()),
828        };
829        let uri = p.to_source_uri();
830        // The issuer URL is percent-encoded (slashes, colon).
831        assert!(uri.starts_with("principal://federated/oidc/"));
832        assert!(uri.contains("/identity/user-42"));
833        assert_eq!(Principal::from_source_uri(&uri).unwrap(), p);
834    }
835
836    #[test]
837    fn federated_github_round_trip() {
838        let p = Principal::Federated {
839            trust_root: TrustRoot::GitHub {
840                org: "anthropic".to_owned(),
841            },
842            identity: ExternalId("octocat".to_owned()),
843        };
844        let uri = p.to_source_uri();
845        assert_eq!(
846            uri,
847            "principal://federated/github/anthropic/identity/octocat"
848        );
849        assert_eq!(Principal::from_source_uri(&uri).unwrap(), p);
850    }
851
852    #[test]
853    fn delegate_round_trip_one_level() {
854        let scope = AuthorityScope::from_capabilities([Capability::ToolApply, Capability::ToolGet]);
855        let p = Principal::compose(op("op-123"), DelegateId("llm-claude-456".to_owned()), scope)
856            .unwrap();
857        let uri = p.to_source_uri();
858        assert_eq!(
859            uri,
860            "principal://operator/op-123/delegate/llm-claude-456?scope=tool:apply,tool:get"
861        );
862        assert_eq!(Principal::from_source_uri(&uri).unwrap(), p);
863    }
864
865    #[test]
866    fn delegate_round_trip_two_levels() {
867        let outer = Principal::compose(
868            op("op-123"),
869            DelegateId("bridge-A".to_owned()),
870            AuthorityScope::from_capabilities([Capability::ToolApply, Capability::ToolGet]),
871        )
872        .unwrap();
873        let inner = Principal::compose(
874            outer,
875            DelegateId("bridge-B".to_owned()),
876            AuthorityScope::from_capabilities([Capability::ToolGet]),
877        )
878        .unwrap();
879        let uri = inner.to_source_uri();
880        assert_eq!(Principal::from_source_uri(&uri).unwrap(), inner);
881    }
882
883    #[test]
884    fn delegate_with_empty_scope_round_trips() {
885        // An empty scope is degenerate but legal — round-trip must not
886        // append a `?scope=` clause and must not synthesise one on parse.
887        let p = Principal::compose(
888            op("op-123"),
889            DelegateId("noop-bridge".to_owned()),
890            AuthorityScope::empty(),
891        )
892        .unwrap();
893        let uri = p.to_source_uri();
894        assert_eq!(uri, "principal://operator/op-123/delegate/noop-bridge");
895        assert_eq!(Principal::from_source_uri(&uri).unwrap(), p);
896    }
897
898    #[test]
899    fn compose_narrows_scope_returns_ok() {
900        // Authorizing scope is unbounded (root), so any tool capability narrows fine.
901        let p = Principal::compose(
902            op("op-123"),
903            DelegateId("session-1".to_owned()),
904            AuthorityScope::from_capabilities([Capability::ToolApply]),
905        );
906        assert!(p.is_ok());
907    }
908
909    #[test]
910    fn compose_broadens_scope_returns_err() {
911        // Authorizing principal is already a Delegate with a narrow scope;
912        // requesting a broader capability must fail with the missing token named.
913        let narrow = Principal::compose(
914            op("op-123"),
915            DelegateId("session-A".to_owned()),
916            AuthorityScope::from_capabilities([Capability::ToolGet]),
917        )
918        .unwrap();
919        let err = Principal::compose(
920            narrow,
921            DelegateId("session-B".to_owned()),
922            AuthorityScope::from_capabilities([Capability::ToolApply]),
923        )
924        .unwrap_err();
925        assert_eq!(err.missing, Capability::ToolApply);
926    }
927
928    #[test]
929    fn compose_wildcard_authorizes_specific_tool() {
930        let wide = Principal::compose(
931            op("op-123"),
932            DelegateId("session-wild".to_owned()),
933            AuthorityScope::from_capabilities([Capability::ToolWildcard]),
934        )
935        .unwrap();
936        let narrow = Principal::compose(
937            wide,
938            DelegateId("session-narrow".to_owned()),
939            AuthorityScope::from_capabilities([Capability::ToolApply, Capability::ToolEvents]),
940        );
941        assert!(narrow.is_ok());
942    }
943
944    #[test]
945    fn root_operator_finds_human_at_depth() {
946        let inner = Principal::compose(
947            Principal::compose(
948                op("op-human"),
949                DelegateId("d1".to_owned()),
950                AuthorityScope::from_capabilities([Capability::ToolApply]),
951            )
952            .unwrap(),
953            DelegateId("d2".to_owned()),
954            AuthorityScope::from_capabilities([Capability::ToolApply]),
955        )
956        .unwrap();
957        assert_eq!(
958            inner.root_operator(),
959            Some(&OperatorId("op-human".to_owned()))
960        );
961    }
962
963    #[test]
964    fn root_operator_returns_none_for_platform() {
965        assert_eq!(plat("hosted-ctrl-plane-prod").root_operator(), None);
966    }
967
968    #[test]
969    fn root_operator_returns_none_for_federated() {
970        assert_eq!(fed_ado("realm-acme", "user-7842").root_operator(), None);
971    }
972
973    #[test]
974    fn root_operator_returns_none_for_platform_rooted_delegate() {
975        // Platform-rooted chain — `root_operator` must walk through
976        // Delegate boxes and report None when the bottom is non-operator.
977        let p = Principal::compose(
978            plat("hosted-ctrl-plane-prod"),
979            DelegateId("compactor".to_owned()),
980            AuthorityScope::from_capabilities([Capability::ToolApply]),
981        )
982        .unwrap();
983        assert_eq!(p.root_operator(), None);
984    }
985
986    #[test]
987    fn serde_json_round_trip_operator() {
988        let p = op("op-123");
989        let json = serde_json::to_string(&p).unwrap();
990        let back: Principal = serde_json::from_str(&json).unwrap();
991        assert_eq!(back, p);
992    }
993
994    #[test]
995    fn serde_json_round_trip_delegate() {
996        let p = Principal::compose(
997            op("op-123"),
998            DelegateId("session-1".to_owned()),
999            AuthorityScope::from_capabilities([Capability::ToolApply, Capability::ToolGet]),
1000        )
1001        .unwrap();
1002        let json = serde_json::to_string(&p).unwrap();
1003        let back: Principal = serde_json::from_str(&json).unwrap();
1004        assert_eq!(back, p);
1005    }
1006
1007    #[test]
1008    fn from_source_uri_rejects_missing_scheme() {
1009        let err = Principal::from_source_uri("http://operator/op-123").unwrap_err();
1010        assert!(matches!(err, PrincipalParseError::MissingScheme(_)));
1011    }
1012
1013    #[test]
1014    fn from_source_uri_rejects_unknown_kind() {
1015        let err = Principal::from_source_uri("principal://martian/op-123").unwrap_err();
1016        assert!(matches!(err, PrincipalParseError::UnknownKind(_)));
1017    }
1018
1019    #[test]
1020    fn from_source_uri_rejects_unknown_capability() {
1021        let err =
1022            Principal::from_source_uri("principal://operator/op-123/delegate/d?scope=tool:explode")
1023                .unwrap_err();
1024        assert!(matches!(err, PrincipalParseError::UnknownCapability(_)));
1025    }
1026
1027    #[test]
1028    fn percent_encoding_round_trips_slash_in_id() {
1029        // Slashes and other reserved characters must survive round-trip
1030        // because real-world ids (e.g. OIDC subs with paths) contain them.
1031        let p = Principal::Operator {
1032            id: OperatorId("ns/op with space".to_owned()),
1033        };
1034        let uri = p.to_source_uri();
1035        assert_eq!(Principal::from_source_uri(&uri).unwrap(), p);
1036    }
1037}