intflow-authctl 0.2.1__tar.gz → 0.2.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. intflow_authctl-0.2.3/PKG-INFO +53 -0
  2. intflow_authctl-0.2.3/README.md +32 -0
  3. {intflow_authctl-0.2.1 → intflow_authctl-0.2.3}/pyproject.toml +1 -1
  4. intflow_authctl-0.2.3/src/intflow_authctl/__init__.py +1 -0
  5. {intflow_authctl-0.2.1 → intflow_authctl-0.2.3}/src/intflow_authctl/api_client.py +9 -6
  6. {intflow_authctl-0.2.1 → intflow_authctl-0.2.3}/src/intflow_authctl/audit.py +0 -2
  7. {intflow_authctl-0.2.1 → intflow_authctl-0.2.3}/src/intflow_authctl/cli.py +1 -3
  8. {intflow_authctl-0.2.1 → intflow_authctl-0.2.3}/src/intflow_authctl/clients.py +77 -2
  9. {intflow_authctl-0.2.1 → intflow_authctl-0.2.3}/src/intflow_authctl/command_utils.py +1 -7
  10. {intflow_authctl-0.2.1 → intflow_authctl-0.2.3}/src/intflow_authctl/commands.py +64 -20
  11. {intflow_authctl-0.2.1 → intflow_authctl-0.2.3}/src/intflow_authctl/config.py +73 -9
  12. {intflow_authctl-0.2.1 → intflow_authctl-0.2.3}/src/intflow_authctl/constants.py +0 -2
  13. {intflow_authctl-0.2.1 → intflow_authctl-0.2.3}/src/intflow_authctl/credentials.py +7 -13
  14. {intflow_authctl-0.2.1 → intflow_authctl-0.2.3}/src/intflow_authctl/discovery.py +5 -2
  15. intflow_authctl-0.2.3/src/intflow_authctl/errors.py +9 -0
  16. {intflow_authctl-0.2.1 → intflow_authctl-0.2.3}/src/intflow_authctl/external_access.py +1 -5
  17. {intflow_authctl-0.2.1 → intflow_authctl-0.2.3}/src/intflow_authctl/output.py +43 -14
  18. {intflow_authctl-0.2.1 → intflow_authctl-0.2.3}/src/intflow_authctl/parser.py +136 -8
  19. {intflow_authctl-0.2.1 → intflow_authctl-0.2.3}/src/intflow_authctl/secret_output.py +13 -7
  20. {intflow_authctl-0.2.1 → intflow_authctl-0.2.3}/src/intflow_authctl/tokens.py +11 -8
  21. {intflow_authctl-0.2.1 → intflow_authctl-0.2.3}/src/intflow_authctl/users.py +0 -2
  22. {intflow_authctl-0.2.1 → intflow_authctl-0.2.3}/src/intflow_authctl/validation.py +8 -10
  23. intflow_authctl-0.2.1/PKG-INFO +0 -116
  24. intflow_authctl-0.2.1/README.md +0 -95
  25. intflow_authctl-0.2.1/src/intflow_authctl/__init__.py +0 -3
  26. intflow_authctl-0.2.1/src/intflow_authctl/errors.py +0 -5
  27. {intflow_authctl-0.2.1 → intflow_authctl-0.2.3}/.gitignore +0 -0
  28. {intflow_authctl-0.2.1 → intflow_authctl-0.2.3}/src/intflow_authctl/py.typed +0 -0
