Skip to main content

blindplane_access/
policy.rs

1//! Signed tenant capability policies.
2
3use blindplane_crypto::Sha256;
4
5use crate::AccessError;
6use crate::codec::{AccessValidationPolicy, Cursor, push_header, push_string, validate_identifier};
7use crate::principal::TrustedIssuer;
8#[cfg(feature = "client")]
9use crate::principal::{AccessIssuer, Principal};
10#[cfg(feature = "client")]
11use crate::signed::sign;
12use crate::signed::verify;
13
14const POLICY_TAG: u8 = 2;
15const POLICY_SIGNATURE_DOMAIN: &[u8] = b"blindplane/access/policy/v1";
16
17/// Capability namespace interpreted by an embedding adapter.
18#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
19pub enum CapabilityKind {
20    /// A complete MCP server name.
21    Mcp,
22    /// An exact tool name.
23    Tool,
24    /// An exact skill name.
25    Skill,
26    /// The local command-line capability.
27    Cli,
28}
29
30impl CapabilityKind {
31    const fn code(self) -> u8 {
32        match self {
33            Self::Mcp => 1,
34            Self::Tool => 2,
35            Self::Skill => 3,
36            Self::Cli => 4,
37        }
38    }
39
40    fn from_code(code: u8) -> Result<Self, AccessError> {
41        match code {
42            1 => Ok(Self::Mcp),
43            2 => Ok(Self::Tool),
44            3 => Ok(Self::Skill),
45            4 => Ok(Self::Cli),
46            _ => Err(AccessError::WrongObjectType),
47        }
48    }
49}
50
51/// Signed rule effect.
52#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
53pub enum Effect {
54    /// Permit the exact capability.
55    Allow,
56    /// Refuse the exact capability.
57    Deny,
58}
59
60impl Effect {
61    const fn code(self) -> u8 {
62        match self {
63            Self::Allow => 1,
64            Self::Deny => 2,
65        }
66    }
67
68    fn from_code(code: u8) -> Result<Self, AccessError> {
69        match code {
70            1 => Ok(Self::Allow),
71            2 => Ok(Self::Deny),
72            _ => Err(AccessError::WrongObjectType),
73        }
74    }
75}
76
77/// Result of evaluating a verified capability policy.
78#[derive(Clone, Copy, Debug, Eq, PartialEq)]
79pub enum Decision {
80    /// The policy permits the capability.
81    Allow,
82    /// The policy refuses the capability.
83    Deny,
84}
85
86/// One exact capability rule.
87#[derive(Clone, Debug, Eq, PartialEq)]
88pub struct CapabilityRule {
89    kind: CapabilityKind,
90    name: String,
91    effect: Effect,
92}
93
94impl CapabilityRule {
95    /// Construct a bounded exact-name rule.
96    pub fn new(
97        kind: CapabilityKind,
98        name: impl Into<String>,
99        effect: Effect,
100    ) -> Result<Self, AccessError> {
101        let name = name.into();
102        validate_identifier(
103            &name,
104            AccessValidationPolicy::default().max_identifier_bytes,
105        )?;
106        Ok(Self { kind, name, effect })
107    }
108
109    /// Rule namespace.
110    pub const fn kind(&self) -> CapabilityKind {
111        self.kind
112    }
113
114    /// Exact case-sensitive capability name.
115    pub fn name(&self) -> &str {
116        &self.name
117    }
118
119    /// Rule effect.
120    pub const fn effect(&self) -> Effect {
121        self.effect
122    }
123}
124
125/// Inputs signed into one tenant policy.
126#[derive(Clone, Debug, Eq, PartialEq)]
127pub struct PolicySpec {
128    /// Monotonic subject-policy revision.
129    pub revision: u64,
130    /// Hash of the preceding policy, or zero for revision one.
131    pub previous_hash: [u8; 32],
132    /// Current authorization epoch.
133    pub authorization_epoch: u64,
134    /// Issuance time in Unix seconds.
135    pub issued_at: u64,
136    /// First valid Unix second.
137    pub not_before: u64,
138    /// Last valid Unix second.
139    pub not_after: u64,
140    /// Exact capability rules.
141    pub rules: Vec<CapabilityRule>,
142}
143
144/// Canonical administrator-signed capability policy for one subject.
145#[derive(Clone, Debug, Eq, PartialEq)]
146pub struct TenantPolicy {
147    tenant_id: String,
148    subject_id: String,
149    subject_key_id: [u8; 32],
150    revision: u64,
151    previous_hash: [u8; 32],
152    authorization_epoch: u64,
153    issued_at: u64,
154    not_before: u64,
155    not_after: u64,
156    default_effect: Effect,
157    rules: Vec<CapabilityRule>,
158    issuer_id: String,
159    issuer_key_id: [u8; 32],
160    issuer_public_key: [u8; 32],
161    signature: [u8; 64],
162}
163
164impl TenantPolicy {
165    /// Issue and sign a canonical default-deny policy.
166    #[cfg(feature = "client")]
167    pub fn issue(
168        issuer: &AccessIssuer,
169        subject: &Principal,
170        mut spec: PolicySpec,
171    ) -> Result<Self, AccessError> {
172        spec.rules.sort_by(|left, right| {
173            (left.kind, left.name.as_str(), left.effect).cmp(&(
174                right.kind,
175                right.name.as_str(),
176                right.effect,
177            ))
178        });
179        let mut policy = Self {
180            tenant_id: subject.tenant_id().to_owned(),
181            subject_id: subject.principal_id().to_owned(),
182            subject_key_id: subject.key_id(),
183            revision: spec.revision,
184            previous_hash: spec.previous_hash,
185            authorization_epoch: spec.authorization_epoch,
186            issued_at: spec.issued_at,
187            not_before: spec.not_before,
188            not_after: spec.not_after,
189            default_effect: Effect::Deny,
190            rules: spec.rules,
191            issuer_id: issuer.issuer_id().to_owned(),
192            issuer_key_id: issuer.key_id(),
193            issuer_public_key: issuer.public_key(),
194            signature: [0; 64],
195        };
196        policy.validate_structure(&AccessValidationPolicy::default())?;
197        policy.signature = sign(
198            POLICY_SIGNATURE_DOMAIN,
199            issuer.signing_key(),
200            &policy.unsigned_bytes(),
201        );
202        Ok(policy)
203    }
204
205    /// Tenant identifier authenticated by the signature.
206    pub fn tenant_id(&self) -> &str {
207        &self.tenant_id
208    }
209
210    /// Subject identifier authenticated by the signature.
211    pub fn subject_id(&self) -> &str {
212        &self.subject_id
213    }
214
215    /// Monotonic policy revision.
216    pub const fn revision(&self) -> u64 {
217        self.revision
218    }
219
220    /// Authorization epoch carried by this policy.
221    pub const fn authorization_epoch(&self) -> u64 {
222        self.authorization_epoch
223    }
224
225    /// Canonical encoded and signed policy.
226    pub fn encode(&self) -> Vec<u8> {
227        let mut out = self.unsigned_bytes();
228        out.extend_from_slice(&self.signature);
229        out
230    }
231
232    /// Domain-separated hash used to link policy revisions.
233    pub fn policy_hash(&self) -> [u8; 32] {
234        let encoded = self.encode();
235        let mut input = Vec::with_capacity(POLICY_SIGNATURE_DOMAIN.len() + encoded.len());
236        input.extend_from_slice(POLICY_SIGNATURE_DOMAIN);
237        input.extend_from_slice(&encoded);
238        Sha256::digest(&input)
239    }
240
241    /// Decode, structurally validate, and verify signature math.
242    pub fn decode(bytes: &[u8], limits: &AccessValidationPolicy) -> Result<Self, AccessError> {
243        let mut cursor = Cursor::new(bytes);
244        cursor.take_header(POLICY_TAG)?;
245        let tenant_id = cursor.take_string(limits.max_identifier_bytes)?;
246        let subject_id = cursor.take_string(limits.max_identifier_bytes)?;
247        let subject_key_id = cursor.take_array32()?;
248        let revision = cursor.take_u64()?;
249        let previous_hash = cursor.take_array32()?;
250        let authorization_epoch = cursor.take_u64()?;
251        let issued_at = cursor.take_u64()?;
252        let not_before = cursor.take_u64()?;
253        let not_after = cursor.take_u64()?;
254        let default_effect = Effect::from_code(cursor.take_u8()?)?;
255        let rule_count = cursor.take_len(limits.max_rules)?;
256        let mut rules = Vec::with_capacity(rule_count);
257        for _ in 0..rule_count {
258            rules.push(CapabilityRule {
259                kind: CapabilityKind::from_code(cursor.take_u8()?)?,
260                name: cursor.take_string(limits.max_identifier_bytes)?,
261                effect: Effect::from_code(cursor.take_u8()?)?,
262            });
263        }
264        let issuer_id = cursor.take_string(limits.max_identifier_bytes)?;
265        let issuer_key_id = cursor.take_array32()?;
266        let issuer_public_key = cursor.take_array32()?;
267        let signature = cursor.take_array64()?;
268        if !cursor.is_empty() {
269            return Err(AccessError::TrailingBytes);
270        }
271        let policy = Self {
272            tenant_id,
273            subject_id,
274            subject_key_id,
275            revision,
276            previous_hash,
277            authorization_epoch,
278            issued_at,
279            not_before,
280            not_after,
281            default_effect,
282            rules,
283            issuer_id,
284            issuer_key_id,
285            issuer_public_key,
286            signature,
287        };
288        policy.validate_structure(limits)?;
289        verify(
290            POLICY_SIGNATURE_DOMAIN,
291            &policy.issuer_public_key,
292            &policy.unsigned_bytes(),
293            &policy.signature,
294        )?;
295        if policy.encode() != bytes {
296            return Err(AccessError::NonCanonicalEncoding);
297        }
298        Ok(policy)
299    }
300
301    /// Verify trust, routing, freshness, and time before policy evaluation.
302    pub fn verify<'a>(
303        &'a self,
304        trusted: &TrustedIssuer,
305        expected_tenant: &str,
306        expected_subject: &str,
307        now: u64,
308        minimum_revision: u64,
309        minimum_authorization_epoch: u64,
310    ) -> Result<VerifiedPolicy<'a>, AccessError> {
311        verify(
312            POLICY_SIGNATURE_DOMAIN,
313            &self.issuer_public_key,
314            &self.unsigned_bytes(),
315            &self.signature,
316        )?;
317        if self.issuer_id != trusted.issuer_id()
318            || self.issuer_key_id != trusted.key_id()
319            || self.issuer_public_key != trusted.public_key()
320        {
321            return Err(AccessError::UntrustedIssuer);
322        }
323        if self.tenant_id != expected_tenant || self.subject_id != expected_subject {
324            return Err(AccessError::SubjectMismatch);
325        }
326        if now < self.not_before {
327            return Err(AccessError::NotYetValid);
328        }
329        if now > self.not_after {
330            return Err(AccessError::Expired);
331        }
332        if self.revision < minimum_revision {
333            return Err(AccessError::StaleRevision);
334        }
335        if self.authorization_epoch < minimum_authorization_epoch {
336            return Err(AccessError::Revoked);
337        }
338        Ok(VerifiedPolicy { policy: self })
339    }
340
341    fn validate_structure(&self, limits: &AccessValidationPolicy) -> Result<(), AccessError> {
342        validate_identifier(&self.tenant_id, limits.max_identifier_bytes)?;
343        validate_identifier(&self.subject_id, limits.max_identifier_bytes)?;
344        validate_identifier(&self.issuer_id, limits.max_identifier_bytes)?;
345        if self.revision == 0
346            || self.authorization_epoch == 0
347            || self.not_before > self.not_after
348            || self.issued_at > self.not_after
349            || (self.revision == 1 && self.previous_hash != [0; 32])
350            || (self.revision > 1 && self.previous_hash == [0; 32])
351        {
352            return Err(AccessError::InvalidEpoch);
353        }
354        if self.default_effect != Effect::Deny || self.rules.len() > limits.max_rules {
355            return Err(AccessError::LengthLimit(self.rules.len()));
356        }
357        for rule in &self.rules {
358            validate_identifier(&rule.name, limits.max_identifier_bytes)?;
359        }
360        for pair in self.rules.windows(2) {
361            let left = (pair[0].kind, pair[0].name.as_str());
362            let right = (pair[1].kind, pair[1].name.as_str());
363            if left >= right {
364                return Err(AccessError::DuplicateRule);
365            }
366        }
367        Ok(())
368    }
369
370    fn unsigned_bytes(&self) -> Vec<u8> {
371        let mut out = Vec::with_capacity(256 + self.rules.len() * 32);
372        push_header(&mut out, POLICY_TAG);
373        push_string(&mut out, &self.tenant_id);
374        push_string(&mut out, &self.subject_id);
375        out.extend_from_slice(&self.subject_key_id);
376        out.extend_from_slice(&self.revision.to_be_bytes());
377        out.extend_from_slice(&self.previous_hash);
378        out.extend_from_slice(&self.authorization_epoch.to_be_bytes());
379        out.extend_from_slice(&self.issued_at.to_be_bytes());
380        out.extend_from_slice(&self.not_before.to_be_bytes());
381        out.extend_from_slice(&self.not_after.to_be_bytes());
382        out.push(self.default_effect.code());
383        out.extend_from_slice(
384            &u32::try_from(self.rules.len())
385                .expect("policy rules are bounded")
386                .to_be_bytes(),
387        );
388        for rule in &self.rules {
389            out.push(rule.kind.code());
390            push_string(&mut out, &rule.name);
391            out.push(rule.effect.code());
392        }
393        push_string(&mut out, &self.issuer_id);
394        out.extend_from_slice(&self.issuer_key_id);
395        out.extend_from_slice(&self.issuer_public_key);
396        out
397    }
398}
399
400/// A policy that passed trust, routing, freshness, epoch, and time checks.
401#[derive(Clone, Copy, Debug, Eq, PartialEq)]
402pub struct VerifiedPolicy<'a> {
403    policy: &'a TenantPolicy,
404}
405
406impl VerifiedPolicy<'_> {
407    /// Evaluate one exact capability name. Version one is default-deny.
408    pub fn decision(&self, kind: CapabilityKind, name: &str) -> Decision {
409        self.policy
410            .rules
411            .iter()
412            .find(|rule| rule.kind == kind && rule.name == name)
413            .map_or(Decision::Deny, |rule| match rule.effect {
414                Effect::Allow => Decision::Allow,
415                Effect::Deny => Decision::Deny,
416            })
417    }
418}