Skip to main content

ignition_core/client/
adopt.rs

1//! API-key adoption wire models + session-tier calls (ADOPT-01).
2//!
3//! Every shape here is LIVE-CAPTURED on the `ignition-devops` rig,
4//! Ignition 8.3.6 (b2026042713), 2026-09-18 — browser-devtools capture
5//! while clicking Config → Security → API Keys / General Settings as
6//! `admin`, the same method as the trial route. Full bodies + the
7//! capture narrative: `.planning/research/ADOPT-RESEARCH.md`.
8//!
9//! ## The wire (live-proven end-to-end, key minted + used same second)
10//!
11//! 8.3 API keys are NOT a bespoke route family — they are resource
12//! type `ignition/api-token` on the resources API, plus ONE generate
13//! endpoint for the key material:
14//!
15//! 1. `GET  /data/api/v1/resources/list/ignition/api-token` — the
16//!    idempotent find-by-name (hash only; the plaintext key appears
17//!    NOWHERE in this list).
18//! 2. `POST /data/api/v1/api-token/generate` — `{key, hash}`; the
19//!    plaintext key is returned HERE AND ONLY HERE.
20//! 3. `POST /data/api/v1/resources/ignition/api-token` — array-of-one
21//!    resource record carrying the hash (NOT the key).
22//! 4. The security-properties singleton — the permissions wiring
23//!    (doctor's part-2 diagnosis, made writable). The GET is
24//!    `/data/api/v1/resources/singleton/ignition/security-properties`
25//!    (carrying `?defaultIfUndefined=true`); the PUT is
26//!    `/data/api/v1/resources/ignition/security-properties`.
27//!
28//! All four ride the native OIDC session cookie + `X-CSRF-Token` —
29//! the [`crate::client::idp`] machinery verbatim (tier 1). This is
30//! the bootstrap credential path: adopt runs BEFORE any API token
31//! exists, so every call is session-tier.
32//!
33//! ## Live-discovered pitfalls (both documented in ADOPT-RESEARCH)
34//!
35//! - The security-properties PUT REPLACES the whole singleton config
36//!   and carries the GET's `signature` (optimistic concurrency — it
37//!   rotates on every write). Merge read-modify-write; never blind.
38//! - `securityLevels: []` on an AnyOf permission means PUBLIC
39//!   (everyone), not "nobody" — the UI serializes a checked Public
40//!   as an empty list. [`merge_level_into`] only ever ADDS.
41//! - Security levels are leaf-path TREES: granting
42//!   `Authenticated/Roles/Administrator` serializes as ONE root
43//!   `{name:"Authenticated", children:[{Roles, children:[{Administrator}]}]}`
44//!   ([`level_tree`]).
45//! - Pitfall 3, as MEASURED by plan 25-24 (G-25-11; fresh 8.3.6 and 8.3.3,
46//!   `25-LIVE-CAPTURES.md` `### API key write admission (G-25-11)`): a
47//!   nested write entry for a key's exact three-level path
48//!   (`Authenticated>Roles>Administrator`) admits NO key, not even one granted
49//!   that path. A nested TWO-level entry that no level definition declares
50//!   (`Authenticated>IgnCliWrite`) admits exactly the keys granted that path
51//!   and refuses every other key. A bare `Authenticated` write entry admits
52//!   EVERY key, so it must never be added to `writePermissions`
53//!   ([`merge_write_level_into`], [`write_admits_every_key`],
54//!   [`remove_bare_authenticated_from_write`]).
55
56use std::collections::BTreeMap;
57
58use serde::{Deserialize, Serialize};
59use serde_json::Value;
60use serde_json::json;
61
62use crate::client::idp::GatewaySession;
63use crate::client::idp::IdpLoginFlow;
64use crate::error::CoreError;
65
66/// The key-material generator (live-captured response below).
67pub(crate) const GENERATE_PATH: &str = "/data/api/v1/api-token/generate";
68
69/// The api-token resource list (idempotent find-by-name).
70pub(crate) const API_TOKEN_LIST_PATH: &str = "/data/api/v1/resources/list/ignition/api-token";
71
72/// The api-token resource CREATE (array-of-one body).
73pub(crate) const API_TOKEN_CREATE_PATH: &str = "/data/api/v1/resources/ignition/api-token";
74
75/// The security-properties singleton READ. NOTE: the NON-singleton
76/// spelling `/resources/ignition/security-properties` is the PUT target
77/// but 404s as a GET on live 8.3.6 (live-observed 2026-09-18; the
78/// doctor capability reads the wrong path — see ADOPT-RESEARCH).
79pub(crate) const SECURITY_SINGLETON_PATH: &str =
80    "/data/api/v1/resources/singleton/ignition/security-properties";
81
82/// The security-properties singleton WRITE (array-of-one + signature).
83pub(crate) const SECURITY_PUT_PATH: &str = "/data/api/v1/resources/ignition/security-properties";
84
85/// The api-token resource type string (the create answer's
86/// `changes[].type`; asserted against the live answer in the mint).
87pub(crate) const API_TOKEN_TYPE: &str = "ignition/api-token";
88
89/// The basic-token profile type string.
90pub(crate) const BASIC_TOKEN_TYPE: &str = "basic-token";
91
92/// `POST /data/api/v1/api-token/generate` — the mint. The `key` is the
93/// plaintext token, returned here and nowhere else; `hash` is what the
94/// resource record stores. Live-captured 8.3.6:
95///
96/// ```json
97/// { "key":  "<43-char urlsafe plaintext — REDACTED, same shape>",
98///   "hash": "<43-char urlsafe stored hash — REDACTED, same shape>" }
99/// ```
100///
101/// Redaction discipline: this struct carries the plaintext between the
102/// client parse and the action's ONE exposure site (the result model +
103/// keyring write). It deliberately implements `Debug` with the fields
104/// visible — it lives core-internal for one hop; the ACTION result is
105/// the documented exposure boundary.
106#[derive(Debug, Clone, Deserialize)]
107pub struct GeneratedKeyWire {
108    /// The plaintext token (43-char urlsafe) — the only place the
109    /// gateway ever returns it.
110    #[serde(default)]
111    pub key: String,
112    /// The stored hash — rides the resource record's
113    /// `config.settings.tokenHash`.
114    #[serde(default)]
115    pub hash: String,
116}
117
118/// One `ignition/api-token` resource record — the typed subset adopt
119/// reads (find-by-name + config inspection); unknown keys round-trip.
120#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
121pub struct ApiTokenRecord {
122    /// The key's name — the find-by-name key AND half of the auth
123    /// header value (`<name>:<key>`).
124    #[serde(default)]
125    pub name: String,
126    /// Free-text description, when set.
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub description: Option<String>,
129    /// Disabled keys authenticate as nothing.
130    #[serde(default)]
131    pub enabled: bool,
132    /// The token profile + hash.
133    #[serde(default)]
134    pub config: ApiTokenConfig,
135    /// Unknown keys round-trip (version, signature, attributes, …).
136    #[serde(flatten)]
137    pub extra: BTreeMap<String, Value>,
138}
139
140/// The record's `config` — `profile` (level + flags) + `settings`
141/// (the hash).
142#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
143pub struct ApiTokenConfig {
144    /// The token profile.
145    #[serde(default)]
146    pub profile: ApiTokenProfile,
147    /// The hash store.
148    #[serde(default)]
149    pub settings: ApiTokenSettings,
150}
151
152/// The record's `config.profile` — everything doctor's three-part
153/// diagnosis needs.
154#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
155pub struct ApiTokenProfile {
156    /// `"basic-token"` (the only extension point today).
157    #[serde(rename = "type", default)]
158    pub kind: String,
159    /// The granted leaf-path trees (see [`level_tree`]).
160    #[serde(rename = "securityLevels", default)]
161    pub security_levels: Vec<Value>,
162    /// The "Require secure connections" flag — MUST be false for
163    /// http gateways or every request 403s (three-part cause 3).
164    #[serde(rename = "secureChannelRequired", default)]
165    pub secure_channel_required: bool,
166    /// Creation time, epoch milliseconds.
167    #[serde(default)]
168    pub timestamp: i64,
169}
170
171/// The record's `config.settings` — the hash only, never the key.
172#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
173pub struct ApiTokenSettings {
174    /// The stored hash (`config.settings.tokenHash`).
175    #[serde(rename = "tokenHash", default)]
176    pub token_hash: String,
177}
178
179/// The shared resources-API mutation envelope (create + singleton PUT;
180/// live-captured on the create — `{success, changes[], problem}`).
181#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
182pub struct ResourceMutationWire {
183    /// The gateway's verdict — `false` or a non-2xx both refuse.
184    #[serde(default)]
185    pub success: bool,
186    /// What changed (name/type/collection/newSignature).
187    #[serde(default)]
188    pub changes: Vec<ResourceChangeWire>,
189    /// Populated (non-null) when the gateway reports a partial
190    /// problem — surfaced verbatim.
191    #[serde(default, skip_serializing_if = "Option::is_none")]
192    pub problem: Option<Value>,
193}
194
195/// One `changes[]` row.
196#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
197pub struct ResourceChangeWire {
198    #[serde(default)]
199    pub name: String,
200    #[serde(rename = "type", default)]
201    pub kind: String,
202    #[serde(default)]
203    pub collection: String,
204    #[serde(rename = "newSignature", default)]
205    pub new_signature: String,
206}
207
208/// Render one granted level tree as its slash path
209/// (`Authenticated/Roles/Administrator`) — the human form for report
210/// rows. Walks the first-child chain (a granted tree is a single
211/// path by construction). Pure function.
212pub fn level_path_string(tree: &Value) -> String {
213    let mut segments: Vec<&str> = Vec::new();
214    let mut node = tree;
215    while let Some(name) = node.get("name").and_then(Value::as_str) {
216        segments.push(name);
217        node = node
218            .get("children")
219            .and_then(Value::as_array)
220            .and_then(|children| children.first())
221            .unwrap_or(&Value::Null);
222    }
223    segments.join("/")
224}
225
226/// The security-properties singleton READ — typed shell (the merge
227/// works at `Value` level inside `config`; the config's full shape is
228/// passthrough by design, the doctor precedent).
229#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
230pub struct SecuritySingletonWire {
231    /// The optimistic-concurrency token — MUST ride the PUT verbatim
232    /// or the write refuses (rotates on every write; live-observed).
233    #[serde(default)]
234    pub signature: String,
235    /// `"core"` on every capture.
236    #[serde(default = "default_collection")]
237    pub collection: String,
238    /// The whole config (`readPermissions`/`writePermissions` live
239    /// HERE, nested — not at the record's top level).
240    #[serde(default)]
241    pub config: Value,
242    /// Unknown keys round-trip.
243    #[serde(flatten)]
244    pub extra: BTreeMap<String, Value>,
245}
246
247fn default_collection() -> String {
248    "core".to_string()
249}
250
251/// Build the nested leaf-path tree for one granted level:
252/// `["Authenticated","Roles","Administrator"]` →
253/// `{name:"Authenticated", children:[{name:"Roles", children:[
254/// {name:"Administrator", children:[]}]}]}` — the live-captured
255/// encoding (the type descriptor's `defaultConfig` and every
256/// permissions list agree). Pure function; the unit test pins the
257/// captured shape verbatim.
258pub fn level_tree(path: &[&str]) -> Value {
259    match path.split_first() {
260        // The LEAF: children is the empty array (not [[]] — the
261        // recursion stops one level early by construction).
262        Some((head, [])) => json!({ "name": head, "children": [] }),
263        Some((head, tail)) => json!({
264            "name": head,
265            "children": [level_tree(tail)],
266        }),
267        // Degenerate defensive value — adopt always passes a path.
268        None => json!([]),
269    }
270}
271
272/// Build the api-token CREATE body (array-of-one) — pure function,
273/// the unit test pins it against the live-captured request VERBATIM.
274/// `levels` is the key's granted security-level trees, in order (one
275/// tree for a plain key; a write-capable key also holds the write
276/// level). `timestamp_ms` is epoch milliseconds (std-only at call
277/// sites).
278pub fn build_token_create_body(
279    name: &str,
280    levels: &[Value],
281    token_hash: &str,
282    timestamp_ms: i64,
283) -> Value {
284    json!([{
285        "name": name,
286        "collection": "core",
287        "enabled": true,
288        "description": "",
289        "config": {
290            "profile": {
291                "securityLevels": levels,
292                "secureChannelRequired": false,
293                "type": BASIC_TOKEN_TYPE,
294                "timestamp": timestamp_ms
295            },
296            "settings": { "tokenHash": token_hash }
297        }
298    }])
299}
300
301/// Build the security-properties PUT body (array-of-one, whole-config
302/// replacement + the GET's signature) — pure function; the unit test
303/// pins the captured shape (incl. `gatewayAuditProfile: null`, which
304/// the UI sends and the GET omits).
305pub fn build_security_put_body(singleton: &SecuritySingletonWire, config: &Value) -> Value {
306    json!([{
307        "collection": singleton.collection,
308        "enabled": true,
309        "description": "",
310        "signature": singleton.signature,
311        "config": config
312    }])
313}
314
315/// The READ-side merge: ensure a BARE-ROOT entry for the granted
316/// level's top segment exists in an AnyOf permission list inside a
317/// singleton `config` — add-if-missing, never remove, never mutate
318/// existing entries. Returns true when the config changed. Missing
319/// permissions objects are CREATED as
320/// `{"type":"AnyOf","securityLevels":[bare-root]}`.
321///
322/// adopt no longer uses this for `writePermissions`: the bare root of
323/// any level under `Authenticated` admits every API key, so a check
324/// token minted for a read-only level could write (G-25-11). The
325/// write side is [`merge_write_level_into`].
326///
327/// WHY THE BARE ROOT and not the granted leaf-path tree
328/// (live-pinned 2026-09-18, ADOPT-RESEARCH pitfall 3): a permission
329/// entry serialized as the NESTED tree
330/// (`Authenticated>Roles>Administrator`, the UI's own spelling, with
331/// or without descriptions) does NOT admit an API token granted that
332/// exact path — 403, live-observed both ways, and re-measured by plan
333/// 25-24 on 8.3.6 and 8.3.3 — while a BARE-root entry
334/// (`{name:"Authenticated",children:[]}`) admits every
335/// descendant-granted token (200, live-observed). The bare root is
336/// the ANCESTOR form: strictly broader than the nested grant, so
337/// adding it can only widen admission, never narrow.
338///
339/// The `[]` case (the PUBLIC encoding — pitfall 2) gets the bare
340/// root ADDED, tightening Public down to root-admitted.
341pub fn merge_level_into(config: &mut Value, field: &str, level: &Value) -> bool {
342    let Some(incoming_root) = level.get("name").and_then(Value::as_str) else {
343        return false;
344    };
345    let Some(config_object) = config.as_object_mut() else {
346        return false;
347    };
348    let permissions = config_object
349        .entry(field)
350        .or_insert_with(|| json!({ "type": "AnyOf", "securityLevels": [] }));
351    let Some(permissions_object) = permissions.as_object_mut() else {
352        return false;
353    };
354    let levels = permissions_object
355        .entry("securityLevels")
356        .or_insert_with(|| Value::Array(Vec::new()));
357    let Some(levels_array) = levels.as_array_mut() else {
358        return false;
359    };
360    // Present = a BARE-root entry exists (children empty). A nested
361    // same-root entry does NOT count — it does not admit the token
362    // (pitfall 3), so the bare form must land beside it.
363    let has_bare_root = levels_array.iter().any(|existing| {
364        existing.get("name").and_then(Value::as_str) == Some(incoming_root)
365            && existing
366                .get("children")
367                .and_then(Value::as_array)
368                .is_some_and(|children| children.is_empty())
369    });
370    if has_bare_root {
371        return false;
372    }
373    levels_array.push(json!({ "name": incoming_root, "children": [] }));
374    true
375}
376
377/// Structural equality of two level trees by `name` and `children`,
378/// recursively. Every other key (`description`, …) is ignored and a
379/// missing `children` reads as empty.
380fn level_matches(a: &Value, b: &Value) -> bool {
381    let name = |v: &Value| v.get("name").and_then(Value::as_str).map(str::to_string);
382    let children = |v: &Value| -> Vec<Value> {
383        v.get("children")
384            .and_then(Value::as_array)
385            .cloned()
386            .unwrap_or_default()
387    };
388    if name(a).is_none() || name(a) != name(b) {
389        return false;
390    }
391    let (ca, cb) = (children(a), children(b));
392    ca.len() == cb.len() && ca.iter().zip(cb.iter()).all(|(x, y)| level_matches(x, y))
393}
394
395/// `true` for a BARE entry (no children) named `Authenticated` or
396/// `Public`, any case — the two forms every API key holds.
397fn is_open_entry(entry: &Value, only_authenticated: bool) -> bool {
398    let bare = entry
399        .get("children")
400        .and_then(Value::as_array)
401        .is_none_or(Vec::is_empty);
402    let name = entry.get("name").and_then(Value::as_str).unwrap_or("");
403    bare && (name.eq_ignore_ascii_case("authenticated")
404        || (!only_authenticated && name.eq_ignore_ascii_case("public")))
405}
406
407/// The WRITE-side merge: add `level` VERBATIM to
408/// `writePermissions.securityLevels` unless an entry equal to it by
409/// name and children already exists (recursive; descriptions and any
410/// other key are ignored). Returns true when the config changed. A
411/// missing permissions object is CREATED as AnyOf holding only
412/// `level`. It never derives a root from `level`, so it can never
413/// add a bare `Authenticated` unless the caller passes exactly that
414/// (adopt refuses it before getting here).
415pub fn merge_write_level_into(config: &mut Value, level: &Value) -> bool {
416    let Some(config_object) = config.as_object_mut() else {
417        return false;
418    };
419    let permissions = config_object
420        .entry("writePermissions")
421        .or_insert_with(|| json!({ "type": "AnyOf", "securityLevels": [] }));
422    let Some(permissions_object) = permissions.as_object_mut() else {
423        return false;
424    };
425    let levels = permissions_object
426        .entry("securityLevels")
427        .or_insert_with(|| Value::Array(Vec::new()));
428    let Some(levels_array) = levels.as_array_mut() else {
429        return false;
430    };
431    if levels_array.iter().any(|entry| level_matches(entry, level)) {
432        return false;
433    }
434    levels_array.push(level.clone());
435    true
436}
437
438/// `Some(reason)` when `writePermissions` admits EVERY API key: it is
439/// not set, it is an empty list (Ignition reads `[]` as Public), or
440/// it holds a bare `Authenticated` / bare `Public` entry (any case).
441/// `None` for a list of exact levels, including a nested entry that
442/// happens to be named `Authenticated` but has children.
443pub fn write_admits_every_key(config: &Value) -> Option<String> {
444    let levels = config
445        .get("writePermissions")
446        .and_then(|permissions| permissions.get("securityLevels"))
447        .and_then(Value::as_array);
448    let Some(levels) = levels else {
449        return Some("writePermissions is not set".to_string());
450    };
451    if levels.is_empty() {
452        return Some("writePermissions is empty, which Ignition reads as Public".to_string());
453    }
454    levels
455        .iter()
456        .find(|entry| is_open_entry(entry, false))
457        .map(|entry| {
458            let name = entry.get("name").and_then(Value::as_str).unwrap_or("");
459            format!("writePermissions holds a bare {name} entry, which every API key holds")
460        })
461}
462
463/// Remove every bare `Authenticated` entry (any case, no children)
464/// from `writePermissions.securityLevels`. `Ok(true)` when something
465/// was removed, `Ok(false)` when there was nothing to remove. Refuses
466/// with `Err` — leaving `config` unchanged — when the removal would
467/// leave the list empty, because Ignition reads `[]` as Public.
468/// Never touches `readPermissions` or any other entry.
469pub fn remove_bare_authenticated_from_write(config: &mut Value) -> Result<bool, String> {
470    let Some(levels) = config
471        .get_mut("writePermissions")
472        .and_then(|permissions| permissions.get_mut("securityLevels"))
473        .and_then(Value::as_array_mut)
474    else {
475        return Ok(false);
476    };
477    let kept: Vec<Value> = levels
478        .iter()
479        .filter(|entry| !is_open_entry(entry, true))
480        .cloned()
481        .collect();
482    if kept.len() == levels.len() {
483        return Ok(false);
484    }
485    if kept.is_empty() {
486        return Err(
487            "removing the bare Authenticated entry would leave writePermissions \
488                    empty, which Ignition reads as Public"
489                .to_string(),
490        );
491    }
492    *levels = kept;
493    Ok(true)
494}
495
496/// `GET …/resources/list/ignition/api-token` on the session tier —
497/// the pre-token idempotent find. One page at `limit=100`; a gateway
498/// holding MORE tokens than that REFUSES rather than guessing (the
499/// review round's truncation trap: a key beyond the page would read
500/// as absent and the mint would collide — an honest error beats an
501/// ambiguous duplicate).
502pub async fn api_tokens_via_session(
503    flow: &IdpLoginFlow,
504    session: &GatewaySession,
505) -> Result<Vec<ApiTokenRecord>, CoreError> {
506    let value = flow
507        .session_get_json(session, API_TOKEN_LIST_PATH, &[("limit", "100")])
508        .await?;
509    let total = value
510        .get("metadata")
511        .and_then(|metadata| metadata.get("total"))
512        .and_then(Value::as_i64)
513        .unwrap_or(0);
514    let items = value
515        .get("items")
516        .cloned()
517        .ok_or_else(|| CoreError::Internal("api-token list carried no items array".into()))?;
518    let count = items.as_array().map_or(0, Vec::len) as i64;
519    if total > count {
520        return Err(CoreError::Internal(format!(
521            "this gateway holds {total} API keys — more than adopt's one-page \
522             lookup ({count} returned); delete unused keys and re-run"
523        )));
524    }
525    let records: Vec<ApiTokenRecord> = serde_json::from_value(items).map_err(|err| {
526        CoreError::Internal(format!(
527            "api-token list item did not match the record shape: {err}"
528        ))
529    })?;
530    Ok(records)
531}
532
533/// `POST /data/api/v1/api-token/generate` on the session tier —
534/// the mint (see [`GeneratedKeyWire`] for the plaintext discipline).
535pub async fn generate_api_key_via_session(
536    flow: &IdpLoginFlow,
537    session: &GatewaySession,
538) -> Result<GeneratedKeyWire, CoreError> {
539    let value = flow
540        .session_post_json(session, GENERATE_PATH, &json!({}))
541        .await?;
542    serde_json::from_value(value)
543        .map_err(|err| CoreError::Internal(format!("api-token generate answer shape: {err}")))
544}
545
546/// `POST …/resources/ignition/api-token` on the session tier — the
547/// create (array-of-one body from [`build_token_create_body`]).
548pub async fn create_api_token_via_session(
549    flow: &IdpLoginFlow,
550    session: &GatewaySession,
551    body: &Value,
552) -> Result<ResourceMutationWire, CoreError> {
553    let value = flow
554        .session_post_json(session, API_TOKEN_CREATE_PATH, body)
555        .await?;
556    serde_json::from_value(value)
557        .map_err(|err| CoreError::Internal(format!("api-token create answer shape: {err}")))
558}
559
560/// `GET …/resources/singleton/ignition/security-properties` on the
561/// session tier — the pre-token read for the merge.
562pub async fn security_properties_via_session(
563    flow: &IdpLoginFlow,
564    session: &GatewaySession,
565) -> Result<SecuritySingletonWire, CoreError> {
566    let value = flow
567        .session_get_json(
568            session,
569            SECURITY_SINGLETON_PATH,
570            &[("defaultIfUndefined", "true")],
571        )
572        .await?;
573    serde_json::from_value(value)
574        .map_err(|err| CoreError::Internal(format!("security-properties singleton shape: {err}")))
575}
576
577/// `PUT …/resources/ignition/security-properties` on the session
578/// tier — the whole-config replacement (body from
579/// [`build_security_put_body`]; the signature is the concurrency
580/// gate).
581pub async fn put_security_properties_via_session(
582    flow: &IdpLoginFlow,
583    session: &GatewaySession,
584    body: &Value,
585) -> Result<ResourceMutationWire, CoreError> {
586    let value = flow
587        .session_put_json(session, SECURITY_PUT_PATH, body)
588        .await?;
589    serde_json::from_value(value)
590        .map_err(|err| CoreError::Internal(format!("security-properties put answer shape: {err}")))
591}
592
593#[cfg(test)]
594mod tests {
595    use serde_json::Value;
596    use serde_json::json;
597
598    use super::{
599        ApiTokenRecord, GeneratedKeyWire, ResourceMutationWire, SecuritySingletonWire,
600        build_security_put_body, build_token_create_body, level_tree, merge_level_into,
601        merge_write_level_into, remove_bare_authenticated_from_write, write_admits_every_key,
602    };
603
604    /// THE live-capture regression — the generate answer's SHAPE,
605    /// from the 8.3.6 rig capture (ADOPT-RESEARCH §1a). The VALUES
606    /// are redacted same-shape synthetics: real key material never
607    /// lands in source, even disposable-rig keys (review round).
608    #[test]
609    fn generate_parses_the_live_capture() {
610        let wire: GeneratedKeyWire = serde_json::from_value(json!({
611            "key": "AAAAexample-redacted-key-43-chars-urlsafe-0",
612            "hash": "BBBBexample_redacted_hash_43_chars_urlsafe0"
613        }))
614        .expect("the live generate shape must parse");
615        assert_eq!(wire.key.len(), 43, "urlsafe plaintext, 43 chars");
616        assert_eq!(wire.hash.len(), 43, "stored hash, 43 chars");
617    }
618
619    /// The live-captured list item (trimmed to the typed fields +
620    /// one passthrough key) must parse with the level + secure flag
621    /// + hash visible (ADOPT-RESEARCH §1c).
622    #[test]
623    fn list_item_parses_the_live_capture() {
624        let record: ApiTokenRecord = serde_json::from_value(json!({
625            "type": "ignition/api-token",
626            "name": "ign-adopt-capture",
627            "description": "",
628            "enabled": true,
629            "version": 1,
630            "collection": "core",
631            "signature": "87717b87",
632            "config": {
633                "profile": {
634                    "type": "basic-token",
635                    "secureChannelRequired": false,
636                    "securityLevels": [
637                        { "name": "Authenticated", "children": [] }
638                    ],
639                    "timestamp": 1789760514446i64
640                },
641                "settings": { "tokenHash": "BBBBexample_redacted_hash_43_chars_urlsafe0" }
642            },
643            "attributes": { "uuid": "…", "enabled": true }
644        }))
645        .expect("the live list item shape must parse");
646        assert_eq!(record.name, "ign-adopt-capture");
647        assert!(record.enabled);
648        assert!(!record.config.profile.secure_channel_required);
649        assert_eq!(record.config.profile.kind, "basic-token");
650        assert_eq!(
651            record.config.settings.token_hash,
652            "BBBBexample_redacted_hash_43_chars_urlsafe0"
653        );
654        assert!(record.extra.contains_key("signature"), "passthrough rides");
655    }
656
657    /// The create body builder must reproduce the live-captured
658    /// request VERBATIM (modulo the varying timestamp) —
659    /// ADOPT-RESEARCH §1b.
660    #[test]
661    fn create_body_matches_the_live_capture() {
662        let level = level_tree(&["Authenticated"]);
663        let body = build_token_create_body(
664            "ign-adopt-capture",
665            std::slice::from_ref(&level),
666            "BBBBexample_redacted_hash_43_chars_urlsafe0",
667            1789760514446,
668        );
669        let captured: Value = json!([{
670            "name": "ign-adopt-capture",
671            "collection": "core",
672            "enabled": true,
673            "description": "",
674            "config": {
675                "profile": {
676                    "securityLevels": [
677                        { "name": "Authenticated",
678                          "description": "Represents a user who has been authenticated by the system.",
679                          "children": [] }
680                    ],
681                    "secureChannelRequired": false,
682                    "type": "basic-token",
683                    "timestamp": 1789760514446i64
684                },
685                "settings": { "tokenHash": "BBBBexample_redacted_hash_43_chars_urlsafe0" }
686            }
687        }]);
688        // descriptions are optional-in (the gateway echoes them out);
689        // the builder omits them by design — compare descriptionless.
690        let mut expected = captured;
691        for record in expected.as_array_mut().expect("array") {
692            record["config"]["profile"]["securityLevels"][0]
693                .as_object_mut()
694                .expect("level object")
695                .remove("description");
696        }
697        assert_eq!(body, expected, "byte-faithful modulo descriptions");
698    }
699
700    /// The create answer — `{success, changes[], problem}` — must
701    /// parse with the new signature visible (ADOPT-RESEARCH §1b).
702    #[test]
703    fn mutation_parses_the_live_create_answer() {
704        let wire: ResourceMutationWire = serde_json::from_value(json!({
705            "success": true,
706            "changes": [{
707                "name": "ign-adopt-capture",
708                "type": "ignition/api-token",
709                "collection": "core",
710                "newSignature": "87717b874ec57a83e676c7e757e5264efc20e4cdb571525470ef6c93d5736f45"
711            }],
712            "problem": null
713        }))
714        .expect("the live create answer must parse");
715        assert!(wire.success);
716        assert_eq!(wire.changes.len(), 1);
717        assert_eq!(wire.changes[0].kind, "ignition/api-token");
718        assert!(wire.problem.is_none());
719    }
720
721    /// The singleton GET shape — signature + config-nested
722    /// permissions + attributes passthrough (ADOPT-RESEARCH §2).
723    #[test]
724    fn singleton_parses_the_live_capture() {
725        let wire: SecuritySingletonWire = serde_json::from_value(json!({
726            "type": "ignition/security-properties",
727            "signature": "dee8c94600032841",
728            "collection": "core",
729            "enabled": true,
730            "config": {
731                "readPermissions": {
732                    "type": "AnyOf",
733                    "securityLevels": [ { "name": "Authenticated", "children": [] } ]
734                },
735                "writePermissions": {
736                    "type": "AnyOf",
737                    "securityLevels": [ { "name": "Authenticated", "children": [] } ]
738                }
739            },
740            "attributes": { "lastModification": { "actor": "admin" } }
741        }))
742        .expect("the live singleton shape must parse");
743        assert_eq!(wire.signature, "dee8c94600032841");
744        assert!(wire.config.get("writePermissions").is_some());
745        assert!(wire.extra.contains_key("attributes"));
746    }
747
748    /// The Administrator leaf-path tree — the defaultConfig encoding
749    /// (ADOPT-RESEARCH §1b/§2).
750    #[test]
751    fn level_tree_nests_the_administrator_path() {
752        assert_eq!(
753            level_tree(&["Authenticated", "Roles", "Administrator"]),
754            json!({
755                "name": "Authenticated",
756                "children": [{
757                    "name": "Roles",
758                    "children": [{
759                        "name": "Administrator",
760                        "children": []
761                    }]
762                }]
763            })
764        );
765        assert_eq!(
766            level_tree(&["Authenticated"]),
767            json!({ "name": "Authenticated", "children": [] })
768        );
769    }
770
771    /// The slash-path render — report rows print the full leaf path.
772    #[test]
773    fn level_path_string_walks_the_chain() {
774        use super::level_path_string;
775        assert_eq!(
776            level_path_string(&level_tree(&["Authenticated", "Roles", "Administrator"])),
777            "Authenticated/Roles/Administrator"
778        );
779        assert_eq!(
780            level_path_string(&level_tree(&["Authenticated"])),
781            "Authenticated"
782        );
783    }
784
785    /// merge: ensures a BARE-ROOT entry for the granted level's top
786    /// segment — add-if-missing, idempotent; a NESTED same-root entry
787    /// does NOT count (pitfall 3: nested entries do not admit
788    /// equal-path tokens — the bare root lands BESIDE it, the
789    /// fresh-gateway-default fix); `[]` (the PUBLIC encoding —
790    /// pitfall 2) gets the root ADDED; a missing field is created.
791    #[test]
792    fn merge_adds_missing_and_is_idempotent() {
793        let authenticated = level_tree(&["Authenticated"]);
794        let administrator = level_tree(&["Authenticated", "Roles", "Administrator"]);
795        let bare = |root: &str| json!({ "name": root, "children": [] });
796
797        // Different root → the bare root added; re-run changes nothing.
798        let mut config = json!({
799            "writePermissions": {
800                "type": "AnyOf",
801                "securityLevels": [ bare("SecurityZones") ]
802            }
803        });
804        assert!(merge_level_into(
805            &mut config,
806            "writePermissions",
807            &administrator
808        ));
809        assert!(
810            !merge_level_into(&mut config, "writePermissions", &administrator),
811            "idempotent re-run changes nothing"
812        );
813        assert_eq!(
814            config["writePermissions"]["securityLevels"][1],
815            bare("Authenticated"),
816            "the BARE root lands (pitfall 3), not the nested tree"
817        );
818
819        // Bare-root entry already present → skip.
820        let mut config = json!({
821            "writePermissions": { "type": "AnyOf", "securityLevels": [authenticated.clone()] }
822        });
823        assert!(!merge_level_into(
824            &mut config,
825            "writePermissions",
826            &administrator
827        ));
828
829        // NESTED same-root entry (the fresh-gateway default) does not
830        // admit the token — the bare root lands BESIDE it.
831        let mut config = json!({
832            "writePermissions": { "type": "AnyOf", "securityLevels": [administrator.clone()] }
833        });
834        assert!(merge_level_into(
835            &mut config,
836            "writePermissions",
837            &administrator
838        ));
839        let levels = config["writePermissions"]["securityLevels"]
840            .as_array()
841            .expect("levels");
842        assert_eq!(levels.len(), 2, "bare root added beside the nested entry");
843        assert_eq!(levels[1], bare("Authenticated"));
844
845        // Empty list (Public) → the bare root lands, tightening it.
846        let mut config = json!({
847            "writePermissions": { "type": "AnyOf", "securityLevels": [] }
848        });
849        assert!(merge_level_into(
850            &mut config,
851            "writePermissions",
852            &authenticated
853        ));
854        let levels = config["writePermissions"]["securityLevels"]
855            .as_array()
856            .expect("levels");
857        assert_eq!(levels.len(), 1);
858        assert_eq!(levels[0], bare("Authenticated"));
859
860        // Missing field entirely → created as AnyOf.
861        let mut config = json!({});
862        assert!(merge_level_into(
863            &mut config,
864            "readPermissions",
865            &authenticated
866        ));
867        assert_eq!(config["readPermissions"]["type"], "AnyOf");
868    }
869
870    /// The PUT body — array-of-one, whole config, the GET's
871    /// signature riding verbatim (ADOPT-RESEARCH §2).
872    #[test]
873    fn put_body_carries_the_signature() {
874        let singleton: SecuritySingletonWire = serde_json::from_value(json!({
875            "signature": "dee8c94600032841",
876            "collection": "core",
877            "config": { "forceIdpAuth": true }
878        }))
879        .expect("parses");
880        let mut config = singleton.config.clone();
881        merge_level_into(
882            &mut config,
883            "writePermissions",
884            &level_tree(&["Authenticated"]),
885        );
886        let body = build_security_put_body(&singleton, &config);
887        assert_eq!(body[0]["signature"], "dee8c94600032841");
888        assert_eq!(body[0]["collection"], "core");
889        assert_eq!(body[0]["config"]["forceIdpAuth"], true);
890        assert!(
891            body[0]["config"]["writePermissions"]["securityLevels"]
892                .as_array()
893                .expect("levels")
894                .len()
895                == 1
896        );
897    }
898
899    /// The commissioned write list: one nested Administrator tree.
900    fn commissioned() -> Value {
901        json!({
902            "writePermissions": {
903                "type": "AnyOf",
904                "securityLevels": [level_tree(&["Authenticated", "Roles", "Administrator"])]
905            }
906        })
907    }
908
909    #[test]
910    fn merge_write_level_adds_the_exact_tree_and_is_idempotent() {
911        let mut config = commissioned();
912        // Already present (a description on the stored entry is ignored).
913        config["writePermissions"]["securityLevels"][0]["description"] = json!("x");
914        let admin = level_tree(&["Authenticated", "Roles", "Administrator"]);
915        assert!(!merge_write_level_into(&mut config, &admin));
916
917        let dedicated = level_tree(&["Authenticated", "IgnCliWrite"]);
918        assert!(merge_write_level_into(&mut config, &dedicated));
919        assert_eq!(config["writePermissions"]["securityLevels"][1], dedicated);
920        assert!(!merge_write_level_into(&mut config, &dedicated));
921
922        let bare_dedicated = level_tree(&["IgnCliWrite"]);
923        assert!(merge_write_level_into(&mut config, &bare_dedicated));
924        assert_eq!(
925            config["writePermissions"]["securityLevels"][2],
926            json!({ "name": "IgnCliWrite", "children": [] })
927        );
928
929        // A missing writePermissions is created as AnyOf holding only the level.
930        let mut empty = json!({});
931        assert!(merge_write_level_into(&mut empty, &dedicated));
932        assert_eq!(empty["writePermissions"]["type"], "AnyOf");
933        assert_eq!(
934            empty["writePermissions"]["securityLevels"],
935            json!([dedicated])
936        );
937
938        // No call ever appended a bare Authenticated entry.
939        for config in [&config, &empty] {
940            let levels = config["writePermissions"]["securityLevels"]
941                .as_array()
942                .expect("levels");
943            assert!(!levels.iter().any(|entry| {
944                entry["name"] == "Authenticated"
945                    && entry["children"].as_array().is_some_and(Vec::is_empty)
946            }));
947        }
948    }
949
950    #[test]
951    fn write_admits_every_key_flags_the_three_open_forms() {
952        // Absent, empty, bare Authenticated, bare Public (any case).
953        assert!(write_admits_every_key(&json!({})).is_some());
954        assert!(
955            write_admits_every_key(&json!({
956                "writePermissions": { "type": "AnyOf", "securityLevels": [] }
957            }))
958            .is_some()
959        );
960        for name in ["Authenticated", "authenticated", "Public", "PUBLIC"] {
961            let config = json!({
962                "writePermissions": {
963                    "type": "AnyOf",
964                    "securityLevels": [
965                        level_tree(&["Authenticated", "Roles", "Administrator"]),
966                        { "name": name, "children": [] }
967                    ]
968                }
969            });
970            assert!(write_admits_every_key(&config).is_some(), "{name}");
971        }
972        // Exact forms are not open.
973        assert_eq!(write_admits_every_key(&commissioned()), None);
974        let mut with_dedicated = commissioned();
975        merge_write_level_into(&mut with_dedicated, &level_tree(&["IgnCliWrite"]));
976        assert_eq!(write_admits_every_key(&with_dedicated), None);
977        // A nested entry named Authenticated that has children is not open.
978        let nested = json!({
979            "writePermissions": {
980                "type": "AnyOf",
981                "securityLevels": [level_tree(&["Authenticated", "IgnCliWrite"])]
982            }
983        });
984        assert_eq!(write_admits_every_key(&nested), None);
985    }
986
987    #[test]
988    fn remove_bare_authenticated_keeps_the_rest_and_refuses_empty() {
989        let admin = level_tree(&["Authenticated", "Roles", "Administrator"]);
990        let bare = json!({ "name": "Authenticated", "children": [] });
991
992        let mut config = json!({
993            "readPermissions": { "type": "AnyOf", "securityLevels": [bare.clone()] },
994            "writePermissions": { "type": "AnyOf", "securityLevels": [admin.clone(), bare.clone()] }
995        });
996        assert_eq!(remove_bare_authenticated_from_write(&mut config), Ok(true));
997        assert_eq!(
998            config["writePermissions"]["securityLevels"],
999            json!([admin.clone()])
1000        );
1001        assert_eq!(
1002            config["readPermissions"]["securityLevels"],
1003            json!([bare.clone()]),
1004            "readPermissions is never touched"
1005        );
1006
1007        // Nothing to remove.
1008        let mut clean = commissioned();
1009        let before = clean.clone();
1010        assert_eq!(remove_bare_authenticated_from_write(&mut clean), Ok(false));
1011        assert_eq!(clean, before);
1012
1013        // The only entry: refused, unchanged.
1014        let mut only = json!({
1015            "writePermissions": { "type": "AnyOf", "securityLevels": [bare] }
1016        });
1017        let before = only.clone();
1018        let err = remove_bare_authenticated_from_write(&mut only).expect_err("would empty");
1019        assert!(err.contains("Public"), "{err}");
1020        assert_eq!(only, before);
1021    }
1022
1023    #[test]
1024    fn create_body_carries_every_level() {
1025        let levels = [
1026            level_tree(&["Authenticated", "Roles", "IgnCheckRead"]),
1027            level_tree(&["Authenticated", "IgnCliWrite"]),
1028        ];
1029        let body = build_token_create_body("k", &levels, "hash", 1);
1030        let granted = body[0]["config"]["profile"]["securityLevels"]
1031            .as_array()
1032            .expect("levels");
1033        assert_eq!(granted.len(), 2);
1034        assert_eq!(granted[0], levels[0]);
1035        assert_eq!(granted[1], levels[1]);
1036    }
1037}