Skip to main content

netget/client/ldap/
actions.rs

1//! LDAP client protocol actions implementation
2
3use crate::llm::actions::{
4    client_trait::{Client, ClientActionResult},
5    protocol_trait::Protocol,
6    ActionDefinition, Parameter,
7};
8use crate::protocol::EventType;
9use crate::state::app_state::AppState;
10use anyhow::{Context, Result};
11use serde_json::json;
12use std::sync::LazyLock;
13
14/// LDAP client connected event
15pub static LDAP_CLIENT_CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
16    EventType::new(
17        "ldap_connected",
18        "LDAP client successfully connected to server",
19        json!({"type": "bind", "dn": "cn=admin,dc=example,dc=com", "password": "secret"}),
20    )
21    .with_parameters(vec![Parameter {
22        name: "remote_addr".to_string(),
23        type_hint: "string".to_string(),
24        description: "LDAP server address".to_string(),
25        required: true,
26    }])
27});
28
29/// LDAP client bind response event
30pub static LDAP_CLIENT_BIND_RESPONSE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
31    EventType::new(
32        "ldap_bind_response",
33        "LDAP bind (authentication) response received",
34        json!({"type": "search", "base_dn": "dc=example,dc=com", "filter": "(objectClass=person)"}),
35    )
36    .with_parameters(vec![
37        Parameter {
38            name: "dn".to_string(),
39            type_hint: "string".to_string(),
40            description: "The DN the bind was for".to_string(),
41            required: true,
42        },
43        Parameter {
44            name: "success".to_string(),
45            type_hint: "boolean".to_string(),
46            description: "Whether bind was successful".to_string(),
47            required: true,
48        },
49        Parameter {
50            name: "message".to_string(),
51            type_hint: "string".to_string(),
52            description: "Response message or error".to_string(),
53            required: false,
54        },
55    ])
56});
57
58/// LDAP client search results event
59pub static LDAP_CLIENT_SEARCH_RESULTS_EVENT: LazyLock<EventType> = LazyLock::new(|| {
60    EventType::new(
61        "ldap_search_results",
62        "LDAP search results received",
63        json!({"type": "wait_for_more"}),
64    )
65    .with_parameters(vec![
66        Parameter {
67            name: "base_dn".to_string(),
68            type_hint: "string".to_string(),
69            description: "The search base this result belongs to".to_string(),
70            required: true,
71        },
72        Parameter {
73            name: "filter".to_string(),
74            type_hint: "string".to_string(),
75            description: "The filter this result belongs to".to_string(),
76            required: true,
77        },
78        Parameter {
79            name: "success".to_string(),
80            type_hint: "boolean".to_string(),
81            description: "Whether the server completed the search (false for e.g. \
82                          noSuchObject; `message` then carries the server's result)"
83                .to_string(),
84            required: true,
85        },
86        Parameter {
87            name: "message".to_string(),
88            type_hint: "string".to_string(),
89            description: "The server's result when the search failed".to_string(),
90            required: false,
91        },
92        Parameter {
93            name: "entries".to_string(),
94            type_hint: "array".to_string(),
95            description: "Array of LDAP entries with DN and attributes".to_string(),
96            required: true,
97        },
98        Parameter {
99            name: "count".to_string(),
100            type_hint: "number".to_string(),
101            description: "Number of entries returned".to_string(),
102            required: true,
103        },
104    ])
105});
106
107/// LDAP client modify response event
108pub static LDAP_CLIENT_MODIFY_RESPONSE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
109    EventType::new(
110        "ldap_modify_response",
111        "Response to an add, modify or delete",
112        json!({"type": "wait_for_more"}),
113    )
114    .with_parameters(vec![
115        Parameter {
116            name: "operation".to_string(),
117            type_hint: "string".to_string(),
118            description: "Which operation this answers: add, modify or delete".to_string(),
119            required: true,
120        },
121        Parameter {
122            name: "dn".to_string(),
123            type_hint: "string".to_string(),
124            description: "The entry the operation was for".to_string(),
125            required: true,
126        },
127        Parameter {
128            name: "success".to_string(),
129            type_hint: "boolean".to_string(),
130            description: "Whether modify was successful".to_string(),
131            required: true,
132        },
133        Parameter {
134            name: "message".to_string(),
135            type_hint: "string".to_string(),
136            description: "Response message or error".to_string(),
137            required: false,
138        },
139    ])
140});
141
142/// LDAP client protocol action handler
143pub struct LdapClientProtocol;
144
145impl LdapClientProtocol {
146    pub fn new() -> Self {
147        Self
148    }
149}
150
151// Implement Protocol trait (common functionality)
152impl Protocol for LdapClientProtocol {
153    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
154        vec![
155                ActionDefinition {
156                    name: "bind".to_string(),
157                    description: "Authenticate to LDAP server with credentials".to_string(),
158                    parameters: vec![
159                        Parameter {
160                            name: "dn".to_string(),
161                            type_hint: "string".to_string(),
162                            description: "Distinguished Name (DN) to bind as (e.g., 'cn=admin,dc=example,dc=com')".to_string(),
163                            required: true,
164                        },
165                        Parameter {
166                            name: "password".to_string(),
167                            type_hint: "string".to_string(),
168                            description: "Password for authentication".to_string(),
169                            required: true,
170                        },
171                    ],
172                    example: json!({
173                        "type": "bind",
174                        "dn": "cn=admin,dc=example,dc=com",
175                        "password": "secret"
176                    }),
177                log_template: None,
178                },
179                ActionDefinition {
180                    name: "search".to_string(),
181                    description: "Search for LDAP entries".to_string(),
182                    parameters: vec![
183                        Parameter {
184                            name: "base_dn".to_string(),
185                            type_hint: "string".to_string(),
186                            description: "Base DN to start search from (e.g., 'dc=example,dc=com')".to_string(),
187                            required: true,
188                        },
189                        Parameter {
190                            name: "filter".to_string(),
191                            type_hint: "string".to_string(),
192                            description: "LDAP search filter (e.g., '(objectClass=person)', '(cn=john)')".to_string(),
193                            required: true,
194                        },
195                        Parameter {
196                            name: "attributes".to_string(),
197                            type_hint: "array".to_string(),
198                            description: "Attributes to retrieve (e.g., ['cn', 'mail', 'uid'])".to_string(),
199                            required: false,
200                        },
201                        Parameter {
202                            name: "scope".to_string(),
203                            type_hint: "string".to_string(),
204                            description: "Search scope: 'base', 'one', or 'subtree' (default: 'subtree')".to_string(),
205                            required: false,
206                        },
207                    ],
208                    example: json!({
209                        "type": "search",
210                        "base_dn": "dc=example,dc=com",
211                        "filter": "(objectClass=person)",
212                        "attributes": ["cn", "mail", "uid"],
213                        "scope": "subtree"
214                    }),
215                log_template: None,
216                },
217                ActionDefinition {
218                    name: "add".to_string(),
219                    description: "Add a new LDAP entry".to_string(),
220                    parameters: vec![
221                        Parameter {
222                            name: "dn".to_string(),
223                            type_hint: "string".to_string(),
224                            description: "Distinguished Name of the new entry".to_string(),
225                            required: true,
226                        },
227                        Parameter {
228                            name: "attributes".to_string(),
229                            type_hint: "object".to_string(),
230                            description: "Object with attribute names as keys and arrays of values".to_string(),
231                            required: true,
232                        },
233                    ],
234                    example: json!({
235                        "type": "add",
236                        "dn": "cn=newuser,dc=example,dc=com",
237                        "attributes": {
238                            "objectClass": ["person", "inetOrgPerson"],
239                            "cn": ["newuser"],
240                            "sn": ["User"],
241                            "mail": ["newuser@example.com"]
242                        }
243                    }),
244                log_template: None,
245                },
246                ActionDefinition {
247                    name: "modify".to_string(),
248                    description: "Modify an existing LDAP entry".to_string(),
249                    parameters: vec![
250                        Parameter {
251                            name: "dn".to_string(),
252                            type_hint: "string".to_string(),
253                            description: "Distinguished Name of the entry to modify".to_string(),
254                            required: true,
255                        },
256                        Parameter {
257                            name: "operation".to_string(),
258                            type_hint: "string".to_string(),
259                            description: "Modification operation: 'add', 'delete', or 'replace'".to_string(),
260                            required: true,
261                        },
262                        Parameter {
263                            name: "attribute".to_string(),
264                            type_hint: "string".to_string(),
265                            description: "Attribute name to modify".to_string(),
266                            required: true,
267                        },
268                        Parameter {
269                            name: "values".to_string(),
270                            type_hint: "array".to_string(),
271                            description: "Array of values for the attribute".to_string(),
272                            required: true,
273                        },
274                    ],
275                    example: json!({
276                        "type": "modify",
277                        "dn": "cn=user,dc=example,dc=com",
278                        "operation": "replace",
279                        "attribute": "mail",
280                        "values": ["newemail@example.com"]
281                    }),
282                log_template: None,
283                },
284                ActionDefinition {
285                    name: "delete".to_string(),
286                    description: "Delete an LDAP entry".to_string(),
287                    parameters: vec![
288                        Parameter {
289                            name: "dn".to_string(),
290                            type_hint: "string".to_string(),
291                            description: "Distinguished Name of the entry to delete".to_string(),
292                            required: true,
293                        },
294                    ],
295                    example: json!({
296                        "type": "delete",
297                        "dn": "cn=olduser,dc=example,dc=com"
298                    }),
299                log_template: None,
300                },
301                ActionDefinition {
302                    name: "disconnect".to_string(),
303                    description: "Disconnect from the LDAP server".to_string(),
304                    parameters: vec![],
305                    example: json!({
306                        "type": "disconnect"
307                    }),
308                log_template: None,
309                },
310            ]
311    }
312    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
313        vec![ActionDefinition {
314            name: "wait_for_more".to_string(),
315            description: "Wait for more data from LDAP server".to_string(),
316            parameters: vec![],
317            example: json!({
318                "type": "wait_for_more"
319            }),
320            log_template: None,
321        }]
322    }
323    fn protocol_name(&self) -> &'static str {
324        "LDAP"
325    }
326    fn get_event_types(&self) -> Vec<EventType> {
327        vec![
328            EventType::new(
329                "ldap_connected",
330                "Triggered when LDAP client connects to server",
331                json!({"type": "wait_for_more"}),
332            ),
333            EventType::new(
334                "ldap_bind_response",
335                "Triggered when LDAP bind response is received",
336                json!({"type": "wait_for_more"}),
337            ),
338            EventType::new(
339                "ldap_search_results",
340                "Triggered when LDAP search results are received",
341                json!({"type": "wait_for_more"}),
342            ),
343            EventType::new(
344                "ldap_modify_response",
345                "Triggered when LDAP modify response is received",
346                json!({"type": "wait_for_more"}),
347            ),
348        ]
349    }
350    fn stack_name(&self) -> &'static str {
351        "ETH>IP>TCP>LDAP"
352    }
353    fn keywords(&self) -> Vec<&'static str> {
354        vec!["ldap", "ldap client", "connect to ldap", "directory"]
355    }
356    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
357        use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
358
359        ProtocolMetadataV2::builder()
360            .state(DevelopmentState::Beta)
361            .implementation(
362                "ldap3 (synchronous LdapConn on spawn_blocking). Every operation's response \
363                 goes back to the model - bind, search (including a search the server refuses), \
364                 add, modify, delete - and the model's answer is executed in turn, bounded at \
365                 four follow-ups",
366            )
367            .llm_control("Full control over bind, search, add, modify, delete operations")
368            .e2e_testing(
369                "tests/client/ldap/real_server_test.rs, 19 LLM calls, against OpenLDAP's slapd \
370                 (mdb, core/cosine/inetorgperson) seeded with ldapadd and read back with \
371                 ldapsearch. The model binds as the rootdn, searches, adds an entry whose \
372                 description it built from the search result, modifies a mail and deletes an \
373                 entry; ldapsearch must find each effect. A search slapd refuses \
374                 (noSuchObject) reaches the model and it creates the missing OU. The follow-up \
375                 bound is asserted from slapd's own log: exactly five searches. Not \
376                 #[ignore]d; a missing slapd, ldapadd, ldapsearch or schema directory fails \
377                 the test rather than skipping it.",
378            )
379            .notes(
380                "ldap3's synchronous LdapConn owns the socket, so every verb runs on \
381                 spawn_blocking and NetGet never sees the wire bytes. Simple bind only: no \
382                 SASL, no LDAPS or StartTLS. Search results are not paged, and attributes \
383                 whose values are not UTF-8 (jpegPhoto, certificates) are left out of them. Bind credentials come \
384                 from the model or the operator; no key material or system account is read.",
385            )
386            .build()
387    }
388    fn description(&self) -> &'static str {
389        "LDAP client for directory services operations"
390    }
391    fn example_prompt(&self) -> &'static str {
392        "Connect to LDAP at localhost:389, bind as cn=admin,dc=example,dc=com and search for all users"
393    }
394    fn group_name(&self) -> &'static str {
395        "Directory"
396    }
397    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
398        use crate::llm::actions::StartupExamples;
399        use serde_json::json;
400
401        StartupExamples::new(
402            // LLM mode: LLM controls LDAP operations
403            json!({
404                "type": "open_client",
405                "remote_addr": "localhost:389",
406                "base_stack": "ldap",
407                "instruction": "Bind as cn=admin,dc=example,dc=com and search for all users"
408            }),
409            // Script mode: Code-based directory operations
410            json!({
411                "type": "open_client",
412                "remote_addr": "localhost:389",
413                "base_stack": "ldap",
414                "event_handlers": [{
415                    "event_pattern": "ldap_search_results",
416                    "handler": {
417                        "type": "script",
418                        "language": "python",
419                        "code": "<ldap_client_handler>"
420                    }
421                }]
422            }),
423            // Static mode: Fixed bind and search
424            json!({
425                "type": "open_client",
426                "remote_addr": "localhost:389",
427                "base_stack": "ldap",
428                "event_handlers": [
429                    {
430                        "event_pattern": "ldap_connected",
431                        "handler": {
432                            "type": "static",
433                            "actions": [{
434                                "type": "bind",
435                                "dn": "cn=admin,dc=example,dc=com",
436                                "password": "secret"
437                            }]
438                        }
439                    },
440                    {
441                        "event_pattern": "ldap_bind_response",
442                        "handler": {
443                            "type": "static",
444                            "actions": [{
445                                "type": "search",
446                                "base_dn": "dc=example,dc=com",
447                                "filter": "(objectClass=person)"
448                            }]
449                        }
450                    },
451                    {
452                        "event_pattern": "ldap_search_results",
453                        "handler": {
454                            "type": "static",
455                            "actions": [{
456                                "type": "disconnect"
457                            }]
458                        }
459                    }
460                ]
461            }),
462        )
463    }
464}
465
466// Implement Client trait (client-specific functionality)
467impl Client for LdapClientProtocol {
468    fn connect(
469        &self,
470        ctx: crate::protocol::ConnectContext,
471    ) -> std::pin::Pin<
472        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
473    > {
474        Box::pin(async move {
475            use crate::client::ldap::LdapClient;
476            LdapClient::connect_with_llm_actions(
477                ctx.remote_addr,
478                ctx.llm_client,
479                ctx.state,
480                ctx.status_tx,
481                ctx.client_id,
482            )
483            .await
484        })
485    }
486    fn execute_action(&self, action: serde_json::Value) -> Result<ClientActionResult> {
487        let action_type = action
488            .get("type")
489            .and_then(|v| v.as_str())
490            .context("Missing 'type' field in action")?;
491
492        match action_type {
493            "bind" => {
494                let dn = action
495                    .get("dn")
496                    .and_then(|v| v.as_str())
497                    .context("Missing 'dn' field")?
498                    .to_string();
499                let password = action
500                    .get("password")
501                    .and_then(|v| v.as_str())
502                    .context("Missing 'password' field")?
503                    .to_string();
504
505                Ok(ClientActionResult::Custom {
506                    name: "ldap_bind".to_string(),
507                    data: json!({
508                        "dn": dn,
509                        "password": password,
510                    }),
511                })
512            }
513            "search" => {
514                let base_dn = action
515                    .get("base_dn")
516                    .and_then(|v| v.as_str())
517                    .context("Missing 'base_dn' field")?
518                    .to_string();
519                let filter = action
520                    .get("filter")
521                    .and_then(|v| v.as_str())
522                    .context("Missing 'filter' field")?
523                    .to_string();
524                let attributes = action
525                    .get("attributes")
526                    .and_then(|v| v.as_array())
527                    .map(|arr| {
528                        arr.iter()
529                            .filter_map(|v| v.as_str().map(|s| s.to_string()))
530                            .collect::<Vec<String>>()
531                    })
532                    .unwrap_or_default();
533                let scope = action
534                    .get("scope")
535                    .and_then(|v| v.as_str())
536                    .unwrap_or("subtree")
537                    .to_string();
538
539                Ok(ClientActionResult::Custom {
540                    name: "ldap_search".to_string(),
541                    data: json!({
542                        "base_dn": base_dn,
543                        "filter": filter,
544                        "attributes": attributes,
545                        "scope": scope,
546                    }),
547                })
548            }
549            "add" => {
550                let dn = action
551                    .get("dn")
552                    .and_then(|v| v.as_str())
553                    .context("Missing 'dn' field")?
554                    .to_string();
555                let attributes = action
556                    .get("attributes")
557                    .context("Missing 'attributes' field")?
558                    .clone();
559
560                Ok(ClientActionResult::Custom {
561                    name: "ldap_add".to_string(),
562                    data: json!({
563                        "dn": dn,
564                        "attributes": attributes,
565                    }),
566                })
567            }
568            "modify" => {
569                let dn = action
570                    .get("dn")
571                    .and_then(|v| v.as_str())
572                    .context("Missing 'dn' field")?
573                    .to_string();
574                let operation = action
575                    .get("operation")
576                    .and_then(|v| v.as_str())
577                    .context("Missing 'operation' field")?
578                    .to_string();
579                let attribute = action
580                    .get("attribute")
581                    .and_then(|v| v.as_str())
582                    .context("Missing 'attribute' field")?
583                    .to_string();
584                let values = action
585                    .get("values")
586                    .and_then(|v| v.as_array())
587                    .context("Missing 'values' field")?
588                    .iter()
589                    .filter_map(|v| v.as_str().map(|s| s.to_string()))
590                    .collect::<Vec<String>>();
591
592                Ok(ClientActionResult::Custom {
593                    name: "ldap_modify".to_string(),
594                    data: json!({
595                        "dn": dn,
596                        "operation": operation,
597                        "attribute": attribute,
598                        "values": values,
599                    }),
600                })
601            }
602            "delete" => {
603                let dn = action
604                    .get("dn")
605                    .and_then(|v| v.as_str())
606                    .context("Missing 'dn' field")?
607                    .to_string();
608
609                Ok(ClientActionResult::Custom {
610                    name: "ldap_delete".to_string(),
611                    data: json!({
612                        "dn": dn,
613                    }),
614                })
615            }
616            "disconnect" => Ok(ClientActionResult::Disconnect),
617            "wait_for_more" => Ok(ClientActionResult::WaitForMore),
618            _ => Err(anyhow::anyhow!(
619                "Unknown LDAP client action: {}",
620                action_type
621            )),
622        }
623    }
624}