ldap-cli 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,386 @@
1
+ # Design Document: ldap-cli
2
+
3
+ ## Overview
4
+
5
+ `ldapc` is a Python CLI tool for querying LDAP directories. It provides user and group search capabilities with persistent configuration and secure credential storage via the OS keychain.
6
+
7
+ The tool follows the same project conventions as `cftcli` and `identity-provider`: a single package with module-per-command structure, `pyproject.toml` for build configuration, hatchling as the build backend, and pytest for testing.
8
+
9
+ ### Key Design Decisions
10
+
11
+ 1. **`ldap3` over `python-ldap`**: The `ldap3` library is a pure-Python RFC 4510 conforming LDAP client. Unlike `python-ldap`, it requires no C compiler or OpenLDAP development headers, making installation trivial across platforms.
12
+
13
+ 2. **`keyring` for credential storage**: The `keyring` library provides a unified API across macOS Keychain, Windows Credential Manager, and Linux Secret Service (via D-Bus). This avoids platform-specific code.
14
+
15
+ 3. **`argparse` for CLI parsing**: Standard library, no extra dependency. Matches the pattern used in `cftcli`.
16
+
17
+ 4. **`pyyaml` for configuration**: Already used across the workspace projects. Lightweight and well-understood.
18
+
19
+ 5. **Dataclass-based configuration**: Following the pattern in `identity-provider`, configuration is modeled as a dataclass for type safety and easy serialization.
20
+
21
+ ## Architecture
22
+
23
+ ```mermaid
24
+ graph TD
25
+ A[ldapc CLI Entry Point] --> B{Argument Parser}
26
+ B -->|--configure| C[Configure Command]
27
+ B -->|--user| D[User Search Command]
28
+ B -->|--group| E[Group Search Command]
29
+ B -->|--version| F[Version Display]
30
+ B -->|--help / no args| G[Help Display]
31
+
32
+ C --> H[Config Writer]
33
+ C --> I[Keyring Store]
34
+
35
+ D --> J[Config Loader]
36
+ D --> K[LDAP Client]
37
+ D --> L[Result Formatter]
38
+
39
+ E --> J
40
+ E --> K
41
+ E --> L
42
+
43
+ J --> M[~/.ldapc/config.yaml]
44
+ I --> N[OS Keychain]
45
+ K --> O[LDAP Server]
46
+ ```
47
+
48
+ ### Layer Separation
49
+
50
+ | Layer | Responsibility | Modules |
51
+ |-------|---------------|---------|
52
+ | CLI | Argument parsing, entry point, exit codes | `__init__.py`, `cli.py` |
53
+ | Config | Read/write YAML config, keyring access | `config.py` |
54
+ | LDAP | Connection management, search queries | `ldap_client.py` |
55
+ | Formatting | Output rendering (text, JSON) | `formatter.py` |
56
+
57
+ ## Components and Interfaces
58
+
59
+ ### CLI Module (`ldapc/cli.py`)
60
+
61
+ ```python
62
+ def main() -> None:
63
+ """Entry point registered as console script `ldapc`."""
64
+ ...
65
+
66
+ def build_parser() -> argparse.ArgumentParser:
67
+ """Construct the argument parser with all flags."""
68
+ ...
69
+ ```
70
+
71
+ ### Config Module (`ldapc/config.py`)
72
+
73
+ ```python
74
+ @dataclass
75
+ class LdapcConfig:
76
+ """Configuration object for ldapc."""
77
+ host: str
78
+ username: str
79
+ base_dn: str = ""
80
+
81
+ def load_config(config_path: Path | None = None) -> LdapcConfig:
82
+ """Load configuration from ~/.ldapc/config.yaml.
83
+
84
+ Raises:
85
+ ConfigError: If the file is missing or contains invalid YAML.
86
+ """
87
+ ...
88
+
89
+ def save_config(config: LdapcConfig, config_path: Path | None = None) -> None:
90
+ """Serialize config to ~/.ldapc/config.yaml with 0600 permissions."""
91
+ ...
92
+
93
+ def config_to_yaml(config: LdapcConfig) -> str:
94
+ """Serialize a config object to a YAML string."""
95
+ ...
96
+
97
+ def yaml_to_config(yaml_str: str) -> LdapcConfig:
98
+ """Parse a YAML string into a config object.
99
+
100
+ Raises:
101
+ ConfigError: If the YAML is invalid or missing required fields.
102
+ """
103
+ ...
104
+
105
+ def store_password(username: str, password: str) -> None:
106
+ """Store password in OS keychain under service 'ldapc'."""
107
+ ...
108
+
109
+ def retrieve_password(username: str) -> str:
110
+ """Retrieve password from OS keychain.
111
+
112
+ Raises:
113
+ ConfigError: If no password is stored for the account.
114
+ """
115
+ ...
116
+ ```
117
+
118
+ ### LDAP Client Module (`ldapc/ldap_client.py`)
119
+
120
+ ```python
121
+ @dataclass
122
+ class LdapEntry:
123
+ """A single LDAP directory entry."""
124
+ dn: str
125
+ attributes: dict[str, list[str]]
126
+
127
+ class LdapClient:
128
+ """Manages LDAP connections and searches."""
129
+
130
+ def __init__(self, host: str, username: str, password: str, timeout: int = 10) -> None:
131
+ ...
132
+
133
+ def search_users(self, search_term: str, base_dn: str) -> list[LdapEntry]:
134
+ """Search for user entries matching cn or uid."""
135
+ ...
136
+
137
+ def search_groups(self, search_term: str, base_dn: str) -> list[LdapEntry]:
138
+ """Search for group entries matching cn."""
139
+ ...
140
+
141
+ def close(self) -> None:
142
+ """Unbind and close the LDAP connection."""
143
+ ...
144
+ ```
145
+
146
+ ### Formatter Module (`ldapc/formatter.py`)
147
+
148
+ ```python
149
+ def format_user_entries(entries: list[LdapEntry]) -> str:
150
+ """Format user entries as aligned key-value text."""
151
+ ...
152
+
153
+ def format_group_entries(entries: list[LdapEntry]) -> str:
154
+ """Format group entries as aligned key-value text."""
155
+ ...
156
+
157
+ def format_entries_json(entries: list[LdapEntry]) -> str:
158
+ """Format entries as a JSON string."""
159
+ ...
160
+
161
+ def build_user_filter(search_term: str) -> str:
162
+ """Construct an LDAP filter for user search (cn and uid)."""
163
+ ...
164
+
165
+ def build_group_filter(search_term: str) -> str:
166
+ """Construct an LDAP filter for group search (cn)."""
167
+ ...
168
+ ```
169
+
170
+ ### Exceptions (`ldapc/exceptions.py`)
171
+
172
+ ```python
173
+ class LdapcError(Exception):
174
+ """Base exception for ldapc."""
175
+ ...
176
+
177
+ class ConfigError(LdapcError):
178
+ """Configuration-related errors (exit code 2)."""
179
+ ...
180
+
181
+ class ConnectionError(LdapcError):
182
+ """LDAP connection errors (exit code 1)."""
183
+ ...
184
+
185
+ class AuthenticationError(LdapcError):
186
+ """LDAP bind/authentication errors (exit code 1)."""
187
+ ...
188
+ ```
189
+
190
+ ## Data Models
191
+
192
+ ### Configuration File (`~/.ldapc/config.yaml`)
193
+
194
+ ```yaml
195
+ host: ldaps://ldap.example.com:636
196
+ username: cn=admin,dc=example,dc=com
197
+ base_dn: dc=example,dc=com
198
+ ```
199
+
200
+ ### LdapcConfig Dataclass
201
+
202
+ | Field | Type | Required | Description |
203
+ |-------|------|----------|-------------|
204
+ | `host` | `str` | Yes | LDAP server URL (ldaps:// or ldap://) |
205
+ | `username` | `str` | Yes | Bind DN for authentication |
206
+ | `base_dn` | `str` | No | Base DN for searches (defaults to derived from host) |
207
+
208
+ ### LdapEntry Dataclass
209
+
210
+ | Field | Type | Description |
211
+ |-------|------|-------------|
212
+ | `dn` | `str` | Distinguished name of the entry |
213
+ | `attributes` | `dict[str, list[str]]` | Attribute name → list of values |
214
+
215
+ ### Exit Codes
216
+
217
+ | Code | Meaning |
218
+ |------|---------|
219
+ | 0 | Success (including no results) |
220
+ | 1 | Connection or authentication error |
221
+ | 2 | Configuration error or invalid arguments |
222
+
223
+ ## Correctness Properties
224
+
225
+ *A property is a characteristic or behavior that should hold true across all valid executions of a system — essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.*
226
+
227
+ ### Property 1: Configuration round-trip
228
+
229
+ *For any* valid `LdapcConfig` object containing arbitrary host and username strings, serializing it to YAML via `config_to_yaml` and then parsing it back via `yaml_to_config` SHALL produce an equivalent configuration object.
230
+
231
+ **Validates: Requirements 2.4, 3.1, 3.3, 3.4**
232
+
233
+ ### Property 2: Invalid YAML produces a descriptive error
234
+
235
+ *For any* string that is not valid YAML (or valid YAML missing required fields), calling `yaml_to_config` SHALL raise a `ConfigError` with a non-empty message describing the failure.
236
+
237
+ **Validates: Requirements 3.2**
238
+
239
+ ### Property 3: Password is never written to disk
240
+
241
+ *For any* valid configuration containing a password, after running the configuration save flow, the password string SHALL NOT appear in the contents of `~/.ldapc/config.yaml` or any other file in `~/.ldapc/`.
242
+
243
+ **Validates: Requirements 4.4**
244
+
245
+ ### Property 4: User search filter construction
246
+
247
+ *For any* non-empty search term string, `build_user_filter(search_term)` SHALL produce a valid LDAP filter that matches against both `cn` and `uid` attributes, and the search term SHALL appear in the resulting filter string.
248
+
249
+ **Validates: Requirements 5.1**
250
+
251
+ ### Property 5: Group search filter construction
252
+
253
+ *For any* non-empty search term string, `build_group_filter(search_term)` SHALL produce a valid LDAP filter that matches against the `cn` attribute, and the search term SHALL appear in the resulting filter string.
254
+
255
+ **Validates: Requirements 6.1**
256
+
257
+ ### Property 6: Entry formatting contains all required fields
258
+
259
+ *For any* `LdapEntry` with a non-empty `dn` and attributes containing `cn`, `mail`, and `memberOf` (for users) or `cn`, `description`, and `member` (for groups), the formatted output string SHALL contain the entry's DN and all specified attribute values.
260
+
261
+ **Validates: Requirements 5.2, 6.2, 7.1**
262
+
263
+ ### Property 7: JSON output is valid and round-trips
264
+
265
+ *For any* list of `LdapEntry` objects, `format_entries_json(entries)` SHALL produce a string that is valid JSON, and deserializing that JSON SHALL yield data equivalent to the original entries' DN and attributes.
266
+
267
+ **Validates: Requirements 7.3**
268
+
269
+ ## Error Handling
270
+
271
+ | Error Condition | User-Facing Message | Exit Code | Stream |
272
+ |----------------|--------------------:|:---------:|--------|
273
+ | Config file missing | `Configuration not found. Run 'ldapc --configure' to set up.` | 2 | stderr |
274
+ | Config file invalid YAML | `Invalid configuration file: {details}` | 2 | stderr |
275
+ | Keychain password missing | `No stored password. Run 'ldapc --configure' to set up.` | 2 | stderr |
276
+ | Invalid arguments | argparse default error | 2 | stderr |
277
+ | LDAP server unreachable | `Connection failed: unable to reach {host}` | 1 | stderr |
278
+ | LDAP connection timeout | `Connection timed out after 10 seconds: {host}` | 1 | stderr |
279
+ | TLS certificate error | `TLS certificate verification failed for {host}` | 1 | stderr |
280
+ | Authentication failure | `Authentication failed: invalid credentials for {username}` | 1 | stderr |
281
+ | No results found | `No results found for '{search_term}'` | 0 | stdout |
282
+
283
+ ### Error Handling Strategy
284
+
285
+ - All exceptions inherit from `LdapcError` for consistent catching at the CLI layer.
286
+ - The CLI `main()` function wraps command execution in a try/except that maps exception types to exit codes.
287
+ - Error messages go to stderr; query results go to stdout.
288
+ - LDAP connections are always closed in a `finally` block to prevent resource leaks.
289
+
290
+ ## Testing Strategy
291
+
292
+ ### Test Framework and Tools
293
+
294
+ - **pytest** for test execution
295
+ - **hypothesis** for property-based testing
296
+ - **unittest.mock** for mocking LDAP connections and keyring
297
+ - **pytest-cov** for coverage reporting
298
+
299
+ ### Property-Based Tests (hypothesis)
300
+
301
+ Each correctness property maps to a single hypothesis test with minimum 100 iterations:
302
+
303
+ | Property | Test File | Strategy |
304
+ |----------|-----------|----------|
305
+ | 1: Config round-trip | `tests/test_config_properties.py` | Generate random `LdapcConfig` objects via `@st.composite` |
306
+ | 2: Invalid YAML error | `tests/test_config_properties.py` | Generate random non-YAML strings |
307
+ | 3: Password not on disk | `tests/test_config_properties.py` | Generate random passwords, run save, grep files |
308
+ | 4: User filter construction | `tests/test_search_properties.py` | Generate random search term strings |
309
+ | 5: Group filter construction | `tests/test_search_properties.py` | Generate random search term strings |
310
+ | 6: Entry formatting | `tests/test_formatter_properties.py` | Generate random `LdapEntry` objects |
311
+ | 7: JSON round-trip | `tests/test_formatter_properties.py` | Generate random lists of `LdapEntry` objects |
312
+
313
+ Each test is tagged with: `# Feature: ldap-cli, Property {N}: {title}`
314
+
315
+ Configuration: `@settings(max_examples=100)`
316
+
317
+ ### Unit Tests (example-based)
318
+
319
+ | Area | Test File | Coverage |
320
+ |------|-----------|----------|
321
+ | CLI argument parsing | `tests/test_cli.py` | --help, --version, no args, invalid args |
322
+ | Configure flow | `tests/test_configure.py` | Prompts, directory creation, permissions |
323
+ | Error scenarios | `tests/test_errors.py` | Connection failures, auth failures, timeouts |
324
+ | Exit codes | `tests/test_exit_codes.py` | All exit code scenarios |
325
+ | Output streams | `tests/test_output.py` | stderr for errors, stdout for results |
326
+
327
+ ### Integration Tests
328
+
329
+ | Area | Test File | Coverage |
330
+ |------|-----------|----------|
331
+ | Keyring integration | `tests/test_keyring_integration.py` | Store/retrieve with mocked keyring backend |
332
+ | LDAP connection lifecycle | `tests/test_ldap_integration.py` | Connect, search, unbind with mocked server |
333
+
334
+ ### Project Structure
335
+
336
+ ```
337
+ ldapc/
338
+ ├── pyproject.toml
339
+ ├── CHANGELOG.md
340
+ ├── README.md
341
+ ├── LICENSE
342
+ ├── docs/
343
+ │ └── README.md
344
+ ├── ldapc/
345
+ │ ├── __init__.py
346
+ │ ├── _version.py
347
+ │ ├── cli.py
348
+ │ ├── config.py
349
+ │ ├── ldap_client.py
350
+ │ ├── formatter.py
351
+ │ └── exceptions.py
352
+ └── tests/
353
+ ├── __init__.py
354
+ ├── conftest.py
355
+ ├── test_cli.py
356
+ ├── test_configure.py
357
+ ├── test_config_properties.py
358
+ ├── test_search_properties.py
359
+ ├── test_formatter_properties.py
360
+ ├── test_errors.py
361
+ ├── test_exit_codes.py
362
+ ├── test_output.py
363
+ ├── test_keyring_integration.py
364
+ └── test_ldap_integration.py
365
+ ```
366
+
367
+ ### Dependencies
368
+
369
+ ```toml
370
+ [project]
371
+ dependencies = [
372
+ "ldap3>=2.9,<3.0",
373
+ "keyring>=25.0,<26.0",
374
+ "pyyaml>=6.0,<7.0",
375
+ ]
376
+
377
+ [project.optional-dependencies]
378
+ dev = [
379
+ "pytest>=8.0,<9.0",
380
+ "pytest-cov>=5.0,<6.0",
381
+ "hypothesis>=6.100,<7.0",
382
+ "ruff>=0.4,<1.0",
383
+ "mypy>=1.10,<2.0",
384
+ "bandit>=1.7,<2.0",
385
+ ]
386
+ ```
@@ -0,0 +1,123 @@
1
+ # Requirements Document
2
+
3
+ ## Introduction
4
+
5
+ A Python CLI tool (`ldapc`) for querying LDAP directories. The tool provides a simple interface to search for users and groups, with persistent configuration stored locally and secure credential management via the OS keychain.
6
+
7
+ ## Glossary
8
+
9
+ - **CLI**: The `ldapc` command-line interface application
10
+ - **Configuration_Store**: The YAML configuration file located at `~/.ldapc/config.yaml`
11
+ - **Keychain**: The operating system's native credential storage (macOS Keychain, Windows Credential Manager, or Linux Secret Service)
12
+ - **LDAP_Server**: The remote LDAP directory server the CLI connects to
13
+ - **Config_Parser**: The component that reads and writes `~/.ldapc/config.yaml`
14
+ - **Config_Printer**: The component that serializes configuration objects back to YAML format
15
+ - **Search_Result**: A structured object containing attributes returned from an LDAP query
16
+
17
+ ## Requirements
18
+
19
+ ### Requirement 1: CLI Entry Point
20
+
21
+ **User Story:** As a developer, I want to invoke the LDAP CLI by typing `ldapc`, so that I can quickly query LDAP without remembering long commands.
22
+
23
+ #### Acceptance Criteria
24
+
25
+ 1. THE CLI SHALL be invocable as `ldapc` from the shell after installation
26
+ 2. WHEN invoked without arguments, THE CLI SHALL display a help message showing available options and usage examples
27
+ 3. WHEN invoked with `--version`, THE CLI SHALL display the current version string
28
+ 4. WHEN invoked with `--help`, THE CLI SHALL display the full help text including all supported flags
29
+
30
+ ### Requirement 2: Interactive Configuration
31
+
32
+ **User Story:** As a developer, I want to configure the LDAP host and credentials interactively, so that I do not have to provide them on every invocation.
33
+
34
+ #### Acceptance Criteria
35
+
36
+ 1. WHEN invoked with `--configure`, THE CLI SHALL prompt the user for the LDAP host URL
37
+ 2. WHEN invoked with `--configure`, THE CLI SHALL prompt the user for the bind username
38
+ 3. WHEN invoked with `--configure`, THE CLI SHALL prompt the user for the bind password
39
+ 4. WHEN the user completes the configuration prompts, THE Configuration_Store SHALL persist the host and username to `~/.ldapc/config.yaml`
40
+ 5. WHEN the user completes the configuration prompts, THE CLI SHALL store the password in the Keychain
41
+ 6. IF the `~/.ldapc/` directory does not exist, THEN THE CLI SHALL create it before writing the configuration file
42
+ 7. THE Configuration_Store SHALL set file permissions on `~/.ldapc/config.yaml` to owner-read-write only (0600)
43
+
44
+ ### Requirement 3: Configuration File Parsing
45
+
46
+ **User Story:** As a developer, I want the CLI to reliably read and write its configuration file, so that my settings persist across sessions.
47
+
48
+ #### Acceptance Criteria
49
+
50
+ 1. WHEN a valid `~/.ldapc/config.yaml` file exists, THE Config_Parser SHALL parse it into a configuration object containing host and username
51
+ 2. IF `~/.ldapc/config.yaml` contains invalid YAML, THEN THE Config_Parser SHALL return a descriptive error message indicating the parse failure
52
+ 3. THE Config_Printer SHALL format configuration objects back into valid YAML files
53
+ 4. FOR ALL valid configuration objects, parsing then printing then parsing SHALL produce an equivalent object (round-trip property)
54
+ 5. IF `~/.ldapc/config.yaml` does not exist, THEN THE CLI SHALL display an error instructing the user to run `ldapc --configure`
55
+
56
+ ### Requirement 4: Secure Credential Storage
57
+
58
+ **User Story:** As a developer, I want my LDAP password stored securely in the OS keychain, so that it is not exposed in plain-text files.
59
+
60
+ #### Acceptance Criteria
61
+
62
+ 1. WHEN storing credentials, THE CLI SHALL use the OS Keychain with service name `ldapc` and the configured username as the account identifier
63
+ 2. WHEN retrieving credentials for an LDAP connection, THE CLI SHALL read the password from the Keychain
64
+ 3. IF the Keychain does not contain a stored password for the configured account, THEN THE CLI SHALL display an error instructing the user to run `ldapc --configure`
65
+ 4. THE CLI SHALL NOT write the password to any file on disk
66
+
67
+ ### Requirement 5: Query by User
68
+
69
+ **User Story:** As a developer, I want to search LDAP for a user by name, so that I can quickly look up user attributes.
70
+
71
+ #### Acceptance Criteria
72
+
73
+ 1. WHEN invoked with `--user {searchuser}`, THE CLI SHALL query the LDAP_Server for entries matching the search term against common name and uid attributes
74
+ 2. WHEN the LDAP_Server returns matching user entries, THE CLI SHALL display each entry's distinguished name, common name, email, and group memberships in a readable format
75
+ 3. WHEN the LDAP_Server returns no matching user entries, THE CLI SHALL display a message indicating no results were found for the search term
76
+ 4. IF the LDAP_Server is unreachable, THEN THE CLI SHALL display a connection error message including the configured host
77
+ 5. IF the LDAP bind credentials are rejected, THEN THE CLI SHALL display an authentication error message
78
+
79
+ ### Requirement 6: Query by Group
80
+
81
+ **User Story:** As a developer, I want to search LDAP for a group by name, so that I can see group membership details.
82
+
83
+ #### Acceptance Criteria
84
+
85
+ 1. WHEN invoked with `--group {searchgroup}`, THE CLI SHALL query the LDAP_Server for group entries matching the search term against common name
86
+ 2. WHEN the LDAP_Server returns matching group entries, THE CLI SHALL display each group's distinguished name, common name, description, and member list in a readable format
87
+ 3. WHEN the LDAP_Server returns no matching group entries, THE CLI SHALL display a message indicating no results were found for the search term
88
+ 4. IF the LDAP_Server is unreachable, THEN THE CLI SHALL display a connection error message including the configured host
89
+ 5. IF the LDAP bind credentials are rejected, THEN THE CLI SHALL display an authentication error message
90
+
91
+ ### Requirement 7: Output Formatting
92
+
93
+ **User Story:** As a developer, I want search results displayed in a clear, readable format, so that I can quickly find the information I need.
94
+
95
+ #### Acceptance Criteria
96
+
97
+ 1. THE CLI SHALL display search results using aligned key-value formatting for single results
98
+ 2. WHEN multiple results are returned, THE CLI SHALL visually separate each entry with a delimiter
99
+ 3. WHEN the `--json` flag is provided, THE CLI SHALL output results as valid JSON to stdout
100
+ 4. THE CLI SHALL write error messages to stderr, keeping stdout reserved for query results
101
+
102
+ ### Requirement 8: Connection Management
103
+
104
+ **User Story:** As a developer, I want the CLI to handle LDAP connections reliably, so that queries complete without leaving stale connections.
105
+
106
+ #### Acceptance Criteria
107
+
108
+ 1. WHEN a query is initiated, THE CLI SHALL establish a TLS-secured connection to the LDAP_Server
109
+ 2. WHEN a query completes, THE CLI SHALL unbind and close the LDAP connection
110
+ 3. IF the connection times out after 10 seconds, THEN THE CLI SHALL display a timeout error and exit with a non-zero status code
111
+ 4. IF a TLS certificate verification fails, THEN THE CLI SHALL display a certificate error message
112
+
113
+ ### Requirement 9: Exit Codes
114
+
115
+ **User Story:** As a developer, I want consistent exit codes, so that I can use `ldapc` in scripts and automation.
116
+
117
+ #### Acceptance Criteria
118
+
119
+ 1. WHEN a query completes successfully, THE CLI SHALL exit with code 0
120
+ 2. WHEN a query returns no results, THE CLI SHALL exit with code 0
121
+ 3. IF a connection or authentication error occurs, THEN THE CLI SHALL exit with code 1
122
+ 4. IF a configuration error occurs, THEN THE CLI SHALL exit with code 2
123
+ 5. IF an invalid argument is provided, THEN THE CLI SHALL exit with code 2