@@ -0,0 +1,53 @@
1
+ Metadata-Version: 2.4
2
+ Name: intflow-authctl
3
+ Version: 0.2.3
4
+ Summary: Intflow Auth operator CLI.
5
+ Author: Intflow
6
+ License: Proprietary
7
+ Requires-Python: >=3.12
8
+ Requires-Dist: httpx<1,>=0.28
9
+ Requires-Dist: keyring<26,>=25
10
+ Provides-Extra: dev
11
+ Requires-Dist: build<2,>=1.2; extra == 'dev'
12
+ Requires-Dist: hatchling==1.31.0; extra == 'dev'
13
+ Requires-Dist: mypy<3,>=1.13; extra == 'dev'
14
+ Requires-Dist: packaging<27,>=24; extra == 'dev'
15
+ Requires-Dist: pytest-cov<8,>=6; extra == 'dev'
16
+ Requires-Dist: pytest-timeout<3,>=2.4; extra == 'dev'
17
+ Requires-Dist: pytest<10,>=8; extra == 'dev'
18
+ Requires-Dist: ruff<1,>=0.9; extra == 'dev'
19
+ Requires-Dist: twine<7,>=6; extra == 'dev'
20
+ Description-Content-Type: text/markdown
21
+
22
+ # intflow-authctl
23
+
24
+ Intflow Auth 운영자용 CLI의 Python 배포판입니다. 권한을 받은 운영자가 사용자, OAuth client, 운영자 토큰, 감사 기록을 관리할 때 씁니다. 서비스 자체나 데이터베이스 migration은 들어 있지 않습니다.
25
+
26
+ ## 요구 사항
27
+
28
+ - Python 3.12 이상
29
+ - Intflow Auth `operator` 또는 `admin` 역할
30
+
31
+ ## 설치
32
+
33
+ ```bash
34
+ python -m pip install --user --upgrade intflow-authctl
35
+ ```
36
+
37
+ ## 사용 예
38
+
39
+ ```bash
40
+ intflow-authctl login
41
+ intflow-authctl whoami
42
+ intflow-authctl clients list
43
+ intflow-authctl clients rotate-secret <oauth-client-id> --secret-file /private/rotation.json
44
+ intflow-authctl logout --revoke
45
+ ```
46
+
47
+ 로그인한 운영자 신원과 client 목록을 확인합니다. 교체한 client secret은 `--secret-file`로 지정한 보호된 새 파일에만 저장되고, `logout --revoke`는 서버 토큰도 폐기합니다. 운영자 자격증명은 지원되는 OS 자격증명 저장소에 둡니다.
48
+
49
+ ## 문서
50
+
51
+ - 명령과 옵션: `intflow-authctl --help`와 `intflow-authctl docs`, 접근 진단은 `intflow-authctl doctor`
52
+ - 상세 문서: 저장소 `yeon3724/intflow-auth`의 `docs/operations.md` (Routine administration, 영어)
53
+ - 저장소: `yeon3724/intflow-auth` (읽기 전용 미러 `intflow/intflow-auth`)
@@ -0,0 +1,32 @@
1
+ # intflow-authctl
2
+
3
+ Intflow Auth 운영자용 CLI의 Python 배포판입니다. 권한을 받은 운영자가 사용자, OAuth client, 운영자 토큰, 감사 기록을 관리할 때 씁니다. 서비스 자체나 데이터베이스 migration은 들어 있지 않습니다.
4
+
5
+ ## 요구 사항
6
+
7
+ - Python 3.12 이상
8
+ - Intflow Auth `operator` 또는 `admin` 역할
9
+
10
+ ## 설치
11
+
12
+ ```bash
13
+ python -m pip install --user --upgrade intflow-authctl
14
+ ```
15
+
16
+ ## 사용 예
17
+
18
+ ```bash
19
+ intflow-authctl login
20
+ intflow-authctl whoami
21
+ intflow-authctl clients list
22
+ intflow-authctl clients rotate-secret <oauth-client-id> --secret-file /private/rotation.json
23
+ intflow-authctl logout --revoke
24
+ ```
25
+
26
+ 로그인한 운영자 신원과 client 목록을 확인합니다. 교체한 client secret은 `--secret-file`로 지정한 보호된 새 파일에만 저장되고, `logout --revoke`는 서버 토큰도 폐기합니다. 운영자 자격증명은 지원되는 OS 자격증명 저장소에 둡니다.
27
+
28
+ ## 문서
29
+
30
+ - 명령과 옵션: `intflow-authctl --help`와 `intflow-authctl docs`, 접근 진단은 `intflow-authctl doctor`
31
+ - 상세 문서: 저장소 `yeon3724/intflow-auth`의 `docs/operations.md` (Routine administration, 영어)
32
+ - 저장소: `yeon3724/intflow-auth` (읽기 전용 미러 `intflow/intflow-auth`)
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "intflow-authctl"
7
- version = "0.2.1"
7
+ version = "0.2.3"
8
8
  description = "Intflow Auth operator CLI."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -0,0 +1 @@
