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.
- secflow_cli-0.1.0/LICENSE +21 -0
- secflow_cli-0.1.0/PKG-INFO +64 -0
- secflow_cli-0.1.0/USAGE.md +50 -0
- secflow_cli-0.1.0/pyproject.toml +23 -0
- secflow_cli-0.1.0/setup.cfg +4 -0
- secflow_cli-0.1.0/src/secflow_cli/__init__.py +1 -0
- secflow_cli-0.1.0/src/secflow_cli/__main__.py +3 -0
- secflow_cli-0.1.0/src/secflow_cli/access_profile_commands.py +94 -0
- secflow_cli-0.1.0/src/secflow_cli/account_commands.py +134 -0
- secflow_cli-0.1.0/src/secflow_cli/browser_login.py +129 -0
- secflow_cli-0.1.0/src/secflow_cli/config.py +106 -0
- secflow_cli-0.1.0/src/secflow_cli/credentials.py +58 -0
- secflow_cli-0.1.0/src/secflow_cli/errors.py +8 -0
- secflow_cli-0.1.0/src/secflow_cli/help.py +100 -0
- secflow_cli-0.1.0/src/secflow_cli/main.py +404 -0
- secflow_cli-0.1.0/src/secflow_cli/namespace_commands.py +151 -0
- secflow_cli-0.1.0/src/secflow_cli/role_commands.py +99 -0
- secflow_cli-0.1.0/src/secflow_cli.egg-info/PKG-INFO +64 -0
- secflow_cli-0.1.0/src/secflow_cli.egg-info/SOURCES.txt +21 -0
- secflow_cli-0.1.0/src/secflow_cli.egg-info/dependency_links.txt +1 -0
- secflow_cli-0.1.0/src/secflow_cli.egg-info/entry_points.txt +2 -0
- secflow_cli-0.1.0/src/secflow_cli.egg-info/requires.txt +2 -0
- secflow_cli-0.1.0/src/secflow_cli.egg-info/top_level.txt +1 -0
|
@@ -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 @@
|
|
|
1
|
+
"""Independent operator CLI; no server imports or executable replacement."""
|
|
@@ -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]
|