use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use serde_json::json;
use crate::client::idp::GatewaySession;
use crate::client::idp::IdpLoginFlow;
use crate::error::CoreError;
pub(crate) const GENERATE_PATH: &str = "/data/api/v1/api-token/generate";
pub(crate) const API_TOKEN_LIST_PATH: &str = "/data/api/v1/resources/list/ignition/api-token";
pub(crate) const API_TOKEN_CREATE_PATH: &str = "/data/api/v1/resources/ignition/api-token";
pub(crate) const SECURITY_SINGLETON_PATH: &str =
"/data/api/v1/resources/singleton/ignition/security-properties";
pub(crate) const SECURITY_PUT_PATH: &str = "/data/api/v1/resources/ignition/security-properties";
pub(crate) const API_TOKEN_TYPE: &str = "ignition/api-token";
pub(crate) const BASIC_TOKEN_TYPE: &str = "basic-token";
#[derive(Debug, Clone, Deserialize)]
pub struct GeneratedKeyWire {
#[serde(default)]
pub key: String,
#[serde(default)]
pub hash: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ApiTokenRecord {
#[serde(default)]
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub config: ApiTokenConfig,
#[serde(flatten)]
pub extra: BTreeMap<String, Value>,
}
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct ApiTokenConfig {
#[serde(default)]
pub profile: ApiTokenProfile,
#[serde(default)]
pub settings: ApiTokenSettings,
}
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct ApiTokenProfile {
#[serde(rename = "type", default)]
pub kind: String,
#[serde(rename = "securityLevels", default)]
pub security_levels: Vec<Value>,
#[serde(rename = "secureChannelRequired", default)]
pub secure_channel_required: bool,
#[serde(default)]
pub timestamp: i64,
}
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct ApiTokenSettings {
#[serde(rename = "tokenHash", default)]
pub token_hash: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ResourceMutationWire {
#[serde(default)]
pub success: bool,
#[serde(default)]
pub changes: Vec<ResourceChangeWire>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub problem: Option<Value>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ResourceChangeWire {
#[serde(default)]
pub name: String,
#[serde(rename = "type", default)]
pub kind: String,
#[serde(default)]
pub collection: String,
#[serde(rename = "newSignature", default)]
pub new_signature: String,
}
pub fn level_path_string(tree: &Value) -> String {
let mut segments: Vec<&str> = Vec::new();
let mut node = tree;
while let Some(name) = node.get("name").and_then(Value::as_str) {
segments.push(name);
node = node
.get("children")
.and_then(Value::as_array)
.and_then(|children| children.first())
.unwrap_or(&Value::Null);
}
segments.join("/")
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SecuritySingletonWire {
#[serde(default)]
pub signature: String,
#[serde(default = "default_collection")]
pub collection: String,
#[serde(default)]
pub config: Value,
#[serde(flatten)]
pub extra: BTreeMap<String, Value>,
}
fn default_collection() -> String {
"core".to_string()
}
pub fn level_tree(path: &[&str]) -> Value {
match path.split_first() {
Some((head, [])) => json!({ "name": head, "children": [] }),
Some((head, tail)) => json!({
"name": head,
"children": [level_tree(tail)],
}),
None => json!([]),
}
}
pub fn build_token_create_body(
name: &str,
levels: &[Value],
token_hash: &str,
timestamp_ms: i64,
) -> Value {
json!([{
"name": name,
"collection": "core",
"enabled": true,
"description": "",
"config": {
"profile": {
"securityLevels": levels,
"secureChannelRequired": false,
"type": BASIC_TOKEN_TYPE,
"timestamp": timestamp_ms
},
"settings": { "tokenHash": token_hash }
}
}])
}
pub fn build_security_put_body(singleton: &SecuritySingletonWire, config: &Value) -> Value {
json!([{
"collection": singleton.collection,
"enabled": true,
"description": "",
"signature": singleton.signature,
"config": config
}])
}
pub fn merge_level_into(config: &mut Value, field: &str, level: &Value) -> bool {
let Some(incoming_root) = level.get("name").and_then(Value::as_str) else {
return false;
};
let Some(config_object) = config.as_object_mut() else {
return false;
};
let permissions = config_object
.entry(field)
.or_insert_with(|| json!({ "type": "AnyOf", "securityLevels": [] }));
let Some(permissions_object) = permissions.as_object_mut() else {
return false;
};
let levels = permissions_object
.entry("securityLevels")
.or_insert_with(|| Value::Array(Vec::new()));
let Some(levels_array) = levels.as_array_mut() else {
return false;
};
let has_bare_root = levels_array.iter().any(|existing| {
existing.get("name").and_then(Value::as_str) == Some(incoming_root)
&& existing
.get("children")
.and_then(Value::as_array)
.is_some_and(|children| children.is_empty())
});
if has_bare_root {
return false;
}
levels_array.push(json!({ "name": incoming_root, "children": [] }));
true
}
fn level_matches(a: &Value, b: &Value) -> bool {
let name = |v: &Value| v.get("name").and_then(Value::as_str).map(str::to_string);
let children = |v: &Value| -> Vec<Value> {
v.get("children")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default()
};
if name(a).is_none() || name(a) != name(b) {
return false;
}
let (ca, cb) = (children(a), children(b));
ca.len() == cb.len() && ca.iter().zip(cb.iter()).all(|(x, y)| level_matches(x, y))
}
fn is_open_entry(entry: &Value, only_authenticated: bool) -> bool {
let bare = entry
.get("children")
.and_then(Value::as_array)
.is_none_or(Vec::is_empty);
let name = entry.get("name").and_then(Value::as_str).unwrap_or("");
bare && (name.eq_ignore_ascii_case("authenticated")
|| (!only_authenticated && name.eq_ignore_ascii_case("public")))
}
pub fn merge_write_level_into(config: &mut Value, level: &Value) -> bool {
let Some(config_object) = config.as_object_mut() else {
return false;
};
let permissions = config_object
.entry("writePermissions")
.or_insert_with(|| json!({ "type": "AnyOf", "securityLevels": [] }));
let Some(permissions_object) = permissions.as_object_mut() else {
return false;
};
let levels = permissions_object
.entry("securityLevels")
.or_insert_with(|| Value::Array(Vec::new()));
let Some(levels_array) = levels.as_array_mut() else {
return false;
};
if levels_array.iter().any(|entry| level_matches(entry, level)) {
return false;
}
levels_array.push(level.clone());
true
}
pub fn write_admits_every_key(config: &Value) -> Option<String> {
let levels = config
.get("writePermissions")
.and_then(|permissions| permissions.get("securityLevels"))
.and_then(Value::as_array);
let Some(levels) = levels else {
return Some("writePermissions is not set".to_string());
};
if levels.is_empty() {
return Some("writePermissions is empty, which Ignition reads as Public".to_string());
}
levels
.iter()
.find(|entry| is_open_entry(entry, false))
.map(|entry| {
let name = entry.get("name").and_then(Value::as_str).unwrap_or("");
format!("writePermissions holds a bare {name} entry, which every API key holds")
})
}
pub fn remove_bare_authenticated_from_write(config: &mut Value) -> Result<bool, String> {
let Some(levels) = config
.get_mut("writePermissions")
.and_then(|permissions| permissions.get_mut("securityLevels"))
.and_then(Value::as_array_mut)
else {
return Ok(false);
};
let kept: Vec<Value> = levels
.iter()
.filter(|entry| !is_open_entry(entry, true))
.cloned()
.collect();
if kept.len() == levels.len() {
return Ok(false);
}
if kept.is_empty() {
return Err(
"removing the bare Authenticated entry would leave writePermissions \
empty, which Ignition reads as Public"
.to_string(),
);
}
*levels = kept;
Ok(true)
}
pub async fn api_tokens_via_session(
flow: &IdpLoginFlow,
session: &GatewaySession,
) -> Result<Vec<ApiTokenRecord>, CoreError> {
let value = flow
.session_get_json(session, API_TOKEN_LIST_PATH, &[("limit", "100")])
.await?;
let total = value
.get("metadata")
.and_then(|metadata| metadata.get("total"))
.and_then(Value::as_i64)
.unwrap_or(0);
let items = value
.get("items")
.cloned()
.ok_or_else(|| CoreError::Internal("api-token list carried no items array".into()))?;
let count = items.as_array().map_or(0, Vec::len) as i64;
if total > count {
return Err(CoreError::Internal(format!(
"this gateway holds {total} API keys — more than adopt's one-page \
lookup ({count} returned); delete unused keys and re-run"
)));
}
let records: Vec<ApiTokenRecord> = serde_json::from_value(items).map_err(|err| {
CoreError::Internal(format!(
"api-token list item did not match the record shape: {err}"
))
})?;
Ok(records)
}
pub async fn generate_api_key_via_session(
flow: &IdpLoginFlow,
session: &GatewaySession,
) -> Result<GeneratedKeyWire, CoreError> {
let value = flow
.session_post_json(session, GENERATE_PATH, &json!({}))
.await?;
serde_json::from_value(value)
.map_err(|err| CoreError::Internal(format!("api-token generate answer shape: {err}")))
}
pub async fn create_api_token_via_session(
flow: &IdpLoginFlow,
session: &GatewaySession,
body: &Value,
) -> Result<ResourceMutationWire, CoreError> {
let value = flow
.session_post_json(session, API_TOKEN_CREATE_PATH, body)
.await?;
serde_json::from_value(value)
.map_err(|err| CoreError::Internal(format!("api-token create answer shape: {err}")))
}
pub async fn security_properties_via_session(
flow: &IdpLoginFlow,
session: &GatewaySession,
) -> Result<SecuritySingletonWire, CoreError> {
let value = flow
.session_get_json(
session,
SECURITY_SINGLETON_PATH,
&[("defaultIfUndefined", "true")],
)
.await?;
serde_json::from_value(value)
.map_err(|err| CoreError::Internal(format!("security-properties singleton shape: {err}")))
}
pub async fn put_security_properties_via_session(
flow: &IdpLoginFlow,
session: &GatewaySession,
body: &Value,
) -> Result<ResourceMutationWire, CoreError> {
let value = flow
.session_put_json(session, SECURITY_PUT_PATH, body)
.await?;
serde_json::from_value(value)
.map_err(|err| CoreError::Internal(format!("security-properties put answer shape: {err}")))
}
#[cfg(test)]
mod tests {
use serde_json::Value;
use serde_json::json;
use super::{
ApiTokenRecord, GeneratedKeyWire, ResourceMutationWire, SecuritySingletonWire,
build_security_put_body, build_token_create_body, level_tree, merge_level_into,
merge_write_level_into, remove_bare_authenticated_from_write, write_admits_every_key,
};
#[test]
fn generate_parses_the_live_capture() {
let wire: GeneratedKeyWire = serde_json::from_value(json!({
"key": "AAAAexample-redacted-key-43-chars-urlsafe-0",
"hash": "BBBBexample_redacted_hash_43_chars_urlsafe0"
}))
.expect("the live generate shape must parse");
assert_eq!(wire.key.len(), 43, "urlsafe plaintext, 43 chars");
assert_eq!(wire.hash.len(), 43, "stored hash, 43 chars");
}
#[test]
fn list_item_parses_the_live_capture() {
let record: ApiTokenRecord = serde_json::from_value(json!({
"type": "ignition/api-token",
"name": "ign-adopt-capture",
"description": "",
"enabled": true,
"version": 1,
"collection": "core",
"signature": "87717b87",
"config": {
"profile": {
"type": "basic-token",
"secureChannelRequired": false,
"securityLevels": [
{ "name": "Authenticated", "children": [] }
],
"timestamp": 1789760514446i64
},
"settings": { "tokenHash": "BBBBexample_redacted_hash_43_chars_urlsafe0" }
},
"attributes": { "uuid": "…", "enabled": true }
}))
.expect("the live list item shape must parse");
assert_eq!(record.name, "ign-adopt-capture");
assert!(record.enabled);
assert!(!record.config.profile.secure_channel_required);
assert_eq!(record.config.profile.kind, "basic-token");
assert_eq!(
record.config.settings.token_hash,
"BBBBexample_redacted_hash_43_chars_urlsafe0"
);
assert!(record.extra.contains_key("signature"), "passthrough rides");
}
#[test]
fn create_body_matches_the_live_capture() {
let level = level_tree(&["Authenticated"]);
let body = build_token_create_body(
"ign-adopt-capture",
std::slice::from_ref(&level),
"BBBBexample_redacted_hash_43_chars_urlsafe0",
1789760514446,
);
let captured: Value = json!([{
"name": "ign-adopt-capture",
"collection": "core",
"enabled": true,
"description": "",
"config": {
"profile": {
"securityLevels": [
{ "name": "Authenticated",
"description": "Represents a user who has been authenticated by the system.",
"children": [] }
],
"secureChannelRequired": false,
"type": "basic-token",
"timestamp": 1789760514446i64
},
"settings": { "tokenHash": "BBBBexample_redacted_hash_43_chars_urlsafe0" }
}
}]);
let mut expected = captured;
for record in expected.as_array_mut().expect("array") {
record["config"]["profile"]["securityLevels"][0]
.as_object_mut()
.expect("level object")
.remove("description");
}
assert_eq!(body, expected, "byte-faithful modulo descriptions");
}
#[test]
fn mutation_parses_the_live_create_answer() {
let wire: ResourceMutationWire = serde_json::from_value(json!({
"success": true,
"changes": [{
"name": "ign-adopt-capture",
"type": "ignition/api-token",
"collection": "core",
"newSignature": "87717b874ec57a83e676c7e757e5264efc20e4cdb571525470ef6c93d5736f45"
}],
"problem": null
}))
.expect("the live create answer must parse");
assert!(wire.success);
assert_eq!(wire.changes.len(), 1);
assert_eq!(wire.changes[0].kind, "ignition/api-token");
assert!(wire.problem.is_none());
}
#[test]
fn singleton_parses_the_live_capture() {
let wire: SecuritySingletonWire = serde_json::from_value(json!({
"type": "ignition/security-properties",
"signature": "dee8c94600032841",
"collection": "core",
"enabled": true,
"config": {
"readPermissions": {
"type": "AnyOf",
"securityLevels": [ { "name": "Authenticated", "children": [] } ]
},
"writePermissions": {
"type": "AnyOf",
"securityLevels": [ { "name": "Authenticated", "children": [] } ]
}
},
"attributes": { "lastModification": { "actor": "admin" } }
}))
.expect("the live singleton shape must parse");
assert_eq!(wire.signature, "dee8c94600032841");
assert!(wire.config.get("writePermissions").is_some());
assert!(wire.extra.contains_key("attributes"));
}
#[test]
fn level_tree_nests_the_administrator_path() {
assert_eq!(
level_tree(&["Authenticated", "Roles", "Administrator"]),
json!({
"name": "Authenticated",
"children": [{
"name": "Roles",
"children": [{
"name": "Administrator",
"children": []
}]
}]
})
);
assert_eq!(
level_tree(&["Authenticated"]),
json!({ "name": "Authenticated", "children": [] })
);
}
#[test]
fn level_path_string_walks_the_chain() {
use super::level_path_string;
assert_eq!(
level_path_string(&level_tree(&["Authenticated", "Roles", "Administrator"])),
"Authenticated/Roles/Administrator"
);
assert_eq!(
level_path_string(&level_tree(&["Authenticated"])),
"Authenticated"
);
}
#[test]
fn merge_adds_missing_and_is_idempotent() {
let authenticated = level_tree(&["Authenticated"]);
let administrator = level_tree(&["Authenticated", "Roles", "Administrator"]);
let bare = |root: &str| json!({ "name": root, "children": [] });
let mut config = json!({
"writePermissions": {
"type": "AnyOf",
"securityLevels": [ bare("SecurityZones") ]
}
});
assert!(merge_level_into(
&mut config,
"writePermissions",
&administrator
));
assert!(
!merge_level_into(&mut config, "writePermissions", &administrator),
"idempotent re-run changes nothing"
);
assert_eq!(
config["writePermissions"]["securityLevels"][1],
bare("Authenticated"),
"the BARE root lands (pitfall 3), not the nested tree"
);
let mut config = json!({
"writePermissions": { "type": "AnyOf", "securityLevels": [authenticated.clone()] }
});
assert!(!merge_level_into(
&mut config,
"writePermissions",
&administrator
));
let mut config = json!({
"writePermissions": { "type": "AnyOf", "securityLevels": [administrator.clone()] }
});
assert!(merge_level_into(
&mut config,
"writePermissions",
&administrator
));
let levels = config["writePermissions"]["securityLevels"]
.as_array()
.expect("levels");
assert_eq!(levels.len(), 2, "bare root added beside the nested entry");
assert_eq!(levels[1], bare("Authenticated"));
let mut config = json!({
"writePermissions": { "type": "AnyOf", "securityLevels": [] }
});
assert!(merge_level_into(
&mut config,
"writePermissions",
&authenticated
));
let levels = config["writePermissions"]["securityLevels"]
.as_array()
.expect("levels");
assert_eq!(levels.len(), 1);
assert_eq!(levels[0], bare("Authenticated"));
let mut config = json!({});
assert!(merge_level_into(
&mut config,
"readPermissions",
&authenticated
));
assert_eq!(config["readPermissions"]["type"], "AnyOf");
}
#[test]
fn put_body_carries_the_signature() {
let singleton: SecuritySingletonWire = serde_json::from_value(json!({
"signature": "dee8c94600032841",
"collection": "core",
"config": { "forceIdpAuth": true }
}))
.expect("parses");
let mut config = singleton.config.clone();
merge_level_into(
&mut config,
"writePermissions",
&level_tree(&["Authenticated"]),
);
let body = build_security_put_body(&singleton, &config);
assert_eq!(body[0]["signature"], "dee8c94600032841");
assert_eq!(body[0]["collection"], "core");
assert_eq!(body[0]["config"]["forceIdpAuth"], true);
assert!(
body[0]["config"]["writePermissions"]["securityLevels"]
.as_array()
.expect("levels")
.len()
== 1
);
}
fn commissioned() -> Value {
json!({
"writePermissions": {
"type": "AnyOf",
"securityLevels": [level_tree(&["Authenticated", "Roles", "Administrator"])]
}
})
}
#[test]
fn merge_write_level_adds_the_exact_tree_and_is_idempotent() {
let mut config = commissioned();
config["writePermissions"]["securityLevels"][0]["description"] = json!("x");
let admin = level_tree(&["Authenticated", "Roles", "Administrator"]);
assert!(!merge_write_level_into(&mut config, &admin));
let dedicated = level_tree(&["Authenticated", "IgnCliWrite"]);
assert!(merge_write_level_into(&mut config, &dedicated));
assert_eq!(config["writePermissions"]["securityLevels"][1], dedicated);
assert!(!merge_write_level_into(&mut config, &dedicated));
let bare_dedicated = level_tree(&["IgnCliWrite"]);
assert!(merge_write_level_into(&mut config, &bare_dedicated));
assert_eq!(
config["writePermissions"]["securityLevels"][2],
json!({ "name": "IgnCliWrite", "children": [] })
);
let mut empty = json!({});
assert!(merge_write_level_into(&mut empty, &dedicated));
assert_eq!(empty["writePermissions"]["type"], "AnyOf");
assert_eq!(
empty["writePermissions"]["securityLevels"],
json!([dedicated])
);
for config in [&config, &empty] {
let levels = config["writePermissions"]["securityLevels"]
.as_array()
.expect("levels");
assert!(!levels.iter().any(|entry| {
entry["name"] == "Authenticated"
&& entry["children"].as_array().is_some_and(Vec::is_empty)
}));
}
}
#[test]
fn write_admits_every_key_flags_the_three_open_forms() {
assert!(write_admits_every_key(&json!({})).is_some());
assert!(
write_admits_every_key(&json!({
"writePermissions": { "type": "AnyOf", "securityLevels": [] }
}))
.is_some()
);
for name in ["Authenticated", "authenticated", "Public", "PUBLIC"] {
let config = json!({
"writePermissions": {
"type": "AnyOf",
"securityLevels": [
level_tree(&["Authenticated", "Roles", "Administrator"]),
{ "name": name, "children": [] }
]
}
});
assert!(write_admits_every_key(&config).is_some(), "{name}");
}
assert_eq!(write_admits_every_key(&commissioned()), None);
let mut with_dedicated = commissioned();
merge_write_level_into(&mut with_dedicated, &level_tree(&["IgnCliWrite"]));
assert_eq!(write_admits_every_key(&with_dedicated), None);
let nested = json!({
"writePermissions": {
"type": "AnyOf",
"securityLevels": [level_tree(&["Authenticated", "IgnCliWrite"])]
}
});
assert_eq!(write_admits_every_key(&nested), None);
}
#[test]
fn remove_bare_authenticated_keeps_the_rest_and_refuses_empty() {
let admin = level_tree(&["Authenticated", "Roles", "Administrator"]);
let bare = json!({ "name": "Authenticated", "children": [] });
let mut config = json!({
"readPermissions": { "type": "AnyOf", "securityLevels": [bare.clone()] },
"writePermissions": { "type": "AnyOf", "securityLevels": [admin.clone(), bare.clone()] }
});
assert_eq!(remove_bare_authenticated_from_write(&mut config), Ok(true));
assert_eq!(
config["writePermissions"]["securityLevels"],
json!([admin.clone()])
);
assert_eq!(
config["readPermissions"]["securityLevels"],
json!([bare.clone()]),
"readPermissions is never touched"
);
let mut clean = commissioned();
let before = clean.clone();
assert_eq!(remove_bare_authenticated_from_write(&mut clean), Ok(false));
assert_eq!(clean, before);
let mut only = json!({
"writePermissions": { "type": "AnyOf", "securityLevels": [bare] }
});
let before = only.clone();
let err = remove_bare_authenticated_from_write(&mut only).expect_err("would empty");
assert!(err.contains("Public"), "{err}");
assert_eq!(only, before);
}
#[test]
fn create_body_carries_every_level() {
let levels = [
level_tree(&["Authenticated", "Roles", "IgnCheckRead"]),
level_tree(&["Authenticated", "IgnCliWrite"]),
];
let body = build_token_create_body("k", &levels, "hash", 1);
let granted = body[0]["config"]["profile"]["securityLevels"]
.as_array()
.expect("levels");
assert_eq!(granted.len(), 2);
assert_eq!(granted[0], levels[0]);
assert_eq!(granted[1], levels[1]);
}
}