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/__init__.py +1 -0
- secflow_cli/__main__.py +3 -0
- secflow_cli/access_profile_commands.py +94 -0
- secflow_cli/account_commands.py +134 -0
- secflow_cli/browser_login.py +129 -0
- secflow_cli/config.py +106 -0
- secflow_cli/credentials.py +58 -0
- secflow_cli/errors.py +8 -0
- secflow_cli/help.py +100 -0
- secflow_cli/main.py +404 -0
- secflow_cli/namespace_commands.py +151 -0
- secflow_cli/role_commands.py +99 -0
- secflow_cli-0.1.0.dist-info/METADATA +64 -0
- secflow_cli-0.1.0.dist-info/RECORD +18 -0
- secflow_cli-0.1.0.dist-info/WHEEL +5 -0
- secflow_cli-0.1.0.dist-info/entry_points.txt +2 -0
- secflow_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- secflow_cli-0.1.0.dist-info/top_level.txt +1 -0
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
|