aperion_shield/orgmode/mod.rs
1//! Aperion Shield -- org-mode client.
2//!
3//! When `aperion-shield enroll <token>` is run, this module bootstraps a
4//! persistent record at `~/.aperion-shield/orgmode.json` and switches the
5//! binary into "org mode":
6//!
7//! - Policy is pulled from Smartflow every 30 s, hot-reloaded on
8//! version bump.
9//! - Audit events are streamed to `/api/enterprise/shield/events`.
10//! - Identity-gated rules use the `SmartflowProvider`, which delegates
11//! to Smartflow as the relying party (which in turn talks to ID.me
12//! or any configured OIDC IdP).
13//! - A heartbeat keeps the fleet dashboard accurate.
14//!
15//! When no enrollment record exists, none of this code runs and the
16//! binary behaves byte-identically to the free standalone tier. The
17//! org-mode plumbing is intentionally optional and additive.
18//!
19//! Module layout:
20//!
21//! ```text
22//! orgmode/
23//! mod.rs -- public OrgClient + top-level coordinator
24//! state.rs -- on-disk enrollment record
25//! client.rs -- thin reqwest wrapper around the smartflow REST API
26//! enroll.rs -- `aperion-shield enroll <token>` impl
27//! heartbeat.rs -- 30 s heartbeat task
28//! policy_pull.rs -- 30 s policy version poll + hot-reload publisher
29//! audit_sink.rs -- bounded queue, batched POST every 5 s
30//! smartflow_provider.rs -- IdentityProvider implementation
31//! ```
32
33#![allow(clippy::module_inception)]
34
35pub mod audit_sink;
36pub mod client;
37pub mod enroll;
38pub mod heartbeat;
39pub mod policy_pull;
40pub mod smartflow_provider;
41pub mod state;
42
43pub use audit_sink::{AuditEvent, AuditSink};
44pub use client::{OrgApi, OrgApiError};
45pub use enroll::{run_disenroll, run_enroll, run_status};
46pub use heartbeat::start_heartbeat;
47pub use policy_pull::{start_policy_pull, PolicyPullHandle};
48pub use smartflow_provider::SmartflowProvider;
49pub use state::{OrgState, ORG_STATE_FILE};
50
51use std::sync::Arc;
52
53use crate::Engine;
54
55/// Outcome of attempting to bootstrap org mode at startup.
56pub enum OrgBootstrap {
57 /// No `orgmode.json` was found -- behave as plain standalone.
58 Standalone,
59 /// Enrolled with smartflow. The contained handles are the policy
60 /// pull / heartbeat / audit-sink machinery; dropping any of them
61 /// drains the corresponding task.
62 Enrolled(EnrolledHandles),
63}
64
65/// Handles returned when org mode is active. Held by `main()` for the
66/// lifetime of the process.
67pub struct EnrolledHandles {
68 pub state: OrgState,
69 pub api: Arc<OrgApi>,
70 pub policy: PolicyPullHandle,
71 pub audit: Arc<AuditSink>,
72 pub _heartbeat_task: tokio::task::JoinHandle<()>,
73}
74
75/// Helper used by `main()` to load the engine that should be active at
76/// process start: org-mode shieldset if enrolled (and reachable), local
77/// rules otherwise.
78///
79/// On failure to reach Smartflow we fall back to the local engine and
80/// log a warning -- this matches the `cached_policy` default offline
81/// behaviour documented in the strategy memo.
82pub async fn load_initial_engine(
83 state: &OrgState,
84 api: &OrgApi,
85 fallback: Engine,
86) -> Engine {
87 match api.get_shieldset(&state.policy_group).await {
88 Ok((yaml, version)) => {
89 log::warn!(
90 "[shield] org-mode policy pulled from {} group={} version={}",
91 state.smartflow_url,
92 state.policy_group,
93 version
94 );
95 match crate::Engine::from_yaml(&yaml) {
96 Ok(eng) => eng,
97 Err(e) => {
98 log::error!(
99 "[shield] failed to compile pulled shieldset (group={}): {}. \
100 Falling back to local rules.",
101 state.policy_group,
102 e
103 );
104 fallback
105 }
106 }
107 }
108 Err(e) => {
109 log::warn!(
110 "[shield] could not pull policy from Smartflow ({}); using local rules: {}",
111 state.smartflow_url,
112 e
113 );
114 fallback
115 }
116 }
117}