secflow-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- secflow_cli/__init__.py +1 -0
- secflow_cli/__main__.py +3 -0
- secflow_cli/access_profile_commands.py +94 -0
- secflow_cli/account_commands.py +134 -0
- secflow_cli/browser_login.py +129 -0
- secflow_cli/config.py +106 -0
- secflow_cli/credentials.py +58 -0
- secflow_cli/errors.py +8 -0
- secflow_cli/help.py +100 -0
- secflow_cli/main.py +404 -0
- secflow_cli/namespace_commands.py +151 -0
- secflow_cli/role_commands.py +99 -0
- secflow_cli-0.1.0.dist-info/METADATA +64 -0
- secflow_cli-0.1.0.dist-info/RECORD +18 -0
- secflow_cli-0.1.0.dist-info/WHEEL +5 -0
- secflow_cli-0.1.0.dist-info/entry_points.txt +2 -0
- secflow_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- secflow_cli-0.1.0.dist-info/top_level.txt +1 -0
secflow_cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Independent operator CLI; no server imports or executable replacement."""
|
secflow_cli/__main__.py
ADDED
|
@@ -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
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)
|