vela_protocol/
access_tier.rs1use serde::{Deserialize, Serialize};
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Serialize, Deserialize)]
47#[serde(rename_all = "snake_case")]
48pub enum AccessTier {
49 #[default]
50 Public,
51 Restricted,
52 Classified,
53}
54
55impl AccessTier {
56 pub fn canonical(&self) -> &'static str {
59 match self {
60 AccessTier::Public => "public",
61 AccessTier::Restricted => "restricted",
62 AccessTier::Classified => "classified",
63 }
64 }
65
66 pub fn parse(s: &str) -> Result<Self, String> {
70 match s {
71 "public" => Ok(AccessTier::Public),
72 "restricted" => Ok(AccessTier::Restricted),
73 "classified" => Ok(AccessTier::Classified),
74 other => Err(format!(
75 "unknown access tier '{other}'; valid: public, restricted, classified"
76 )),
77 }
78 }
79}
80
81pub fn actor_may_read(tier: AccessTier, clearance: Option<AccessTier>) -> bool {
86 let effective = clearance.unwrap_or(AccessTier::Public);
87 tier <= effective
88}
89
90pub fn redact_for_actor(
107 project: &crate::project::Project,
108 clearance: Option<AccessTier>,
109) -> crate::project::Project {
110 let findings: Vec<_> = project
111 .findings
112 .iter()
113 .filter(|f| actor_may_read(f.access_tier, clearance))
114 .cloned()
115 .collect();
116 let visible_finding_ids: std::collections::BTreeSet<&str> =
117 findings.iter().map(|f| f.id.as_str()).collect();
118
119 let negative_results: Vec<_> = project
120 .negative_results
121 .iter()
122 .filter(|n| actor_may_read(n.access_tier, clearance))
123 .cloned()
124 .collect();
125 let visible_nr_ids: std::collections::BTreeSet<&str> =
126 negative_results.iter().map(|n| n.id.as_str()).collect();
127
128 let trajectories: Vec<_> = project
129 .trajectories
130 .iter()
131 .filter(|t| actor_may_read(t.access_tier, clearance))
132 .cloned()
133 .collect();
134 let visible_traj_ids: std::collections::BTreeSet<&str> =
135 trajectories.iter().map(|t| t.id.as_str()).collect();
136
137 let artifacts: Vec<_> = project
138 .artifacts
139 .iter()
140 .filter(|a| actor_may_read(a.access_tier, clearance))
141 .cloned()
142 .collect();
143 let visible_artifact_ids: std::collections::BTreeSet<&str> =
144 artifacts.iter().map(|a| a.id.as_str()).collect();
145
146 let events: Vec<_> = project
147 .events
148 .iter()
149 .filter(|e| match e.target.r#type.as_str() {
150 "finding" => visible_finding_ids.contains(e.target.id.as_str()),
151 "negative_result" => visible_nr_ids.contains(e.target.id.as_str()),
152 "trajectory" => visible_traj_ids.contains(e.target.id.as_str()),
153 "artifact" => visible_artifact_ids.contains(e.target.id.as_str()),
154 _ => true, })
156 .cloned()
157 .collect();
158
159 crate::project::Project {
160 findings,
161 negative_results,
162 trajectories,
163 artifacts,
164 events,
165 ..clone_project_metadata(project)
170 }
171}
172
173fn clone_project_metadata(p: &crate::project::Project) -> crate::project::Project {
177 crate::project::Project {
178 vela_version: p.vela_version.clone(),
179 schema: p.schema.clone(),
180 frontier_id: p.frontier_id.clone(),
181 project: crate::project::ProjectMeta {
182 name: p.project.name.clone(),
183 description: p.project.description.clone(),
184 compiled_at: p.project.compiled_at.clone(),
185 compiler: p.project.compiler.clone(),
186 papers_processed: p.project.papers_processed,
187 errors: p.project.errors,
188 dependencies: p.project.dependencies.clone(),
189 },
190 stats: serde_json::from_value(serde_json::to_value(&p.stats).unwrap_or_default())
191 .unwrap_or_default(),
192 findings: Vec::new(),
193 sources: p.sources.clone(),
194 evidence_atoms: p.evidence_atoms.clone(),
195 condition_records: p.condition_records.clone(),
196 review_events: p.review_events.clone(),
197 confidence_updates: p.confidence_updates.clone(),
198 events: Vec::new(),
199 proposals: p.proposals.clone(),
200 proof_state: p.proof_state.clone(),
201 signatures: p.signatures.clone(),
202 actors: p.actors.clone(),
203 replications: p.replications.clone(),
204 datasets: p.datasets.clone(),
205 code_artifacts: p.code_artifacts.clone(),
206 artifacts: Vec::new(),
207 predictions: p.predictions.clone(),
208 resolutions: p.resolutions.clone(),
209 peers: p.peers.clone(),
210 negative_results: Vec::new(),
211 trajectories: Vec::new(),
212 }
213}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218
219 #[test]
220 fn ordering_is_public_lt_restricted_lt_classified() {
221 assert!(AccessTier::Public < AccessTier::Restricted);
222 assert!(AccessTier::Restricted < AccessTier::Classified);
223 }
224
225 #[test]
226 fn anonymous_reader_sees_only_public() {
227 assert!(actor_may_read(AccessTier::Public, None));
228 assert!(!actor_may_read(AccessTier::Restricted, None));
229 assert!(!actor_may_read(AccessTier::Classified, None));
230 }
231
232 #[test]
233 fn restricted_clearance_excludes_classified() {
234 assert!(actor_may_read(
235 AccessTier::Public,
236 Some(AccessTier::Restricted)
237 ));
238 assert!(actor_may_read(
239 AccessTier::Restricted,
240 Some(AccessTier::Restricted)
241 ));
242 assert!(!actor_may_read(
243 AccessTier::Classified,
244 Some(AccessTier::Restricted)
245 ));
246 }
247
248 #[test]
249 fn classified_clearance_reads_everything() {
250 assert!(actor_may_read(
251 AccessTier::Public,
252 Some(AccessTier::Classified)
253 ));
254 assert!(actor_may_read(
255 AccessTier::Restricted,
256 Some(AccessTier::Classified)
257 ));
258 assert!(actor_may_read(
259 AccessTier::Classified,
260 Some(AccessTier::Classified)
261 ));
262 }
263
264 #[test]
265 fn parse_round_trips_canonical() {
266 for tier in [
267 AccessTier::Public,
268 AccessTier::Restricted,
269 AccessTier::Classified,
270 ] {
271 assert_eq!(AccessTier::parse(tier.canonical()).unwrap(), tier);
272 }
273 }
274
275 #[test]
276 fn parse_rejects_unknown() {
277 assert!(AccessTier::parse("restrictd").is_err());
278 assert!(AccessTier::parse("").is_err());
279 assert!(AccessTier::parse("CLASSIFIED").is_err());
280 }
281}