secflow-cli 0.2.2__py3-none-any.whl → 0.2.3__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 +0 -1
- secflow_cli/access_profile_commands.py +16 -49
- secflow_cli/account_commands.py +33 -73
- secflow_cli/browser_login.py +23 -18
- secflow_cli/command_support.py +53 -27
- secflow_cli/completion.py +0 -1
- secflow_cli/config.py +70 -30
- secflow_cli/credentials.py +0 -4
- secflow_cli/doctor_commands.py +5 -11
- secflow_cli/errors.py +0 -1
- secflow_cli/help.py +0 -1
- secflow_cli/main.py +279 -283
- secflow_cli/manifest_commands.py +8 -25
- secflow_cli/namespace_commands.py +30 -40
- secflow_cli/operator_commands.py +100 -98
- secflow_cli/output.py +85 -0
- secflow_cli/paging.py +59 -0
- secflow_cli/policy_audit_commands.py +3 -5
- secflow_cli/reference_commands.py +0 -1
- secflow_cli/report_commands.py +11 -17
- secflow_cli/role_commands.py +23 -52
- secflow_cli/runtime_commands.py +17 -14
- secflow_cli/secret_commands.py +2 -3
- secflow_cli/session_commands.py +1 -2
- secflow_cli/token_commands.py +1 -3
- {secflow_cli-0.2.2.dist-info → secflow_cli-0.2.3.dist-info}/METADATA +5 -5
- secflow_cli-0.2.3.dist-info/RECORD +32 -0
- secflow_cli-0.2.2.dist-info/RECORD +0 -30
- {secflow_cli-0.2.2.dist-info → secflow_cli-0.2.3.dist-info}/WHEEL +0 -0
- {secflow_cli-0.2.2.dist-info → secflow_cli-0.2.3.dist-info}/entry_points.txt +0 -0
- {secflow_cli-0.2.2.dist-info → secflow_cli-0.2.3.dist-info}/licenses/LICENSE +0 -0
- {secflow_cli-0.2.2.dist-info → secflow_cli-0.2.3.dist-info}/top_level.txt +0 -0
secflow_cli/__init__.py
CHANGED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
"""Independent operator CLI; no server imports or executable replacement."""
|
|
@@ -1,12 +1,8 @@
|
|
|
1
|
-
"""Default access profile metadata through the existing org-admin API."""
|
|
2
1
|
from collections.abc import Mapping
|
|
3
|
-
from uuid import UUID
|
|
4
|
-
from uuid import uuid4
|
|
5
|
-
import re
|
|
6
2
|
import sys
|
|
7
3
|
|
|
8
|
-
from secflow_sdk import SecFlowConfigError, SecFlowSdkError
|
|
9
4
|
from secflow_cli.account_commands import required_input
|
|
5
|
+
from secflow_cli.command_support import canonical_id, idempotency_key, is_uuid, run_mutation, typed_confirm
|
|
10
6
|
from secflow_cli.errors import CliError
|
|
11
7
|
|
|
12
8
|
FLAGS = ("allow_cli_login", "allow_api_session", "allow_namespace_request")
|
|
@@ -28,66 +24,37 @@ def add_commands(groups):
|
|
|
28
24
|
|
|
29
25
|
def prepare(args):
|
|
30
26
|
profile_id = required_input(args, "profile_id", "profile ID")
|
|
31
|
-
|
|
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
|
|
27
|
+
canonical_id(profile_id, "Supply a canonical profile UUID.")
|
|
36
28
|
profile = dict(profile_id=profile_id, domain="intflow.ai", roles=[], allow_audit_read=False, allow_plaintext_read=False)
|
|
37
29
|
for field in FLAGS:
|
|
38
30
|
value = required_input(args, field, field.replace("_", " "))
|
|
39
31
|
if value not in ("true", "false"):
|
|
40
32
|
raise CliError(2, "INVALID_INPUT", "Profile settings must be true or false.")
|
|
41
33
|
profile[field] = value == "true"
|
|
42
|
-
key = args
|
|
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.")
|
|
34
|
+
key = idempotency_key(args, "cli-access-profile-", "Supply a normalized idempotency key of 16-128 characters.")
|
|
45
35
|
print("Replace global default access profile: " + profile_id, file=sys.stderr)
|
|
46
36
|
for field in FLAGS:
|
|
47
37
|
print(field + ": " + str(profile[field]).lower(), file=sys.stderr)
|
|
48
38
|
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
|
-
|
|
50
|
-
|
|
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.")
|
|
39
|
+
typed_confirm(args, profile_id, prompt="Type the profile ID to confirm: ", cancelled="Profile replacement cancelled; no mutation was sent.",
|
|
40
|
+
required="Supply all settings and --yes for unattended execution or when stdin carries a token.")
|
|
59
41
|
return dict(profile=profile, idempotency_key=key)
|
|
60
42
|
|
|
61
43
|
|
|
62
|
-
def execute(client, values
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
44
|
+
def execute(client, values):
|
|
45
|
+
data = dict(idempotency_key=values["idempotency_key"], outcome="unknown", profile=None)
|
|
46
|
+
return run_mutation(data, lambda: send(client, values))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def send(client, values):
|
|
50
|
+
result = metadata(client.put_default_access_profile(**values))
|
|
51
|
+
if result != values["profile"]:
|
|
52
|
+
raise CliError(7, "INVALID_RESPONSE", "The server did not confirm the requested profile.")
|
|
53
|
+
return dict(profile=result)
|
|
81
54
|
|
|
82
55
|
|
|
83
56
|
def metadata(value):
|
|
84
|
-
|
|
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") != []
|
|
57
|
+
if (not isinstance(value, Mapping) or not is_uuid(value.get("profile_id")) or value.get("domain") != "intflow.ai" or value.get("roles") != []
|
|
91
58
|
or any(type(value.get(field)) is not bool for field in FLAGS)
|
|
92
59
|
or value.get("allow_audit_read") is not False or value.get("allow_plaintext_read") is not False):
|
|
93
60
|
raise CliError(7, "INVALID_RESPONSE", "The server returned an invalid default access profile.")
|
secflow_cli/account_commands.py
CHANGED
|
@@ -1,20 +1,16 @@
|
|
|
1
|
-
"""Additive account mutations using existing Admin API contracts."""
|
|
2
1
|
from __future__ import annotations
|
|
3
2
|
|
|
4
|
-
import re
|
|
5
|
-
import hashlib
|
|
6
3
|
import sys
|
|
7
4
|
from collections.abc import Mapping
|
|
8
|
-
from uuid import UUID, uuid4
|
|
9
5
|
|
|
10
|
-
from
|
|
11
|
-
|
|
12
|
-
from secflow_cli.command_support import preview_delete, require_confirmable
|
|
6
|
+
from secflow_cli.command_support import canonical_id, correlation, idempotency_key, preview_delete, require_confirmable, run_mutation, typed_confirm
|
|
13
7
|
from secflow_cli.errors import CliError
|
|
8
|
+
from secflow_cli.output import account_metadata
|
|
14
9
|
|
|
15
10
|
|
|
16
11
|
ACTIONS = ("create", "update", "reactivate", "suspend", "delete")
|
|
17
12
|
DESTRUCTIVE_ACTIONS = frozenset({"suspend", "delete"})
|
|
13
|
+
ACCOUNT_UUID = "Supply an exact canonical account UUID."
|
|
18
14
|
|
|
19
15
|
|
|
20
16
|
def add_commands(commands):
|
|
@@ -46,18 +42,11 @@ def required_input(args, field, label):
|
|
|
46
42
|
|
|
47
43
|
|
|
48
44
|
def prepare(args):
|
|
49
|
-
"""
|
|
50
|
-
key = args.idempotency_key or "cli-account-" + str(uuid4())
|
|
51
|
-
if not re.fullmatch(r"[A-Za-z0-9_.:-]{16,128}", key):
|
|
52
|
-
raise CliError(2, "INVALID_INPUT", "Idempotency keys must contain 16-128 letters, digits, dots, underscores, colons, or hyphens.")
|
|
45
|
+
key = idempotency_key(args, "cli-account-", "Idempotency keys must contain 16-128 letters, digits, dots, underscores, colons, or hyphens.")
|
|
53
46
|
values = {"idempotency_key": key}
|
|
54
47
|
if args.action == "create":
|
|
55
48
|
values["email"] = required_input(args, "email", "email")
|
|
56
|
-
|
|
57
|
-
# Keep these stable when a user retries in a later CLI process.
|
|
58
|
-
operation = hashlib.sha256(key.encode("ascii")).hexdigest()
|
|
59
|
-
values["request_id"] = "cli-request-" + operation
|
|
60
|
-
values["trace_id"] = "cli-trace-" + operation
|
|
49
|
+
values.update(correlation(key))
|
|
61
50
|
else:
|
|
62
51
|
values["account_id"] = required_input(args, "account_id", "account ID")
|
|
63
52
|
if args.action == "update":
|
|
@@ -67,21 +56,13 @@ def prepare(args):
|
|
|
67
56
|
if args.action == "reactivate":
|
|
68
57
|
values["status"] = "active"
|
|
69
58
|
if args.action == "delete":
|
|
70
|
-
|
|
59
|
+
canonical_id(values["account_id"], ACCOUNT_UUID)
|
|
71
60
|
require_confirmable(args, "Supply the exact account ID and --yes for unattended execution or when stdin carries a token.")
|
|
72
61
|
elif args.action in DESTRUCTIVE_ACTIONS:
|
|
73
62
|
confirm_destructive(args, values["account_id"])
|
|
74
63
|
return values
|
|
75
64
|
|
|
76
65
|
|
|
77
|
-
def canonical_account(account_id):
|
|
78
|
-
try:
|
|
79
|
-
if str(UUID(account_id)) != account_id:
|
|
80
|
-
raise ValueError
|
|
81
|
-
except ValueError:
|
|
82
|
-
raise CliError(2, "INVALID_INPUT", "Supply an exact canonical account UUID.") from None
|
|
83
|
-
|
|
84
|
-
|
|
85
66
|
def preview(args, client, values):
|
|
86
67
|
return preview_delete(args, client, kind="account", target=values["account_id"], operation="accounts.impact",
|
|
87
68
|
query={"account_id": values["account_id"]},
|
|
@@ -89,55 +70,34 @@ def preview(args, client, values):
|
|
|
89
70
|
|
|
90
71
|
|
|
91
72
|
def confirm_destructive(args, account_id):
|
|
92
|
-
|
|
73
|
+
canonical_id(account_id, ACCOUNT_UUID)
|
|
93
74
|
print("Suspend account: " + account_id, file=sys.stderr)
|
|
94
75
|
print("Operation semantics, not a server impact preview: suspension revokes active Admin sessions and SDK tokens. Reactivation does not restore revoked credentials. Affected counts are unknown.", file=sys.stderr)
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
answer = input()
|
|
102
|
-
except EOFError:
|
|
103
|
-
answer = None
|
|
104
|
-
if answer != account_id:
|
|
105
|
-
raise CliError(2, "CONFIRMATION_CANCELLED", "Account operation cancelled; no mutation was sent.")
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
def execute(args, client, values, *, account_metadata, mapped_error):
|
|
109
|
-
key = values["idempotency_key"]
|
|
110
|
-
print("Idempotency key: " + key, file=sys.stderr)
|
|
111
|
-
data = {"idempotency_key": key, "outcome": "unknown", "account": None}
|
|
76
|
+
typed_confirm(args, account_id, prompt="Type the account ID to confirm: ", cancelled="Account operation cancelled; no mutation was sent.",
|
|
77
|
+
required="Supply the exact account ID and --yes for unattended execution or when stdin carries a token.")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def execute(args, client, values):
|
|
81
|
+
data = {"idempotency_key": values["idempotency_key"], "outcome": "unknown", "account": None}
|
|
112
82
|
if args.action == "delete":
|
|
113
83
|
data["deletion"] = None
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
error = mapped_error(caught)
|
|
135
|
-
if isinstance(caught, SecFlowConfigError):
|
|
136
|
-
data["outcome"] = "not_sent"
|
|
137
|
-
elif error.status in (2, 3, 4, 5, 6):
|
|
138
|
-
# The final request was rejected; an earlier attempt using this
|
|
139
|
-
# key may have completed. Never imply rollback of that attempt.
|
|
140
|
-
data["outcome"] = "not_confirmed"
|
|
141
|
-
else:
|
|
142
|
-
error = CliError(7, "MUTATION_OUTCOME_UNKNOWN", "Mutation completion is unknown. Inspect current state; any retry must use identical inputs and the same idempotency key.")
|
|
143
|
-
return data, error
|
|
84
|
+
return run_mutation(data, lambda: send(args, client, values))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def send(args, client, values):
|
|
88
|
+
method = {"create": client.create_account, "update": client.patch_account,
|
|
89
|
+
"reactivate": client.patch_account, "suspend": client.suspend_account,
|
|
90
|
+
"delete": client.delete_account}[args.action]
|
|
91
|
+
response = method(**values)
|
|
92
|
+
if args.action == "delete":
|
|
93
|
+
if (not isinstance(response, Mapping) or response.get("kind") != "account"
|
|
94
|
+
or response.get("id") != values["account_id"] or response.get("deleted") is not True
|
|
95
|
+
or not isinstance(response.get("label"), str)):
|
|
96
|
+
raise CliError(7, "INVALID_RESPONSE", "The server did not confirm deletion of the selected account.")
|
|
97
|
+
return {"deletion": {name: response[name] for name in ("kind", "id", "label", "deleted")}}
|
|
98
|
+
account = account_metadata(response)
|
|
99
|
+
if not isinstance(account.get("account_id"), str) or not account["account_id"]:
|
|
100
|
+
raise CliError(7, "INVALID_RESPONSE", "The server did not return confirmed account metadata.")
|
|
101
|
+
if args.action == "suspend" and (account["account_id"] != values["account_id"] or account.get("status") != "suspended"):
|
|
102
|
+
raise CliError(7, "INVALID_RESPONSE", "The server did not confirm suspension of the selected account.")
|
|
103
|
+
return {"account": account}
|
secflow_cli/browser_login.py
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
"""Human browser login; Auth credentials stay on the SecFlow server."""
|
|
2
1
|
from __future__ import annotations
|
|
3
2
|
|
|
4
3
|
import base64
|
|
@@ -55,6 +54,27 @@ def acquire_session(url, timeout, *, no_browser=False):
|
|
|
55
54
|
raise rejected() from None
|
|
56
55
|
|
|
57
56
|
|
|
57
|
+
def _open_browser(verification_uri):
|
|
58
|
+
try:
|
|
59
|
+
opened = webbrowser.open(verification_uri)
|
|
60
|
+
except Exception:
|
|
61
|
+
opened = False
|
|
62
|
+
if not opened:
|
|
63
|
+
print("Open the URL above manually to continue.", file=sys.stderr)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _issued_session(result):
|
|
67
|
+
token = result.get("session_token")
|
|
68
|
+
if not isinstance(token, str) or not re.fullmatch(r"[A-Za-z0-9_-]{32,256}", token):
|
|
69
|
+
raise rejected()
|
|
70
|
+
if any(str(UUID(result.get(field))) != result[field] for field in ("session_id", "account_id")):
|
|
71
|
+
raise rejected()
|
|
72
|
+
expiry = datetime.fromisoformat(result["expires_at"].replace("Z", "+00:00"))
|
|
73
|
+
if expiry.tzinfo is None or expiry <= datetime.now(timezone.utc):
|
|
74
|
+
raise rejected()
|
|
75
|
+
return {field: result[field] for field in ("session_token", "session_id", "account_id", "expires_at")}
|
|
76
|
+
|
|
77
|
+
|
|
58
78
|
def _acquire_session(url, timeout, *, no_browser):
|
|
59
79
|
proof = base64.urlsafe_b64encode(os.urandom(32)).rstrip(b"=").decode("ascii")
|
|
60
80
|
started_at = time.monotonic()
|
|
@@ -76,12 +96,7 @@ def _acquire_session(url, timeout, *, no_browser):
|
|
|
76
96
|
print("Open " + verification_uri, file=sys.stderr)
|
|
77
97
|
print("Confirm code " + created["user_code"] + " and approve the login in your browser.", file=sys.stderr)
|
|
78
98
|
if not no_browser:
|
|
79
|
-
|
|
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)
|
|
99
|
+
_open_browser(verification_uri)
|
|
85
100
|
interval = created["interval"]
|
|
86
101
|
while time.monotonic() + interval < deadline:
|
|
87
102
|
time.sleep(interval)
|
|
@@ -97,15 +112,7 @@ def _acquire_session(url, timeout, *, no_browser):
|
|
|
97
112
|
continue
|
|
98
113
|
if status != 200:
|
|
99
114
|
raise rejected()
|
|
100
|
-
|
|
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")}
|
|
115
|
+
return _issued_session(result)
|
|
109
116
|
raise CliError(3, "BROWSER_LOGIN_EXPIRED", "Browser login expired. Start a new login.")
|
|
110
117
|
|
|
111
118
|
|
|
@@ -119,8 +126,6 @@ def login(url, timeout, store, reference, *, no_browser=False):
|
|
|
119
126
|
raise rejected()
|
|
120
127
|
store.set(reference, session["session_token"])
|
|
121
128
|
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
129
|
try:
|
|
125
130
|
client.revoke_current_session()
|
|
126
131
|
except Exception:
|
secflow_cli/command_support.py
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
"""Shared validation for operator commands; never render request payloads."""
|
|
2
1
|
import hashlib
|
|
3
2
|
import re
|
|
4
3
|
import sys
|
|
5
4
|
from uuid import UUID, uuid4
|
|
6
5
|
|
|
6
|
+
from secflow_sdk import SecFlowConfigError, SecFlowSdkError
|
|
7
|
+
|
|
7
8
|
from secflow_cli.errors import CliError
|
|
9
|
+
from secflow_cli.output import mapped_error
|
|
8
10
|
|
|
9
11
|
|
|
10
12
|
def mutation_options(command, *, confirm=False):
|
|
@@ -14,39 +16,72 @@ def mutation_options(command, *, confirm=False):
|
|
|
14
16
|
command.add_argument('--yes', action='store_true')
|
|
15
17
|
|
|
16
18
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
KEY_PATTERN = r'[A-Za-z0-9_.:-]{16,128}'
|
|
20
|
+
UNKNOWN_OUTCOME = 'Mutation completion is unknown. Inspect current state; any retry must use identical inputs and the same idempotency key.'
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def idempotency_key(args, prefix, message):
|
|
24
|
+
key = args.idempotency_key or prefix + str(uuid4())
|
|
25
|
+
if not re.fullmatch(KEY_PATTERN, key):
|
|
26
|
+
raise CliError(2, 'INVALID_INPUT', message)
|
|
21
27
|
return key
|
|
22
28
|
|
|
23
29
|
|
|
30
|
+
def mutation_key(args):
|
|
31
|
+
return idempotency_key(args, 'cli-operation-', 'Idempotency keys must contain 16-128 normalized characters.')
|
|
32
|
+
|
|
33
|
+
|
|
24
34
|
def correlation(key):
|
|
25
35
|
digest = hashlib.sha256(key.encode('ascii')).hexdigest()
|
|
26
36
|
return dict(request_id='cli-request-' + digest, trace_id='cli-trace-' + digest)
|
|
27
37
|
|
|
28
38
|
|
|
29
|
-
def
|
|
39
|
+
def is_uuid(value):
|
|
30
40
|
try:
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
pass
|
|
35
|
-
raise CliError(2, 'INVALID_INPUT', 'Supply a canonical resource UUID.')
|
|
41
|
+
return isinstance(value, str) and str(UUID(value)) == value
|
|
42
|
+
except ValueError:
|
|
43
|
+
return False
|
|
36
44
|
|
|
37
45
|
|
|
38
|
-
def
|
|
46
|
+
def canonical_id(value, message='Supply a canonical resource UUID.'):
|
|
47
|
+
if not is_uuid(value):
|
|
48
|
+
raise CliError(2, 'INVALID_INPUT', message)
|
|
49
|
+
return value
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def typed_confirm(args, target, *, prompt, cancelled, required=None):
|
|
39
53
|
if args.yes:
|
|
40
54
|
return
|
|
41
|
-
if
|
|
42
|
-
|
|
43
|
-
print(
|
|
55
|
+
if required is not None:
|
|
56
|
+
require_confirmable(args, required)
|
|
57
|
+
print(prompt, end='', file=sys.stderr, flush=True)
|
|
44
58
|
try:
|
|
45
59
|
answer = input()
|
|
46
60
|
except EOFError:
|
|
47
61
|
answer = None
|
|
48
62
|
if answer != target:
|
|
49
|
-
raise CliError(2, 'CONFIRMATION_CANCELLED',
|
|
63
|
+
raise CliError(2, 'CONFIRMATION_CANCELLED', cancelled)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def confirm(args, target):
|
|
67
|
+
typed_confirm(args, target, prompt='Type the exact target to confirm: ' + target + '\n', cancelled='Cancelled; no mutation was sent.',
|
|
68
|
+
required='Inspect the exact target, then supply --yes to confirm this change.')
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def run_mutation(data, send):
|
|
72
|
+
print('Idempotency key: ' + data['idempotency_key'], file=sys.stderr)
|
|
73
|
+
try:
|
|
74
|
+
data.update(send(), outcome='confirmed')
|
|
75
|
+
return data, None
|
|
76
|
+
except (SecFlowSdkError, CliError) as caught:
|
|
77
|
+
error = mapped_error(caught)
|
|
78
|
+
if isinstance(caught, SecFlowConfigError):
|
|
79
|
+
data['outcome'] = 'not_sent'
|
|
80
|
+
elif error.status in (2, 3, 4, 5, 6):
|
|
81
|
+
data['outcome'] = 'not_confirmed'
|
|
82
|
+
else:
|
|
83
|
+
error = CliError(7, 'MUTATION_OUTCOME_UNKNOWN', UNKNOWN_OUTCOME)
|
|
84
|
+
return data, error
|
|
50
85
|
|
|
51
86
|
|
|
52
87
|
BLOCKING = frozenset({'current_account'})
|
|
@@ -58,9 +93,8 @@ def require_confirmable(args, message):
|
|
|
58
93
|
|
|
59
94
|
|
|
60
95
|
def server_impact(client, operation, query):
|
|
61
|
-
from secflow_sdk import SecFlowSdkError
|
|
62
96
|
try:
|
|
63
|
-
response = client.
|
|
97
|
+
response = client.get_operator_metadata(operation, parameters=query)
|
|
64
98
|
except SecFlowSdkError:
|
|
65
99
|
return None
|
|
66
100
|
impact, blocking = response.get('impact'), response.get('blocking')
|
|
@@ -72,7 +106,6 @@ def server_impact(client, operation, query):
|
|
|
72
106
|
|
|
73
107
|
|
|
74
108
|
def preview_delete(args, client, *, kind, target, operation, query, semantics):
|
|
75
|
-
"""Show the server's current-state impact, then require exact confirmation."""
|
|
76
109
|
print('Delete ' + kind + ': ' + target, file=sys.stderr)
|
|
77
110
|
impact = server_impact(client, operation, query)
|
|
78
111
|
if impact is None:
|
|
@@ -82,12 +115,5 @@ def preview_delete(args, client, *, kind, target, operation, query, semantics):
|
|
|
82
115
|
print('Server impact preview of current state (not a reservation): ' + counts, file=sys.stderr)
|
|
83
116
|
if impact['blocking']:
|
|
84
117
|
raise CliError(6, 'PRECONDITION_FAILED', 'The server reports a blocking precondition (' + ', '.join(impact['blocking']) + '); no mutation was sent.')
|
|
85
|
-
|
|
86
|
-
print('Type the ' + kind + ' to confirm: ', end='', file=sys.stderr, flush=True)
|
|
87
|
-
try:
|
|
88
|
-
answer = input()
|
|
89
|
-
except EOFError:
|
|
90
|
-
answer = None
|
|
91
|
-
if answer != target:
|
|
92
|
-
raise CliError(2, 'CONFIRMATION_CANCELLED', kind.capitalize() + ' deletion cancelled; no mutation was sent.')
|
|
118
|
+
typed_confirm(args, target, prompt='Type the ' + kind + ' to confirm: ', cancelled=kind.capitalize() + ' deletion cancelled; no mutation was sent.')
|
|
93
119
|
return impact
|
secflow_cli/completion.py
CHANGED
secflow_cli/config.py
CHANGED
|
@@ -51,48 +51,88 @@ def credential_ref(name: str, url: str) -> str:
|
|
|
51
51
|
return "admin:" + hashlib.sha256((profile_name(name) + "\n" + endpoint(url)).encode()).hexdigest()
|
|
52
52
|
|
|
53
53
|
|
|
54
|
+
def access_denied(directory: Path) -> CliError:
|
|
55
|
+
return CliError(
|
|
56
|
+
2,
|
|
57
|
+
"CONFIG_ACCESS_DENIED",
|
|
58
|
+
f"This account has no permission to use the CLI configuration folder {directory}. "
|
|
59
|
+
"Fix the folder permissions or delete it, or set SECFLOW_CLI_CONFIG_DIR to another folder.",
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def require_readable_folder(directory: Path) -> None:
|
|
64
|
+
try:
|
|
65
|
+
os.listdir(directory)
|
|
66
|
+
except FileNotFoundError:
|
|
67
|
+
return
|
|
68
|
+
except PermissionError:
|
|
69
|
+
raise access_denied(directory) from None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def config_directory() -> Path:
|
|
73
|
+
override = os.environ.get("SECFLOW_CLI_CONFIG_DIR")
|
|
74
|
+
if override:
|
|
75
|
+
return Path(override).expanduser()
|
|
76
|
+
if sys.platform == "win32":
|
|
77
|
+
return Path(os.environ.get("APPDATA", str(Path.home() / "AppData/Roaming"))) / "SecFlow/cli"
|
|
78
|
+
if sys.platform == "darwin":
|
|
79
|
+
return Path.home() / "Library/Application Support/SecFlow/cli"
|
|
80
|
+
return Path(os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config"))) / "secflow/cli"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def read_profiles_text(path: Path) -> str | None:
|
|
84
|
+
try:
|
|
85
|
+
return path.read_text(encoding="utf-8")
|
|
86
|
+
except FileNotFoundError:
|
|
87
|
+
require_readable_folder(path.parent)
|
|
88
|
+
return None
|
|
89
|
+
except PermissionError:
|
|
90
|
+
raise access_denied(path.parent) from None
|
|
91
|
+
except OSError:
|
|
92
|
+
raise CliError(2, "CONFIG_READ_FAILED", f"Cannot read the CLI profile configuration file {path}.") from None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def parsed_profiles(text: str) -> dict:
|
|
96
|
+
try:
|
|
97
|
+
raw = json.loads(text)
|
|
98
|
+
if not isinstance(raw, dict) or set(raw) != {"version", "current", "profiles"} or raw["version"] != 1 or not isinstance(raw["profiles"], dict):
|
|
99
|
+
raise ValueError
|
|
100
|
+
for name, profile in raw["profiles"].items():
|
|
101
|
+
profile_name(name)
|
|
102
|
+
if not isinstance(profile, dict) or set(profile) != {"base_url", "credential_ref"}:
|
|
103
|
+
raise ValueError
|
|
104
|
+
if profile["credential_ref"] != credential_ref(name, endpoint(profile["base_url"])):
|
|
105
|
+
raise ValueError
|
|
106
|
+
if raw["current"] is not None and raw["current"] not in raw["profiles"]:
|
|
107
|
+
raise ValueError
|
|
108
|
+
return raw
|
|
109
|
+
except (ValueError, TypeError, KeyError):
|
|
110
|
+
raise CliError(2, "INVALID_CONFIG", "Cannot read valid CLI profile configuration.") from None
|
|
111
|
+
|
|
112
|
+
|
|
54
113
|
class Profiles:
|
|
55
114
|
def __init__(self):
|
|
56
|
-
|
|
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": {}}
|
|
115
|
+
self.path = config_directory().resolve() / "profiles.json"
|
|
69
116
|
try:
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
|
117
|
+
inside_repository = any((ancestor / ".git").exists() for ancestor in self.path.parents)
|
|
118
|
+
except PermissionError:
|
|
119
|
+
raise access_denied(self.path.parent) from None
|
|
120
|
+
if inside_repository:
|
|
121
|
+
raise CliError(2, "INVALID_CONFIG_LOCATION", "CLI configuration must be outside a repository.")
|
|
122
|
+
text = read_profiles_text(self.path)
|
|
123
|
+
self.data = parsed_profiles(text) if text is not None else {"version": 1, "current": None, "profiles": {}}
|
|
86
124
|
|
|
87
125
|
def save(self):
|
|
88
|
-
self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
89
126
|
temporary = None
|
|
90
127
|
try:
|
|
128
|
+
self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
91
129
|
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=self.path.parent, delete=False) as stream:
|
|
92
130
|
temporary = Path(stream.name)
|
|
93
131
|
json.dump(self.data, stream, indent=2)
|
|
94
132
|
stream.write("\n")
|
|
95
133
|
os.replace(temporary, self.path)
|
|
134
|
+
except PermissionError:
|
|
135
|
+
raise access_denied(self.path.parent) from None
|
|
96
136
|
except OSError:
|
|
97
137
|
raise CliError(2, "CONFIG_WRITE_FAILED", "Cannot save CLI profile configuration.") from None
|
|
98
138
|
finally:
|
secflow_cli/credentials.py
CHANGED
|
@@ -4,7 +4,6 @@ from secflow_cli.errors import CliError
|
|
|
4
4
|
|
|
5
5
|
|
|
6
6
|
class CredentialStore:
|
|
7
|
-
"""Only explicitly supported OS stores; never file-backed fallback plugins."""
|
|
8
7
|
|
|
9
8
|
service = "secflow-cli"
|
|
10
9
|
supported = frozenset({
|
|
@@ -24,9 +23,6 @@ class CredentialStore:
|
|
|
24
23
|
import keyring
|
|
25
24
|
backend = keyring.get_keyring()
|
|
26
25
|
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
26
|
if not self.allow_interaction and kind != ("keyring.backends.Windows", "WinVaultKeyring"):
|
|
31
27
|
raise RuntimeError
|
|
32
28
|
if kind not in self.supported or backend.priority <= 0:
|