Skip to main content

greentic_operator/ingress/
control_directive.rs

1use base64::Engine;
2use base64::engine::general_purpose::STANDARD;
3use serde_json::Value as JsonValue;
4
5#[derive(Clone, Debug, PartialEq, Eq)]
6pub struct DispatchTarget {
7    pub tenant: String,
8    pub team: Option<String>,
9    pub pack: String,
10    pub flow: Option<String>,
11    pub node: Option<String>,
12}
13
14#[derive(Clone, Debug)]
15pub struct IngressReply {
16    pub text: Option<String>,
17    pub card_cbor: Option<JsonValue>,
18    pub status_code: Option<u16>,
19    pub reason_code: Option<String>,
20}
21
22#[derive(Clone, Debug)]
23pub enum ControlDirective {
24    Continue,
25    Dispatch { target: DispatchTarget },
26    Respond { reply: IngressReply },
27    Deny { reply: IngressReply },
28}
29
30pub fn try_parse_control_directive(output: &JsonValue) -> Option<ControlDirective> {
31    let decoded = decode_directive_json(output).unwrap_or_else(|| output.clone());
32    let action = decoded
33        .get("action")
34        .and_then(JsonValue::as_str)
35        .map(|value| value.trim().to_ascii_lowercase())?;
36    match action.as_str() {
37        "continue" => Some(ControlDirective::Continue),
38        "dispatch" => parse_dispatch(decoded.get("target"))
39            .map(|target| ControlDirective::Dispatch { target }),
40        "respond" => Some(ControlDirective::Respond {
41            reply: parse_reply(&decoded, false),
42        }),
43        "deny" => Some(ControlDirective::Deny {
44            reply: parse_reply(&decoded, true),
45        }),
46        _ => None,
47    }
48}
49
50fn decode_directive_json(output: &JsonValue) -> Option<JsonValue> {
51    let object = output.as_object()?;
52    for key in [
53        "hook_decision_cbor_b64",
54        "cbor_b64",
55        "hook_decision_cbor",
56        "result_cbor_b64",
57    ] {
58        let Some(raw) = object.get(key).and_then(JsonValue::as_str) else {
59            continue;
60        };
61        let Ok(bytes) = STANDARD.decode(raw) else {
62            continue;
63        };
64        if let Ok(value) = serde_cbor::from_slice::<JsonValue>(&bytes) {
65            return Some(value);
66        }
67    }
68    None
69}
70
71fn parse_dispatch(raw: Option<&JsonValue>) -> Option<DispatchTarget> {
72    let raw = raw?;
73    if let Some(target) = raw.as_str() {
74        return parse_dispatch_target_string(target);
75    }
76    let map = raw.as_object()?;
77    let tenant = map.get("tenant")?.as_str()?.trim().to_string();
78    let team = map
79        .get("team")
80        .and_then(JsonValue::as_str)
81        .map(str::trim)
82        .filter(|value| !value.is_empty())
83        .map(ToString::to_string);
84    let pack = map.get("pack")?.as_str()?.trim().to_string();
85    let flow = map
86        .get("flow")
87        .and_then(JsonValue::as_str)
88        .map(str::trim)
89        .filter(|value| !value.is_empty())
90        .map(ToString::to_string);
91    let node = map
92        .get("node")
93        .and_then(JsonValue::as_str)
94        .map(str::trim)
95        .filter(|value| !value.is_empty())
96        .map(ToString::to_string);
97    if tenant.is_empty() || pack.is_empty() {
98        return None;
99    }
100    Some(DispatchTarget {
101        tenant,
102        team,
103        pack,
104        flow,
105        node,
106    })
107}
108
109fn parse_dispatch_target_string(raw: &str) -> Option<DispatchTarget> {
110    let segments = raw
111        .split('/')
112        .map(str::trim)
113        .filter(|value| !value.is_empty())
114        .collect::<Vec<_>>();
115    if segments.len() < 3 || segments.len() > 5 {
116        return None;
117    }
118    let tenant = segments[0].to_string();
119    let team = Some(segments[1].to_string()).filter(|value| !value.is_empty());
120    let pack = segments[2].to_string();
121    if tenant.is_empty() || pack.is_empty() {
122        return None;
123    }
124    Some(DispatchTarget {
125        tenant,
126        team,
127        pack,
128        flow: segments.get(3).map(|value| value.to_string()),
129        node: segments.get(4).map(|value| value.to_string()),
130    })
131}
132
133fn parse_reply(decoded: &JsonValue, deny: bool) -> IngressReply {
134    let text = decoded
135        .get("response_text")
136        .and_then(JsonValue::as_str)
137        .map(ToString::to_string)
138        .or_else(|| {
139            if deny {
140                decoded
141                    .get("reason")
142                    .and_then(|value| value.get("text"))
143                    .and_then(JsonValue::as_str)
144                    .map(ToString::to_string)
145            } else {
146                None
147            }
148        });
149    let reason_code = decoded
150        .get("reason_code")
151        .and_then(JsonValue::as_str)
152        .map(ToString::to_string)
153        .or_else(|| {
154            decoded
155                .get("reason")
156                .and_then(|value| value.get("code"))
157                .and_then(JsonValue::as_str)
158                .map(ToString::to_string)
159        });
160    let status_code = decoded
161        .get("status_code")
162        .and_then(JsonValue::as_u64)
163        .map(|value| value as u16)
164        .or(if deny { Some(403) } else { Some(200) });
165    IngressReply {
166        text,
167        card_cbor: decoded.get("response_card").cloned(),
168        status_code,
169        reason_code,
170    }
171}
172
173#[cfg(test)]
174mod tests {
175    use serde_json::json;
176
177    use super::*;
178
179    #[test]
180    fn parse_dispatch_string_target() {
181        let directive = try_parse_control_directive(&json!({
182            "action": "dispatch",
183            "target": "acme/default/pack-a/flow-x/node-y"
184        }))
185        .expect("directive");
186        let ControlDirective::Dispatch { target } = directive else {
187            panic!("expected dispatch");
188        };
189        assert_eq!(target.tenant, "acme");
190        assert_eq!(target.team.as_deref(), Some("default"));
191        assert_eq!(target.pack, "pack-a");
192        assert_eq!(target.flow.as_deref(), Some("flow-x"));
193        assert_eq!(target.node.as_deref(), Some("node-y"));
194    }
195
196    #[test]
197    fn parse_respond_directive() {
198        let directive = try_parse_control_directive(&json!({
199            "action": "respond",
200            "response_text": "ok"
201        }))
202        .expect("directive");
203        let ControlDirective::Respond { reply } = directive else {
204            panic!("expected respond");
205        };
206        assert_eq!(reply.text.as_deref(), Some("ok"));
207        assert_eq!(reply.status_code, Some(200));
208    }
209
210    #[test]
211    fn parse_dispatch_target_requires_min_segments() {
212        let directive = try_parse_control_directive(&json!({
213            "action": "dispatch",
214            "target": "acme/default"
215        }));
216        assert!(directive.is_none());
217    }
218
219    #[test]
220    fn parse_deny_defaults_to_forbidden() {
221        let directive = try_parse_control_directive(&json!({
222            "action": "deny",
223            "reason": { "code": "blocked", "text": "denied by policy" }
224        }))
225        .expect("directive");
226        let ControlDirective::Deny { reply } = directive else {
227            panic!("expected deny");
228        };
229        assert_eq!(reply.reason_code.as_deref(), Some("blocked"));
230        assert_eq!(reply.text.as_deref(), Some("denied by policy"));
231        assert_eq!(reply.status_code, Some(403));
232    }
233}