1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
5#[serde(rename_all = "snake_case")]
6pub enum KeyClass {
7 Secret,
9 Publishable,
11}
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "kebab-case")]
16pub enum TargetKind {
17 Deployment,
19 ProgramReadBinding,
21 SolanaGatewayBinding,
23}
24
25#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
27pub struct Limits {
28 #[serde(skip_serializing_if = "Option::is_none")]
30 pub max_connections: Option<u32>,
31 #[serde(skip_serializing_if = "Option::is_none")]
33 pub max_subscriptions: Option<u32>,
34 #[serde(skip_serializing_if = "Option::is_none")]
36 pub max_snapshot_rows: Option<u32>,
37 #[serde(skip_serializing_if = "Option::is_none")]
39 pub max_messages_per_minute: Option<u32>,
40 #[serde(skip_serializing_if = "Option::is_none")]
42 pub max_bytes_per_minute: Option<u64>,
43 #[serde(skip_serializing_if = "Option::is_none")]
45 pub max_http_requests_per_minute: Option<u32>,
46 #[serde(skip_serializing_if = "Option::is_none")]
48 pub max_http_batch_addresses: Option<u32>,
49 #[serde(skip_serializing_if = "Option::is_none")]
51 pub max_transaction_inspect_requests_per_minute: Option<u32>,
52 #[serde(skip_serializing_if = "Option::is_none")]
54 pub max_transaction_send_requests_per_minute: Option<u32>,
55 #[serde(skip_serializing_if = "Option::is_none")]
57 pub max_transaction_status_requests_per_minute: Option<u32>,
58 #[serde(skip_serializing_if = "Option::is_none")]
60 pub max_transaction_request_bytes: Option<u32>,
61 #[serde(skip_serializing_if = "Option::is_none")]
63 pub max_transaction_bytes: Option<u32>,
64 #[serde(skip_serializing_if = "Option::is_none")]
66 pub max_transaction_concurrency: Option<u32>,
67 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub max_connection_attempts_per_minute: Option<u32>,
70 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub max_subscription_creates_per_minute: Option<u32>,
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct SessionClaims {
78 pub iss: String,
80 pub sub: String,
82 pub aud: String,
84 pub iat: u64,
86 pub nbf: u64,
88 pub exp: u64,
90 pub jti: String,
92 pub scope: String,
94 pub metering_key: String,
96 #[serde(skip_serializing_if = "Option::is_none")]
98 pub deployment_id: Option<String>,
99 #[serde(
101 default,
102 rename = "targetKind",
103 skip_serializing_if = "Option::is_none"
104 )]
105 pub target_kind: Option<TargetKind>,
106 #[serde(default, rename = "targetId", skip_serializing_if = "Option::is_none")]
108 pub target_id: Option<String>,
109 #[serde(default, rename = "programId", skip_serializing_if = "Option::is_none")]
111 pub program_id: Option<String>,
112 #[serde(
114 default,
115 rename = "programReleaseHash",
116 skip_serializing_if = "Option::is_none"
117 )]
118 pub program_release_hash: Option<String>,
119 #[serde(skip_serializing_if = "Option::is_none")]
121 pub origin: Option<String>,
122 #[serde(skip_serializing_if = "Option::is_none", rename = "client_ip")]
124 pub client_ip: Option<String>,
125 #[serde(skip_serializing_if = "Option::is_none")]
127 pub limits: Option<Limits>,
128 #[serde(skip_serializing_if = "Option::is_none")]
130 pub plan: Option<String>,
131 #[serde(rename = "key_class")]
133 pub key_class: KeyClass,
134 #[serde(default, skip_serializing_if = "Option::is_none")]
136 pub actor_key: Option<String>,
137 #[serde(default, skip_serializing_if = "Option::is_none")]
139 pub account_key: Option<String>,
140 #[serde(default, skip_serializing_if = "Option::is_none")]
142 pub consumer_key: Option<String>,
143 #[serde(default, skip_serializing_if = "Option::is_none")]
145 pub policy_version: Option<u32>,
146 #[serde(default, skip_serializing_if = "Option::is_none")]
148 pub account_limits: Option<Limits>,
149}
150
151pub const MAX_POLICY_IDENTITY_BYTES: usize = 512;
153
154pub const PLAN_ANONYMOUS: &str = "anonymous";
156
157fn valid_policy_identity(value: &str) -> bool {
158 !value.is_empty()
159 && value.len() <= MAX_POLICY_IDENTITY_BYTES
160 && value.bytes().all(|byte| {
161 byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':' | b'@' | b'/')
162 })
163}
164
165pub(crate) fn resolve_policy_identity<'a>(explicit: Option<&'a str>, fallback: &'a str) -> &'a str {
166 explicit.unwrap_or(fallback)
167}
168
169#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
171pub enum PolicyClaimsError {
172 #[error(
173 "invalid {0} identity: must be 1-{MAX_POLICY_IDENTITY_BYTES} bytes of [A-Za-z0-9._:@/-]"
174 )]
175 InvalidIdentity(&'static str),
176 #[error("incomplete policy claims: {0}")]
177 IncompleteClaims(&'static str),
178}
179
180impl SessionClaims {
181 pub fn builder(
183 iss: impl Into<String>,
184 sub: impl Into<String>,
185 aud: impl Into<String>,
186 ) -> SessionClaimsBuilder {
187 SessionClaimsBuilder::new(iss, sub, aud)
188 }
189
190 pub fn program_read_builder(
192 iss: impl Into<String>,
193 sub: impl Into<String>,
194 target_id: impl Into<String>,
195 program_id: impl Into<String>,
196 program_release_hash: impl Into<String>,
197 ) -> SessionClaimsBuilder {
198 SessionClaimsBuilder::new(iss, sub, crate::PROGRAM_READ_AUDIENCE).with_program_read_binding(
199 target_id,
200 program_id,
201 program_release_hash,
202 )
203 }
204
205 pub fn solana_gateway_builder(
207 iss: impl Into<String>,
208 sub: impl Into<String>,
209 target_id: impl Into<String>,
210 ) -> SessionClaimsBuilder {
211 SessionClaimsBuilder::new(iss, sub, crate::SOLANA_GATEWAY_AUDIENCE)
212 .with_solana_gateway_binding(target_id)
213 }
214
215 pub fn validate_policy_claims(&self) -> Result<(), PolicyClaimsError> {
225 for (name, value) in [
226 ("actor_key", &self.actor_key),
227 ("account_key", &self.account_key),
228 ("consumer_key", &self.consumer_key),
229 ] {
230 if let Some(value) = value {
231 if !valid_policy_identity(value) {
232 return Err(PolicyClaimsError::InvalidIdentity(name));
233 }
234 }
235 }
236
237 let has_any = self.actor_key.is_some()
238 || self.account_key.is_some()
239 || self.consumer_key.is_some()
240 || self.policy_version.is_some()
241 || self.account_limits.is_some();
242 if !has_any {
243 return Ok(());
244 }
245
246 let has_core = self.actor_key.is_some()
247 && self.consumer_key.is_some()
248 && self.policy_version.is_some();
249 if self.account_key.is_some() || self.account_limits.is_some() {
250 if !has_core || self.account_key.is_none() || self.account_limits.is_none() {
251 return Err(PolicyClaimsError::IncompleteClaims(
252 "account-scoped tokens require actor_key, account_key, consumer_key, \
253 policy_version, and account_limits together",
254 ));
255 }
256 return Ok(());
257 }
258
259 if !has_core {
260 return Err(PolicyClaimsError::IncompleteClaims(
261 "policy claims require actor_key, consumer_key, and policy_version together",
262 ));
263 }
264 if self.plan.as_deref() != Some(PLAN_ANONYMOUS) {
265 return Err(PolicyClaimsError::IncompleteClaims(
266 "tokens without account_key must declare the anonymous plan",
267 ));
268 }
269 Ok(())
270 }
271
272 pub fn is_expired(&self, now: u64) -> bool {
274 self.exp <= now
275 }
276
277 pub fn is_valid(&self, now: u64) -> bool {
279 self.nbf <= now && self.iat <= now
280 }
281}
282
283pub struct SessionClaimsBuilder {
285 iss: String,
286 sub: String,
287 aud: String,
288 iat: u64,
289 nbf: u64,
290 exp: u64,
291 jti: String,
292 scope: String,
293 metering_key: String,
294 deployment_id: Option<String>,
295 target_kind: Option<TargetKind>,
296 target_id: Option<String>,
297 program_id: Option<String>,
298 program_release_hash: Option<String>,
299 origin: Option<String>,
300 client_ip: Option<String>,
301 limits: Option<Limits>,
302 plan: Option<String>,
303 key_class: KeyClass,
304 actor_key: Option<String>,
305 account_key: Option<String>,
306 consumer_key: Option<String>,
307 policy_version: Option<u32>,
308 account_limits: Option<Limits>,
309}
310
311impl SessionClaimsBuilder {
312 fn new(iss: impl Into<String>, sub: impl Into<String>, aud: impl Into<String>) -> Self {
313 use std::time::{SystemTime, UNIX_EPOCH};
314 let now = SystemTime::now()
315 .duration_since(UNIX_EPOCH)
316 .expect("time should not be before epoch")
317 .as_secs();
318
319 Self {
320 iss: iss.into(),
321 sub: sub.into(),
322 aud: aud.into(),
323 iat: now,
324 nbf: now,
325 exp: now + crate::DEFAULT_SESSION_TTL_SECONDS,
326 jti: uuid::Uuid::new_v4().to_string(),
327 scope: crate::SCOPE_READ.to_string(),
328 metering_key: String::new(),
329 deployment_id: None,
330 target_kind: None,
331 target_id: None,
332 program_id: None,
333 program_release_hash: None,
334 origin: None,
335 client_ip: None,
336 limits: None,
337 plan: None,
338 key_class: KeyClass::Publishable,
339 actor_key: None,
340 account_key: None,
341 consumer_key: None,
342 policy_version: None,
343 account_limits: None,
344 }
345 }
346
347 pub fn with_ttl(mut self, ttl_seconds: u64) -> Self {
348 self.exp = self.iat + ttl_seconds;
349 self
350 }
351
352 pub fn with_scope(mut self, scope: impl Into<String>) -> Self {
353 self.scope = scope.into();
354 self
355 }
356
357 pub fn with_metering_key(mut self, key: impl Into<String>) -> Self {
358 self.metering_key = key.into();
359 self
360 }
361
362 pub fn with_deployment_id(mut self, id: impl Into<String>) -> Self {
363 self.deployment_id = Some(id.into());
364 self
365 }
366
367 pub fn with_target(mut self, kind: TargetKind, id: impl Into<String>) -> Self {
369 self.target_kind = Some(kind);
370 self.target_id = Some(id.into());
371 self
372 }
373
374 pub fn with_program_id(mut self, program_id: impl Into<String>) -> Self {
376 self.program_id = Some(program_id.into());
377 self
378 }
379
380 pub fn with_program_release_hash(mut self, hash: impl Into<String>) -> Self {
382 self.program_release_hash = Some(hash.into());
383 self
384 }
385
386 pub fn with_program_read_binding(
388 mut self,
389 target_id: impl Into<String>,
390 program_id: impl Into<String>,
391 program_release_hash: impl Into<String>,
392 ) -> Self {
393 self.aud = crate::PROGRAM_READ_AUDIENCE.to_string();
394 self.scope = crate::SCOPE_READ.to_string();
395 self.target_kind = Some(TargetKind::ProgramReadBinding);
396 self.target_id = Some(target_id.into());
397 self.program_id = Some(program_id.into());
398 self.program_release_hash = Some(program_release_hash.into());
399 self
400 }
401
402 pub fn with_solana_gateway_binding(mut self, target_id: impl Into<String>) -> Self {
404 self.aud = crate::SOLANA_GATEWAY_AUDIENCE.to_string();
405 self.scope = crate::SCOPE_READ.to_string();
406 self.target_kind = Some(TargetKind::SolanaGatewayBinding);
407 self.target_id = Some(target_id.into());
408 self
409 }
410
411 pub fn with_origin(mut self, origin: impl Into<String>) -> Self {
412 self.origin = Some(origin.into());
413 self
414 }
415
416 pub fn with_client_ip(mut self, client_ip: impl Into<String>) -> Self {
417 self.client_ip = Some(client_ip.into());
418 self
419 }
420
421 pub fn with_limits(mut self, limits: Limits) -> Self {
422 self.limits = Some(limits);
423 self
424 }
425
426 pub fn with_plan(mut self, plan: impl Into<String>) -> Self {
427 self.plan = Some(plan.into());
428 self
429 }
430
431 pub fn with_key_class(mut self, key_class: KeyClass) -> Self {
432 self.key_class = key_class;
433 self
434 }
435
436 pub fn with_jti(mut self, jti: impl Into<String>) -> Self {
437 self.jti = jti.into();
438 self
439 }
440
441 pub fn with_actor_key(mut self, actor_key: impl Into<String>) -> Self {
443 self.actor_key = Some(actor_key.into());
444 self
445 }
446
447 pub fn with_account_key(mut self, account_key: impl Into<String>) -> Self {
449 self.account_key = Some(account_key.into());
450 self
451 }
452
453 pub fn with_consumer_key(mut self, consumer_key: impl Into<String>) -> Self {
455 self.consumer_key = Some(consumer_key.into());
456 self
457 }
458
459 pub fn with_policy_version(mut self, policy_version: u32) -> Self {
461 self.policy_version = Some(policy_version);
462 self
463 }
464
465 pub fn with_account_limits(mut self, account_limits: Limits) -> Self {
467 self.account_limits = Some(account_limits);
468 self
469 }
470
471 pub fn build(self) -> SessionClaims {
472 SessionClaims {
473 iss: self.iss,
474 sub: self.sub,
475 aud: self.aud,
476 iat: self.iat,
477 nbf: self.nbf,
478 exp: self.exp,
479 jti: self.jti,
480 scope: self.scope,
481 metering_key: self.metering_key,
482 deployment_id: self.deployment_id,
483 target_kind: self.target_kind,
484 target_id: self.target_id,
485 program_id: self.program_id,
486 program_release_hash: self.program_release_hash,
487 origin: self.origin,
488 client_ip: self.client_ip,
489 limits: self.limits,
490 plan: self.plan,
491 key_class: self.key_class,
492 actor_key: self.actor_key,
493 account_key: self.account_key,
494 consumer_key: self.consumer_key,
495 policy_version: self.policy_version,
496 account_limits: self.account_limits,
497 }
498 }
499}
500
501#[derive(Debug, Clone)]
503pub struct AuthContext {
504 pub subject: String,
506 pub issuer: String,
508 pub audience: String,
510 pub key_class: KeyClass,
512 pub metering_key: String,
514 pub deployment_id: Option<String>,
516 pub target_kind: Option<TargetKind>,
518 pub target_id: Option<String>,
520 pub program_id: Option<String>,
522 pub program_release_hash: Option<String>,
524 pub expires_at: u64,
526 pub scope: String,
528 pub limits: Limits,
530 pub plan: Option<String>,
532 pub origin: Option<String>,
534 pub client_ip: Option<String>,
536 pub jti: String,
538 pub actor_key: Option<String>,
540 pub account_key: Option<String>,
542 pub consumer_key: Option<String>,
544 pub policy_version: Option<u32>,
546 pub account_limits: Limits,
548}
549
550impl AuthContext {
551 pub fn has_scope(&self, required: &str) -> bool {
553 self.scope.split_whitespace().any(|scope| scope == required)
554 }
555
556 pub fn actor_key(&self) -> &str {
558 resolve_policy_identity(self.actor_key.as_deref(), &self.subject)
559 }
560
561 pub fn consumer_key(&self) -> &str {
563 resolve_policy_identity(self.consumer_key.as_deref(), &self.subject)
564 }
565
566 pub fn account_key(&self) -> &str {
569 resolve_policy_identity(self.account_key.as_deref(), &self.metering_key)
570 }
571
572 pub fn is_legacy_policy(&self) -> bool {
575 self.actor_key.is_none()
576 && self.account_key.is_none()
577 && self.consumer_key.is_none()
578 && self.policy_version.is_none()
579 }
580
581 pub fn from_claims(claims: SessionClaims) -> Self {
583 Self {
584 subject: claims.sub,
585 issuer: claims.iss,
586 audience: claims.aud,
587 key_class: claims.key_class,
588 metering_key: claims.metering_key,
589 deployment_id: claims.deployment_id,
590 target_kind: claims.target_kind,
591 target_id: claims.target_id,
592 program_id: claims.program_id,
593 program_release_hash: claims.program_release_hash,
594 expires_at: claims.exp,
595 scope: claims.scope,
596 limits: claims.limits.unwrap_or_default(),
597 plan: claims.plan,
598 origin: claims.origin,
599 client_ip: claims.client_ip,
600 jti: claims.jti,
601 actor_key: claims.actor_key,
602 account_key: claims.account_key,
603 consumer_key: claims.consumer_key,
604 policy_version: claims.policy_version,
605 account_limits: claims.account_limits.unwrap_or_default(),
606 }
607 }
608}
609
610#[cfg(test)]
611mod tests {
612 use super::*;
613
614 #[test]
615 fn scopes_are_exact_and_independent() {
616 let context = AuthContext::from_claims(
617 SessionClaims::builder("issuer", "subject", "audience")
618 .with_scope("read transaction:inspect transaction:send-extra")
619 .build(),
620 );
621
622 assert!(context.has_scope("read"));
623 assert!(context.has_scope("transaction:inspect"));
624 assert!(!context.has_scope("transaction:send"));
625 assert!(!context.has_scope("transaction"));
626 }
627
628 #[test]
629 fn old_limits_claims_remain_deserializable() {
630 let limits: Limits = serde_json::from_value(serde_json::json!({
631 "max_connections": 2
632 }))
633 .unwrap();
634
635 assert_eq!(limits.max_connections, Some(2));
636 assert_eq!(limits.max_transaction_bytes, None);
637 }
638
639 #[test]
640 fn transaction_limits_round_trip_additively() {
641 let limits = Limits {
642 max_transaction_inspect_requests_per_minute: Some(120),
643 max_transaction_send_requests_per_minute: Some(12),
644 max_transaction_status_requests_per_minute: Some(240),
645 max_transaction_request_bytes: Some(4096),
646 max_transaction_bytes: Some(1232),
647 max_transaction_concurrency: Some(4),
648 ..Limits::default()
649 };
650 let value = serde_json::to_value(&limits).unwrap();
651 let decoded: Limits = serde_json::from_value(value).unwrap();
652
653 assert_eq!(decoded.max_transaction_bytes, Some(1232));
654 assert_eq!(decoded.max_transaction_concurrency, Some(4));
655 }
656
657 #[test]
658 fn program_read_claims_use_camel_case_fields() {
659 let claims = SessionClaims::program_read_builder(
660 "issuer",
661 "subject",
662 "binding-1",
663 "program-1",
664 "release-1",
665 )
666 .build();
667 let value = serde_json::to_value(claims).unwrap();
668
669 assert_eq!(value["aud"], crate::PROGRAM_READ_AUDIENCE);
670 assert_eq!(value["targetKind"], "program-read-binding");
671 assert_eq!(value["targetId"], "binding-1");
672 assert_eq!(value["programId"], "program-1");
673 assert_eq!(value["programReleaseHash"], "release-1");
674 assert!(value.get("target_kind").is_none());
675 }
676
677 #[test]
678 fn gateway_claims_use_stable_audience_target_and_default_scope() {
679 let claims =
680 SessionClaims::solana_gateway_builder("issuer", "subject", "gateway-us-east-1").build();
681 let value = serde_json::to_value(claims).unwrap();
682
683 assert_eq!(value["aud"], crate::SOLANA_GATEWAY_AUDIENCE);
684 assert_eq!(value["targetKind"], "solana-gateway-binding");
685 assert_eq!(value["targetId"], "gateway-us-east-1");
686 assert_eq!(value["scope"], crate::SCOPE_READ);
687 }
688
689 fn account_limits() -> Limits {
690 Limits {
691 max_connections: Some(50),
692 max_messages_per_minute: Some(50_000),
693 max_connection_attempts_per_minute: Some(600),
694 max_subscription_creates_per_minute: Some(1_200),
695 ..Limits::default()
696 }
697 }
698
699 fn v2_builder() -> SessionClaimsBuilder {
700 SessionClaims::builder("issuer", "user:1", "deployment-1")
701 .with_metering_key("account:42")
702 .with_plan("pro")
703 .with_actor_key("user:1")
704 .with_account_key("account:42")
705 .with_consumer_key("consumer:abc123")
706 .with_policy_version(7)
707 .with_account_limits(account_limits())
708 }
709
710 #[test]
711 fn golden_old_token_json_still_deserializes_and_resolves() {
712 let claims: SessionClaims = serde_json::from_value(serde_json::json!({
714 "iss": "issuer",
715 "sub": "user:1",
716 "aud": "deployment-1",
717 "iat": 1, "nbf": 1, "exp": 2, "jti": "jti-1",
718 "scope": "read",
719 "metering_key": "api_key:42",
720 "limits": { "max_connections": 2 },
721 "plan": "starter",
722 "key_class": "publishable"
723 }))
724 .unwrap();
725
726 claims.validate_policy_claims().unwrap();
727 let context = AuthContext::from_claims(claims);
728
729 assert!(context.is_legacy_policy());
730 assert_eq!(context.actor_key(), "user:1");
731 assert_eq!(context.consumer_key(), "user:1");
732 assert_eq!(context.account_key(), "api_key:42");
733 assert_eq!(context.account_limits, Limits::default());
734 assert_eq!(context.limits.max_connections, Some(2));
735 }
736
737 #[test]
738 fn tokens_built_without_new_methods_serialize_to_the_old_shape() {
739 let claims = SessionClaims::builder("issuer", "user:1", "deployment-1")
740 .with_metering_key("api_key:42")
741 .build();
742 let value = serde_json::to_value(claims).unwrap();
743
744 for absent in [
745 "actor_key",
746 "account_key",
747 "consumer_key",
748 "policy_version",
749 "account_limits",
750 ] {
751 assert!(value.get(absent).is_none(), "{absent} must be absent");
752 }
753 }
754
755 #[test]
756 fn v2_claims_round_trip_with_exact_snake_case_wire_keys() {
757 let claims = v2_builder().build();
758 claims.validate_policy_claims().unwrap();
759 let value = serde_json::to_value(&claims).unwrap();
760
761 assert_eq!(value["actor_key"], "user:1");
762 assert_eq!(value["account_key"], "account:42");
763 assert_eq!(value["consumer_key"], "consumer:abc123");
764 assert_eq!(value["policy_version"], 7);
765 assert_eq!(value["account_limits"]["max_connections"], 50);
766 assert_eq!(
767 value["account_limits"]["max_connection_attempts_per_minute"],
768 600
769 );
770 assert_eq!(
771 value["account_limits"]["max_subscription_creates_per_minute"],
772 1200
773 );
774
775 let decoded: SessionClaims = serde_json::from_value(value).unwrap();
776 assert_eq!(decoded.actor_key.as_deref(), Some("user:1"));
777 assert_eq!(decoded.policy_version, Some(7));
778 assert_eq!(decoded.account_limits, Some(account_limits()));
779
780 let context = AuthContext::from_claims(decoded);
781 assert!(!context.is_legacy_policy());
782 assert_eq!(context.actor_key(), "user:1");
783 assert_eq!(context.consumer_key(), "consumer:abc123");
784 assert_eq!(context.account_key(), "account:42");
785 assert_eq!(context.policy_version, Some(7));
786 assert_eq!(context.account_limits, account_limits());
787 }
788
789 #[test]
790 fn anonymous_v2_claims_require_the_anonymous_plan_and_no_account() {
791 let anonymous = SessionClaims::builder("issuer", "anon:ip-1", "deployment-1")
792 .with_metering_key("anon:ip-1")
793 .with_plan(PLAN_ANONYMOUS)
794 .with_actor_key("anon:ip-1")
795 .with_consumer_key("consumer:abc123")
796 .with_policy_version(3)
797 .build();
798 anonymous.validate_policy_claims().unwrap();
799
800 let wrong_plan = SessionClaims::builder("issuer", "anon:ip-1", "deployment-1")
801 .with_plan("pro")
802 .with_actor_key("anon:ip-1")
803 .with_consumer_key("consumer:abc123")
804 .with_policy_version(3)
805 .build();
806 assert!(matches!(
807 wrong_plan.validate_policy_claims(),
808 Err(PolicyClaimsError::IncompleteClaims(_))
809 ));
810 }
811
812 #[test]
813 fn partial_v2_identity_subsets_are_rejected() {
814 let missing_consumer = SessionClaims::builder("issuer", "user:1", "deployment-1")
816 .with_actor_key("user:1")
817 .with_account_key("account:42")
818 .with_policy_version(1)
819 .with_account_limits(Limits::default())
820 .build();
821 assert!(matches!(
822 missing_consumer.validate_policy_claims(),
823 Err(PolicyClaimsError::IncompleteClaims(_))
824 ));
825
826 let limits_only = SessionClaims::builder("issuer", "user:1", "deployment-1")
828 .with_account_limits(Limits::default())
829 .build();
830 assert!(limits_only.validate_policy_claims().is_err());
831
832 let version_only = SessionClaims::builder("issuer", "user:1", "deployment-1")
834 .with_policy_version(1)
835 .build();
836 assert!(version_only.validate_policy_claims().is_err());
837
838 let missing_limits = SessionClaims::builder("issuer", "user:1", "deployment-1")
840 .with_actor_key("user:1")
841 .with_account_key("account:42")
842 .with_consumer_key("consumer:abc123")
843 .with_policy_version(1)
844 .build();
845 assert!(missing_limits.validate_policy_claims().is_err());
846 }
847
848 #[test]
849 fn malformed_or_oversized_identities_are_rejected() {
850 let empty = v2_builder().with_consumer_key("").build();
851 assert_eq!(
852 empty.validate_policy_claims(),
853 Err(PolicyClaimsError::InvalidIdentity("consumer_key"))
854 );
855
856 let oversized = v2_builder()
857 .with_account_key("a".repeat(MAX_POLICY_IDENTITY_BYTES + 1))
858 .build();
859 assert_eq!(
860 oversized.validate_policy_claims(),
861 Err(PolicyClaimsError::InvalidIdentity("account_key"))
862 );
863
864 let bad_charset = v2_builder().with_actor_key("user 1\n").build();
865 assert_eq!(
866 bad_charset.validate_policy_claims(),
867 Err(PolicyClaimsError::InvalidIdentity("actor_key"))
868 );
869
870 let boundary = v2_builder()
871 .with_account_key("a".repeat(MAX_POLICY_IDENTITY_BYTES))
872 .build();
873 assert!(boundary.validate_policy_claims().is_ok());
874 }
875
876 #[test]
877 fn legacy_deployment_claims_remain_untyped() {
878 let claims = SessionClaims::builder("issuer", "subject", "deployment-1")
879 .with_deployment_id("deployment-1")
880 .build();
881 let value = serde_json::to_value(&claims).unwrap();
882 let decoded: SessionClaims = serde_json::from_value(value.clone()).unwrap();
883
884 assert_eq!(decoded.deployment_id.as_deref(), Some("deployment-1"));
885 assert_eq!(decoded.target_kind, None);
886 assert!(value.get("targetKind").is_none());
887 }
888}