alien_core/
sandbox_capability.rs1use serde::{Deserialize, Serialize};
10
11use crate::error::{ErrorData, Result};
12use alien_error::AlienError;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
17#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
18#[serde(rename_all = "camelCase")]
19pub enum SandboxOperationClass {
20 Execute,
22 Manage,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
29#[serde(rename_all = "camelCase")]
30pub struct SandboxCapabilityClaims {
31 pub session_id: String,
33 pub operation: SandboxOperationClass,
35 pub generation: u64,
37 pub expires_at: i64,
39 pub key_id: String,
41}
42
43#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct SandboxSessionIdentity {
46 pub session_id: String,
48 pub generation: u64,
50}
51
52impl SandboxCapabilityClaims {
53 pub fn verify(
59 &self,
60 identity: &SandboxSessionIdentity,
61 required: SandboxOperationClass,
62 now_unix: i64,
63 ) -> Result<()> {
64 if self.session_id != identity.session_id {
67 return Err(refused("this capability addresses a different sandbox"));
68 }
69
70 if self.generation != identity.generation {
73 return Err(refused(
74 "this capability was issued for a previous lifecycle generation",
75 ));
76 }
77
78 if self.expires_at <= now_unix {
79 return Err(refused("this capability has expired"));
80 }
81
82 if self.operation != required {
85 return Err(refused(
86 "this capability does not permit this operation class",
87 ));
88 }
89
90 Ok(())
91 }
92}
93
94fn refused(reason: &str) -> AlienError<ErrorData> {
95 AlienError::new(ErrorData::SandboxCapabilityRefused {
96 reason: reason.to_string(),
97 })
98}
99
100#[cfg(test)]
101mod tests {
102 use super::*;
103
104 const NOW: i64 = 1_000_000;
105
106 fn identity() -> SandboxSessionIdentity {
107 SandboxSessionIdentity {
108 session_id: "s1".to_string(),
109 generation: 2,
110 }
111 }
112
113 fn claims() -> SandboxCapabilityClaims {
114 SandboxCapabilityClaims {
115 session_id: "s1".to_string(),
116 operation: SandboxOperationClass::Execute,
117 generation: 2,
118 expires_at: NOW + 300,
119 key_id: "k1".to_string(),
120 }
121 }
122
123 #[test]
124 fn a_matching_capability_is_accepted() {
125 claims()
126 .verify(&identity(), SandboxOperationClass::Execute, NOW)
127 .expect("a capability for this sandbox, generation and class is valid");
128 }
129
130 #[test]
133 fn a_capability_for_another_session_is_refused() {
134 let mut other = claims();
135 other.session_id = "s2".to_string();
136
137 let error = other
138 .verify(&identity(), SandboxOperationClass::Execute, NOW)
139 .expect_err("sandbox B must not accept sandbox A's capability");
140 assert!(error.to_string().contains("different sandbox"));
141 }
142
143 #[test]
146 fn a_capability_from_a_previous_generation_is_refused() {
147 let mut stale = claims();
148 stale.generation = 1;
149
150 let error = stale
151 .verify(&identity(), SandboxOperationClass::Execute, NOW)
152 .expect_err("a previous generation must be refused");
153 assert!(error.to_string().contains("generation"));
154 }
155
156 #[test]
157 fn an_expired_capability_is_refused() {
158 let mut expired = claims();
159 expired.expires_at = NOW;
160
161 expired
162 .verify(&identity(), SandboxOperationClass::Execute, NOW)
163 .expect_err("expiry is inclusive: a capability expiring now is already void");
164 }
165
166 #[test]
169 fn operation_classes_do_not_imply_each_other() {
170 claims()
171 .verify(&identity(), SandboxOperationClass::Manage, NOW)
172 .expect_err("execute must not permit manage");
173
174 let mut manage = claims();
175 manage.operation = SandboxOperationClass::Manage;
176 manage
177 .verify(&identity(), SandboxOperationClass::Execute, NOW)
178 .expect_err("manage must not permit execute");
179 }
180
181 #[test]
184 fn a_wrong_session_is_reported_as_wrong_session_even_when_also_expired() {
185 let mut wrong = claims();
186 wrong.session_id = "s2".to_string();
187 wrong.expires_at = NOW - 1;
188
189 let error = wrong
190 .verify(&identity(), SandboxOperationClass::Execute, NOW)
191 .expect_err("refused");
192 assert!(
193 error.to_string().contains("different sandbox"),
194 "the reason must not reveal that some other sandbox's capability had expired"
195 );
196 }
197
198 #[test]
199 fn claims_round_trip_so_minting_and_verification_cannot_drift() {
200 let json = serde_json::to_string(&claims()).expect("serializes");
201 let restored: SandboxCapabilityClaims = serde_json::from_str(&json).expect("deserializes");
202 assert_eq!(claims(), restored);
203 }
204}