1use serde::{Deserialize, Serialize};
25
26use crate::principal::{PrincipalId, PrincipalKind};
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
34#[non_exhaustive]
35pub enum Scope {
36 ArchRead,
37 ArchWrite,
38 BoardRead,
39 BoardWrite,
40 CampRead,
41 CampAdmin,
42 CloudRead,
43 CloudDeploy,
44 CloudDestroy,
45 CloudAdmin,
50 PartyRead,
51 PartyWrite,
52 SubagentSpawn,
53 SubagentControl,
54 OwnershipWrite,
56 AuditRead,
57 AuditWrite,
59}
60
61impl Scope {
62 pub const fn as_wire(self) -> &'static str {
64 match self {
65 Self::ArchRead => "arch:read",
66 Self::ArchWrite => "arch:write",
67 Self::BoardRead => "board:read",
68 Self::BoardWrite => "board:write",
69 Self::CampRead => "camp:read",
70 Self::CampAdmin => "camp:admin",
71 Self::CloudRead => "cloud:read",
72 Self::CloudDeploy => "cloud:deploy",
73 Self::CloudDestroy => "cloud:destroy",
74 Self::CloudAdmin => "cloud:admin",
75 Self::PartyRead => "party:read",
76 Self::PartyWrite => "party:write",
77 Self::SubagentSpawn => "subagent:spawn",
78 Self::SubagentControl => "subagent:control",
79 Self::OwnershipWrite => "ownership:write",
80 Self::AuditRead => "audit:read",
81 Self::AuditWrite => "audit:write",
82 }
83 }
84
85 pub const fn is_service_only(self) -> bool {
88 matches!(self, Self::OwnershipWrite | Self::AuditWrite)
89 }
90
91 pub const ALL: &'static [Scope] = &[
102 Self::ArchRead,
103 Self::ArchWrite,
104 Self::BoardRead,
105 Self::BoardWrite,
106 Self::CampRead,
107 Self::CampAdmin,
108 Self::CloudRead,
109 Self::CloudDeploy,
110 Self::CloudDestroy,
111 Self::CloudAdmin,
112 Self::PartyRead,
113 Self::PartyWrite,
114 Self::SubagentSpawn,
115 Self::SubagentControl,
116 Self::OwnershipWrite,
117 Self::AuditRead,
118 Self::AuditWrite,
119 ];
120}
121
122impl std::fmt::Display for Scope {
123 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
124 f.write_str(self.as_wire())
125 }
126}
127
128#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
130pub enum ScopeParseError {
131 #[error("wildcard scope '{0}' is not allowed on the wire")]
134 Wildcard(String),
135 #[error("unknown scope '{0}'")]
137 Unknown(String),
138}
139
140impl std::str::FromStr for Scope {
141 type Err = ScopeParseError;
142
143 fn from_str(s: &str) -> Result<Self, Self::Err> {
144 if s.contains('*') {
145 return Err(ScopeParseError::Wildcard(s.to_owned()));
146 }
147 Ok(match s {
148 "arch:read" => Self::ArchRead,
149 "arch:write" => Self::ArchWrite,
150 "board:read" => Self::BoardRead,
151 "board:write" => Self::BoardWrite,
152 "camp:read" => Self::CampRead,
153 "camp:admin" => Self::CampAdmin,
154 "cloud:read" => Self::CloudRead,
155 "cloud:deploy" => Self::CloudDeploy,
156 "cloud:destroy" => Self::CloudDestroy,
157 "cloud:admin" => Self::CloudAdmin,
158 "party:read" => Self::PartyRead,
159 "party:write" => Self::PartyWrite,
160 "subagent:spawn" => Self::SubagentSpawn,
161 "subagent:control" => Self::SubagentControl,
162 "ownership:write" => Self::OwnershipWrite,
163 "audit:read" => Self::AuditRead,
164 "audit:write" => Self::AuditWrite,
165 other => return Err(ScopeParseError::Unknown(other.to_owned())),
166 })
167 }
168}
169
170impl Serialize for Scope {
171 fn serialize<S: serde::Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
172 ser.serialize_str(self.as_wire())
173 }
174}
175
176impl<'de> Deserialize<'de> for Scope {
177 fn deserialize<D: serde::Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
178 let s = String::deserialize(de)?;
179 s.parse().map_err(serde::de::Error::custom)
180 }
181}
182
183#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
185pub enum GrantError {
186 #[error("scope {scope} is service-only; cannot grant to {kind} principal")]
189 ServiceOnlyScope { scope: Scope, kind: PrincipalKind },
190}
191
192pub fn validate_grant(kind: PrincipalKind, scope: Scope) -> Result<(), GrantError> {
206 if scope.is_service_only() && kind != PrincipalKind::Service {
207 return Err(GrantError::ServiceOnlyScope { scope, kind });
208 }
209 Ok(())
210}
211
212#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
218#[non_exhaustive]
219pub struct Actor {
220 pub sub: PrincipalId,
221}
222
223impl Actor {
224 pub fn new(sub: PrincipalId) -> Self {
225 Self { sub }
226 }
227}
228
229#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
243#[non_exhaustive]
244pub struct Owns {
245 #[serde(default, skip_serializing_if = "Vec::is_empty")]
246 pub service: Vec<String>,
247 #[serde(default, skip_serializing_if = "Vec::is_empty")]
248 pub arch_doc: Vec<String>,
249 #[serde(default, skip_serializing_if = "Vec::is_empty")]
251 pub node: Vec<String>,
252 #[serde(flatten)]
254 pub extra: std::collections::BTreeMap<String, Vec<String>>,
255}
256
257impl Owns {
258 pub fn is_empty(&self) -> bool {
259 self.service.is_empty()
260 && self.arch_doc.is_empty()
261 && self.node.is_empty()
262 && self.extra.is_empty()
263 }
264}
265
266#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
273#[non_exhaustive]
274#[serde(rename_all = "kebab-case")]
275pub enum AuthStrength {
276 Bootstrap,
277 UserFresh,
278}
279
280#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
287#[non_exhaustive]
288pub struct McpClaims {
289 pub iss: String,
290 pub aud: String,
291 pub sub: PrincipalId,
292 pub iat: i64,
293 pub exp: i64,
294 pub jti: String,
295 pub scope: Vec<Scope>,
296
297 #[serde(default, skip_serializing_if = "Option::is_none")]
298 pub act: Option<Actor>,
299 #[serde(default, skip_serializing_if = "Option::is_none")]
300 pub camp_id: Option<String>,
301 #[serde(default, skip_serializing_if = "Owns::is_empty")]
302 pub owns: Owns,
303 #[serde(default, skip_serializing_if = "Option::is_none")]
304 pub auth_strength: Option<AuthStrength>,
305}
306
307impl McpClaims {
308 pub fn new(
310 iss: impl Into<String>,
311 aud: impl Into<String>,
312 sub: PrincipalId,
313 iat: i64,
314 exp: i64,
315 jti: impl Into<String>,
316 scope: Vec<Scope>,
317 ) -> Self {
318 Self {
319 iss: iss.into(),
320 aud: aud.into(),
321 sub,
322 iat,
323 exp,
324 jti: jti.into(),
325 scope,
326 act: None,
327 camp_id: None,
328 owns: Owns::default(),
329 auth_strength: None,
330 }
331 }
332
333 pub fn with_act(mut self, act: Actor) -> Self {
334 self.act = Some(act);
335 self
336 }
337
338 pub fn with_camp_id(mut self, camp_id: impl Into<String>) -> Self {
339 self.camp_id = Some(camp_id.into());
340 self
341 }
342
343 pub fn with_owns(mut self, owns: Owns) -> Self {
344 self.owns = owns;
345 self
346 }
347
348 pub fn with_auth_strength(mut self, strength: AuthStrength) -> Self {
349 self.auth_strength = Some(strength);
350 self
351 }
352
353 pub fn is_expired_at(&self, now: i64) -> bool {
356 self.exp <= now
357 }
358}
359
360#[cfg(test)]
361mod tests {
362 use super::*;
363 use std::str::FromStr;
364
365 #[test]
366 fn scope_wire_string_roundtrips_for_every_variant() {
367 let all = [
369 Scope::ArchRead,
370 Scope::ArchWrite,
371 Scope::BoardRead,
372 Scope::BoardWrite,
373 Scope::CampRead,
374 Scope::CampAdmin,
375 Scope::CloudRead,
376 Scope::CloudDeploy,
377 Scope::CloudDestroy,
378 Scope::CloudAdmin,
379 Scope::PartyRead,
380 Scope::PartyWrite,
381 Scope::SubagentSpawn,
382 Scope::SubagentControl,
383 Scope::OwnershipWrite,
384 Scope::AuditRead,
385 Scope::AuditWrite,
386 ];
387 for s in all {
388 let wire = s.as_wire();
389 assert_eq!(Scope::from_str(wire).unwrap(), s, "roundtrip failed for {wire}");
390 assert!(wire.contains(':'), "wire form must contain ':' — {wire}");
391 }
392 }
393
394 #[test]
395 fn scope_all_is_exhaustive() {
396 fn assert_in_all(s: Scope) {
401 let in_all = match s {
402 Scope::ArchRead => Scope::ALL.contains(&Scope::ArchRead),
403 Scope::ArchWrite => Scope::ALL.contains(&Scope::ArchWrite),
404 Scope::BoardRead => Scope::ALL.contains(&Scope::BoardRead),
405 Scope::BoardWrite => Scope::ALL.contains(&Scope::BoardWrite),
406 Scope::CampRead => Scope::ALL.contains(&Scope::CampRead),
407 Scope::CampAdmin => Scope::ALL.contains(&Scope::CampAdmin),
408 Scope::CloudRead => Scope::ALL.contains(&Scope::CloudRead),
409 Scope::CloudDeploy => Scope::ALL.contains(&Scope::CloudDeploy),
410 Scope::CloudDestroy => Scope::ALL.contains(&Scope::CloudDestroy),
411 Scope::CloudAdmin => Scope::ALL.contains(&Scope::CloudAdmin),
412 Scope::PartyRead => Scope::ALL.contains(&Scope::PartyRead),
413 Scope::PartyWrite => Scope::ALL.contains(&Scope::PartyWrite),
414 Scope::SubagentSpawn => Scope::ALL.contains(&Scope::SubagentSpawn),
415 Scope::SubagentControl => Scope::ALL.contains(&Scope::SubagentControl),
416 Scope::OwnershipWrite => Scope::ALL.contains(&Scope::OwnershipWrite),
417 Scope::AuditRead => Scope::ALL.contains(&Scope::AuditRead),
418 Scope::AuditWrite => Scope::ALL.contains(&Scope::AuditWrite),
419 };
420 assert!(in_all, "{s} reachable in match but missing from Scope::ALL");
421 }
422 for s in Scope::ALL {
423 assert_in_all(*s);
424 }
425 }
426
427 #[test]
428 fn scope_parser_rejects_wildcards() {
429 for w in ["cloud:*", "*", "*:read", "ownership:*"] {
430 let err = Scope::from_str(w).unwrap_err();
431 assert!(
432 matches!(err, ScopeParseError::Wildcard(ref s) if s == w),
433 "{w}: expected Wildcard, got {err:?}"
434 );
435 }
436 }
437
438 #[test]
439 fn scope_parser_rejects_unknown_literals() {
440 let err = Scope::from_str("cloud:nuke").unwrap_err();
441 assert!(matches!(err, ScopeParseError::Unknown(ref s) if s == "cloud:nuke"));
442 }
443
444 #[test]
445 fn scope_serialize_is_plain_string() {
446 let v = vec![Scope::CloudDeploy, Scope::CloudRead];
447 let json = serde_json::to_string(&v).unwrap();
448 assert_eq!(json, r#"["cloud:deploy","cloud:read"]"#);
449 let back: Vec<Scope> = serde_json::from_str(&json).unwrap();
450 assert_eq!(back, v);
451 }
452
453 #[test]
454 fn scope_deserialize_rejects_wildcard_in_list() {
455 let err = serde_json::from_str::<Vec<Scope>>(r#"["cloud:read","cloud:*"]"#).unwrap_err();
456 assert!(
457 err.to_string().contains("wildcard"),
458 "expected wildcard message, got: {err}"
459 );
460 }
461
462 #[test]
463 fn validate_grant_rejects_service_only_for_user() {
464 let err = validate_grant(PrincipalKind::User, Scope::OwnershipWrite).unwrap_err();
465 assert_eq!(
466 err,
467 GrantError::ServiceOnlyScope {
468 scope: Scope::OwnershipWrite,
469 kind: PrincipalKind::User,
470 }
471 );
472
473 let err = validate_grant(PrincipalKind::User, Scope::AuditWrite).unwrap_err();
474 assert_eq!(
475 err,
476 GrantError::ServiceOnlyScope {
477 scope: Scope::AuditWrite,
478 kind: PrincipalKind::User,
479 }
480 );
481 }
482
483 #[test]
484 fn validate_grant_rejects_service_only_for_camp() {
485 let err = validate_grant(PrincipalKind::Camp, Scope::OwnershipWrite).unwrap_err();
486 assert!(matches!(
487 err,
488 GrantError::ServiceOnlyScope {
489 scope: Scope::OwnershipWrite,
490 kind: PrincipalKind::Camp,
491 }
492 ));
493 }
494
495 #[test]
496 fn validate_grant_allows_service_principal_for_service_only_scopes() {
497 validate_grant(PrincipalKind::Service, Scope::OwnershipWrite).unwrap();
498 validate_grant(PrincipalKind::Service, Scope::AuditWrite).unwrap();
499 }
500
501 #[test]
502 fn validate_grant_allows_normal_scopes_for_any_principal() {
503 for k in [
504 PrincipalKind::User,
505 PrincipalKind::Service,
506 PrincipalKind::Camp,
507 ] {
508 for s in [
509 Scope::ArchRead,
510 Scope::CloudDeploy,
511 Scope::CampAdmin,
512 Scope::AuditRead,
513 ] {
514 validate_grant(k, s).unwrap();
515 }
516 }
517 }
518
519 #[test]
520 fn camp_admin_is_distinct_from_camp_read_and_camp_write() {
521 assert_ne!(Scope::CampAdmin, Scope::CampRead);
524 assert!(Scope::from_str("camp:write").is_err());
527 }
528
529 #[test]
530 fn auth_strength_serializes_kebab_case() {
531 assert_eq!(serde_json::to_string(&AuthStrength::Bootstrap).unwrap(), "\"bootstrap\"");
532 assert_eq!(serde_json::to_string(&AuthStrength::UserFresh).unwrap(), "\"user-fresh\"");
533 let back: AuthStrength = serde_json::from_str("\"user-fresh\"").unwrap();
534 assert_eq!(back, AuthStrength::UserFresh);
535 }
536
537 #[test]
538 fn owns_omits_empty_lists_on_wire_but_roundtrips() {
539 let o = Owns::default();
540 let json = serde_json::to_string(&o).unwrap();
541 assert_eq!(json, "{}");
542
543 let o = Owns {
544 service: vec!["svc-a".into()],
545 arch_doc: vec![],
546 node: vec![],
547 extra: Default::default(),
548 };
549 let json = serde_json::to_string(&o).unwrap();
550 assert_eq!(json, r#"{"service":["svc-a"]}"#);
551 let back: Owns = serde_json::from_str(&json).unwrap();
552 assert_eq!(back, o);
553 }
554
555 #[test]
556 fn owns_extra_carries_unknown_resource_kinds() {
557 let json = r#"{"service":["s1"],"pond":["p1","p2"]}"#;
558 let o: Owns = serde_json::from_str(json).unwrap();
559 assert_eq!(o.service, vec!["s1".to_string()]);
560 assert_eq!(o.extra.get("pond"), Some(&vec!["p1".into(), "p2".into()]));
561
562 let back = serde_json::to_string(&o).unwrap();
564 assert!(back.contains(r#""pond":["p1","p2"]"#));
565 }
566
567 fn sample_claims() -> McpClaims {
568 McpClaims::new(
569 "https://cheers.example",
570 "https://kamaji.camp.example",
571 PrincipalId::user("alice"),
572 1000,
573 1300,
574 "jti-1",
575 vec![Scope::CloudDeploy, Scope::CloudRead],
576 )
577 .with_act(Actor::new(PrincipalId::service("agent-claude")))
578 .with_camp_id("camp-xyz")
579 .with_owns(Owns {
580 service: vec!["svc-a".into()],
581 arch_doc: vec!["doc-1".into()],
582 node: vec![],
583 extra: Default::default(),
584 })
585 .with_auth_strength(AuthStrength::UserFresh)
586 }
587
588 #[test]
589 fn mcp_claims_roundtrip_full_shape() {
590 let c = sample_claims();
591 let json = serde_json::to_string(&c).unwrap();
592 assert!(json.contains(r#""sub":"user:alice""#));
594 assert!(json.contains(r#""act":{"sub":"svc:agent-claude"}"#));
595 assert!(json.contains(r#""camp_id":"camp-xyz""#));
596 assert!(json.contains(r#""auth_strength":"user-fresh""#));
597 assert!(json.contains(r#""scope":["cloud:deploy","cloud:read"]"#));
598 let back: McpClaims = serde_json::from_str(&json).unwrap();
599 assert_eq!(back, c);
600 }
601
602 #[test]
603 fn mcp_claims_minimal_shape_omits_optionals() {
604 let c = McpClaims::new(
605 "iss",
606 "aud",
607 PrincipalId::service("yubaba"),
608 1000,
609 1300,
610 "jti-2",
611 vec![Scope::OwnershipWrite],
612 );
613 let json = serde_json::to_string(&c).unwrap();
614 for absent in ["\"act\"", "\"camp_id\"", "\"owns\"", "\"auth_strength\""] {
615 assert!(
616 !json.contains(absent),
617 "{absent} must be omitted when unset: {json}"
618 );
619 }
620 let back: McpClaims = serde_json::from_str(&json).unwrap();
621 assert_eq!(back, c);
622 }
623
624 #[test]
625 fn mcp_claims_expiry_check() {
626 let c = sample_claims();
627 assert!(!c.is_expired_at(1299));
628 assert!(c.is_expired_at(1300));
629 assert!(c.is_expired_at(1301));
630 }
631
632 #[test]
633 fn mcp_claims_deserialize_rejects_unprefixed_sub() {
634 let json = r#"{"iss":"i","aud":"a","sub":"alice","iat":1,"exp":2,"jti":"j","scope":[]}"#;
635 let err = serde_json::from_str::<McpClaims>(json).unwrap_err();
636 assert!(
637 err.to_string().contains("must be prefixed"),
638 "expected prefix-required error: {err}"
639 );
640 }
641
642 #[test]
643 fn mcp_claims_deserialize_rejects_wildcard_scope() {
644 let json = r#"{"iss":"i","aud":"a","sub":"user:alice","iat":1,"exp":2,"jti":"j","scope":["cloud:*"]}"#;
645 let err = serde_json::from_str::<McpClaims>(json).unwrap_err();
646 assert!(err.to_string().contains("wildcard"), "expected wildcard rejection: {err}");
647 }
648}