| 1 | use crate::{Error, Result, Scope}; |
| 2 | use std::collections::BTreeSet; |
| 3 | |
| 4 | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] |
| 5 | pub enum Capability { |
| 6 | Read, |
| 7 | Propose, |
| 8 | Review, |
| 9 | Correct, |
| 10 | Forget, |
| 11 | Index, |
| 12 | Link, |
| 13 | Checkpoint, |
| 14 | Export, |
| 15 | Maintenance, |
| 16 | ContextDispatch, |
| 17 | } |
| 18 | |
| 19 | /// Constructed by trusted host code. Deliberately NOT Deserialize: request bodies |
| 20 | /// cannot create or enlarge grants. These checks do not sandbox a hostile local OS user. |
| 21 | #[derive(Debug, Clone)] |
| 22 | pub struct Access { |
| 23 | actor: String, |
| 24 | reads: BTreeSet<Scope>, |
| 25 | writes: BTreeSet<Scope>, |
| 26 | capabilities: BTreeSet<Capability>, |
| 27 | } |
| 28 | impl Access { |
| 29 | pub fn new( |
| 30 | actor: impl Into<String>, |
| 31 | reads: Vec<Scope>, |
| 32 | writes: Vec<Scope>, |
| 33 | caps: Vec<Capability>, |
| 34 | ) -> Result<Self> { |
| 35 | let actor = actor.into(); |
| 36 | if actor.trim().is_empty() || actor.len() > 256 { |
| 37 | return Err(Error::Invalid("actor identity is required".into())); |
| 38 | } |
| 39 | if reads.len() > 64 || writes.len() > 64 { |
| 40 | return Err(Error::Invalid( |
| 41 | "at most 64 exact scopes may be granted".into(), |
| 42 | )); |
| 43 | } |
| 44 | for scope in reads.iter().chain(writes.iter()) { |
| 45 | scope.validate()?; |
| 46 | } |
| 47 | if let Some(first) = reads.first() |
| 48 | && reads |
| 49 | .iter() |
| 50 | .chain(writes.iter()) |
| 51 | .any(|s| s.tenant != first.tenant || s.user != first.user) |
| 52 | { |
| 53 | return Err(Error::Invalid( |
| 54 | "one access context must belong to exactly one tenant and user".into(), |
| 55 | )); |
| 56 | } |
| 57 | // One trusted freshness snapshot represents one live workspace/branch/ |
| 58 | // session/agent. Do not apply its file hashes to another repository. |
| 59 | for field in 0..4 { |
| 60 | let values: BTreeSet<&str> = reads |
| 61 | .iter() |
| 62 | .filter_map(|s| match field { |
| 63 | 0 => s.workspace.as_deref(), |
| 64 | 1 => s.branch.as_deref(), |
| 65 | 2 => s.session.as_deref(), |
| 66 | _ => s.agent.as_deref(), |
| 67 | }) |
| 68 | .collect(); |
| 69 | if values.len() > 1 { |
| 70 | return Err(Error::Invalid("one access context cannot span different workspace, branch, session, or agent identities".into())); |
| 71 | } |
| 72 | } |
| 73 | let reads: BTreeSet<_> = reads.into_iter().collect(); |
| 74 | let writes: BTreeSet<_> = writes.into_iter().collect(); |
| 75 | if !writes.is_subset(&reads) { |
| 76 | return Err(Error::Denied); |
| 77 | } |
| 78 | Ok(Self { |
| 79 | actor, |
| 80 | reads, |
| 81 | writes, |
| 82 | capabilities: caps.into_iter().collect(), |
| 83 | }) |
| 84 | } |
| 85 | pub fn operator(scopes: Vec<Scope>) -> Result<Self> { |
| 86 | Self::new( |
| 87 | "local-operator", |
| 88 | scopes.clone(), |
| 89 | scopes, |
| 90 | vec![ |
| 91 | Capability::Read, |
| 92 | Capability::Propose, |
| 93 | Capability::Review, |
| 94 | Capability::Correct, |
| 95 | Capability::Forget, |
| 96 | Capability::Index, |
| 97 | Capability::Link, |
| 98 | Capability::Checkpoint, |
| 99 | Capability::Export, |
| 100 | Capability::Maintenance, |
| 101 | Capability::ContextDispatch, |
| 102 | ], |
| 103 | ) |
| 104 | } |
| 105 | pub fn agent(scopes: Vec<Scope>) -> Result<Self> { |
| 106 | Self::new( |
| 107 | "agent", |
| 108 | scopes.clone(), |
| 109 | scopes, |
| 110 | vec![ |
| 111 | Capability::Read, |
| 112 | Capability::Propose, |
| 113 | Capability::Link, |
| 114 | Capability::Checkpoint, |
| 115 | ], |
| 116 | ) |
| 117 | } |
| 118 | pub fn readonly(scopes: Vec<Scope>) -> Result<Self> { |
| 119 | Self::new("reader", scopes, vec![], vec![Capability::Read]) |
| 120 | } |
| 121 | pub fn delegate( |
| 122 | &self, |
| 123 | actor: impl Into<String>, |
| 124 | reads: Vec<Scope>, |
| 125 | writes: Vec<Scope>, |
| 126 | caps: Vec<Capability>, |
| 127 | ) -> Result<Self> { |
| 128 | let child = Self::new(actor, reads, writes, caps)?; |
| 129 | if !child.reads.is_subset(&self.reads) |
| 130 | || !child.writes.is_subset(&self.writes) |
| 131 | || !child.capabilities.is_subset(&self.capabilities) |
| 132 | { |
| 133 | return Err(Error::Denied); |
| 134 | } |
| 135 | Ok(child) |
| 136 | } |
| 137 | pub fn has(&self, cap: Capability) -> bool { |
| 138 | self.capabilities.contains(&cap) |
| 139 | } |
| 140 | pub fn require(&self, cap: Capability) -> Result<()> { |
| 141 | if self.has(cap) { |
| 142 | Ok(()) |
| 143 | } else { |
| 144 | Err(Error::Denied) |
| 145 | } |
| 146 | } |
| 147 | pub fn read(&self, scope: &Scope) -> Result<()> { |
| 148 | self.require(Capability::Read)?; |
| 149 | if self.reads.contains(scope) { |
| 150 | Ok(()) |
| 151 | } else { |
| 152 | Err(Error::NotFound) |
| 153 | } |
| 154 | } |
| 155 | pub fn write(&self, scope: &Scope, cap: Capability) -> Result<()> { |
| 156 | self.require(cap)?; |
| 157 | if self.writes.contains(scope) { |
| 158 | Ok(()) |
| 159 | } else { |
| 160 | Err(Error::Denied) |
| 161 | } |
| 162 | } |
| 163 | pub fn scopes(&self) -> impl Iterator<Item = &Scope> { |
| 164 | self.reads.iter() |
| 165 | } |
| 166 | pub fn writable_scopes(&self) -> impl Iterator<Item = &Scope> { |
| 167 | self.writes.iter() |
| 168 | } |
| 169 | pub(crate) fn scope_json(&self) -> Result<String> { |
| 170 | Ok(serde_json::to_string( |
| 171 | &self |
| 172 | .reads |
| 173 | .iter() |
| 174 | .map(Scope::key) |
| 175 | .collect::<Result<Vec<_>>>()?, |
| 176 | )?) |
| 177 | } |
| 178 | pub(crate) fn actor_hash(&self) -> String { |
| 179 | crate::policy::sha256(self.actor.as_bytes()) |
| 180 | } |
| 181 | } |
| 182 |