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.
@@ -0,0 +1 @@
1
+ """Independent operator CLI; no server imports or executable replacement."""
@@ -0,0 +1,3 @@
1
+ from secflow_cli.main import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,94 @@
1
+ """Default access profile metadata through the existing org-admin API."""
2
+ from collections.abc import Mapping
3
+ from uuid import UUID
4
+ from uuid import uuid4
5
+ import re
6
+ import sys
7
+
8
+ from secflow_sdk import SecFlowConfigError, SecFlowSdkError
9
+ from secflow_cli.account_commands import required_input
10
+ from secflow_cli.errors import CliError
11
+
12
+ FLAGS = ("allow_cli_login", "allow_api_session", "allow_namespace_request")
13
+ FIELDS = ("profile_id", "domain", "roles", *FLAGS, "allow_audit_read", "allow_plaintext_read")
14
+
15
+
16
+ def add_commands(groups):
17
+ commands = groups.add_parser("access-profile").add_subparsers(dest="action", required=True)
18
+ command = commands.add_parser("get")
19
+ command.add_argument("--token-stdin", action="store_true")
20
+ command = commands.add_parser("set")
21
+ command.add_argument("--profile-id")
22
+ for field in FLAGS:
23
+ command.add_argument("--" + field.replace("_", "-"), choices=("true", "false"))
24
+ command.add_argument("--token-stdin", action="store_true")
25
+ command.add_argument("--idempotency-key")
26
+ command.add_argument("--yes", action="store_true")
27
+
28
+
29
+ def prepare(args):
30
+ profile_id = required_input(args, "profile_id", "profile ID")
31
+ try:
32
+ if str(UUID(profile_id)) != profile_id:
33
+ raise ValueError
34
+ except ValueError:
35
+ raise CliError(2, "INVALID_INPUT", "Supply a canonical profile UUID.") from None
36
+ profile = dict(profile_id=profile_id, domain="intflow.ai", roles=[], allow_audit_read=False, allow_plaintext_read=False)
37
+ for field in FLAGS:
38
+ value = required_input(args, field, field.replace("_", " "))
39
+ if value not in ("true", "false"):
40
+ raise CliError(2, "INVALID_INPUT", "Profile settings must be true or false.")
41
+ profile[field] = value == "true"
42
+ key = args.idempotency_key or "cli-access-profile-" + str(uuid4())
43
+ if not re.fullmatch(r"[A-Za-z0-9_.:-]{16,128}", key):
44
+ raise CliError(2, "INVALID_INPUT", "Supply a normalized idempotency key of 16-128 characters.")
45
+ print("Replace global default access profile: " + profile_id, file=sys.stderr)
46
+ for field in FLAGS:
47
+ print(field + ": " + str(profile[field]).lower(), file=sys.stderr)
48
+ print("Full replacement; concurrent changes are not protected by a server precondition. This does not grant roles or revoke existing credentials.", file=sys.stderr)
49
+ if not args.yes:
50
+ if args.non_interactive or args.token_stdin or not sys.stdin.isatty():
51
+ raise CliError(2, "CONFIRMATION_REQUIRED", "Supply all settings and --yes for unattended execution or when stdin carries a token.")
52
+ print("Type the profile ID to confirm: ", end="", file=sys.stderr, flush=True)
53
+ try:
54
+ answer = input()
55
+ except EOFError:
56
+ answer = None
57
+ if answer != profile_id:
58
+ raise CliError(2, "CONFIRMATION_CANCELLED", "Profile replacement cancelled; no mutation was sent.")
59
+ return dict(profile=profile, idempotency_key=key)
60
+
61
+
62
+ def execute(client, values, *, mapped_error):
63
+ key = values["idempotency_key"]
64
+ print("Idempotency key: " + key, file=sys.stderr)
65
+ data = dict(idempotency_key=key, outcome="unknown", profile=None)
66
+ try:
67
+ result = metadata(client.put_default_access_profile(**values))
68
+ if result != values["profile"]:
69
+ raise CliError(7, "INVALID_RESPONSE", "The server did not confirm the requested profile.")
70
+ data.update(outcome="confirmed", profile=result)
71
+ return data, None
72
+ except (SecFlowSdkError, CliError) as caught:
73
+ error = mapped_error(caught)
74
+ if isinstance(caught, SecFlowConfigError):
75
+ data["outcome"] = "not_sent"
76
+ elif error.status in (2, 3, 4, 5, 6):
77
+ data["outcome"] = "not_confirmed"
78
+ else:
79
+ error = CliError(7, "MUTATION_OUTCOME_UNKNOWN", "Mutation completion is unknown. Inspect current state; any retry must use identical inputs and the same idempotency key.")
80
+ return data, error
81
+
82
+
83
+ def metadata(value):
84
+ valid_id = False
85
+ if isinstance(value, Mapping) and isinstance(value.get("profile_id"), str):
86
+ try:
87
+ valid_id = str(UUID(value["profile_id"])) == value["profile_id"]
88
+ except ValueError:
89
+ pass
90
+ if (not valid_id or value.get("domain") != "intflow.ai" or value.get("roles") != []
91
+ or any(type(value.get(field)) is not bool for field in FLAGS)
92
+ or value.get("allow_audit_read") is not False or value.get("allow_plaintext_read") is not False):
93
+ raise CliError(7, "INVALID_RESPONSE", "The server returned an invalid default access profile.")
94
+ return {field: value[field] for field in FIELDS}
@@ -0,0 +1,134 @@
1
+ """Additive account mutations using existing Admin API contracts."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ import hashlib
6
+ import sys
7
+ from collections.abc import Mapping
8
+ from uuid import UUID, uuid4
9
+
10
+ from secflow_sdk import SecFlowConfigError, SecFlowSdkError
11
+
12
+ from secflow_cli.errors import CliError
13
+
14
+
15
+ ACTIONS = ("create", "update", "reactivate", "suspend", "delete")
16
+ DESTRUCTIVE_ACTIONS = frozenset({"suspend", "delete"})
17
+
18
+
19
+ def add_commands(commands):
20
+ for action in ACTIONS:
21
+ command = commands.add_parser(action)
22
+ command.add_argument("--token-stdin", action="store_true")
23
+ command.add_argument("--idempotency-key")
24
+ if action == "create":
25
+ command.add_argument("--email")
26
+ else:
27
+ command.add_argument("--account-id")
28
+ if action in ("create", "update"):
29
+ command.add_argument("--display-name")
30
+ if action in DESTRUCTIVE_ACTIONS:
31
+ command.add_argument("--yes", action="store_true")
32
+
33
+
34
+ def required_input(args, field, label):
35
+ value = getattr(args, field)
36
+ if value is None and not args.non_interactive and not args.token_stdin and sys.stdin.isatty():
37
+ print(label + ": ", end="", file=sys.stderr, flush=True)
38
+ try:
39
+ value = input()
40
+ except EOFError:
41
+ value = None
42
+ if not isinstance(value, str) or not value.strip() or value != value.strip():
43
+ raise CliError(2, "INVALID_INPUT", "Supply a non-empty normalized " + label + ".")
44
+ return value
45
+
46
+
47
+ def prepare(args):
48
+ """Validate metadata and the retry key before any mutation is sent."""
49
+ key = args.idempotency_key or "cli-account-" + str(uuid4())
50
+ if not re.fullmatch(r"[A-Za-z0-9_.:-]{16,128}", key):
51
+ raise CliError(2, "INVALID_INPUT", "Idempotency keys must contain 16-128 letters, digits, dots, underscores, colons, or hyphens.")
52
+ values = {"idempotency_key": key}
53
+ if args.action == "create":
54
+ values["email"] = required_input(args, "email", "email")
55
+ # The server digests the complete request, including correlation IDs.
56
+ # Keep these stable when a user retries in a later CLI process.
57
+ operation = hashlib.sha256(key.encode("ascii")).hexdigest()
58
+ values["request_id"] = "cli-request-" + operation
59
+ values["trace_id"] = "cli-trace-" + operation
60
+ else:
61
+ values["account_id"] = required_input(args, "account_id", "account ID")
62
+ if args.action == "update":
63
+ values["display_name"] = required_input(args, "display_name", "display name")
64
+ elif args.action == "create" and args.display_name is not None:
65
+ values["display_name"] = required_input(args, "display_name", "display name")
66
+ if args.action == "reactivate":
67
+ values["status"] = "active"
68
+ if args.action in DESTRUCTIVE_ACTIONS:
69
+ confirm_destructive(args, values["account_id"])
70
+ return values
71
+
72
+
73
+ def confirm_destructive(args, account_id):
74
+ try:
75
+ if str(UUID(account_id)) != account_id:
76
+ raise ValueError
77
+ except ValueError:
78
+ raise CliError(2, "INVALID_INPUT", "Supply an exact canonical account UUID.") from None
79
+ print(args.action.capitalize() + " account: " + account_id, file=sys.stderr)
80
+ impact = (
81
+ "suspension revokes active Admin sessions and SDK tokens. Reactivation does not restore revoked credentials."
82
+ if args.action == "suspend" else
83
+ "deletion removes the account, its sessions, SDK tokens, and direct role bindings."
84
+ )
85
+ print("Operation semantics, not a server impact preview: " + impact + " Affected counts are unknown.", file=sys.stderr)
86
+ if args.yes:
87
+ return
88
+ if args.non_interactive or not sys.stdin.isatty() or args.token_stdin:
89
+ raise CliError(2, "CONFIRMATION_REQUIRED", "Supply the exact account ID and --yes for unattended execution or when stdin carries a token.")
90
+ print("Type the account ID to confirm: ", end="", file=sys.stderr, flush=True)
91
+ try:
92
+ answer = input()
93
+ except EOFError:
94
+ answer = None
95
+ if answer != account_id:
96
+ raise CliError(2, "CONFIRMATION_CANCELLED", "Account operation cancelled; no mutation was sent.")
97
+
98
+
99
+ def execute(args, client, values, *, account_metadata, mapped_error):
100
+ key = values["idempotency_key"]
101
+ print("Idempotency key: " + key, file=sys.stderr)
102
+ data = {"idempotency_key": key, "outcome": "unknown", "account": None}
103
+ if args.action == "delete":
104
+ data["deletion"] = None
105
+ try:
106
+ method = {"create": client.create_account, "update": client.patch_account,
107
+ "reactivate": client.patch_account, "suspend": client.suspend_account,
108
+ "delete": client.delete_account}[args.action]
109
+ response = method(**values)
110
+ if args.action == "delete":
111
+ if (not isinstance(response, Mapping) or response.get("kind") != "account"
112
+ or response.get("id") != values["account_id"] or response.get("deleted") is not True
113
+ or not isinstance(response.get("label"), str)):
114
+ raise CliError(7, "INVALID_RESPONSE", "The server did not confirm deletion of the selected account.")
115
+ data.update(outcome="confirmed", deletion={name: response[name] for name in ("kind", "id", "label", "deleted")})
116
+ return data, None
117
+ account = account_metadata(response)
118
+ if not isinstance(account.get("account_id"), str) or not account["account_id"]:
119
+ raise CliError(7, "INVALID_RESPONSE", "The server did not return confirmed account metadata.")
120
+ if args.action == "suspend" and (account["account_id"] != values["account_id"] or account.get("status") != "suspended"):
121
+ raise CliError(7, "INVALID_RESPONSE", "The server did not confirm suspension of the selected account.")
122
+ data.update(account=account, outcome="confirmed")
123
+ return data, None
124
+ except (SecFlowSdkError, CliError) as caught:
125
+ error = mapped_error(caught)
126
+ if isinstance(caught, SecFlowConfigError):
127
+ data["outcome"] = "not_sent"
128
+ elif error.status in (2, 3, 4, 5, 6):
129
+ # The final request was rejected; an earlier attempt using this
130
+ # key may have completed. Never imply rollback of that attempt.
131
+ data["outcome"] = "not_confirmed"
132
+ else:
133
+ error = CliError(7, "MUTATION_OUTCOME_UNKNOWN", "Mutation completion is unknown. Inspect current state; any retry must use identical inputs and the same idempotency key.")
134
+ return data, error
@@ -0,0 +1,129 @@
1
+ """Human browser login; Auth credentials stay on the SecFlow server."""
2
+ from __future__ import annotations
3
+
4
+ import base64
5
+ from datetime import datetime, timezone
6
+ import hashlib
7
+ import json
8
+ import os
9
+ import re
10
+ import sys
11
+ import time
12
+ from urllib.error import HTTPError
13
+ from urllib.request import HTTPRedirectHandler, Request, build_opener
14
+ from uuid import UUID
15
+ import webbrowser
16
+
17
+ from secflow_sdk import SecFlowAdminClient
18
+ from secflow_cli.errors import CliError
19
+
20
+
21
+ def rejected():
22
+ return CliError(3, "BROWSER_LOGIN_FAILED", "Browser login did not complete. Start a new login; uncertain session exchanges are not retried.")
23
+
24
+
25
+ class NoRedirect(HTTPRedirectHandler):
26
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
27
+ return None
28
+
29
+
30
+ def post(url, body, timeout):
31
+ request = Request(url, data=json.dumps(body).encode("utf-8"), method="POST",
32
+ headers={"Content-Type": "application/json", "Accept": "application/json"})
33
+ try:
34
+ response = build_opener(NoRedirect).open(request, timeout=timeout)
35
+ except HTTPError as error:
36
+ status = error.code
37
+ error.close()
38
+ return status, {}
39
+ with response:
40
+ raw = response.read(16385)
41
+ if len(raw) > 16384:
42
+ raise rejected()
43
+ value = json.loads(raw)
44
+ if not isinstance(value, dict):
45
+ raise rejected()
46
+ return response.status, value
47
+
48
+
49
+ def acquire_session(url, timeout, *, no_browser=False):
50
+ try:
51
+ return _acquire_session(url, timeout, no_browser=no_browser)
52
+ except CliError:
53
+ raise
54
+ except Exception:
55
+ raise rejected() from None
56
+
57
+
58
+ def _acquire_session(url, timeout, *, no_browser):
59
+ proof = base64.urlsafe_b64encode(os.urandom(32)).rstrip(b"=").decode("ascii")
60
+ started_at = time.monotonic()
61
+ status, created = post(url + "/v1/auth/browser-login", {"poll_challenge": hashlib.sha256(proof.encode()).hexdigest()}, timeout)
62
+ if status == 404:
63
+ raise CliError(3, "BROWSER_LOGIN_UNAVAILABLE", "This server has not enabled browser login. Use a Slack-issued Admin session.")
64
+ if status != 201:
65
+ raise rejected()
66
+ identifier = created.get("transaction_id")
67
+ if str(UUID(identifier)) != identifier:
68
+ raise rejected()
69
+ verification_uri = url + "/auth/intflow/start?transaction_id=" + identifier
70
+ if (created.get("verification_uri") != verification_uri
71
+ or created.get("user_code") != identifier[:8].upper()
72
+ or type(created.get("expires_in")) is not int or not 1 <= created["expires_in"] <= 300
73
+ or type(created.get("interval")) is not int or not 2 <= created["interval"] <= 30):
74
+ raise rejected()
75
+ deadline = started_at + created["expires_in"]
76
+ print("Open " + verification_uri, file=sys.stderr)
77
+ print("Confirm code " + created["user_code"] + " and approve the login in your browser.", file=sys.stderr)
78
+ if not no_browser:
79
+ try:
80
+ opened = webbrowser.open(verification_uri)
81
+ except Exception:
82
+ opened = False
83
+ if not opened:
84
+ print("Open the URL above manually to continue.", file=sys.stderr)
85
+ interval = created["interval"]
86
+ while time.monotonic() + interval < deadline:
87
+ time.sleep(interval)
88
+ remaining = deadline - time.monotonic()
89
+ if remaining <= 0:
90
+ break
91
+ status, result = post(url + "/v1/auth/browser-login/redeem",
92
+ {"transaction_id": identifier, "poll_secret": proof}, min(timeout, remaining))
93
+ if status == 202 and result == {"status": "pending"}:
94
+ continue
95
+ if status == 429:
96
+ interval = min(30, interval + 2)
97
+ continue
98
+ if status != 200:
99
+ raise rejected()
100
+ token = result.get("session_token")
101
+ if not isinstance(token, str) or not re.fullmatch(r"[A-Za-z0-9_-]{32,256}", token):
102
+ raise rejected()
103
+ if any(str(UUID(result.get(field))) != result[field] for field in ("session_id", "account_id")):
104
+ raise rejected()
105
+ expiry = datetime.fromisoformat(result["expires_at"].replace("Z", "+00:00"))
106
+ if expiry.tzinfo is None or expiry <= datetime.now(timezone.utc):
107
+ raise rejected()
108
+ return {field: result[field] for field in ("session_token", "session_id", "account_id", "expires_at")}
109
+ raise CliError(3, "BROWSER_LOGIN_EXPIRED", "Browser login expired. Start a new login.")
110
+
111
+
112
+ def login(url, timeout, store, reference, *, no_browser=False):
113
+ store.check()
114
+ session = acquire_session(url, timeout, no_browser=no_browser)
115
+ client = SecFlowAdminClient(url, admin_session_token=session["session_token"], timeout=timeout)
116
+ try:
117
+ account = client.get_current_account()
118
+ if account.get("account_id") != session["account_id"] or account.get("status") != "active":
119
+ raise rejected()
120
+ store.set(reference, session["session_token"])
121
+ except BaseException:
122
+ # Do not erase a previously stored credential. Revoke only the session
123
+ # obtained by this invocation when validation/storage is unsuccessful.
124
+ try:
125
+ client.revoke_current_session()
126
+ except Exception:
127
+ pass
128
+ raise
129
+ return account, session["expires_at"]
secflow_cli/config.py ADDED
@@ -0,0 +1,106 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import ipaddress
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+ import re
9
+ import sys
10
+ import tempfile
11
+ from urllib.parse import urlsplit
12
+
13
+ from secflow_cli.errors import CliError
14
+
15
+
16
+ def endpoint(value: str) -> str:
17
+ try:
18
+ parts = urlsplit(value)
19
+ host = parts.hostname
20
+ if (value != value.strip() or any(ord(c) < 33 for c in value) or not host
21
+ or parts.username is not None or parts.password is not None
22
+ or parts.query or parts.fragment or parts.path not in ("", "/")
23
+ or parts.scheme not in ("http", "https")):
24
+ raise ValueError
25
+ loopback = host == "localhost"
26
+ try:
27
+ loopback = loopback or ipaddress.ip_address(host).is_loopback
28
+ except ValueError:
29
+ pass
30
+ if parts.scheme == "http" and not loopback:
31
+ raise ValueError
32
+ port = parts.port
33
+ if port is not None and port < 1:
34
+ raise ValueError
35
+ host = host.encode("idna").decode("ascii").lower()
36
+ if ":" in host:
37
+ host = f"[{host}]"
38
+ suffix = f":{port}" if port and port != (443 if parts.scheme == "https" else 80) else ""
39
+ return f"{parts.scheme}://{host}{suffix}"
40
+ except (ValueError, UnicodeError):
41
+ raise CliError(2, "INVALID_ENDPOINT", "Use an HTTPS server origin without credentials, path, query, or fragment; loopback HTTP is allowed.") from None
42
+
43
+
44
+ def profile_name(value: str) -> str:
45
+ if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,63}", value):
46
+ raise CliError(2, "INVALID_PROFILE", "Profile names must contain 1-64 letters, digits, dots, underscores, or hyphens.")
47
+ return value
48
+
49
+
50
+ def credential_ref(name: str, url: str) -> str:
51
+ return "admin:" + hashlib.sha256((profile_name(name) + "\n" + endpoint(url)).encode()).hexdigest()
52
+
53
+
54
+ class Profiles:
55
+ def __init__(self):
56
+ override = os.environ.get("SECFLOW_CLI_CONFIG_DIR")
57
+ if override:
58
+ directory = Path(override).expanduser()
59
+ elif sys.platform == "win32":
60
+ directory = Path(os.environ.get("APPDATA", str(Path.home() / "AppData/Roaming"))) / "SecFlow/cli"
61
+ elif sys.platform == "darwin":
62
+ directory = Path.home() / "Library/Application Support/SecFlow/cli"
63
+ else:
64
+ directory = Path(os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config"))) / "secflow/cli"
65
+ self.path = directory.resolve() / "profiles.json"
66
+ if any((ancestor / ".git").exists() for ancestor in self.path.parents):
67
+ raise CliError(2, "INVALID_CONFIG_LOCATION", "CLI configuration must be outside a repository.")
68
+ self.data = {"version": 1, "current": None, "profiles": {}}
69
+ try:
70
+ if self.path.exists():
71
+ raw = json.loads(self.path.read_text(encoding="utf-8"))
72
+ if not isinstance(raw, dict) or set(raw) != {"version", "current", "profiles"} or raw["version"] != 1 or not isinstance(raw["profiles"], dict):
73
+ raise ValueError
74
+ for name, profile in raw["profiles"].items():
75
+ profile_name(name)
76
+ if not isinstance(profile, dict) or set(profile) != {"base_url", "credential_ref"}:
77
+ raise ValueError
78
+ url = endpoint(profile["base_url"])
79
+ if profile["credential_ref"] != credential_ref(name, url):
80
+ raise ValueError
81
+ if raw["current"] is not None and raw["current"] not in raw["profiles"]:
82
+ raise ValueError
83
+ self.data = raw
84
+ except (OSError, ValueError, TypeError, KeyError):
85
+ raise CliError(2, "INVALID_CONFIG", "Cannot read valid CLI profile configuration.") from None
86
+
87
+ def save(self):
88
+ self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
89
+ temporary = None
90
+ try:
91
+ with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=self.path.parent, delete=False) as stream:
92
+ temporary = Path(stream.name)
93
+ json.dump(self.data, stream, indent=2)
94
+ stream.write("\n")
95
+ os.replace(temporary, self.path)
96
+ except OSError:
97
+ raise CliError(2, "CONFIG_WRITE_FAILED", "Cannot save CLI profile configuration.") from None
98
+ finally:
99
+ if temporary and temporary.exists():
100
+ temporary.unlink()
101
+
102
+ def select(self, name: str | None):
103
+ selected = name or os.environ.get("SECFLOW_PROFILE") or self.data["current"]
104
+ if selected is None or selected not in self.data["profiles"]:
105
+ raise CliError(2, "PROFILE_REQUIRED", "First use: secflowctl login. For a custom server, see secflowctl profile add --help; select an existing profile with --profile.")
106
+ return selected, self.data["profiles"][selected]
@@ -0,0 +1,58 @@
1
+ from __future__ import annotations
2
+
3
+ from secflow_cli.errors import CliError
4
+
5
+
6
+ class CredentialStore:
7
+ """Only explicitly supported OS stores; never file-backed fallback plugins."""
8
+
9
+ service = "secflow-cli"
10
+ supported = frozenset({
11
+ ("keyring.backends.Windows", "WinVaultKeyring"),
12
+ ("keyring.backends.macOS", "Keyring"),
13
+ ("keyring.backends.SecretService", "Keyring"),
14
+ })
15
+
16
+ def __init__(self, *, allow_interaction=True):
17
+ self.backend = None
18
+ self.allow_interaction = allow_interaction
19
+
20
+ def check(self):
21
+ if self.backend is not None:
22
+ return
23
+ try:
24
+ import keyring
25
+ backend = keyring.get_keyring()
26
+ kind = (type(backend).__module__, type(backend).__name__)
27
+ # Keychain and Secret Service may display OS unlock dialogs. Until
28
+ # a prompt-free adapter is available, require explicit credentials
29
+ # in unattended sessions on these platforms.
30
+ if not self.allow_interaction and kind != ("keyring.backends.Windows", "WinVaultKeyring"):
31
+ raise RuntimeError
32
+ if kind not in self.supported or backend.priority <= 0:
33
+ raise RuntimeError
34
+ self.backend = backend
35
+ except Exception:
36
+ raise CliError(2, "CREDENTIAL_STORE_UNAVAILABLE", "A supported OS credential store is unavailable. Use --ephemeral with token stdin or SECFLOW_ADMIN_SESSION_TOKEN.") from None
37
+
38
+ def get(self, reference: str) -> str | None:
39
+ self.check()
40
+ try:
41
+ return self.backend.get_password(self.service, reference)
42
+ except Exception:
43
+ raise CliError(3, "CREDENTIAL_READ_FAILED", "Cannot read the selected OS credential entry.") from None
44
+
45
+ def set(self, reference: str, token: str):
46
+ self.check()
47
+ try:
48
+ self.backend.set_password(self.service, reference, token)
49
+ except Exception:
50
+ raise CliError(2, "CREDENTIAL_WRITE_FAILED", "Credential validation succeeded but secure storage failed.") from None
51
+
52
+ def delete(self, reference: str):
53
+ self.check()
54
+ try:
55
+ if self.backend.get_password(self.service, reference) is not None:
56
+ self.backend.delete_password(self.service, reference)
57
+ except Exception:
58
+ raise CliError(2, "CREDENTIAL_DELETE_FAILED", "Cannot clear the selected OS credential entry.") from None
secflow_cli/errors.py ADDED
@@ -0,0 +1,8 @@
1
+ class CliError(Exception):
2
+ """A deliberately safe, locally authored error suitable for rendering."""
3
+
4
+ def __init__(self, status: int, code: str, message: str):
5
+ super().__init__(message)
6
+ self.status = status
7
+ self.code = code
8
+ self.message = message
secflow_cli/help.py ADDED
@@ -0,0 +1,100 @@
1
+ """Offline command discovery; all authorization remains server-side."""
2
+ import argparse
3
+ import sys
4
+
5
+ GROUPS = {
6
+ 'login': 'Sign in through Intflow; first use creates a profile for https://secflow.intflow.dev.',
7
+ 'profile': 'Manage local server profiles (credentials remain in the OS credential store).',
8
+ 'auth': 'Inspect, import, revoke, or forget your Admin session. Slack sessions remain supported.',
9
+ 'health': 'Check server health and readiness without an Admin session.',
10
+ 'accounts': 'Manage SecFlow accounts; server authorization is required.',
11
+ 'namespaces': 'List and manage namespaces allowed by server authorization.',
12
+ 'tokens': 'List SDK token metadata; this CLI does not issue tokens or display token values.',
13
+ 'roles': 'List, grant, and revoke role bindings; server authorization is required.',
14
+ 'access-profile': 'Read or replace the default access profile; set replaces all supported flags.',
15
+ }
16
+ OPTIONS = {
17
+ 'profile': 'Select a saved profile (or SECFLOW_PROFILE). Global options go before the command.',
18
+ 'base_url': 'HTTPS server origin. An override cannot reuse credentials for another origin.',
19
+ 'output': 'Output format; JSONL is available for list commands only.',
20
+ 'non_interactive': 'Disable prompts and interactive credential-store access.',
21
+ 'timeout': 'Request timeout in seconds (default: 5; maximum: 300).',
22
+ 'token_stdin': 'Read an Admin session from stdin; never pass credentials as arguments.',
23
+ 'ephemeral': 'Validate an imported session for this invocation without storing it.',
24
+ 'query': 'Filter results using the server search query.',
25
+ 'page_size': 'Items per page, 1-200 (default: 50).',
26
+ 'page_token': 'Continue from a previous response next_page_token.',
27
+ 'all': 'Fetch successive pages up to --max-pages.',
28
+ 'max_pages': 'Maximum pages for --all, 1-1000 (default: 100).',
29
+ 'yes': 'Confirm this change without an interactive confirmation.',
30
+ 'idempotency_key': 'Unique retry key for this operation; reuse only for the identical request.',
31
+ 'namespace': 'Namespace identifier, for example dev/example.',
32
+ 'account_id': 'SecFlow account UUID.',
33
+ 'binding_id': 'Role binding UUID to revoke.',
34
+ 'clear_expiration_policy': 'Remove the namespace expiration policy.',
35
+ }
36
+ EXAMPLES = {
37
+ '': 'secflowctl login\nsecflowctl auth status\nsecflowctl namespaces list --help',
38
+ 'login': 'secflowctl login\nsecflowctl login --no-browser\nsecflowctl --profile staging login',
39
+ 'profile': 'secflowctl profile list\nsecflowctl profile add staging --base-url https://secflow.example.test\nsecflowctl profile use staging',
40
+ 'auth': 'secflowctl login\nsecflowctl auth status\nsecflowctl auth logout',
41
+ 'auth login': 'secflowctl auth login --browser\nsecflowctl auth login\nsecflowctl --non-interactive auth login --token-stdin',
42
+ 'profile add': 'secflowctl profile add staging --base-url https://secflow.example.test',
43
+ 'profile use': 'secflowctl profile use staging',
44
+ 'profile remove': 'secflowctl profile remove staging',
45
+ 'accounts create': 'secflowctl accounts create --email user@intflow.ai --display-name Example',
46
+ 'accounts update': 'secflowctl accounts update --account-id ACCOUNT_UUID --display-name Example',
47
+ 'accounts suspend': 'secflowctl accounts suspend --account-id ACCOUNT_UUID',
48
+ 'accounts reactivate': 'secflowctl accounts reactivate --account-id ACCOUNT_UUID',
49
+ 'accounts delete': 'secflowctl accounts delete --account-id ACCOUNT_UUID',
50
+ 'namespaces create': 'secflowctl namespaces create --namespace dev/example --owner TEAM --administrator ADMIN --audit-logging true',
51
+ 'namespaces update': 'secflowctl namespaces update --namespace dev/example --owner TEAM',
52
+ 'namespaces delete': 'secflowctl namespaces delete --namespace dev/example',
53
+ 'roles grant': 'secflowctl roles grant --account-id ACCOUNT_UUID --role secret_reader --scope-type namespace --scope dev/example',
54
+ 'roles revoke': 'secflowctl roles revoke --binding-id BINDING_UUID',
55
+ 'access-profile set': 'secflowctl access-profile get\nsecflowctl access-profile set --profile-id PROFILE_UUID --allow-cli-login true --allow-api-session true --allow-namespace-request false',
56
+ }
57
+
58
+
59
+ def enrich(command):
60
+ path = command.prog.removeprefix('secflowctl').strip()
61
+ group = path.split(' ')[0]
62
+ command.formatter_class = argparse.RawDescriptionHelpFormatter
63
+ command.description = GROUPS.get(path) or (
64
+ f'{path.capitalize()}. {GROUPS.get(group, "Manage SecFlow through its Admin API.")}'
65
+ if path else 'SecFlow CLI: sign in, inspect your access, and manage authorized resources.')
66
+ example = EXAMPLES.get(path)
67
+ if example is None:
68
+ if ' ' not in path and group not in ('health',):
69
+ example = f'secflowctl {path} list\nsecflowctl {path} --help'
70
+ if group == 'access-profile':
71
+ example = 'secflowctl access-profile get\nsecflowctl access-profile set --help'
72
+ else:
73
+ action = path.split(' ')[-1]
74
+ example = f'secflowctl {path}' + (' --help' if action not in ('list', 'get', 'status', 'whoami', 'logout', 'forget', 'show', 'health') else '')
75
+ command.epilog = 'Examples:\n ' + example.replace('\n', '\n ')
76
+ command.epilog += '\n\nExplore: secflowctl --help; secflowctl <command> --help.\nGlobal options precede the command. Access is checked by the server.'
77
+ if path == 'auth forget':
78
+ command.description = 'Remove the local credential only; the server session remains valid. Use auth logout to revoke it.'
79
+ if path == 'auth logout':
80
+ command.description = 'Revoke the current server session and clear its local credential.'
81
+ if path == 'access-profile set':
82
+ command.epilog += '\nFull replacement: roles become empty; audit/plaintext access flags become false.\nAll three login/request flags are required. Existing sessions are not revoked.'
83
+ if any(marker in command.epilog for marker in ('ACCOUNT_UUID', 'BINDING_UUID', 'PROFILE_UUID')):
84
+ command.epilog += '\nReplace uppercase placeholders with IDs from the corresponding list/get command.'
85
+ for action in command._actions:
86
+ if isinstance(action, argparse._SubParsersAction):
87
+ for name, child in action.choices.items():
88
+ enrich(child)
89
+ action._choices_actions.append(action._ChoicesPseudoAction(name, [], child.description))
90
+ elif action.help is None:
91
+ action.help = OPTIONS.get(action.dest, action.dest.replace('_', ' ').capitalize() + '.')
92
+
93
+
94
+ def login_hint(args, account):
95
+ if args.non_interactive or not sys.stdin.isatty():
96
+ return
97
+ print('Signed in. Next: secflowctl auth status; secflowctl namespaces list --help.', file=sys.stderr)
98
+ if 'org_admin' in account.get('roles', []):
99
+ print('Account administration: secflowctl accounts --help; secflowctl roles --help.', file=sys.stderr)
100
+ print('Server roles and policies determine access. Explore all commands with secflowctl --help.', file=sys.stderr)