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.
- kx_auth_cli/__init__.py +11 -0
- kx_auth_cli/assert_cmd.py +278 -0
- kx_auth_cli/atomic.py +54 -0
- kx_auth_cli/cache.py +66 -0
- kx_auth_cli/cli.py +103 -0
- kx_auth_cli/discovery.py +261 -0
- kx_auth_cli/envelope.py +145 -0
- kx_auth_cli/exchange.py +212 -0
- kx_auth_cli/introspect.py +162 -0
- kx_auth_cli/login.py +153 -0
- kx_auth_cli/qbridge.py +54 -0
- kx_auth_cli/rbac.py +759 -0
- kx_auth_cli-0.5.0.dist-info/METADATA +116 -0
- kx_auth_cli-0.5.0.dist-info/RECORD +16 -0
- kx_auth_cli-0.5.0.dist-info/WHEEL +4 -0
- kx_auth_cli-0.5.0.dist-info/entry_points.txt +2 -0
kx_auth_cli/__init__.py
ADDED
|
@@ -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))
|