secflow-cli 0.1.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.
secflow_cli/main.py ADDED
@@ -0,0 +1,404 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ from collections.abc import Mapping
5
+ import getpass
6
+ from importlib.metadata import PackageNotFoundError, version as distribution_version
7
+ import json
8
+ import math
9
+ import os
10
+ import sys
11
+ from urllib.error import HTTPError, URLError
12
+ from urllib.request import HTTPRedirectHandler, Request, build_opener
13
+
14
+ from secflow_sdk import SecFlowAdminClient, SecFlowConfigError, SecFlowHttpError, SecFlowSdkError
15
+
16
+ from secflow_cli.config import Profiles, credential_ref, endpoint, profile_name
17
+ from secflow_cli.credentials import CredentialStore
18
+ from secflow_cli.errors import CliError
19
+ from secflow_cli import account_commands, role_commands, namespace_commands, access_profile_commands
20
+
21
+
22
+ class Parser(argparse.ArgumentParser):
23
+ def error(self, message):
24
+ # argparse includes raw invalid values in its default error messages.
25
+ raise CliError(2, "INVALID_INPUT", "Invalid or missing arguments. Use --help for command syntax.")
26
+
27
+
28
+ def parser():
29
+ result = Parser(prog="secflowctl", description="SecFlow operator CLI (independent of the service CLI).")
30
+ try:
31
+ version = distribution_version("secflow-cli")
32
+ except PackageNotFoundError:
33
+ version = "development"
34
+ result.add_argument("--version", action="version", version=f"secflowctl {version}")
35
+ result.add_argument("--profile")
36
+ result.add_argument("--base-url")
37
+ result.add_argument("--output", choices=("table", "json", "jsonl"))
38
+ result.add_argument("--non-interactive", action="store_true")
39
+ result.add_argument("--timeout", type=float, default=5.0)
40
+ groups = result.add_subparsers(dest="group", required=True)
41
+ login = groups.add_parser("login")
42
+ login.add_argument("--no-browser", action="store_true", help="Print the login URL without opening a browser.")
43
+ login.set_defaults(action="login", browser=True, ephemeral=False, token_stdin=False)
44
+ profiles = groups.add_parser("profile").add_subparsers(dest="action", required=True)
45
+ for action in ("add", "list", "show", "use", "remove"):
46
+ command = profiles.add_parser(action)
47
+ if action in ("add", "use", "remove"):
48
+ command.add_argument("name")
49
+ if action == "show":
50
+ command.add_argument("name", nargs="?")
51
+ if action == "add":
52
+ command.add_argument("--base-url", required=True)
53
+ auth = groups.add_parser("auth").add_subparsers(dest="action", required=True)
54
+ for action in ("login", "status", "whoami", "logout", "forget"):
55
+ command = auth.add_parser(action)
56
+ if action != "forget":
57
+ command.add_argument("--token-stdin", action="store_true")
58
+ if action == "login":
59
+ command.add_argument("--ephemeral", action="store_true")
60
+ command.add_argument("--browser", action="store_true", help="Log in through Intflow in a browser.")
61
+ command.add_argument("--no-browser", action="store_true", help="With --browser, print the URL without opening it.")
62
+ groups.add_parser("health")
63
+ access_profile_commands.add_commands(groups)
64
+ for resource in ("namespaces", "accounts", "tokens", "roles"):
65
+ commands = groups.add_parser(resource).add_subparsers(dest="action", required=True)
66
+ listing = commands.add_parser("list")
67
+ if resource != "roles":
68
+ listing.add_argument("--query")
69
+ listing.add_argument("--page-size", type=int, default=50)
70
+ listing.add_argument("--page-token")
71
+ listing.add_argument("--all", action="store_true")
72
+ listing.add_argument("--max-pages", type=int, default=100)
73
+ listing.add_argument("--token-stdin", action="store_true")
74
+ if resource == "tokens":
75
+ listing.add_argument("--namespace")
76
+ listing.add_argument("--status", choices=("active", "revoked", "expired"))
77
+ if resource == "accounts":
78
+ account_commands.add_commands(commands)
79
+ if resource == "roles":
80
+ role_commands.add_commands(commands)
81
+ if resource == "namespaces":
82
+ namespace_commands.add_commands(commands)
83
+ from secflow_cli.help import enrich
84
+ enrich(result)
85
+ return result
86
+
87
+
88
+ def safe_fields(value, fields):
89
+ if not isinstance(value, Mapping):
90
+ raise CliError(7, "INVALID_RESPONSE", "The server returned an invalid metadata response.")
91
+ return {key: value[key] for key in fields if key in value and (value[key] is None or isinstance(value[key], (str, bool, int)))}
92
+
93
+
94
+ ACCOUNT_FIELDS = ("account_id", "email", "domain", "display_name", "status", "source", "created_at", "last_login_at")
95
+ NAMESPACE_FIELDS = ("namespace", "environment", "service", "owner", "administrator", "audit_logging", "status")
96
+ TOKEN_FIELDS = ("token_id", "account_id", "scope_type", "scope", "issued_at", "expires_at", "revoked_at", "last_used_at", "status")
97
+ ROLE_FIELDS = role_commands.FIELDS
98
+
99
+
100
+ def account_metadata(value):
101
+ result = safe_fields(value, ACCOUNT_FIELDS)
102
+ roles = value.get("roles")
103
+ if isinstance(roles, list):
104
+ result["roles"] = [role for role in roles if role in ("org_admin", "namespace_admin", "secret_writer", "secret_reader")]
105
+ return result
106
+
107
+
108
+ def envelope(command, data=None, *, cursor=None, error=None):
109
+ return {"schema_version": 1, "command": command, "data": data, "request_id": None, "trace_id": None,
110
+ "pagination": {"next_page_token": cursor},
111
+ "error": {"code": error.code, "message": error.message} if error else None}
112
+
113
+
114
+ def render(record, output):
115
+ if output in ("json", "jsonl"):
116
+ print(json.dumps(record, ensure_ascii=True, separators=(",", ":")))
117
+ return
118
+ data = record["data"]
119
+ rows = data if isinstance(data, list) else [data] if data is not None else []
120
+ if not rows and not record["error"]:
121
+ print("No results.")
122
+ for row in rows:
123
+ if isinstance(row, dict):
124
+ print(" ".join(f"{key}: {json.dumps(value, ensure_ascii=True) if isinstance(value, (dict, list)) else _terminal(value)}" for key, value in row.items()))
125
+ else:
126
+ print(_terminal(row))
127
+ if record["pagination"]["next_page_token"]:
128
+ print("More results available; use --page-token or --all.", file=sys.stderr)
129
+
130
+
131
+ def _terminal(value):
132
+ return "".join(c if c.isprintable() else "?" for c in str(value))
133
+
134
+
135
+ def mapped_error(error):
136
+ if isinstance(error, CliError):
137
+ return error
138
+ if isinstance(error, SecFlowHttpError):
139
+ status, code, message = {
140
+ 401: (3, "AUTHENTICATION_FAILED", "The server rejected the Admin session. Sign in again with secflowctl login, or import a new Slack session with secflowctl auth login."),
141
+ 403: (4, "ACCESS_DENIED", "The server denied this operation. Check secflowctl auth status and ask your administrator to review roles and scope."),
142
+ 404: (5, "NOT_FOUND", "The requested resource was not found."),
143
+ 409: (6, "CONFLICT", "The server reported a conflict."),
144
+ 412: (6, "PRECONDITION_CHANGED", "The server precondition changed."),
145
+ 400: (2, "INVALID_INPUT", "The server rejected the request inputs."),
146
+ 422: (2, "INVALID_INPUT", "The server rejected the request inputs."),
147
+ }.get(error.status_code, (7, "DEPENDENCY_FAILED", "The server request could not be completed."))
148
+ return CliError(status, code, message)
149
+ if isinstance(error, SecFlowConfigError):
150
+ return CliError(2, "INVALID_INPUT", "The client configuration or request inputs are invalid.")
151
+ if isinstance(error, (SecFlowSdkError, URLError, TimeoutError)):
152
+ return CliError(7, "DEPENDENCY_FAILED", "The server request could not be completed.")
153
+ return CliError(1, "LOCAL_FAILURE", "The CLI could not complete this operation.")
154
+
155
+
156
+ def read_token(args, *, prompt=False):
157
+ if getattr(args, "token_stdin", False):
158
+ value = sys.stdin.read(16385)
159
+ if len(value) > 16384:
160
+ raise CliError(2, "INVALID_CREDENTIAL_INPUT", "Credential input exceeds the supported size.")
161
+ value = value.rstrip("\r\n")
162
+ elif os.environ.get("SECFLOW_ADMIN_SESSION_TOKEN"):
163
+ value = os.environ["SECFLOW_ADMIN_SESSION_TOKEN"]
164
+ elif prompt and not args.non_interactive and sys.stdin.isatty():
165
+ value = getpass.getpass("Admin session token: ")
166
+ else:
167
+ return None
168
+ if not value or any(c.isspace() or ord(c) < 33 or ord(c) > 126 for c in value):
169
+ raise CliError(2, "INVALID_CREDENTIAL_INPUT", "Provide one non-empty Admin session token.")
170
+ return value
171
+
172
+
173
+ def resolve_client(args, profile, url, store):
174
+ token = read_token(args)
175
+ if token is None:
176
+ if url != profile["base_url"]:
177
+ raise CliError(3, "CREDENTIAL_ORIGIN_MISMATCH", "An endpoint override cannot reuse credentials from another origin. Supply an explicit Admin session.")
178
+ try:
179
+ token = store.get(profile["credential_ref"])
180
+ except CliError as error:
181
+ if error.code == "CREDENTIAL_STORE_UNAVAILABLE":
182
+ raise CliError(3, "AUTHENTICATION_REQUIRED", "Run secflowctl login, or supply an Admin session through --token-stdin, SECFLOW_ADMIN_SESSION_TOKEN, or secflowctl auth login.") from None
183
+ raise
184
+ if not token:
185
+ raise CliError(3, "AUTHENTICATION_REQUIRED", "Run secflowctl login, or supply an Admin session through --token-stdin, SECFLOW_ADMIN_SESSION_TOKEN, or secflowctl auth login.")
186
+ return SecFlowAdminClient(url, admin_session_token=token, timeout=args.timeout)
187
+
188
+
189
+ def profile_command(args, profiles):
190
+ entries = profiles.data["profiles"]
191
+ if args.action == "list":
192
+ return [{"name": name, "base_url": item["base_url"], "current": name == profiles.data["current"]} for name, item in sorted(entries.items())]
193
+ if args.action == "show":
194
+ name, item = profiles.select(args.name or args.profile)
195
+ return {"name": name, "base_url": item["base_url"], "current": name == profiles.data["current"]}
196
+ name = profile_name(args.name)
197
+ if args.action == "add":
198
+ if name in entries:
199
+ raise CliError(6, "PROFILE_EXISTS", "The profile already exists; use a new profile name.")
200
+ url = endpoint(args.base_url)
201
+ entries[name] = {"base_url": url, "credential_ref": credential_ref(name, url)}
202
+ if profiles.data["current"] is None:
203
+ profiles.data["current"] = name
204
+ else:
205
+ if name not in entries:
206
+ raise CliError(5, "PROFILE_NOT_FOUND", "The profile does not exist.")
207
+ if args.action == "use":
208
+ profiles.data["current"] = name
209
+ else:
210
+ # Local configuration removal does not revoke a server session.
211
+ CredentialStore(allow_interaction=not args.non_interactive and sys.stdin.isatty()).delete(entries[name]["credential_ref"])
212
+ del entries[name]
213
+ if profiles.data["current"] == name:
214
+ profiles.data["current"] = None
215
+ profiles.save()
216
+ return {"name": name, "action": args.action}
217
+
218
+
219
+ class NoRedirect(HTTPRedirectHandler):
220
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
221
+ return None
222
+
223
+
224
+ def health(url, timeout):
225
+ result = {}
226
+ opener = build_opener(NoRedirect)
227
+ for path in ("healthz", "readyz"):
228
+ try:
229
+ with opener.open(Request(f"{url}/{path}", headers={"Accept": "application/json"}), timeout=timeout) as response:
230
+ raw = response.read(65537)
231
+ if len(raw) > 65536:
232
+ raise ValueError
233
+ value = json.loads(raw)
234
+ status = value.get("status") if isinstance(value, dict) else None
235
+ result[path] = {"status": status if status in ("ok", "ready", "not_ready") else "unknown"}
236
+ except (HTTPError, URLError, OSError, ValueError):
237
+ result[path] = {"status": "unavailable"}
238
+ ok = result["healthz"]["status"] == "ok" and result["readyz"]["status"] == "ready"
239
+ return result, None if ok else CliError(7, "UNHEALTHY", "Health or readiness did not succeed.")
240
+
241
+
242
+ def collection_pages(args, client, command, output):
243
+ if not 1 <= args.page_size <= 200 or not 1 <= args.max_pages <= 1000:
244
+ raise CliError(2, "INVALID_INPUT", "Page size must be 1-200 and max pages must be 1-1000.")
245
+ rows, cursor, seen = [], args.page_token, set()
246
+ if cursor:
247
+ seen.add(cursor)
248
+ fetch = {"namespaces": client.list_namespaces, "accounts": client.list_accounts, "tokens": client.list_sdk_tokens, "roles": client.list_role_bindings}[args.group]
249
+ filters = {"namespace": args.namespace, "status": args.status} if args.group == "tokens" else {}
250
+ if args.group != "roles":
251
+ filters["q"] = args.query
252
+ for index in range(args.max_pages if args.all else 1):
253
+ try:
254
+ page = fetch(page_token=cursor, page_size=args.page_size, **filters)
255
+ items = page.get("items")
256
+ if not isinstance(items, list):
257
+ raise CliError(7, "INVALID_RESPONSE", "The server returned an invalid page.")
258
+ fields = {"namespaces": NAMESPACE_FIELDS, "tokens": TOKEN_FIELDS, "roles": ROLE_FIELDS}
259
+ page_rows = [account_metadata(item) if args.group == "accounts" else safe_fields(item, fields[args.group]) for item in items]
260
+ next_cursor = page.get("next_page_token")
261
+ if next_cursor is not None and (not isinstance(next_cursor, str) or not next_cursor or len(next_cursor) > 8192):
262
+ raise CliError(7, "INVALID_RESPONSE", "The server returned an invalid page cursor.")
263
+ rows.extend(page_rows)
264
+ cursor = next_cursor
265
+ if output == "jsonl":
266
+ render(envelope(command, page_rows, cursor=cursor), output)
267
+ if not args.all or cursor is None:
268
+ if output != "jsonl":
269
+ render(envelope(command, rows, cursor=cursor), output)
270
+ return 0
271
+ if cursor in seen:
272
+ raise CliError(8, "PAGINATION_INCOMPLETE", "The server repeated a page cursor; results are incomplete.")
273
+ seen.add(cursor)
274
+ except (CliError, SecFlowSdkError) as caught:
275
+ error = mapped_error(caught)
276
+ if rows:
277
+ error = CliError(8, "PAGINATION_INCOMPLETE", "Page retrieval stopped; results are incomplete.")
278
+ render(envelope(command, [] if output == "jsonl" else rows, cursor=cursor, error=error), output)
279
+ print(error.message, file=sys.stderr)
280
+ return error.status
281
+ error = CliError(8, "PAGE_BOUND_REACHED", "The page bound was reached; use the next page token to continue.")
282
+ render(envelope(command, [] if output == "jsonl" else rows, cursor=cursor, error=error), output)
283
+ print(error.message, file=sys.stderr)
284
+ return 8
285
+
286
+
287
+ def main(argv=None):
288
+ output = "table" if sys.stdout.isatty() else "json"
289
+ command = "cli"
290
+ try:
291
+ arguments = list(sys.argv[1:] if argv is None else argv)
292
+ root = parser()
293
+ if not arguments:
294
+ root.print_help()
295
+ return 0
296
+ args = root.parse_args(arguments)
297
+ quick_login = args.group == "login"
298
+ if quick_login:
299
+ if args.non_interactive or not sys.stdin.isatty():
300
+ raise CliError(2, "INTERACTIVE_LOGIN_REQUIRED", "Use an interactive terminal for login; automation can use auth login --token-stdin.")
301
+ args.group = "auth"
302
+ output = args.output or output
303
+ command = args.group + (" " + args.action if hasattr(args, "action") else "")
304
+ if not math.isfinite(args.timeout) or not 0 < args.timeout <= 300:
305
+ raise CliError(2, "INVALID_INPUT", "Timeout must be greater than zero and at most 300 seconds.")
306
+ if output == "jsonl" and command not in ("profile list", "namespaces list", "accounts list", "tokens list", "roles list"):
307
+ raise CliError(2, "INVALID_INPUT", "JSONL output is available for collection commands only.")
308
+ profiles = Profiles()
309
+ if quick_login and not profiles.data["profiles"] and not args.profile and not os.environ.get("SECFLOW_PROFILE"):
310
+ url = endpoint(args.base_url or os.environ.get("SECFLOW_BASE_URL") or "https://secflow.intflow.dev")
311
+ profiles.data["profiles"]["production"] = {"base_url": url, "credential_ref": credential_ref("production", url)}
312
+ profiles.data["current"] = "production"
313
+ profiles.save()
314
+ error = None
315
+ if args.group == "profile":
316
+ data = profile_command(args, profiles)
317
+ else:
318
+ name, profile = profiles.select(args.profile)
319
+ url = endpoint(args.base_url or os.environ.get("SECFLOW_BASE_URL") or profile["base_url"])
320
+ store = CredentialStore(allow_interaction=not args.non_interactive and sys.stdin.isatty())
321
+ if args.group == "health":
322
+ data, error = health(url, args.timeout)
323
+ elif args.group == "auth" and args.action == "login":
324
+ if url != profile["base_url"]:
325
+ raise CliError(2, "PROFILE_ORIGIN_MISMATCH", "Create a profile for the login server origin.")
326
+ if args.no_browser and not args.browser:
327
+ raise CliError(2, "INVALID_INPUT", "--no-browser requires --browser.")
328
+ if args.browser:
329
+ if args.non_interactive or not sys.stdin.isatty() or args.ephemeral or args.token_stdin:
330
+ raise CliError(2, "INTERACTIVE_LOGIN_REQUIRED", "Browser login requires an interactive terminal and OS credential storage; omit --token-stdin and --ephemeral.")
331
+ from secflow_cli.browser_login import login
332
+ account, expires_at = login(url, args.timeout, store, profile["credential_ref"], no_browser=args.no_browser)
333
+ render(envelope(command, {"account": account_metadata(account), "credential_storage": "os-store", "expires_at": expires_at}), output)
334
+ from secflow_cli.help import login_hint
335
+ login_hint(args, account)
336
+ return 0
337
+ if not args.ephemeral:
338
+ store.check()
339
+ token = read_token(args, prompt=True)
340
+ if token is None:
341
+ raise CliError(3, "AUTHENTICATION_REQUIRED", "Provide --token-stdin or SECFLOW_ADMIN_SESSION_TOKEN; interactive login accepts a hidden prompt.")
342
+ client = SecFlowAdminClient(url, admin_session_token=token, timeout=args.timeout)
343
+ account = account_metadata(client.get_current_account())
344
+ if not args.ephemeral:
345
+ store.set(profile["credential_ref"], token)
346
+ data = {"account": account, "credential_storage": "ephemeral" if args.ephemeral else "os-store"}
347
+ if args.ephemeral:
348
+ print("Session validated for this invocation only. Supply the credential again for subsequent commands.", file=sys.stderr)
349
+ elif args.group == "auth" and args.action == "forget":
350
+ store.delete(profile["credential_ref"])
351
+ data = {"local_credential_cleared": True, "server_revocation": "not_attempted"}
352
+ else:
353
+ account_mutation = args.group == "accounts" and args.action in account_commands.ACTIONS
354
+ role_mutation = args.group == "roles" and args.action in role_commands.ACTIONS
355
+ namespace_mutation = args.group == "namespaces" and args.action in namespace_commands.ACTIONS
356
+ access_profile_mutation = args.group == "access-profile" and args.action == "set"
357
+ mutation_module = account_commands if account_mutation else role_commands if role_mutation else namespace_commands if namespace_mutation else access_profile_commands if access_profile_mutation else None
358
+ mutation_values = mutation_module.prepare(args) if mutation_module else None
359
+ client = resolve_client(args, profile, url, store)
360
+ if access_profile_mutation:
361
+ data, error = access_profile_commands.execute(client, mutation_values, mapped_error=mapped_error)
362
+ elif args.group == "access-profile":
363
+ data = access_profile_commands.metadata(client.get_default_access_profile())
364
+ elif account_mutation:
365
+ data, error = account_commands.execute(args, client, mutation_values, account_metadata=account_metadata, mapped_error=mapped_error)
366
+ elif role_mutation:
367
+ data, error = role_commands.execute(args, client, mutation_values, safe_fields=safe_fields, mapped_error=mapped_error)
368
+ elif namespace_mutation:
369
+ data, error = namespace_commands.execute(args, client, mutation_values, safe_fields=safe_fields, mapped_error=mapped_error)
370
+ elif args.group in ("namespaces", "accounts", "tokens", "roles"):
371
+ return collection_pages(args, client, command, output)
372
+ elif args.action in ("status", "whoami"):
373
+ data = {"account": account_metadata(client.get_current_account()), "expires_at": None, "capabilities": None}
374
+ elif args.action == "logout":
375
+ if url != profile["base_url"]:
376
+ raise CliError(2, "PROFILE_ORIGIN_MISMATCH", "Logout must use the selected profile origin.")
377
+ error = None
378
+ try:
379
+ client.revoke_current_session()
380
+ except SecFlowSdkError as caught:
381
+ error = mapped_error(caught)
382
+ data = {"server_revocation": "failed_or_unknown" if error else "confirmed", "local_credential_cleared": False}
383
+ try:
384
+ store.delete(profile["credential_ref"])
385
+ data["local_credential_cleared"] = True
386
+ except CliError as caught:
387
+ error = error or caught
388
+ print("Environment and caller-managed stdin credentials are not erased by logout.", file=sys.stderr)
389
+ else:
390
+ raise CliError(2, "INVALID_INPUT", "Unsupported command.")
391
+ render(envelope(command, data, error=error), output)
392
+ if command == "auth login" and not error and not args.ephemeral:
393
+ from secflow_cli.help import login_hint
394
+ login_hint(args, account)
395
+ if error:
396
+ print(error.message, file=sys.stderr)
397
+ return error.status if error else 0
398
+ except KeyboardInterrupt:
399
+ error = CliError(130, "INTERRUPTED", "Operation interrupted; completion is not guaranteed.")
400
+ except Exception as caught:
401
+ error = mapped_error(caught)
402
+ render(envelope(command, error=error), output)
403
+ print(error.message, file=sys.stderr)
404
+ return error.status
@@ -0,0 +1,151 @@
1
+ """Namespace metadata operations using the existing Admin API."""
2
+ import re
3
+ import sys
4
+ from collections.abc import Mapping
5
+ from uuid import uuid4
6
+
7
+ from secflow_sdk import SecFlowConfigError, SecFlowSdkError
8
+ from secflow_cli.account_commands import required_input
9
+ from secflow_cli.errors import CliError
10
+
11
+ ACTIONS = ("create", "update", "delete")
12
+ FIELDS = ("namespace", "environment", "service", "owner", "administrator", "audit_logging", "status")
13
+
14
+
15
+ def add_commands(commands):
16
+ for action in ACTIONS:
17
+ command = commands.add_parser(action)
18
+ command.add_argument("--namespace")
19
+ command.add_argument("--token-stdin", action="store_true")
20
+ command.add_argument("--idempotency-key")
21
+ if action == "delete":
22
+ command.add_argument("--yes", action="store_true")
23
+ continue
24
+ command.add_argument("--owner")
25
+ command.add_argument("--administrator")
26
+ command.add_argument("--audit-logging", choices=("true", "false"))
27
+ command.add_argument("--max-secret-ttl-days", help="1-3660, or none for no maximum")
28
+ command.add_argument("--warn-before-days", type=int)
29
+ if action == "update":
30
+ command.add_argument("--clear-expiration-policy", action="store_true")
31
+
32
+
33
+ def prepare(args):
34
+ target = required_input(args, "namespace", "namespace")
35
+ if not re.fullmatch(r"(dev|stage|prod|shared)/[a-z0-9][a-z0-9._-]{1,62}", target):
36
+ raise CliError(2, "INVALID_INPUT", "Supply an exact namespace in environment/service form.")
37
+ key = args.idempotency_key or "cli-namespace-" + str(uuid4())
38
+ if not re.fullmatch(r"[A-Za-z0-9_.:-]{16,128}", key):
39
+ raise CliError(2, "INVALID_INPUT", "Supply a normalized idempotency key of 16-128 characters.")
40
+ environment, service = target.split("/")
41
+ values = dict(environment=environment, service=service, idempotency_key=key)
42
+ if args.action == "delete":
43
+ confirm_delete(args, target)
44
+ return values
45
+ if args.action == "update":
46
+ choose_update(args)
47
+ for field in ("owner", "administrator", "audit_logging"):
48
+ if args.action == "update" and getattr(args, field) is None:
49
+ continue
50
+ value = required_input(args, field, field.replace("_", " "))
51
+ if field == "audit_logging":
52
+ if value not in ("true", "false"):
53
+ raise CliError(2, "INVALID_INPUT", "Audit logging must be true or false.")
54
+ value = value == "true"
55
+ values[field] = value
56
+ ttl = args.max_secret_ttl_days
57
+ if ttl is None and environment == "prod" and args.action == "create":
58
+ ttl = required_input(args, "max_secret_ttl_days", "maximum secret TTL days (1-3660 or none)")
59
+ if ttl is not None:
60
+ if ttl != "none" and (not re.fullmatch(r"[0-9]{1,4}", ttl) or not 1 <= int(ttl) <= 3660):
61
+ raise CliError(2, "INVALID_INPUT", "Maximum secret TTL must be 1-3660 or none.")
62
+ values["expiration_policy"] = {"max_secret_ttl_days": None if ttl == "none" else int(ttl)}
63
+ if args.warn_before_days is not None:
64
+ if ttl in (None, "none") or not 1 <= args.warn_before_days <= 365:
65
+ raise CliError(2, "INVALID_INPUT", "Warning days require a finite maximum TTL and must be 1-365.")
66
+ values["expiration_policy"]["warn_before_days"] = args.warn_before_days
67
+ if getattr(args, "clear_expiration_policy", False):
68
+ if ttl is not None or args.warn_before_days is not None or environment == "prod":
69
+ raise CliError(2, "INVALID_INPUT", "Policy clearing cannot be combined with policy fields or used for production.")
70
+ values["expiration_policy"] = None
71
+ if environment == "prod" and values.get("audit_logging") is False:
72
+ raise CliError(2, "INVALID_INPUT", "Production namespaces require audit logging.")
73
+ return values
74
+
75
+
76
+ def confirm_delete(args, target):
77
+ print("Delete namespace: " + target, file=sys.stderr)
78
+ print("Operation semantics, not a server impact preview: removes the namespace and related secrets, SDK tokens, role bindings, and policies. Affected counts are unknown.", file=sys.stderr)
79
+ if args.yes:
80
+ return
81
+ if args.non_interactive or args.token_stdin or not sys.stdin.isatty():
82
+ raise CliError(2, "CONFIRMATION_REQUIRED", "Supply the exact namespace and --yes for unattended execution or when stdin carries a token.")
83
+ print("Type the namespace to confirm: ", end="", file=sys.stderr, flush=True)
84
+ try:
85
+ answer = input()
86
+ except EOFError:
87
+ answer = None
88
+ if answer != target:
89
+ raise CliError(2, "CONFIRMATION_CANCELLED", "Namespace deletion cancelled; no mutation was sent.")
90
+
91
+
92
+ def choose_update(args):
93
+ fields = ("owner", "administrator", "audit_logging", "max_secret_ttl_days", "warn_before_days")
94
+ if args.clear_expiration_policy or any(getattr(args, field) is not None for field in fields):
95
+ return
96
+ if args.non_interactive or args.token_stdin or not sys.stdin.isatty():
97
+ raise CliError(2, "INVALID_INPUT", "Supply at least one namespace field to update.")
98
+ args.update_field = None
99
+ field = required_input(args, "update_field", "field to update (owner, administrator, audit_logging, max_secret_ttl_days, clear_expiration_policy)")
100
+ if field == "clear_expiration_policy":
101
+ args.clear_expiration_policy = True
102
+ elif field in fields and field != "warn_before_days":
103
+ setattr(args, field, required_input(args, field, field.replace("_", " ")))
104
+ else:
105
+ raise CliError(2, "INVALID_INPUT", "Select a supported namespace field.")
106
+
107
+
108
+ def metadata(response, safe_fields):
109
+ result = safe_fields(response, FIELDS)
110
+ if "expiration_policy" in response:
111
+ policy = response["expiration_policy"]
112
+ if policy is None:
113
+ result["expiration_policy"] = None
114
+ elif isinstance(policy, Mapping):
115
+ result["expiration_policy"] = {key: policy[key] for key in ("max_secret_ttl_days", "warn_before_days") if key in policy and (policy[key] is None or type(policy[key]) is int)}
116
+ else:
117
+ raise CliError(7, "INVALID_RESPONSE", "The server returned invalid expiration metadata.")
118
+ return result
119
+
120
+
121
+ def execute(args, client, values, *, safe_fields, mapped_error):
122
+ key = values["idempotency_key"]
123
+ print("Idempotency key: " + key, file=sys.stderr)
124
+ data = dict(idempotency_key=key, outcome="unknown", namespace=None)
125
+ if args.action == "delete":
126
+ data["deletion"] = None
127
+ try:
128
+ target = values["environment"] + "/" + values["service"]
129
+ if args.action == "delete":
130
+ response = client.delete_namespace(**values)
131
+ if (not isinstance(response, Mapping) or response.get("kind") != "namespace"
132
+ or response.get("id") != target or response.get("label") != target or response.get("deleted") is not True):
133
+ raise CliError(7, "INVALID_RESPONSE", "The server did not confirm deletion of the selected namespace.")
134
+ data.update(outcome="confirmed", deletion={field: response[field] for field in ("kind", "id", "label", "deleted")})
135
+ return data, None
136
+ method = client.create_namespace if args.action == "create" else client.patch_namespace
137
+ result = metadata(method(**values), safe_fields)
138
+ if (result.get("namespace") != target or (args.action == "create" and result.get("status") != "active")
139
+ or any(result.get(field) != value for field, value in values.items() if field != "idempotency_key")):
140
+ raise CliError(7, "INVALID_RESPONSE", "The server did not confirm the requested namespace metadata.")
141
+ data.update(outcome="confirmed", namespace=result)
142
+ return data, None
143
+ except (SecFlowSdkError, CliError) as caught:
144
+ error = mapped_error(caught)
145
+ if isinstance(caught, SecFlowConfigError):
146
+ data["outcome"] = "not_sent"
147
+ elif error.status in (2, 3, 4, 5, 6):
148
+ data["outcome"] = "not_confirmed"
149
+ else:
150
+ error = CliError(7, "MUTATION_OUTCOME_UNKNOWN", "Mutation completion is unknown. Inspect current state; any retry must use identical inputs and the same idempotency key.")
151
+ return data, error
@@ -0,0 +1,99 @@
1
+ """Role mutations through the existing Admin API."""
2
+ import re
3
+ import sys
4
+ from uuid import UUID, uuid4
5
+
6
+ from secflow_sdk import SecFlowConfigError, SecFlowSdkError
7
+ from secflow_cli.account_commands import required_input
8
+ from secflow_cli.errors import CliError
9
+
10
+ ACTIONS = ("grant", "revoke")
11
+ FIELDS = ("binding_id", "principal_type", "principal_id", "role", "scope_type", "scope", "status", "created_at")
12
+ ROLES = ("org_admin", "namespace_admin", "secret_reader", "secret_writer")
13
+
14
+
15
+ def add_commands(commands):
16
+ command = commands.add_parser("grant")
17
+ command.add_argument("--token-stdin", action="store_true")
18
+ command.add_argument("--idempotency-key")
19
+ command.add_argument("--account-id")
20
+ command.add_argument("--role", choices=ROLES)
21
+ command.add_argument("--scope-type", choices=("org", "namespace"))
22
+ command.add_argument("--scope")
23
+ revoke = commands.add_parser("revoke")
24
+ revoke.add_argument("--token-stdin", action="store_true")
25
+ revoke.add_argument("--idempotency-key")
26
+ revoke.add_argument("--binding-id")
27
+ revoke.add_argument("--yes", action="store_true")
28
+
29
+
30
+ def canonical_id(value):
31
+ try:
32
+ return isinstance(value, str) and str(UUID(value)) == value
33
+ except ValueError:
34
+ return False
35
+
36
+
37
+ def prepare(args):
38
+ key = args.idempotency_key or "cli-role-" + str(uuid4())
39
+ if not re.fullmatch(r"[A-Za-z0-9_.:-]{16,128}", key):
40
+ raise CliError(2, "INVALID_INPUT", "Supply a normalized idempotency key of 16-128 characters.")
41
+ if args.action == "revoke":
42
+ binding_id = required_input(args, "binding_id", "binding ID")
43
+ if not canonical_id(binding_id):
44
+ raise CliError(2, "INVALID_INPUT", "Supply an exact canonical binding UUID.")
45
+ confirm_revoke(args, binding_id)
46
+ return dict(binding_id=binding_id, idempotency_key=key)
47
+ account = required_input(args, "account_id", "account ID")
48
+ role = required_input(args, "role", "role")
49
+ scope_type = required_input(args, "scope_type", "scope type")
50
+ expected = "org" if role in ("org_admin", "namespace_admin") else "namespace"
51
+ if not canonical_id(account) or role not in ROLES or scope_type != expected:
52
+ raise CliError(2, "INVALID_INPUT", "Supply a canonical account UUID and a supported role/scope combination.")
53
+ scope = required_input(args, "scope", "namespace") if scope_type == "namespace" else args.scope
54
+ if (scope_type == "org" and scope is not None) or (scope_type == "namespace" and not re.fullmatch(r"(dev|stage|prod|shared)/[a-z0-9][a-z0-9._-]{1,62}", scope)):
55
+ raise CliError(2, "INVALID_INPUT", "Supply a valid namespace only for namespace scope.")
56
+ return dict(idempotency_key=key, principal_type="account", principal_id=account, role=role, scope_type=scope_type, scope=scope)
57
+
58
+
59
+ def confirm_revoke(args, binding_id):
60
+ print("Revoke role binding: " + binding_id, file=sys.stderr)
61
+ print("Operation semantics, not a server impact preview: marks this binding inactive and invalidates affected Admin sessions. Other grants may retain access. Affected counts are unknown.", file=sys.stderr)
62
+ if args.yes:
63
+ return
64
+ if args.non_interactive or args.token_stdin or not sys.stdin.isatty():
65
+ raise CliError(2, "CONFIRMATION_REQUIRED", "Supply the exact binding ID and --yes for unattended execution or when stdin carries a token.")
66
+ print("Type the binding ID to confirm: ", end="", file=sys.stderr, flush=True)
67
+ try:
68
+ answer = input()
69
+ except EOFError:
70
+ answer = None
71
+ if answer != binding_id:
72
+ raise CliError(2, "CONFIRMATION_CANCELLED", "Role revocation cancelled; no mutation was sent.")
73
+
74
+
75
+ def execute(args, client, values, *, safe_fields, mapped_error):
76
+ key = values["idempotency_key"]
77
+ print("Idempotency key: " + key, file=sys.stderr)
78
+ data = dict(idempotency_key=key, outcome="unknown", binding=None)
79
+ try:
80
+ method = client.create_role_binding if args.action == "grant" else client.revoke_role_binding
81
+ binding = safe_fields(method(**values), FIELDS)
82
+ if args.action == "revoke":
83
+ confirmed = binding.get("binding_id") == values["binding_id"] and binding.get("status") == "inactive"
84
+ else:
85
+ confirmed = (canonical_id(binding.get("binding_id")) and binding.get("status") == "active"
86
+ and all(binding.get(field) == values[field] for field in ("principal_type", "principal_id", "role", "scope_type", "scope")))
87
+ if not confirmed:
88
+ raise CliError(7, "INVALID_RESPONSE", "The server did not confirm the requested role assignment.")
89
+ data.update(outcome="confirmed", binding=binding)
90
+ return data, None
91
+ except (SecFlowSdkError, CliError) as caught:
92
+ error = mapped_error(caught)
93
+ if isinstance(caught, SecFlowConfigError):
94
+ data["outcome"] = "not_sent"
95
+ elif error.status in (2, 3, 4, 5, 6):
96
+ data["outcome"] = "not_confirmed"
97
+ else:
98
+ error = CliError(7, "MUTATION_OUTCOME_UNKNOWN", "Mutation completion is unknown. Inspect current state; any retry must use identical inputs and the same idempotency key.")
99
+ return data, error