1
+ __version__ = "0.2.3"
@@ -1,12 +1,10 @@
1
- """HTTP transport helpers for the Intflow Auth operator CLI."""
2
-
3
1
  import re
4
2
 
5
3
  import httpx
6
4
 
7
5
  from intflow_authctl.constants import DEFAULT_AUTHCTL_TIMEOUT_SECONDS
8
6
  from intflow_authctl.discovery import permission_hint
9
- from intflow_authctl.errors import AuthctlError
7
+ from intflow_authctl.errors import AuthctlError, AuthctlHttpError
10
8
  from intflow_authctl.validation import _normalize_base_url
11
9
 
12
10
  MAX_RETRY_AFTER_SECONDS = 86_400
@@ -46,6 +44,7 @@ def _api_request_without_auth(
46
44
  *,
47
45
  client_factory,
48
46
  json_body: dict[str, object] | None = None,
47
+ timeout_seconds: float = DEFAULT_AUTHCTL_TIMEOUT_SECONDS,
49
48
  ) -> dict[str, object]:
50
49
  return _api_request_to_base_url(
51
50
  base_url,
@@ -55,6 +54,7 @@ def _api_request_without_auth(
55
54
  json_body=json_body,
56
55
  params=None,
57
56
  client_factory=client_factory,
57
+ timeout_seconds=timeout_seconds,
58
58
  )
59
59
 
60
60
 
@@ -67,13 +67,14 @@ def _api_request_to_base_url(
67
67
  client_factory,
68
68
  json_body: dict[str, object] | None,
69
69
  params: dict[str, object] | None,
70
+ timeout_seconds: float = DEFAULT_AUTHCTL_TIMEOUT_SECONDS,
70
71
  ) -> dict[str, object]:
71
72
  base_url = _normalize_base_url(base_url)
72
73
  try:
73
74
  with client_factory(
74
75
  base_url=base_url,
75
76
  headers=headers,
76
- timeout=DEFAULT_AUTHCTL_TIMEOUT_SECONDS,
77
+ timeout=min(timeout_seconds, DEFAULT_AUTHCTL_TIMEOUT_SECONDS),
77
78
  ) as client:
78
79
  request_kwargs: dict[str, object] = {"params": params}
79
80
  if json_body is not None:
@@ -93,10 +94,12 @@ def _api_request_to_base_url(
93
94
  if response.status_code >= 400:
94
95
  retry_after = _retry_after_seconds(response.headers.get("retry-after"))
95
96
  retry_message = "" if retry_after is None else f" Retry after {retry_after} seconds."
96
- raise AuthctlError(
97
+ raise AuthctlHttpError(
97
98
  f"Admin API request failed: {response.status_code} "
98
99
  f"{_safe_error_message(response)}.{retry_message}"
99
- f"{permission_hint(response.status_code, method, path)}"
100
+ f"{permission_hint(response.status_code, method, path)}",
101
+ response.status_code,
102
+ retry_after,
100
103
  )
101
104
  if response.status_code == 204 or not response.content:
102
105
  return {"status": "ok"}
@@ -1,5 +1,3 @@
1
- """Audit-event command handlers for authctl."""
2
-
3
1
  import argparse
4
2
 
5
3
  from intflow_authctl.api_client import _api_request
@@ -1,5 +1,3 @@
1
- """Operator Admin API command-line entry point."""
2
-
3
1
  import sys
4
2
  from typing import TextIO
5
3
 
@@ -39,7 +37,7 @@ def authctl_main(
39
37
  client_factory=httpx.Client,
40
38
  credential_store: AuthctlCredentialStore | None = None,
41
39
  ) -> None:
42
- """Run the routine operator Admin API CLI."""
40
+
43
41
  parser = _build_authctl_parser()
44
42
  args = parser.parse_args(argv)
45
43
  try:
@@ -1,5 +1,3 @@
1
- """OAuth client command handlers for authctl."""
2
-
3
1
  import argparse
4
2
 
5
3
  from intflow_authctl.api_client import _api_request
@@ -63,6 +61,8 @@ def _run_authctl_clients_command(
63
61
  }
64
62
  if args.admin_access_required:
65
63
  create_body["admin_access_required"] = True
64
+ if args.assignment_required:
65
+ create_body["assignment_required"] = True
66
66
  return with_secret_file(
67
67
  args.secret_file,
68
68
  lambda: _api_request(
@@ -89,6 +89,10 @@ def _run_authctl_clients_command(
89
89
  update_body["admin_access_required"] = True
90
90
  if args.no_admin_access_required:
91
91
  update_body["admin_access_required"] = False
92
+ if args.assignment_required:
93
+ update_body["assignment_required"] = True
94
+ if args.no_assignment_required:
95
+ update_body["assignment_required"] = False
92
96
  if not update_body:
93
97
  raise AuthctlError("At least one client update field is required.")
94
98
  return _api_request(
@@ -123,6 +127,18 @@ def _run_authctl_clients_command(
123
127
  config,
124
128
  client_factory=client_factory,
125
129
  )
130
+ if args.clients_command == "roles":
131
+ return _run_authctl_client_roles_command(
132
+ args,
133
+ config,
134
+ client_factory=client_factory,
135
+ )
136
+ if args.clients_command == "pending-roles":
137
+ return _run_authctl_client_pending_roles_command(
138
+ args,
139
+ config,
140
+ client_factory=client_factory,
141
+ )
126
142
  raise AuthctlError(f"Unsupported clients command: {args.clients_command}")
127
143
 
128
144
 
@@ -158,6 +174,65 @@ def _run_authctl_client_admins_command(
158
174
  raise AuthctlError(f"Unsupported clients admins command: {args.client_admins_command}")
159
175
 
160
176
 
177
+ def _run_authctl_client_roles_command(
178
+ args: argparse.Namespace,
179
+ config: dict[str, object],
180
+ *,
181
+ client_factory,
182
+ ) -> dict[str, object]:
183
+ base_path = f"/v1/admin/clients/{args.client_id}"
184
+ if args.client_roles_command == "list":
185
+ return _api_request(
186
+ config,
187
+ "GET",
188
+ f"{base_path}/role-assignments",
189
+ params=_request_params(args, "limit", "cursor", extra={"role_name": args.role}),
190
+ client_factory=client_factory,
191
+ )
192
+ if args.client_roles_command in {"grant", "revoke"}:
193
+ return _api_request(
194
+ config,
195
+ "PUT" if args.client_roles_command == "grant" else "DELETE",
196
+ f"{base_path}/roles/{args.role_name}/users/{args.user_id}",
197
+ client_factory=client_factory,
198
+ )
199
+ raise AuthctlError(f"Unsupported clients roles command: {args.client_roles_command}")
200
+
201
+
202
+ def _run_authctl_client_pending_roles_command(
203
+ args: argparse.Namespace,
204
+ config: dict[str, object],
205
+ *,
206
+ client_factory,
207
+ ) -> dict[str, object]:
208
+ base_path = f"/v1/admin/clients/{args.client_id}/pending-role-grants"
209
+ command = args.client_pending_roles_command
210
+ if command == "list":
211
+ return _api_request(
212
+ config,
213
+ "GET",
214
+ base_path,
215
+ params=_request_params(args, "email", "limit", "cursor"),
216
+ client_factory=client_factory,
217
+ )
218
+ if command == "create":
219
+ return _api_request(
220
+ config,
221
+ "POST",
222
+ base_path,
223
+ json_body={"email": args.email, "role_name": args.role_name},
224
+ client_factory=client_factory,
225
+ )
226
+ if command == "delete":
227
+ return _api_request(
228
+ config,
229
+ "DELETE",
230
+ f"{base_path}/{args.grant_id}",
231
+ client_factory=client_factory,
232
+ )
233
+ raise AuthctlError(f"Unsupported clients pending-roles command: {command}")
234
+
235
+
161
236
  def _validate_cli_redirect_uris(values: list[str]) -> tuple[str, ...]:
162
237
  try:
163
238
  return validate_redirect_uris(values)
@@ -1,5 +1,3 @@
1
- """Shared helpers for authctl command-family modules."""
2
-
3
1
  import argparse
4
2
 
5
3
  from intflow_authctl.output import _response_list
@@ -10,11 +8,7 @@ def _request_params(
10
8
  *names: str,
11
9
  extra: dict[str, object] | None = None,
12
10
  ) -> dict[str, object] | None:
13
- params = {
14
- name: value
15
- for name in names
16
- if (value := getattr(args, name, None)) is not None
17
- }
11
+ params = {name: value for name in names if (value := getattr(args, name, None)) is not None}
18
12
  if extra is not None:
19
13
  params.update({name: value for name, value in extra.items() if value is not None})
20
14
  return params or None
@@ -1,6 +1,5 @@
1
- """Command dispatch for the Intflow Auth operator CLI."""
2
-
3
1
  import argparse
2
+ import math
4
3
  import os
5
4
  import time
6
5
  import webbrowser
@@ -17,13 +16,14 @@ from intflow_authctl.clients import _run_authctl_clients_command
17
16
  from intflow_authctl.config import (
18
17
  _authctl_config_path,
19
18
  _authctl_credential_store,
19
+ _check_credential_storage,
20
20
  _delete_authctl_config,
21
21
  _load_authctl_config,
22
22
  _save_authctl_config,
23
23
  )
24
24
  from intflow_authctl.credentials import AuthctlCredentialStore
25
25
  from intflow_authctl.discovery import documentation
26
- from intflow_authctl.errors import AuthctlError
26
+ from intflow_authctl.errors import AuthctlError, AuthctlHttpError
27
27
  from intflow_authctl.external_access import _run_authctl_external_access_command
28
28
  from intflow_authctl.tokens import _run_authctl_tokens_command
29
29
  from intflow_authctl.users import _run_authctl_users_command
@@ -51,6 +51,7 @@ def _run_authctl_command(
51
51
  webbrowser.open(str(result["api_docs"]))
52
52
  return result
53
53
  if args.command == "login":
54
+ _check_credential_storage(config_path, selected_credential_store)
54
55
  if args.token is not None:
55
56
  print(
56
57
  "WARNING: --token is deprecated because command arguments may enter "
@@ -76,9 +77,9 @@ def _run_authctl_command(
76
77
  )
77
78
 
78
79
  if args.command == "logout":
79
- config = _load_authctl_config(config_path, selected_credential_store)
80
- _warn_if_file_config(config, config_path, error_output)
81
80
  if args.revoke:
81
+ config = _load_authctl_config(config_path, selected_credential_store)
82
+ _warn_if_file_config(config, config_path, error_output)
82
83
  token_id = config.get("token_id")
83
84
  if not isinstance(token_id, str) or not token_id:
84
85
  whoami = _api_request(
@@ -104,6 +105,17 @@ def _run_authctl_command(
104
105
  _delete_authctl_config(config_path, selected_credential_store)
105
106
  return {"status": "logged_out"}
106
107
 
108
+ if args.command == "doctor" and args.check_store:
109
+ _check_credential_storage(config_path, selected_credential_store)
110
+ return {
111
+ "status": "ok",
112
+ "checks": ["config_writable", "credential_store_probe"],
113
+ "credential_backend": (
114
+ selected_credential_store.backend_name if selected_credential_store else "file"
115
+ ),
116
+ "next": "intflow-authctl login",
117
+ }
118
+
107
119
  config = _load_authctl_config(config_path, selected_credential_store)
108
120
  _warn_if_file_config(config, config_path, error_output)
109
121
  if args.command in {"whoami", "doctor"}:
@@ -207,6 +219,9 @@ def _run_authctl_browser_login(
207
219
  ) -> dict[str, object]:
208
220
  base_url = _normalize_base_url(args.base_url)
209
221
  ttl_seconds = _parse_ttl_seconds(args.ttl)
222
+ timeout = _positive_float(args.timeout_seconds, "timeout-seconds")
223
+ if not math.isfinite(args.poll_interval):
224
+ raise AuthctlError("poll-interval must be finite.")
210
225
  start_body: dict[str, object] = {
211
226
  "name": args.name,
212
227
  "ttl_seconds": ttl_seconds,
@@ -227,28 +242,57 @@ def _run_authctl_browser_login(
227
242
  if not args.no_browser:
228
243
  webbrowser.open(verification_uri)
229
244
 
230
- deadline = time.monotonic() + _positive_float(args.timeout_seconds, "timeout-seconds")
231
- poll_interval = max(args.poll_interval, 0.0)
245
+ deadline = time.monotonic() + timeout
246
+ advertised = transaction.get("poll_interval_seconds")
247
+ server_interval = (
248
+ float(advertised)
249
+ if isinstance(advertised, (float, int)) and math.isfinite(advertised) and advertised > 0
250
+ else 2.0
251
+ )
252
+ poll_interval = max(args.poll_interval, server_interval, 1.0)
232
253
  while True:
233
- response = _api_request_without_auth(
234
- base_url,
235
- "POST",
236
- f"/v1/cli-login-transactions/{transaction_id}:redeem",
237
- json_body={"poll_secret": poll_secret},
238
- client_factory=client_factory,
239
- )
254
+ remaining = deadline - time.monotonic()
255
+ if remaining <= 0:
256
+ raise AuthctlError("CLI login timed out before browser approval completed.")
257
+ try:
258
+ response = _api_request_without_auth(
259
+ base_url,
260
+ "POST",
261
+ f"/v1/cli-login-transactions/{transaction_id}:redeem",
262
+ json_body={"poll_secret": poll_secret},
263
+ client_factory=client_factory,
264
+ timeout_seconds=remaining,
265
+ )
266
+ except AuthctlHttpError as exc:
267
+ if exc.status != 429:
268
+ raise
269
+ delay = max(poll_interval, exc.retry_after or server_interval)
270
+ print(
271
+ "Login polling was rate limited; waiting within the login deadline.",
272
+ file=error_output,
273
+ )
274
+ time.sleep(min(delay, max(deadline - time.monotonic(), 0.0)))
275
+ continue
240
276
  if response.get("status") != "pending":
241
277
  token = _required_response_string(response, "token")
242
278
  operator_token = response.get("operator_token")
243
279
  config: dict[str, object] = {"base_url": base_url, "token": token}
244
280
  if isinstance(operator_token, dict) and operator_token.get("id"):
245
281
  config["token_id"] = str(operator_token["id"])
246
- _save_authctl_config(
247
- config_path,
248
- config,
249
- token=token,
250
- credential_store=credential_store,
251
- )
282
+ try:
283
+ _save_authctl_config(
284
+ config_path,
285
+ config,
286
+ token=token,
287
+ credential_store=credential_store,
288
+ )
289
+ except (AuthctlError, OSError):
290
+ raise AuthctlError(
291
+ "Login token was issued but local storage failed. Run doctor --check-store, "
292
+ "then log in again; inspect tokens list and explicitly revoke "
293
+ "the unused token. "
294
+ "No token was printed or automatically revoked."
295
+ ) from None
252
296
  return {
253
297
  "status": "logged_in",
254
298
  "base_url": base_url,
@@ -1,5 +1,3 @@
1
- """Credential configuration helpers for the Intflow Auth operator CLI."""
2
-
3
1
  import argparse
4
2
  import json
5
3
  import os
@@ -72,9 +70,7 @@ def _load_authctl_config(
72
70
  if not isinstance(service, str) or not isinstance(account, str):
73
71
  raise AuthctlError(f"Invalid CLI config file: {path}")
74
72
  if credential_store is None:
75
- raise AuthctlError(
76
- "CLI config uses an OS credential store; use --credential-store auto."
77
- )
73
+ raise AuthctlError("CLI config uses an OS credential store; use --credential-store auto.")
78
74
  store = credential_store
79
75
  if backend != store.backend_name:
80
76
  raise AuthctlError(f"Invalid CLI config file: {path}")
@@ -95,9 +91,11 @@ def _save_authctl_config(
95
91
  token: str,
96
92
  credential_store: AuthctlCredentialStore | None,
97
93
  ) -> None:
94
+ previous = _load_raw_authctl_config(path)
95
+ path.parent.mkdir(parents=True, exist_ok=True)
98
96
  stored_config = dict(config)
99
97
  stored_config.pop("token", None)
100
- credential_account = _credential_account(stored_config)
98
+ credential_account = f"{_credential_account(stored_config)}|local-{secrets.token_hex(16)}"
101
99
  secret_stored = False
102
100
  if credential_store is not None:
103
101
  try:
@@ -112,6 +110,7 @@ def _save_authctl_config(
112
110
  "credential_backend": credential_store.backend_name,
113
111
  "credential_service": AUTHCTL_CREDENTIAL_SERVICE,
114
112
  "credential_account": credential_account,
113
+ "credential_owner": _config_owner(path),
115
114
  }
116
115
  )
117
116
  except AuthctlCredentialStoreError as exc:
@@ -122,7 +121,6 @@ def _save_authctl_config(
122
121
  else:
123
122
  stored_config.update({"credential_backend": "file", "token": token})
124
123
 
125
- path.parent.mkdir(parents=True, exist_ok=True)
126
124
  temporary_path = path.with_name(f".{path.name}.{secrets.token_hex(8)}.tmp")
127
125
  replaced = False
128
126
  try:
@@ -150,7 +148,72 @@ def _save_authctl_config(
150
148
  if secret_stored and credential_store is not None:
151
149
  with suppress(AuthctlCredentialStoreError):
152
150
  credential_store.delete_password(AUTHCTL_CREDENTIAL_SERVICE, credential_account)
153
- raise
151
+ raise AuthctlError(
152
+ "Could not save CLI configuration; the previous login was retained where possible."
153
+ ) from None
154
+ if (
155
+ previous
156
+ and previous.get("credential_account")
157
+ and previous.get("credential_backend") != "file"
158
+ ):
159
+ if (
160
+ credential_store is not None
161
+ and previous.get("credential_backend") == credential_store.backend_name
162
+ and previous.get("credential_owner") == _config_owner(path)
163
+ and previous.get("credential_service") == AUTHCTL_CREDENTIAL_SERVICE
164
+ ):
165
+ try:
166
+ credential_store.delete_password(
167
+ AUTHCTL_CREDENTIAL_SERVICE, str(previous["credential_account"])
168
+ )
169
+ except AuthctlCredentialStoreError:
170
+ _storage_warning(
171
+ "Login saved, but the previous local credential could not be removed."
172
+ )
173
+ else:
174
+ _storage_warning(
175
+ "Previous legacy/shared credential retained to protect other profiles."
176
+ )
177
+
178
+
179
+ def _storage_warning(message: str) -> None:
180
+
181
+ import sys
182
+
183
+ print(f"WARNING: {message} Remote tokens were not revoked.", file=sys.stderr)
184
+
185
+
186
+ def _config_owner(path: Path) -> str:
187
+ return os.path.normcase(os.path.abspath(path))
188
+
189
+
190
+ def _check_credential_storage(path: Path, store: AuthctlCredentialStore | None) -> None:
191
+ probe = f"probe-{secrets.token_hex(16)}"
192
+ temporary = path.parent / f".authctl-{probe}"
193
+ try:
194
+ path.parent.mkdir(parents=True, exist_ok=True)
195
+ if path.exists() and not path.is_file():
196
+ raise OSError("Not a file")
197
+ descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
198
+ with os.fdopen(descriptor, "w") as probe_file:
199
+ probe_file.write(probe)
200
+ probe_file.flush()
201
+ os.fsync(probe_file.fileno())
202
+ if store is not None:
203
+ try:
204
+ store.set_password(AUTHCTL_CREDENTIAL_SERVICE, probe, probe)
205
+ if store.get_password(AUTHCTL_CREDENTIAL_SERVICE, probe) != probe:
206
+ raise AuthctlCredentialStoreError("Probe failed")
207
+ finally:
208
+ store.delete_password(AUTHCTL_CREDENTIAL_SERVICE, probe)
209
+ except (OSError, AuthctlCredentialStoreError):
210
+ raise AuthctlError(
211
+ "Credential storage is unavailable; no login request was sent. Check config directory "
212
+ "and OS keyring, or explicitly select --credential-store file if acceptable."
213
+ ) from None
214
+ finally:
215
+ with suppress(OSError):
216
+ temporary.unlink(missing_ok=True)
154
217
 
155
218
 
156
219
  def _delete_authctl_config(
@@ -168,7 +231,8 @@ def _delete_authctl_config(
168
231
  )
169
232
  store = credential_store
170
233
  try:
171
- store.delete_password(service, account)
234
+ if store.get_password(service, account) is not None:
235
+ store.delete_password(service, account)
172
236
  except AuthctlCredentialStoreError as exc:
173
237
  raise AuthctlError(
174
238
  "Stored CLI credential could not be deleted; local config was retained."
@@ -1,5 +1,3 @@
1
- """Constants for the Intflow Auth operator CLI."""
2
-
3
1
  AUTHCTL_CONFIG_ENV = "INTFLOW_AUTHCTL_CONFIG"
4
2
  AUTHCTL_CREDENTIAL_SERVICE = "intflow-authctl"
5
3
  DEFAULT_AUTHCTL_BASE_URL = "https://id.intflow.dev"
@@ -1,49 +1,43 @@
1
- """Credential-store adapters for the intflow-authctl CLI."""
2
-
3
1
  from importlib import import_module
4
2
  from typing import Protocol
5
3
 
6
4
 
7
5
  class AuthctlCredentialStore(Protocol):
8
- """Minimal credential-store interface used by intflow-authctl."""
9
-
10
6
  backend_name: str
11
7
 
12
8
  def get_password(self, service: str, account: str) -> str | None:
13
- """Return one stored secret or None."""
9
+ pass
14
10
 
15
11
  def set_password(self, service: str, account: str, password: str) -> None:
16
- """Store one secret."""
12
+ pass
17
13
 
18
14
  def delete_password(self, service: str, account: str) -> None:
19
- """Delete one secret."""
15
+ pass
20
16
 
21
17
 
22
18
  class AuthctlCredentialStoreError(RuntimeError):
23
- """Raised when a credential-store backend is unavailable or fails."""
19
+ pass
24
20
 
25
21
 
26
22
  class KeyringAuthctlCredentialStore:
27
- """Credential-store adapter backed by the optional keyring package."""
28
-
29
23
  backend_name = "keyring"
30
24
 
31
25
  def get_password(self, service: str, account: str) -> str | None:
32
- """Return one stored secret or None."""
26
+
33
27
  try:
34
28
  return self._keyring().get_password(service, account)
35
29
  except Exception as exc:
36
30
  raise AuthctlCredentialStoreError("Credential store lookup failed.") from exc
37
31
 
38
32
  def set_password(self, service: str, account: str, password: str) -> None:
39
- """Store one secret."""
33
+
40
34
  try:
41
35
  self._keyring().set_password(service, account, password)
42
36
  except Exception as exc:
43
37
  raise AuthctlCredentialStoreError("Credential store write failed.") from exc
44
38
 
45
39
  def delete_password(self, service: str, account: str) -> None:
46
- """Delete one secret."""
40
+
47
41
  try:
48
42
  self._keyring().delete_password(service, account)
49
43
  except Exception as exc:
@@ -1,4 +1,7 @@
1
- """Offline onboarding and locally authored permission guidance."""
1
+ def _is_app_role_path(path: str) -> bool:
2
+ return any(
3
+ part in path for part in ("/admins", "/roles/", "/role-assignments", "/pending-role-grants")
4
+ )
2
5
 
3
6
 
4
7
  def permission_hint(status: int, method: str, path: str) -> str:
@@ -8,7 +11,7 @@ def permission_hint(status: int, method: str, path: str) -> str:
8
11
  return ""
9
12
  resource = None
10
13
  if path.startswith("/v1/admin/clients"):
11
- resource = "app_roles" if "/admins" in path else "clients"
14
+ resource = "app_roles" if _is_app_role_path(path) else "clients"
12
15
  elif path.startswith("/v1/admin/operator-tokens"):
13
16
  resource = "tokens"
14
17
  elif path.startswith("/v1/admin/audit"):
@@ -0,0 +1,9 @@
1
+ class AuthctlError(RuntimeError):
2
+ pass
3
+
4
+
5
+ class AuthctlHttpError(AuthctlError):
6
+ def __init__(self, message: str, status: int, retry_after: int | None) -> None:
7
+ super().__init__(message)
8
+ self.status = status
9
+ self.retry_after = retry_after