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,
level: &Value,
token_hash: &str,
timestamp_ms: i64,
) -> Value {
json!([{
"name": name,
"collection": "core",
"enabled": true,
"description": "",
"config": {
"profile": {
"securityLevels": [level],
"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
}
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,
};
#[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",
&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
);
}
}