1use std::collections::BTreeMap;
47
48use serde::{Deserialize, Serialize};
49use serde_json::Value;
50use serde_json::json;
51
52use crate::client::idp::GatewaySession;
53use crate::client::idp::IdpLoginFlow;
54use crate::error::CoreError;
55
56pub(crate) const GENERATE_PATH: &str = "/data/api/v1/api-token/generate";
58
59pub(crate) const API_TOKEN_LIST_PATH: &str = "/data/api/v1/resources/list/ignition/api-token";
61
62pub(crate) const API_TOKEN_CREATE_PATH: &str = "/data/api/v1/resources/ignition/api-token";
64
65pub(crate) const SECURITY_SINGLETON_PATH: &str =
70 "/data/api/v1/resources/singleton/ignition/security-properties";
71
72pub(crate) const SECURITY_PUT_PATH: &str = "/data/api/v1/resources/ignition/security-properties";
74
75pub(crate) const API_TOKEN_TYPE: &str = "ignition/api-token";
78
79pub(crate) const BASIC_TOKEN_TYPE: &str = "basic-token";
81
82#[derive(Debug, Clone, Deserialize)]
97pub struct GeneratedKeyWire {
98 #[serde(default)]
101 pub key: String,
102 #[serde(default)]
105 pub hash: String,
106}
107
108#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
111pub struct ApiTokenRecord {
112 #[serde(default)]
115 pub name: String,
116 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub description: Option<String>,
119 #[serde(default)]
121 pub enabled: bool,
122 #[serde(default)]
124 pub config: ApiTokenConfig,
125 #[serde(flatten)]
127 pub extra: BTreeMap<String, Value>,
128}
129
130#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
133pub struct ApiTokenConfig {
134 #[serde(default)]
136 pub profile: ApiTokenProfile,
137 #[serde(default)]
139 pub settings: ApiTokenSettings,
140}
141
142#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
145pub struct ApiTokenProfile {
146 #[serde(rename = "type", default)]
148 pub kind: String,
149 #[serde(rename = "securityLevels", default)]
151 pub security_levels: Vec<Value>,
152 #[serde(rename = "secureChannelRequired", default)]
155 pub secure_channel_required: bool,
156 #[serde(default)]
158 pub timestamp: i64,
159}
160
161#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
163pub struct ApiTokenSettings {
164 #[serde(rename = "tokenHash", default)]
166 pub token_hash: String,
167}
168
169#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
172pub struct ResourceMutationWire {
173 #[serde(default)]
175 pub success: bool,
176 #[serde(default)]
178 pub changes: Vec<ResourceChangeWire>,
179 #[serde(default, skip_serializing_if = "Option::is_none")]
182 pub problem: Option<Value>,
183}
184
185#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
187pub struct ResourceChangeWire {
188 #[serde(default)]
189 pub name: String,
190 #[serde(rename = "type", default)]
191 pub kind: String,
192 #[serde(default)]
193 pub collection: String,
194 #[serde(rename = "newSignature", default)]
195 pub new_signature: String,
196}
197
198pub fn level_path_string(tree: &Value) -> String {
203 let mut segments: Vec<&str> = Vec::new();
204 let mut node = tree;
205 while let Some(name) = node.get("name").and_then(Value::as_str) {
206 segments.push(name);
207 node = node
208 .get("children")
209 .and_then(Value::as_array)
210 .and_then(|children| children.first())
211 .unwrap_or(&Value::Null);
212 }
213 segments.join("/")
214}
215
216#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
220pub struct SecuritySingletonWire {
221 #[serde(default)]
224 pub signature: String,
225 #[serde(default = "default_collection")]
227 pub collection: String,
228 #[serde(default)]
231 pub config: Value,
232 #[serde(flatten)]
234 pub extra: BTreeMap<String, Value>,
235}
236
237fn default_collection() -> String {
238 "core".to_string()
239}
240
241pub fn level_tree(path: &[&str]) -> Value {
249 match path.split_first() {
250 Some((head, [])) => json!({ "name": head, "children": [] }),
253 Some((head, tail)) => json!({
254 "name": head,
255 "children": [level_tree(tail)],
256 }),
257 None => json!([]),
259 }
260}
261
262pub fn build_token_create_body(
266 name: &str,
267 level: &Value,
268 token_hash: &str,
269 timestamp_ms: i64,
270) -> Value {
271 json!([{
272 "name": name,
273 "collection": "core",
274 "enabled": true,
275 "description": "",
276 "config": {
277 "profile": {
278 "securityLevels": [level],
279 "secureChannelRequired": false,
280 "type": BASIC_TOKEN_TYPE,
281 "timestamp": timestamp_ms
282 },
283 "settings": { "tokenHash": token_hash }
284 }
285 }])
286}
287
288pub fn build_security_put_body(singleton: &SecuritySingletonWire, config: &Value) -> Value {
293 json!([{
294 "collection": singleton.collection,
295 "enabled": true,
296 "description": "",
297 "signature": singleton.signature,
298 "config": config
299 }])
300}
301
302pub fn merge_level_into(config: &mut Value, field: &str, level: &Value) -> bool {
322 let Some(incoming_root) = level.get("name").and_then(Value::as_str) else {
323 return false;
324 };
325 let Some(config_object) = config.as_object_mut() else {
326 return false;
327 };
328 let permissions = config_object
329 .entry(field)
330 .or_insert_with(|| json!({ "type": "AnyOf", "securityLevels": [] }));
331 let Some(permissions_object) = permissions.as_object_mut() else {
332 return false;
333 };
334 let levels = permissions_object
335 .entry("securityLevels")
336 .or_insert_with(|| Value::Array(Vec::new()));
337 let Some(levels_array) = levels.as_array_mut() else {
338 return false;
339 };
340 let has_bare_root = levels_array.iter().any(|existing| {
344 existing.get("name").and_then(Value::as_str) == Some(incoming_root)
345 && existing
346 .get("children")
347 .and_then(Value::as_array)
348 .is_some_and(|children| children.is_empty())
349 });
350 if has_bare_root {
351 return false;
352 }
353 levels_array.push(json!({ "name": incoming_root, "children": [] }));
354 true
355}
356
357pub async fn api_tokens_via_session(
364 flow: &IdpLoginFlow,
365 session: &GatewaySession,
366) -> Result<Vec<ApiTokenRecord>, CoreError> {
367 let value = flow
368 .session_get_json(session, API_TOKEN_LIST_PATH, &[("limit", "100")])
369 .await?;
370 let total = value
371 .get("metadata")
372 .and_then(|metadata| metadata.get("total"))
373 .and_then(Value::as_i64)
374 .unwrap_or(0);
375 let items = value
376 .get("items")
377 .cloned()
378 .ok_or_else(|| CoreError::Internal("api-token list carried no items array".into()))?;
379 let count = items.as_array().map_or(0, Vec::len) as i64;
380 if total > count {
381 return Err(CoreError::Internal(format!(
382 "this gateway holds {total} API keys — more than adopt's one-page \
383 lookup ({count} returned); delete unused keys and re-run"
384 )));
385 }
386 let records: Vec<ApiTokenRecord> = serde_json::from_value(items).map_err(|err| {
387 CoreError::Internal(format!(
388 "api-token list item did not match the record shape: {err}"
389 ))
390 })?;
391 Ok(records)
392}
393
394pub async fn generate_api_key_via_session(
397 flow: &IdpLoginFlow,
398 session: &GatewaySession,
399) -> Result<GeneratedKeyWire, CoreError> {
400 let value = flow
401 .session_post_json(session, GENERATE_PATH, &json!({}))
402 .await?;
403 serde_json::from_value(value)
404 .map_err(|err| CoreError::Internal(format!("api-token generate answer shape: {err}")))
405}
406
407pub async fn create_api_token_via_session(
410 flow: &IdpLoginFlow,
411 session: &GatewaySession,
412 body: &Value,
413) -> Result<ResourceMutationWire, CoreError> {
414 let value = flow
415 .session_post_json(session, API_TOKEN_CREATE_PATH, body)
416 .await?;
417 serde_json::from_value(value)
418 .map_err(|err| CoreError::Internal(format!("api-token create answer shape: {err}")))
419}
420
421pub async fn security_properties_via_session(
424 flow: &IdpLoginFlow,
425 session: &GatewaySession,
426) -> Result<SecuritySingletonWire, CoreError> {
427 let value = flow
428 .session_get_json(
429 session,
430 SECURITY_SINGLETON_PATH,
431 &[("defaultIfUndefined", "true")],
432 )
433 .await?;
434 serde_json::from_value(value)
435 .map_err(|err| CoreError::Internal(format!("security-properties singleton shape: {err}")))
436}
437
438pub async fn put_security_properties_via_session(
443 flow: &IdpLoginFlow,
444 session: &GatewaySession,
445 body: &Value,
446) -> Result<ResourceMutationWire, CoreError> {
447 let value = flow
448 .session_put_json(session, SECURITY_PUT_PATH, body)
449 .await?;
450 serde_json::from_value(value)
451 .map_err(|err| CoreError::Internal(format!("security-properties put answer shape: {err}")))
452}
453
454#[cfg(test)]
455mod tests {
456 use serde_json::Value;
457 use serde_json::json;
458
459 use super::{
460 ApiTokenRecord, GeneratedKeyWire, ResourceMutationWire, SecuritySingletonWire,
461 build_security_put_body, build_token_create_body, level_tree, merge_level_into,
462 };
463
464 #[test]
469 fn generate_parses_the_live_capture() {
470 let wire: GeneratedKeyWire = serde_json::from_value(json!({
471 "key": "AAAAexample-redacted-key-43-chars-urlsafe-0",
472 "hash": "BBBBexample_redacted_hash_43_chars_urlsafe0"
473 }))
474 .expect("the live generate shape must parse");
475 assert_eq!(wire.key.len(), 43, "urlsafe plaintext, 43 chars");
476 assert_eq!(wire.hash.len(), 43, "stored hash, 43 chars");
477 }
478
479 #[test]
483 fn list_item_parses_the_live_capture() {
484 let record: ApiTokenRecord = serde_json::from_value(json!({
485 "type": "ignition/api-token",
486 "name": "ign-adopt-capture",
487 "description": "",
488 "enabled": true,
489 "version": 1,
490 "collection": "core",
491 "signature": "87717b87",
492 "config": {
493 "profile": {
494 "type": "basic-token",
495 "secureChannelRequired": false,
496 "securityLevels": [
497 { "name": "Authenticated", "children": [] }
498 ],
499 "timestamp": 1789760514446i64
500 },
501 "settings": { "tokenHash": "BBBBexample_redacted_hash_43_chars_urlsafe0" }
502 },
503 "attributes": { "uuid": "…", "enabled": true }
504 }))
505 .expect("the live list item shape must parse");
506 assert_eq!(record.name, "ign-adopt-capture");
507 assert!(record.enabled);
508 assert!(!record.config.profile.secure_channel_required);
509 assert_eq!(record.config.profile.kind, "basic-token");
510 assert_eq!(
511 record.config.settings.token_hash,
512 "BBBBexample_redacted_hash_43_chars_urlsafe0"
513 );
514 assert!(record.extra.contains_key("signature"), "passthrough rides");
515 }
516
517 #[test]
521 fn create_body_matches_the_live_capture() {
522 let level = level_tree(&["Authenticated"]);
523 let body = build_token_create_body(
524 "ign-adopt-capture",
525 &level,
526 "BBBBexample_redacted_hash_43_chars_urlsafe0",
527 1789760514446,
528 );
529 let captured: Value = json!([{
530 "name": "ign-adopt-capture",
531 "collection": "core",
532 "enabled": true,
533 "description": "",
534 "config": {
535 "profile": {
536 "securityLevels": [
537 { "name": "Authenticated",
538 "description": "Represents a user who has been authenticated by the system.",
539 "children": [] }
540 ],
541 "secureChannelRequired": false,
542 "type": "basic-token",
543 "timestamp": 1789760514446i64
544 },
545 "settings": { "tokenHash": "BBBBexample_redacted_hash_43_chars_urlsafe0" }
546 }
547 }]);
548 let mut expected = captured;
551 for record in expected.as_array_mut().expect("array") {
552 record["config"]["profile"]["securityLevels"][0]
553 .as_object_mut()
554 .expect("level object")
555 .remove("description");
556 }
557 assert_eq!(body, expected, "byte-faithful modulo descriptions");
558 }
559
560 #[test]
563 fn mutation_parses_the_live_create_answer() {
564 let wire: ResourceMutationWire = serde_json::from_value(json!({
565 "success": true,
566 "changes": [{
567 "name": "ign-adopt-capture",
568 "type": "ignition/api-token",
569 "collection": "core",
570 "newSignature": "87717b874ec57a83e676c7e757e5264efc20e4cdb571525470ef6c93d5736f45"
571 }],
572 "problem": null
573 }))
574 .expect("the live create answer must parse");
575 assert!(wire.success);
576 assert_eq!(wire.changes.len(), 1);
577 assert_eq!(wire.changes[0].kind, "ignition/api-token");
578 assert!(wire.problem.is_none());
579 }
580
581 #[test]
584 fn singleton_parses_the_live_capture() {
585 let wire: SecuritySingletonWire = serde_json::from_value(json!({
586 "type": "ignition/security-properties",
587 "signature": "dee8c94600032841",
588 "collection": "core",
589 "enabled": true,
590 "config": {
591 "readPermissions": {
592 "type": "AnyOf",
593 "securityLevels": [ { "name": "Authenticated", "children": [] } ]
594 },
595 "writePermissions": {
596 "type": "AnyOf",
597 "securityLevels": [ { "name": "Authenticated", "children": [] } ]
598 }
599 },
600 "attributes": { "lastModification": { "actor": "admin" } }
601 }))
602 .expect("the live singleton shape must parse");
603 assert_eq!(wire.signature, "dee8c94600032841");
604 assert!(wire.config.get("writePermissions").is_some());
605 assert!(wire.extra.contains_key("attributes"));
606 }
607
608 #[test]
611 fn level_tree_nests_the_administrator_path() {
612 assert_eq!(
613 level_tree(&["Authenticated", "Roles", "Administrator"]),
614 json!({
615 "name": "Authenticated",
616 "children": [{
617 "name": "Roles",
618 "children": [{
619 "name": "Administrator",
620 "children": []
621 }]
622 }]
623 })
624 );
625 assert_eq!(
626 level_tree(&["Authenticated"]),
627 json!({ "name": "Authenticated", "children": [] })
628 );
629 }
630
631 #[test]
633 fn level_path_string_walks_the_chain() {
634 use super::level_path_string;
635 assert_eq!(
636 level_path_string(&level_tree(&["Authenticated", "Roles", "Administrator"])),
637 "Authenticated/Roles/Administrator"
638 );
639 assert_eq!(
640 level_path_string(&level_tree(&["Authenticated"])),
641 "Authenticated"
642 );
643 }
644
645 #[test]
652 fn merge_adds_missing_and_is_idempotent() {
653 let authenticated = level_tree(&["Authenticated"]);
654 let administrator = level_tree(&["Authenticated", "Roles", "Administrator"]);
655 let bare = |root: &str| json!({ "name": root, "children": [] });
656
657 let mut config = json!({
659 "writePermissions": {
660 "type": "AnyOf",
661 "securityLevels": [ bare("SecurityZones") ]
662 }
663 });
664 assert!(merge_level_into(
665 &mut config,
666 "writePermissions",
667 &administrator
668 ));
669 assert!(
670 !merge_level_into(&mut config, "writePermissions", &administrator),
671 "idempotent re-run changes nothing"
672 );
673 assert_eq!(
674 config["writePermissions"]["securityLevels"][1],
675 bare("Authenticated"),
676 "the BARE root lands (pitfall 3), not the nested tree"
677 );
678
679 let mut config = json!({
681 "writePermissions": { "type": "AnyOf", "securityLevels": [authenticated.clone()] }
682 });
683 assert!(!merge_level_into(
684 &mut config,
685 "writePermissions",
686 &administrator
687 ));
688
689 let mut config = json!({
692 "writePermissions": { "type": "AnyOf", "securityLevels": [administrator.clone()] }
693 });
694 assert!(merge_level_into(
695 &mut config,
696 "writePermissions",
697 &administrator
698 ));
699 let levels = config["writePermissions"]["securityLevels"]
700 .as_array()
701 .expect("levels");
702 assert_eq!(levels.len(), 2, "bare root added beside the nested entry");
703 assert_eq!(levels[1], bare("Authenticated"));
704
705 let mut config = json!({
707 "writePermissions": { "type": "AnyOf", "securityLevels": [] }
708 });
709 assert!(merge_level_into(
710 &mut config,
711 "writePermissions",
712 &authenticated
713 ));
714 let levels = config["writePermissions"]["securityLevels"]
715 .as_array()
716 .expect("levels");
717 assert_eq!(levels.len(), 1);
718 assert_eq!(levels[0], bare("Authenticated"));
719
720 let mut config = json!({});
722 assert!(merge_level_into(
723 &mut config,
724 "readPermissions",
725 &authenticated
726 ));
727 assert_eq!(config["readPermissions"]["type"], "AnyOf");
728 }
729
730 #[test]
733 fn put_body_carries_the_signature() {
734 let singleton: SecuritySingletonWire = serde_json::from_value(json!({
735 "signature": "dee8c94600032841",
736 "collection": "core",
737 "config": { "forceIdpAuth": true }
738 }))
739 .expect("parses");
740 let mut config = singleton.config.clone();
741 merge_level_into(
742 &mut config,
743 "writePermissions",
744 &level_tree(&["Authenticated"]),
745 );
746 let body = build_security_put_body(&singleton, &config);
747 assert_eq!(body[0]["signature"], "dee8c94600032841");
748 assert_eq!(body[0]["collection"], "core");
749 assert_eq!(body[0]["config"]["forceIdpAuth"], true);
750 assert!(
751 body[0]["config"]["writePermissions"]["securityLevels"]
752 .as_array()
753 .expect("levels")
754 .len()
755 == 1
756 );
757 }
758}