use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AccessTier {
#[default]
Public,
Restricted,
Classified,
}
impl AccessTier {
pub fn canonical(&self) -> &'static str {
match self {
AccessTier::Public => "public",
AccessTier::Restricted => "restricted",
AccessTier::Classified => "classified",
}
}
pub fn parse(s: &str) -> Result<Self, String> {
match s {
"public" => Ok(AccessTier::Public),
"restricted" => Ok(AccessTier::Restricted),
"classified" => Ok(AccessTier::Classified),
other => Err(format!(
"unknown access tier '{other}'; valid: public, restricted, classified"
)),
}
}
}
pub fn actor_may_read(tier: AccessTier, clearance: Option<AccessTier>) -> bool {
let effective = clearance.unwrap_or(AccessTier::Public);
tier <= effective
}
pub fn redact_for_actor(
project: &crate::project::Project,
clearance: Option<AccessTier>,
) -> crate::project::Project {
let findings: Vec<_> = project
.findings
.iter()
.filter(|f| actor_may_read(f.access_tier, clearance))
.cloned()
.collect();
let visible_finding_ids: std::collections::BTreeSet<&str> =
findings.iter().map(|f| f.id.as_str()).collect();
let negative_results: Vec<_> = project
.negative_results
.iter()
.filter(|n| actor_may_read(n.access_tier, clearance))
.cloned()
.collect();
let visible_nr_ids: std::collections::BTreeSet<&str> =
negative_results.iter().map(|n| n.id.as_str()).collect();
let trajectories: Vec<_> = project
.trajectories
.iter()
.filter(|t| actor_may_read(t.access_tier, clearance))
.cloned()
.collect();
let visible_traj_ids: std::collections::BTreeSet<&str> =
trajectories.iter().map(|t| t.id.as_str()).collect();
let artifacts: Vec<_> = project
.artifacts
.iter()
.filter(|a| actor_may_read(a.access_tier, clearance))
.cloned()
.collect();
let visible_artifact_ids: std::collections::BTreeSet<&str> =
artifacts.iter().map(|a| a.id.as_str()).collect();
let events: Vec<_> = project
.events
.iter()
.filter(|e| match e.target.r#type.as_str() {
"finding" => visible_finding_ids.contains(e.target.id.as_str()),
"negative_result" => visible_nr_ids.contains(e.target.id.as_str()),
"trajectory" => visible_traj_ids.contains(e.target.id.as_str()),
"artifact" => visible_artifact_ids.contains(e.target.id.as_str()),
_ => true, })
.cloned()
.collect();
crate::project::Project {
findings,
negative_results,
trajectories,
artifacts,
events,
..clone_project_metadata(project)
}
}
fn clone_project_metadata(p: &crate::project::Project) -> crate::project::Project {
crate::project::Project {
vela_version: p.vela_version.clone(),
schema: p.schema.clone(),
frontier_id: p.frontier_id.clone(),
project: crate::project::ProjectMeta {
name: p.project.name.clone(),
description: p.project.description.clone(),
compiled_at: p.project.compiled_at.clone(),
compiler: p.project.compiler.clone(),
papers_processed: p.project.papers_processed,
errors: p.project.errors,
dependencies: p.project.dependencies.clone(),
},
stats: serde_json::from_value(serde_json::to_value(&p.stats).unwrap_or_default())
.unwrap_or_default(),
findings: Vec::new(),
sources: p.sources.clone(),
evidence_atoms: p.evidence_atoms.clone(),
condition_records: p.condition_records.clone(),
review_events: p.review_events.clone(),
confidence_updates: p.confidence_updates.clone(),
events: Vec::new(),
proposals: p.proposals.clone(),
proof_state: p.proof_state.clone(),
signatures: p.signatures.clone(),
actors: p.actors.clone(),
replications: p.replications.clone(),
datasets: p.datasets.clone(),
code_artifacts: p.code_artifacts.clone(),
artifacts: Vec::new(),
predictions: p.predictions.clone(),
resolutions: p.resolutions.clone(),
peers: p.peers.clone(),
negative_results: Vec::new(),
trajectories: Vec::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ordering_is_public_lt_restricted_lt_classified() {
assert!(AccessTier::Public < AccessTier::Restricted);
assert!(AccessTier::Restricted < AccessTier::Classified);
}
#[test]
fn anonymous_reader_sees_only_public() {
assert!(actor_may_read(AccessTier::Public, None));
assert!(!actor_may_read(AccessTier::Restricted, None));
assert!(!actor_may_read(AccessTier::Classified, None));
}
#[test]
fn restricted_clearance_excludes_classified() {
assert!(actor_may_read(
AccessTier::Public,
Some(AccessTier::Restricted)
));
assert!(actor_may_read(
AccessTier::Restricted,
Some(AccessTier::Restricted)
));
assert!(!actor_may_read(
AccessTier::Classified,
Some(AccessTier::Restricted)
));
}
#[test]
fn classified_clearance_reads_everything() {
assert!(actor_may_read(
AccessTier::Public,
Some(AccessTier::Classified)
));
assert!(actor_may_read(
AccessTier::Restricted,
Some(AccessTier::Classified)
));
assert!(actor_may_read(
AccessTier::Classified,
Some(AccessTier::Classified)
));
}
#[test]
fn parse_round_trips_canonical() {
for tier in [
AccessTier::Public,
AccessTier::Restricted,
AccessTier::Classified,
] {
assert_eq!(AccessTier::parse(tier.canonical()).unwrap(), tier);
}
}
#[test]
fn parse_rejects_unknown() {
assert!(AccessTier::parse("restrictd").is_err());
assert!(AccessTier::parse("").is_err());
assert!(AccessTier::parse("CLASSIFIED").is_err());
}
}