Skip to main content

secunit_core/
model.rs

1//! Strongly-typed registry model.
2//!
3//! Types here mirror the on-disk YAML/JSON shapes documented in
4//! `docs/storage.md` and validated by the schemas under `schemas/`. Keep
5//! field names aligned with the schemas — `serde` deserialisation is the
6//! load path used by `registry::loader`.
7
8use std::collections::BTreeMap;
9use std::path::PathBuf;
10
11use chrono::NaiveDate;
12use serde::{Deserialize, Serialize};
13
14// ---------- shared primitives -----------------------------------------------
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
17#[serde(rename_all = "kebab-case")]
18pub enum Cadence {
19    Continuous,
20    Weekly,
21    Monthly,
22    Quarterly,
23    #[serde(rename = "semi-annual")]
24    SemiAnnual,
25    Annual,
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
29#[serde(rename_all = "lowercase")]
30pub enum Weekday {
31    Monday,
32    Tuesday,
33    Wednesday,
34    Thursday,
35    Friday,
36    Saturday,
37    Sunday,
38}
39
40impl Weekday {
41    pub fn to_chrono(self) -> chrono::Weekday {
42        match self {
43            Weekday::Monday => chrono::Weekday::Mon,
44            Weekday::Tuesday => chrono::Weekday::Tue,
45            Weekday::Wednesday => chrono::Weekday::Wed,
46            Weekday::Thursday => chrono::Weekday::Thu,
47            Weekday::Friday => chrono::Weekday::Fri,
48            Weekday::Saturday => chrono::Weekday::Sat,
49            Weekday::Sunday => chrono::Weekday::Sun,
50        }
51    }
52}
53
54// ---------- control ---------------------------------------------------------
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct Control {
58    pub id: String,
59    pub title: String,
60    pub policy: String,
61    #[serde(default)]
62    pub nist: Vec<String>,
63    pub owner: String,
64    pub cadence: Cadence,
65    #[serde(default)]
66    pub weekday: Option<Weekday>,
67    #[serde(default)]
68    pub due_by: Option<String>,
69    pub skill: String,
70    #[serde(default)]
71    pub skill_args: Option<serde_json::Value>,
72    #[serde(default)]
73    pub scope: Option<Scope>,
74    #[serde(default)]
75    pub evidence_required: Vec<EvidenceRequirement>,
76    #[serde(default)]
77    pub remediation_thresholds: BTreeMap<String, u32>,
78    #[serde(default)]
79    pub outputs: Option<serde_json::Value>,
80    #[serde(default)]
81    pub references: Vec<Reference>,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct Reference {
86    pub title: String,
87    #[serde(default)]
88    pub path: Option<String>,
89    #[serde(default)]
90    pub url: Option<String>,
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct EvidenceRequirement {
95    pub kind: String,
96    #[serde(default)]
97    pub description: Option<String>,
98    #[serde(default)]
99    pub prompt: Option<String>,
100    #[serde(default)]
101    pub path: Option<String>,
102    #[serde(default)]
103    pub cmd: Option<String>,
104    #[serde(default)]
105    pub per_system: Option<bool>,
106}
107
108// ---------- scope -----------------------------------------------------------
109
110#[derive(Debug, Clone, Serialize, Deserialize)]
111#[serde(untagged)]
112pub enum Scope {
113    Inventory(InventoryScope),
114    Inline(InlineScope),
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize)]
118pub struct InventoryScope {
119    pub kind: String,
120    #[serde(default)]
121    pub all: Option<bool>,
122    #[serde(default)]
123    pub has_tags: Vec<String>,
124    #[serde(default)]
125    pub excludes: Vec<String>,
126}
127
128#[derive(Debug, Clone, Serialize, Deserialize)]
129pub struct InlineScope {
130    pub inline: Vec<InlineEntry>,
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize)]
134pub struct InlineEntry {
135    pub name: String,
136    pub kind: String,
137    #[serde(default)]
138    pub tags: Vec<String>,
139}
140
141// ---------- inventory -------------------------------------------------------
142
143/// Inventory is a top-level map keyed by `kind` → `Vec<Entry>`. Kinds are
144/// free-form; the conventional set is documented in `docs/storage.md`.
145#[derive(Debug, Clone, Default, Serialize, Deserialize)]
146#[serde(transparent)]
147pub struct Inventory {
148    pub kinds: BTreeMap<String, Vec<InventoryEntry>>,
149}
150
151impl Inventory {
152    /// Inventory sections are conventionally plural (`source_repos`,
153    /// `cloud_accounts`) but `scope.kind` is singular (`source_repo`,
154    /// `cloud_account`). Look up by exact match first, then by trailing-`s`
155    /// pluralisation.
156    pub fn entries(&self, kind: &str) -> &[InventoryEntry] {
157        if let Some(v) = self.kinds.get(kind) {
158            return v;
159        }
160        let plural = format!("{kind}s");
161        if let Some(v) = self.kinds.get(&plural) {
162            return v;
163        }
164        &[]
165    }
166
167    /// Returns the canonical section name (as it appears in YAML) that
168    /// satisfies the given scope kind, if any. Used by validation to tell
169    /// "section is missing" from "kind is genuinely unknown".
170    pub fn section_for(&self, kind: &str) -> Option<&str> {
171        if let Some((k, _)) = self.kinds.get_key_value(kind) {
172            return Some(k.as_str());
173        }
174        let plural = format!("{kind}s");
175        self.kinds.get_key_value(&plural).map(|(k, _)| k.as_str())
176    }
177
178    pub fn iter(&self) -> impl Iterator<Item = (&String, &InventoryEntry)> {
179        self.kinds
180            .iter()
181            .flat_map(|(k, v)| v.iter().map(move |e| (k, e)))
182    }
183}
184
185#[derive(Debug, Clone, Serialize, Deserialize)]
186pub struct InventoryEntry {
187    pub name: String,
188    #[serde(default)]
189    pub tags: Vec<String>,
190    #[serde(default)]
191    pub in_scope_since: Option<NaiveDate>,
192    #[serde(default)]
193    pub retired_on: Option<NaiveDate>,
194    #[serde(default)]
195    pub aliases: Vec<String>,
196    /// Skill names this entry opts out of, even if the tag filter matches.
197    #[serde(default)]
198    pub excludes: Vec<String>,
199    /// Anything else from the YAML — `url`, `stack`, `provider`, `profile`,
200    /// `owner`, `address`, etc. — surfaced to skills as-is.
201    #[serde(flatten)]
202    pub extras: BTreeMap<String, serde_json::Value>,
203}
204
205impl InventoryEntry {
206    /// Active on `date` according to lifecycle dates. `in_scope_since` is
207    /// inclusive; `retired_on` is exclusive (per `storage.md`).
208    pub fn is_active_on(&self, date: NaiveDate) -> bool {
209        if let Some(start) = self.in_scope_since {
210            if date < start {
211                return false;
212            }
213        }
214        if let Some(end) = self.retired_on {
215            if date >= end {
216                return false;
217            }
218        }
219        true
220    }
221}
222
223// ---------- schedule --------------------------------------------------------
224
225#[derive(Debug, Clone, Default, Serialize, Deserialize)]
226pub struct Schedule {
227    #[serde(default)]
228    pub overrides: Vec<ScheduleEntry>,
229}
230
231#[derive(Debug, Clone, Serialize, Deserialize)]
232pub struct ScheduleEntry {
233    pub control_id: String,
234    #[serde(default)]
235    pub due: Option<NaiveDate>,
236    #[serde(default)]
237    pub weekday: Option<Weekday>,
238    #[serde(default)]
239    pub note: Option<String>,
240    #[serde(default)]
241    pub reason: Option<String>,
242    #[serde(default)]
243    pub skip: Option<ScheduleSkip>,
244    #[serde(default)]
245    pub insert: Option<ScheduleInsert>,
246}
247
248#[derive(Debug, Clone, Serialize, Deserialize)]
249pub struct ScheduleSkip {
250    #[serde(default)]
251    pub quarter: Option<String>,
252    #[serde(default)]
253    pub year: Option<i32>,
254    #[serde(default)]
255    pub reason: Option<String>,
256}
257
258#[derive(Debug, Clone, Serialize, Deserialize)]
259pub struct ScheduleInsert {
260    pub run_at: NaiveDate,
261    #[serde(default)]
262    pub reason: Option<String>,
263}
264
265// ---------- state -----------------------------------------------------------
266
267#[derive(Debug, Clone, Serialize, Deserialize)]
268pub struct State {
269    pub schema_version: u32,
270    #[serde(default)]
271    pub controls: BTreeMap<String, StateEntry>,
272    #[serde(default)]
273    pub updated_at: Option<chrono::DateTime<chrono::Utc>>,
274}
275
276impl Default for State {
277    fn default() -> Self {
278        Self {
279            schema_version: crate::SCHEMA_VERSION,
280            controls: BTreeMap::new(),
281            updated_at: None,
282        }
283    }
284}
285
286#[derive(Debug, Clone, Serialize, Deserialize)]
287pub struct StateEntry {
288    pub last_run_id: Option<String>,
289    pub last_run_path: Option<String>,
290    pub last_run_at: Option<chrono::DateTime<chrono::Utc>>,
291    pub last_status: RunStatus,
292    pub next_due: Option<NaiveDate>,
293}
294
295#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
296#[serde(rename_all = "kebab-case")]
297pub enum RunStatus {
298    Complete,
299    InProgress,
300    Failed,
301    NeverRun,
302}
303
304// ---------- config ----------------------------------------------------------
305
306#[derive(Debug, Clone, Default, Serialize, Deserialize)]
307pub struct Config {
308    #[serde(default)]
309    pub schema_version: Option<u32>,
310    #[serde(default)]
311    pub org: Option<OrgConfig>,
312    #[serde(default)]
313    pub owners: BTreeMap<String, String>,
314    #[serde(default)]
315    pub weekly_default_weekday: Option<Weekday>,
316    #[serde(default)]
317    pub thresholds: BTreeMap<String, serde_json::Value>,
318    #[serde(default)]
319    pub integrations: BTreeMap<String, serde_json::Value>,
320    /// Remaining org-specific fields surfaced to skills as-is.
321    #[serde(flatten)]
322    pub extras: BTreeMap<String, serde_json::Value>,
323}
324
325#[derive(Debug, Clone, Serialize, Deserialize)]
326pub struct OrgConfig {
327    #[serde(default)]
328    pub name: Option<String>,
329    #[serde(default)]
330    pub wisp_repo: Option<String>,
331    #[serde(flatten)]
332    pub extras: BTreeMap<String, serde_json::Value>,
333}
334
335// ---------- resolved scope --------------------------------------------------
336
337/// One entry produced by scope resolution. Carries enough metadata to be
338/// embedded into `prepare.json` and `manifest.json` directly.
339#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
340pub struct ResolvedSystem {
341    pub name: String,
342    pub kind: String,
343    #[serde(default)]
344    pub tags: Vec<String>,
345    /// Inventory-side fields (url, stack, provider, ...) preserved for
346    /// skill consumption.
347    #[serde(flatten)]
348    pub extras: BTreeMap<String, serde_json::Value>,
349}
350
351// ---------- registry container ---------------------------------------------
352
353/// The fully-loaded org tree, in memory.
354#[derive(Debug, Clone)]
355pub struct LoadedRegistry {
356    pub root: PathBuf,
357    pub controls: BTreeMap<String, Control>,
358    pub inventory: Inventory,
359    pub schedule: Schedule,
360    pub state: State,
361    pub config: Config,
362}