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.
- .kiro/specs/ldap-cli/design.md +386 -0
- .kiro/specs/ldap-cli/requirements.md +123 -0
- .kiro/specs/ldap-cli/tasks.md +246 -0
- CHANGELOG.md +47 -0
- README.md +145 -0
- docs/reference.md +198 -0
- ldap_cli-0.2.0.dist-info/METADATA +176 -0
- ldap_cli-0.2.0.dist-info/RECORD +18 -0
- ldap_cli-0.2.0.dist-info/WHEEL +4 -0
- ldap_cli-0.2.0.dist-info/entry_points.txt +2 -0
- ldap_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
- ldapc/__init__.py +7 -0
- ldapc/_version.py +23 -0
- ldapc/cli.py +490 -0
- ldapc/config.py +184 -0
- ldapc/exceptions.py +37 -0
- ldapc/formatter.py +282 -0
- ldapc/ldap_client.py +506 -0
ldapc/cli.py
ADDED
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
"""CLI entry point for ldapc.
|
|
2
|
+
|
|
3
|
+
Provides the argument parser and main entry point for the ldapc
|
|
4
|
+
command-line tool. Uses subcommands for LDAP operations:
|
|
5
|
+
|
|
6
|
+
ldapc list users|groups
|
|
7
|
+
ldapc search user|group <term>
|
|
8
|
+
ldapc get user|group <name>
|
|
9
|
+
ldapc add user|group <name>
|
|
10
|
+
ldapc delete user|group <name>
|
|
11
|
+
ldapc user add group <user> <group>
|
|
12
|
+
ldapc user remove group <user> <group>
|
|
13
|
+
ldapc configure
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import logging
|
|
20
|
+
import os
|
|
21
|
+
import sys
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from ldapc import __version__
|
|
25
|
+
from ldapc.exceptions import (
|
|
26
|
+
AuthenticationError,
|
|
27
|
+
ConfigError,
|
|
28
|
+
ConnectionError,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
LOG = logging.getLogger("ldapc")
|
|
32
|
+
|
|
33
|
+
ENV_SKIP_SSL_VERIFY = "LDAPC_SKIP_SSL_VERIFY"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _env_skip_ssl() -> bool:
|
|
37
|
+
"""Check if SSL verification should be skipped via environment variable."""
|
|
38
|
+
return os.environ.get(ENV_SKIP_SSL_VERIFY, "").lower() in ("1", "true", "yes")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
42
|
+
"""Construct the argument parser with subcommands."""
|
|
43
|
+
# Shared flags available on all subcommands
|
|
44
|
+
shared = argparse.ArgumentParser(add_help=False)
|
|
45
|
+
shared.add_argument(
|
|
46
|
+
"--debug",
|
|
47
|
+
action="store_true",
|
|
48
|
+
default=None,
|
|
49
|
+
help="Enable debug logging for troubleshooting.",
|
|
50
|
+
)
|
|
51
|
+
shared.add_argument(
|
|
52
|
+
"--skip-ssl-verify",
|
|
53
|
+
action="store_true",
|
|
54
|
+
default=None,
|
|
55
|
+
help=f"Skip TLS certificate verification (env: {ENV_SKIP_SSL_VERIFY}).",
|
|
56
|
+
)
|
|
57
|
+
shared.add_argument(
|
|
58
|
+
"--json",
|
|
59
|
+
action="store_true",
|
|
60
|
+
default=None,
|
|
61
|
+
help="Output results as JSON.",
|
|
62
|
+
)
|
|
63
|
+
shared.add_argument(
|
|
64
|
+
"--yaml",
|
|
65
|
+
action="store_true",
|
|
66
|
+
default=None,
|
|
67
|
+
help="Output results as YAML (simplified, human-friendly).",
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
parser = argparse.ArgumentParser(
|
|
71
|
+
prog="ldapc",
|
|
72
|
+
description="Query and manage LDAP directories.",
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
parser.add_argument(
|
|
76
|
+
"--version",
|
|
77
|
+
action="version",
|
|
78
|
+
version=f"ldapc {__version__}",
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
parser.add_argument(
|
|
82
|
+
"--docs",
|
|
83
|
+
nargs="?",
|
|
84
|
+
const="",
|
|
85
|
+
default=None,
|
|
86
|
+
metavar="FILE",
|
|
87
|
+
help="View bundled documentation. Without FILE, lists available docs. With FILE, renders it.",
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
parser.add_argument(
|
|
91
|
+
"--page",
|
|
92
|
+
action="store_true",
|
|
93
|
+
help="Enable paging when viewing docs (use with --docs).",
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
parser.add_argument(
|
|
97
|
+
"--markdown",
|
|
98
|
+
action="store_true",
|
|
99
|
+
help="Render docs as raw markdown instead of rich text (use with --docs).",
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
103
|
+
|
|
104
|
+
# ldapc configure
|
|
105
|
+
subparsers.add_parser("configure", parents=[shared], help="Set up host, bind DN, base DN, and password.")
|
|
106
|
+
|
|
107
|
+
# ldapc list users|groups
|
|
108
|
+
list_parser = subparsers.add_parser("list", parents=[shared], help="List all users or groups.")
|
|
109
|
+
list_parser.add_argument("type", choices=["users", "groups"], help="What to list.")
|
|
110
|
+
|
|
111
|
+
# ldapc search user|group <term>
|
|
112
|
+
search_parser = subparsers.add_parser("search", parents=[shared], help="Search for users or groups.")
|
|
113
|
+
search_parser.add_argument("type", choices=["user", "group"], help="What to search.")
|
|
114
|
+
search_parser.add_argument("term", help="Search term (case-insensitive substring match).")
|
|
115
|
+
|
|
116
|
+
# ldapc get user|group <name>
|
|
117
|
+
get_parser = subparsers.add_parser("get", parents=[shared], help="Get details of a user or group.")
|
|
118
|
+
get_parser.add_argument("type", choices=["user", "group"], help="What to get.")
|
|
119
|
+
get_parser.add_argument("name", help="Exact cn or uid to look up.")
|
|
120
|
+
|
|
121
|
+
# ldapc add user|group <name>
|
|
122
|
+
add_parser = subparsers.add_parser("add", parents=[shared], help="Add a user or group.")
|
|
123
|
+
add_parser.add_argument("type", choices=["user", "group"], help="What to add.")
|
|
124
|
+
add_parser.add_argument("name", help="Name of the user or group to create.")
|
|
125
|
+
|
|
126
|
+
# ldapc delete user|group <name>
|
|
127
|
+
delete_parser = subparsers.add_parser("delete", parents=[shared], help="Delete a user or group.")
|
|
128
|
+
delete_parser.add_argument("type", choices=["user", "group"], help="What to delete.")
|
|
129
|
+
delete_parser.add_argument("name", help="Name of the user or group to delete.")
|
|
130
|
+
|
|
131
|
+
# ldapc user add|remove group <user> <group>
|
|
132
|
+
user_parser = subparsers.add_parser("user", parents=[shared], help="Manage user group membership.")
|
|
133
|
+
user_sub = user_parser.add_subparsers(dest="action")
|
|
134
|
+
|
|
135
|
+
user_add_parser = user_sub.add_parser("add", parents=[shared], help="Add user to a group.")
|
|
136
|
+
user_add_parser.add_argument("target", choices=["group"], help="Must be 'group'.")
|
|
137
|
+
user_add_parser.add_argument("user", help="User to add.")
|
|
138
|
+
user_add_parser.add_argument("group", help="Group to add the user to.")
|
|
139
|
+
|
|
140
|
+
user_remove_parser = user_sub.add_parser("remove", parents=[shared], help="Remove user from a group.")
|
|
141
|
+
user_remove_parser.add_argument("target", choices=["group"], help="Must be 'group'.")
|
|
142
|
+
user_remove_parser.add_argument("user", help="User to remove.")
|
|
143
|
+
user_remove_parser.add_argument("group", help="Group to remove the user from.")
|
|
144
|
+
|
|
145
|
+
return parser
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _get_docs_dir() -> Path:
|
|
149
|
+
"""Locate the docs directory bundled with the installed package.
|
|
150
|
+
|
|
151
|
+
Searches the package's installed location for .md files at the
|
|
152
|
+
top level and in a docs/ subdirectory.
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
Path to the package's site-packages directory.
|
|
156
|
+
"""
|
|
157
|
+
import importlib.resources
|
|
158
|
+
|
|
159
|
+
# The package root in site-packages
|
|
160
|
+
package_dir = Path(__file__).resolve().parent.parent
|
|
161
|
+
return package_dir
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _find_doc_files() -> list[str]:
|
|
165
|
+
"""Find all .md files bundled with the package."""
|
|
166
|
+
base = _get_docs_dir()
|
|
167
|
+
md_files: list[str] = []
|
|
168
|
+
|
|
169
|
+
# Top-level .md files
|
|
170
|
+
for f in sorted(base.glob("*.md")):
|
|
171
|
+
md_files.append(f.name)
|
|
172
|
+
|
|
173
|
+
# docs/ subdirectory
|
|
174
|
+
docs_dir = base / "docs"
|
|
175
|
+
if docs_dir.is_dir():
|
|
176
|
+
for f in sorted(docs_dir.glob("*.md")):
|
|
177
|
+
md_files.append(f"docs/{f.name}")
|
|
178
|
+
|
|
179
|
+
return md_files
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _resolve_doc_path(filename: str) -> Path | None:
|
|
183
|
+
"""Resolve a doc filename to its full path."""
|
|
184
|
+
base = _get_docs_dir()
|
|
185
|
+
candidate = base / filename
|
|
186
|
+
if candidate.is_file():
|
|
187
|
+
return candidate
|
|
188
|
+
return None
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _handle_docs(filename: str | None, page: bool, markdown: bool) -> None:
|
|
192
|
+
"""Handle the --docs flag.
|
|
193
|
+
|
|
194
|
+
If filename is empty/None, list available docs.
|
|
195
|
+
Otherwise render the specified file.
|
|
196
|
+
"""
|
|
197
|
+
if not filename:
|
|
198
|
+
# List available docs
|
|
199
|
+
files = _find_doc_files()
|
|
200
|
+
if not files:
|
|
201
|
+
print("No documentation files found.")
|
|
202
|
+
sys.exit(1)
|
|
203
|
+
print("Available documentation:")
|
|
204
|
+
print()
|
|
205
|
+
for f in files:
|
|
206
|
+
print(f" {f}")
|
|
207
|
+
print()
|
|
208
|
+
print("Usage: ldapc --docs <filename>")
|
|
209
|
+
return
|
|
210
|
+
|
|
211
|
+
doc_path = _resolve_doc_path(filename)
|
|
212
|
+
if doc_path is None:
|
|
213
|
+
print(f"Documentation file not found: {filename}", file=sys.stderr)
|
|
214
|
+
print("Run 'ldapc --docs' to see available files.", file=sys.stderr)
|
|
215
|
+
sys.exit(1)
|
|
216
|
+
|
|
217
|
+
content = doc_path.read_text(encoding="utf-8")
|
|
218
|
+
|
|
219
|
+
if markdown:
|
|
220
|
+
# Raw markdown output
|
|
221
|
+
if page:
|
|
222
|
+
_page_text(content)
|
|
223
|
+
else:
|
|
224
|
+
print(content)
|
|
225
|
+
else:
|
|
226
|
+
# Rich rendered output
|
|
227
|
+
_render_rich(content, page)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _render_rich(content: str, page: bool) -> None:
|
|
231
|
+
"""Render markdown content using rich."""
|
|
232
|
+
from rich.console import Console
|
|
233
|
+
from rich.markdown import Markdown
|
|
234
|
+
|
|
235
|
+
md = Markdown(content)
|
|
236
|
+
|
|
237
|
+
if page:
|
|
238
|
+
with Console() as console:
|
|
239
|
+
with console.pager(styles=True):
|
|
240
|
+
console.print(md)
|
|
241
|
+
else:
|
|
242
|
+
console = Console()
|
|
243
|
+
console.print(md)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _page_text(text: str) -> None:
|
|
247
|
+
"""Display text with paging."""
|
|
248
|
+
import pydoc
|
|
249
|
+
pydoc.pager(text)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _get_client(skip_ssl_verify: bool):
|
|
253
|
+
"""Load config and return a connected LdapClient."""
|
|
254
|
+
from ldapc.config import load_config, retrieve_password
|
|
255
|
+
from ldapc.ldap_client import LdapClient
|
|
256
|
+
|
|
257
|
+
config = load_config()
|
|
258
|
+
password = retrieve_password(config.username)
|
|
259
|
+
|
|
260
|
+
LOG.debug("Connecting to %s as %s", config.host, config.username)
|
|
261
|
+
LOG.debug("Base DN: %s", config.base_dn)
|
|
262
|
+
if skip_ssl_verify:
|
|
263
|
+
LOG.debug("TLS certificate verification disabled")
|
|
264
|
+
|
|
265
|
+
client = LdapClient(config.host, config.username, password, skip_ssl_verify=skip_ssl_verify)
|
|
266
|
+
return client, config
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _handle_configure() -> None:
|
|
270
|
+
"""Run the interactive configuration flow."""
|
|
271
|
+
from ldapc.config import save_config, store_password, LdapcConfig
|
|
272
|
+
import getpass
|
|
273
|
+
|
|
274
|
+
host = input("LDAP host URL: ")
|
|
275
|
+
base_dn = input("Base DN: ")
|
|
276
|
+
username = input("Bind DN: ")
|
|
277
|
+
users_ou = input("Users container RDN [cn=users]: ").strip() or "cn=users"
|
|
278
|
+
groups_ou = input("Groups container RDN [cn=groups]: ").strip() or "cn=groups"
|
|
279
|
+
password = getpass.getpass("Bind password: ")
|
|
280
|
+
|
|
281
|
+
config = LdapcConfig(host=host, username=username, base_dn=base_dn,
|
|
282
|
+
users_ou=users_ou, groups_ou=groups_ou)
|
|
283
|
+
save_config(config)
|
|
284
|
+
store_password(username, password)
|
|
285
|
+
LOG.debug("Configuration saved to ~/.ldapc/config.yaml")
|
|
286
|
+
print("Configuration saved.")
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _handle_list(args) -> None:
|
|
290
|
+
"""Handle: ldapc list users|groups"""
|
|
291
|
+
from ldapc.formatter import (
|
|
292
|
+
format_user_entries, format_group_entries, format_entries_json,
|
|
293
|
+
format_user_entries_yaml, format_group_entries_yaml,
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
client, config = _get_client(args.skip_ssl_verify)
|
|
297
|
+
with client:
|
|
298
|
+
if args.type == "users":
|
|
299
|
+
entries = client.list_users(config.base_dn)
|
|
300
|
+
else:
|
|
301
|
+
entries = client.list_groups(config.base_dn)
|
|
302
|
+
|
|
303
|
+
LOG.debug("List returned %d entries", len(entries))
|
|
304
|
+
|
|
305
|
+
if not entries:
|
|
306
|
+
print(f"No {args.type} found.")
|
|
307
|
+
return
|
|
308
|
+
|
|
309
|
+
if args.json:
|
|
310
|
+
print(format_entries_json(entries))
|
|
311
|
+
elif args.yaml:
|
|
312
|
+
if args.type == "users":
|
|
313
|
+
print(format_user_entries_yaml(entries))
|
|
314
|
+
else:
|
|
315
|
+
print(format_group_entries_yaml(entries))
|
|
316
|
+
elif args.type == "users":
|
|
317
|
+
print(format_user_entries(entries))
|
|
318
|
+
else:
|
|
319
|
+
print(format_group_entries(entries))
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _handle_search(args) -> None:
|
|
323
|
+
"""Handle: ldapc search user|group <term>"""
|
|
324
|
+
from ldapc.formatter import (
|
|
325
|
+
format_user_entries, format_group_entries, format_entries_json,
|
|
326
|
+
format_user_entries_yaml, format_group_entries_yaml,
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
client, config = _get_client(args.skip_ssl_verify)
|
|
330
|
+
with client:
|
|
331
|
+
if args.type == "user":
|
|
332
|
+
entries = client.search_users(args.term, config.base_dn)
|
|
333
|
+
else:
|
|
334
|
+
entries = client.search_groups(args.term, config.base_dn)
|
|
335
|
+
|
|
336
|
+
LOG.debug("Search returned %d entries", len(entries))
|
|
337
|
+
|
|
338
|
+
if not entries:
|
|
339
|
+
print(f"No results found for '{args.term}'")
|
|
340
|
+
return
|
|
341
|
+
|
|
342
|
+
if args.json:
|
|
343
|
+
print(format_entries_json(entries))
|
|
344
|
+
elif args.yaml:
|
|
345
|
+
if args.type == "user":
|
|
346
|
+
print(format_user_entries_yaml(entries))
|
|
347
|
+
else:
|
|
348
|
+
print(format_group_entries_yaml(entries))
|
|
349
|
+
elif args.type == "user":
|
|
350
|
+
print(format_user_entries(entries))
|
|
351
|
+
else:
|
|
352
|
+
print(format_group_entries(entries))
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _handle_get(args) -> None:
|
|
356
|
+
"""Handle: ldapc get user|group <name>"""
|
|
357
|
+
from ldapc.formatter import (
|
|
358
|
+
format_user_entries, format_group_entries, format_entries_json,
|
|
359
|
+
format_user_entries_yaml, format_group_entries_yaml,
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
client, config = _get_client(args.skip_ssl_verify)
|
|
363
|
+
with client:
|
|
364
|
+
if args.type == "user":
|
|
365
|
+
entries = client.get_user(args.name, config.base_dn)
|
|
366
|
+
else:
|
|
367
|
+
entries = client.get_group(args.name, config.base_dn)
|
|
368
|
+
|
|
369
|
+
if not entries:
|
|
370
|
+
print(f"No {args.type} found: '{args.name}'")
|
|
371
|
+
sys.exit(1)
|
|
372
|
+
|
|
373
|
+
if args.json:
|
|
374
|
+
print(format_entries_json(entries))
|
|
375
|
+
elif args.yaml:
|
|
376
|
+
if args.type == "user":
|
|
377
|
+
print(format_user_entries_yaml(entries))
|
|
378
|
+
else:
|
|
379
|
+
print(format_group_entries_yaml(entries))
|
|
380
|
+
elif args.type == "user":
|
|
381
|
+
print(format_user_entries(entries))
|
|
382
|
+
else:
|
|
383
|
+
print(format_group_entries(entries))
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _handle_add(args) -> None:
|
|
387
|
+
"""Handle: ldapc add user|group <name>"""
|
|
388
|
+
client, config = _get_client(args.skip_ssl_verify)
|
|
389
|
+
with client:
|
|
390
|
+
if args.type == "user":
|
|
391
|
+
client.add_user(args.name, config.base_dn, config.users_ou)
|
|
392
|
+
else:
|
|
393
|
+
client.add_group(args.name, config.base_dn, config.groups_ou)
|
|
394
|
+
|
|
395
|
+
print(f"{args.type.capitalize()} '{args.name}' added.")
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _handle_delete(args) -> None:
|
|
399
|
+
"""Handle: ldapc delete user|group <name>"""
|
|
400
|
+
client, config = _get_client(args.skip_ssl_verify)
|
|
401
|
+
with client:
|
|
402
|
+
if args.type == "user":
|
|
403
|
+
client.delete_user(args.name, config.base_dn, config.users_ou)
|
|
404
|
+
else:
|
|
405
|
+
client.delete_group(args.name, config.base_dn, config.groups_ou)
|
|
406
|
+
|
|
407
|
+
print(f"{args.type.capitalize()} '{args.name}' deleted.")
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _handle_user_group(args) -> None:
|
|
411
|
+
"""Handle: ldapc user add|remove group <user> <group>"""
|
|
412
|
+
client, config = _get_client(args.skip_ssl_verify)
|
|
413
|
+
with client:
|
|
414
|
+
if args.action == "add":
|
|
415
|
+
client.add_user_to_group(args.user, args.group, config.base_dn,
|
|
416
|
+
config.users_ou, config.groups_ou)
|
|
417
|
+
print(f"User '{args.user}' added to group '{args.group}'.")
|
|
418
|
+
elif args.action == "remove":
|
|
419
|
+
client.remove_user_from_group(args.user, args.group, config.base_dn,
|
|
420
|
+
config.users_ou, config.groups_ou)
|
|
421
|
+
print(f"User '{args.user}' removed from group '{args.group}'.")
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def main() -> None:
|
|
425
|
+
"""Entry point registered as console script `ldapc`.
|
|
426
|
+
|
|
427
|
+
Parses arguments, dispatches to the appropriate handler, and maps
|
|
428
|
+
exceptions to exit codes:
|
|
429
|
+
- ConfigError → exit 2
|
|
430
|
+
- ConnectionError → exit 1
|
|
431
|
+
- AuthenticationError → exit 1
|
|
432
|
+
"""
|
|
433
|
+
parser = build_parser()
|
|
434
|
+
|
|
435
|
+
if len(sys.argv) == 1:
|
|
436
|
+
parser.print_help(sys.stdout)
|
|
437
|
+
sys.exit(0)
|
|
438
|
+
|
|
439
|
+
args = parser.parse_args()
|
|
440
|
+
|
|
441
|
+
# Handle --docs before anything else (top-level flag)
|
|
442
|
+
if args.docs is not None:
|
|
443
|
+
_handle_docs(args.docs, args.page, args.markdown)
|
|
444
|
+
sys.exit(0)
|
|
445
|
+
|
|
446
|
+
# Resolve boolean flags (None means not explicitly set)
|
|
447
|
+
args.debug = bool(args.debug)
|
|
448
|
+
args.json = bool(args.json)
|
|
449
|
+
args.yaml = bool(args.yaml)
|
|
450
|
+
args.skip_ssl_verify = bool(args.skip_ssl_verify) or _env_skip_ssl()
|
|
451
|
+
|
|
452
|
+
if args.debug:
|
|
453
|
+
logging.basicConfig(
|
|
454
|
+
level=logging.DEBUG,
|
|
455
|
+
format="%(name)s %(levelname)s: %(message)s",
|
|
456
|
+
stream=sys.stderr,
|
|
457
|
+
)
|
|
458
|
+
else:
|
|
459
|
+
logging.basicConfig(
|
|
460
|
+
level=logging.WARNING,
|
|
461
|
+
format="%(name)s %(levelname)s: %(message)s",
|
|
462
|
+
stream=sys.stderr,
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
try:
|
|
466
|
+
if args.command == "configure":
|
|
467
|
+
_handle_configure()
|
|
468
|
+
elif args.command == "list":
|
|
469
|
+
_handle_list(args)
|
|
470
|
+
elif args.command == "search":
|
|
471
|
+
_handle_search(args)
|
|
472
|
+
elif args.command == "get":
|
|
473
|
+
_handle_get(args)
|
|
474
|
+
elif args.command == "add":
|
|
475
|
+
_handle_add(args)
|
|
476
|
+
elif args.command == "delete":
|
|
477
|
+
_handle_delete(args)
|
|
478
|
+
elif args.command == "user":
|
|
479
|
+
if not args.action:
|
|
480
|
+
parser.parse_args(["user", "--help"])
|
|
481
|
+
_handle_user_group(args)
|
|
482
|
+
else:
|
|
483
|
+
parser.print_help(sys.stdout)
|
|
484
|
+
sys.exit(0)
|
|
485
|
+
except ConfigError as exc:
|
|
486
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
487
|
+
sys.exit(2)
|
|
488
|
+
except (ConnectionError, AuthenticationError) as exc:
|
|
489
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
490
|
+
sys.exit(1)
|
ldapc/config.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Configuration management for ldapc.
|
|
2
|
+
|
|
3
|
+
Handles reading, writing, and validating the ldapc configuration file
|
|
4
|
+
stored at ~/.ldapc/config.yaml. Provides serialization between
|
|
5
|
+
LdapcConfig dataclass instances and YAML format.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import stat
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import keyring
|
|
16
|
+
import yaml
|
|
17
|
+
|
|
18
|
+
from ldapc.exceptions import ConfigError
|
|
19
|
+
|
|
20
|
+
DEFAULT_CONFIG_DIR = Path.home() / ".ldapc"
|
|
21
|
+
DEFAULT_CONFIG_PATH = DEFAULT_CONFIG_DIR / "config.yaml"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class LdapcConfig:
|
|
26
|
+
"""Configuration object for ldapc.
|
|
27
|
+
|
|
28
|
+
Attributes:
|
|
29
|
+
host: LDAP server URL (ldaps:// or ldap://).
|
|
30
|
+
username: Bind DN for authentication.
|
|
31
|
+
base_dn: Base DN for searches. Defaults to empty string.
|
|
32
|
+
users_ou: RDN for the users container. Defaults to "cn=users".
|
|
33
|
+
groups_ou: RDN for the groups container. Defaults to "cn=groups".
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
host: str
|
|
37
|
+
username: str
|
|
38
|
+
base_dn: str = ""
|
|
39
|
+
users_ou: str = "cn=users"
|
|
40
|
+
groups_ou: str = "cn=groups"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def config_to_yaml(config: LdapcConfig) -> str:
|
|
44
|
+
"""Serialize a config object to a YAML string.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
config: The LdapcConfig instance to serialize.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
A YAML-formatted string representing the configuration.
|
|
51
|
+
"""
|
|
52
|
+
data = {
|
|
53
|
+
"host": config.host,
|
|
54
|
+
"username": config.username,
|
|
55
|
+
"base_dn": config.base_dn,
|
|
56
|
+
"users_ou": config.users_ou,
|
|
57
|
+
"groups_ou": config.groups_ou,
|
|
58
|
+
}
|
|
59
|
+
return yaml.dump(data, default_flow_style=False, sort_keys=False)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def yaml_to_config(yaml_str: str) -> LdapcConfig:
|
|
63
|
+
"""Parse a YAML string into a config object.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
yaml_str: A YAML-formatted string to parse.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
An LdapcConfig instance populated from the YAML data.
|
|
70
|
+
|
|
71
|
+
Raises:
|
|
72
|
+
ConfigError: If the YAML is invalid or missing required fields.
|
|
73
|
+
"""
|
|
74
|
+
try:
|
|
75
|
+
data = yaml.safe_load(yaml_str)
|
|
76
|
+
except yaml.YAMLError as exc:
|
|
77
|
+
raise ConfigError(f"Invalid YAML in configuration: {exc}") from exc
|
|
78
|
+
|
|
79
|
+
if not isinstance(data, dict):
|
|
80
|
+
raise ConfigError(
|
|
81
|
+
"Invalid configuration file: expected a YAML mapping, "
|
|
82
|
+
f"got {type(data).__name__}"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
missing_fields = []
|
|
86
|
+
for field in ("host", "username"):
|
|
87
|
+
if field not in data:
|
|
88
|
+
missing_fields.append(field)
|
|
89
|
+
|
|
90
|
+
if missing_fields:
|
|
91
|
+
raise ConfigError(
|
|
92
|
+
f"Missing required configuration fields: {', '.join(missing_fields)}"
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
return LdapcConfig(
|
|
96
|
+
host=str(data["host"]),
|
|
97
|
+
username=str(data["username"]),
|
|
98
|
+
base_dn=str(data.get("base_dn", "")),
|
|
99
|
+
users_ou=str(data.get("users_ou", "cn=users")),
|
|
100
|
+
groups_ou=str(data.get("groups_ou", "cn=groups")),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def load_config(config_path: Path | None = None) -> LdapcConfig:
|
|
105
|
+
"""Load configuration from ~/.ldapc/config.yaml.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
config_path: Optional path to the config file. Defaults to
|
|
109
|
+
~/.ldapc/config.yaml.
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
An LdapcConfig instance loaded from the file.
|
|
113
|
+
|
|
114
|
+
Raises:
|
|
115
|
+
ConfigError: If the file is missing or contains invalid YAML.
|
|
116
|
+
"""
|
|
117
|
+
path = config_path or DEFAULT_CONFIG_PATH
|
|
118
|
+
|
|
119
|
+
if not path.exists():
|
|
120
|
+
raise ConfigError(
|
|
121
|
+
"Configuration not found. Run 'ldapc configure' to set up."
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
yaml_str = path.read_text(encoding="utf-8")
|
|
126
|
+
except OSError as exc:
|
|
127
|
+
raise ConfigError(f"Unable to read configuration file: {exc}") from exc
|
|
128
|
+
|
|
129
|
+
return yaml_to_config(yaml_str)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def save_config(config: LdapcConfig, config_path: Path | None = None) -> None:
|
|
133
|
+
"""Serialize config to ~/.ldapc/config.yaml with 0600 permissions.
|
|
134
|
+
|
|
135
|
+
Creates the ~/.ldapc/ directory if it does not exist.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
config: The LdapcConfig instance to persist.
|
|
139
|
+
config_path: Optional path to the config file. Defaults to
|
|
140
|
+
~/.ldapc/config.yaml.
|
|
141
|
+
"""
|
|
142
|
+
path = config_path or DEFAULT_CONFIG_PATH
|
|
143
|
+
config_dir = path.parent
|
|
144
|
+
|
|
145
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
146
|
+
|
|
147
|
+
yaml_str = config_to_yaml(config)
|
|
148
|
+
path.write_text(yaml_str, encoding="utf-8")
|
|
149
|
+
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
KEYRING_SERVICE = "ldapc"
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def store_password(username: str, password: str) -> None:
|
|
156
|
+
"""Store password in OS keychain under service 'ldapc'.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
username: The account identifier (bind DN) to associate with
|
|
160
|
+
the stored password.
|
|
161
|
+
password: The password to store securely.
|
|
162
|
+
"""
|
|
163
|
+
keyring.set_password(KEYRING_SERVICE, username, password)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def retrieve_password(username: str) -> str:
|
|
167
|
+
"""Retrieve password from OS keychain.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
username: The account identifier (bind DN) whose password
|
|
171
|
+
should be retrieved.
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
The stored password string.
|
|
175
|
+
|
|
176
|
+
Raises:
|
|
177
|
+
ConfigError: If no password is stored for the account.
|
|
178
|
+
"""
|
|
179
|
+
password = keyring.get_password(KEYRING_SERVICE, username)
|
|
180
|
+
if password is None:
|
|
181
|
+
raise ConfigError(
|
|
182
|
+
"No stored password. Run 'ldapc configure' to set up."
|
|
183
|
+
)
|
|
184
|
+
return password
|
ldapc/exceptions.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Exception classes for ldapc.
|
|
2
|
+
|
|
3
|
+
All exceptions inherit from LdapcError, enabling consistent catching
|
|
4
|
+
at the CLI layer and mapping to appropriate exit codes.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class LdapcError(Exception):
|
|
9
|
+
"""Base exception for ldapc.
|
|
10
|
+
|
|
11
|
+
All ldapc-specific exceptions inherit from this class so the CLI
|
|
12
|
+
entry point can catch them uniformly and map to exit codes.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ConfigError(LdapcError):
|
|
17
|
+
"""Configuration-related errors.
|
|
18
|
+
|
|
19
|
+
Raised when the configuration file is missing, contains invalid YAML,
|
|
20
|
+
or required fields are absent. Maps to exit code 2.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ConnectionError(LdapcError):
|
|
25
|
+
"""LDAP connection errors.
|
|
26
|
+
|
|
27
|
+
Raised when the LDAP server is unreachable, the connection times out,
|
|
28
|
+
or TLS certificate verification fails. Maps to exit code 1.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class AuthenticationError(LdapcError):
|
|
33
|
+
"""LDAP bind/authentication errors.
|
|
34
|
+
|
|
35
|
+
Raised when the LDAP server rejects the bind credentials.
|
|
36
|
+
Maps to exit code 1.
|
|
37
|
+
"""
|