Skip to main content

cortex_store/repo/
authority.rs

1//! Temporal authority timeline repository.
2
3use chrono::{DateTime, Utc};
4use cortex_core::{
5    revalidate_temporal_authority, KeyLifecycleState, PolicyContribution, PolicyDecision,
6    PolicyOutcome, TemporalAuthorityEvidence, TemporalAuthorityReport, TrustTier,
7};
8use rusqlite::{params, OptionalExtension, Row};
9
10use crate::{Pool, StoreError, StoreResult};
11
12/// Required contributor rule id documenting that an operator attested the
13/// proposed key state transition (ADR 0019 §3, ADR 0026 §4).
14pub const KEY_STATE_ATTESTED_BY_OPERATOR_RULE_ID: &str = "authority.key_state.attested_by_operator";
15/// Required contributor rule id documenting that the trust tier gate composed
16/// into the decision for this key state mutation (ADR 0019, ADR 0026 §4).
17pub const KEY_STATE_TRUST_TIER_GATE_RULE_ID: &str = "authority.key_state.trust_tier_gate";
18/// Required contributor rule id documenting that an operator attested the
19/// principal trust tier promotion or downgrade (ADR 0019 §3, ADR 0026 §4).
20pub const PRINCIPAL_STATE_ATTESTED_BY_OPERATOR_RULE_ID: &str =
21    "authority.principal_state.attested_by_operator";
22/// Required contributor rule id documenting that the trust tier gate composed
23/// into the decision for this principal trust mutation (ADR 0019, ADR 0026 §4).
24pub const PRINCIPAL_STATE_TRUST_TIER_GATE_RULE_ID: &str =
25    "authority.principal_state.trust_tier_gate";
26/// Required contributor rule id documenting that the proposed principal tier
27/// is supported by a fresh tier promotion attestation (ADR 0019 §1, ADR 0026
28/// §4). `BreakGlass` MUST NOT substitute for this contributor.
29pub const PRINCIPAL_STATE_TIER_PROMOTION_ATTESTATION_RULE_ID: &str =
30    "authority.principal_state.tier_promotion_attestation";
31
32/// Key lifecycle timeline row.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct KeyTimelineRecord {
35    /// Key identifier.
36    pub key_id: String,
37    /// Principal bound to this key at the effective time.
38    pub principal_id: String,
39    /// Key lifecycle state.
40    pub state: KeyLifecycleState,
41    /// Effective time for this state.
42    pub effective_at: DateTime<Utc>,
43    /// Optional reason such as compromise/offboarding.
44    pub reason: Option<String>,
45    /// Optional audit row reference.
46    pub audit_ref: Option<String>,
47}
48
49/// Principal trust timeline row.
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct PrincipalTimelineRecord {
52    /// Principal identifier.
53    pub principal_id: String,
54    /// Trust tier effective from this row.
55    pub trust_tier: TrustTier,
56    /// Effective time for this state.
57    pub effective_at: DateTime<Utc>,
58    /// Optional trust review deadline.
59    pub trust_review_due_at: Option<DateTime<Utc>>,
60    /// Optional principal removal time.
61    pub removed_at: Option<DateTime<Utc>>,
62    /// Optional audit row reference.
63    pub audit_ref: Option<String>,
64}
65
66/// Query for temporal authority revalidation.
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct TemporalAuthorityQuery {
69    /// Key identifier carried by the attestation.
70    pub key_id: String,
71    /// Signed event/audit time.
72    pub event_time: DateTime<Utc>,
73    /// Verification time.
74    pub now: DateTime<Utc>,
75    /// Minimum trust tier required for current use.
76    pub minimum_trust_tier: TrustTier,
77}
78
79/// Repository for authority timelines.
80#[derive(Debug)]
81pub struct AuthorityRepo<'a> {
82    pool: &'a Pool,
83}
84
85impl<'a> AuthorityRepo<'a> {
86    /// Creates an authority repository over an open SQLite connection.
87    #[must_use]
88    pub const fn new(pool: &'a Pool) -> Self {
89        Self { pool }
90    }
91
92    /// Append a key lifecycle timeline row through the ADR 0026 enforcement
93    /// lattice.
94    ///
95    /// `policy` is the composed [`PolicyDecision`] for this key-state mutation
96    /// and MUST satisfy:
97    ///
98    /// 1. The final outcome is one of [`PolicyOutcome::Allow`],
99    ///    [`PolicyOutcome::Warn`], or [`PolicyOutcome::BreakGlass`]. A
100    ///    `Quarantine` or `Reject` decision fails closed and writes nothing.
101    /// 2. The composition includes contributors for both
102    ///    [`KEY_STATE_ATTESTED_BY_OPERATOR_RULE_ID`] and
103    ///    [`KEY_STATE_TRUST_TIER_GATE_RULE_ID`]. The repo refuses callers that
104    ///    skipped composition.
105    /// 3. Per ADR 0026 §4, the operator attestation contributor MUST be
106    ///    [`PolicyOutcome::Allow`] even when the final decision is
107    ///    `BreakGlass`. Break-glass never substitutes for operator attestation
108    ///    at the key-state authority root.
109    /// 4. The proposed [`KeyTimelineRecord::state`] is consistent with the
110    ///    principal's current trust tier visible in the timeline. Activating a
111    ///    new key for a principal that is below
112    ///    [`TrustTier::Verified`] is refused.
113    pub fn append_key_state(
114        &self,
115        record: &KeyTimelineRecord,
116        policy: &PolicyDecision,
117    ) -> StoreResult<()> {
118        require_policy_final_outcome(policy, "authority.key_state")?;
119        require_contributor_rule(policy, KEY_STATE_ATTESTED_BY_OPERATOR_RULE_ID)?;
120        require_contributor_rule(policy, KEY_STATE_TRUST_TIER_GATE_RULE_ID)?;
121        require_attestation_not_break_glassed(
122            policy,
123            KEY_STATE_ATTESTED_BY_OPERATOR_RULE_ID,
124            "authority.key_state",
125        )?;
126
127        if matches!(record.state, KeyLifecycleState::Active) {
128            self.reject_active_key_for_undertrust_principal(record)?;
129        }
130
131        self.pool.execute(
132            "INSERT INTO authority_key_timeline (
133                key_id, principal_id, state, effective_at, reason, audit_ref
134             ) VALUES (?1, ?2, ?3, ?4, ?5, ?6);",
135            params![
136                record.key_id,
137                record.principal_id,
138                key_state_wire(record.state),
139                record.effective_at.to_rfc3339(),
140                record.reason,
141                record.audit_ref,
142            ],
143        )?;
144        Ok(())
145    }
146
147    /// Append a principal trust timeline row through the ADR 0026 enforcement
148    /// lattice.
149    ///
150    /// Same envelope as [`Self::append_key_state`] plus the
151    /// [`PRINCIPAL_STATE_TIER_PROMOTION_ATTESTATION_RULE_ID`] contributor.
152    /// ADR 0026 §4 is a hard wall: `BreakGlass` MUST NOT substitute for the
153    /// tier promotion attestation. The repo enforces that the attestation
154    /// contributor is `Allow` even when the final decision is `BreakGlass`.
155    pub fn append_principal_state(
156        &self,
157        record: &PrincipalTimelineRecord,
158        policy: &PolicyDecision,
159    ) -> StoreResult<()> {
160        require_policy_final_outcome(policy, "authority.principal_state")?;
161        require_contributor_rule(policy, PRINCIPAL_STATE_ATTESTED_BY_OPERATOR_RULE_ID)?;
162        require_contributor_rule(policy, PRINCIPAL_STATE_TRUST_TIER_GATE_RULE_ID)?;
163        require_contributor_rule(policy, PRINCIPAL_STATE_TIER_PROMOTION_ATTESTATION_RULE_ID)?;
164        require_attestation_not_break_glassed(
165            policy,
166            PRINCIPAL_STATE_ATTESTED_BY_OPERATOR_RULE_ID,
167            "authority.principal_state",
168        )?;
169        require_attestation_not_break_glassed(
170            policy,
171            PRINCIPAL_STATE_TIER_PROMOTION_ATTESTATION_RULE_ID,
172            "authority.principal_state",
173        )?;
174
175        self.pool.execute(
176            "INSERT INTO authority_principal_timeline (
177                principal_id, trust_tier, effective_at, trust_review_due_at, removed_at, audit_ref
178             ) VALUES (?1, ?2, ?3, ?4, ?5, ?6);",
179            params![
180                record.principal_id,
181                trust_tier_wire(record.trust_tier),
182                record.effective_at.to_rfc3339(),
183                record.trust_review_due_at.map(|value| value.to_rfc3339()),
184                record.removed_at.map(|value| value.to_rfc3339()),
185                record.audit_ref,
186            ],
187        )?;
188        Ok(())
189    }
190
191    fn reject_active_key_for_undertrust_principal(
192        &self,
193        record: &KeyTimelineRecord,
194    ) -> StoreResult<()> {
195        let current_trust = self.principal_state_at(&record.principal_id, record.effective_at)?;
196        match current_trust {
197            Some(state) if state.trust_tier < TrustTier::Verified => {
198                Err(StoreError::Validation(format!(
199                    "authority.key_state preflight: refuse to activate key `{}` for principal `{}` while current trust tier `{:?}` is below `Verified`",
200                    record.key_id, record.principal_id, state.trust_tier,
201                )))
202            }
203            // Unknown principal: caller must register the trust state first.
204            None => Err(StoreError::Validation(format!(
205                "authority.key_state preflight: refuse to activate key `{}` for principal `{}` without a current trust tier row",
206                record.key_id, record.principal_id,
207            ))),
208            Some(_) => Ok(()),
209        }
210    }
211
212    /// Revalidate a key's authority at event time and for current use.
213    pub fn revalidate(
214        &self,
215        query: &TemporalAuthorityQuery,
216    ) -> StoreResult<TemporalAuthorityReport> {
217        let key_at_event = self.key_state_at(&query.key_id, query.event_time)?;
218        let current_key_state = self.key_state_at(&query.key_id, query.now)?;
219        let key_activated_at = self.key_state_effective_at(
220            &query.key_id,
221            KeyLifecycleState::Active,
222            query.event_time,
223        )?;
224        let key_retired_at =
225            self.key_state_effective_at(&query.key_id, KeyLifecycleState::Retired, query.now)?;
226        let key_revoked_at =
227            self.key_state_effective_at(&query.key_id, KeyLifecycleState::Revoked, query.now)?;
228        let principal_id = key_at_event
229            .as_ref()
230            .or(current_key_state.as_ref())
231            .map(|record| record.principal_id.clone());
232        let trust_at_event = match principal_id.as_deref() {
233            Some(principal_id) => self.principal_state_at(principal_id, query.event_time)?,
234            None => None,
235        };
236        let current_trust = match principal_id.as_deref() {
237            Some(principal_id) => self.principal_state_at(principal_id, query.now)?,
238            None => None,
239        };
240
241        Ok(revalidate_temporal_authority(TemporalAuthorityEvidence {
242            key_id: query.key_id.clone(),
243            principal_id,
244            event_time: query.event_time,
245            now: query.now,
246            key_activated_at,
247            key_retired_at,
248            key_revoked_at,
249            trust_tier_at_event_time: trust_at_event.as_ref().map(|record| record.trust_tier),
250            current_trust_tier: current_trust.as_ref().map(|record| record.trust_tier),
251            current_trust_tier_effective_at: current_trust
252                .as_ref()
253                .map(|record| record.effective_at),
254            minimum_trust_tier: query.minimum_trust_tier,
255            principal_removed_at: current_trust.as_ref().and_then(|record| record.removed_at),
256            trust_review_due_at: current_trust
257                .as_ref()
258                .and_then(|record| record.trust_review_due_at),
259        }))
260    }
261
262    fn key_state_at(
263        &self,
264        key_id: &str,
265        at: DateTime<Utc>,
266    ) -> StoreResult<Option<KeyTimelineRecord>> {
267        let row = self
268            .pool
269            .query_row(
270                "SELECT key_id, principal_id, state, effective_at, reason, audit_ref
271                 FROM authority_key_timeline
272                 WHERE key_id = ?1 AND effective_at <= ?2
273                 ORDER BY effective_at DESC, state DESC
274                 LIMIT 1;",
275                params![key_id, at.to_rfc3339()],
276                key_timeline_row,
277            )
278            .optional()?;
279
280        row.map(TryInto::try_into).transpose()
281    }
282
283    fn key_state_effective_at(
284        &self,
285        key_id: &str,
286        state: KeyLifecycleState,
287        at: DateTime<Utc>,
288    ) -> StoreResult<Option<DateTime<Utc>>> {
289        let value = self
290            .pool
291            .query_row(
292                "SELECT effective_at
293                 FROM authority_key_timeline
294                 WHERE key_id = ?1 AND state = ?2 AND effective_at <= ?3
295                 ORDER BY effective_at DESC
296                 LIMIT 1;",
297                params![key_id, key_state_wire(state), at.to_rfc3339()],
298                |row| row.get::<_, String>(0),
299            )
300            .optional()?;
301
302        value.as_deref().map(parse_utc).transpose()
303    }
304
305    fn principal_state_at(
306        &self,
307        principal_id: &str,
308        at: DateTime<Utc>,
309    ) -> StoreResult<Option<PrincipalTimelineRecord>> {
310        let row = self
311            .pool
312            .query_row(
313                "SELECT principal_id, trust_tier, effective_at, trust_review_due_at, removed_at, audit_ref
314                 FROM authority_principal_timeline
315                 WHERE principal_id = ?1 AND effective_at <= ?2
316                 ORDER BY effective_at DESC
317                 LIMIT 1;",
318                params![principal_id, at.to_rfc3339()],
319                principal_timeline_row,
320            )
321            .optional()?;
322
323        row.map(TryInto::try_into).transpose()
324    }
325}
326
327#[derive(Debug)]
328struct KeyTimelineRow {
329    key_id: String,
330    principal_id: String,
331    state: String,
332    effective_at: String,
333    reason: Option<String>,
334    audit_ref: Option<String>,
335}
336
337fn key_timeline_row(row: &Row<'_>) -> rusqlite::Result<KeyTimelineRow> {
338    Ok(KeyTimelineRow {
339        key_id: row.get(0)?,
340        principal_id: row.get(1)?,
341        state: row.get(2)?,
342        effective_at: row.get(3)?,
343        reason: row.get(4)?,
344        audit_ref: row.get(5)?,
345    })
346}
347
348impl TryFrom<KeyTimelineRow> for KeyTimelineRecord {
349    type Error = StoreError;
350
351    fn try_from(row: KeyTimelineRow) -> StoreResult<Self> {
352        Ok(Self {
353            key_id: row.key_id,
354            principal_id: row.principal_id,
355            state: parse_key_state(&row.state)?,
356            effective_at: parse_utc(&row.effective_at)?,
357            reason: row.reason,
358            audit_ref: row.audit_ref,
359        })
360    }
361}
362
363#[derive(Debug)]
364struct PrincipalTimelineRow {
365    principal_id: String,
366    trust_tier: String,
367    effective_at: String,
368    trust_review_due_at: Option<String>,
369    removed_at: Option<String>,
370    audit_ref: Option<String>,
371}
372
373fn principal_timeline_row(row: &Row<'_>) -> rusqlite::Result<PrincipalTimelineRow> {
374    Ok(PrincipalTimelineRow {
375        principal_id: row.get(0)?,
376        trust_tier: row.get(1)?,
377        effective_at: row.get(2)?,
378        trust_review_due_at: row.get(3)?,
379        removed_at: row.get(4)?,
380        audit_ref: row.get(5)?,
381    })
382}
383
384impl TryFrom<PrincipalTimelineRow> for PrincipalTimelineRecord {
385    type Error = StoreError;
386
387    fn try_from(row: PrincipalTimelineRow) -> StoreResult<Self> {
388        Ok(Self {
389            principal_id: row.principal_id,
390            trust_tier: parse_trust_tier(&row.trust_tier)?,
391            effective_at: parse_utc(&row.effective_at)?,
392            trust_review_due_at: row
393                .trust_review_due_at
394                .as_deref()
395                .map(parse_utc)
396                .transpose()?,
397            removed_at: row.removed_at.as_deref().map(parse_utc).transpose()?,
398            audit_ref: row.audit_ref,
399        })
400    }
401}
402
403fn require_policy_final_outcome(policy: &PolicyDecision, surface: &str) -> StoreResult<()> {
404    match policy.final_outcome {
405        PolicyOutcome::Allow | PolicyOutcome::Warn | PolicyOutcome::BreakGlass => Ok(()),
406        PolicyOutcome::Quarantine | PolicyOutcome::Reject => Err(StoreError::Validation(format!(
407            "{surface} preflight: composed policy outcome {:?} blocks authority-root mutation",
408            policy.final_outcome,
409        ))),
410    }
411}
412
413fn require_contributor_rule(policy: &PolicyDecision, rule_id: &str) -> StoreResult<()> {
414    let contains_rule = policy
415        .contributing
416        .iter()
417        .chain(policy.discarded.iter())
418        .any(|contribution| contribution.rule_id.as_str() == rule_id);
419    if contains_rule {
420        Ok(())
421    } else {
422        Err(StoreError::Validation(format!(
423            "policy decision missing required contributor `{rule_id}`; caller skipped ADR 0026 composition",
424        )))
425    }
426}
427
428fn require_attestation_not_break_glassed(
429    policy: &PolicyDecision,
430    rule_id: &str,
431    surface: &str,
432) -> StoreResult<()> {
433    // ADR 0026 §4: BreakGlass MUST NOT substitute for required attestation at
434    // an authority root. The attestation contributor must itself have voted
435    // `Allow` regardless of how the rest of the composition resolved.
436    let attestation = policy
437        .contributing
438        .iter()
439        .chain(policy.discarded.iter())
440        .find(|contribution| contribution.rule_id.as_str() == rule_id)
441        .ok_or_else(|| {
442            StoreError::Validation(format!(
443                "{surface} preflight: required attestation contributor `{rule_id}` is absent from the policy decision",
444            ))
445        })?;
446    if attestation.outcome == PolicyOutcome::Allow {
447        Ok(())
448    } else {
449        Err(StoreError::Validation(format!(
450            "{surface} preflight: attestation contributor `{rule_id}` returned {:?}; ADR 0026 §4 forbids BreakGlass substituting for attestation",
451            attestation.outcome,
452        )))
453    }
454}
455
456/// Build a `PolicyDecision` that satisfies [`AuthorityRepo::append_key_state`]
457/// inputs for the happy path. Intended for tests and fixtures only.
458///
459/// Production callers MUST compose
460/// [`KEY_STATE_ATTESTED_BY_OPERATOR_RULE_ID`] and
461/// [`KEY_STATE_TRUST_TIER_GATE_RULE_ID`] from real attestation evidence and
462/// real trust-tier state. This helper is exposed unconditionally because
463/// integration test crates outside `cortex-store` need the same fixture
464/// shape; the `_test_allow` suffix is the contract that documents intent.
465#[must_use]
466pub fn key_state_policy_decision_test_allow() -> PolicyDecision {
467    use cortex_core::compose_policy_outcomes;
468    compose_policy_outcomes(
469        vec![
470            PolicyContribution::new(
471                KEY_STATE_ATTESTED_BY_OPERATOR_RULE_ID,
472                PolicyOutcome::Allow,
473                "test fixture: operator attestation present",
474            )
475            .expect("static test contribution is valid"),
476            PolicyContribution::new(
477                KEY_STATE_TRUST_TIER_GATE_RULE_ID,
478                PolicyOutcome::Allow,
479                "test fixture: trust tier gate satisfied",
480            )
481            .expect("static test contribution is valid"),
482        ],
483        None,
484    )
485}
486
487/// Build a `PolicyDecision` that satisfies
488/// [`AuthorityRepo::append_principal_state`] inputs for the happy path.
489/// Intended for tests and fixtures only; see
490/// [`key_state_policy_decision_test_allow`] for the production-caller
491/// contract.
492#[must_use]
493pub fn principal_state_policy_decision_test_allow() -> PolicyDecision {
494    use cortex_core::compose_policy_outcomes;
495    compose_policy_outcomes(
496        vec![
497            PolicyContribution::new(
498                PRINCIPAL_STATE_ATTESTED_BY_OPERATOR_RULE_ID,
499                PolicyOutcome::Allow,
500                "test fixture: operator attestation present",
501            )
502            .expect("static test contribution is valid"),
503            PolicyContribution::new(
504                PRINCIPAL_STATE_TRUST_TIER_GATE_RULE_ID,
505                PolicyOutcome::Allow,
506                "test fixture: trust tier gate satisfied",
507            )
508            .expect("static test contribution is valid"),
509            PolicyContribution::new(
510                PRINCIPAL_STATE_TIER_PROMOTION_ATTESTATION_RULE_ID,
511                PolicyOutcome::Allow,
512                "test fixture: tier promotion attestation present",
513            )
514            .expect("static test contribution is valid"),
515        ],
516        None,
517    )
518}
519
520fn parse_utc(value: &str) -> StoreResult<DateTime<Utc>> {
521    Ok(DateTime::parse_from_rfc3339(value)?.with_timezone(&Utc))
522}
523
524fn key_state_wire(state: KeyLifecycleState) -> &'static str {
525    match state {
526        KeyLifecycleState::Active => "active",
527        KeyLifecycleState::Retired => "retired",
528        KeyLifecycleState::Revoked => "revoked",
529    }
530}
531
532fn parse_key_state(value: &str) -> StoreResult<KeyLifecycleState> {
533    match value {
534        "active" => Ok(KeyLifecycleState::Active),
535        "retired" => Ok(KeyLifecycleState::Retired),
536        "revoked" => Ok(KeyLifecycleState::Revoked),
537        other => Err(StoreError::Validation(format!(
538            "unknown key lifecycle state `{other}`"
539        ))),
540    }
541}
542
543fn trust_tier_wire(tier: TrustTier) -> &'static str {
544    match tier {
545        TrustTier::Untrusted => "untrusted",
546        TrustTier::Observed => "observed",
547        TrustTier::Verified => "verified",
548        TrustTier::Operator => "operator",
549    }
550}
551
552fn parse_trust_tier(value: &str) -> StoreResult<TrustTier> {
553    match value {
554        "untrusted" => Ok(TrustTier::Untrusted),
555        "observed" => Ok(TrustTier::Observed),
556        "verified" => Ok(TrustTier::Verified),
557        "operator" => Ok(TrustTier::Operator),
558        other => Err(StoreError::Validation(format!(
559            "unknown trust tier `{other}`"
560        ))),
561    }
562}