secflow-cli 0.2.2__py3-none-any.whl → 0.2.4__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 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
- 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
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.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.")
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
- 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.")
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, *, 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
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
- 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") != []
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.")
@@ -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 secflow_sdk import SecFlowConfigError, SecFlowSdkError
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
- """Validate metadata and the retry key before any mutation is sent."""
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
- # The server digests the complete request, including correlation IDs.
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
- canonical_account(values["account_id"])
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
- canonical_account(account_id)
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
- if args.yes:
96
- return
97
- if args.non_interactive or not sys.stdin.isatty() or args.token_stdin:
98
- raise CliError(2, "CONFIRMATION_REQUIRED", "Supply the exact account ID and --yes for unattended execution or when stdin carries a token.")
99
- print("Type the account ID to confirm: ", end="", file=sys.stderr, flush=True)
100
- try:
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
- try:
115
- method = {"create": client.create_account, "update": client.patch_account,
116
- "reactivate": client.patch_account, "suspend": client.suspend_account,
117
- "delete": client.delete_account}[args.action]
118
- response = method(**values)
119
- if args.action == "delete":
120
- if (not isinstance(response, Mapping) or response.get("kind") != "account"
121
- or response.get("id") != values["account_id"] or response.get("deleted") is not True
122
- or not isinstance(response.get("label"), str)):
123
- raise CliError(7, "INVALID_RESPONSE", "The server did not confirm deletion of the selected account.")
124
- data.update(outcome="confirmed", deletion={name: response[name] for name in ("kind", "id", "label", "deleted")})
125
- return data, None
126
- account = account_metadata(response)
127
- if not isinstance(account.get("account_id"), str) or not account["account_id"]:
128
- raise CliError(7, "INVALID_RESPONSE", "The server did not return confirmed account metadata.")
129
- if args.action == "suspend" and (account["account_id"] != values["account_id"] or account.get("status") != "suspended"):
130
- raise CliError(7, "INVALID_RESPONSE", "The server did not confirm suspension of the selected account.")
131
- data.update(account=account, outcome="confirmed")
132
- return data, None
133
- except (SecFlowSdkError, CliError) as caught:
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}
@@ -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
- 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)
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
- 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")}
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:
@@ -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
- def mutation_key(args):
18
- key = args.idempotency_key or 'cli-operation-' + str(uuid4())
19
- if not re.fullmatch(r'[A-Za-z0-9_.:-]{16,128}', key):
20
- raise CliError(2, 'INVALID_INPUT', 'Idempotency keys must contain 16-128 normalized characters.')
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 canonical_id(value):
39
+ def is_uuid(value):
30
40
  try:
31
- if str(UUID(value)) == value:
32
- return value
33
- except (ValueError, TypeError, AttributeError):
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 confirm(args, target):
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 args.non_interactive or args.token_stdin or not sys.stdin.isatty():
42
- raise CliError(2, 'CONFIRMATION_REQUIRED', 'Inspect the exact target, then supply --yes to confirm this change.')
43
- print('Type the exact target to confirm: ' + target, file=sys.stderr)
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', 'Cancelled; no mutation was sent.')
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._request_json('GET', '/v1/admin/operator/' + operation, query=tuple(query.items()))
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
- if not args.yes:
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
@@ -1,4 +1,3 @@
1
- """Offline shell completion generated from the parser; never fetches server catalogs."""
2
1
  import argparse
3
2
  import json
4
3
 
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
- 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": {}}
115
+ self.path = config_directory().resolve() / "profiles.json"
69
116
  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
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:
@@ -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: