Skip to main content

arkhe_kernel/state/
authz.rs

1//! Authorization phantom-typed Effect (A8 + A19).
2//!
3//! `Effect<'i, S: AuthState>` carries:
4//! - `S` — authorization state at the type level (Unverified or Authorized).
5//! - `'i` — invariant-lifetime brand (GhostCell pattern, A19) preventing
6//!   cross-instance reuse of authorized effects.
7//!
8//! `authorize()` is the sole constructor for `Effect<'i, Authorized>` —
9//! external code cannot fabricate an `Authorized` value because the
10//! struct fields are `pub(crate)` and the path through `authorize()`
11//! is the only audited gate.
12
13use crate::abi::{CapabilityMask, InstanceId, Principal};
14use core::marker::PhantomData;
15
16use super::op::Op;
17
18/// Invariant lifetime — both contravariant and covariant in `'i`.
19/// Standard GhostCell pattern (Yanovski et al., ICFP 2021). Do not change.
20pub(crate) type InvariantLifetime<'i> = PhantomData<fn(&'i ()) -> &'i ()>;
21
22mod seal {
23    pub trait Sealed {}
24}
25
26/// Uninhabited tag — Effect has not been authorized.
27#[derive(Debug)]
28pub enum Unverified {}
29
30/// Uninhabited tag — Effect has been validated by `authorize()`.
31#[derive(Debug)]
32pub enum Authorized {}
33
34impl seal::Sealed for Unverified {}
35impl seal::Sealed for Authorized {}
36
37/// Sealed marker — only `Unverified` and `Authorized` may implement.
38pub trait AuthState: seal::Sealed {}
39impl AuthState for Unverified {}
40impl AuthState for Authorized {}
41
42/// Phantom-typed Effect — carries `Op` payload + `Principal` origin tag.
43///
44/// `instance_id` is the runtime witness retained against brand-erasure
45/// at WAL boundaries (replay reconstructs a fresh `'j`-branded scope and
46/// uses the witness to verify the instance binding survives).
47#[derive(Debug)]
48pub struct Effect<'i, S: AuthState> {
49    pub(crate) instance_id: InstanceId,
50    pub(crate) principal: Principal,
51    pub(crate) op: Op,
52    _state: PhantomData<S>,
53    _brand: InvariantLifetime<'i>,
54}
55
56impl<'i> Effect<'i, Unverified> {
57    /// Construct an unauthorized Effect. `pub(crate)` because external
58    /// code submits Effects through `Kernel::submit`.
59    pub(crate) fn new(instance_id: InstanceId, principal: Principal, op: Op) -> Self {
60        Self {
61            instance_id,
62            principal,
63            op,
64            _state: PhantomData,
65            _brand: PhantomData,
66        }
67    }
68}
69
70impl<'i, S: AuthState> Effect<'i, S> {
71    /// `InstanceId` this effect is bound to. Survives the brand-erasure
72    /// boundary at WAL serialize/deserialize.
73    pub fn instance_id(&self) -> InstanceId {
74        self.instance_id
75    }
76}
77
78/// Reason an effect was denied during authorization.
79#[non_exhaustive]
80#[derive(Clone, Debug, PartialEq, Eq)]
81pub enum DenyReason {
82    /// `CapabilityMask` lacks the bit required by the principal/op pair.
83    CapabilityDenied,
84    /// Effect's branded instance does not match the authorize call site.
85    InstanceMismatch,
86    /// Op type is restricted under the current policy (reserved for
87    /// future per-op deny refinement).
88    OperationRestricted,
89    /// Authorization path not yet wired for this op variant.
90    NotImplemented,
91}
92
93impl core::fmt::Display for DenyReason {
94    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
95        match self {
96            Self::CapabilityDenied => write!(f, "capability denied"),
97            Self::InstanceMismatch => write!(f, "instance mismatch"),
98            Self::OperationRestricted => write!(f, "operation restricted"),
99            Self::NotImplemented => write!(f, "authorize not implemented"),
100        }
101    }
102}
103
104impl std::error::Error for DenyReason {}
105
106/// Resolve the effective capabilities an action runs under, from the
107/// instance's configured `default_caps`, the action's `principal`, and the
108/// `ceiling` it inherited (an external submission's granted caps, or a
109/// scheduling parent's effective caps). Authority is the *intersection* of
110/// config and ceiling on every authenticated path (min-of-bounds, like the
111/// A10 quota reduction): `Principal::System` is bounded by `default_caps &
112/// ceiling` exactly as `External` is — it is NOT an unconditional bypass and
113/// NOT exempt from the ceiling. System is privileged only insofar as
114/// `default_caps` grants AND the ceiling permits, so a submission or a
115/// scheduled child of ANY principal can never exceed either bound and
116/// privilege only narrows across a schedule (a scheduled child's ceiling is
117/// the parent's effective caps, captured at schedule time, so a later-raised
118/// operator session ceiling cannot re-widen the child — no time-shift
119/// escalation). `Unauthenticated` is always empty.
120pub(crate) fn effective_caps(
121    default_caps: CapabilityMask,
122    principal: &Principal,
123    ceiling: CapabilityMask,
124) -> CapabilityMask {
125    match principal {
126        Principal::System | Principal::External(_) => default_caps & ceiling,
127        Principal::Unauthenticated => CapabilityMask::empty(),
128    }
129}
130
131/// Authorize an unverified Effect against the already-resolved
132/// `effective_caps` (see [`effective_caps`]) for its (instance, principal,
133/// ceiling) — intersected by the caller with any operator session ceiling.
134///
135/// Policy (per-entity ownership refinement deferred):
136/// - `Principal::Unauthenticated`   — denied (no read/write paths yet).
137/// - `Principal::System` / `External(_)` — gated identically by
138///   `effective_caps`: the `SYSTEM` bit grants all Ops, otherwise per-Op
139///   cap match (`match_op_cap`). System holds authority only when the
140///   instance's `default_caps` grant it — there is no blanket bypass.
141pub(crate) fn authorize<'i>(
142    caps: CapabilityMask,
143    effect: Effect<'i, Unverified>,
144) -> Result<Effect<'i, Authorized>, DenyReason> {
145    match &effect.principal {
146        Principal::Unauthenticated => return Err(DenyReason::CapabilityDenied),
147        Principal::System | Principal::External(_) => {
148            if !caps.contains(CapabilityMask::SYSTEM) && !match_op_cap(&effect.op, caps) {
149                return Err(DenyReason::CapabilityDenied);
150            }
151        }
152    }
153    Ok(Effect {
154        instance_id: effect.instance_id,
155        principal: effect.principal,
156        op: effect.op,
157        _state: PhantomData,
158        _brand: PhantomData,
159    })
160}
161
162/// Per-Op capability matching. Per-entity ownership and finer-grained
163/// cap bits are reserved (deferred).
164fn match_op_cap(op: &Op, caps: CapabilityMask) -> bool {
165    match op {
166        // Basic state mutations: open to External in v0.14.
167        Op::SpawnEntity { .. }
168        | Op::DespawnEntity { .. }
169        | Op::SetComponent { .. }
170        | Op::RemoveComponent { .. }
171        | Op::EmitEvent { .. } => true,
172        // Scheduler/IPC require SYSTEM cap.
173        Op::ScheduleAction { .. } | Op::SendSignal { .. } => caps.contains(CapabilityMask::SYSTEM),
174    }
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180    use crate::abi::{EntityId, ExternalId, RouteId, Tick, TypeCode};
181    use bytes::Bytes;
182
183    fn inst() -> InstanceId {
184        InstanceId::new(1).unwrap()
185    }
186    fn ent() -> EntityId {
187        EntityId::new(1).unwrap()
188    }
189
190    #[test]
191    fn unverified_is_uninhabited() {
192        fn _proof(x: Unverified) -> ! {
193            match x {}
194        }
195    }
196
197    #[test]
198    fn authorized_is_uninhabited() {
199        fn _proof(x: Authorized) -> ! {
200            match x {}
201        }
202    }
203
204    #[test]
205    fn effect_carries_instance_id_and_op() {
206        let e: Effect<'_, Unverified> = Effect::new(
207            inst(),
208            Principal::System,
209            Op::SpawnEntity {
210                id: ent(),
211                owner: Principal::System,
212            },
213        );
214        assert_eq!(e.instance_id().get(), 1);
215    }
216
217    #[test]
218    fn auth_state_seal_blocks_external_impl() {
219        fn assert_authstate<T: AuthState>() {}
220        assert_authstate::<Unverified>();
221        assert_authstate::<Authorized>();
222    }
223
224    #[test]
225    fn deny_reason_display_and_error() {
226        assert_eq!(
227            format!("{}", DenyReason::CapabilityDenied),
228            "capability denied"
229        );
230        assert_eq!(
231            format!("{}", DenyReason::OperationRestricted),
232            "operation restricted"
233        );
234        fn assert_err<E: std::error::Error>() {}
235        assert_err::<DenyReason>();
236    }
237
238    // ---- authorize() body tests ----
239
240    fn spawn_op() -> Op {
241        Op::SpawnEntity {
242            id: ent(),
243            owner: Principal::System,
244        }
245    }
246    fn schedule_op() -> Op {
247        Op::ScheduleAction {
248            at: Tick(0),
249            actor: None,
250            action_type_code: TypeCode(0),
251            action_bytes: Bytes::new(),
252        }
253    }
254    fn signal_op() -> Op {
255        Op::SendSignal {
256            target: inst(),
257            route: RouteId(1),
258            payload: Bytes::new(),
259        }
260    }
261
262    #[test]
263    fn system_principal_gated_by_effective_caps_not_blanket_bypass() {
264        // System is NOT an unconditional bypass: under empty effective caps a
265        // SYSTEM-gated op (schedule/signal) is denied; under SYSTEM caps all
266        // ops pass. (Basic state ops remain open to any non-empty principal.)
267        for op in [schedule_op(), signal_op()] {
268            let denied = authorize(CapabilityMask::empty(), Effect::new(inst(), Principal::System, op));
269            assert_eq!(
270                denied.unwrap_err(),
271                DenyReason::CapabilityDenied,
272                "System with empty caps must NOT pass a SYSTEM-gated op"
273            );
274        }
275        for op in [spawn_op(), schedule_op(), signal_op()] {
276            let ok = authorize(CapabilityMask::SYSTEM, Effect::new(inst(), Principal::System, op));
277            assert!(ok.is_ok(), "System with SYSTEM caps passes every op");
278        }
279    }
280
281    #[test]
282    fn effective_caps_intersects_config_and_ceiling_for_authenticated_principals() {
283        // Effective caps = default_caps ∩ ceiling (min-of-bounds) for BOTH
284        // System and External — System is not exempt from the ceiling.
285        let full = CapabilityMask::all();
286        let none = CapabilityMask::empty();
287        let ext = Principal::External(ExternalId(7));
288        let sys = Principal::System;
289        assert_eq!(effective_caps(full, &ext, none), none, "ceiling caps to none");
290        assert_eq!(effective_caps(none, &ext, full), none, "config caps to none");
291        assert_eq!(effective_caps(full, &ext, full), full);
292        // System obeys the ceiling exactly like External (monotone narrowing).
293        assert_eq!(
294            effective_caps(full, &sys, none),
295            none,
296            "System is bounded by the ceiling — a low ceiling narrows it (no bypass)"
297        );
298        assert_eq!(effective_caps(none, &sys, full), none, "System bounded by config");
299        assert_eq!(effective_caps(full, &sys, full), full);
300        // Unauthenticated = empty always.
301        assert_eq!(
302            effective_caps(full, &Principal::Unauthenticated, full),
303            none
304        );
305    }
306
307    #[test]
308    fn scheduled_system_child_cannot_rewiden_past_caps_ceiling() {
309        // The core no-time-shift-escalation invariant for System children: a
310        // child whose caps_ceiling (= the parent's narrowed effective caps) is
311        // a strict subset of default_caps stays bounded by that ceiling even if
312        // default_caps (or a later session) would permit more. Pre-fix this
313        // returned `default_caps`, silently re-widening the child.
314        let full = CapabilityMask::all();
315        let parent_effective = CapabilityMask::SYSTEM; // parent ran narrowed
316        let child = effective_caps(full, &Principal::System, parent_effective);
317        assert_eq!(
318            child, parent_effective,
319            "System child is locked to the parent's effective caps, never default_caps"
320        );
321        assert!(
322            child.bits() <= parent_effective.bits(),
323            "child authority is a subset of the scheduling parent's"
324        );
325    }
326
327    #[test]
328    fn unauthenticated_principal_always_denied() {
329        let e = Effect::new(inst(), Principal::Unauthenticated, spawn_op());
330        let result = authorize(CapabilityMask::SYSTEM, e);
331        assert_eq!(result.unwrap_err(), DenyReason::CapabilityDenied);
332    }
333
334    #[test]
335    fn external_with_system_cap_authorized() {
336        let e = Effect::new(inst(), Principal::External(ExternalId(7)), schedule_op());
337        let result = authorize(CapabilityMask::SYSTEM, e);
338        assert!(result.is_ok());
339    }
340
341    #[test]
342    fn external_without_cap_denied_for_schedule() {
343        let e = Effect::new(inst(), Principal::External(ExternalId(7)), schedule_op());
344        let result = authorize(CapabilityMask::default(), e);
345        assert_eq!(result.unwrap_err(), DenyReason::CapabilityDenied);
346    }
347
348    #[test]
349    fn external_without_cap_denied_for_send_signal() {
350        let e = Effect::new(inst(), Principal::External(ExternalId(7)), signal_op());
351        let result = authorize(CapabilityMask::default(), e);
352        assert_eq!(result.unwrap_err(), DenyReason::CapabilityDenied);
353    }
354
355    #[test]
356    fn external_with_basic_cap_authorized_for_state_op() {
357        let e = Effect::new(inst(), Principal::External(ExternalId(7)), spawn_op());
358        let result = authorize(CapabilityMask::default(), e);
359        assert!(
360            result.is_ok(),
361            "External with basic state op (no SYSTEM) is allowed in MVP"
362        );
363    }
364}