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/rbac.py ADDED
@@ -0,0 +1,759 @@
1
+ """``kx rbac`` — inspect and administer the kx.rbac policy engine.
2
+
3
+ The command has two transports: direct qIPC (``--connect``) and an OAuth-aware HTTP gateway
4
+ (``--server``). Policy visibility and pure decisions are public inside either transport. Mutations
5
+ authorize the connection/request principal in q and persist atomically through the module's batch
6
+ API; ``--principal`` is decision input only and can never authenticate a mutation.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import csv
13
+ import datetime
14
+ import io
15
+ import json
16
+ import os
17
+ import sys
18
+ import time
19
+ from dataclasses import dataclass
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ import httpx
24
+
25
+ from . import atomic, cache, qbridge
26
+ from .envelope import AuthRequired, CliError, Denied, UsageError, guarded, json_parent
27
+
28
+ HELP_DESCRIPTION = (
29
+ "Inspect, test and atomically administer a kx.rbac policy over direct qIPC or an "
30
+ "OAuth-aware HTTP gateway. Grants are visible; only changes require admin:kx.rbac."
31
+ )
32
+ HELP_EPILOG = """\
33
+ exit codes (branch on the code, not the text):
34
+ 0 ok the call succeeded, or the decision was allow
35
+ 1 error transport failure, a malformed server response or import file, or --fail-on tripped
36
+ 2 usage bad ACTION/RESOURCE, a bad --ctx value, an import file that fails validation
37
+ 3 auth-required no bearer (or an expired cached login) for --server, or the gateway answered 401
38
+ 4 denied check/explain decided deny, the gateway answered 403, or q signalled "denied: ..."
39
+
40
+ transport (exactly one): --connect HOST:PORT (qIPC, needs the [qipc] extra) | --server URL (gateway)
41
+ --principal JSON|@FILE|- models the subject for check/explain/show only; it never authenticates
42
+ """
43
+
44
+
45
+ def add_transport_arguments(parser: argparse.ArgumentParser) -> None:
46
+ transport = parser.add_mutually_exclusive_group(required=True)
47
+ transport.add_argument("--connect", metavar="HOST:PORT", help="Direct qIPC target (needs the [qipc] extra).")
48
+ transport.add_argument("--server", metavar="URL", help="OAuth-aware gateway exposing /kx/rbac/v1.")
49
+ parser.add_argument("--user", default=os.environ.get("KX_AUTH_KDB_USER", ""), help="qIPC login user.")
50
+ parser.add_argument("--password", default=os.environ.get("KX_AUTH_KDB_PASSWORD"), help="qIPC login password.")
51
+ parser.add_argument("--tls", action="store_true", help="Use TLS for qIPC.")
52
+ parser.add_argument("--token", help="Gateway bearer (then $KX_AUTH_TOKEN, then cached login for --server).")
53
+ parser.add_argument("--no-verify", action="store_true", help="Disable gateway TLS verification (development only).")
54
+ parser.add_argument("--timeout", type=float, default=5.0, help="Transport timeout, seconds.")
55
+
56
+
57
+ def add_commands(commands: argparse._SubParsersAction) -> None:
58
+ parent = argparse.ArgumentParser(add_help=False, parents=[json_parent()])
59
+ add_transport_arguments(parent)
60
+
61
+ show = commands.add_parser("show", parents=[parent], help="Show public grants.")
62
+ show.add_argument("--group", help="Restrict rows to one group.")
63
+ show.add_argument("--principal", help="Show only what this principal holds: JSON, '-' for stdin, or @FILE.")
64
+ show.set_defaults(func=run_show)
65
+
66
+ verify = commands.add_parser("verify", parents=[parent], help="Lint the live policy.")
67
+ verify.add_argument(
68
+ "--fail-on", choices=("error", "warning"), dest="fail_on",
69
+ help="Exit 1 when a finding at this severity or worse is present.",
70
+ )
71
+ verify.set_defaults(func=run_verify)
72
+
73
+ for name, func in (("check", run_check), ("explain", run_explain)):
74
+ p = commands.add_parser(name, parents=[parent], help=f"{name.title()} an action/resource for a principal.")
75
+ p.add_argument("action", help="Action, or ACTION:RESOURCE.")
76
+ p.add_argument("resource", nargs="?", help="Resource (omit with ACTION:RESOURCE).")
77
+ p.add_argument("--principal", help="Canonical principal JSON, '-' for stdin, or @FILE.")
78
+ p.add_argument(
79
+ "--resource", dest="resources", action="append", metavar="RESOURCE",
80
+ help="Additional resource; repeat for many. Asks the seam, which answers with the permitted subset.",
81
+ )
82
+ p.add_argument(
83
+ "--ctx", metavar="JSON",
84
+ help="Declared request context as a JSON object, '-' for stdin, or @FILE. "
85
+ "Asks the seam, which may answer with narrowings of the axes you declare.",
86
+ )
87
+ p.set_defaults(func=func)
88
+
89
+ for name, func in (("grant", run_grant), ("revoke", run_revoke)):
90
+ p = commands.add_parser(name, parents=[parent], help=f"Atomically persist one {name} operation.")
91
+ p.add_argument("group")
92
+ p.add_argument("action", help="Action, or ACTION:RESOURCE.")
93
+ p.add_argument("resource", nargs="?", help="Resource (omit with ACTION:RESOURCE).")
94
+ p.set_defaults(func=func)
95
+
96
+ imp = commands.add_parser("import", parents=[parent], help="Validate and atomically import JSON or CSV.")
97
+ imp.add_argument("file", type=Path)
98
+ imp.add_argument("--replace", action="store_true", help="Replace from a full grants snapshot.")
99
+ imp.add_argument("--dry-run", action="store_true", help="Return the q-computed diff and findings without mutation.")
100
+ imp.set_defaults(func=run_import)
101
+
102
+ exp = commands.add_parser("export", parents=[parent], help="Export a portable grants snapshot.")
103
+ exp.add_argument("file", type=Path)
104
+ exp.add_argument("--format", choices=("json", "csv"), help="Defaults from FILE suffix, then JSON.")
105
+ exp.set_defaults(func=run_export)
106
+
107
+ save = commands.add_parser("save", parents=[parent], help="Atomically save the live policy to its configured store.")
108
+ save.set_defaults(func=run_save)
109
+ load = commands.add_parser("load", parents=[parent], help="Load the configured store into the live policy.")
110
+ load.set_defaults(func=run_load)
111
+
112
+
113
+ def _records(value: Any, columns: tuple[str, ...] = ("grp", "act", "res")) -> list[dict[str, Any]]:
114
+ value = qbridge.readable(value)
115
+ if value is None:
116
+ return []
117
+ if isinstance(value, list):
118
+ if not value:
119
+ return []
120
+ if all(isinstance(row, dict) for row in value):
121
+ return [{str(k): v for k, v in row.items()} for row in value]
122
+ if isinstance(value, dict) and all(c in value for c in columns):
123
+ # Every list column must agree: the loop below indexes all of them by position, so a ragged
124
+ # table (a real possibility from a hand-rolled gateway) would IndexError mid-render rather than
125
+ # being refused as the malformed response it is.
126
+ widths = {len(value[c]) for c in columns if isinstance(value[c], list)}
127
+ if len(widths) > 1:
128
+ raise CliError(f"server returned a ragged table: column lengths {sorted(widths)} differ")
129
+ n = widths.pop() if widths else 1
130
+ rows = []
131
+ for i in range(n):
132
+ rows.append({c: value[c][i] if isinstance(value[c], list) else value[c] for c in columns})
133
+ return rows
134
+ raise CliError(f"server returned an unexpected table shape: {value!r}")
135
+
136
+
137
+ def _as_allowed(value: Any) -> bool:
138
+ """Read a decision as a boolean, refusing anything that is not one.
139
+
140
+ `bool(value)` fails OPEN. The string "false", the string "denied", `{"allowed": False}` and even
141
+ the nested `{"result": {"allowed": False}}` — the last two being the shapes this CLI's own
142
+ explain/scope responses use — are all truthy in Python, so a gateway or q build answering `check`
143
+ in any of those richer forms would turn every denial into an allow. A decision is a boolean or it
144
+ is not an answer.
145
+ """
146
+ value = qbridge.readable(value)
147
+ if isinstance(value, bool):
148
+ return value
149
+ raise CliError(f"the policy engine returned a non-boolean decision: {value!r}")
150
+
151
+
152
+ def _wild(value: Any) -> Any:
153
+ return "*" if value in (None, "") else value
154
+
155
+
156
+ def _public_rows(value: Any) -> list[dict[str, Any]]:
157
+ return [
158
+ {"group": _wild(r.get("grp")), "action": _wild(r.get("act")), "resource": _wild(r.get("res"))}
159
+ for r in _records(value)
160
+ ]
161
+
162
+
163
+ def _parse_pair(action: str, resource: str | None) -> tuple[str | None, str | None]:
164
+ if resource is None:
165
+ if ":" not in action:
166
+ raise UsageError("supply ACTION RESOURCE or ACTION:RESOURCE")
167
+ action, resource = action.split(":", 1)
168
+ elif ":" in action:
169
+ raise UsageError("do not combine ACTION:RESOURCE with a separate RESOURCE")
170
+ if not action or resource == "":
171
+ raise UsageError("action and resource must not be empty; use '*' for a wildcard")
172
+ return (None if action == "*" else action, None if resource == "*" else resource)
173
+
174
+
175
+ def _read_arg(raw: str, what: str) -> str:
176
+ """Literal, '-' for stdin, or @FILE — the one convention every JSON-bearing argument here uses."""
177
+ if raw == "-":
178
+ # A tty would block forever, and a broken stdin raises OSError, which the boundary would file
179
+ # as an operational error — but a stdin that yields nothing is the caller's usage mistake, so
180
+ # `--principal -`/`--ctx -` classify it here.
181
+ try:
182
+ text = "" if sys.stdin.isatty() else sys.stdin.read()
183
+ except (OSError, ValueError) as exc:
184
+ raise UsageError(f"cannot read {what} from stdin: {exc}") from exc
185
+ if not text.strip():
186
+ raise UsageError(f"no {what} supplied: nothing readable on stdin")
187
+ return text
188
+ if raw.startswith("@"):
189
+ try:
190
+ return Path(raw[1:]).read_text(encoding="utf-8")
191
+ except OSError as exc:
192
+ raise UsageError(f"cannot read {what} file: {exc}") from exc
193
+ return raw
194
+
195
+
196
+ def _principal(raw: str | None) -> dict[str, Any] | None:
197
+ if raw is None:
198
+ return None
199
+ text = _read_arg(raw, "principal")
200
+ # A principal is DATA — usually a file written by `assert --promoted-out` — so malformed contents
201
+ # are an error (exit 1), matching `assert --principal`, not a usage mistake (exit 2). `--ctx` is
202
+ # the opposite: those are values the caller typed, and `_to_q` keeps them at exit 2 via UsageError.
203
+ try:
204
+ value = json.loads(text)
205
+ except json.JSONDecodeError as exc:
206
+ raise CliError(f"principal is not valid JSON: {exc}") from exc
207
+ if not isinstance(value, dict):
208
+ raise CliError("principal JSON must be an object")
209
+ return value
210
+
211
+
212
+ @dataclass
213
+ class DirectTransport:
214
+ conn: Any
215
+
216
+ @classmethod
217
+ def open(cls, args: argparse.Namespace) -> "DirectTransport":
218
+ try:
219
+ kx = qbridge.import_pykx()
220
+ except Exception as exc:
221
+ raise CliError("PyKX is required for --connect; install 'kx-auth-cli[qipc]'") from exc
222
+ host, sep, port = args.connect.rpartition(":")
223
+ if not sep or not port.isdigit():
224
+ raise UsageError("--connect must be HOST:PORT")
225
+ try:
226
+ conn = kx.SyncQConnection(
227
+ host=host or "localhost", port=int(port), username=args.user,
228
+ password=args.password or "", timeout=args.timeout, tls=args.tls,
229
+ )
230
+ except Exception as exc:
231
+ raise CliError(f"connect failed: {exc}") from exc
232
+ return cls(conn)
233
+
234
+ def call(self, name: str, payload: dict[str, Any] | None = None) -> Any:
235
+ payload = payload or {}
236
+ try:
237
+ if name == "grants":
238
+ return qbridge.readable(self.conn(".kx.rbac.grants[]"))
239
+ if name == "current":
240
+ return json.loads(qbridge.readable(self.conn(".j.j .kx.auth.current[]")))
241
+ if name in ("check", "explain"):
242
+ principal = payload.get("principal")
243
+ if principal is None:
244
+ principal = self.call("current")
245
+ expression = f"{{[p;a;r] .j.j .kx.rbac.{name}[p;`$string a;`$string r]}}" if name == "explain" else (
246
+ "{[p;a;r] .kx.rbac.check[p;`$string a;`$string r]}"
247
+ )
248
+ result = qbridge.readable(self.conn(
249
+ expression, principal,
250
+ "" if payload["action"] is None else payload["action"],
251
+ "" if payload["resource"] is None else payload["resource"],
252
+ ))
253
+ return json.loads(result) if name == "explain" else result
254
+ if name == "scope":
255
+ # The seam, not the engine: only .kx.auth sees narrowing, and only it knows the installed
256
+ # policy's rank. Keys and values go over as parallel lists and are dict-ed in q, the same
257
+ # shape `apply` uses, so nothing depends on how PyKX renders a dictionary.
258
+ principal = payload.get("principal")
259
+ if principal is None:
260
+ principal = self.call("current")
261
+ axes = payload.get("context") or {}
262
+ kx = qbridge.import_pykx()
263
+ expression = (
264
+ "{[p;a;rs;k;v] .j.j .kx.auth.explain["
265
+ "p; `$string a; `$string rs; $[count k; (`$string k)!v; (::)]]}"
266
+ )
267
+ result = qbridge.readable(self.conn(
268
+ expression, principal,
269
+ "" if payload["action"] is None else payload["action"],
270
+ payload["resources"],
271
+ list(axes.keys()),
272
+ [_to_q(kx, k, v) for k, v in axes.items()],
273
+ ))
274
+ return json.loads(result)
275
+ if name == "apply":
276
+ ops = payload["operations"]
277
+ expression = (
278
+ "{[op;g;a;r;d] .j.j .kx.rbac.apply["
279
+ "([] op:`$string op;grp:`$string g;act:`$string a;res:`$string r);d]}"
280
+ )
281
+ result = qbridge.readable(self.conn(
282
+ expression,
283
+ [o["op"] for o in ops], [o["group"] for o in ops],
284
+ ["" if o["action"] is None else o["action"] for o in ops],
285
+ ["" if o["resource"] is None else o["resource"] for o in ops],
286
+ payload.get("dry_run", False),
287
+ ))
288
+ return json.loads(result)
289
+ if name == "replace":
290
+ grants = payload["grants"]
291
+ expression = (
292
+ "{[g;a;r;d] .j.j .kx.rbac.replace["
293
+ "([] grp:`$string g;act:`$string a;res:`$string r);d]}"
294
+ )
295
+ result = qbridge.readable(self.conn(
296
+ expression,
297
+ [o["group"] for o in grants],
298
+ ["" if o["action"] is None else o["action"] for o in grants],
299
+ ["" if o["resource"] is None else o["resource"] for o in grants],
300
+ payload.get("dry_run", False),
301
+ ))
302
+ return json.loads(result)
303
+ if name == "effective":
304
+ principal = payload.get("principal")
305
+ if principal is None:
306
+ principal = self.call("current")
307
+ return qbridge.readable(self.conn("{[p] .kx.rbac.effective[p]}", principal))
308
+ if name == "verify":
309
+ return json.loads(qbridge.readable(self.conn(".j.j .kx.rbac.verify[]")))
310
+ if name in ("save", "load"):
311
+ return qbridge.readable(self.conn(f".kx.rbac.{name}[]"))
312
+ except CliError:
313
+ # Already classified — `_to_q` refusing a context value the CALLER typed is a usage error,
314
+ # not a q/transport failure — so the blanket wrap below must not re-file it as exit 1.
315
+ raise
316
+ except Exception as exc:
317
+ message = str(exc)
318
+ if message.lower().startswith("denied"):
319
+ raise Denied(message) from exc
320
+ raise CliError(message) from exc
321
+ raise CliError(f"unsupported direct operation: {name}")
322
+
323
+
324
+ @dataclass
325
+ class HttpTransport:
326
+ base: str
327
+ token: str
328
+ verify: bool
329
+ timeout: float
330
+
331
+ @classmethod
332
+ def open(cls, args: argparse.Namespace) -> "HttpTransport":
333
+ credential = cache.get(args.server) or {}
334
+ token = args.token or os.environ.get("KX_AUTH_TOKEN")
335
+ if not token:
336
+ expires_at = credential.get("expires_at")
337
+ if expires_at is not None:
338
+ try:
339
+ expired = float(expires_at) <= time.time()
340
+ except (TypeError, ValueError) as exc:
341
+ # An expiry that will not read is not a usable credential — same answer as an
342
+ # expired one: demand a re-login rather than let a TypeError escape `_guarded`'s
343
+ # type-scoped allow-list as a traceback.
344
+ raise AuthRequired(
345
+ f"cached login for {args.server} has an unreadable expiry; "
346
+ f"run `kx auth login --server {args.server}`"
347
+ ) from exc
348
+ if expired:
349
+ raise AuthRequired(f"cached login for {args.server} has expired; run `kx auth login --server {args.server}`")
350
+ token = credential.get("access_token")
351
+ if not token:
352
+ raise AuthRequired(f"no bearer for {args.server}; run `kx auth login --server {args.server}`")
353
+ return cls(args.server.rstrip("/") + "/kx/rbac/v1", token, not args.no_verify, args.timeout)
354
+
355
+ def call(self, name: str, payload: dict[str, Any] | None = None) -> Any:
356
+ method, path = {
357
+ "grants": ("GET", "/grants"), "verify": ("GET", "/verify"),
358
+ "effective": ("POST", "/effective"),
359
+ "check": ("POST", "/check"), "explain": ("POST", "/explain"),
360
+ "scope": ("POST", "/scope"),
361
+ "apply": ("POST", "/transactions"), "replace": ("POST", "/replace"),
362
+ "save": ("POST", "/save"), "load": ("POST", "/load"),
363
+ }[name]
364
+ headers = {"Authorization": f"Bearer {self.token}"}
365
+ try:
366
+ response = httpx.request(
367
+ method, self.base + path, headers=headers, json=payload if method == "POST" else None,
368
+ verify=self.verify, timeout=self.timeout,
369
+ )
370
+ # httpx.InvalidURL does NOT subclass httpx.HTTPError, so a malformed --server would
371
+ # otherwise escape as a traceback instead of this command's exit-code contract.
372
+ except (httpx.HTTPError, httpx.InvalidURL) as exc:
373
+ raise CliError(f"gateway request failed: {exc}") from exc
374
+ if response.status_code == 401:
375
+ raise AuthRequired(response.text or "gateway authentication required")
376
+ if response.status_code == 403:
377
+ raise Denied(response.text or "gateway denied the caller")
378
+ try:
379
+ response.raise_for_status()
380
+ body = response.json()
381
+ except (httpx.HTTPError, ValueError) as exc:
382
+ raise CliError(f"gateway returned {response.status_code}: {response.text}") from exc
383
+ return body.get("result", body) if isinstance(body, dict) else body
384
+
385
+
386
+ def _transport(args: argparse.Namespace):
387
+ return DirectTransport.open(args) if args.connect else HttpTransport.open(args)
388
+
389
+
390
+ def _guarded(args: argparse.Namespace, operation) -> int:
391
+ # The transport is opened INSIDE the boundary: an unreachable host or a missing bearer is a
392
+ # classified failure, not a traceback.
393
+ return guarded(args, lambda: operation(_transport(args)))
394
+
395
+
396
+ def run_show(args: argparse.Namespace) -> int:
397
+ def op(transport):
398
+ principal = _principal(getattr(args, "principal", None))
399
+ if principal is None and getattr(args, "principal", None) is None:
400
+ rows = _public_rows(transport.call("grants"))
401
+ else:
402
+ rows = _public_rows(transport.call("effective", {"principal": principal}))
403
+ if args.group:
404
+ rows = [row for row in rows if row["group"] == args.group]
405
+ return {"grants": rows}
406
+ return _guarded(args, op)
407
+
408
+
409
+ _SEVERITY = {"note": 0, "warning": 1, "error": 2}
410
+
411
+
412
+ def run_verify(args: argparse.Namespace) -> int:
413
+ def op(transport):
414
+ findings = _findings(transport.call("verify"))
415
+ result = {"findings": findings, "counts": _counts(findings)}
416
+ if args.fail_on:
417
+ floor = _SEVERITY[args.fail_on]
418
+ result["failed"] = any(_SEVERITY.get(f.get("severity"), 0) >= floor for f in findings)
419
+ if result["failed"]:
420
+ # The command worked, so this is not a denial — the policy is simply not in a state
421
+ # the caller was willing to accept. The findings ride along so a pipeline can print them.
422
+ counts = result["counts"]
423
+ at_or_above = [s for s in ("error", "warning") if _SEVERITY[s] >= floor and counts[s]]
424
+ reason = "policy lint reported " + ", ".join(
425
+ f"{counts[s]} {s}" + ("s" if counts[s] != 1 else "") for s in at_or_above
426
+ )
427
+ raise CliError(reason, result=result)
428
+ return result
429
+ return _guarded(args, op)
430
+
431
+
432
+ def _findings(value: Any) -> list[dict[str, Any]]:
433
+ """Normalise q's (severity;issue;detail) findings table into a list of records."""
434
+ return [
435
+ {"severity": _text(r.get("severity")), "issue": _text(r.get("issue")), "detail": _text(r.get("detail"))}
436
+ for r in _records(value, ("severity", "issue", "detail"))
437
+ ]
438
+
439
+
440
+ def _counts(findings: list[dict[str, Any]]) -> dict[str, int]:
441
+ counts = {"error": 0, "warning": 0, "note": 0}
442
+ for finding in findings:
443
+ if finding.get("severity") in counts:
444
+ counts[finding["severity"]] += 1
445
+ return counts
446
+
447
+
448
+ def _text(value: Any) -> str:
449
+ if isinstance(value, bytes):
450
+ return value.decode()
451
+ if isinstance(value, list):
452
+ return "".join(v.decode() if isinstance(v, bytes) else str(v) for v in value)
453
+ return "" if value is None else str(value)
454
+
455
+
456
+ _ISO = (
457
+ "%Y-%m-%dT%H:%M:%S.%f", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M",
458
+ "%Y-%m-%d %H:%M:%S.%f", "%Y-%m-%d %H:%M:%S", "%Y-%m-%d",
459
+ )
460
+
461
+
462
+ def _as_datetime(text: str) -> Any:
463
+ for fmt in _ISO:
464
+ try:
465
+ return datetime.datetime.strptime(text, fmt)
466
+ except ValueError:
467
+ continue
468
+ return None
469
+
470
+
471
+ def _ctx(raw: str | None) -> dict[str, Any] | None:
472
+ """Read a declared context from JSON, '-' or @FILE. Returns None when none was declared."""
473
+ if raw is None:
474
+ return None
475
+ text = _read_arg(raw, "context")
476
+ try:
477
+ value = json.loads(text)
478
+ except json.JSONDecodeError as exc:
479
+ raise UsageError(f"context is not valid JSON: {exc}") from exc
480
+ if not isinstance(value, dict):
481
+ raise UsageError("context must be a JSON object of axis names to values")
482
+ return value
483
+
484
+
485
+ def _to_q(kx: Any, key: str, value: Any) -> Any:
486
+ """Map one JSON context value onto a q type.
487
+
488
+ JSON has no timestamp and no symbol, and the seam requires an obligation to carry the SAME q type the
489
+ caller declared — so this mapping is part of the CLI's contract, not an implementation detail:
490
+
491
+ * a string that parses as ISO-8601 becomes a q timestamp, any other string becomes a symbol
492
+ * an integer becomes a long, a real becomes a float, a boolean becomes a boolean
493
+ * an array becomes a vector of whatever its elements map to, and must be homogeneous
494
+ * null and nested objects are refused: an axis carries a value or a list of them, not a structure
495
+ """
496
+ if isinstance(value, bool):
497
+ return kx.BooleanAtom(value)
498
+ if isinstance(value, int):
499
+ return kx.LongAtom(value)
500
+ if isinstance(value, float):
501
+ return kx.FloatAtom(value)
502
+ if isinstance(value, str):
503
+ stamp = _as_datetime(value)
504
+ return kx.TimestampAtom(stamp) if stamp is not None else kx.SymbolAtom(value)
505
+ if isinstance(value, list):
506
+ if not value:
507
+ raise UsageError(f"context axis '{key}' is an empty list; omit the axis instead")
508
+ kinds = {type(v) is bool or isinstance(v, (int, float, str)) for v in value}
509
+ if kinds != {True}:
510
+ raise UsageError(f"context axis '{key}' may only list strings, numbers or booleans")
511
+ if len({type(v) for v in value}) > 1:
512
+ raise UsageError(f"context axis '{key}' mixes value types; a declared axis is homogeneous")
513
+ first = value[0]
514
+ if isinstance(first, str):
515
+ if _as_datetime(first) is not None:
516
+ return kx.TimestampVector([_as_datetime(v) for v in value])
517
+ return kx.SymbolVector(value)
518
+ if isinstance(first, bool):
519
+ return kx.BooleanVector(value)
520
+ if isinstance(first, int):
521
+ return kx.LongVector(value)
522
+ return kx.FloatVector(value)
523
+ raise UsageError(f"context axis '{key}' must be a value or a list of values, not {type(value).__name__}")
524
+
525
+
526
+ def _decision(args: argparse.Namespace, explain: bool) -> int:
527
+ def op(transport):
528
+ action, resource = _parse_pair(args.action, args.resource)
529
+ principal = _principal(args.principal)
530
+ context = _ctx(getattr(args, "ctx", None))
531
+ extra = list(getattr(args, "resources", None) or [])
532
+
533
+ # Extra resources or a declared context make this a question only the SEAM can answer, because
534
+ # only the seam sees narrowing. Without them it stays the grant-table question it has always been,
535
+ # answered by the engine on exactly today's route — so the dominant case is untouched, and
536
+ # `kx rbac check` still works against a host running kx.rbac on its own.
537
+ if context is not None or extra:
538
+ resources = ([] if resource is None else [resource]) + extra
539
+ if not resources:
540
+ raise UsageError("a wildcard resource cannot be combined with --resource or --ctx")
541
+ decision = transport.call("scope", {
542
+ "action": action, "resources": resources,
543
+ "principal": principal, "context": context or {},
544
+ })
545
+ decision = qbridge.readable(decision)
546
+ if not isinstance(decision, dict):
547
+ raise CliError(f"the seam returned an unexpected decision shape: {decision!r}")
548
+ allowed = _as_allowed(decision.get("allowed"))
549
+ public = {
550
+ "allowed": allowed,
551
+ "action": _wild(action),
552
+ "resources": resources,
553
+ "obligations": decision.get("obligations") or {},
554
+ "declared": sorted(context or {}),
555
+ }
556
+ for key in ("reason", "denial"):
557
+ if _text(decision.get(key)):
558
+ public[key] = _text(decision[key])
559
+ if not allowed:
560
+ raise Denied(result=public)
561
+ return public
562
+
563
+ result = transport.call(
564
+ "explain" if explain else "check",
565
+ {"action": action, "resource": resource, "principal": principal},
566
+ )
567
+ if explain:
568
+ result = qbridge.readable(result)
569
+ if not isinstance(result, dict):
570
+ raise CliError(f"the seam returned an unexpected explain shape: {result!r}")
571
+ if not _as_allowed(result.get("allowed")):
572
+ raise Denied(result=result)
573
+ return result
574
+ allowed = _as_allowed(result)
575
+ public = {"allowed": allowed, "action": _wild(action), "resource": _wild(resource)}
576
+ if not allowed:
577
+ raise Denied(result=public)
578
+ return public
579
+ return _guarded(args, op)
580
+
581
+
582
+ def run_check(args: argparse.Namespace) -> int:
583
+ return _decision(args, False)
584
+
585
+
586
+ def run_explain(args: argparse.Namespace) -> int:
587
+ return _decision(args, True)
588
+
589
+
590
+ def _single_mutation(args: argparse.Namespace, verb: str) -> int:
591
+ def op(transport):
592
+ action, resource = _parse_pair(args.action, args.resource)
593
+ result = transport.call("apply", {
594
+ "operations": [{"op": verb, "group": args.group, "action": action, "resource": resource}],
595
+ "dry_run": False,
596
+ })
597
+ public = _transaction_public(result)
598
+ if isinstance(public, dict):
599
+ changed = bool(public.get("changed"))
600
+ public["acknowledgement"] = (
601
+ ("grant removed" if changed else "grant did not exist")
602
+ if verb == "revoke"
603
+ else ("grant added" if changed else "grant already existed")
604
+ )
605
+ return public
606
+ return _guarded(args, op)
607
+
608
+
609
+ def run_grant(args: argparse.Namespace) -> int:
610
+ return _single_mutation(args, "grant")
611
+
612
+
613
+ def run_revoke(args: argparse.Namespace) -> int:
614
+ return _single_mutation(args, "revoke")
615
+
616
+
617
+ def _normal_row(row: dict[Any, Any], *, operation: bool, csv_mode: bool = False) -> dict[str, Any]:
618
+ accepted = {"group", "grp", "action", "act", "resource", "res"}
619
+ if operation:
620
+ accepted.add("op")
621
+ if None in row:
622
+ raise UsageError("CSV row has more fields than its header")
623
+ extra = set(row) - accepted
624
+ if extra:
625
+ raise UsageError(f"unexpected import field(s): {', '.join(sorted(str(key) for key in extra))}")
626
+ for public, q_name in (("group", "grp"), ("action", "act"), ("resource", "res")):
627
+ if public not in row and q_name not in row:
628
+ raise UsageError(f"each row requires a {public} field")
629
+ group = row.get("group", row.get("grp"))
630
+ action = row.get("action", row.get("act"))
631
+ resource = row.get("resource", row.get("res"))
632
+ if not isinstance(group, str) or not group or group == "*":
633
+ raise UsageError("each row requires a non-wildcard string group")
634
+ if csv_mode and any(value is None or value == "" for value in (action, resource)):
635
+ raise UsageError("CSV action and resource cells must be non-empty; use literal '*' for a wildcard")
636
+ result = {
637
+ "group": group,
638
+ "action": None if action in (None, "", "*") else action,
639
+ "resource": None if resource in (None, "", "*") else resource,
640
+ }
641
+ if not all(value is None or isinstance(value, str) for value in (result["action"], result["resource"])):
642
+ raise UsageError("action and resource must be strings, '*' or JSON null")
643
+ if operation:
644
+ verb = row.get("op")
645
+ if verb not in ("grant", "revoke"):
646
+ raise UsageError("each operation row requires op 'grant' or 'revoke'")
647
+ result["op"] = verb
648
+ return result
649
+
650
+
651
+ def _load_import(path: Path) -> tuple[str, list[dict[str, Any]]]:
652
+ try:
653
+ if path.suffix.lower() == ".csv":
654
+ with path.open(newline="", encoding="utf-8") as fh:
655
+ reader = csv.DictReader(fh)
656
+ rows = list(reader)
657
+ operation = "op" in (reader.fieldnames or ())
658
+ kind = "operations" if operation else "grants"
659
+ return kind, [_normal_row(row, operation=operation, csv_mode=True) for row in rows]
660
+ value = json.loads(path.read_text(encoding="utf-8"))
661
+ except (OSError, csv.Error, json.JSONDecodeError) as exc:
662
+ raise UsageError(f"cannot read import file: {exc}") from exc
663
+ if not isinstance(value, dict):
664
+ raise UsageError("JSON import must be an object containing 'operations' or 'grants'")
665
+ keys = [key for key in ("operations", "grants") if key in value]
666
+ if len(keys) != 1 or not isinstance(value[keys[0]], list):
667
+ raise UsageError("JSON import must contain exactly one array: 'operations' or 'grants'")
668
+ extra = set(value) - {keys[0]}
669
+ if extra:
670
+ raise UsageError(f"unexpected import field(s): {', '.join(sorted(extra))}")
671
+ kind = keys[0]
672
+ rows = [_normal_row(row, operation=kind == "operations") for row in value[kind] if isinstance(row, dict)]
673
+ if len(rows) != len(value[kind]):
674
+ raise UsageError(f"every {kind} entry must be an object")
675
+ return kind, rows
676
+
677
+
678
+ def _transaction_public(result: Any) -> Any:
679
+ result = qbridge.readable(result)
680
+ if not isinstance(result, dict):
681
+ return result
682
+ names = {
683
+ "dryRun": "dry_run", "beforeCount": "before_count",
684
+ "afterCount": "after_count", "wouldPersist": "would_persist",
685
+ }
686
+ out = {names.get(key, key): value for key, value in result.items()}
687
+ for key in ("added", "removed"):
688
+ if key in out:
689
+ out[key] = _public_rows(out[key])
690
+ if "findings" in out:
691
+ try:
692
+ out["findings"] = _records(out["findings"], ("severity", "issue", "detail"))
693
+ except CliError:
694
+ pass
695
+ return out
696
+
697
+
698
+ def run_import(args: argparse.Namespace) -> int:
699
+ def op(transport):
700
+ kind, rows = _load_import(args.file)
701
+ if args.replace != (kind == "grants"):
702
+ if kind == "grants":
703
+ raise UsageError("a grants snapshot requires --replace")
704
+ raise UsageError("--replace accepts a grants snapshot, not operations")
705
+ result = transport.call("replace" if args.replace else "apply", {
706
+ kind: rows, "dry_run": args.dry_run,
707
+ })
708
+ return _transaction_public(result)
709
+ return _guarded(args, op)
710
+
711
+
712
+ def _atomic_text(path: Path, text: str) -> None:
713
+ # An export is a portable snapshot of the PUBLIC grant set, so it takes the process umask rather
714
+ # than the private mode the login cache and a promoted principal use.
715
+ try:
716
+ atomic.write_text(path, text)
717
+ except OSError as exc:
718
+ raise CliError(f"cannot write export: {exc}") from exc
719
+
720
+
721
+ def run_export(args: argparse.Namespace) -> int:
722
+ def op(transport):
723
+ rows = _public_rows(transport.call("grants"))
724
+ fmt = args.format or ("csv" if args.file.suffix.lower() == ".csv" else "json")
725
+ if fmt == "json":
726
+ text = json.dumps({"grants": rows}, indent=2) + "\n"
727
+ else:
728
+ stream = io.StringIO(newline="")
729
+ writer = csv.DictWriter(stream, fieldnames=("group", "action", "resource"))
730
+ writer.writeheader()
731
+ writer.writerows(rows)
732
+ text = stream.getvalue()
733
+ _atomic_text(args.file, text)
734
+ return {"file": str(args.file), "format": fmt, "count": len(rows)}
735
+ return _guarded(args, op)
736
+
737
+
738
+ def _persistence(args: argparse.Namespace, verb: str) -> int:
739
+ """Run `save` or `load` and name what each one actually returns.
740
+
741
+ q hands back a store path from `save` and an installed grant count from `load`, so the envelope
742
+ names each rather than nesting a second "result" under the envelope's own.
743
+ """
744
+ def op(transport):
745
+ value = qbridge.readable(transport.call(verb))
746
+ if verb == "save":
747
+ # A q filehandle symbol carries a leading colon. Report the filesystem path, as `export` does.
748
+ return {"path": str(value).lstrip(":")}
749
+ return {"grants": int(value)}
750
+
751
+ return _guarded(args, op)
752
+
753
+
754
+ def run_save(args: argparse.Namespace) -> int:
755
+ return _persistence(args, "save")
756
+
757
+
758
+ def run_load(args: argparse.Namespace) -> int:
759
+ return _persistence(args, "load")