secflow-cli 0.1.0__tar.gz

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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SecFlow contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,64 @@
1
+ Metadata-Version: 2.4
2
+ Name: secflow-cli
3
+ Version: 0.1.0
4
+ Summary: SecFlow operator CLI
5
+ License-Expression: MIT
6
+ Project-URL: Repository, https://github.com/yeon3724/secflow
7
+ Project-URL: Issues, https://github.com/yeon3724/secflow/issues
8
+ Requires-Python: >=3.10
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Requires-Dist: secflow-sdk==0.1.8
12
+ Requires-Dist: keyring<26,>=25
13
+ Dynamic: license-file
14
+
15
+ # SecFlow CLI
16
+
17
+ Use Intflow browser login or an existing Slack-issued Admin session. Both distributions provide the same `secflowctl` commands.
18
+
19
+ ## Install and update
20
+
21
+ Choose one distribution per machine; both own the same `secflowctl` command and share profiles and the OS credential store.
22
+
23
+ ```sh
24
+ pipx install secflow-cli
25
+ # Or in a Python environment:
26
+ python -m pip install secflow-cli
27
+
28
+ # Alternative: Node.js 22+; no separate Python installation required
29
+ npm install --global @intflow/secflow-cli
30
+ secflowctl --version
31
+ ```
32
+
33
+ The Python distribution requires Python 3.10+. Installation automatically includes the Admin-capable `secflow-sdk==0.1.8` dependency.
34
+
35
+ The npm distribution includes Python and the CLI dependencies in OS-specific native packages. Initial targets: Windows x64, macOS Intel/Apple Silicon, and Linux x64 with glibc 2.35+ (Ubuntu 22.04+). Alpine/musl and Linux ARM are not npm targets yet; use the Python distribution there. Keep npm optional dependencies enabled. Installation has no postinstall script or runtime download. Native binaries are not platform-signed or notarized.
36
+
37
+ The bundled interpreter includes CA certificates for HTTPS. An explicit `SSL_CERT_FILE` or `SSL_CERT_DIR` takes precedence for organization-managed trust; TLS certificate verification stays enabled.
38
+
39
+ ```sh
40
+ pipx upgrade secflow-cli
41
+ # Or use the same installer as the initial installation:
42
+ python -m pip install --upgrade secflow-cli
43
+ npm install --global @intflow/secflow-cli@latest
44
+ ```
45
+
46
+ ## Connect
47
+
48
+ ```sh
49
+ secflowctl login
50
+ secflowctl auth status
51
+ secflowctl namespaces list --help
52
+ secflowctl namespaces list --all
53
+ secflowctl --help
54
+ ```
55
+
56
+ `secflowctl` without arguments displays a quick start. `secflowctl login` opens Intflow login and creates the `production` profile for `https://secflow.intflow.dev` on first use. Existing profiles retain their selected server. For another server, first use `secflowctl profile add staging --base-url https://secflow.example.test`, then `secflowctl --profile staging login`. Global options precede the command. Every command supports `--help`, including examples. Account and role administration still requires server authorization; token commands currently list metadata only.
57
+
58
+ `auth login` remains compatible with Slack-issued Admin sessions and prompts without echo for an existing session. Sessions are stored in Windows Credential Manager, macOS Keychain, or Linux Secret Service. Linux persistent login needs an unlocked Secret Service (for example GNOME Keyring) and a D-Bus session. If secure storage is unavailable, login fails without a plaintext file fallback. Installing the CLI does not create an account or grant access.
59
+
60
+ When browser login is enabled on the server, run `secflowctl auth login --browser`. Sign in to Intflow, compare the code with your terminal, and approve the SecFlow login. Use `--browser --no-browser` to open the displayed URL manually. This requires an interactive terminal, an existing authorized SecFlow account, and OS credential storage. The CLI stores only the resulting SecFlow session. If login expires or the exchange fails, start a new login.
61
+
62
+ For automation use `SECFLOW_ADMIN_SESSION_TOKEN` supplied by your secret manager, or `--token-stdin` with `--non-interactive`. Never put tokens in command arguments, profiles, shell history, or tracked files. SDK tokens cannot authorize these Admin commands. `auth logout` revokes the current server session; `auth forget` only removes the local credential.
63
+
64
+ To uninstall, use `pipx uninstall secflow-cli`, `python -m pip uninstall secflow-cli`, or `npm uninstall --global @intflow/secflow-cli` as appropriate. Uninstalling does not revoke server sessions or erase profiles; log out and remove unwanted profiles first.
@@ -0,0 +1,50 @@
1
+ # SecFlow CLI
2
+
3
+ Use Intflow browser login or an existing Slack-issued Admin session. Both distributions provide the same `secflowctl` commands.
4
+
5
+ ## Install and update
6
+
7
+ Choose one distribution per machine; both own the same `secflowctl` command and share profiles and the OS credential store.
8
+
9
+ ```sh
10
+ pipx install secflow-cli
11
+ # Or in a Python environment:
12
+ python -m pip install secflow-cli
13
+
14
+ # Alternative: Node.js 22+; no separate Python installation required
15
+ npm install --global @intflow/secflow-cli
16
+ secflowctl --version
17
+ ```
18
+
19
+ The Python distribution requires Python 3.10+. Installation automatically includes the Admin-capable `secflow-sdk==0.1.8` dependency.
20
+
21
+ The npm distribution includes Python and the CLI dependencies in OS-specific native packages. Initial targets: Windows x64, macOS Intel/Apple Silicon, and Linux x64 with glibc 2.35+ (Ubuntu 22.04+). Alpine/musl and Linux ARM are not npm targets yet; use the Python distribution there. Keep npm optional dependencies enabled. Installation has no postinstall script or runtime download. Native binaries are not platform-signed or notarized.
22
+
23
+ The bundled interpreter includes CA certificates for HTTPS. An explicit `SSL_CERT_FILE` or `SSL_CERT_DIR` takes precedence for organization-managed trust; TLS certificate verification stays enabled.
24
+
25
+ ```sh
26
+ pipx upgrade secflow-cli
27
+ # Or use the same installer as the initial installation:
28
+ python -m pip install --upgrade secflow-cli
29
+ npm install --global @intflow/secflow-cli@latest
30
+ ```
31
+
32
+ ## Connect
33
+
34
+ ```sh
35
+ secflowctl login
36
+ secflowctl auth status
37
+ secflowctl namespaces list --help
38
+ secflowctl namespaces list --all
39
+ secflowctl --help
40
+ ```
41
+
42
+ `secflowctl` without arguments displays a quick start. `secflowctl login` opens Intflow login and creates the `production` profile for `https://secflow.intflow.dev` on first use. Existing profiles retain their selected server. For another server, first use `secflowctl profile add staging --base-url https://secflow.example.test`, then `secflowctl --profile staging login`. Global options precede the command. Every command supports `--help`, including examples. Account and role administration still requires server authorization; token commands currently list metadata only.
43
+
44
+ `auth login` remains compatible with Slack-issued Admin sessions and prompts without echo for an existing session. Sessions are stored in Windows Credential Manager, macOS Keychain, or Linux Secret Service. Linux persistent login needs an unlocked Secret Service (for example GNOME Keyring) and a D-Bus session. If secure storage is unavailable, login fails without a plaintext file fallback. Installing the CLI does not create an account or grant access.
45
+
46
+ When browser login is enabled on the server, run `secflowctl auth login --browser`. Sign in to Intflow, compare the code with your terminal, and approve the SecFlow login. Use `--browser --no-browser` to open the displayed URL manually. This requires an interactive terminal, an existing authorized SecFlow account, and OS credential storage. The CLI stores only the resulting SecFlow session. If login expires or the exchange fails, start a new login.
47
+
48
+ For automation use `SECFLOW_ADMIN_SESSION_TOKEN` supplied by your secret manager, or `--token-stdin` with `--non-interactive`. Never put tokens in command arguments, profiles, shell history, or tracked files. SDK tokens cannot authorize these Admin commands. `auth logout` revokes the current server session; `auth forget` only removes the local credential.
49
+
50
+ To uninstall, use `pipx uninstall secflow-cli`, `python -m pip uninstall secflow-cli`, or `npm uninstall --global @intflow/secflow-cli` as appropriate. Uninstalling does not revoke server sessions or erase profiles; log out and remove unwanted profiles first.
@@ -0,0 +1,23 @@
1
+ [build-system]
2
+ requires = ["setuptools>=69"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "secflow-cli"
7
+ version = "0.1.0"
8
+ description = "SecFlow operator CLI"
9
+ readme = "USAGE.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ dependencies = ["secflow-sdk==0.1.8", "keyring>=25,<26"]
13
+
14
+ [project.scripts]
15
+ secflowctl = "secflow_cli.main:main"
16
+
17
+ [project.urls]
18
+ Repository = "https://github.com/yeon3724/secflow"
19
+ Issues = "https://github.com/yeon3724/secflow/issues"
20
+
21
+ [tool.setuptools.packages.find]
22
+ where = ["src"]
23
+ include = ["secflow_cli*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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"]
@@ -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]