kx-auth-cli 0.5.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,11 @@
1
+ """``kx auth`` — the client-side auth CLI for kdb+ identity assertion.
2
+
3
+ A thin ``kx`` umbrella (stdlib argparse) whose ``auth`` group exposes the auth flows as a headless
4
+ surface for an operator, a CI step or an autonomous agent: structured ``--json`` output and a stable
5
+ exit-code contract so the caller branches on codes, not prose. Subcommands: ``introspect``, ``login``,
6
+ ``exchange``, ``assert``. Verification reuses the lean :mod:`kx_auth_core` (no fastmcp).
7
+
8
+ It is the OAuth-aware half of the ``kx.auth`` story: the q module deliberately does no token parsing
9
+ and no crypto, so acquiring a bearer, exchanging it and projecting it into a principal all happen
10
+ here, client-side, and ``assert`` binds the result onto a kdb+ process over qIPC.
11
+ """
@@ -0,0 +1,278 @@
1
+ """``kx auth assert`` — exercise the kdb+ identity-assertion handshake from a shell.
2
+
3
+ plain kdb+ has no bearer over qIPC, so a caller does not mint a token for it — it **asserts** the
4
+ end user's identity: projects the validated claims into a q dict and binds them to a trusted
5
+ service-account connection via ``.kx.auth.bind[principal]``. This command runs that handshake
6
+ end-to-end from a shell, so an operator, a CI step or an agent can drive a kdb+ target's ``kx.auth``
7
+ module and confirm its policy behaves as expected for a given principal.
8
+
9
+ The target must hold an ``assert`` grant on ``kx.identity`` for the login this command connects as —
10
+ ``bind`` consults the same default-deny policy it protects, so an ungranted service account is
11
+ refused. (A kdb+ running an older ``kx.auth`` gates on the pre-rename bare ``identity`` resource
12
+ instead; ``bind`` is called positionally, so this command works against either.)
13
+
14
+ Two modes:
15
+
16
+ - **project-only** (no ``--connect``): build and print the principal wire-dict that *would* be bound.
17
+ Uses the shared, fastmcp-free projection — always available, no PyKX needed. Note groups and tenant
18
+ are **not** derived here: q's ``.kx.auth.promote`` is the single promotion authority, so this is an
19
+ indicative preview of the ferry dict, not the promoted principal a policy sees.
20
+ - **handshake** (``--connect host:port``): connect as the service account, call ``.kx.auth.bind``,
21
+ then read back ``.kx.auth.valid[]`` and ``.kx.auth.current[]`` (and optionally run a ``--probe``
22
+ query) to confirm the assertion took. The ``current[]`` readback is the point of a handshake over a
23
+ projection: it is the **promoted** principal, with ``groups``/``tenant`` resolved q-side from the
24
+ host's configured claim paths, which the projection cannot show. It is best-effort — a target that
25
+ binds and validates is working, so a readback that will not render does not fail the handshake. The
26
+ qIPC leg needs PyKX, pulled in via the optional ``kx-auth-cli[qipc]`` extra so the base CLI stays
27
+ light + fastmcp-free.
28
+
29
+ Claims source (first found wins): ``--principal`` JSON (``-`` for stdin, ``@file`` for a file) →
30
+ ``--token`` (a JWT, decoded **unverified** — this is a local handshake exerciser, not a validator) →
31
+ ``$KX_AUTH_TOKEN`` (as a JWT). Exit codes: ``0`` ok · ``1`` error (including a principal q refuses as
32
+ malformed) · ``2`` usage · ``4`` denied (the target refused the bind — the login lacks ``assert`` on
33
+ ``kx.identity`` — or a ``--probe`` query was refused by the q-side permission check).
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import argparse
39
+ import json
40
+ import os
41
+ import sys
42
+ from pathlib import Path
43
+ from typing import Any
44
+
45
+ from kx_auth_core import decode_claims_unverified, project_from_claims
46
+
47
+ from . import atomic, qbridge
48
+ from .envelope import CliError, Denied, UsageError, guarded
49
+
50
+ HELP_DESCRIPTION = (
51
+ "Exercise the kdb+ identity-assertion handshake: project a principal into the q wire-dict and "
52
+ "(with --connect) bind it on a service-account connection to verify a target's kx.auth module."
53
+ )
54
+ HELP_EPILOG = """\
55
+ exit codes (branch on the code, not the text):
56
+ 0 ok projected (project-only), or connected + bound + asserted
57
+ 1 error bad claims, a principal q rejects as malformed, target missing .kx.auth, connect
58
+ failure, or PyKX not installed
59
+ 2 usage no principal/claims supplied, or a bad --connect value
60
+ 3 auth-required not produced by assert; listed so every command shows the same five codes
61
+ 4 denied the target refused the bind (no `assert on `kx.identity for the login), or a
62
+ --probe query was refused by the q-side permission check
63
+
64
+ claims source (first found wins): --principal JSON -> --token JWT -> $KX_AUTH_TOKEN (JWT)
65
+ --principal '-' read JSON claims from stdin
66
+ --principal @file.json read JSON claims from a file
67
+
68
+ examples:
69
+ # project-only: see exactly what would be bound (no kdb+, no PyKX)
70
+ kx auth assert --principal '{"sub":"alice","scope":"kdbx.read","aud":"kx-mcp"}' --json
71
+
72
+ # full handshake against a kdb+ carrying the kx.auth module (needs kx-auth-cli[qipc])
73
+ kx auth assert --token "$TOK" --connect localhost:5010 \\
74
+ --user kxmcp --password "$SVC_PW" --probe "select from trades" --json
75
+ """
76
+
77
+ # q's stable prefix for a principal that will not promote (modules/kx/auth/init.q, shapeFault).
78
+ _MALFORMED = "kx.auth: malformed principal"
79
+
80
+
81
+ def add_arguments(parser: argparse.ArgumentParser) -> None:
82
+ parser.add_argument("--principal", help="Principal claims as JSON ('-' for stdin, '@file' for a file).")
83
+ parser.add_argument("--token", help="A JWT to decode (UNVERIFIED) into claims — convenience for a local handshake.")
84
+ parser.add_argument("--connect", metavar="HOST:PORT", help="kdb+ target to run the bind handshake against (needs the [qipc] extra).")
85
+ parser.add_argument("--user", default=os.environ.get("KX_AUTH_KDB_USER", ""), help="Service-account user for the qIPC login (or $KX_AUTH_KDB_USER).")
86
+ parser.add_argument(
87
+ "--password",
88
+ default=os.environ.get("KX_AUTH_KDB_PASSWORD"),
89
+ help="Service-account password (or $KX_AUTH_KDB_PASSWORD — preferred, keeps it out of shell history).",
90
+ )
91
+ parser.add_argument("--tls", action="store_true", help="Use TLS for the qIPC connection.")
92
+ parser.add_argument("--probe", help="Optional q expression to run after binding (a denial maps to exit 4).")
93
+ parser.add_argument(
94
+ "--promoted-out",
95
+ metavar="FILE",
96
+ help="Atomically write q's promoted principal as JSON (requires --connect).",
97
+ )
98
+ parser.add_argument("--timeout", type=float, default=5.0, help="qIPC connection timeout, seconds.")
99
+
100
+
101
+ def _resolve_claims(args: argparse.Namespace) -> dict:
102
+ """The claims to project. Source: --principal JSON, then --token, then $KX_AUTH_TOKEN.
103
+
104
+ Two kinds of failure, two exit codes: claims that arrived but do not parse are an error (1), while
105
+ a source that yielded nothing readable is a usage error (2) — the caller has not supplied a
106
+ principal yet.
107
+ """
108
+ if args.principal:
109
+ raw = args.principal
110
+ if raw == "-":
111
+ # An interactive stdin would block, and a broken one raises — the same guard
112
+ # `introspect` and `exchange` put on their own stdin fallbacks.
113
+ try:
114
+ raw = "" if sys.stdin.isatty() else sys.stdin.read()
115
+ except (OSError, ValueError) as exc:
116
+ raise UsageError(f"cannot read the principal from stdin: {exc}") from exc
117
+ if not raw.strip():
118
+ raise UsageError("no principal supplied: nothing readable on stdin")
119
+ elif raw.startswith("@"):
120
+ try:
121
+ with open(raw[1:], "r") as fh:
122
+ raw = fh.read()
123
+ except OSError as exc:
124
+ raise CliError(f"cannot read principal file: {exc}") from exc
125
+ try:
126
+ claims = json.loads(raw)
127
+ except json.JSONDecodeError as exc:
128
+ raise CliError(f"--principal is not valid JSON: {exc}") from exc
129
+ if not isinstance(claims, dict):
130
+ raise CliError("--principal JSON must be an object of claims")
131
+ return claims
132
+
133
+ token = args.token or os.environ.get("KX_AUTH_TOKEN")
134
+ if token:
135
+ claims = decode_claims_unverified(token.strip())
136
+ if not claims:
137
+ raise CliError("--token did not decode to any claims (opaque or malformed JWT)")
138
+ return claims
139
+
140
+ raise UsageError("no principal supplied (pass --principal JSON, --token JWT, or $KX_AUTH_TOKEN)")
141
+
142
+
143
+ def _charvec(obj, kx):
144
+ """Recursively wrap str leaves as q char vectors, so PyKX does not auto-symbolise them.
145
+
146
+ Applied to the raw ``claims`` blob before binding. PyKX maps a Python ``str`` to a q **symbol**,
147
+ and q symbols are never garbage-collected — so ferrying high-cardinality claim values (``jti`` is
148
+ unique per token) as plain strings grows the target's symbol table without bound. As char vectors
149
+ they never intern. This mirrors what a KX MCP server does on the same path, and it is why
150
+ ``.kx.auth.promote`` symbolises only the *promoted* fields it extracts and leaves ``claims`` alone.
151
+ """
152
+ if isinstance(obj, str):
153
+ return kx.CharVector(obj)
154
+ if isinstance(obj, dict):
155
+ return {k: _charvec(v, kx) for k, v in obj.items()}
156
+ if isinstance(obj, (list, tuple)):
157
+ return [_charvec(v, kx) for v in obj]
158
+ return obj
159
+
160
+
161
+ def _bind_failure(exc: Exception) -> CliError:
162
+ """Classify a `.kx.auth.bind` failure.
163
+
164
+ q names two refusals of the caller's input — the assert gate ("denied: ...") and a principal that
165
+ will not promote ("kx.auth: malformed principal: ...") — and both are reported verbatim under their
166
+ own exit code. Only an unnamed failure gets the "is kx.auth even loaded?" hint: on a target without
167
+ the module `bind` is undefined, and the raw q error alone is not actionable.
168
+ """
169
+ message = str(exc).strip()
170
+ if message.lower().startswith("denied"):
171
+ return Denied(message)
172
+ if message.startswith(_MALFORMED):
173
+ return CliError(message)
174
+ return CliError(f"bind failed (target missing the kx.auth module?): {exc}")
175
+
176
+
177
+ def _handshake(args: argparse.Namespace, wire: dict) -> dict:
178
+ """Connect as the service account, bind the principal, confirm, optionally probe."""
179
+ try:
180
+ kx = qbridge.import_pykx()
181
+ except Exception as exc:
182
+ raise CliError(
183
+ "PyKX is required for --connect; install the qIPC extra: pip install 'kx-auth-cli[qipc]'"
184
+ ) from exc
185
+
186
+ host, _, port = args.connect.partition(":")
187
+ if not port.isdigit():
188
+ raise UsageError("--connect must be HOST:PORT")
189
+
190
+ try:
191
+ conn = kx.SyncQConnection(
192
+ host=host or "localhost",
193
+ port=int(port),
194
+ username=args.user,
195
+ password=args.password or "",
196
+ timeout=args.timeout,
197
+ tls=args.tls,
198
+ )
199
+ except Exception as exc:
200
+ raise CliError(f"connect failed: {exc}") from exc
201
+
202
+ # Only `claims` is wrapped: the promoted top-level fields are meant to arrive as symbols, which
203
+ # is what q's promote expects of them, and they are low-cardinality by construction.
204
+ ferry = dict(wire)
205
+ if "claims" in ferry:
206
+ ferry["claims"] = _charvec(ferry["claims"], kx)
207
+
208
+ try:
209
+ conn(".kx.auth.bind", ferry)
210
+ except Exception as exc:
211
+ raise _bind_failure(exc) from exc
212
+
213
+ try:
214
+ bound_valid = bool(conn(".kx.auth.valid[]").py())
215
+ except Exception as exc:
216
+ raise CliError(f"could not read .kx.auth.valid[]: {exc}") from exc
217
+
218
+ # Read back the PROMOTED principal, which is the whole reason a handshake beats a projection: it
219
+ # is q's answer, with `groups`/`tenant` resolved by .kx.auth.promote from whatever claim paths the
220
+ # host configured. The projection printed without --connect cannot show any of that. Best-effort —
221
+ # a target that binds and validates is working, so failing to render the readback must not turn a
222
+ # successful handshake into an error.
223
+ promoted = None
224
+ try:
225
+ promoted = qbridge.readable(conn(".kx.auth.current[]").py())
226
+ except Exception:
227
+ promoted = None
228
+
229
+ if args.promoted_out:
230
+ if not isinstance(promoted, dict):
231
+ raise CliError("--promoted-out requires a readable .kx.auth.current[] principal")
232
+ try:
233
+ # PRIVATE, like the login cache: this file is not a credential — it never authenticates a
234
+ # mutation — but it carries an identity's promoted attributes and its ferried claims, and
235
+ # the documented handoff (`assert --promoted-out` into `rbac check --principal @FILE`) is
236
+ # one user in one session. Nothing in that flow needs the process umask's wider audience.
237
+ atomic.write_json(Path(args.promoted_out), promoted, mode=atomic.PRIVATE, newline=True)
238
+ except OSError as exc:
239
+ raise CliError(f"cannot write --promoted-out: {exc}") from exc
240
+
241
+ result: dict[str, Any] = {"principal": wire, "bound": True, "valid": bound_valid, "probed": bool(args.probe)}
242
+ if promoted is not None:
243
+ result["promoted"] = promoted
244
+
245
+ if args.probe:
246
+ try:
247
+ conn(args.probe)
248
+ except Exception as exc:
249
+ if str(exc).strip().lower().startswith("denied"):
250
+ # The bind itself succeeded, so the handshake result rides along with the refusal.
251
+ raise Denied(str(exc), result=result) from exc
252
+ raise CliError(f"probe failed: {exc}") from exc
253
+
254
+ return result
255
+
256
+
257
+ def _human(result: dict) -> str:
258
+ # `promoted` is q's own view of the bound principal, so it is a dict rather than a scalar — kept
259
+ # out of the one-line summary and printed under it.
260
+ line = f"ok — principal sub={result['principal'].get('sub')!r}"
261
+ flags = {k: v for k, v in result.items() if k not in ("principal", "promoted")}
262
+ if flags:
263
+ line += " " + " ".join(f"{k}={v}" for k, v in flags.items())
264
+ if "promoted" in result:
265
+ line += f"\n promoted by q: {result['promoted']}"
266
+ return line
267
+
268
+
269
+ def run(args: argparse.Namespace) -> int:
270
+ def fn() -> dict:
271
+ if args.promoted_out and not args.connect:
272
+ raise UsageError("--promoted-out requires --connect")
273
+ wire = project_from_claims(_resolve_claims(args))
274
+ if not args.connect:
275
+ return {"principal": wire}
276
+ return _handshake(args, wire)
277
+
278
+ return guarded(args, fn, human=_human)
kx_auth_cli/atomic.py ADDED
@@ -0,0 +1,54 @@
1
+ """Temp-then-rename file writes, with the file mode stated rather than inherited.
2
+
3
+ Three commands write files a later step reads back — the login cache, `assert --promoted-out`, and
4
+ `rbac export`. All three need the same two properties, so they share one implementation:
5
+
6
+ * **Atomic replacement.** A crash mid-write must not leave a truncated file where a complete one was.
7
+ `os.replace` is atomic within a filesystem, and the temp file is created alongside the target so
8
+ the rename never crosses one.
9
+ * **An explicit mode.** The mode is passed at `open` rather than `chmod`'d afterwards, so the file is
10
+ never briefly readable at the process umask before being narrowed.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import contextlib
16
+ import json
17
+ import os
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ #: Owner-only. For anything carrying a credential or an identity's claims.
22
+ PRIVATE = 0o600
23
+
24
+ #: Whatever the process umask allows. For files whose contents are not sensitive.
25
+ UMASK = 0o666
26
+
27
+
28
+ def write_text(path: Path, text: str, *, mode: int = UMASK) -> Path:
29
+ """Write ``text`` to ``path`` atomically, creating parent directories as needed."""
30
+ path.parent.mkdir(parents=True, exist_ok=True)
31
+ tmp = path.with_suffix(path.suffix + ".tmp")
32
+ # os.open's mode applies only when it CREATES the file, so a temp left by an earlier run (or
33
+ # planted) would survive an O_TRUNC with its own wider mode and then be renamed onto the target —
34
+ # observed as a 0644 credentials.json holding a bearer. Remove it, then O_EXCL, so the mode below
35
+ # is always one we set on a file we created (O_EXCL also refuses a planted symlink).
36
+ with contextlib.suppress(FileNotFoundError):
37
+ os.unlink(tmp)
38
+ fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, mode)
39
+ try:
40
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
41
+ fh.write(text)
42
+ os.replace(tmp, path)
43
+ except BaseException:
44
+ # Leave no half-written temp file behind for the next run to trip over.
45
+ with contextlib.suppress(OSError):
46
+ os.unlink(tmp)
47
+ raise
48
+ return path
49
+
50
+
51
+ def write_json(path: Path, obj: Any, *, mode: int = UMASK, newline: bool = False) -> Path:
52
+ """Write ``obj`` as indented JSON, atomically. ``default=str`` matches the CLI's output contract."""
53
+ text = json.dumps(obj, indent=2, default=str)
54
+ return write_text(path, text + ("\n" if newline else ""), mode=mode)
kx_auth_cli/cache.py ADDED
@@ -0,0 +1,66 @@
1
+ """Client-side token cache for ``kx auth`` — a single JSON file, keyed by endpoint.
2
+
3
+ ``login`` writes the acquired credential here; ``exchange`` reads it as a subject-token fallback.
4
+ The cache is a *client-side* concern (the agent/operator's own credentials), so it lives in the CLI
5
+ rather than the shared core. Templates include product CLIs' endpoint-keyed caches and ``gh``'s
6
+ ``~/.config/gh/hosts.yml`` — a single JSON file under a dotdir, path overridable, keyed by endpoint
7
+ so multiple deployments coexist. OS-keyring storage is a later hardening, not the v1 bar.
8
+
9
+ The keys are the ``--server`` (MCP-server / resource) URLs the agent connects to. ``introspect`` is
10
+ stateless and never touches this file.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import contextlib
16
+ import json
17
+ import os
18
+ import stat
19
+ from pathlib import Path
20
+ from typing import Any, Optional
21
+
22
+ from . import atomic
23
+
24
+
25
+ def cache_path() -> Path:
26
+ """The credential file path: ``$KX_AUTH_CACHE`` if set, else ``~/.kx/credentials.json``."""
27
+ override = os.environ.get("KX_AUTH_CACHE")
28
+ if override:
29
+ return Path(override).expanduser()
30
+ return Path.home() / ".kx" / "credentials.json"
31
+
32
+
33
+ def load() -> dict[str, Any]:
34
+ """The whole cache as ``{server: credential}``; ``{}`` if absent or unreadable (never raises)."""
35
+ path = cache_path()
36
+ try:
37
+ with path.open("r", encoding="utf-8") as fh:
38
+ data = json.load(fh)
39
+ return data if isinstance(data, dict) else {}
40
+ except (OSError, ValueError):
41
+ # Missing or corrupt cache is "no cached credentials", not a hard error.
42
+ return {}
43
+
44
+
45
+ def get(server: str) -> Optional[dict[str, Any]]:
46
+ """The cached credential for ``server``, or ``None`` if not present."""
47
+ entry = load().get(server)
48
+ return entry if isinstance(entry, dict) else None
49
+
50
+
51
+ def save(server: str, credential: dict[str, Any]) -> Path:
52
+ """Merge ``credential`` under ``server`` and persist. Dir ``0700``, file ``0600``."""
53
+ path = cache_path()
54
+ # The directory mode is this function's own concern; atomic.write_json owns the file's. mkdir's
55
+ # mode is create-only, so a directory that already existed keeps whatever mode it had — narrow it
56
+ # explicitly, except a sticky directory (e.g. $KX_AUTH_CACHE pointed into /tmp), which is shared by
57
+ # design and never ours to re-mode; there the 0600 file mode carries the guarantee alone.
58
+ parent = path.parent
59
+ parent.mkdir(mode=0o700, parents=True, exist_ok=True)
60
+ with contextlib.suppress(OSError):
61
+ if not parent.stat().st_mode & stat.S_ISVTX:
62
+ parent.chmod(0o700)
63
+ data = load()
64
+ data[server] = credential
65
+ # PRIVATE, not the umask: this file holds bearer tokens.
66
+ return atomic.write_json(path, data, mode=atomic.PRIVATE)
kx_auth_cli/cli.py ADDED
@@ -0,0 +1,103 @@
1
+ """The ``kx`` umbrella entry point (stdlib argparse).
2
+
3
+ The ``auth`` group exposes ``introspect``, ``login``, ``exchange``, and ``assert``; the umbrella
4
+ leaves room for future top-level ``kx`` commands. Each leaf command sets ``func``; ``main`` dispatches
5
+ and propagates its int return as the process exit code. Argparse's own usage errors exit ``2`` — the
6
+ code the spec reserves for that.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import sys
13
+ from typing import Optional, Sequence
14
+
15
+ from . import assert_cmd, envelope, exchange, introspect, login, rbac
16
+
17
+
18
+ def build_parser() -> argparse.ArgumentParser:
19
+ parser = argparse.ArgumentParser(
20
+ prog="kx",
21
+ description="kx auth CLI. Agent-first: every command supports --json and a stable "
22
+ "exit-code contract (0 ok · 1 error · 2 usage · 3 auth-required · 4 denied).",
23
+ )
24
+ groups = parser.add_subparsers(dest="group", metavar="<group>")
25
+
26
+ auth = groups.add_parser(
27
+ "auth",
28
+ help="Authentication flows (introspect / login / exchange / assert).",
29
+ description="Authentication flows for kdb+ identity assertion: `introspect`, `login`, "
30
+ "`exchange`, `assert`.",
31
+ )
32
+ auth_commands = auth.add_subparsers(dest="auth_command", metavar="<command>")
33
+
34
+ # `--json` is declared once (envelope.json_parent) and inherited by every leaf: the flag selects
35
+ # the envelope, so it belongs to the module that owns the envelope, not to each command.
36
+ json_parent = envelope.json_parent()
37
+
38
+ introspect_parser = auth_commands.add_parser(
39
+ "introspect",
40
+ parents=[json_parent],
41
+ help="Validate a bearer token against the configured KX_MCP_AUTH keys.",
42
+ description=introspect.HELP_DESCRIPTION,
43
+ epilog=introspect.HELP_EPILOG,
44
+ formatter_class=argparse.RawDescriptionHelpFormatter,
45
+ )
46
+ introspect.add_arguments(introspect_parser)
47
+ introspect_parser.set_defaults(func=introspect.run)
48
+
49
+ login_parser = auth_commands.add_parser(
50
+ "login",
51
+ parents=[json_parent],
52
+ help="Acquire a bearer for an MCP server via server-advertised device-code OAuth.",
53
+ description=login.HELP_DESCRIPTION,
54
+ epilog=login.HELP_EPILOG,
55
+ formatter_class=argparse.RawDescriptionHelpFormatter,
56
+ )
57
+ login.add_arguments(login_parser)
58
+ login_parser.set_defaults(func=login.run)
59
+
60
+ exchange_parser = auth_commands.add_parser(
61
+ "exchange",
62
+ parents=[json_parent],
63
+ help="Swap a subject token for a backend-scoped credential (RFC 8693).",
64
+ description=exchange.HELP_DESCRIPTION,
65
+ epilog=exchange.HELP_EPILOG,
66
+ formatter_class=argparse.RawDescriptionHelpFormatter,
67
+ )
68
+ exchange.add_arguments(exchange_parser)
69
+ exchange_parser.set_defaults(func=exchange.run)
70
+
71
+ assert_parser = auth_commands.add_parser(
72
+ "assert",
73
+ parents=[json_parent],
74
+ help="Exercise the kdb+ identity-assertion handshake (project a principal; optionally bind it).",
75
+ description=assert_cmd.HELP_DESCRIPTION,
76
+ epilog=assert_cmd.HELP_EPILOG,
77
+ formatter_class=argparse.RawDescriptionHelpFormatter,
78
+ )
79
+ assert_cmd.add_arguments(assert_parser)
80
+ assert_parser.set_defaults(func=assert_cmd.run)
81
+
82
+ rbac_parser = groups.add_parser(
83
+ "rbac",
84
+ help="Inspect, test and atomically administer kx.rbac policy.",
85
+ description=rbac.HELP_DESCRIPTION,
86
+ epilog=rbac.HELP_EPILOG,
87
+ formatter_class=argparse.RawDescriptionHelpFormatter,
88
+ )
89
+ rbac_commands = rbac_parser.add_subparsers(dest="rbac_command", metavar="<command>")
90
+ rbac.add_commands(rbac_commands)
91
+
92
+ return parser
93
+
94
+
95
+ def main(argv: Optional[Sequence[str]] = None) -> None:
96
+ parser = build_parser()
97
+ args = parser.parse_args(argv)
98
+ func = getattr(args, "func", None)
99
+ if func is None:
100
+ # `kx` or `kx auth` with no leaf command — show help and exit as a usage error.
101
+ parser.print_help(sys.stderr)
102
+ sys.exit(envelope.EXIT_USAGE)
103
+ sys.exit(func(args))